diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 9e482825a..778600f24 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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 diff --git a/config/scripts/serve-sim-camera-packaging.test.mjs b/config/scripts/serve-sim-camera-packaging.test.mjs new file mode 100644 index 000000000..88eeaf862 --- /dev/null +++ b/config/scripts/serve-sim-camera-packaging.test.mjs @@ -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() + }) +}) diff --git a/config/serve-sim-camera-packaging.cjs b/config/serve-sim-camera-packaging.cjs new file mode 100644 index 000000000..aa3bd50aa --- /dev/null +++ b/config/serve-sim-camera-packaging.cjs @@ -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 } diff --git a/src/main/emulator/serve-sim-execution.ts b/src/main/emulator/serve-sim-execution.ts index 62631cf23..bda31aa3a 100644 --- a/src/main/emulator/serve-sim-execution.ts +++ b/src/main/emulator/serve-sim-execution.ts @@ -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 } } diff --git a/src/main/emulator/serve-sim-runtime-materializer.test.ts b/src/main/emulator/serve-sim-runtime-materializer.test.ts new file mode 100644 index 000000000..90848f717 --- /dev/null +++ b/src/main/emulator/serve-sim-runtime-materializer.test.ts @@ -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 { + 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 { + 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() + }) +}) diff --git a/src/main/emulator/serve-sim-runtime-materializer.ts b/src/main/emulator/serve-sim-runtime-materializer.ts new file mode 100644 index 000000000..baffd81e9 --- /dev/null +++ b/src/main/emulator/serve-sim-runtime-materializer.ts @@ -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 }) + } +}