diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 0b15e696f46..a5bf2731bd3 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -407,7 +407,6 @@ module.exports = { verifyPackagedPluginResources(resourcesDir) finalizePackagedRipgrep(resourcesDir) chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) - chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { if (!filename.startsWith('agent-browser-')) { continue @@ -718,23 +717,6 @@ function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) { } } -function chmodMacServeSimHelpers(resourcesDir, electronPlatformName) { - if (electronPlatformName !== 'darwin') { - return - } - const helperPaths = [ - join(resourcesDir, 'serve-sim', 'bin', 'serve-sim-bin'), - join(resourcesDir, 'serve-sim', 'dist', 'simcam', 'serve-sim-camera-helper'), - join(resourcesDir, 'node_modules', 'serve-sim', 'bin', 'serve-sim-bin'), - join(resourcesDir, 'node_modules', 'serve-sim', 'dist', 'simcam', 'serve-sim-camera-helper') - ] - for (const helperPath of helperPaths) { - if (existsSync(helperPath)) { - chmodSync(helperPath, 0o755) - } - } -} - async function signMacComputerUseHelper(helperAppPath, packager) { if (!existsSync(helperAppPath)) { if (isMacRelease) { diff --git a/package.json b/package.json index 0b41ed3980a..7e342f2f358 100644 --- a/package.json +++ b/package.json @@ -191,7 +191,7 @@ "proper-lockfile": "4.1.2", "qrcode": "^1.5.4", "react-i18next": "17.0.15", - "serve-sim": "0.1.40", + "serve-sim": "0.1.47", "sherpa-onnx": "1.12.37", "ssh2": "^1.17.0", "tldts": "7.4.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b5c4b890d4..17332ca0bb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,8 +176,8 @@ importers: specifier: 17.0.15 version: 17.0.15(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) serve-sim: - specifier: 0.1.40 - version: 0.1.40(typescript@7.0.2) + specifier: 0.1.47 + version: 0.1.47(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) sherpa-onnx: specifier: 1.12.37 version: 1.12.37 @@ -6930,9 +6930,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} - serve-sim@0.1.40: - resolution: {integrity: sha512-32JSUriN/EnNR3zx6fmiOKjuFrbA8VHWR2J3dX8T51Z/xkSmrSLOwaz/3W9lPiCIaF2O3LT4qpAhLkp0f1kVaw==} - engines: {node: '>=18'} + serve-sim@0.1.47: + resolution: {integrity: sha512-zDibExbPY6x6Oo173vwtx6zrvVG3464ReC6Q+DxlLqzhL5gmPVJWBwXSh5/BpaYI4PzszaRwL3N47Vjc3lDy9w==} + engines: {node: '>=20'} hasBin: true serve-static@2.2.1: @@ -14368,11 +14368,17 @@ snapshots: type-fest: 0.13.1 optional: true - serve-sim@0.1.40(typescript@7.0.2): + serve-sim@0.1.47(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2): dependencies: inspect-webkit: 0.0.5(typescript@7.0.2) + sonner: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + ws: 8.21.3 transitivePeerDependencies: + - bufferutil + - react + - react-dom - typescript + - utf-8-validate serve-static@2.2.1(supports-color@7.2.0): dependencies: diff --git a/src/main/emulator/serve-sim-execution.ts b/src/main/emulator/serve-sim-execution.ts index 08e5e1c1984..99ceeb31044 100644 --- a/src/main/emulator/serve-sim-execution.ts +++ b/src/main/emulator/serve-sim-execution.ts @@ -1,13 +1,5 @@ import { runProcess } from '../../shared/child-process/run-process' -import { - accessSync, - chmodSync, - constants, - existsSync, - mkdirSync, - readFileSync, - writeFileSync -} from 'node:fs' +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { app } from 'electron' import { platform, tmpdir } from 'node:os' import { delimiter, join } from 'node:path' @@ -125,14 +117,6 @@ export function resolveServeSimExecutable(): ServeSimExecutable { const nodeModulesPackageDir = join(app.getAppPath(), 'node_modules', 'serve-sim') const nodeModulesEntry = join(nodeModulesPackageDir, 'dist', 'serve-sim.js') if (existsSync(nodeModulesEntry)) { - const helperBin = join(nodeModulesPackageDir, 'bin', 'serve-sim-bin') - if (existsSync(helperBin) && process.platform !== 'win32') { - try { - accessSync(helperBin, constants.X_OK) - } catch { - chmodSync(helperBin, 0o755) - } - } return { command: process.execPath, baseArgs: [nodeModulesEntry], usesElectronAsNode: true } } diff --git a/src/main/emulator/serve-sim-helper-processes.test.ts b/src/main/emulator/serve-sim-helper-processes.test.ts index ef9673e2700..275961a9057 100644 --- a/src/main/emulator/serve-sim-helper-processes.test.ts +++ b/src/main/emulator/serve-sim-helper-processes.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { parseServeSimHelperProcesses } from './serve-sim-helper-processes' describe('parseServeSimHelperProcesses', () => { - it('returns exact serve-sim-bin helper processes from ps output', () => { + it('returns legacy serve-sim-bin helper processes from ps output', () => { const psOutput = ` 101 /Applications/serve-sim/bin/serve-sim-bin UDID-1 --port 3100 102 /Applications/serve-sim/bin/serve-sim UDID-1 --port 3100 @@ -22,6 +22,29 @@ describe('parseServeSimHelperProcesses', () => { ]) }) + it('returns node-hosted serve-sim helpers and skips one-shot serve-sim commands', () => { + const runtime = '/Users/me/Library/Application Support/Orca/serve-sim-runtime/1.4.0' + const psOutput = ` + 301 /Applications/Orca.app/Contents/MacOS/Orca ${runtime}/dist/serve-sim.js UDID-1 --port 3100 --host 127.0.0.1 --exit-on-simulator-shutdown + 302 /Applications/Orca.app/Contents/MacOS/Orca ${runtime}/dist/serve-sim.js tap 0.5 0.5 -d UDID-1 + 303 /Applications/Orca.app/Contents/MacOS/Orca ${runtime}/dist/serve-sim.js --detach -q UDID-1 + 304 /usr/local/bin/serve-sim UDID-2 --port 3101 --host 127.0.0.1 --exit-on-simulator-shutdown + 305 node /tmp/not-serve-sim.js UDID-3 --exit-on-simulator-shutdown + ` + + expect(parseServeSimHelperProcesses(psOutput)).toEqual([ + { + pid: 301, + command: `/Applications/Orca.app/Contents/MacOS/Orca ${runtime}/dist/serve-sim.js UDID-1 --port 3100 --host 127.0.0.1 --exit-on-simulator-shutdown` + }, + { + pid: 304, + command: + '/usr/local/bin/serve-sim UDID-2 --port 3101 --host 127.0.0.1 --exit-on-simulator-shutdown' + } + ]) + }) + it('scans ps output without line-array splitting', () => { const splitSpy = vi.spyOn(String.prototype, 'split') try { diff --git a/src/main/emulator/serve-sim-helper-processes.ts b/src/main/emulator/serve-sim-helper-processes.ts index 9bcf14b6d9b..77ea809f6ef 100644 --- a/src/main/emulator/serve-sim-helper-processes.ts +++ b/src/main/emulator/serve-sim-helper-processes.ts @@ -8,6 +8,8 @@ export type ServeSimHelperProcess = { command: string } +const SERVE_SIM_DETACHED_HELPER_FLAG = '--exit-on-simulator-shutdown' + type ServeSimHelperProcessLookupOptions = { helperPid?: number includeOrphaned?: boolean @@ -25,6 +27,17 @@ function execFileText(command: string, args: string[]): Promise { }) } +// Why: serve-sim >= 0.1.47 hosts the helper in node; keep serve-sim-bin so a pre-update helper is still reaped. +function isServeSimHelperCommand(command: string): boolean { + if (/(^|\/)serve-sim-bin(?:\s|$)/.test(command)) { + return true + } + return ( + commandContainsToken(command, SERVE_SIM_DETACHED_HELPER_FLAG) && + /(^|\/)serve-sim(?:\.js)?\s/.test(command) + ) +} + export function parseServeSimHelperProcesses(psOutput: string): ServeSimHelperProcess[] { const helpers: ServeSimHelperProcess[] = [] for (const line of iterateProcessOutputLines(psOutput)) { @@ -34,7 +47,7 @@ export function parseServeSimHelperProcesses(psOutput: string): ServeSimHelperPr } const pid = Number(match[1]) const command = match[2] ?? '' - if (!Number.isInteger(pid) || !/(^|\/)serve-sim-bin(?:\s|$)/.test(command)) { + if (!Number.isInteger(pid) || !isServeSimHelperCommand(command)) { continue } helpers.push({ pid, command }) diff --git a/src/main/emulator/serve-sim-runtime-materializer.test.ts b/src/main/emulator/serve-sim-runtime-materializer.test.ts index 538cc833bbc..1b8d3b6182f 100644 --- a/src/main/emulator/serve-sim-runtime-materializer.test.ts +++ b/src/main/emulator/serve-sim-runtime-materializer.test.ts @@ -1,27 +1,48 @@ -import { mkdirSync, writeFileSync } from 'node:fs' -import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { cpSync, mkdirSync, readdirSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs' +import { + mkdtemp, + mkdir, + readFile, + readdir, + readlink, + rename, + rm, + stat, + writeFile +} from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' import { materializeServeSimRuntime } from './serve-sim-runtime-materializer' const DYLIB_CONTENT = Buffer.from('signed-simcam-dylib-mach-o-bytes') async function createBundledServeSimPackage(root: string): Promise { - const packageDir = join(root, 'bundled-serve-sim') + const nodeModulesDir = join(root, 'bundle', 'node_modules') + const packageDir = join(nodeModulesDir, 'serve-sim') await mkdir(join(packageDir, 'dist', 'simcam'), { recursive: true }) - await mkdir(join(packageDir, 'bin'), { recursive: true }) + await mkdir(join(nodeModulesDir, 'ws'), { recursive: true }) + await writeFile(join(nodeModulesDir, 'ws', 'package.json'), '{"name":"ws"}') + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ name: 'serve-sim', dependencies: { ws: '^8', 'not-bundled': '^1' } }) + ) await writeFile(join(packageDir, 'dist', 'serve-sim.js'), 'console.log("serve-sim")') await writeFile(join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib'), DYLIB_CONTENT, { mode: 0o644 }) await writeFile(join(packageDir, 'dist', 'simcam', 'serve-sim-camera-helper'), 'helper', { - mode: 0o644 + mode: 0o755 }) - await writeFile(join(packageDir, 'bin', 'serve-sim-bin'), 'bin', { mode: 0o644 }) return packageDir } +function symlinkDir(target: string, path: string): void { + // 'junction' lets Windows create the link without symlink privilege; POSIX ignores it. + symlinkSync(target, path, 'junction') +} + describe('materializeServeSimRuntime', () => { const cleanupPaths: string[] = [] @@ -49,19 +70,15 @@ describe('materializeServeSimRuntime', () => { clearQuarantine }) - expect(materialized).toBe(join(root, 'runtime', '1.2.3')) + expect(materialized).toBe(join(root, 'runtime', '1.2.3', 'node_modules', 'serve-sim')) // The dylib must be byte-identical to the bundled (Developer-ID-signed) copy. const dylibPath = join(materialized!, 'dist', 'simcam', 'libSimCameraInjector.dylib') expect(await readFile(dylibPath)).toEqual(DYLIB_CONTENT) expect(clearQuarantine).toHaveBeenCalledTimes(1) expect(clearQuarantine).toHaveBeenCalledWith(expect.stringContaining('.staging-1.2.3-')) if (process.platform !== 'win32') { - for (const executable of [ - join(materialized!, 'bin', 'serve-sim-bin'), - join(materialized!, 'dist', 'simcam', 'serve-sim-camera-helper') - ]) { - expect(((await stat(executable)).mode & 0o111) !== 0).toBe(true) - } + const helper = join(materialized!, 'dist', 'simcam', 'serve-sim-camera-helper') + expect(((await stat(helper)).mode & 0o111) !== 0).toBe(true) } }) @@ -97,7 +114,7 @@ describe('materializeServeSimRuntime', () => { clearQuarantine: () => {} }) - expect(materialized).toBe(join(targetRootDir, '1.2.3')) + expect(materialized).toBe(join(targetRootDir, '1.2.3', 'node_modules', 'serve-sim')) await expect(stat(join(targetRootDir, '1.0.0'))).rejects.toThrow() }) @@ -105,22 +122,24 @@ describe('materializeServeSimRuntime', () => { const root = await createRoot() const bundledPackageDir = await createBundledServeSimPackage(root) const targetRootDir = join(root, 'runtime') - const targetDir = join(targetRootDir, '1.2.3') + const winnerNodeModulesDir = join(targetRootDir, '1.2.3', 'node_modules') + const winnerPackageDir = join(winnerNodeModulesDir, 'serve-sim') // Simulate another instance finishing first: right before our rename, drop a - // complete target dir in place so renameSync fails but the entry exists. + // complete target dir in place so renameSync fails but the runtime is current. const materialized = materializeServeSimRuntime({ bundledPackageDir, targetRootDir, version: '1.2.3', clearQuarantine: () => { - mkdirSync(join(targetDir, 'dist'), { recursive: true }) - writeFileSync(join(targetDir, 'dist', 'serve-sim.js'), 'winner') + mkdirSync(join(winnerPackageDir, 'dist'), { recursive: true }) + writeFileSync(join(winnerPackageDir, 'dist', 'serve-sim.js'), 'winner') + symlinkDir(join(root, 'bundle', 'node_modules', 'ws'), join(winnerNodeModulesDir, 'ws')) } }) - expect(materialized).toBe(targetDir) - expect(await readFile(join(targetDir, 'dist', 'serve-sim.js'), 'utf8')).toBe('winner') + expect(materialized).toBe(winnerPackageDir) + expect(await readFile(join(winnerPackageDir, 'dist', 'serve-sim.js'), 'utf8')).toBe('winner') const leftovers = (await readdir(targetRootDir)).filter((name) => name.startsWith('.staging')) expect(leftovers).toEqual([]) }) @@ -157,4 +176,121 @@ describe('materializeServeSimRuntime', () => { expect(materialized).toBeNull() }) + + it('links each bundled dependency next to the copy and skips ones the bundle lacks', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + + const materialized = materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir: join(root, 'runtime'), + version: '1.2.3', + clearQuarantine: () => {} + }) + + const nodeModulesDir = join(root, 'runtime', '1.2.3', 'node_modules') + expect(materialized).toBe(join(nodeModulesDir, 'serve-sim')) + expect(realpathSync(join(nodeModulesDir, 'ws'))).toBe( + realpathSync(join(root, 'bundle', 'node_modules', 'ws')) + ) + expect((await readdir(nodeModulesDir)).sort()).toEqual(['serve-sim', 'ws']) + }) + + it('clears quarantine on the copied package before linking into the bundle', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + const seenEntries: string[][] = [] + + materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir: join(root, 'runtime'), + version: '1.2.3', + clearQuarantine: (dir) => { + seenEntries.push([dir, ...readdirSync(join(dir, '..'))]) + } + }) + + expect(seenEntries).toHaveLength(1) + expect(seenEntries[0][0]).toMatch(/\.staging-1\.2\.3-\d+[/\\]node_modules[/\\]serve-sim$/) + expect(seenEntries[0].slice(1)).toEqual(['serve-sim']) + }) + + it('rebuilds a same-version runtime left in the old flat layout', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + const targetRootDir = join(root, 'runtime') + await mkdir(join(targetRootDir, '1.2.3', 'dist'), { recursive: true }) + await writeFile(join(targetRootDir, '1.2.3', 'dist', 'serve-sim.js'), 'old layout') + + const materialized = materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir, + version: '1.2.3', + clearQuarantine: () => {} + }) + + expect(materialized).toBe(join(targetRootDir, '1.2.3', 'node_modules', 'serve-sim')) + await expect(stat(join(targetRootDir, '1.2.3', 'dist'))).rejects.toThrow() + }) + + it('rebuilds when the app moved and the dependency links dangle', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + const targetRootDir = join(root, 'runtime') + materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir, + version: '1.2.3', + clearQuarantine: () => {} + }) + await rename(join(root, 'bundle'), join(root, 'moved-bundle')) + const movedPackageDir = join(root, 'moved-bundle', 'node_modules', 'serve-sim') + + const materialized = materializeServeSimRuntime({ + bundledPackageDir: movedPackageDir, + targetRootDir, + version: '1.2.3', + clearQuarantine: () => {} + }) + + expect(materialized).toBe(join(targetRootDir, '1.2.3', 'node_modules', 'serve-sim')) + expect(await readlink(join(targetRootDir, '1.2.3', 'node_modules', 'ws'))).toContain( + 'moved-bundle' + ) + }) + + // Regression: serve-sim 0.1.47's ESM entry imports `ws`, which a bare package copy cannot resolve. + it('lets the real serve-sim entry resolve every declared dependency from the copy', async () => { + const root = await createRoot() + const installedPackageDir = realpathSync(join(process.cwd(), 'node_modules', 'serve-sim')) + const manifest = JSON.parse(await readFile(join(installedPackageDir, 'package.json'), 'utf8')) + const dependencyNames = Object.keys(manifest.dependencies ?? {}) + expect(dependencyNames).toContain('ws') + // Mirror the packaged app: a real serve-sim dir with its dependencies hoisted beside it. + const bundledNodeModulesDir = join(root, 'Resources', 'node_modules') + const bundledPackageDir = join(bundledNodeModulesDir, 'serve-sim') + cpSync(installedPackageDir, bundledPackageDir, { recursive: true }) + for (const name of dependencyNames) { + symlinkDir(join(installedPackageDir, '..', name), join(bundledNodeModulesDir, name)) + } + + const materialized = materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir: join(root, 'runtime'), + version: '1.2.3', + clearQuarantine: () => {} + }) + expect(materialized).not.toBeNull() + + // Probe from the entry's own directory so ESM resolution walks up exactly as serve-sim.js does. + const probePath = join(materialized!, 'dist', 'orca-dependency-probe.mjs') + await writeFile( + probePath, + `for (const name of ${JSON.stringify(dependencyNames)}) import.meta.resolve(name)\n` + ) + const result = await runProcess({ program: process.execPath, args: [probePath] }) + + expect(result.stderr).toBe('') + expect(result.code).toBe(0) + }) }) diff --git a/src/main/emulator/serve-sim-runtime-materializer.ts b/src/main/emulator/serve-sim-runtime-materializer.ts index 26abff4a6be..a890fe25078 100644 --- a/src/main/emulator/serve-sim-runtime-materializer.ts +++ b/src/main/emulator/serve-sim-runtime-materializer.ts @@ -1,6 +1,16 @@ import { execFileSync } from 'node:child_process' -import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs' -import { join } from 'node:path' +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync +} from 'node:fs' +import { dirname, join } from 'node:path' export type ServeSimRuntimeMaterializerOptions = { bundledPackageDir: string @@ -9,11 +19,6 @@ export type ServeSimRuntimeMaterializerOptions = { clearQuarantine?: (dir: string) => void } -const EXECUTABLE_RELATIVE_PATHS = [ - join('bin', 'serve-sim-bin'), - join('dist', 'simcam', 'serve-sim-camera-helper') -] - function defaultClearQuarantine(dir: string): void { if (process.platform !== 'darwin') { return @@ -47,45 +52,89 @@ function pruneStaleServeSimRuntimes(targetRootDir: string, keepVersion: string): } } +function readDependencyNames(packageDir: string): string[] { + const manifest: unknown = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) + if (!manifest || typeof manifest !== 'object' || !('dependencies' in manifest)) { + return [] + } + const { dependencies } = manifest + return dependencies && typeof dependencies === 'object' ? Object.keys(dependencies) : [] +} + +// Why: the copy has no node_modules of its own, so its bare imports (e.g. `ws`) must resolve +// through links to the bundle's installed siblings; a dangling link (moved or translocated app) is stale. +function bundledDependencyLinks( + bundledPackageDir: string, + runtimeNodeModulesDir: string +): { linkPath: string; targetPath: string }[] { + const bundledNodeModulesDir = dirname(realpathSync(bundledPackageDir)) + return readDependencyNames(bundledPackageDir) + .map((name) => ({ + linkPath: join(runtimeNodeModulesDir, name), + targetPath: join(bundledNodeModulesDir, name) + })) + .filter(({ targetPath }) => existsSync(targetPath)) +} + +function isMaterializedRuntimeCurrent(bundledPackageDir: string, versionDir: string): boolean { + const nodeModulesDir = join(versionDir, 'node_modules') + if (!existsSync(join(nodeModulesDir, 'serve-sim', 'dist', 'serve-sim.js'))) { + return false + } + try { + return bundledDependencyLinks(bundledPackageDir, nodeModulesDir).every( + ({ linkPath, targetPath }) => realpathSync(linkPath) === realpathSync(targetPath) + ) + } catch { + return false + } +} + // Copies the bundled serve-sim package to a per-version directory outside the // signed app bundle and strips quarantine, so the camera dylib injected from // it is not subject to Gatekeeper assessment. The bundled dylib stays signed // and in place (it must, or the app fails notarization) — this only relocates // the copy that actually gets DYLD-injected. serve-sim resolves the dylib and // helper relative to its own entry, so the whole package moves together. +// Layout: /node_modules/serve-sim plus links to its bundled dependencies. export function materializeServeSimRuntime( options: ServeSimRuntimeMaterializerOptions ): string | null { const { bundledPackageDir, targetRootDir, version } = options const clearQuarantine = options.clearQuarantine ?? defaultClearQuarantine const targetDir = join(targetRootDir, version) - const entryPath = join(targetDir, 'dist', 'serve-sim.js') - if (existsSync(entryPath)) { - return targetDir + const packageDir = join(targetDir, 'node_modules', 'serve-sim') + if (isMaterializedRuntimeCurrent(bundledPackageDir, targetDir)) { + return packageDir } const stagingDir = join(targetRootDir, `.staging-${version}-${process.pid}`) + const stagingNodeModulesDir = join(stagingDir, 'node_modules') try { mkdirSync(targetRootDir, { recursive: true }) pruneStaleServeSimRuntimes(targetRootDir, version) rmSync(stagingDir, { recursive: true, force: true }) rmSync(targetDir, { recursive: true, force: true }) - cpSync(bundledPackageDir, stagingDir, { recursive: true }) - for (const relativePath of EXECUTABLE_RELATIVE_PATHS) { - const executablePath = join(stagingDir, relativePath) - if (existsSync(executablePath)) { - chmodSync(executablePath, 0o755) - } + const stagingPackageDir = join(stagingNodeModulesDir, 'serve-sim') + // Why realpath: cpSync copies a symlinked package dir (pnpm layout) as a link back into the bundle. + cpSync(realpathSync(bundledPackageDir), stagingPackageDir, { recursive: true }) + // Why before linking: the recursive xattr walk must not reach into the signed bundle. + clearQuarantine(stagingPackageDir) + for (const { linkPath, targetPath } of bundledDependencyLinks( + bundledPackageDir, + stagingNodeModulesDir + )) { + mkdirSync(dirname(linkPath), { recursive: true }) + symlinkSync(targetPath, linkPath, 'junction') } - clearQuarantine(stagingDir) try { renameSync(stagingDir, targetDir) } catch (error) { // Another app instance sharing userData may have finished first. - if (!existsSync(entryPath)) { + if (!isMaterializedRuntimeCurrent(bundledPackageDir, targetDir)) { throw error } } - return existsSync(entryPath) ? targetDir : null + return isMaterializedRuntimeCurrent(bundledPackageDir, targetDir) ? packageDir : null } catch { return null } finally {