From 1396b3a70c11da892b909052009c3bb6c9c6bd84 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 14 Oct 2026 08:59:46 +0800 Subject: [PATCH] fix(desktop): find spawn-helper in universal macOS builds (#1630) The universal build ships app-arm64.asar and app-x64.asar instead of app.asar, so node-pty's helper path rewrite never pointed at the unpacked copy and every local terminal failed with posix_spawnp failed. --- electron/local-shell.cjs | 6 +- scripts/patch-node-pty.cjs | 82 +++++++++++-------- .../tests/electron/local-shell.test.ts | 16 ++++ .../tests/electron/patch-node-pty.test.ts | 61 +++++++++++++- 4 files changed, 129 insertions(+), 36 deletions(-) diff --git a/electron/local-shell.cjs b/electron/local-shell.cjs index bbf965fb2..39daa9a8b 100644 --- a/electron/local-shell.cjs +++ b/electron/local-shell.cjs @@ -39,7 +39,11 @@ function resolveLocalShell( * how many helpers were fixed. */ function repairSpawnHelpers(ptyDir, fsImpl = fs) { - const dir = ptyDir.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked"); + // A universal macOS build ships app-arm64.asar and app-x64.asar. + const dir = ptyDir.replace( + /\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/, + "$1.asar.unpacked", + ); const candidates = [ path.join(dir, "build", "Release", "spawn-helper"), path.join( diff --git a/scripts/patch-node-pty.cjs b/scripts/patch-node-pty.cjs index 197529a35..327a98ccf 100644 --- a/scripts/patch-node-pty.cjs +++ b/scripts/patch-node-pty.cjs @@ -3,43 +3,45 @@ const path = require("node:path"); const nodePtyDir = path.join(__dirname, "..", "node_modules", "node-pty"); -if (!fs.existsSync(nodePtyDir)) { - console.log("[patch-node-pty] node-pty not found, skipping"); - process.exit(0); -} - const unixTerminalPath = path.join(nodePtyDir, "lib", "unixTerminal.js"); -const originalHelperRewrite = [ - "helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", - "helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", -].join("\n"); +// Older installs may still carry the first version of this patch. +const knownHelperRewrites = [ + [ + "helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", + "helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", + ].join("\n"), + [ + "if (!helperPath.includes('app.asar.unpacked')) {", + " helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", + "}", + "if (!helperPath.includes('node_modules.asar.unpacked')) {", + " helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", + "}", + ].join("\n"), +]; -const patchedHelperRewrite = [ - "if (!helperPath.includes('app.asar.unpacked')) {", - " helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", - "}", - "if (!helperPath.includes('node_modules.asar.unpacked')) {", - " helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", - "}", -].join("\n"); +// A universal macOS build ships app-arm64.asar and app-x64.asar, not app.asar. +const patchedHelperRewrite = + "helperPath = helperPath.replace(/\\b(app(?:-[a-z0-9]+)?|node_modules)\\.asar(?!\\.unpacked)/g, '$1.asar.unpacked');"; -function patchUnixTerminalHelperRewrite() { - if (!fs.existsSync(unixTerminalPath)) { +function patchUnixTerminalHelperRewrite(file = unixTerminalPath) { + if (!fs.existsSync(file)) { return false; } - const source = fs.readFileSync(unixTerminalPath, "utf8"); + const source = fs.readFileSync(file, "utf8"); if (source.includes(patchedHelperRewrite)) { return false; } - if (!source.includes(originalHelperRewrite)) { + const known = knownHelperRewrites.find((rewrite) => source.includes(rewrite)); + if (!known) { return false; } fs.writeFileSync( - unixTerminalPath, - source.replace(originalHelperRewrite, patchedHelperRewrite), + file, + source.replace(known, () => patchedHelperRewrite), ); return true; } @@ -67,19 +69,31 @@ function chmodSpawnHelpers(rootDir) { return fixed; } -const helperRewritePatched = patchUnixTerminalHelperRewrite(); -const helpersChmodded = chmodSpawnHelpers(nodePtyDir); +// Tests and afterPack require this file; only a direct run patches node_modules. +function main() { + if (!fs.existsSync(nodePtyDir)) { + console.log("[patch-node-pty] node-pty not found, skipping"); + return; + } -if (helperRewritePatched) { - console.log("[patch-node-pty] Patched unixTerminal helper path rewrite"); + const helperRewritePatched = patchUnixTerminalHelperRewrite(); + const helpersChmodded = chmodSpawnHelpers(nodePtyDir); + + if (helperRewritePatched) { + console.log("[patch-node-pty] Patched unixTerminal helper path rewrite"); + } + if (helpersChmodded > 0) { + console.log( + `[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`, + ); + } + if (!helperRewritePatched && helpersChmodded === 0) { + console.log("[patch-node-pty] Already patched or target files not found"); + } } -if (helpersChmodded > 0) { - console.log( - `[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`, - ); -} -if (!helperRewritePatched && helpersChmodded === 0) { - console.log("[patch-node-pty] Already patched or target files not found"); + +if (require.main === module) { + main(); } module.exports = { chmodSpawnHelpers, patchUnixTerminalHelperRewrite }; diff --git a/src/backend/tests/electron/local-shell.test.ts b/src/backend/tests/electron/local-shell.test.ts index 1d085023e..32a44a6fc 100644 --- a/src/backend/tests/electron/local-shell.test.ts +++ b/src/backend/tests/electron/local-shell.test.ts @@ -80,4 +80,20 @@ describe("repairSpawnHelpers", () => { expect(chmods[0][1] & 0o111).not.toBe(0); expect(chmods[0][0]).toContain("app.asar.unpacked"); }); + + it.each(["app-arm64.asar", "app-x64.asar"])( + "repairs the helper in the unpacked copy of a universal build's %s", + (asar) => { + const chmods: string[] = []; + const fsImpl = { + statSync: () => ({ mode: 0o644 }), + chmodSync: (file: string) => { + chmods.push(file.split(require("node:path").sep).join("/")); + }, + }; + const dir = `/Applications/Termix.app/Contents/Resources/${asar}/node_modules/node-pty`; + repairSpawnHelpers(dir, fsImpl); + expect(chmods[0]).toContain(`/${asar}.unpacked/node_modules/node-pty/`); + }, + ); }); diff --git a/src/backend/tests/electron/patch-node-pty.test.ts b/src/backend/tests/electron/patch-node-pty.test.ts index ea4086258..5205c5bc5 100644 --- a/src/backend/tests/electron/patch-node-pty.test.ts +++ b/src/backend/tests/electron/patch-node-pty.test.ts @@ -11,7 +11,48 @@ import { tmpdir } from "node:os"; import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); -const { chmodSpawnHelpers } = require("../../../../scripts/patch-node-pty.cjs"); +const { + chmodSpawnHelpers, + patchUnixTerminalHelperRewrite, +} = require("../../../../scripts/patch-node-pty.cjs"); + +const upstreamRewrite = [ + "helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", + "helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", +].join("\n"); + +const previouslyPatchedRewrite = [ + "if (!helperPath.includes('app.asar.unpacked')) {", + " helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", + "}", + "if (!helperPath.includes('node_modules.asar.unpacked')) {", + " helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", + "}", +].join("\n"); + +const resources = "/Applications/Termix.app/Contents/Resources"; +const helperPaths = [ + ["app.asar", "app.asar.unpacked"], + ["app-arm64.asar", "app-arm64.asar.unpacked"], + ["app-x64.asar", "app-x64.asar.unpacked"], + ["app.asar.unpacked", "app.asar.unpacked"], + ["app-arm64.asar.unpacked", "app-arm64.asar.unpacked"], +].map(([from, to]) => [ + `${resources}/${from}/node_modules/node-pty/build/Release/spawn-helper`, + `${resources}/${to}/node_modules/node-pty/build/Release/spawn-helper`, +]); + +function writeUnixTerminal(rewrite: string) { + const file = join( + tmpdir(), + `termix-unix-terminal-${Date.now()}-${Math.random()}.cjs`, + ); + writeFileSync( + file, + `module.exports = function (helperPath) {\n${rewrite}\nreturn helperPath;\n};\n`, + ); + return file; +} // chmod's execute bit is a POSIX concept; Windows/NTFS has no equivalent mode bit. const itPosix = process.platform === "win32" ? it.skip : it; @@ -30,4 +71,22 @@ describe("patch-node-pty", () => { expect(chmodSpawnHelpers(root)).toBe(0); expect(existsSync(helper)).toBe(true); }); + + it.each([ + ["upstream", upstreamRewrite], + ["previously patched", previouslyPatchedRewrite], + ])( + "points the %s helper path at the unpacked copy of any app asar", + (_, rewrite) => { + const file = writeUnixTerminal(rewrite); + + expect(patchUnixTerminalHelperRewrite(file)).toBe(true); + expect(patchUnixTerminalHelperRewrite(file)).toBe(false); + + const rewriteHelperPath = require(file); + for (const [from, to] of helperPaths) { + expect(rewriteHelperPath(from)).toBe(to); + } + }, + ); });