From 9e31d53bddf497dcbf942f4ecc246676cc2b961e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:48:59 +0800 Subject: [PATCH 01/10] Slim release package Slim release package --- electron-builder.config.cjs | 47 +- .../bridges/registerBridgesFigSpec.test.cjs | 22 + electron/main/registerBridges.cjs | 15 +- package-lock.json | 470 +----------------- package.json | 3 - vite.config.ts | 1 - 6 files changed, 91 insertions(+), 467 deletions(-) create mode 100644 electron/bridges/registerBridgesFigSpec.test.cjs diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index 2ffca59d..0d286371 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -8,6 +8,7 @@ module.exports = { appId: 'com.netcatty.app', productName: 'Netcatty', artifactName: '${productName}-${version}-${os}-${arch}.${ext}', + electronLanguages: ['en', 'en-US', 'zh_CN', 'zh-CN', 'ru'], // Give the macOS build a unique Mach-O LC_UUID before signing, so macOS // Local Network privacy treats Netcatty distinctly from every other // Electron app (which all share Electron's prebuilt LC_UUID) — see #1040 @@ -43,8 +44,43 @@ module.exports = { 'lib/**/*.json', '!electron/.dev-config.json', 'skills/**/*', - 'public/**/*', - 'node_modules/**/*', + '!public/**/*', + '!**/*.map', + '!**/*.d.ts', + '!**/*.d.mts', + '!**/*.d.cts', + '!**/*.ts', + '!**/*.tsx', + '!**/*.test.*', + '!**/*.spec.*', + '!**/__tests__/**/*', + '!**/test/**/*', + '!**/tests/**/*', + '!**/example/**/*', + '!**/examples/**/*', + // Renderer-only packages are compiled into dist by Vite. Keep them + // installed for npm run dev/build, but do not ship the duplicate source + // packages in release artifacts. + '!node_modules/@fontsource/**/*', + '!node_modules/@monaco-editor/**/*', + '!node_modules/@radix-ui/**/*', + '!node_modules/@xterm/**/*', + '!node_modules/lucide-react/**/*', + '!node_modules/monaco-editor/**/*', + '!node_modules/react/**/*', + '!node_modules/react-dom/**/*', + // Heavy cloud completion specs are intentionally not bundled. The main + // process filters the same prefixes so dev and packaged builds behave + // consistently. + '!node_modules/@withfig/autocomplete/build/aws.js', + '!node_modules/@withfig/autocomplete/build/aws/**/*', + '!node_modules/@withfig/autocomplete/build/gcloud.js', + '!node_modules/@withfig/autocomplete/build/gcloud/**/*', + '!node_modules/@withfig/autocomplete/build/az/**/*', + // Fig specs are already compiled JavaScript; TypeScript is only pulled + // in by Fig helper packages as build tooling and is not needed at app + // runtime. + '!node_modules/typescript/**/*', // ── Exclude per-platform native agent binaries (~100s of MB each). ── // Netcatty is "bring your own CLI": each SDK is pointed at the user's // system-installed CLI via an absolute path override (claude @@ -62,7 +98,12 @@ module.exports = { '!node_modules/@anthropic-ai/claude-code-*/**/*', '!node_modules/@openai/codex-{darwin,linux,linuxmusl,win32}-*/**/*', '!node_modules/@github/copilot-{darwin,linux,linuxmusl,win32}-*/**/*', - '!node_modules/@github/copilot/**/*' + '!node_modules/@github/copilot/**/*', + // CodeBuddy follows the same first-party integration model as the + // other coding agents: Netcatty discovers and passes the user's + // installed CLI path to the SDK. Keep the small SDK wrapper, but do not + // bundle the full CodeBuddy CLI payload (rg vendors + web UI). + '!node_modules/@tencent-ai/agent-sdk/cli/**/*' ], asarUnpack: [ 'node_modules/node-pty/**/*', diff --git a/electron/bridges/registerBridgesFigSpec.test.cjs b/electron/bridges/registerBridgesFigSpec.test.cjs new file mode 100644 index 00000000..879ade88 --- /dev/null +++ b/electron/bridges/registerBridgesFigSpec.test.cjs @@ -0,0 +1,22 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + filterExcludedFigSpecs, + isExcludedFigSpec, +} = require("../main/registerBridges.cjs"); + +test("filters cloud fig specs removed from packaged builds", () => { + assert.equal(isExcludedFigSpec("aws"), true); + assert.equal(isExcludedFigSpec("aws/s3"), true); + assert.equal(isExcludedFigSpec("gcloud"), true); + assert.equal(isExcludedFigSpec("gcloud/compute"), true); + assert.equal(isExcludedFigSpec("az"), true); + assert.equal(isExcludedFigSpec("az/2.53.0"), true); + assert.equal(isExcludedFigSpec("aws-vault"), false); + + assert.deepEqual( + filterExcludedFigSpecs(["git", "aws", "aws/s3", "gcloud", "az/2.53.0", "aws-vault"]), + ["git", "aws-vault"], + ); +}); diff --git a/electron/main/registerBridges.cjs b/electron/main/registerBridges.cjs index 612f48c2..f3d5141c 100644 --- a/electron/main/registerBridges.cjs +++ b/electron/main/registerBridges.cjs @@ -4,6 +4,16 @@ let bridgesRegistered = false; let cloudSyncSessionPassword = null; const { readClipboardFiles, readClipboardImage } = require("../bridges/clipboardFiles.cjs"); +const excludedFigSpecPrefixes = ["aws", "gcloud", "az"]; + +function isExcludedFigSpec(commandName) { + return excludedFigSpecPrefixes.some((prefix) => commandName === prefix || commandName.startsWith(`${prefix}/`)); +} + +function filterExcludedFigSpecs(specNames) { + return specNames.filter((name) => !isExcludedFigSpec(name)); +} + function createBridgeRegistrar(context) { const { electronModule, @@ -207,7 +217,7 @@ function createBridgeRegistrar(context) { .filter(f => f.endsWith(".js")) .map(f => f.slice(0, -3)); } catch { /* no local specs dir */ } - const merged = [...new Set([...figSpecs, ...localNames])]; + const merged = filterExcludedFigSpecs([...new Set([...figSpecs, ...localNames])]); return merged; } catch (err) { console.warn("[Main] Failed to load fig spec list:", err?.message || err); @@ -219,6 +229,7 @@ function createBridgeRegistrar(context) { // Sanitize: reject absolute paths, path traversal, and non-spec characters if (!commandName || commandName.startsWith("/") || commandName.startsWith("\\") || commandName.includes("..") || !/^[@a-zA-Z0-9._/+-]+$/.test(commandName)) return null; + if (isExcludedFigSpec(commandName)) return null; const { pathToFileURL } = require("url"); const fs = require("fs"); @@ -803,4 +814,4 @@ function createBridgeRegistrar(context) { return registerBridges; } -module.exports = { createBridgeRegistrar }; +module.exports = { createBridgeRegistrar, filterExcludedFigSpecs, isExcludedFigSpec }; diff --git a/package-lock.json b/package-lock.json index 5ec02fa9..217af6fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@aws-sdk/client-s3": "^3.956.0", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/space-grotesk": "^5.2.10", - "@google/genai": "1.33.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", "@openai/codex-sdk": "^0.136.0", @@ -27,12 +26,10 @@ "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@streamdown/cjk": "^1.0.2", "@streamdown/code": "^1.1.0", - "@tencent-ai/agent-sdk": "^0.3.173", "@withfig/autocomplete": "^2.692.3", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", @@ -71,7 +68,6 @@ "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", "@vitejs/plugin-react": "^5.1.2", - "@withfig/autocomplete-types": "^1.31.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "electron": "^42.3.3", @@ -2978,27 +2974,6 @@ "copilot-win32-x64": "copilot.exe" } }, - "node_modules/@google/genai": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.33.0.tgz", - "integrity": "sha512-ThUjFZ1N0DU88peFjnQkb8K198EWaW2RmmnDShFQ+O+xkIH9itjpRe358x3L/b4X/A7dimkvq63oz49Vbh7Cog==", - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.24.0" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -3117,102 +3092,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -3680,16 +3559,6 @@ "node": ">=14.18.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -4399,24 +4268,6 @@ } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -7012,13 +6863,6 @@ "pnpm": ">=9" } }, - "node_modules/@withfig/autocomplete-types": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/@withfig/autocomplete-types/-/autocomplete-types-1.31.0.tgz", - "integrity": "sha512-TSZDo5jvEaeIHqmHY6Wkd3gBqVbxcHQVdkF6N1J8CXRBuQZpjUVci15/HPNYe0nKLvsomBWIRsTP3m1zr9pv3A==", - "dev": true, - "license": "MIT" - }, "node_modules/@withfig/autocomplete/node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -7168,6 +7012,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -7278,6 +7123,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7287,6 +7133,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -7684,6 +7531,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "devOptional": true, "funding": [ { "type": "github", @@ -7719,15 +7567,6 @@ "tweetnacl": "^0.14.3" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -7903,12 +7742,6 @@ "node": "*" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8420,6 +8253,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -8432,6 +8266,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/color-support": { @@ -9036,21 +8871,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -9274,6 +9094,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "devOptional": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -10151,34 +9972,6 @@ } } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -10332,35 +10125,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -10584,33 +10348,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/google-auth-library": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", - "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.0.0", - "gcp-metadata": "^8.0.0", - "google-logging-utils": "^1.0.0", - "gtoken": "^8.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -10655,19 +10392,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/gtoken": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", - "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", - "license": "MIT", - "dependencies": { - "gaxios": "^7.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -11054,6 +10778,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -11279,6 +11004,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -11381,21 +11107,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -11484,15 +11195,6 @@ "node": ">=6" } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -11604,27 +11306,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13219,6 +12900,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -13865,12 +13547,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -14026,28 +13702,6 @@ "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", "license": "ISC" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -14898,41 +14552,6 @@ "node": ">= 4" } }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -15921,21 +15540,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -15964,19 +15569,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -17109,51 +16702,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index adc38d0e..020db682 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@aws-sdk/client-s3": "^3.956.0", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/space-grotesk": "^5.2.10", - "@google/genai": "1.33.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", "@openai/codex-sdk": "^0.136.0", @@ -55,7 +54,6 @@ "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@streamdown/cjk": "^1.0.2", @@ -95,7 +93,6 @@ "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", "@vitejs/plugin-react": "^5.1.2", - "@withfig/autocomplete-types": "^1.31.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "electron": "^42.3.3", diff --git a/vite.config.ts b/vite.config.ts index 05702fe7..d702340b 100755 --- a/vite.config.ts +++ b/vite.config.ts @@ -51,7 +51,6 @@ export default defineConfig(() => { '@radix-ui/react-popover', '@radix-ui/react-scroll-area', '@radix-ui/react-select', - '@radix-ui/react-slot', '@radix-ui/react-tabs', ], 'vendor-xterm': [ From babe06a94443cedbb8858773e6c82f76dd284b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:54:26 +0800 Subject: [PATCH 02/10] Fix local debug launch Fix local debug launch --- electron/main.cjs | 13 +++++++++- scripts/afterPackMacUuid.cjs | 27 ++++++++++++++++++++ scripts/afterPackMacUuid.test.cjs | 41 +++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/electron/main.cjs b/electron/main.cjs index 9bb48aab..06567f78 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -172,9 +172,20 @@ const devServerUrl = process.env.VITE_DEV_SERVER_URL; // Never treat a packaged app as "dev" even if the user has VITE_DEV_SERVER_URL set globally. const isDev = !app.isPackaged && !!devServerUrl; const effectiveDevServerUrl = isDev ? devServerUrl : undefined; +if (isDev) { + app.setName("Netcatty Dev"); + app.setPath("userData", path.join(app.getPath("userData"), "dev")); +} const preload = path.join(__dirname, "preload.cjs"); const isMac = process.platform === "darwin"; -const appIcon = path.join(__dirname, "../public/icon.png"); +function resolveAppIconPath() { + const candidates = [ + path.join(__dirname, "../dist/icon.png"), + path.join(__dirname, "../public/icon.png"), + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0]; +} +const appIcon = resolveAppIconPath(); const electronDir = __dirname; const APP_PROTOCOL_HEADERS = { diff --git a/scripts/afterPackMacUuid.cjs b/scripts/afterPackMacUuid.cjs index d862e04e..d92ad6ea 100644 --- a/scripts/afterPackMacUuid.cjs +++ b/scripts/afterPackMacUuid.cjs @@ -19,6 +19,7 @@ const fs = require("node:fs"); const path = require("node:path"); const crypto = require("node:crypto"); +const { execFileSync } = require("node:child_process"); const LC_UUID = 0x1b; const MH_MAGIC_64 = 0xfeedfacf; // thin 64-bit, little-endian on disk @@ -105,6 +106,23 @@ function patchMachOFile(file, uuid) { return result; } +function adHocSignExecutable(exePath, options = {}) { + const hostPlatform = options.hostPlatform || process.platform; + const execFile = options.execFileSync || execFileSync; + + if (hostPlatform !== "darwin") { + console.warn( + `[afterPack] Skipping ad-hoc codesign for ${exePath}; host platform is ${hostPlatform}`, + ); + return false; + } + + execFile("codesign", ["--force", "--sign", "-", "--timestamp=none", exePath], { + stdio: ["ignore", "pipe", "pipe"], + }); + return true; +} + /** @param {import('electron-builder').AfterPackContext} context */ async function afterPack(context) { if (context.electronPlatformName !== "darwin") return; @@ -137,6 +155,14 @@ async function afterPack(context) { `${oldUuids.map((h) => formatUuid(Buffer.from(h, "hex"))).join(", ")} -> ${formatUuid(uuid)} ` + `(${patched} slice(s), appId=${appId})`, ); + + // The official Developer ID signing step runs after afterPack and replaces + // this temporary signature. Local unsigned builds skip that step, so the + // patched executable still needs a valid ad-hoc signature or macOS kills it + // before Electron can start. + if (adHocSignExecutable(exePath)) { + console.log("[afterPack] Ad-hoc signed patched macOS executable for local unsigned builds"); + } } module.exports = afterPack; @@ -145,3 +171,4 @@ module.exports.deriveUuid = deriveUuid; module.exports.formatUuid = formatUuid; module.exports.patchMachOBuffer = patchMachOBuffer; module.exports.patchMachOFile = patchMachOFile; +module.exports.adHocSignExecutable = adHocSignExecutable; diff --git a/scripts/afterPackMacUuid.test.cjs b/scripts/afterPackMacUuid.test.cjs index 5eebba94..e0df291f 100644 --- a/scripts/afterPackMacUuid.test.cjs +++ b/scripts/afterPackMacUuid.test.cjs @@ -2,6 +2,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { + adHocSignExecutable, deriveUuid, patchMachOBuffer, } = require("./afterPackMacUuid.cjs"); @@ -115,3 +116,43 @@ test("patchMachOBuffer reports zero when there is no LC_UUID", () => { const { patched } = patchMachOBuffer(buf, deriveUuid("com.netcatty.app")); assert.equal(patched, 0); }); + +test("adHocSignExecutable signs patched binaries on macOS hosts", () => { + const calls = []; + + const didSign = adHocSignExecutable("/tmp/Netcatty.app/Contents/MacOS/Netcatty", { + hostPlatform: "darwin", + execFileSync: (bin, args, options) => { + calls.push({ bin, args, options }); + }, + }); + + assert.equal(didSign, true); + assert.deepEqual(calls, [ + { + bin: "codesign", + args: [ + "--force", + "--sign", + "-", + "--timestamp=none", + "/tmp/Netcatty.app/Contents/MacOS/Netcatty", + ], + options: { stdio: ["ignore", "pipe", "pipe"] }, + }, + ]); +}); + +test("adHocSignExecutable skips non-macOS hosts", () => { + let called = false; + + const didSign = adHocSignExecutable("/tmp/Netcatty", { + hostPlatform: "linux", + execFileSync: () => { + called = true; + }, + }); + + assert.equal(didSign, false); + assert.equal(called, false); +}); From 4d1a7ea55ad74072b4be4a46351c12e63fed4f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:10:59 +0800 Subject: [PATCH 03/10] Prevent app reload shortcut from closing sessions (#1432) --- electron/bridges/windowManager.cjs | 1 - .../bridges/windowManagerReadiness.test.cjs | 148 ++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/electron/bridges/windowManager.cjs b/electron/bridges/windowManager.cjs index 0f99bd3a..b4cc03a2 100644 --- a/electron/bridges/windowManager.cjs +++ b/electron/bridges/windowManager.cjs @@ -1132,7 +1132,6 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) { label: tMenu(language, "view"), submenu: [ { label: tMenu(language, "reload"), click: (_, win) => { if (win) win.reload(); } }, - { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, diff --git a/electron/bridges/windowManagerReadiness.test.cjs b/electron/bridges/windowManagerReadiness.test.cjs index 8b1d8507..955269a0 100644 --- a/electron/bridges/windowManagerReadiness.test.cjs +++ b/electron/bridges/windowManagerReadiness.test.cjs @@ -297,6 +297,39 @@ test("buildAppMenu sends Cmd+W to any registered main window renderer", () => { } }); +test("buildAppMenu keeps app reload click-only so custom reload-like shortcuts reach the renderer", () => { + let capturedTemplate = null; + const Menu = { + buildFromTemplate(template) { + capturedTemplate = template; + return { template }; + }, + }; + + buildAppMenu(Menu, { name: "Netcatty" }, false); + + const viewMenu = capturedTemplate.find((item) => item.label === "View"); + assert.ok(viewMenu); + assert.equal(viewMenu.submenu.some((item) => item.role === "reload"), false); + assert.equal(viewMenu.submenu.some((item) => item.role === "forceReload"), false); + assert.equal(viewMenu.submenu.some((item) => item.accelerator === "CommandOrControl+R"), false); + assert.equal(viewMenu.submenu.some((item) => item.accelerator === "CommandOrControl+Shift+R"), false); + + const reloadItem = viewMenu.submenu.find((item) => item.label === "Reload"); + assert.ok(reloadItem); + assert.equal(reloadItem.role, undefined); + assert.equal(reloadItem.accelerator, undefined); + + const calls = []; + reloadItem.click(null, { + reload() { + calls.push("reload"); + }, + }); + + assert.deepEqual(calls, ["reload"]); +}); + test("requestWindowCommandClose sends command-close to renderer-capable windows", () => { const sentChannels = []; const win = { @@ -434,6 +467,121 @@ test("main window asks renderer to close tabs from macOS Command+W before-input- assert.equal(commandCloseRequests.length, 1); }); +test("main window leaves primary-modifier reload-like shortcuts available to renderer handlers", async () => { + let beforeInputHandler = null; + const ignoreMenuShortcutValues = []; + + class BrowserWindowStub { + constructor() { + this.webContents = { + id: 1, + on(channel, handler) { + if (channel === "before-input-event") beforeInputHandler = handler; + }, + once() {}, + isDestroyed() { + return false; + }, + isCrashed() { + return false; + }, + setIgnoreMenuShortcuts(value) { + ignoreMenuShortcutValues.push(value); + }, + setWindowOpenHandler() {}, + openDevTools() {}, + }; + } + + on() {} + once() {} + isDestroyed() { return false; } + isMaximized() { return false; } + isFullScreen() { return false; } + getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; } + setBackgroundColor() {} + setOpacity() {} + async loadURL() {} + close() {} + } + + const api = createMainWindowApi({ + mainWindow: null, + electronApp: null, + currentTheme: "light", + isQuitting: false, + pendingWindowStateWrite: null, + queuedWindowState: null, + windowStateCloseRequested: false, + DEFAULT_WINDOW_WIDTH: 1400, + DEFAULT_WINDOW_HEIGHT: 900, + MIN_WINDOW_WIDTH: 1100, + MIN_WINDOW_HEIGHT: 640, + V8_CACHE_OPTIONS: "bypassHeatCheck", + THEME_COLORS: { light: { background: "#fff" } }, + unhealthyWebContentsIds: new Set(), + rendererReadySeenByWebContentsId: new Set(), + __dirname, + URL, + require, + console, + setTimeout, + clearTimeout, + getGlobalShortcutBridge() { + return { handleWindowClose: () => false }; + }, + debugLog() {}, + resolveFrontendBackgroundColor() { return null; }, + loadWindowState() { return null; }, + getDevRendererBaseUrl(url) { return url; }, + getWindowBoundsState() { return null; }, + queueWindowStateSave() {}, + saveWindowStateSync() {}, + setupDeferredShow() {}, + createExternalOnlyWindowOpenHandler() { return {}; }, + createAppWindowOpenHandler() { return {}; }, + attachOAuthLoadingOverlay() {}, + registerWindowHandlers() {}, + requestWindowCommandClose() { + return true; + }, + shouldCloseWindowFromInput, + applyWindowOpacityToWindow() {}, + closeSettingsWindow() {}, + hideSettingsWindow() {}, + }); + + await api.createWindow( + { + BrowserWindow: BrowserWindowStub, + nativeTheme: {}, + app: {}, + screen: {}, + shell: {}, + ipcMain: {}, + }, + { + preload: "/tmp/preload.cjs", + devServerUrl: "http://localhost:5173", + isDev: true, + appIcon: null, + isMac: false, + electronDir: __dirname, + }, + ); + + let prevented = false; + beforeInputHandler({ preventDefault: () => { prevented = true; } }, { + type: "keyDown", + control: true, + shift: true, + key: "R", + }); + + assert.equal(prevented, false); + assert.deepEqual(ignoreMenuShortcutValues, [false]); +}); + test("createWindow registers each main window as an independent app window", async () => { const registered = []; const unregistered = []; From 17c8f111940c47debce24fa44366a87e9d2a6644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:36:46 +0800 Subject: [PATCH 04/10] Fix macOS package ad-hoc signing (#1433) --- scripts/afterPackMacUuid.cjs | 21 +++++++++++---------- scripts/afterPackMacUuid.test.cjs | 13 +++++++------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/scripts/afterPackMacUuid.cjs b/scripts/afterPackMacUuid.cjs index d92ad6ea..c825c079 100644 --- a/scripts/afterPackMacUuid.cjs +++ b/scripts/afterPackMacUuid.cjs @@ -106,18 +106,18 @@ function patchMachOFile(file, uuid) { return result; } -function adHocSignExecutable(exePath, options = {}) { +function adHocSignAppBundle(appPath, options = {}) { const hostPlatform = options.hostPlatform || process.platform; const execFile = options.execFileSync || execFileSync; if (hostPlatform !== "darwin") { console.warn( - `[afterPack] Skipping ad-hoc codesign for ${exePath}; host platform is ${hostPlatform}`, + `[afterPack] Skipping ad-hoc codesign for ${appPath}; host platform is ${hostPlatform}`, ); return false; } - execFile("codesign", ["--force", "--sign", "-", "--timestamp=none", exePath], { + execFile("codesign", ["--force", "--deep", "--sign", "-", "--timestamp=none", appPath], { stdio: ["ignore", "pipe", "pipe"], }); return true; @@ -129,9 +129,9 @@ async function afterPack(context) { const appId = context.packager.appInfo.id || "com.netcatty.app"; const productFilename = context.packager.appInfo.productFilename; + const appPath = path.join(context.appOutDir, `${productFilename}.app`); const exePath = path.join( - context.appOutDir, - `${productFilename}.app`, + appPath, "Contents", "MacOS", productFilename, @@ -158,10 +158,11 @@ async function afterPack(context) { // The official Developer ID signing step runs after afterPack and replaces // this temporary signature. Local unsigned builds skip that step, so the - // patched executable still needs a valid ad-hoc signature or macOS kills it - // before Electron can start. - if (adHocSignExecutable(exePath)) { - console.log("[afterPack] Ad-hoc signed patched macOS executable for local unsigned builds"); + // patched app bundle still needs a valid ad-hoc signature or macOS kills it + // before Electron can start. Signing the whole bundle also covers Electron's + // nested frameworks, which codesign validates as subcomponents. + if (adHocSignAppBundle(appPath)) { + console.log("[afterPack] Ad-hoc signed patched macOS app for local unsigned builds"); } } @@ -171,4 +172,4 @@ module.exports.deriveUuid = deriveUuid; module.exports.formatUuid = formatUuid; module.exports.patchMachOBuffer = patchMachOBuffer; module.exports.patchMachOFile = patchMachOFile; -module.exports.adHocSignExecutable = adHocSignExecutable; +module.exports.adHocSignAppBundle = adHocSignAppBundle; diff --git a/scripts/afterPackMacUuid.test.cjs b/scripts/afterPackMacUuid.test.cjs index e0df291f..a75665d0 100644 --- a/scripts/afterPackMacUuid.test.cjs +++ b/scripts/afterPackMacUuid.test.cjs @@ -2,7 +2,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { - adHocSignExecutable, + adHocSignAppBundle, deriveUuid, patchMachOBuffer, } = require("./afterPackMacUuid.cjs"); @@ -117,10 +117,10 @@ test("patchMachOBuffer reports zero when there is no LC_UUID", () => { assert.equal(patched, 0); }); -test("adHocSignExecutable signs patched binaries on macOS hosts", () => { +test("adHocSignAppBundle signs the full app bundle on macOS hosts", () => { const calls = []; - const didSign = adHocSignExecutable("/tmp/Netcatty.app/Contents/MacOS/Netcatty", { + const didSign = adHocSignAppBundle("/tmp/Netcatty.app", { hostPlatform: "darwin", execFileSync: (bin, args, options) => { calls.push({ bin, args, options }); @@ -133,20 +133,21 @@ test("adHocSignExecutable signs patched binaries on macOS hosts", () => { bin: "codesign", args: [ "--force", + "--deep", "--sign", "-", "--timestamp=none", - "/tmp/Netcatty.app/Contents/MacOS/Netcatty", + "/tmp/Netcatty.app", ], options: { stdio: ["ignore", "pipe", "pipe"] }, }, ]); }); -test("adHocSignExecutable skips non-macOS hosts", () => { +test("adHocSignAppBundle skips non-macOS hosts", () => { let called = false; - const didSign = adHocSignExecutable("/tmp/Netcatty", { + const didSign = adHocSignAppBundle("/tmp/Netcatty.app", { hostPlatform: "linux", execFileSync: () => { called = true; From 46b9bf6ccba8a96ae974f7d2002d784bd84ebc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:00:16 +0800 Subject: [PATCH 05/10] [codex] Hide managed startup commands from history Hide Netcatty-managed Docker and tmux terminal launch commands from command history. Validated locally with lint, full tests, and build. Multi-agent review completed with no remaining issues. --- .../state/shellHistoryPersistence.test.ts | 53 ++++++++++++++++++ application/state/shellHistoryPersistence.ts | 23 ++++++++ application/state/useVaultState.ts | 15 +++-- domain/globalHistory.test.ts | 27 +++++++++ domain/globalHistory.ts | 14 ++++- domain/remoteHistory.test.ts | 55 +++++++++++++++++++ domain/remoteHistory.ts | 9 +++ 7 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 application/state/shellHistoryPersistence.test.ts create mode 100644 application/state/shellHistoryPersistence.ts diff --git a/application/state/shellHistoryPersistence.test.ts b/application/state/shellHistoryPersistence.test.ts new file mode 100644 index 00000000..e554b156 --- /dev/null +++ b/application/state/shellHistoryPersistence.test.ts @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildDockerLogsCommand } from '../../domain/systemManager/dockerShell.ts'; +import { loadSanitizedShellHistory } from './shellHistoryPersistence.ts'; +import type { ShellHistoryEntry } from '../../domain/models.ts'; + +const entry = (id: string, command: string): ShellHistoryEntry => ({ + id, + command, + hostId: 'host-1', + hostLabel: 'Host', + sessionId: 'session-1', + timestamp: 1000, +}); + +test('loadSanitizedShellHistory removes persisted managed startup commands and writes back cleaned history', () => { + const stored = [ + entry('managed', buildDockerLogsCommand('587abcdef123')), + entry('user', 'docker ps -a'), + ]; + let written: ShellHistoryEntry[] | null = null; + + const loaded = loadSanitizedShellHistory({ + read: () => stored, + write: (_key, value) => { + written = value; + return true; + }, + }); + + assert.deepEqual( + loaded?.map((item) => item.command), + ['docker ps -a'], + ); + assert.deepEqual(written, loaded); +}); + +test('loadSanitizedShellHistory does not write when persisted history is already clean', () => { + const stored = [entry('user', 'docker ps -a')]; + let writeCount = 0; + + const loaded = loadSanitizedShellHistory({ + read: () => stored, + write: () => { + writeCount += 1; + return true; + }, + }); + + assert.deepEqual(loaded, stored); + assert.equal(writeCount, 0); +}); diff --git a/application/state/shellHistoryPersistence.ts b/application/state/shellHistoryPersistence.ts new file mode 100644 index 00000000..f9d7fe91 --- /dev/null +++ b/application/state/shellHistoryPersistence.ts @@ -0,0 +1,23 @@ +import type { ShellHistoryEntry } from '../../domain/models'; +import { sanitizeGlobalHistoryEntries } from '../../domain/globalHistory'; +import { STORAGE_KEY_SHELL_HISTORY } from '../../infrastructure/config/storageKeys'; +import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; + +type ShellHistoryStorage = { + read(key: string): T | null; + write(key: string, value: T): boolean; +}; + +export function loadSanitizedShellHistory( + storage: ShellHistoryStorage = localStorageAdapter, + storageKey = STORAGE_KEY_SHELL_HISTORY, +): ShellHistoryEntry[] | null { + const savedShellHistory = storage.read(storageKey); + if (!savedShellHistory) return null; + + const cleanedShellHistory = sanitizeGlobalHistoryEntries(savedShellHistory); + if (cleanedShellHistory.length !== savedShellHistory.length) { + storage.write(storageKey, cleanedShellHistory); + } + return cleanedShellHistory; +} diff --git a/application/state/useVaultState.ts b/application/state/useVaultState.ts index fed11ae9..a8fcfcc2 100644 --- a/application/state/useVaultState.ts +++ b/application/state/useVaultState.ts @@ -36,8 +36,9 @@ import { STORAGE_KEY_TERM_SETTINGS, } from "../../infrastructure/config/storageKeys"; import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter"; -import { mergeGlobalHistoryOnAppend } from "../../domain/globalHistory"; +import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory"; import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder"; +import { loadSanitizedShellHistory } from "./shellHistoryPersistence"; import { decryptGroupConfigs, decryptHosts, @@ -598,10 +599,10 @@ export const useVaultState = () => { } // Load shell history - const savedShellHistory = localStorageAdapter.read( - STORAGE_KEY_SHELL_HISTORY, - ); - if (savedShellHistory) setShellHistory(savedShellHistory); + const savedShellHistory = loadSanitizedShellHistory(); + if (savedShellHistory) { + setShellHistory(savedShellHistory); + } // Load connection logs const savedConnectionLogs = localStorageAdapter.read( @@ -729,7 +730,9 @@ export const useVaultState = () => { } if (key === STORAGE_KEY_SHELL_HISTORY) { - const next = safeParse(event.newValue) ?? []; + const next = sanitizeGlobalHistoryEntries( + safeParse(event.newValue) ?? [], + ); setShellHistory(next); return; } diff --git a/domain/globalHistory.test.ts b/domain/globalHistory.test.ts index 9c54302f..77f85d0e 100644 --- a/domain/globalHistory.test.ts +++ b/domain/globalHistory.test.ts @@ -3,10 +3,13 @@ import assert from 'node:assert/strict'; import { mergeGlobalHistoryOnAppend, + sanitizeGlobalHistoryEntries, shouldRecordGlobalHistoryCommand, toGlobalHistoryDisplayEntries, } from './globalHistory.ts'; import { NETCATTY_AI_HISTORY_MARKER } from './remoteHistory.ts'; +import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts'; +import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts'; import type { ShellHistoryEntry } from './models'; const baseEntry = ( @@ -30,6 +33,17 @@ test('shouldRecordGlobalHistoryCommand: rejects empty and AI marker commands', ( assert.equal(shouldRecordGlobalHistoryCommand('ls -la'), true); }); +test('shouldRecordGlobalHistoryCommand: rejects Netcatty managed Docker and tmux startup commands', () => { + assert.equal(shouldRecordGlobalHistoryCommand(buildDockerExecShellCommand('587abcdef123')), false); + assert.equal(shouldRecordGlobalHistoryCommand(buildDockerLogsCommand('587abcdef123')), false); + assert.equal(shouldRecordGlobalHistoryCommand(buildTmuxAttachCommand('my-session')), false); + assert.equal(shouldRecordGlobalHistoryCommand(buildTmuxAttachCommand('my-session', 2)), false); + assert.equal(shouldRecordGlobalHistoryCommand('docker ps -a'), true); + assert.equal(shouldRecordGlobalHistoryCommand('docker logs -f 587abcdef123'), true); + assert.equal(shouldRecordGlobalHistoryCommand('docker exec -it 587abcdef123 bash'), true); + assert.equal(shouldRecordGlobalHistoryCommand('tmux attach -t my-session'), true); +}); + test('mergeGlobalHistoryOnAppend: trims and prepends a new command', () => { const next = mergeGlobalHistoryOnAppend([], { command: ' pwd ', @@ -41,6 +55,19 @@ test('mergeGlobalHistoryOnAppend: trims and prepends a new command', () => { assert.equal(next[0].command, 'pwd'); }); +test('sanitizeGlobalHistoryEntries: removes persisted Netcatty managed startup commands', () => { + const entries = [ + baseEntry({ id: 'a', command: buildDockerLogsCommand('587abcdef123') }), + baseEntry({ id: 'b', command: 'docker ps -a' }), + baseEntry({ id: 'c', command: buildTmuxAttachCommand('my-session') }), + ]; + const out = sanitizeGlobalHistoryEntries(entries); + assert.deepEqual( + out.map((entry) => entry.command), + ['docker ps -a'], + ); +}); + test('mergeGlobalHistoryOnAppend: bumps timestamp for consecutive duplicate', () => { const prev = [baseEntry({ id: 'a', command: 'ls', timestamp: 1000 })]; const next = mergeGlobalHistoryOnAppend(prev, { diff --git a/domain/globalHistory.ts b/domain/globalHistory.ts index c9562786..e083ce50 100644 --- a/domain/globalHistory.ts +++ b/domain/globalHistory.ts @@ -1,5 +1,8 @@ import type { ShellHistoryEntry } from './models'; -import { isNetcattyAiHistoryCommand } from './remoteHistory'; +import { + isNetcattyAiHistoryCommand, + isNetcattyManagedStartupHistoryCommand, +} from './remoteHistory'; const makeId = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { @@ -13,9 +16,16 @@ export function shouldRecordGlobalHistoryCommand(command: string): boolean { const cmd = command.trim(); if (!cmd) return false; if (isNetcattyAiHistoryCommand(cmd)) return false; + if (isNetcattyManagedStartupHistoryCommand(cmd)) return false; return true; } +export function sanitizeGlobalHistoryEntries( + entries: ShellHistoryEntry[], +): ShellHistoryEntry[] { + return entries.filter((entry) => shouldRecordGlobalHistoryCommand(entry.command)); +} + /** * Append one command to global history: trim, drop noise, and de-dupe the most * recent identical command by bumping its timestamp instead of adding a row. @@ -61,7 +71,7 @@ export interface GlobalHistoryDisplayEntry { export function toGlobalHistoryDisplayEntries( entries: ShellHistoryEntry[], ): GlobalHistoryDisplayEntry[] { - return entries.map((entry) => ({ + return sanitizeGlobalHistoryEntries(entries).map((entry) => ({ id: entry.id, command: entry.command, timestamp: entry.timestamp, diff --git a/domain/remoteHistory.test.ts b/domain/remoteHistory.test.ts index 88f662b9..8e66a3b5 100644 --- a/domain/remoteHistory.test.ts +++ b/domain/remoteHistory.test.ts @@ -8,7 +8,10 @@ import { parseShellHistory, mergeRemoteHistory, isNetcattyAiHistoryCommand, + isNetcattyManagedStartupHistoryCommand, } from './remoteHistory.ts'; +import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts'; +import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts'; test('parseBashHistory: plain lines', () => { const out = parseBashHistory(['ls -la', 'cd /tmp', 'echo hi'].join('\n')); @@ -193,6 +196,17 @@ test('isNetcattyAiHistoryCommand: detects AI PTY marker lines', () => { assert.equal(isNetcattyAiHistoryCommand('grep NCMCP log.txt'), false); }); +test('isNetcattyManagedStartupHistoryCommand: detects Docker and tmux terminal launch commands', () => { + assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerExecShellCommand('587abcdef123')), true); + assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerLogsCommand('587abcdef123')), true); + assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session')), true); + assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session', 2)), true); + assert.equal(isNetcattyManagedStartupHistoryCommand('docker ps -a'), false); + assert.equal(isNetcattyManagedStartupHistoryCommand('docker logs -f 587abcdef123'), false); + assert.equal(isNetcattyManagedStartupHistoryCommand('docker exec -it 587abcdef123 bash'), false); + assert.equal(isNetcattyManagedStartupHistoryCommand('tmux attach -t my-session'), false); +}); + test('mergeRemoteHistory: drops Netcatty AI PTY history lines', () => { const lists = [ parseBashHistory( @@ -205,3 +219,44 @@ test('mergeRemoteHistory: drops Netcatty AI PTY history lines', () => { ['git status', 'ls -la'], ); }); + +test('mergeRemoteHistory: drops Netcatty managed Docker and tmux startup lines', () => { + const lists = [ + parseBashHistory( + [ + 'docker ps -a', + buildDockerLogsCommand('587abcdef123'), + buildTmuxAttachCommand('my-session'), + 'history', + ].join('\n'), + ), + ]; + const merged = mergeRemoteHistory(lists); + assert.deepEqual( + merged.map((e) => e.command), + ['history', 'docker ps -a'], + ); +}); + +test('mergeRemoteHistory: drops Netcatty managed startup lines from zsh and fish history', () => { + const zsh = parseZshHistory( + [ + ': 1700000000:0;git status', + `: 1700000100:0;${buildDockerExecShellCommand('587abcdef123')}`, + ].join('\n'), + ); + const fish = parseFishHistory( + [ + '- cmd: docker ps -a', + ' when: 1700000200', + `- cmd: ${buildTmuxAttachCommand('my-session')}`, + ' when: 1700000300', + ].join('\n'), + ); + + const merged = mergeRemoteHistory([zsh, fish]); + assert.deepEqual( + merged.map((e) => e.command), + ['docker ps -a', 'git status'], + ); +}); diff --git a/domain/remoteHistory.ts b/domain/remoteHistory.ts index 01836675..d3129459 100644 --- a/domain/remoteHistory.ts +++ b/domain/remoteHistory.ts @@ -8,6 +8,14 @@ export function isNetcattyAiHistoryCommand(command: string): boolean { return command.includes(NETCATTY_AI_HISTORY_MARKER); } +const NETCATTY_MANAGED_STARTUP_COMMAND = + /^printf '\\033\[H\\033\[2J\\033\[3J';\s*exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)/; + +/** True when a shell history line came from a Netcatty-managed terminal launch. */ +export function isNetcattyManagedStartupHistoryCommand(command: string): boolean { + return NETCATTY_MANAGED_STARTUP_COMMAND.test(command.trim()); +} + const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/; // fish_history is a YAML subset: each record starts with `- cmd: `, // optionally followed by ` when: ` and a ` paths:` block. @@ -215,6 +223,7 @@ export function mergeRemoteHistory( const merged: RemoteHistoryEntry[] = []; for (const { entry } of indexed) { if (isNetcattyAiHistoryCommand(entry.command)) continue; + if (isNetcattyManagedStartupHistoryCommand(entry.command)) continue; if (seen.has(entry.command)) continue; seen.add(entry.command); merged.push(entry); From a301ecb2cad923c812260943a5a6f90af47bf0c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:16:09 +0800 Subject: [PATCH 06/10] Add terminal selection AI toggle (#1436) Adds a Settings > AI switch to hide the automatic Add to Conversation button on terminal selection while keeping the context menu action available. Closes #1397 --- application/i18n/locales/en/ai.ts | 5 ++ application/i18n/locales/ru/ai.ts | 5 ++ application/i18n/locales/zh-CN/ai.ts | 5 ++ application/state/useAISettingsState.ts | 10 ++++ components/SettingsPage.tsx | 2 + components/Terminal.tsx | 3 +- components/settings/settings-ui.tsx | 4 +- components/settings/tabs/SettingsAITab.tsx | 21 +++++++- .../terminal/TerminalContextMenu.test.ts | 13 +++++ components/terminal/TerminalContextMenu.tsx | 6 ++- components/terminal/TerminalView.test.tsx | 33 ++++++++++++ components/terminal/TerminalView.tsx | 28 +++++++++- components/terminal/terminalHelpers.ts | 1 + components/terminal/terminalMemo.test.ts | 25 +++++++++ components/terminal/terminalMemo.ts | 1 + .../terminalLayer/TerminalLayerSupport.tsx | 52 ++++++++++++------- infrastructure/config/storageKeys.ts | 1 + 17 files changed, 190 insertions(+), 25 deletions(-) create mode 100644 components/terminal/terminalMemo.test.ts diff --git a/application/i18n/locales/en/ai.ts b/application/i18n/locales/en/ai.ts index d05eb99a..d5584dc6 100644 --- a/application/i18n/locales/en/ai.ts +++ b/application/i18n/locales/en/ai.ts @@ -267,6 +267,11 @@ export const enAiMessages: Messages = { 'ai.chat.slashNoResults': 'No matching commands', 'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.', + // AI Chat Shortcuts + 'ai.chatShortcuts.title': 'Chat Shortcuts', + 'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text', + 'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.', + // AI Error 'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.', diff --git a/application/i18n/locales/ru/ai.ts b/application/i18n/locales/ru/ai.ts index 07c0cf16..f6436c94 100644 --- a/application/i18n/locales/ru/ai.ts +++ b/application/i18n/locales/ru/ai.ts @@ -267,6 +267,11 @@ export const ruAiMessages: Messages = { 'ai.chat.slashNoResults': 'Нет подходящих команд', 'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.', + // AI Chat Shortcuts + 'ai.chatShortcuts.title': 'Быстрые действия чата', + 'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале', + 'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.', + // AI Error 'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.', diff --git a/application/i18n/locales/zh-CN/ai.ts b/application/i18n/locales/zh-CN/ai.ts index cf0ca93b..884912d4 100644 --- a/application/i18n/locales/zh-CN/ai.ts +++ b/application/i18n/locales/zh-CN/ai.ts @@ -267,6 +267,11 @@ export const zhCNAiMessages: Messages = { 'ai.chat.slashNoResults': '没有匹配的命令', 'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。', + // AI 聊天快捷入口 + 'ai.chatShortcuts.title': '聊天快捷入口', + 'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”', + 'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。', + // AI Error 'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。', diff --git a/application/state/useAISettingsState.ts b/application/state/useAISettingsState.ts index 87aa0165..601977c2 100644 --- a/application/state/useAISettingsState.ts +++ b/application/state/useAISettingsState.ts @@ -15,6 +15,7 @@ import { STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_WEB_SEARCH, STORAGE_KEY_AI_QUICK_MESSAGES, + STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, } from '../../infrastructure/config/storageKeys'; import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages'; import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages'; @@ -29,6 +30,7 @@ import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types'; import { removeProviderReferences } from './aiProviderCleanup'; import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents'; import { getAIBridge } from './aiStateSnapshots'; +import { useStoredBoolean } from './useStoredBoolean'; function readPermissionMode(): AIPermissionMode { const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE); @@ -75,6 +77,10 @@ export function useAISettingsState() { const [quickMessages, setQuickMessagesRaw] = useState(() => sanitizeQuickMessages(localStorageAdapter.read(STORAGE_KEY_AI_QUICK_MESSAGES)), ); + const [showTerminalSelectionAIAction, setShowTerminalSelectionAIAction] = useStoredBoolean( + STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, + true, + ); const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => { setProvidersRaw((prev) => { @@ -307,6 +313,8 @@ export function useAISettingsState() { setWebSearchConfig, quickMessages, setQuickMessages, + showTerminalSelectionAIAction, + setShowTerminalSelectionAIAction, }), [ providers, setProviders, @@ -336,5 +344,7 @@ export function useAISettingsState() { setWebSearchConfig, quickMessages, setQuickMessages, + showTerminalSelectionAIAction, + setShowTerminalSelectionAIAction, ]); } diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index 927ca293..faaeb5c2 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -157,6 +157,8 @@ const SettingsAITabContainer: React.FC = () => { setWebSearchConfig={aiState.setWebSearchConfig} quickMessages={aiState.quickMessages} setQuickMessages={aiState.setQuickMessages} + showTerminalSelectionAIAction={aiState.showTerminalSelectionAIAction} + setShowTerminalSelectionAIAction={aiState.setShowTerminalSelectionAIAction} /> ); diff --git a/components/Terminal.tsx b/components/Terminal.tsx index f08211d8..f9991813 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -147,6 +147,7 @@ const TerminalComponent: React.FC = ({ sessionLog, sshDebugLogEnabled, sudoAutofillPassword, + showSelectionAIAction, onAddSelectionToAI, }) => { const layoutSuppressActive = useTerminalLayoutSuppressActive(); @@ -1210,7 +1211,7 @@ const TerminalComponent: React.FC = ({ useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef }); - return ; + return ; }; const Terminal = memo(TerminalComponent, terminalPropsAreEqual); diff --git a/components/settings/settings-ui.tsx b/components/settings/settings-ui.tsx index 2e101990..62daa6ce 100644 --- a/components/settings/settings-ui.tsx +++ b/components/settings/settings-ui.tsx @@ -8,13 +8,15 @@ interface ToggleProps { checked: boolean; onChange: (checked: boolean) => void; disabled?: boolean; + ariaLabel?: string; } -export const Toggle: React.FC = ({ checked, onChange, disabled }) => ( +export const Toggle: React.FC = ({ checked, onChange, disabled, ariaLabel }) => ( - + + + + + {SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => ( + { + void onDuplicateTab?.(tab.id, item.mode); + }} + > + + {t(item.labelKey)} + + ))} + + ); })} @@ -432,7 +503,8 @@ const sftpTabBarAreEqual = ( prevTab.id !== nextTab.id || prevTab.label !== nextTab.label || prevTab.isLocal !== nextTab.isLocal || - prevTab.hostId !== nextTab.hostId + prevTab.hostId !== nextTab.hostId || + prevTab.canDuplicate !== nextTab.canDuplicate ) { return false; } diff --git a/components/sftp/hooks/useSftpViewTabs.ts b/components/sftp/hooks/useSftpViewTabs.ts index 9665300f..58192eac 100644 --- a/components/sftp/hooks/useSftpViewTabs.ts +++ b/components/sftp/hooks/useSftpViewTabs.ts @@ -6,17 +6,22 @@ import { editorTabStore } from "../../../application/state/editorTabStore"; import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore"; import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave"; import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog"; +import { + getSftpTabDuplicateRequest, + type SftpTabDuplicateMode, +} from "../sftpTabDuplication"; interface UseSftpViewTabsParams { sftp: SftpStateApi; sftpRef: MutableRefObject; + hosts?: Host[]; } interface UseSftpViewTabsResult { leftPanes: SftpStateApi["leftPane"][]; rightPanes: SftpStateApi["rightPane"][]; - leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[]; - rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[]; + leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[]; + rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[]; showHostPickerLeft: boolean; showHostPickerRight: boolean; hostSearchLeft: string; @@ -35,15 +40,19 @@ interface UseSftpViewTabsResult { handleReorderTabsRight: (draggedId: string, targetId: string, position: "before" | "after") => void; handleMoveTabFromLeftToRight: (tabId: string) => void; handleMoveTabFromRightToLeft: (tabId: string) => void; + handleDuplicateTabLeft: (tabId: string, mode: SftpTabDuplicateMode) => Promise; + handleDuplicateTabRight: (tabId: string, mode: SftpTabDuplicateMode) => Promise; handleHostSelectLeft: (host: Host | "local") => void; handleHostSelectRight: (host: Host | "local") => void; } -export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSftpViewTabsResult => { +export const useSftpViewTabs = ({ sftp, sftpRef, hosts = [] }: UseSftpViewTabsParams): UseSftpViewTabsResult => { const [showHostPickerLeft, setShowHostPickerLeft] = useState(false); const [showHostPickerRight, setShowHostPickerRight] = useState(false); const [hostSearchLeft, setHostSearchLeft] = useState(""); const [hostSearchRight, setHostSearchRight] = useState(""); + const hostsRef = React.useRef(hosts); + hostsRef.current = hosts; const handleAddTabLeft = useCallback(() => { const tabId = sftpRef.current.addTab("left"); @@ -132,6 +141,43 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf sftpRef.current.moveTabToOtherSide("right", tabId); }, [sftpRef]); + const handleDuplicateTab = useCallback( + async (side: "left" | "right", tabId: string, mode: SftpTabDuplicateMode) => { + const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs; + const pane = sideTabs.tabs.find((tab) => tab.id === tabId); + const request = getSftpTabDuplicateRequest(pane, mode); + if (!request) return null; + + const host = request.kind === "local" + ? "local" + : hostsRef.current.find((item) => item.id === request.hostId); + if (!host) return null; + + let duplicatedTabId: string | null = null; + await sftpRef.current.connect(side, host, { + forceNewTab: true, + ignoreSharedCache: mode === "defaultPath", + initialPath: request.path, + onTabCreated: (createdTabId) => { + duplicatedTabId = createdTabId; + }, + }); + + return duplicatedTabId; + }, + [sftpRef], + ); + + const handleDuplicateTabLeft = useCallback( + (tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("left", tabId, mode), + [handleDuplicateTab], + ); + + const handleDuplicateTabRight = useCallback( + (tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("right", tabId, mode), + [handleDuplicateTab], + ); + const handleHostSelectLeft = useCallback((host: Host | "local") => { sftpRef.current.connect("left", host); setShowHostPickerLeft(false); @@ -149,6 +195,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf label: pane.connection?.hostLabel || "New Tab", isLocal: pane.connection?.isLocal || false, hostId: pane.connection?.hostId || null, + canDuplicate: pane.connection?.status === "connected", })), [sftp.leftTabs.tabs], ); @@ -160,6 +207,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf label: pane.connection?.hostLabel || "New Tab", isLocal: pane.connection?.isLocal || false, hostId: pane.connection?.hostId || null, + canDuplicate: pane.connection?.status === "connected", })), [sftp.rightTabs.tabs], ); @@ -187,6 +235,8 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf handleReorderTabsRight, handleMoveTabFromLeftToRight, handleMoveTabFromRightToLeft, + handleDuplicateTabLeft, + handleDuplicateTabRight, handleHostSelectLeft, handleHostSelectRight, }; diff --git a/components/sftp/sftpTabDuplication.test.ts b/components/sftp/sftpTabDuplication.test.ts new file mode 100644 index 00000000..3aa6b358 --- /dev/null +++ b/components/sftp/sftpTabDuplication.test.ts @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import type { SftpPane } from "../../application/state/sftp/types.ts"; +import { + canDuplicateSftpTab, + getSftpTabDuplicateRequest, + isSftpTabKeyboardContextMenuShortcut, + isSftpTabKeyboardSelectShortcut, + shouldHandleSftpTabKeyboardEvent, + SFTP_TAB_DUPLICATE_MENU_ITEMS, +} from "./sftpTabDuplication.ts"; + +const connectedPane = (overrides: Partial> = {}): SftpPane => ({ + id: "tab-1", + connection: { + id: "conn-1", + hostId: "host-1", + hostLabel: "Prod", + isLocal: false, + status: "connected", + currentPath: "/var/www/app", + homeDir: "/home/deploy", + ...overrides, + }, + files: [], + loading: false, + reconnecting: false, + error: null, + connectionLogs: [], + selectedFiles: new Set(), + filter: "", + filenameEncoding: "auto", + showHiddenFiles: false, + transferMutationToken: 0, +}); + +test("default-path SFTP tab duplication keeps only the remote host identity", () => { + assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "defaultPath"), { + kind: "remote", + hostId: "host-1", + }); +}); + +test("current-path SFTP tab duplication carries the active directory", () => { + assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "currentPath"), { + kind: "remote", + hostId: "host-1", + path: "/var/www/app", + }); +}); + +test("local SFTP tab duplication targets the local filesystem", () => { + assert.deepEqual( + getSftpTabDuplicateRequest( + connectedPane({ + hostId: "local", + hostLabel: "Local", + isLocal: true, + currentPath: "/Users/damao/projects", + homeDir: "/Users/damao", + }), + "currentPath", + ), + { + kind: "local", + path: "/Users/damao/projects", + }, + ); +}); + +test("SFTP tab duplication is unavailable before a tab is connected", () => { + assert.equal(getSftpTabDuplicateRequest({ ...connectedPane(), connection: null }, "defaultPath"), null); + assert.equal( + getSftpTabDuplicateRequest(connectedPane({ status: "connecting" }), "currentPath"), + null, + ); +}); + +test("SFTP tab duplicate menu exposes separate default and current path actions", () => { + assert.deepEqual( + SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.mode), + ["defaultPath", "currentPath"], + ); + assert.deepEqual( + SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.labelKey), + ["sftp.tabs.copyDefaultPath", "sftp.tabs.copyCurrentPath"], + ); +}); + +test("SFTP tab duplicate menu is disabled without a connected tab and handler", () => { + assert.equal(canDuplicateSftpTab({ canDuplicate: true }, true), true); + assert.equal(canDuplicateSftpTab({ canDuplicate: true }, false), false); + assert.equal(canDuplicateSftpTab({ canDuplicate: false }, true), false); + assert.equal(canDuplicateSftpTab(connectedPane(), true), true); + assert.equal(canDuplicateSftpTab(connectedPane({ status: "connecting" }), true), false); +}); + +test("SFTP tab duplicate menu has keyboard shortcuts for selection and menu access", () => { + assert.equal(isSftpTabKeyboardSelectShortcut("Enter"), true); + assert.equal(isSftpTabKeyboardSelectShortcut(" "), true); + assert.equal(isSftpTabKeyboardSelectShortcut("Escape"), false); + assert.equal(isSftpTabKeyboardContextMenuShortcut("ContextMenu"), true); + assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", true), true); + assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", false), false); +}); + +test("SFTP tab keyboard shortcuts do not intercept nested close button events", () => { + const tab = new EventTarget(); + const closeButton = new EventTarget(); + + assert.equal(shouldHandleSftpTabKeyboardEvent(tab, tab), true); + assert.equal(shouldHandleSftpTabKeyboardEvent(closeButton, tab), false); +}); diff --git a/components/sftp/sftpTabDuplication.ts b/components/sftp/sftpTabDuplication.ts new file mode 100644 index 00000000..d3938609 --- /dev/null +++ b/components/sftp/sftpTabDuplication.ts @@ -0,0 +1,73 @@ +import type { SftpPane } from "../../application/state/sftp/types"; + +export type SftpTabDuplicateMode = "defaultPath" | "currentPath"; + +export type SftpTabDuplicateRequest = + | { kind: "local"; path?: string } + | { kind: "remote"; hostId: string; path?: string }; + +export const SFTP_TAB_DUPLICATE_MENU_ITEMS: ReadonlyArray<{ + mode: SftpTabDuplicateMode; + labelKey: "sftp.tabs.copyDefaultPath" | "sftp.tabs.copyCurrentPath"; +}> = Object.freeze([ + { mode: "defaultPath", labelKey: "sftp.tabs.copyDefaultPath" }, + { mode: "currentPath", labelKey: "sftp.tabs.copyCurrentPath" }, +]); + +export function canDuplicateSftpTab( + tab: Pick | { canDuplicate?: boolean } | null | undefined, + hasDuplicateHandler: boolean, +): boolean { + if (!hasDuplicateHandler || !tab) return false; + if ("connection" in tab) return tab.connection?.status === "connected"; + return !!tab.canDuplicate; +} + +export function isSftpTabKeyboardContextMenuShortcut( + key: string, + shiftKey = false, +): boolean { + return key === "ContextMenu" || (shiftKey && key === "F10"); +} + +export function isSftpTabKeyboardSelectShortcut(key: string): boolean { + return key === "Enter" || key === " "; +} + +export function shouldHandleSftpTabKeyboardEvent( + target: EventTarget | null, + currentTarget: EventTarget | null, +): boolean { + return target === currentTarget; +} + +export function getSftpTabDuplicateRequest( + pane: Pick | null | undefined, + mode: SftpTabDuplicateMode, +): SftpTabDuplicateRequest | null { + const connection = pane?.connection; + if (!connection || connection.status !== "connected") { + return null; + } + + const path = mode === "currentPath" && connection.currentPath + ? { path: connection.currentPath } + : {}; + + if (connection.isLocal) { + return { + kind: "local", + ...path, + }; + } + + if (!connection.hostId) { + return null; + } + + return { + kind: "remote", + hostId: connection.hostId, + ...path, + }; +} From 2b396c14e3ac3857971e6d7e42387e72976d4e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:43:10 +0800 Subject: [PATCH 10/10] [codex] Fix CentOS 7 process listing (#1440) * Fix CentOS 7 process listing * Tighten CentOS 7 process listing regression test --- electron/bridges/systemManagerBridge.cjs | 2 +- .../systemManagerBridge.processes.test.cjs | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 electron/bridges/systemManagerBridge.processes.test.cjs diff --git a/electron/bridges/systemManagerBridge.cjs b/electron/bridges/systemManagerBridge.cjs index 8c2fea2a..43b96740 100644 --- a/electron/bridges/systemManagerBridge.cjs +++ b/electron/bridges/systemManagerBridge.cjs @@ -16,7 +16,7 @@ const CAPABILITY_SCRIPT_POSIX = [ const PROCESS_LIST_SCRIPT_POSIX = [ "exec sh -c ", "'", - "ps -eo pid=,ppid=,user=,stat=,pcpu=,pmem=,rss=,vsz=,etime=,args= 2>/dev/null | head -n 200", + "ps -eo pid= -o ppid= -o user= -o stat= -o pcpu= -o pmem= -o rss= -o vsz= -o etime= -o args= 2>/dev/null | head -n 200", "'", ].join(""); diff --git a/electron/bridges/systemManagerBridge.processes.test.cjs b/electron/bridges/systemManagerBridge.processes.test.cjs new file mode 100644 index 00000000..09c0e357 --- /dev/null +++ b/electron/bridges/systemManagerBridge.processes.test.cjs @@ -0,0 +1,48 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); +const { createSystemManagerBridge } = require("./systemManagerBridge.cjs"); + +function createFakeExecStream(stdout) { + const stream = new EventEmitter(); + stream.stderr = new EventEmitter(); + process.nextTick(() => { + stream.emit("data", stdout); + stream.emit("close", 0); + }); + return stream; +} + +test("listProcesses uses a ps format that works on CentOS 7 procps", async () => { + const compatiblePsFormat = "ps -eo pid= -o ppid= -o user= -o stat= -o pcpu= -o pmem= -o rss= -o vsz= -o etime= -o args="; + const badCentos7Output = [ + ",ppid=,user=,stat=,pcpu=,pmem=,rss=,vsz=,etime=,args=", + " 1", + ].join("\n"); + const compatibleOutput = [ + " 1 0 root Ss 0.0 0.0 4060 191024 2-19:23:42 /usr/lib/systemd/systemd --switched-root --system --deserialize 21", + ].join("\n"); + + const conn = { + exec(command, callback) { + const stdout = command.includes(compatiblePsFormat) + ? compatibleOutput + : badCentos7Output; + callback(null, createFakeExecStream(stdout)); + }, + }; + const sessions = new Map([["s1", { conn, type: "ssh" }]]); + const bridge = createSystemManagerBridge({ + getSessions: () => sessions, + process, + }); + + const result = await bridge.listProcesses(null, { sessionId: "s1" }); + + assert.equal(result.success, true); + assert.equal(result.processes.length, 1); + assert.equal(result.processes[0].pid, 1); + assert.equal(result.processes[0].command, "/usr/lib/systemd/systemd --switched-root --system --deserialize 21"); +});