build(desktop): stage get-windows like node-pty

get-windows@9.3.0 (MIT, zero runtime deps on macOS/Linux) is external to the
esbuild bundle and staged into dist/node_modules per target platform: the
universal Swift helper on macOS, the prebuilt N-API binding on Windows
(fail-closed magic-byte validation), nothing on Linux (xprop at runtime).
The staged lib/windows.js is rewritten to load the binding directly so
@mapbox/node-pre-gyp's tree stays out of the package.
This commit is contained in:
Brooklyn Nicholson
2026-08-07 22:27:42 -05:00
committed by brooklyn!
parent f22ae72921
commit f463a7e8ee
5 changed files with 1818 additions and 34 deletions
+1
View File
@@ -104,6 +104,7 @@
"emojibase-data": "16.0.3", "emojibase-data": "16.0.3",
"fflate": "0.8.3", "fflate": "0.8.3",
"frimousse": "0.3.0", "frimousse": "0.3.0",
"get-windows": "9.3.0",
"hast-util-from-html-isomorphic": "2.0.0", "hast-util-from-html-isomorphic": "2.0.0",
"hast-util-to-text": "4.0.2", "hast-util-to-text": "4.0.2",
"ignore": "7.0.6", "ignore": "7.0.6",
+7 -2
View File
@@ -60,7 +60,7 @@
import { existsSync, rmSync, renameSync } from 'node:fs' import { existsSync, rmSync, renameSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { Arch } from 'electron-builder' import { Arch } from 'electron-builder'
import { stageNodePty } from './stage-native-deps.mjs' import { stageNodePty, stageGetWindows } from './stage-native-deps.mjs'
export function cleanStaleAppOutDir(appOutDir) { export function cleanStaleAppOutDir(appOutDir) {
if (!appOutDir || typeof appOutDir !== 'string') { if (!appOutDir || typeof appOutDir !== 'string') {
@@ -144,11 +144,16 @@ export default async function beforePack(context) {
await stageNodePty({ platform, arch: archName }) await stageNodePty({ platform, arch: archName })
console.log(`[before-pack] re-staged node-pty for target ${platform}-${archName}`) console.log(`[before-pack] re-staged node-pty for target ${platform}-${archName}`)
} }
// get-windows' native payload is per-platform, not per-arch (the macOS
// helper is universal, Windows stages every prebuilt binding dir), so
// it re-stages for the universal target too.
stageGetWindows({ platform })
console.log(`[before-pack] re-staged get-windows for target ${platform}`)
} }
} catch (err) { } catch (err) {
// This one SHOULD fail the build — a missing/wrong native binary for the // This one SHOULD fail the build — a missing/wrong native binary for the
// target arch means a broken package shipped to users, which is worse // target arch means a broken package shipped to users, which is worse
// than a build that fails loudly here. // than a build that fails loudly here.
throw new Error(`[before-pack] failed to stage node-pty for this target: ${err.message}`) throw new Error(`[before-pack] failed to stage native deps for this target: ${err.message}`)
} }
} }
@@ -24,7 +24,7 @@ const mainOut = resolve(distDir, 'electron-main.mjs')
const preloadEntry = resolve(root, 'electron/preload.ts') const preloadEntry = resolve(root, 'electron/preload.ts')
const preloadOut = resolve(distDir, 'electron-preload.js') const preloadOut = resolve(distDir, 'electron-preload.js')
const external = ['electron', 'node-pty', 'fs'] const external = ['electron', 'node-pty', 'get-windows', 'fs']
// Production bundles bake packaged=true so unpackaged `electron .` still // Production bundles bake packaged=true so unpackaged `electron .` still
// behaves like a packaged build. Dev bundles (`--dev`) leave the env alone // behaves like a packaged build. Dev bundles (`--dev`) leave the env alone
// so HERMES_DESKTOP_DEV_SERVER / source-tree resolution keep working. // so HERMES_DESKTOP_DEV_SERVER / source-tree resolution keep working.
+134
View File
@@ -349,8 +349,142 @@ export function stageNodePty({ platform = process.platform, arch = process.arch
return stageNodePtyInto(srcRoot, destRoot, { platform, arch }) return stageNodePtyInto(srcRoot, destRoot, { platform, arch })
} }
// ─── get-windows (read_window_below tool) ────────────────────────────
//
// Staged like node-pty: external to the esbuild bundle, resolved at runtime
// from dist/node_modules (which asarUnpack ships unpacked via `dist/**`).
//
// The published package's lib/windows.js statically imports
// @mapbox/node-pre-gyp — and index.js statically imports lib/windows.js on
// EVERY platform — so shipping it verbatim would drag node-pre-gyp's whole
// dependency tree into the package. pre-gyp is only used to *locate* the
// prebuilt .node we stage ourselves, so the staged copy replaces
// lib/windows.js with a resolver that requires the staged binding directly
// (and fails soft to no-op stubs, matching upstream's missing-binding
// behavior).
const STAGED_WINDOWS_JS = `// Rewritten by stage-native-deps.mjs: resolves the staged prebuilt binding
// directly instead of through @mapbox/node-pre-gyp (see stageGetWindowsInto).
import path from 'node:path';
import fs from 'node:fs';
import {fileURLToPath} from 'node:url';
import {createRequire} from 'node:module';
const getAddon = () => {
\tconst require = createRequire(import.meta.url);
\tconst bindingRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), 'binding');
\ttry {
\t\tfor (const dir of fs.readdirSync(bindingRoot)) {
\t\t\tconst bindingPath = path.join(bindingRoot, dir, 'node-get-windows.node');
\t\t\tif (fs.existsSync(bindingPath)) {
\t\t\t\treturn require(bindingPath);
\t\t\t}
\t\t}
\t} catch {}
\treturn {
\t\tgetActiveWindow() {},
\t\tgetOpenWindows() {},
\t};
};
export async function activeWindow() {
\treturn getAddon().getActiveWindow();
}
export function activeWindowSync() {
\treturn getAddon().getActiveWindow();
}
export function openWindows() {
\treturn getAddon().getOpenWindows();
}
export function openWindowsSync() {
\treturn getAddon().getOpenWindows();
}
`
function resolveGetWindowsRoot() {
// get-windows' exports map doesn't expose ./package.json; resolve the entry
// (index.js sits at the package root) and take its directory.
const entryPath = require.resolve('get-windows', {
paths: [projectRoot]
})
return dirname(entryPath)
}
/**
* Stage get-windows into `destRoot` for `platform`.
*
* Per-platform native payload: macOS ships the `main` Swift helper binary
* (universal, present in every published tarball), Windows the node-pre-gyp
* prebuilt under lib/binding (downloaded by the package's install script on
* a Windows host — cross-platform packs already can't happen, see
* stageNodePtyInto), Linux nothing (it shells out to xprop at runtime).
*/
export function stageGetWindowsInto(srcRoot, destRoot, { platform = process.platform } = {}) {
rmSync(destRoot, { recursive: true, force: true })
mkdirSync(destRoot, { recursive: true })
cpSync(join(srcRoot, 'package.json'), join(destRoot, 'package.json'))
cpSync(join(srcRoot, 'index.js'), join(destRoot, 'index.js'))
copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js'])
writeFileSync(join(destRoot, 'lib', 'windows.js'), STAGED_WINDOWS_JS)
if (platform === 'darwin') {
const helper = join(srcRoot, 'main')
if (!existsSync(helper)) {
throw new Error('[stage-native-deps] get-windows is missing its macOS helper binary (main)')
}
cpSync(helper, join(destRoot, 'main'))
makeExecutable(join(destRoot, 'main'))
}
if (platform === 'win32') {
const bindingRoot = join(srcRoot, 'lib', 'binding')
const bindingDirs = existsSync(bindingRoot)
? readdirSync(bindingRoot).filter((dir) =>
existsSync(join(bindingRoot, dir, 'node-get-windows.node'))
)
: []
if (bindingDirs.length === 0) {
throw new Error(
'[stage-native-deps] get-windows has no win32 prebuilt binding under lib/binding; ' +
'reinstall dependencies on the Windows build host.'
)
}
for (const dir of bindingDirs) {
const dest = join(destRoot, 'lib', 'binding', dir)
mkdirSync(dest, { recursive: true })
const destFile = join(dest, 'node-get-windows.node')
cpSync(join(bindingRoot, dir, 'node-get-windows.node'), destFile)
const classified = classifyNativeBinary(destFile)
if (classified !== platform) {
throw new Error(
`[stage-native-deps] get-windows binding ${dir}/node-get-windows.node: ` +
`expected ${platform}, got ${classified ?? 'unknown'}. ` +
'Refusing to stage a binary compiled for the wrong platform.'
)
}
}
}
console.log(`[stage-native-deps] staged get-windows (${platform}) -> ${destRoot}`)
return destRoot
}
export function stageGetWindows({ platform = process.platform } = {}) {
const srcRoot = resolveGetWindowsRoot()
const destRoot = resolve(projectRoot, 'dist/node_modules/get-windows')
return stageGetWindowsInto(srcRoot, destRoot, { platform })
}
// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch] // Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch]
if (isMain(import.meta.url)) { if (isMain(import.meta.url)) {
const [platform, arch] = process.argv.slice(2) const [platform, arch] = process.argv.slice(2)
stageNodePty({ platform, arch }) stageNodePty({ platform, arch })
stageGetWindows({ platform })
} }
+1675 -31
View File
File diff suppressed because it is too large Load Diff