Compare commits

...
Author SHA1 Message Date
bincxz ce9815e903 chore(packaging): omit duplicate renderer and unused native files
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:04:22 +08:00
5 changed files with 622 additions and 102 deletions

No files matched your search

+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/**/*',
+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");