diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 8d8c3cfad7c..7db8711ee9d 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -35,7 +35,7 @@ const { let nodePty = null try { - nodePty = require('@homebridge/node-pty-prebuilt-multiarch') + nodePty = require('node-pty') } catch { // Packaged builds set `files:` in package.json, which excludes node_modules // from the asar. Workspace dedup also hoists this native dep to the repo @@ -49,7 +49,7 @@ try { const resourcesPath = process.resourcesPath if (resourcesPath) { nodePty = require( - path.join(resourcesPath, 'native-deps', '@homebridge', 'node-pty-prebuilt-multiarch') + path.join(resourcesPath, 'native-deps', 'node-pty') ) } } catch { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7601baeaa24..9bf79511b74 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,7 +49,6 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hermes/shared": "file:../shared", - "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@nanostores/react": "^1.1.0", "@nous-research/ui": "^0.13.0", "@radix-ui/react-slot": "^1.2.4", @@ -75,6 +74,7 @@ "leva": "^0.10.1", "motion": "^12.38.0", "nanostores": "^1.3.0", + "node-pty": "1.1.0", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-arborist": "^3.5.0", @@ -152,7 +152,8 @@ "asar": true, "afterSign": "scripts/notarize.cjs", "asarUnpack": [ - "**/*.node" + "**/*.node", + "**/prebuilds/**" ], "mac": { "category": "public.app-category.developer-tools", diff --git a/apps/desktop/scripts/stage-native-deps.cjs b/apps/desktop/scripts/stage-native-deps.cjs index b8e037f54c6..d84ae2cf51f 100644 --- a/apps/desktop/scripts/stage-native-deps.cjs +++ b/apps/desktop/scripts/stage-native-deps.cjs @@ -3,11 +3,11 @@ /** * Stage native node-modules dependencies for electron-builder packaging. * - * Workspace dedup hoists @homebridge/node-pty-prebuilt-multiarch into the - * root `node_modules/`, which electron-builder's default file collector - * (when `files:` is explicitly set in package.json) cannot reach. The - * result: packaged builds ship with no .node binaries and PTY initialization - * fails at runtime ("PTY support is unavailable"). + * Workspace dedup hoists `node-pty` into the root `node_modules/`, which + * electron-builder's default file collector (when `files:` is explicitly set + * in package.json) cannot reach. The result: packaged builds ship with no + * .node binaries and PTY initialization fails at runtime ("PTY support is + * unavailable"). * * Rather than restructure the workspace dedup (would require nohoist / * package.json shenanigans and risk breaking dev) or balloon the package @@ -18,6 +18,14 @@ * * Runs as part of `npm run build`. Idempotent -- always re-stages on each * build to pick up native binary updates. + * + * Layout note: upstream node-pty (microsoft/node-pty 1.x) is N-API based + * and ships its prebuilts under `prebuilds/-/` instead of + * `build/Release/`. Its runtime resolver (lib/utils.js) checks + * build/Release first and falls through to the per-arch prebuilds dir, so + * shipping only the latter is sufficient for packaged runs. Per-arch + * staging keeps the resource bundle lean -- we only need the target + * arch's prebuilt, not all of them. */ const fs = require('node:fs') @@ -27,18 +35,33 @@ const APP_ROOT = path.resolve(__dirname, '..') const REPO_ROOT = path.resolve(APP_ROOT, '..', '..') const STAGE_ROOT = path.join(APP_ROOT, 'build', 'native-deps') +// The target arch may be overridden by electron-builder via npm_config_arch +// (e.g. `npm run dist -- --arm64`); fall back to the build host's arch. +const TARGET_ARCH = process.env.npm_config_arch || process.arch +const TARGET_PLATFORM = process.platform + // Modules to stage. The "from" path is the hoisted location in the workspace // root; "to" is the layout we want inside build/native-deps/. The "include" // globs (relative to "from") select the runtime-essential files. Anything // outside the include list is left behind (source, deps/, scripts/, etc.). const NATIVE_DEPS = [ { - from: path.join(REPO_ROOT, 'node_modules', '@homebridge', 'node-pty-prebuilt-multiarch'), - to: path.join(STAGE_ROOT, '@homebridge', 'node-pty-prebuilt-multiarch'), + from: path.join(REPO_ROOT, 'node_modules', 'node-pty'), + to: path.join(STAGE_ROOT, 'node-pty'), include: [ 'package.json', - 'lib/**', - 'build/Release/*.node' + 'lib/*.js', + 'lib/**/*.js', + 'build/Release/*.node', + // Per-arch runtime payload. Explicit file types so we don't ship the + // ~25 MB of .pdb debug symbols that prebuild-install bundles for + // Windows crash analysis -- not used at runtime, would just bloat + // the installer. + `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.node`, + `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.dll`, + `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.exe`, + `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/spawn-helper`, + `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/conpty/*` ] } ] @@ -111,6 +134,15 @@ function stageOne(spec) { const dest = path.join(spec.to, rel) ensureDir(path.dirname(dest)) fs.copyFileSync(abs, dest) + // node-pty's darwin spawn-helper and the Windows helper binaries + // (OpenConsole.exe, winpty-agent.exe) are invoked via posix_spawn / + // CreateProcess at runtime, so they must remain executable in the + // staged tree. fs.copyFileSync preserves source mode on POSIX, but we + // re-assert +x defensively for the darwin spawn-helper (no extension + // means a stripped mode would be silently broken at runtime). + if (path.basename(rel) === 'spawn-helper' && process.platform !== 'win32') { + try { fs.chmodSync(dest, 0o755) } catch { /* best-effort */ } + } copied += 1 } console.log(`[stage-native-deps] ${path.relative(APP_ROOT, spec.to)}: ${copied} files`) diff --git a/apps/desktop/scripts/test-desktop.mjs b/apps/desktop/scripts/test-desktop.mjs index 161664d5c9d..fdff1523f8f 100644 --- a/apps/desktop/scripts/test-desktop.mjs +++ b/apps/desktop/scripts/test-desktop.mjs @@ -84,16 +84,16 @@ function exists(target) { } // Match nodepty native binding location to what main.cjs's resolver fallback -// expects (apps/desktop/electron/main.cjs, packaged-build branch). +// expects (apps/desktop/electron/main.cjs, packaged-build branch). Upstream +// node-pty 1.x is N-API based and ships per-arch prebuilts under +// prebuilds/-/ instead of build/Release/. We check the +// per-arch dir since that's what stage-native-deps actually copies. function expectedNativeDepPaths() { - const root = path.join(APP.resourcesPath, 'native-deps', '@homebridge', 'node-pty-prebuilt-multiarch') - const releaseDir = path.join(root, 'build', 'Release') - // Just check the package.json exists; the actual .node binary names vary - // (pty.node + conpty.node on Windows; pty.node on Unix), so we let the - // existence-of-the-directory + a non-empty list be enough. + const root = path.join(APP.resourcesPath, 'native-deps', 'node-pty') + const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`) return { packageJson: path.join(root, 'package.json'), - releaseDir, + prebuildsDir, libIndex: path.join(root, 'lib', 'index.js') } } @@ -325,12 +325,20 @@ function validateBundle() { if (!exists(native.libIndex)) { die(`Missing node-pty lib/index.js in resources/native-deps: ${native.libIndex}`) } - if (!exists(native.releaseDir)) { - die(`Missing node-pty build/Release directory: ${native.releaseDir}`) + if (!exists(native.prebuildsDir)) { + die(`Missing node-pty prebuilds dir for ${PLATFORM}-${ARCH}: ${native.prebuildsDir}`) } - const nodeBinaries = fs.readdirSync(native.releaseDir).filter(name => name.endsWith('.node')) + const nodeBinaries = fs.readdirSync(native.prebuildsDir).filter(name => name.endsWith('.node')) if (nodeBinaries.length === 0) { - die(`No .node native binaries found in: ${native.releaseDir}`) + die(`No .node native binaries found in: ${native.prebuildsDir}`) + } + // Darwin requires a runtime-execed spawn-helper alongside pty.node; missing + // it manifests as "ENOENT: spawn-helper" on first pty.spawn() call. + if (PLATFORM === 'darwin') { + const spawnHelper = path.join(native.prebuildsDir, 'spawn-helper') + if (!exists(spawnHelper)) { + die(`Missing node-pty spawn-helper (required on darwin): ${spawnHelper}`) + } } // Renderer payload check (either unpacked or in the asar) diff --git a/package-lock.json b/package-lock.json index 03da2ef635e..3b1d30d2b6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,6 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hermes/shared": "file:../shared", - "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@nanostores/react": "^1.1.0", "@nous-research/ui": "^0.13.0", "@radix-ui/react-slot": "^1.2.4", @@ -100,6 +99,7 @@ "leva": "^0.10.1", "motion": "^12.38.0", "nanostores": "^1.3.0", + "node-pty": "1.1.0", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-arborist": "^3.5.0", @@ -2642,24 +2642,6 @@ "resolved": "apps/shared", "link": true }, - "node_modules/@homebridge/node-pty-prebuilt-multiarch": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz", - "integrity": "sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==", - "hasInstallScript": true, - "dependencies": { - "node-addon-api": "^7.1.0", - "prebuild-install": "^7.1.2" - }, - "engines": { - "node": ">=18.0.0 <25.0.0" - } - }, - "node_modules/@homebridge/node-pty-prebuilt-multiarch/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -17255,6 +17237,22 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",