mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
fix(emulator): take serve-sim 0.1.47 so the iOS simulator works on Xcode 27 (#24228)
* fix(emulator): take serve-sim 0.1.47 so the iOS simulator works on Xcode 27 serve-sim 0.1.40's helper binary hard-linked SimulatorKit at its pre-Xcode 27 path, so every iOS emulator start failed with a dyld error on Xcode 27. 0.1.47 replaces that binary with a napi addon that finds SimulatorKit in either location, and runs the stream helper as a node process. Match the new helper process (serve-sim.js ... --exit-on-simulator-shutdown) while still matching legacy serve-sim-bin helpers left by an older Orca, and mark the package's new standalone helpers executable. * refactor(emulator): drop redundant serve-sim chmod; the package already ships helpers executable serve-sim publishes its helpers as 755 and pnpm, fs.cpSync and electron-builder all preserve the mode, so the executables list and every chmod over it are dead. * fix(emulator): give the materialized serve-sim runtime its node dependencies serve-sim 0.1.47's entry imports `ws`. Packaged macOS builds run serve-sim from a copy under userData, and that copy had no node_modules, so every iOS emulator attach failed with ERR_MODULE_NOT_FOUND. Dev builds were unaffected because they run serve-sim straight from pnpm's node_modules. Lay the copy out as <version>/node_modules/serve-sim and link each dependency the package declares to the bundle's installed sibling, so transitive deps keep resolving from the bundle. A runtime in the old flat layout, or one whose links dangle because the app moved, is rebuilt instead of reused.
This commit is contained in:
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Generated
+12
-6
@@ -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:
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string> {
|
||||
})
|
||||
}
|
||||
|
||||
// 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 })
|
||||
|
||||
@@ -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<string> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: <version>/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 {
|
||||
|
||||
Reference in New Issue
Block a user