Ship serve-sim camera dylib as data and materialize it at runtime (#7168)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
c06507ee30
commit
f44cb3059b
|
|
@ -7,6 +7,7 @@ const {
|
|||
prunePackagedRuntimeNodeModules,
|
||||
verifyPackagedMainRuntimeDeps
|
||||
} = require('./packaged-runtime-node-modules.cjs')
|
||||
const { compressMacServeSimCameraDylibs } = require('./serve-sim-camera-packaging.cjs')
|
||||
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
|
||||
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
|
||||
|
|
@ -131,6 +132,7 @@ module.exports = {
|
|||
verifyPackagedMainRuntimeDeps(resourcesDir)
|
||||
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
|
||||
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
|
||||
compressMacServeSimCameraDylibs(resourcesDir, context.electronPlatformName)
|
||||
for (const filename of readdirSync(resourcesDir)) {
|
||||
if (!filename.startsWith('agent-browser-')) {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { compressMacServeSimCameraDylibs } = require('../serve-sim-camera-packaging.cjs')
|
||||
|
||||
const DYLIB_CONTENT = Buffer.from('fake-ios-simulator-dylib')
|
||||
|
||||
async function createServeSimPackage(packageDir, { withSources = true } = {}) {
|
||||
await mkdir(join(packageDir, 'dist', 'simcam'), { recursive: true })
|
||||
await writeFile(join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib'), DYLIB_CONTENT)
|
||||
await writeFile(join(packageDir, 'dist', 'simcam', 'serve-sim-camera-helper'), 'helper')
|
||||
if (withSources) {
|
||||
await mkdir(join(packageDir, 'Sources', 'SimCameraInjector'), { recursive: true })
|
||||
await writeFile(join(packageDir, 'Sources', 'SimCameraInjector', 'build.sh'), 'echo build')
|
||||
}
|
||||
}
|
||||
|
||||
describe('compressMacServeSimCameraDylibs', () => {
|
||||
const cleanupPaths = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const path of cleanupPaths.splice(0)) {
|
||||
await rm(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function createResourcesDir() {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-simcam-packaging-'))
|
||||
cleanupPaths.push(resourcesDir)
|
||||
return resourcesDir
|
||||
}
|
||||
|
||||
it('replaces raw dylibs with gzip payloads in both packaged copies', async () => {
|
||||
const resourcesDir = await createResourcesDir()
|
||||
await createServeSimPackage(join(resourcesDir, 'serve-sim'))
|
||||
await createServeSimPackage(join(resourcesDir, 'node_modules', 'serve-sim'))
|
||||
|
||||
compressMacServeSimCameraDylibs(resourcesDir, 'darwin')
|
||||
|
||||
for (const packageDir of ['serve-sim', join('node_modules', 'serve-sim')]) {
|
||||
const dylibPath = join(
|
||||
resourcesDir,
|
||||
packageDir,
|
||||
'dist',
|
||||
'simcam',
|
||||
'libSimCameraInjector.dylib'
|
||||
)
|
||||
await expect(stat(dylibPath)).rejects.toThrow()
|
||||
expect(gunzipSync(await readFile(`${dylibPath}.gz`))).toEqual(DYLIB_CONTENT)
|
||||
const helperPath = join(resourcesDir, packageDir, 'dist', 'simcam', 'serve-sim-camera-helper')
|
||||
expect(await readFile(helperPath, 'utf8')).toBe('helper')
|
||||
}
|
||||
})
|
||||
|
||||
it('removes Sources so the build-from-source fallback cannot write into the sealed bundle', async () => {
|
||||
const resourcesDir = await createResourcesDir()
|
||||
await createServeSimPackage(join(resourcesDir, 'serve-sim'))
|
||||
|
||||
compressMacServeSimCameraDylibs(resourcesDir, 'darwin')
|
||||
|
||||
await expect(stat(join(resourcesDir, 'serve-sim', 'Sources'))).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws when a packaged copy is missing its dylib', async () => {
|
||||
const resourcesDir = await createResourcesDir()
|
||||
await createServeSimPackage(join(resourcesDir, 'serve-sim'))
|
||||
await rm(join(resourcesDir, 'serve-sim', 'dist', 'simcam', 'libSimCameraInjector.dylib'))
|
||||
|
||||
expect(() => compressMacServeSimCameraDylibs(resourcesDir, 'darwin')).toThrow(
|
||||
/camera dylib missing/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when no packaged serve-sim copy exists on darwin', async () => {
|
||||
const resourcesDir = await createResourcesDir()
|
||||
|
||||
expect(() => compressMacServeSimCameraDylibs(resourcesDir, 'darwin')).toThrow(
|
||||
/No packaged serve-sim copies/
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves non-darwin packaging untouched', async () => {
|
||||
const resourcesDir = await createResourcesDir()
|
||||
await createServeSimPackage(join(resourcesDir, 'serve-sim'))
|
||||
|
||||
compressMacServeSimCameraDylibs(resourcesDir, 'win32')
|
||||
compressMacServeSimCameraDylibs(resourcesDir, 'linux')
|
||||
|
||||
const dylibPath = join(
|
||||
resourcesDir,
|
||||
'serve-sim',
|
||||
'dist',
|
||||
'simcam',
|
||||
'libSimCameraInjector.dylib'
|
||||
)
|
||||
expect(await readFile(dylibPath)).toEqual(DYLIB_CONTENT)
|
||||
await expect(stat(join(resourcesDir, 'serve-sim', 'Sources'))).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
const { existsSync, readFileSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const { gzipSync } = require('node:zlib')
|
||||
|
||||
// Why: libSimCameraInjector.dylib targets the iOS-simulator platform, which
|
||||
// Apple's notary service never tickets for arm64 — a raw copy anywhere in the
|
||||
// bundle is permanently rejected by Gatekeeper assessment. Shipping it gzipped
|
||||
// makes it plain data (unsigned, unassessed); the app materializes a
|
||||
// quarantine-free copy at runtime before serve-sim injects it.
|
||||
function compressMacServeSimCameraDylibs(resourcesDir, electronPlatformName) {
|
||||
if (electronPlatformName !== 'darwin') {
|
||||
return
|
||||
}
|
||||
const packageDirs = [
|
||||
join(resourcesDir, 'serve-sim'),
|
||||
join(resourcesDir, 'node_modules', 'serve-sim')
|
||||
].filter((packageDir) => existsSync(packageDir))
|
||||
if (packageDirs.length === 0) {
|
||||
throw new Error(`No packaged serve-sim copies found under ${resourcesDir}`)
|
||||
}
|
||||
for (const packageDir of packageDirs) {
|
||||
const dylibPath = join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib')
|
||||
if (!existsSync(dylibPath)) {
|
||||
throw new Error(`Expected serve-sim camera dylib missing: ${dylibPath}`)
|
||||
}
|
||||
writeFileSync(`${dylibPath}.gz`, gzipSync(readFileSync(dylibPath)))
|
||||
rmSync(dylibPath)
|
||||
// Why: with the prebuilt dylib gone, serve-sim's build-from-source fallback
|
||||
// would compile into the sealed bundle and break its signature. Without
|
||||
// Sources the fallback fails with serve-sim's own reinstall error instead.
|
||||
rmSync(join(packageDir, 'Sources'), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { compressMacServeSimCameraDylibs }
|
||||
|
|
@ -12,6 +12,7 @@ import { app } from 'electron'
|
|||
import { platform, tmpdir } from 'node:os'
|
||||
import { delimiter, dirname, join } from 'node:path'
|
||||
import { EmulatorError } from './emulator-errors'
|
||||
import { materializeServeSimRuntime } from './serve-sim-runtime-materializer'
|
||||
|
||||
const EXEC_TIMEOUT_MS = 90_000
|
||||
const MAC_OPEN_SHIM_DIR = join(tmpdir(), 'orca-serve-sim-open-shim')
|
||||
|
|
@ -67,6 +68,27 @@ function getServeSimEnv(executable: ServeSimExecutable): NodeJS.ProcessEnv {
|
|||
return env
|
||||
}
|
||||
|
||||
// Cached per process: materialization is a one-time copy; later calls only stat.
|
||||
let materializedServeSimPackageDir: string | null | undefined
|
||||
|
||||
function resolveMaterializedServeSimPackageDir(bundledPackageDir: string): string | null {
|
||||
if (materializedServeSimPackageDir !== undefined) {
|
||||
return materializedServeSimPackageDir
|
||||
}
|
||||
materializedServeSimPackageDir = materializeServeSimRuntime({
|
||||
bundledPackageDir,
|
||||
targetRootDir: join(app.getPath('userData'), 'serve-sim-runtime'),
|
||||
version: app.getVersion()
|
||||
})
|
||||
if (materializedServeSimPackageDir === null) {
|
||||
console.warn(
|
||||
'[serve-sim] runtime materialization failed; running from the app bundle ' +
|
||||
'(camera injection may be blocked by Gatekeeper)'
|
||||
)
|
||||
}
|
||||
return materializedServeSimPackageDir
|
||||
}
|
||||
|
||||
export function resolveServeSimExecutable(): ServeSimExecutable {
|
||||
const bundledResourcesPath =
|
||||
process.resourcesPath ??
|
||||
|
|
@ -75,6 +97,21 @@ export function resolveServeSimExecutable(): ServeSimExecutable {
|
|||
: join(app.getPath('exe'), '..', 'resources'))
|
||||
const bundled = join(bundledResourcesPath, 'serve-sim', 'dist', 'serve-sim.js')
|
||||
if (existsSync(bundled)) {
|
||||
if (process.platform === 'darwin') {
|
||||
// Why: the macOS bundle ships the camera dylib gzipped (an iOS-simulator
|
||||
// binary Apple never notarizes), so serve-sim must run from a
|
||||
// quarantine-free materialized copy for camera injection to work.
|
||||
const materializedDir = resolveMaterializedServeSimPackageDir(
|
||||
join(bundledResourcesPath, 'serve-sim')
|
||||
)
|
||||
if (materializedDir) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
baseArgs: [join(materializedDir, 'dist', 'serve-sim.js')],
|
||||
usesElectronAsNode: true
|
||||
}
|
||||
}
|
||||
}
|
||||
return { command: process.execPath, baseArgs: [bundled], usesElectronAsNode: true }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { materializeServeSimRuntime } from './serve-sim-runtime-materializer'
|
||||
|
||||
const DYLIB_CONTENT = Buffer.from('fake-simcam-dylib-mach-o-bytes')
|
||||
|
||||
async function createBundledServeSimPackage(root: string): Promise<string> {
|
||||
const packageDir = join(root, 'bundled-serve-sim')
|
||||
await mkdir(join(packageDir, 'dist', 'simcam'), { recursive: true })
|
||||
await mkdir(join(packageDir, 'bin'), { recursive: true })
|
||||
await writeFile(join(packageDir, 'dist', 'serve-sim.js'), 'console.log("serve-sim")')
|
||||
await writeFile(
|
||||
join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib.gz'),
|
||||
gzipSync(DYLIB_CONTENT)
|
||||
)
|
||||
await writeFile(join(packageDir, 'dist', 'simcam', 'serve-sim-camera-helper'), 'helper', {
|
||||
mode: 0o644
|
||||
})
|
||||
await writeFile(join(packageDir, 'bin', 'serve-sim-bin'), 'bin', { mode: 0o644 })
|
||||
return packageDir
|
||||
}
|
||||
|
||||
describe('materializeServeSimRuntime', () => {
|
||||
const cleanupPaths: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const path of cleanupPaths.splice(0)) {
|
||||
await rm(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function createRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-simcam-materializer-'))
|
||||
cleanupPaths.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
it('copies the package, restores the gzipped dylib, and clears quarantine', async () => {
|
||||
const root = await createRoot()
|
||||
const bundledPackageDir = await createBundledServeSimPackage(root)
|
||||
const clearQuarantine = vi.fn()
|
||||
|
||||
const materialized = materializeServeSimRuntime({
|
||||
bundledPackageDir,
|
||||
targetRootDir: join(root, 'runtime'),
|
||||
version: '1.2.3',
|
||||
clearQuarantine
|
||||
})
|
||||
|
||||
expect(materialized).toBe(join(root, 'runtime', '1.2.3'))
|
||||
const dylibPath = join(materialized!, 'dist', 'simcam', 'libSimCameraInjector.dylib')
|
||||
expect(await readFile(dylibPath)).toEqual(DYLIB_CONTENT)
|
||||
await expect(stat(`${dylibPath}.gz`)).rejects.toThrow()
|
||||
expect(clearQuarantine).toHaveBeenCalledTimes(1)
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the existing runtime without re-copying', async () => {
|
||||
const root = await createRoot()
|
||||
const bundledPackageDir = await createBundledServeSimPackage(root)
|
||||
const clearQuarantine = vi.fn()
|
||||
const options = {
|
||||
bundledPackageDir,
|
||||
targetRootDir: join(root, 'runtime'),
|
||||
version: '1.2.3',
|
||||
clearQuarantine
|
||||
}
|
||||
|
||||
const first = materializeServeSimRuntime(options)
|
||||
const second = materializeServeSimRuntime(options)
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(clearQuarantine).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('prunes runtimes left behind by older app versions', async () => {
|
||||
const root = await createRoot()
|
||||
const bundledPackageDir = await createBundledServeSimPackage(root)
|
||||
const targetRootDir = join(root, 'runtime')
|
||||
await mkdir(join(targetRootDir, '1.0.0', 'dist'), { recursive: true })
|
||||
await writeFile(join(targetRootDir, '1.0.0', 'dist', 'serve-sim.js'), 'old')
|
||||
|
||||
materializeServeSimRuntime({
|
||||
bundledPackageDir,
|
||||
targetRootDir,
|
||||
version: '1.2.3',
|
||||
clearQuarantine: () => {}
|
||||
})
|
||||
|
||||
await expect(stat(join(targetRootDir, '1.0.0'))).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('materializes packages that still ship a raw dylib', async () => {
|
||||
const root = await createRoot()
|
||||
const bundledPackageDir = await createBundledServeSimPackage(root)
|
||||
await rm(join(bundledPackageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib.gz'))
|
||||
await writeFile(
|
||||
join(bundledPackageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib'),
|
||||
DYLIB_CONTENT
|
||||
)
|
||||
|
||||
const materialized = materializeServeSimRuntime({
|
||||
bundledPackageDir,
|
||||
targetRootDir: join(root, 'runtime'),
|
||||
version: '1.2.3',
|
||||
clearQuarantine: () => {}
|
||||
})
|
||||
|
||||
expect(materialized).not.toBeNull()
|
||||
expect(
|
||||
await readFile(join(materialized!, 'dist', 'simcam', 'libSimCameraInjector.dylib'))
|
||||
).toEqual(DYLIB_CONTENT)
|
||||
})
|
||||
|
||||
it('returns null and leaves no staging behind when quarantine clearing fails', async () => {
|
||||
const root = await createRoot()
|
||||
const bundledPackageDir = await createBundledServeSimPackage(root)
|
||||
const targetRootDir = join(root, 'runtime')
|
||||
|
||||
const materialized = materializeServeSimRuntime({
|
||||
bundledPackageDir,
|
||||
targetRootDir,
|
||||
version: '1.2.3',
|
||||
clearQuarantine: () => {
|
||||
throw new Error('xattr failed')
|
||||
}
|
||||
})
|
||||
|
||||
expect(materialized).toBeNull()
|
||||
await expect(stat(join(targetRootDir, '1.2.3'))).rejects.toThrow()
|
||||
const { readdir } = await import('node:fs/promises')
|
||||
const leftovers = (await readdir(targetRootDir)).filter((name) => name.startsWith('.staging'))
|
||||
expect(leftovers).toEqual([])
|
||||
})
|
||||
|
||||
it('returns null when the bundled package is missing', async () => {
|
||||
const root = await createRoot()
|
||||
|
||||
const materialized = materializeServeSimRuntime({
|
||||
bundledPackageDir: join(root, 'does-not-exist'),
|
||||
targetRootDir: join(root, 'runtime'),
|
||||
version: '1.2.3',
|
||||
clearQuarantine: () => {}
|
||||
})
|
||||
|
||||
expect(materialized).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
|
||||
export type ServeSimRuntimeMaterializerOptions = {
|
||||
bundledPackageDir: string
|
||||
targetRootDir: string
|
||||
version: string
|
||||
clearQuarantine?: (dir: string) => void
|
||||
}
|
||||
|
||||
const CAMERA_DYLIB_RELATIVE_PATH = join('dist', 'simcam', 'libSimCameraInjector.dylib')
|
||||
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
|
||||
}
|
||||
// Why: bundle files carry com.apple.quarantine after download/update and
|
||||
// cpSync can clone xattrs; a quarantined camera dylib injected into a
|
||||
// simulator process is what syspolicyd malware-rejects.
|
||||
execFileSync('/usr/bin/xattr', ['-cr', dir], { timeout: 30_000 })
|
||||
}
|
||||
|
||||
function pruneStaleServeSimRuntimes(targetRootDir: string, keepVersion: string): void {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(targetRootDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entryName of entries) {
|
||||
if (entryName === keepVersion) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
rmSync(join(targetRootDir, entryName), { recursive: true, force: true })
|
||||
} catch {
|
||||
// Old-version cleanup is best-effort; a locked file must not block materialization.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copies the bundled serve-sim package to a per-version directory outside the
|
||||
// signed app bundle, restores the gzipped camera dylib, and strips quarantine.
|
||||
// Why: the bundle cannot ship the raw dylib (iOS-simulator platform, never
|
||||
// notarizable), and serve-sim resolves camera assets relative to its own
|
||||
// entry, so the whole package must run from the materialized copy.
|
||||
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 stagingDir = join(targetRootDir, `.staging-${version}-${process.pid}`)
|
||||
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 })
|
||||
const gzippedDylibPath = join(stagingDir, `${CAMERA_DYLIB_RELATIVE_PATH}.gz`)
|
||||
if (existsSync(gzippedDylibPath)) {
|
||||
writeFileSync(
|
||||
join(stagingDir, CAMERA_DYLIB_RELATIVE_PATH),
|
||||
gunzipSync(readFileSync(gzippedDylibPath)),
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
rmSync(gzippedDylibPath)
|
||||
}
|
||||
for (const relativePath of EXECUTABLE_RELATIVE_PATHS) {
|
||||
const executablePath = join(stagingDir, relativePath)
|
||||
if (existsSync(executablePath)) {
|
||||
chmodSync(executablePath, 0o755)
|
||||
}
|
||||
}
|
||||
clearQuarantine(stagingDir)
|
||||
try {
|
||||
renameSync(stagingDir, targetDir)
|
||||
} catch (error) {
|
||||
// Another app instance sharing userData may have finished first.
|
||||
if (!existsSync(entryPath)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return existsSync(entryPath) ? targetDir : null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
rmSync(stagingDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue