From d48ca65a1e8d6ef15ff2074f4d1deca9c25e541a 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 16:38:45 +0800 Subject: [PATCH] Slim release package (#1446) --- electron-builder.config.cjs | 15 +- scripts/afterPackMacUuid.cjs | 177 ++++++++++++++++++++++ scripts/afterPackMacUuid.test.cjs | 181 +++++++++++++++++++++++ scripts/electron-builder-config.test.cjs | 21 ++- 4 files changed, 391 insertions(+), 3 deletions(-) diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index 0d286371..652724fb 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -58,6 +58,10 @@ module.exports = { '!**/tests/**/*', '!**/example/**/*', '!**/examples/**/*', + '!node_modules/**/docs/**/*', + '!node_modules/**/doc/**/*', + '!node_modules/**/benchmark/**/*', + '!node_modules/**/benchmarks/**/*', // 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. @@ -103,7 +107,15 @@ module.exports = { // 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/**/*' + '!node_modules/@tencent-ai/agent-sdk/cli/**/*', + // Netcatty loads Cursor SDK through ESM dynamic import, so the duplicate + // CommonJS build and type metadata are not needed at runtime. + '!node_modules/@cursor/sdk/dist/cjs/**/*', + '!node_modules/@cursor/sdk/dist/**/*.d.ts', + '!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/**/*' ], asarUnpack: [ 'node_modules/node-pty/**/*', @@ -111,7 +123,6 @@ module.exports = { 'node_modules/cpu-features/**/*', 'node_modules/@vscode/windows-process-tree/**/*', 'node_modules/@anthropic-ai/claude-agent-sdk/**/*', - 'node_modules/@cursor/sdk/**/*', 'node_modules/@cursor/sdk-*/**/*', 'node_modules/sqlite3/**/*', 'node_modules/@modelcontextprotocol/sdk/**/*', diff --git a/scripts/afterPackMacUuid.cjs b/scripts/afterPackMacUuid.cjs index c825c079..ef180149 100644 --- a/scripts/afterPackMacUuid.cjs +++ b/scripts/afterPackMacUuid.cjs @@ -123,8 +123,182 @@ function adHocSignAppBundle(appPath, options = {}) { return true; } +const ELECTRON_BUILDER_ARCH_NAMES = { + 0: "ia32", + 1: "x64", + 2: "armv7l", + 3: "arm64", + 4: "universal", +}; + +function archNameFromContext(context) { + const arch = context?.arch; + if (typeof arch === "string") return arch; + if (typeof arch === "number" && ELECTRON_BUILDER_ARCH_NAMES[arch]) { + return ELECTRON_BUILDER_ARCH_NAMES[arch]; + } + return process.arch; +} + +function cursorPlatformPackageBases(platform) { + if (platform === "darwin") return ["sdk-darwin-arm64", "sdk-darwin-x64"]; + if (platform === "linux") return ["sdk-linux-arm64", "sdk-linux-x64"]; + if (platform === "win32") return ["sdk-win32-x64"]; + return []; +} + +function cursorPackagesToKeep(platform, archName) { + if (platform === "darwin" && archName === "universal") { + return new Set(["sdk-darwin-arm64", "sdk-darwin-x64"]); + } + if (platform === "darwin" && (archName === "arm64" || archName === "x64")) { + return new Set([`sdk-darwin-${archName}`]); + } + if (platform === "linux" && (archName === "arm64" || archName === "x64")) { + return new Set([`sdk-linux-${archName}`]); + } + if (platform === "win32" && archName === "x64") { + return new Set(["sdk-win32-x64"]); + } + return new Set(); +} + +function appResourcesDir(context) { + if (context.electronPlatformName === "darwin") { + const productFilename = context.packager.appInfo.productFilename; + return path.join(context.appOutDir, `${productFilename}.app`, "Contents", "Resources"); + } + return path.join(context.appOutDir, "resources"); +} + +function readAsarHeader(asarPath) { + const fd = fs.openSync(asarPath, "r"); + try { + const sizeBuf = Buffer.alloc(8); + if (fs.readSync(fd, sizeBuf, 0, sizeBuf.length, 0) !== sizeBuf.length) { + throw new Error(`[afterPack] Unable to read ASAR header size: ${asarPath}`); + } + + const sizePicklePayloadSize = sizeBuf.readUInt32LE(0); + if (sizePicklePayloadSize !== 4) { + throw new Error(`[afterPack] Unsupported ASAR size pickle in ${asarPath}`); + } + + const headerSize = sizeBuf.readUInt32LE(4); + const headerBuf = Buffer.alloc(headerSize); + if (fs.readSync(fd, headerBuf, 0, headerSize, 8) !== headerSize) { + throw new Error(`[afterPack] Unable to read ASAR header: ${asarPath}`); + } + + const headerPicklePayloadSize = headerBuf.readUInt32LE(0); + if (headerPicklePayloadSize !== headerSize - 4) { + throw new Error(`[afterPack] Unsupported ASAR header pickle in ${asarPath}`); + } + + const headerStringLength = headerBuf.readInt32LE(4); + const headerString = headerBuf.subarray(8, 8 + headerStringLength).toString("utf8"); + return { header: JSON.parse(headerString), headerSize }; + } finally { + fs.closeSync(fd); + } +} + +function writeAsarHeaderPreservingDataOffset(asarPath, header, headerSize) { + const headerString = JSON.stringify(header); + const headerStringLength = Buffer.byteLength(headerString); + const fixedPrefixSize = 8; // payload size uint32 + string length int32 + if (fixedPrefixSize + headerStringLength > headerSize) { + throw new Error( + `[afterPack] Updated ASAR header is larger than the original header for ${asarPath}`, + ); + } + + const headerBuf = Buffer.alloc(headerSize); + headerBuf.writeUInt32LE(headerSize - 4, 0); + headerBuf.writeInt32LE(headerStringLength, 4); + headerBuf.write(headerString, fixedPrefixSize, headerStringLength, "utf8"); + + const fd = fs.openSync(asarPath, "r+"); + try { + fs.writeSync(fd, headerBuf, 0, headerBuf.length, 8); + } finally { + fs.closeSync(fd); + } +} + +function removeAsarHeaderEntry(header, entryPath) { + const segments = entryPath.split(/[\\/]+/).filter(Boolean); + if (segments.length === 0) return false; + + let node = header; + for (const segment of segments.slice(0, -1)) { + node = node.files?.[segment]; + if (!node) return false; + } + + const leaf = segments[segments.length - 1]; + if (!Object.prototype.hasOwnProperty.call(node.files || {}, leaf)) return false; + delete node.files[leaf]; + return true; +} + +function pruneAsarHeaderEntries(asarPath, entryPaths) { + if (!fs.existsSync(asarPath) || entryPaths.length === 0) return []; + + const { header, headerSize } = readAsarHeader(asarPath); + const removed = entryPaths.filter((entryPath) => removeAsarHeaderEntry(header, entryPath)); + if (removed.length > 0) { + writeAsarHeaderPreservingDataOffset(asarPath, header, headerSize); + } + return removed; +} + +function pruneCursorSdkPlatformPackages(context) { + const platform = context.electronPlatformName; + const candidates = cursorPlatformPackageBases(platform); + if (candidates.length === 0) return []; + + const keep = cursorPackagesToKeep(platform, archNameFromContext(context)); + if (keep.size === 0) return []; + + const cursorRoot = path.join( + appResourcesDir(context), + "app.asar.unpacked", + "node_modules", + "@cursor", + ); + if (!fs.existsSync(cursorRoot)) return []; + + const removed = []; + const asarHeaderEntriesToRemove = []; + for (const baseName of candidates) { + if (keep.has(baseName)) continue; + const dir = path.join(cursorRoot, baseName); + if (!fs.existsSync(dir)) continue; + removed.push(baseName); + asarHeaderEntriesToRemove.push(`node_modules/@cursor/${baseName}`); + } + + if (removed.length === 0) return []; + + const appAsar = path.join(appResourcesDir(context), "app.asar"); + pruneAsarHeaderEntries(appAsar, asarHeaderEntriesToRemove); + + for (const baseName of removed) { + fs.rmSync(path.join(cursorRoot, baseName), { recursive: true, force: true }); + } + return removed; +} + /** @param {import('electron-builder').AfterPackContext} context */ async function afterPack(context) { + const removedCursorPackages = pruneCursorSdkPlatformPackages(context); + if (removedCursorPackages.length > 0) { + console.log( + `[afterPack] Removed unused Cursor SDK platform package(s): ${removedCursorPackages.join(", ")}`, + ); + } + if (context.electronPlatformName !== "darwin") return; const appId = context.packager.appInfo.id || "com.netcatty.app"; @@ -173,3 +347,6 @@ module.exports.formatUuid = formatUuid; module.exports.patchMachOBuffer = patchMachOBuffer; module.exports.patchMachOFile = patchMachOFile; module.exports.adHocSignAppBundle = adHocSignAppBundle; +module.exports.readAsarHeader = readAsarHeader; +module.exports.pruneAsarHeaderEntries = pruneAsarHeaderEntries; +module.exports.pruneCursorSdkPlatformPackages = pruneCursorSdkPlatformPackages; diff --git a/scripts/afterPackMacUuid.test.cjs b/scripts/afterPackMacUuid.test.cjs index a75665d0..90ceee88 100644 --- a/scripts/afterPackMacUuid.test.cjs +++ b/scripts/afterPackMacUuid.test.cjs @@ -5,12 +5,37 @@ const { adHocSignAppBundle, deriveUuid, patchMachOBuffer, + pruneAsarHeaderEntries, + pruneCursorSdkPlatformPackages, + readAsarHeader, } = require("./afterPackMacUuid.cjs"); const LC_UUID = 0x1b; const LC_OTHER = 0x19; const MH_MAGIC_64 = 0xfeedfacf; +function align4(value) { + return value + ((4 - (value % 4)) % 4); +} + +function writeFakeAsar(asarPath, header, payload = Buffer.from("packed-payload")) { + const headerString = JSON.stringify(header); + const headerStringLength = Buffer.byteLength(headerString); + const headerPayloadSize = 4 + align4(headerStringLength); + const headerSize = 4 + headerPayloadSize; + const sizeBuf = Buffer.alloc(8); + const headerBuf = Buffer.alloc(headerSize); + + sizeBuf.writeUInt32LE(4, 0); + sizeBuf.writeUInt32LE(headerSize, 4); + headerBuf.writeUInt32LE(headerPayloadSize, 0); + headerBuf.writeInt32LE(headerStringLength, 4); + headerBuf.write(headerString, 8, headerStringLength, "utf8"); + + require("node:fs").writeFileSync(asarPath, Buffer.concat([sizeBuf, headerBuf, payload])); + return headerSize; +} + // Build a minimal thin little-endian 64-bit Mach-O with two load commands: // one dummy command and one LC_UUID carrying `uuidBytes`. function buildThinMachO(uuidBytes) { @@ -157,3 +182,159 @@ test("adHocSignAppBundle skips non-macOS hosts", () => { assert.equal(didSign, false); assert.equal(called, false); }); + +test("pruneAsarHeaderEntries removes package records without moving packed payload", (t) => { + const fs = require("node:fs"); + const os = require("node:os"); + const path = require("node:path"); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-prune-asar-")); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + + const asarPath = path.join(tempDir, "app.asar"); + const payload = Buffer.from("packed-payload"); + const headerSize = writeFakeAsar( + asarPath, + { + files: { + node_modules: { + files: { + "@cursor": { + files: { + "sdk-darwin-arm64": { + files: { + "package.json": { size: 2, unpacked: true }, + }, + }, + "sdk-darwin-x64": { + files: { + "package.json": { size: 2, unpacked: true }, + }, + }, + }, + }, + }, + }, + "packed.txt": { size: payload.length, offset: "0" }, + }, + }, + payload, + ); + + const removed = pruneAsarHeaderEntries(asarPath, ["node_modules/@cursor/sdk-darwin-x64"]); + const { header, headerSize: updatedHeaderSize } = readAsarHeader(asarPath); + const packedPayload = fs.readFileSync(asarPath).subarray(8 + headerSize); + + assert.deepEqual(removed, ["node_modules/@cursor/sdk-darwin-x64"]); + assert.equal(updatedHeaderSize, headerSize); + assert.ok(header.files.node_modules.files["@cursor"].files["sdk-darwin-arm64"]); + assert.equal(header.files.node_modules.files["@cursor"].files["sdk-darwin-x64"], undefined); + assert.equal(packedPayload.toString("utf8"), payload.toString("utf8")); +}); + +test("pruneCursorSdkPlatformPackages keeps only the target macOS arch package", (t) => { + const fs = require("node:fs"); + const os = require("node:os"); + const path = require("node:path"); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-prune-cursor-")); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + const cursorRoot = path.join( + tempDir, + "Netcatty.app", + "Contents", + "Resources", + "app.asar.unpacked", + "node_modules", + "@cursor", + ); + fs.mkdirSync(path.join(cursorRoot, "sdk-darwin-arm64"), { recursive: true }); + fs.mkdirSync(path.join(cursorRoot, "sdk-darwin-x64"), { recursive: true }); + writeFakeAsar(path.join(tempDir, "Netcatty.app", "Contents", "Resources", "app.asar"), { + files: { + node_modules: { + files: { + "@cursor": { + files: { + "sdk-darwin-arm64": { files: { "package.json": { size: 2, unpacked: true } } }, + "sdk-darwin-x64": { files: { "package.json": { size: 2, unpacked: true } } }, + }, + }, + }, + }, + }, + }); + + const removed = pruneCursorSdkPlatformPackages({ + electronPlatformName: "darwin", + arch: 3, + appOutDir: tempDir, + packager: { appInfo: { productFilename: "Netcatty" } }, + }); + + assert.deepEqual(removed, ["sdk-darwin-x64"]); + assert.ok(fs.existsSync(path.join(cursorRoot, "sdk-darwin-arm64"))); + assert.ok(!fs.existsSync(path.join(cursorRoot, "sdk-darwin-x64"))); + + const { header } = readAsarHeader( + path.join(tempDir, "Netcatty.app", "Contents", "Resources", "app.asar"), + ); + assert.ok(header.files.node_modules.files["@cursor"].files["sdk-darwin-arm64"]); + assert.equal(header.files.node_modules.files["@cursor"].files["sdk-darwin-x64"], undefined); +}); + +test("pruneCursorSdkPlatformPackages keeps both macOS packages for universal builds", (t) => { + const fs = require("node:fs"); + const os = require("node:os"); + const path = require("node:path"); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-prune-cursor-")); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + const cursorRoot = path.join( + tempDir, + "Netcatty.app", + "Contents", + "Resources", + "app.asar.unpacked", + "node_modules", + "@cursor", + ); + fs.mkdirSync(path.join(cursorRoot, "sdk-darwin-arm64"), { recursive: true }); + fs.mkdirSync(path.join(cursorRoot, "sdk-darwin-x64"), { recursive: true }); + + const removed = pruneCursorSdkPlatformPackages({ + electronPlatformName: "darwin", + arch: 4, + appOutDir: tempDir, + packager: { appInfo: { productFilename: "Netcatty" } }, + }); + + assert.deepEqual(removed, []); + assert.ok(fs.existsSync(path.join(cursorRoot, "sdk-darwin-arm64"))); + assert.ok(fs.existsSync(path.join(cursorRoot, "sdk-darwin-x64"))); +}); + +test("pruneCursorSdkPlatformPackages keeps only the target Linux arch package", (t) => { + const fs = require("node:fs"); + const os = require("node:os"); + const path = require("node:path"); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-prune-cursor-")); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + const cursorRoot = path.join( + tempDir, + "resources", + "app.asar.unpacked", + "node_modules", + "@cursor", + ); + fs.mkdirSync(path.join(cursorRoot, "sdk-linux-arm64"), { recursive: true }); + fs.mkdirSync(path.join(cursorRoot, "sdk-linux-x64"), { recursive: true }); + + const removed = pruneCursorSdkPlatformPackages({ + electronPlatformName: "linux", + arch: 1, + appOutDir: tempDir, + packager: { appInfo: { productFilename: "netcatty" } }, + }); + + assert.deepEqual(removed, ["sdk-linux-arm64"]); + assert.ok(!fs.existsSync(path.join(cursorRoot, "sdk-linux-arm64"))); + assert.ok(fs.existsSync(path.join(cursorRoot, "sdk-linux-x64"))); +}); diff --git a/scripts/electron-builder-config.test.cjs b/scripts/electron-builder-config.test.cjs index 32eb0879..89da43d3 100644 --- a/scripts/electron-builder-config.test.cjs +++ b/scripts/electron-builder-config.test.cjs @@ -55,7 +55,10 @@ test("asarUnpack keeps MCP server runtime deps unpacked", () => { }); test("asarUnpack keeps Cursor SDK runtime deps unpacked", () => { - assert.ok(config.asarUnpack.includes("node_modules/@cursor/sdk/**/*")); + assert.ok( + !config.asarUnpack.includes("node_modules/@cursor/sdk/**/*"), + "Cursor SDK JavaScript can load from app.asar and should not be duplicated into app.asar.unpacked", + ); assert.ok(config.asarUnpack.includes("node_modules/@cursor/sdk-*/**/*")); assert.ok(config.asarUnpack.includes("node_modules/sqlite3/**/*")); }); @@ -64,6 +67,22 @@ test("beforePack installs missing Cursor SDK platform runtime packages", () => { assert.equal(config.beforePack, "./scripts/beforePackCursorSdk.cjs"); }); +test("build.files trims release-only dependency payloads", () => { + const files = config.files; + for (const glob of [ + "!node_modules/@cursor/sdk/dist/cjs/**/*", + "!node_modules/@cursor/sdk/dist/**/*.d.ts", + "!node_modules/@cursor/sdk/dist/**/*.d.ts.map", + "!node_modules/sqlite3/deps/**/*", + "!node_modules/**/docs/**/*", + "!node_modules/**/doc/**/*", + "!node_modules/**/benchmark/**/*", + "!node_modules/**/benchmarks/**/*", + ]) { + assert.ok(files.includes(glob), `build.files must exclude release-only payload: ${glob}`); + } +}); + test("linux packaging uses multi-size build/icons instead of a single 1024px override", async () => { assert.equal( config.linux.icon,