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 <zwcf5200@163.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
This commit is contained in:
Siddharth Balyan 2026-07-16 17:28:50 +05:30 committed by GitHub
parent 59787b9ada
commit 7d8c499893
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 109 additions and 4 deletions

View file

@ -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/<platform>-<arch>/* — 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)
}
}
}

View file

@ -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 {