Compare commits

...
Author SHA1 Message Date
bincxz 506dcab734 style(sync): align convergent sync panel with settings 2026-10-18 16:08:57 +08:00
陈大猫 4b8803369f refactor(snippets): consolidate creation buttons into a New dropdown (#3727)
* refactor(snippets): combine creation actions in a dropdown

* fix(snippets): support keyboard navigation in creation menu

* fix(snippets): retain focus when choosing a creation action
2026-10-18 16:01:31 +08:00
陈大猫 ef4caf8b08 chore(packaging): omit duplicate renderer and unused native files (#3726)
Keep renderer-only libraries available as development dependencies so Vite
bundles their runtime code without shipping their dependency trees again.
Exclude native compilation helpers from packaged resources, and prune
node-pty/serialport prebuilds for other platforms and architectures while
preserving matching universal binaries and rebuilt runtime files.

Validation:
- npm run lint; 33 packaging tests pass.
- npm run pack:dir, real packaged application UI, and 29 packaged runtime
  smoke checks pass (SDK imports, PTY, serial port listing, SQLite query).
- All 999 UI files retain their code/content; differences are generated
  asset references and the embedded dependency classification metadata.
- Dependency versions are unchanged.
- macOS arm64 app: 394,959,135 -> 360,740,625 bytes.
- macOS arm64 ZIP: 150,847,369 -> 143,151,622 bytes; ZIP integrity passes.
- Full suite: 14,035 pass, 36 skipped, 5 fail. The four FIDO helper failures
  and transfer FileHandle-GC failure reproduce on unchanged main with Node 26.
2026-10-18 15:26:58 +08:00
Netcatty-Bot ebd77e43bb fix(ssh): cancel background jobs via in-band Ctrl+C (#3725)
Deliver PTY job interrupts as terminal data instead of sending SSH signal channel requests. This addresses the background-cancellation log noise reported in #3577 while preserving the existing cancellation retries and result semantics.

Add regression coverage asserting cancellation writes ETX without invoking the stream signal method.
2026-10-18 15:20:56 +08:00
15 changed files with 794 additions and 172 deletions

No files matched your search

+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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': '匯入',
+55 -17
View File
@@ -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"
+19 -43
View File
@@ -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" />
+55
View File
@@ -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,
+9 -1
View File
@@ -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/**/*',
+7 -10
View File
@@ -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(),
});
+31
View File
@@ -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",
+455 -53
View File
File diff suppressed because it is too large. Load diff
+48 -48
View File
@@ -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"
},
+40
View File
@@ -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;
+70
View File
@@ -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");