From 7d8c499893516030caabc4bf374685c5de6da430 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:28:50 +0530 Subject: [PATCH] fix(desktop): preserve node-pty helper in packaged app (#65611) Guard staged node-pty ASAR path rewrites so already-unpacked paths are not rewritten twice. Normalize spawn-helper to mode 0755 in both the prebuild and locally compiled build/Release staging paths. Add behavioral coverage for both unpacked path forms and both helper layouts. Co-authored-by: zhouwei Co-authored-by: liuhao1024 --- apps/desktop/scripts/stage-native-deps.mjs | 39 +++++++++- .../scripts/stage-native-deps.test.mjs | 74 +++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/apps/desktop/scripts/stage-native-deps.mjs b/apps/desktop/scripts/stage-native-deps.mjs index 8966fece6cb4..e4a6958fee89 100644 --- a/apps/desktop/scripts/stage-native-deps.mjs +++ b/apps/desktop/scripts/stage-native-deps.mjs @@ -19,7 +19,8 @@ import { mkdirSync, readdirSync, readFileSync, - rmSync + rmSync, + writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { isMain } from './utils.mjs' @@ -28,6 +29,30 @@ const here = dirname(fileURLToPath(import.meta.url)) const projectRoot = resolve(here, '..') const require = createRequire(import.meta.url) +function makeExecutable(filePath) { + chmodSync(filePath, 0o755) +} + +function patchUnixTerminalAsarPaths(destRoot) { + const filePath = join(destRoot, 'lib', 'unixTerminal.js') + if (!existsSync(filePath)) return + + const source = readFileSync(filePath, 'utf8') + const patched = source + .replace( + "helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", + "helperPath = helperPath.replace(/app\\.asar(?!\\.unpacked)/, 'app.asar.unpacked');" + ) + .replace( + "helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", + "helperPath = helperPath.replace(/node_modules\\.asar(?!\\.unpacked)/, 'node_modules.asar.unpacked');" + ) + + if (patched !== source) { + writeFileSync(filePath, patched) + } +} + /** * Locate node-pty's package root via real module resolution, so this * works whether it's hoisted to a workspace root or local to this app. @@ -75,7 +100,11 @@ function copyBuildRelease(srcDir, destDir) { continue } if (entry.name === 'spawn-helper' || /\.(node|dll|exe)$/.test(entry.name)) { - cpSync(join(srcDir, entry.name), join(destDir, entry.name)) + const destFile = join(destDir, entry.name) + cpSync(join(srcDir, entry.name), destFile) + if (entry.name === 'spawn-helper') { + makeExecutable(destFile) + } } } } @@ -220,6 +249,7 @@ export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platfor // lib/**/*.js — the JS surface node-pty's `main` points into. copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js']) + patchUnixTerminalAsarPaths(destRoot) // prebuilds/-/* — the prebuild-install payload for the // *target* we're packaging, not necessarily the host running this script. @@ -239,8 +269,9 @@ export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platfor continue } if (entry.name === 'spawn-helper') { - cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name)) - chmodSync(join(destPrebuild, entry.name), 0o775) + const destFile = join(destPrebuild, entry.name) + cpSync(join(prebuildDir, entry.name), destFile) + makeExecutable(destFile) } } } diff --git a/apps/desktop/scripts/stage-native-deps.test.mjs b/apps/desktop/scripts/stage-native-deps.test.mjs index 65e1bb5d3022..ec163b29ac65 100644 --- a/apps/desktop/scripts/stage-native-deps.test.mjs +++ b/apps/desktop/scripts/stage-native-deps.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import fs, { existsSync } from 'node:fs' import os from 'node:os' import path from 'node:path' +import { pathToFileURL } from 'node:url' import { test } from 'vitest' import { @@ -43,6 +44,19 @@ function makeFakeNodePty(srcRoot, { prebuildPlatform, prebuildArch } = {}) { } } +function makeFakeUnixTerminal(srcRoot) { + fs.writeFileSync( + join(srcRoot, 'lib', 'unixTerminal.js'), + [ + "exports.resolveHelper = function (helperPath) {", + " helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", + " helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", + ' return helperPath;', + '};' + ].join('\n') + ) +} + // ─── classifyNativeBinary tests ───────────────────────────────────── test('classifyNativeBinary detects ELF as linux', () => { @@ -262,6 +276,66 @@ test('host-target: host build/Release IS staged for a matching target', () => { } }) +test.skipIf(process.platform === 'win32')( + 'host-target: staged node-pty resolves an already-unpacked helper and preserves executable helpers', + async () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'node-pty') + const destRoot = join(tmp, 'dest') + const prebuildDir = join(srcRoot, 'prebuilds', `${process.platform}-${process.arch}`) + const buildReleaseDir = join(srcRoot, 'build', 'Release') + + makeFakeNodePty(srcRoot, { + prebuildPlatform: process.platform, + prebuildArch: process.arch + }) + makeFakeUnixTerminal(srcRoot) + makeFakeNode(join(buildReleaseDir, 'pty.node'), process.platform) + fs.writeFileSync(join(prebuildDir, 'spawn-helper'), 'prebuild helper') + fs.writeFileSync(join(buildReleaseDir, 'spawn-helper'), 'build helper') + fs.chmodSync(join(prebuildDir, 'spawn-helper'), 0o644) + fs.chmodSync(join(buildReleaseDir, 'spawn-helper'), 0o644) + + stageNodePtyInto(srcRoot, destRoot, { platform: process.platform, arch: process.arch }) + + const stagedUnixTerminalUrl = pathToFileURL(join(destRoot, 'lib', 'unixTerminal.js')) + stagedUnixTerminalUrl.searchParams.set('t', String(Date.now())) + const stagedUnixTerminal = await import(stagedUnixTerminalUrl.href) + const unpackedHelper = join( + tmp, + 'Hermes.app', + 'Contents', + 'Resources', + 'app.asar.unpacked', + 'dist', + 'node_modules', + 'node-pty', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'spawn-helper' + ) + const nodeModulesUnpackedHelper = unpackedHelper.replace( + `${path.sep}node_modules${path.sep}`, + `${path.sep}node_modules.asar.unpacked${path.sep}` + ) + + assert.equal(stagedUnixTerminal.resolveHelper(unpackedHelper), unpackedHelper) + assert.equal( + stagedUnixTerminal.resolveHelper(nodeModulesUnpackedHelper), + nodeModulesUnpackedHelper + ) + assert.equal( + fs.statSync(join(destRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper')).mode & 0o777, + 0o755 + ) + assert.equal(fs.statSync(join(destRoot, 'build', 'Release', 'spawn-helper')).mode & 0o777, 0o755) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } + } +) + test('validation rejects a staged binary with the wrong platform magic', () => { const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) try {