mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-02 03:42:34 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
506dcab734 | ||
|
|
4b8803369f | ||
|
|
ef4caf8b08 | ||
|
|
ebd77e43bb |
No files matched your search
@@ -727,6 +727,7 @@ export const enTerminalMessages: Messages = {
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Search scripts...',
|
||||
'snippets.action.new': 'New',
|
||||
'snippets.action.newSnippet': 'New Snippet',
|
||||
'snippets.action.newPackage': 'New Script Package',
|
||||
'snippets.action.import': 'Import',
|
||||
|
||||
@@ -689,6 +689,7 @@ export const esTerminalMessages: Messages = {
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Buscar scripts...',
|
||||
'snippets.action.new': 'Nuevo',
|
||||
'snippets.action.newSnippet': 'Nuevo snippet',
|
||||
'snippets.action.newPackage': 'Nuevo paquete de scripts',
|
||||
'snippets.action.import': 'Importar',
|
||||
|
||||
@@ -742,6 +742,7 @@ export const ruTerminalMessages: Messages = {
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Поиск сниппетов...',
|
||||
'snippets.action.new': 'Создать',
|
||||
'snippets.action.newSnippet': 'Новый сниппет',
|
||||
'snippets.action.newPackage': 'Новый пакет',
|
||||
'snippets.action.import': 'Импорт',
|
||||
|
||||
@@ -813,6 +813,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': '搜索脚本...',
|
||||
'snippets.action.new': '新建',
|
||||
'snippets.action.newSnippet': '新建代码片段',
|
||||
'snippets.action.newPackage': '新建脚本包',
|
||||
'snippets.action.import': '导入',
|
||||
|
||||
@@ -813,6 +813,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': '搜尋程式碼片段...',
|
||||
'snippets.action.new': '新增',
|
||||
'snippets.action.newSnippet': '新增程式碼片段',
|
||||
'snippets.action.newPackage': '新增程式碼包',
|
||||
'snippets.action.import': '匯入',
|
||||
|
||||
@@ -493,6 +493,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
);
|
||||
const shellHistory = shellHistoryProp ?? (shellHistoryFromStore as ShellHistoryEntry[]);
|
||||
const [rightPanelMode, setRightPanelMode] = useState<RightPanelMode>('none');
|
||||
const [showNewMenu, setShowNewMenu] = useState(false);
|
||||
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>({
|
||||
label: '',
|
||||
command: '',
|
||||
@@ -1664,23 +1665,60 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
/>
|
||||
<div className={snippetsHeaderActionsWrapClass}>
|
||||
<div className={cn(snippetsHeaderActionsClass, hasSnippetsSidePanel && 'pr-1')}>
|
||||
<Button onClick={() => handleEdit()} size="sm" className="h-10 px-3 shrink-0">
|
||||
<Plus size={14} className="mr-2" /> {t('snippets.action.newSnippet')}
|
||||
</Button>
|
||||
<Button onClick={() => handleEdit(undefined, true)} size="sm" variant="secondary" className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}>
|
||||
<Play size={14} className="mr-2" /> {t('snippets.action.newScript')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNewPackageName('');
|
||||
setIsPackageDialogOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}
|
||||
>
|
||||
<FolderPlus size={14} className="mr-1" /> {t('snippets.action.newPackage')}
|
||||
</Button>
|
||||
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}
|
||||
aria-expanded={showNewMenu}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('snippets.action.new')}
|
||||
<ChevronDown size={14} className={cn('transition-transform', showNewMenu && 'rotate-180')} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-max flex flex-col" align="start" sideOffset={8} keyboardNavigation>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="menuitem"
|
||||
className="justify-start gap-3 h-10 px-3 whitespace-nowrap focus-visible:bg-accent"
|
||||
onClick={() => {
|
||||
setShowNewMenu(false);
|
||||
handleEdit();
|
||||
}}
|
||||
>
|
||||
<Zap size={16} className="text-sky-500" />
|
||||
{t('snippets.action.newSnippet')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="menuitem"
|
||||
className="justify-start gap-3 h-10 px-3 whitespace-nowrap focus-visible:bg-accent"
|
||||
onClick={() => {
|
||||
setShowNewMenu(false);
|
||||
handleEdit(undefined, true);
|
||||
}}
|
||||
>
|
||||
<Play size={16} className="text-violet-500" />
|
||||
{t('snippets.action.newScript')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="menuitem"
|
||||
className="justify-start gap-3 h-10 px-3 whitespace-nowrap focus-visible:bg-accent"
|
||||
onClick={() => {
|
||||
setShowNewMenu(false);
|
||||
setNewPackageName('');
|
||||
setIsPackageDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus size={16} className="text-primary" />
|
||||
{t('snippets.action.newPackage')}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -11,34 +11,10 @@ import {
|
||||
isConvergentConflictSecret,
|
||||
} from '../../domain/convergentSync';
|
||||
import { Button } from '../ui/button';
|
||||
import { Toggle } from '../settings/settings-ui';
|
||||
|
||||
type Translate = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
const ConvergentToggle: React.FC<{
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void | Promise<void>;
|
||||
}> = ({ checked, disabled, label, onChange }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label={label}
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 transition-colors ${
|
||||
checked ? 'border-amber-500 bg-amber-500' : 'border-border bg-muted'
|
||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||
checked ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
function conflictLabel(conflict: ConvergentFieldConflict, t: Translate): string {
|
||||
const { address } = conflict;
|
||||
switch (address.kind) {
|
||||
@@ -104,19 +80,19 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
onResolveConflict,
|
||||
onDowngrade,
|
||||
}) => (
|
||||
<div className="space-y-4 overflow-hidden rounded-xl border border-amber-500/25 bg-gradient-to-br from-amber-500/[0.07] via-card to-card p-4 shadow-sm">
|
||||
<div className="space-y-4 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
data-testid="convergent-sync-icon"
|
||||
className="mt-0.5 flex h-9 w-9 shrink-0 self-start items-center justify-center rounded-lg border border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
className="mt-0.5 flex h-9 w-9 shrink-0 self-start items-center justify-center rounded-lg bg-muted text-muted-foreground"
|
||||
>
|
||||
<FlaskConical size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold">{t('cloudSync.convergent.title')}</span>
|
||||
<span className="inline-flex rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
||||
<span className="text-sm font-medium">{t('cloudSync.convergent.title')}</span>
|
||||
<span className="inline-flex rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t('cloudSync.convergent.experimental')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -125,11 +101,11 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConvergentToggle
|
||||
<Toggle
|
||||
checked={config.enabled}
|
||||
onChange={onToggle}
|
||||
disabled={busy}
|
||||
label={t('cloudSync.convergent.title')}
|
||||
ariaLabel={t('cloudSync.convergent.title')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -143,20 +119,20 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="space-y-3 rounded-lg border border-border/70 bg-background/65 p-3.5 shadow-sm">
|
||||
<div className="space-y-3 rounded-md bg-muted/40 p-3">
|
||||
<div className="text-sm font-semibold">{t('cloudSync.convergent.preview.title')}</div>
|
||||
<div className="grid grid-cols-1 gap-2 text-center sm:grid-cols-3">
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-lg font-semibold tabular-nums">
|
||||
{Object.values(preview.entityCounts).reduce((sum, count) => sum + (count ?? 0), 0)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.entities')}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-lg font-semibold tabular-nums">{preview.providers.length}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.providers')}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-lg font-semibold tabular-nums">{preview.conflictCount}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.conflicts')}</div>
|
||||
</div>
|
||||
@@ -165,7 +141,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
{t('cloudSync.convergent.preview.compatibility')}
|
||||
</div>
|
||||
{preview.providers.length > 0 && (
|
||||
<div className="divide-y rounded border text-xs">
|
||||
<div className="divide-y divide-border/40 text-xs">
|
||||
{preview.providers.map((provider) => (
|
||||
<div key={provider.provider} className="flex items-start justify-between gap-3 px-2 py-1.5">
|
||||
<span className="font-medium">{provider.provider}</span>
|
||||
@@ -178,11 +154,11 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{preview.blockedReasons.length > 0 && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<div className="rounded-md bg-destructive/5 p-2 text-xs text-destructive">
|
||||
{preview.blockedReasons.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 pt-3">
|
||||
<div className="flex flex-wrap justify-end gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={onCancelMigration} disabled={busy}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
@@ -194,7 +170,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<div className="flex gap-2 rounded-md bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
@@ -209,13 +185,13 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
{conflicts.map((conflict) => {
|
||||
const addressKey = convergentConflictAddressKey(conflict.address);
|
||||
return (
|
||||
<div key={addressKey} className="space-y-2 rounded-lg border border-border/70 bg-background/65 p-3 shadow-sm">
|
||||
<div key={addressKey} className="space-y-2 rounded-md bg-muted/40 p-3">
|
||||
<div className="break-all font-mono text-xs">{conflictLabel(conflict, t)}</div>
|
||||
<div className="space-y-1.5">
|
||||
{conflict.candidates.map((candidate) => (
|
||||
<div
|
||||
key={dotKey(candidate.dot)}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/15 p-2.5"
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-md bg-background/60 p-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium">
|
||||
@@ -228,7 +204,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={candidate.selected ? 'secondary' : 'outline'}
|
||||
variant={candidate.selected ? 'secondary' : 'ghost'}
|
||||
disabled={busy}
|
||||
onClick={() => onResolveConflict(addressKey, dotKey(candidate.dot))}
|
||||
>
|
||||
@@ -245,7 +221,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
)}
|
||||
|
||||
{config.initialized && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 pt-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
<div className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">{t('cloudSync.convergent.downgrade.desc')}</div>
|
||||
<Button variant="ghost" size="sm" className="text-destructive" onClick={onDowngrade} disabled={busy}>
|
||||
<RotateCcw size={14} className="mr-1" />
|
||||
|
||||
@@ -123,6 +123,8 @@ interface DropdownContentProps {
|
||||
alignToParent?: boolean;
|
||||
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
|
||||
/** Opt in for action menus whose options need keyboard focus and navigation. */
|
||||
keyboardNavigation?: boolean;
|
||||
}
|
||||
|
||||
const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
@@ -134,9 +136,11 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
alignToParent = false,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
keyboardNavigation = false,
|
||||
}) => {
|
||||
const { open, setOpen, triggerRef } = useDropdown();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const didFocusOnOpen = useRef(false);
|
||||
const [position, setPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
@@ -248,6 +252,17 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
}
|
||||
}, [open, calculatePosition]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
didFocusOnOpen.current = false;
|
||||
return;
|
||||
}
|
||||
if (keyboardNavigation && position && !didFocusOnOpen.current) {
|
||||
contentRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
|
||||
didFocusOnOpen.current = true;
|
||||
}
|
||||
}, [open, position, keyboardNavigation]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -293,6 +308,46 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
role={keyboardNavigation ? 'menu' : undefined}
|
||||
onClickCapture={keyboardNavigation ? (event) => {
|
||||
if (event.target instanceof Element && event.target.closest('button:not(:disabled)')) {
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
} : undefined}
|
||||
onKeyDown={keyboardNavigation ? (event) => {
|
||||
const items = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'),
|
||||
);
|
||||
const index = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
let nextIndex: number;
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
nextIndex = (index + 1) % items.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
nextIndex = (index - 1 + items.length) % items.length;
|
||||
break;
|
||||
case 'Home':
|
||||
nextIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
nextIndex = items.length - 1;
|
||||
break;
|
||||
case 'Escape':
|
||||
case 'Tab':
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
items[nextIndex]?.focus();
|
||||
} : undefined}
|
||||
style={{
|
||||
top: position?.top ?? -9999,
|
||||
left: position?.left ?? -9999,
|
||||
|
||||
@@ -167,10 +167,18 @@ module.exports = {
|
||||
'!node_modules/@cursor/sdk/dist/**/*.d.ts.map',
|
||||
// sqlite3 rebuilds a native module for Electron; its upstream source
|
||||
// tarball is build-time payload only.
|
||||
'!node_modules/sqlite3/deps/**/*'
|
||||
'!node_modules/sqlite3/deps/**/*',
|
||||
// Native compilation helpers are needed during install/rebuild only.
|
||||
// Match nested copies too; keep the actual native loaders and binaries.
|
||||
'!node_modules/**/node-gyp/**/*',
|
||||
'!node_modules/**/node-addon-api/**/*',
|
||||
'!node_modules/**/nan/**/*'
|
||||
],
|
||||
asarUnpack: [
|
||||
'node_modules/node-pty/**/*',
|
||||
// afterPack removes prebuilds for other platforms/architectures. Keep
|
||||
// these outside ASAR so removing them also removes their payload.
|
||||
'node_modules/@serialport/bindings-cpp/prebuilds/**/*',
|
||||
'node_modules/ssh2/**/*',
|
||||
'node_modules/cpu-features/**/*',
|
||||
'node_modules/@vscode/windows-process-tree/**/*',
|
||||
|
||||
@@ -254,17 +254,14 @@ function startPtyJob(ptyStream, command, options) {
|
||||
startupTimeoutMs: maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs,
|
||||
});
|
||||
|
||||
// Deliver the interrupt as terminal data (ETX). This matches manual Ctrl+C
|
||||
// in the terminal and terminalBridge.interruptSession: the remote line
|
||||
// discipline turns \x03 into SIGINT. Never send an SSH "signal" channel
|
||||
// request here — real OpenSSH rejects it ("session_signal_req: session
|
||||
// signalling requires privilege separation"), which pollutes auth logs and
|
||||
// makes other host-side tooling report brute-force noise (#3577).
|
||||
function sendInterrupt(trigger = null) {
|
||||
let signalSent = false;
|
||||
let etxWritten = false;
|
||||
try {
|
||||
if (typeof ptyStream.signal === "function") {
|
||||
ptyStream.signal("INT");
|
||||
signalSent = true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore signal failures and fall back to ETX.
|
||||
}
|
||||
try {
|
||||
if (typeof ptyStream.write === "function") {
|
||||
ptyStream.write("\x03");
|
||||
@@ -275,7 +272,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
}
|
||||
jobDiag.emit("send-interrupt", {
|
||||
trigger,
|
||||
signalSent,
|
||||
signalSent: false,
|
||||
etxWritten,
|
||||
session: diagSnapshot(),
|
||||
});
|
||||
|
||||
@@ -153,6 +153,37 @@ test("foreground PTY cancellation returns only a bounded tail", async () => {
|
||||
assert.equal(result.outputTruncated, true);
|
||||
});
|
||||
|
||||
test("PTY cancellation delivers Ctrl+C as terminal data without a channel signal request", async () => {
|
||||
const calls = [];
|
||||
class SignalSpyPty extends EventEmitter {
|
||||
signal(name) {
|
||||
calls.push(["signal", name]);
|
||||
}
|
||||
write(data) {
|
||||
if (data === "\x03") calls.push(["write", data]);
|
||||
const marker = markerFromWrite(data);
|
||||
if (!marker || this.started) return;
|
||||
this.started = true;
|
||||
queueMicrotask(() => {
|
||||
this.emit("data", Buffer.from(`${marker}_S\n`));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pty = new SignalSpyPty();
|
||||
const job = startPtyJob(pty, "cancel-signal-request", {
|
||||
shellKind: "posix",
|
||||
timeoutMs: 1_000,
|
||||
expectedPrompt: "$ ",
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
job.cancel();
|
||||
pty.emit("data", Buffer.from("$ "));
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.error, "Cancelled");
|
||||
assert.deepEqual(calls, [["write", "\x03"]]);
|
||||
});
|
||||
|
||||
test("background PTY jobs preserve output that has no trailing newline", async () => {
|
||||
const job = startPtyJob(new ShellBackedPty(), "printf 'abc'", {
|
||||
shellKind: "posix",
|
||||
|
||||
Generated
+455
-53
File diff suppressed because it is too large.
Load diff
+48
-48
@@ -76,23 +76,41 @@
|
||||
"test:xterm-keyword-highlight-throughput": "electron scripts/xterm-keyword-highlight-throughput.live.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.956.0",
|
||||
"@mimo-ai/sdk": "^0.1.15",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@netcatty/plugin-cli": "0.1.0-internal",
|
||||
"@netcatty/plugin-contract": "0.1.0-internal",
|
||||
"@netcatty/plugin-sdk": "0.1.0-internal",
|
||||
"@openai/codex-sdk": "0.144.3",
|
||||
"@smithy/node-http-handler": "^4.4.9",
|
||||
"@withfig/autocomplete": "^2.692.3",
|
||||
"ajv": "8.18.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"node-pty": "1.1.0",
|
||||
"serialport": "^13.0.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"webdav": "^5.8.0",
|
||||
"zmodem.js": "^0.1.10",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.0",
|
||||
"@ai-sdk/google": "^4.0.0",
|
||||
"@ai-sdk/openai": "4.0.8",
|
||||
"@aws-sdk/client-s3": "^3.956.0",
|
||||
"@eslint-community/regexpp": "4.12.2",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/mona-sans": "^5.2.8",
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@mdxeditor/editor": "^4.0.4",
|
||||
"@mimo-ai/sdk": "^0.1.15",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@netcatty/plugin-cli": "0.1.0-internal",
|
||||
"@netcatty/plugin-contract": "0.1.0-internal",
|
||||
"@netcatty/plugin-sdk": "0.1.0-internal",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@openai/codex-sdk": "0.144.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-context-menu": "2.2.16",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
@@ -102,11 +120,16 @@
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@smithy/node-http-handler": "^4.4.9",
|
||||
"@streamdown/cjk": "^1.0.2",
|
||||
"@streamdown/code": "^1.1.0",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-virtual": "^3.14.8",
|
||||
"@withfig/autocomplete": "^2.692.3",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@xterm/addon-fit": "0.12.0-beta.292",
|
||||
"@xterm/addon-image": "0.10.0-beta.292",
|
||||
"@xterm/addon-ligatures": "0.11.0-beta.292",
|
||||
@@ -117,46 +140,7 @@
|
||||
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||
"@xterm/xterm": "6.1.0-beta.292",
|
||||
"ai": "^7.0.2",
|
||||
"ajv": "8.18.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"clsx": "2.1.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"katex": "^0.18.4",
|
||||
"lucide-react": "0.560.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-pty": "1.1.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"re2js": "2.8.6",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"serialport": "^13.0.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"streamdown": "^2.4.0",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"use-stick-to-bottom": "^1.1.3",
|
||||
"webdav": "^5.8.0",
|
||||
"zmodem.js": "^0.1.10",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^42.11.12",
|
||||
@@ -167,13 +151,29 @@
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"fast-check": "^4.9.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"katex": "^0.18.4",
|
||||
"lucide-react": "0.560.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"patch-package": "^8.0.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"re2js": "2.8.6",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-test-renderer": "^19.2.4",
|
||||
"sass": "^1.103.1",
|
||||
"simple-icons": "^16.23.0",
|
||||
"streamdown": "^2.4.0",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"typescript": "~5.9.3",
|
||||
"use-stick-to-bottom": "^1.1.3",
|
||||
"vite": "^7.3.2",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
|
||||
@@ -294,6 +294,40 @@ function pruneCursorSdkPlatformPackages(context) {
|
||||
return removed;
|
||||
}
|
||||
|
||||
function pruneNativePrebuilds(context) {
|
||||
const platform = context.electronPlatformName;
|
||||
const arch = typeof context.arch === "number" ? ELECTRON_BUILDER_ARCH_NAMES[context.arch] : context.arch;
|
||||
if (!["darwin", "linux", "win32"].includes(platform)) return [];
|
||||
const architectures = platform === "darwin" && arch === "universal"
|
||||
? ["arm64", "x64"]
|
||||
: [arch === "armv7l" ? "arm" : arch];
|
||||
if (!architectures.every((value) => ["arm", "arm64", "ia32", "x64"].includes(value))) return [];
|
||||
|
||||
const resourcesDir = appResourcesDir(context);
|
||||
const unpackedDir = path.join(resourcesDir, "app.asar.unpacked");
|
||||
const removed = [];
|
||||
for (const packageName of ["node-pty", "@serialport/bindings-cpp"]) {
|
||||
const prebuilds = `node_modules/${packageName}/prebuilds`;
|
||||
const directory = path.join(unpackedDir, prebuilds);
|
||||
if (!fs.existsSync(directory)) continue;
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const tuple = /^([a-z0-9]+)-([a-z0-9+]+)$/.exec(entry.name);
|
||||
if (!tuple) continue;
|
||||
const [, buildPlatform, buildArchitectures] = tuple;
|
||||
if (buildPlatform === platform && architectures.some((value) => buildArchitectures.split("+").includes(value))) continue;
|
||||
removed.push(`${prebuilds}/${entry.name}`);
|
||||
}
|
||||
}
|
||||
if (removed.length === 0) return [];
|
||||
|
||||
pruneAsarHeaderEntries(path.join(resourcesDir, "app.asar"), removed);
|
||||
for (const entry of removed) {
|
||||
fs.rmSync(path.join(unpackedDir, entry), { recursive: true, force: true });
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** @param {import('electron-builder').AfterPackContext} context */
|
||||
async function afterPack(context) {
|
||||
const removedCursorPackages = pruneCursorSdkPlatformPackages(context);
|
||||
@@ -303,6 +337,11 @@ async function afterPack(context) {
|
||||
);
|
||||
}
|
||||
|
||||
const removedPrebuilds = pruneNativePrebuilds(context);
|
||||
if (removedPrebuilds.length > 0) {
|
||||
console.log(`[afterPack] Removed unused native prebuild(s): ${removedPrebuilds.join(", ")}`);
|
||||
}
|
||||
|
||||
if (context.electronPlatformName === "win32") {
|
||||
const projectDir = context.packager.appDir || context.packager.projectDir;
|
||||
rebuildPatchedNodePty({ projectDir, platform: "win32", arch: context.arch });
|
||||
@@ -365,3 +404,4 @@ module.exports.adHocSignAppBundle = adHocSignAppBundle;
|
||||
module.exports.readAsarHeader = readAsarHeader;
|
||||
module.exports.pruneAsarHeaderEntries = pruneAsarHeaderEntries;
|
||||
module.exports.pruneCursorSdkPlatformPackages = pruneCursorSdkPlatformPackages;
|
||||
module.exports.pruneNativePrebuilds = pruneNativePrebuilds;
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
patchMachOBuffer,
|
||||
pruneAsarHeaderEntries,
|
||||
pruneCursorSdkPlatformPackages,
|
||||
pruneNativePrebuilds,
|
||||
readAsarHeader,
|
||||
} = require("./afterPackMacUuid.cjs");
|
||||
|
||||
@@ -92,6 +93,75 @@ test("deriveUuid is deterministic and 16 bytes", () => {
|
||||
assert.ok(a.equals(b));
|
||||
});
|
||||
|
||||
for (const [platform, arch, keep] of [
|
||||
["darwin", 3, ["darwin-arm64", "darwin-x64+arm64"]],
|
||||
["darwin", 1, ["darwin-x64", "darwin-x64+arm64"]],
|
||||
["darwin", 4, ["darwin-arm64", "darwin-x64", "darwin-x64+arm64"]],
|
||||
["linux", 3, ["linux-arm64"]],
|
||||
["linux", 1, ["linux-x64"]],
|
||||
["linux", 2, ["linux-arm"]],
|
||||
["win32", 1, ["win32-x64"]],
|
||||
["win32", 3, ["win32-arm64"]],
|
||||
["win32", 0, ["win32-ia32"]],
|
||||
["darwin", "unknown-arch", null],
|
||||
["darwin", 99, null],
|
||||
["unknown-platform", 1, null],
|
||||
]) {
|
||||
test(`native prebuild pruning preserves the ${platform}/${arch} runtime and ASAR payload`, (t) => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getTempFilePath } = require("../electron/bridges/tempDirBridge.cjs");
|
||||
const tempDir = getTempFilePath("native-prebuild-pruning");
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
|
||||
const resources = platform === "darwin"
|
||||
? path.join(tempDir, "Netcatty.app", "Contents", "Resources")
|
||||
: path.join(tempDir, "resources");
|
||||
const unpacked = path.join(resources, "app.asar.unpacked");
|
||||
const payload = Buffer.from("preserved-packed-payload");
|
||||
const header = { files: { "packed.txt": { size: payload.length, offset: "0" } } };
|
||||
const tuples = [
|
||||
"darwin-arm64", "darwin-x64", "darwin-x64+arm64", "linux-arm64", "linux-x64",
|
||||
"linux-arm", "win32-x64", "win32-arm64", "win32-ia32", "android-arm64", "custom",
|
||||
];
|
||||
const paths = [];
|
||||
for (const packageName of ["node-pty", "@serialport/bindings-cpp"]) {
|
||||
for (const directory of [...tuples.map((tuple) => `prebuilds/${tuple}`), "build/Release"]) {
|
||||
const relative = `node_modules/${packageName}/${directory}/runtime.node`;
|
||||
const file = path.join(unpacked, relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, "native-runtime");
|
||||
let node = header;
|
||||
const segments = relative.split("/");
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
node.files ??= {};
|
||||
node = node.files[segment] ??= { files: {} };
|
||||
}
|
||||
node.files[segments.at(-1)] = { size: 14, unpacked: true };
|
||||
paths.push(relative);
|
||||
}
|
||||
}
|
||||
const asarPath = path.join(resources, "app.asar");
|
||||
const originalHeaderSize = writeFakeAsar(asarPath, header, payload);
|
||||
const context = {
|
||||
electronPlatformName: platform, arch, appOutDir: tempDir,
|
||||
packager: { appInfo: { productFilename: "Netcatty" } },
|
||||
};
|
||||
const removed = pruneNativePrebuilds(context);
|
||||
const updated = readAsarHeader(asarPath);
|
||||
assert.equal(updated.headerSize, originalHeaderSize);
|
||||
assert.deepEqual(fs.readFileSync(asarPath).subarray(8 + originalHeaderSize), payload);
|
||||
for (const relative of paths) {
|
||||
const tuple = relative.split("/prebuilds/")[1]?.split("/")[0];
|
||||
const retained = keep === null || !tuple || tuple === "custom" || keep.includes(tuple);
|
||||
assert.equal(fs.existsSync(path.join(unpacked, relative)), retained, relative);
|
||||
const entry = relative.split("/").reduce((node, segment) => node?.files?.[segment], updated.header);
|
||||
assert.equal(Boolean(entry), retained, `ASAR ${relative}`);
|
||||
}
|
||||
if (keep === null) assert.deepEqual(removed, []);
|
||||
assert.deepEqual(pruneNativePrebuilds(context), []);
|
||||
});
|
||||
}
|
||||
|
||||
test("deriveUuid differs per appId and sets version/variant bits", () => {
|
||||
const a = deriveUuid("com.netcatty.app");
|
||||
const b = deriveUuid("com.example.other");
|
||||
|
||||
Reference in new issue
Block a user