diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 87b8ef2c..bbd138cf 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -25,6 +25,7 @@ "@linkcode/common": "workspace:*", "@linkcode/engine": "workspace:*", "@linkcode/schema": "workspace:*", + "@linkcode/sim": "workspace:*", "@linkcode/transport": "workspace:*", "@sentry/node": "10.62.0", "@sentry/profiling-node": "10.62.0", diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index aeebdb60..46b1176e 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -11,6 +11,7 @@ import { } from '@linkcode/engine'; import type { DaemonIdentity, DaemonListenerInfo, DaemonRuntimeInfo } from '@linkcode/schema'; import { DAEMON_EXIT_ALREADY_RUNNING, ManagedAssetIdSchema } from '@linkcode/schema'; +import { SimSidecarClient } from '@linkcode/sim'; import { Hub } from '@linkcode/transport/server'; import * as Sentry from '@sentry/node'; import type { Runtime } from 'effect'; @@ -36,6 +37,7 @@ import { } from './runtime'; import { createScheduleStore } from './schedule-store'; import { createSessionStore } from './session-store'; +import { resolveSimSidecarPath } from './sim/backend'; import { createWorkspaceStore } from './workspace-store'; // After an uncaught exception the process state (live sessions, mid-writes) is untrustworthy — @@ -206,9 +208,13 @@ async function main(): Promise { // status`) that take seconds on a cold machine — listener bind must not wait on them, or // every client sits on ECONNREFUSED for the whole probe. The engine seeds from the promise. const agentRuntimesReady = agentRuntimeProber.collect(); + const simSidecarPath = resolveSimSidecarPath(); const EngineInfrastructureLive = makeEngineInfrastructureLayer(hub, { providerStore: store, ptyBackend: new SidecarPtyBackend(resolveSidecarPath()), + // Absent (not a rejecting stub) off macOS or unconfigured: the engine then has no + // simulator surface at all, which is what the capability gate reads. + simulatorBackend: simSidecarPath ? new SimSidecarClient(simSidecarPath) : undefined, sessionStore: createSessionStore(databasePath()), // After sessionStore so its migration-ledger reconcile runs before this store migrates. scheduleStore: createScheduleStore(databasePath()), diff --git a/apps/daemon/src/sim/backend.ts b/apps/daemon/src/sim/backend.ts new file mode 100644 index 00000000..4bbb31a5 --- /dev/null +++ b/apps/daemon/src/sim/backend.ts @@ -0,0 +1,28 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { logger } from '../logger'; + +/** + * Resolve the `linkcode-sim` sidecar binary, mirroring the PTY sidecar's order: the explicit + * override (set by the desktop supervisor from `process.resourcesPath`) always wins; dev falls + * back to the workspace release build via this file's known depth under `src/`. That depth breaks + * in the flat tsup `dist/` bundle, so production trusts only the override. Returns `''` off + * macOS — Apple's simulator does not exist there, so nothing is logged either. + */ +export function resolveSimSidecarPath(): string { + if (process.platform !== 'darwin') return ''; + const override = process.env.LINKCODE_SIM_SIDECAR_PATH; + if (override) return override; + const here = fileURLToPath(import.meta.url); + // tsx runs this file straight from source (`.ts`); a tsup bundle is emitted as `.js`/`.mjs`. + if (here.endsWith('.ts')) { + // Dev: this file lives at apps/daemon/src/sim, so the repo root is four levels up. + const repoRoot = join(dirname(here), '..', '..', '..', '..'); + return join(repoRoot, 'target', 'release', 'linkcode-sim'); + } + logger.warn( + { operation: 'sim.resolve' }, + 'sim sidecar is not configured; simulators will be unavailable', + ); + return ''; +} diff --git a/apps/desktop/src/main/daemon-supervisor.ts b/apps/desktop/src/main/daemon-supervisor.ts index 705263ce..a171edf9 100644 --- a/apps/desktop/src/main/daemon-supervisor.ts +++ b/apps/desktop/src/main/daemon-supervisor.ts @@ -101,6 +101,14 @@ function spawnDaemon(): void { const sidecar = sidecarPath(); if (existsSync(sidecar)) env.LINKCODE_PTY_SIDECAR_PATH = sidecar; else log.warn(`[linkcode/desktop] pty sidecar missing at ${sidecar}; terminals unavailable`); + if (process.platform === 'darwin') { + const simSidecar = join(process.resourcesPath, 'linkcode-sim'); + if (existsSync(simSidecar)) { + env.LINKCODE_SIM_SIDECAR_PATH = simSidecar; + } else { + log.warn(`[linkcode/desktop] sim sidecar missing at ${simSidecar}; simulators unavailable`); + } + } // Same DSN the Electron main process inlined at build time (signed builds only). Publishable id. const sentryDsn = import.meta.env.MAIN_VITE_SENTRY_DSN; if (sentryDsn) env.LINKCODE_SENTRY_DSN = sentryDsn; diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index c712cd44..e7df062a 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -19,6 +19,7 @@ Read by the daemon, desktop, webview, or mobile at run time. | `LINKCODE_PORT` | `apps/daemon/src/config.ts` | Overrides every configured listener's port. Must parse as an integer in `1..65535`, otherwise the config value stands. | | `LINKCODE_HOST` | `apps/daemon/src/config.ts` | Overrides every listener's bind host. | | `LINKCODE_PTY_SIDECAR_PATH` | `apps/daemon/src/pty/sidecar.ts` | Absolute path to the `linkcode-pty` binary; always wins. Dev falls back to `target/release/linkcode-pty`; a bundled `dist/` daemon has no fallback and disables terminals. The packaged desktop supervisor sets it to `/sidecar/`. | +| `LINKCODE_SIM_SIDECAR_PATH` | `apps/daemon/src/sim/backend.ts` | Absolute path to the `linkcode-sim` iOS Simulator sidecar; always wins. macOS only — other platforms resolve to none regardless. Dev falls back to `target/release/linkcode-sim`; a bundled `dist/` daemon has no fallback and disables simulators. The packaged desktop supervisor sets it from ``. | | `LINKCODE_AIGATEWAY_PATH` | `apps/daemon/src/ai-gateway.ts` | Path to the `aigateway` translation sidecar, overriding the managed-asset install. | | `LINKCODE_ASSETS_DIR` | `packages/host/assets/src/paths.ts` | Redirects the managed-asset store root (default: `~/Library/Application Support/LinkCode/assets`, `%LOCALAPPDATA%/LinkCode/assets`, `$XDG_DATA_HOME/linkcode/assets`). Resolved per call, so tests can stub it. | | `ELECTRON_RENDERER_URL` | `apps/desktop/src/main/window.ts` | Dev-server URL the main process loads instead of the packaged renderer. Written by `apps/desktop/scripts/dev.mts`. | diff --git a/packages/host/engine/src/__tests__/simulator-service.test.ts b/packages/host/engine/src/__tests__/simulator-service.test.ts new file mode 100644 index 00000000..a01c1eaa --- /dev/null +++ b/packages/host/engine/src/__tests__/simulator-service.test.ts @@ -0,0 +1,214 @@ +import type { SessionId } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { asyncNoop, noop } from 'foxts/noop'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SimulatorBackend, SimulatorDeviceInfo } from '../simulator/backend'; +import { SimulatorService } from '../simulator/service'; + +const S1 = 'session-1' as SessionId; +const S2 = 'session-2' as SessionId; + +function device(udid: string, state: string): SimulatorDeviceInfo { + return { + udid, + name: `iPhone ${udid}`, + state, + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', + deviceType: null, + }; +} + +function fakeBackend(devices: SimulatorDeviceInfo[]) { + return { + probe: vi.fn(() => Promise.resolve({ simctlPath: '/usr/bin/simctl', developerDir: '/dev' })), + list: vi.fn(() => Promise.resolve(devices)), + boot: vi.fn(asyncNoop), + shutdownDevice: vi.fn(asyncNoop), + install: vi.fn(asyncNoop), + launch: vi.fn(() => Promise.resolve(42)), + terminate: vi.fn(asyncNoop), + openUrl: vi.fn(asyncNoop), + screenshot: vi.fn(() => Promise.resolve(new Uint8Array([0xff, 0xd8]))), + close: vi.fn(noop), + } satisfies SimulatorBackend; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('SimulatorService', () => { + it('claims a device for the booting session and rejects other sessions', async () => { + const backend = fakeBackend([device('A', 'Shutdown')]); + const service = new SimulatorService(backend); + + await service.boot(S1, 'A'); + expect(backend.boot).toHaveBeenCalledWith('A'); + expect(service.ownerOf('A')).toBe(S1); + + await expect(service.launch(S2, 'A', 'com.example')).rejects.toMatchObject({ + code: 'conflict', + }); + await expect(service.launch(S1, 'A', 'com.example')).resolves.toBe(42); + }); + + it('caps a session at four devices', async () => { + const backend = fakeBackend([]); + const service = new SimulatorService(backend); + + for (const udid of ['A', 'B', 'C', 'D']) { + await service.openUrl(S1, udid, 'https://example.com'); + } + await expect(service.openUrl(S1, 'E', 'https://example.com')).rejects.toMatchObject({ + code: 'limit_exceeded', + }); + await expect(service.openUrl(S2, 'E', 'https://example.com')).resolves.toBeUndefined(); + }); + + it('reclaims a service-booted device after the idle window', async () => { + const backend = fakeBackend([device('A', 'Shutdown')]); + const service = new SimulatorService(backend, { idleReclaimMs: 1000 }); + + await service.boot(S1, 'A'); + service.releaseSession(S1); + // Still reserved during the window: another session cannot take it over. + await expect(service.launch(S2, 'A', 'com.example')).rejects.toMatchObject({ + code: 'conflict', + }); + expect(backend.shutdownDevice).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1000); + expect(backend.shutdownDevice).toHaveBeenCalledWith('A'); + expect(service.ownerOf('A')).toBeUndefined(); + }); + + it('keeps the device when its session comes back within the window', async () => { + const backend = fakeBackend([device('A', 'Shutdown')]); + const service = new SimulatorService(backend, { idleReclaimMs: 1000 }); + + await service.boot(S1, 'A'); + service.releaseSession(S1); + await service.screenshot(S1, 'A'); + + await vi.advanceTimersByTimeAsync(5000); + expect(backend.shutdownDevice).not.toHaveBeenCalled(); + expect(service.ownerOf('A')).toBe(S1); + }); + + it('never shuts down a device the user booted', async () => { + const backend = fakeBackend([device('A', 'Booted')]); + const service = new SimulatorService(backend, { idleReclaimMs: 1000 }); + + await service.boot(S1, 'A'); + expect(backend.boot).not.toHaveBeenCalled(); + service.releaseSession(S1); + // Released immediately — no reservation window for a device that isn't ours to reclaim. + expect(service.ownerOf('A')).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(5000); + expect(backend.shutdownDevice).not.toHaveBeenCalled(); + }); + + it('shuts service-booted devices down and closes the backend on engine shutdown', async () => { + const backend = fakeBackend([device('A', 'Shutdown'), device('B', 'Booted')]); + const service = new SimulatorService(backend); + + await service.boot(S1, 'A'); + await service.boot(S2, 'B'); + await Effect.runPromise(service.shutdown()); + + expect(backend.shutdownDevice).toHaveBeenCalledTimes(1); + expect(backend.shutdownDevice).toHaveBeenCalledWith('A'); + expect(backend.close).toHaveBeenCalledTimes(1); + }); + + it('reclaims a service-booted device whose session stops mid-boot', async () => { + const backend = fakeBackend([device('A', 'Shutdown')]); + let resolveBoot!: () => void; + backend.boot.mockImplementation( + () => + new Promise((resolve) => { + resolveBoot = resolve; + }), + ); + const service = new SimulatorService(backend, { idleReclaimMs: 1000 }); + + const booting = service.boot(S1, 'A'); + // Let boot() claim the device and reach the (still-pending) backend.boot() call. + await vi.advanceTimersByTimeAsync(0); + // The owning session stops before the boot finishes, dropping the not-yet-service-booted claim. + service.releaseSession(S1); + resolveBoot(); + await booting; + + // The device we booted must not be left running untracked: it is re-tracked for reclaim, so it + // is not shut down immediately but is once the idle window elapses (without the fix there is no + // claim, so the booted device is never reclaimed). + expect(backend.shutdownDevice).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1000); + expect(backend.shutdownDevice).toHaveBeenCalledWith('A'); + expect(service.ownerOf('A')).toBeUndefined(); + }); + + it('holds the claim until the reclaim shutdown settles', async () => { + const backend = fakeBackend([device('A', 'Shutdown')]); + let resolveShutdown!: () => void; + backend.shutdownDevice.mockImplementation( + () => + new Promise((resolve) => { + resolveShutdown = resolve; + }), + ); + const service = new SimulatorService(backend, { idleReclaimMs: 1000 }); + + await service.boot(S1, 'A'); + service.releaseSession(S1); + await vi.advanceTimersByTimeAsync(1000); // idle timer fires → shutdown in flight + expect(backend.shutdownDevice).toHaveBeenCalledWith('A'); + // Another session must not grab the udid while its shutdown is still running. + await expect(service.openUrl(S2, 'A', 'https://example.com')).rejects.toMatchObject({ + code: 'conflict', + }); + // The device frees only once the shutdown settles. + resolveShutdown(); + await vi.advanceTimersByTimeAsync(0); + expect(service.ownerOf('A')).toBeUndefined(); + }); + + it('keeps ownership when the session resumes during the reclaim shutdown', async () => { + const backend = fakeBackend([device('A', 'Shutdown')]); + let resolveShutdown!: () => void; + backend.shutdownDevice.mockImplementation( + () => + new Promise((resolve) => { + resolveShutdown = resolve; + }), + ); + const service = new SimulatorService(backend, { idleReclaimMs: 1000 }); + + await service.boot(S1, 'A'); + service.releaseSession(S1); + await vi.advanceTimersByTimeAsync(1000); // idle timer fires → shutdown in flight + // The owning session comes back while the reclaim shutdown is still settling. + await service.screenshot(S1, 'A'); + resolveShutdown(); + await vi.advanceTimersByTimeAsync(0); + // The resumed session keeps the device; the settling shutdown must not drop its re-claim. + expect(service.ownerOf('A')).toBe(S1); + }); + + it('frees a device on owner-driven shutdown', async () => { + const backend = fakeBackend([device('A', 'Shutdown')]); + const service = new SimulatorService(backend); + + await service.boot(S1, 'A'); + await service.shutdownDevice(S1, 'A'); + expect(service.ownerOf('A')).toBeUndefined(); + // Freed for anyone: a different session can claim it next. + await expect(service.openUrl(S2, 'A', 'https://example.com')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/host/engine/src/deps.ts b/packages/host/engine/src/deps.ts index 8d26feb4..7f3e2458 100644 --- a/packages/host/engine/src/deps.ts +++ b/packages/host/engine/src/deps.ts @@ -8,6 +8,7 @@ import type { LoopStore, ScheduleStore } from './automation'; import type { GitService } from './git/git-service'; import type { PreviewRouteRegistry } from './preview/route-registry'; import type { SessionStore } from './session/session-store'; +import type { SimulatorBackend } from './simulator/backend'; import type { PtyBackend } from './terminal/pty-backend'; import type { FileSuggestService } from './workspace/file-suggest-service'; import type { WorkspaceStore } from './workspace/workspace-store'; @@ -17,6 +18,8 @@ export interface EngineDeps { factory?: AdapterFactory; sessionStore?: SessionStore; ptyBackend?: PtyBackend; + /** iOS Simulator sidecar client (macOS hosts only); absent Engines have no simulator surface. */ + simulatorBackend?: SimulatorBackend; providerStore?: ProviderConfigStore; git?: GitService; fileSuggest?: FileSuggestService; diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index fb18c811..ae140762 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -35,6 +35,7 @@ import { SessionRequestHandler } from './session/request-handler'; import { SessionRecordRegistry } from './session/session-record-registry'; import { InMemorySessionStore } from './session/session-store'; import { SessionStartOptionsResolver } from './session/start-options-resolver'; +import { SimulatorService } from './simulator/service'; import { TerminalRequestHandler } from './terminal/request-handler'; import { TerminalService } from './terminal/service'; import { WireRequestRouter } from './wire/request-router'; @@ -82,6 +83,9 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( runTask, ); let terminals: TerminalService | undefined; + const simulators = deps.simulatorBackend + ? new SimulatorService(deps.simulatorBackend) + : undefined; const sessions = new SessionOrchestrator( transport, factory, @@ -89,7 +93,10 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( runtimes, scope, runTask, - (sessionId) => terminals?.killBySession(sessionId), + (sessionId) => { + terminals?.killBySession(sessionId); + simulators?.releaseSession(sessionId); + }, ); terminals = deps.ptyBackend ? new TerminalService(deps.ptyBackend, transport, (id) => sessions.has(id)) @@ -273,6 +280,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( yield* finalize('artifacts.shutdown', () => artifacts.close()); yield* finalize('file-host.shutdown', () => fileHost.close()); yield* finalizeEffect('terminals.shutdown', terminals?.shutdown() ?? Effect.void); + yield* finalizeEffect('simulators.shutdown', simulators?.shutdown() ?? Effect.void); yield* finalize('agent-login.shutdown', () => logins?.closeAll()); yield* finalize('translator.shutdown', () => translator?.closeAll()); yield* finalize('assets.shutdown', () => assets.close()); diff --git a/packages/host/engine/src/simulator/backend.ts b/packages/host/engine/src/simulator/backend.ts new file mode 100644 index 00000000..fec04d57 --- /dev/null +++ b/packages/host/engine/src/simulator/backend.ts @@ -0,0 +1,44 @@ +/** + * Host-side iOS Simulator boundary (interface-first, docs/ARCHITECTURE.md#core-principles): the + * daemon injects `@linkcode/sim`'s client, which satisfies this port structurally — neither + * package imports the other, mirroring the PTY seam. Shapes and error codes follow + * `crates/linkcode-sim/PROTOCOL.md`; rejections may carry a string `code` (e.g. `xcodeMissing`, + * on which the daemon gates the whole capability). + */ + +/** One available simulator device. */ +export interface SimulatorDeviceInfo { + udid: string; + name: string; + /** CoreSimulator state string: `Shutdown`, `Booted`, `Booting`, … */ + state: string; + /** Runtime identifier, e.g. `com.apple.CoreSimulator.SimRuntime.iOS-26-5`. */ + runtime: string; + /** Human-readable runtime name (`iOS 26.5`); absent when unknown. */ + runtimeName?: string; + deviceType: string | null; +} + +/** Where the host's simulator tooling lives; resolving it proves the capability exists. */ +export interface SimulatorProbe { + simctlPath: string; + developerDir: string; +} + +export type SimulatorImageFormat = 'jpeg' | 'png'; + +export interface SimulatorBackend { + probe(): Promise; + list(): Promise; + /** Boot and wait until the device is fully usable; an already-booted device succeeds. */ + boot(udid: string): Promise; + /** Shut a device down; an already-shutdown device succeeds. */ + shutdownDevice(udid: string): Promise; + install(udid: string, appPath: string): Promise; + launch(udid: string, bundleId: string): Promise; + terminate(udid: string, bundleId: string): Promise; + openUrl(udid: string, url: string): Promise; + screenshot(udid: string, format?: SimulatorImageFormat): Promise; + /** Release the backend process (engine shutdown). Booted devices keep running host-side. */ + close(): void; +} diff --git a/packages/host/engine/src/simulator/service.ts b/packages/host/engine/src/simulator/service.ts new file mode 100644 index 00000000..9b874565 --- /dev/null +++ b/packages/host/engine/src/simulator/service.ts @@ -0,0 +1,222 @@ +import type { SessionId } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { noop } from 'foxts/noop'; +import { RequestError } from '../failure'; +import type { SimulatorBackend, SimulatorDeviceInfo, SimulatorImageFormat } from './backend'; + +/** Mirrors the reference session model: at most four simulator panes per session. */ +const MAX_DEVICES_PER_SESSION = 4; +/** How long a released device we booted stays reserved before it is shut down and freed. */ +const IDLE_RECLAIM_MS = 10 * 60000; + +interface DeviceClaim { + sessionId: SessionId; + /** Devices this service booted are reclaimed (shut down) after idling; user-booted never are. */ + bootedByService: boolean; + /** Armed when the owning session stops; a new claim within the window disarms it. */ + idleTimer?: ReturnType; + /** True once the idle timer has fired and the reclaim shutdown is in flight; a resume clears it + * so the settling shutdown does not drop the re-acquired claim. */ + reclaiming?: boolean; +} + +/** + * The engine's simulator policy layer: device↔session ownership, per-session caps, and reclaim. + * Every simulator operation — wire requests (CODE-394) and agent MCP tools (CODE-395) alike — + * must route through this service; the injected {@link SimulatorBackend} is transport only, and + * reaching around this layer would bypass ownership and consent. + * + * Ownership model (mirrors the reference implementation): a device belongs to the session that + * first drives it, and concurrent sessions never share one. When the owning session stops, a + * device this service booted idles for {@link IDLE_RECLAIM_MS} — resuming work re-claims it in + * place — then is shut down and freed. Devices the user booted are released immediately and + * never shut down by the engine. + */ +export class SimulatorService { + private readonly claims = new Map(); + private readonly idleReclaimMs: number; + + constructor( + private readonly backend: SimulatorBackend, + opts?: { idleReclaimMs?: number }, + ) { + this.idleReclaimMs = opts?.idleReclaimMs ?? IDLE_RECLAIM_MS; + } + + probe(): Promise<{ simctlPath: string; developerDir: string }> { + return this.backend.probe(); + } + + /** Read-only; claims are not required to look. */ + list(): Promise { + return this.backend.list(); + } + + /** Boot a device for a session, claiming it. Booting a device the user already booted claims + * it without marking it reclaimable. */ + async boot(sessionId: SessionId, udid: string): Promise { + this.claim(sessionId, udid); + // Distinguish "we booted it" from "it was already up": only the former is ours to shut down. + const state = (await this.backend.list()).find((device) => device.udid === udid)?.state; + if (state === 'Booted') return; + try { + await this.backend.boot(udid); + } finally { + // Reconcile ownership whether the boot resolved or rejected: `simctl boot` can start the + // device even when a later bootstatus wait, the sidecar, or the reply then fails, so both + // paths must keep it reclaimable (shutting down a device that never actually booted is a + // harmless no-op). + this.reconcileBootedClaim(sessionId, udid); + } + } + + /** After a boot attempt, mark the device service-booted so it is reclaimed. If the owning session + * stopped mid-boot (its not-yet-service-booted claim was dropped), re-track it and arm idle + * reclaim, exactly as if the stop had arrived after the boot. */ + private reconcileBootedClaim(sessionId: SessionId, udid: string): void { + const claim = this.claims.get(udid); + if (claim === undefined) { + const reclaimed: DeviceClaim = { sessionId, bootedByService: true }; + this.claims.set(udid, reclaimed); + this.release(udid, reclaimed); + } else if (claim.sessionId === sessionId) { + claim.bootedByService = true; + } + // else: another session claimed the device during our boot — it is theirs to manage now. + } + + /** Shut a device down on the owner's behalf and free it. */ + async shutdownDevice(sessionId: SessionId, udid: string): Promise { + this.claim(sessionId, udid); + await this.backend.shutdownDevice(udid); + this.drop(udid); + } + + // The passthroughs are `async` so a claim failure surfaces as a rejection, not a sync throw — + // wire handlers and MCP tools treat every service call uniformly as a promise. + + async install(sessionId: SessionId, udid: string, appPath: string): Promise { + this.claim(sessionId, udid); + return this.backend.install(udid, appPath); + } + + async launch(sessionId: SessionId, udid: string, bundleId: string): Promise { + this.claim(sessionId, udid); + return this.backend.launch(udid, bundleId); + } + + async terminate(sessionId: SessionId, udid: string, bundleId: string): Promise { + this.claim(sessionId, udid); + return this.backend.terminate(udid, bundleId); + } + + async openUrl(sessionId: SessionId, udid: string, url: string): Promise { + this.claim(sessionId, udid); + return this.backend.openUrl(udid, url); + } + + async screenshot( + sessionId: SessionId, + udid: string, + format?: SimulatorImageFormat, + ): Promise { + this.claim(sessionId, udid); + return this.backend.screenshot(udid, format); + } + + /** Release every device a session holds (the session-stop hook). */ + releaseSession(sessionId: SessionId): void { + for (const [udid, claim] of this.claims) { + if (claim.sessionId === sessionId) this.release(udid, claim); + } + } + + /** Which session holds a device, if any (the panel's ownership display reads this). */ + ownerOf(udid: string): SessionId | undefined { + return this.claims.get(udid)?.sessionId; + } + + /** Reclaim service-booted devices and release the backend (engine shutdown). */ + shutdown(): Effect.Effect { + return Effect.promise(() => this.reclaimAll()).pipe( + Effect.andThen(Effect.sync(() => this.backend.close())), + ); + } + + /** + * Claim a device for a session, or refresh an existing claim (disarming a pending reclaim). + * Throws `conflict` when another session holds the device and `limit_exceeded` past the + * per-session cap. + */ + private claim(sessionId: SessionId, udid: string): void { + const existing = this.claims.get(udid); + if (existing) { + if (existing.sessionId !== sessionId) { + throw new RequestError({ + code: 'conflict', + message: `simulator ${udid} is in use by another session`, + }); + } + if (existing.idleTimer) { + clearTimeout(existing.idleTimer); + existing.idleTimer = undefined; + } + // Cancel an in-flight reclaim's claim-drop: the session is back, so it keeps ownership even if + // the shutdown started (it re-boots the device on its next op). + existing.reclaiming = false; + return; + } + let held = 0; + for (const claim of this.claims.values()) { + if (claim.sessionId === sessionId) held += 1; + } + if (held >= MAX_DEVICES_PER_SESSION) { + throw new RequestError({ + code: 'limit_exceeded', + message: `session already holds ${MAX_DEVICES_PER_SESSION} simulators`, + }); + } + this.claims.set(udid, { sessionId, bootedByService: false }); + } + + private release(udid: string, claim: DeviceClaim): void { + if (!claim.bootedByService) { + this.drop(udid); + return; + } + if (claim.idleTimer) clearTimeout(claim.idleTimer); + claim.idleTimer = setTimeout(() => { + // Shut the device down before releasing its claim: dropping first opens a window where + // another session claims and boots the same udid while this shutdown is still in flight, + // which would then tear down the device that session just acquired. Reclaim stays best-effort + // (the device may already be gone — deleted in Xcode, host reboot). + claim.reclaiming = true; + void this.backend + .shutdownDevice(udid) + .catch(noop) + .finally(() => { + // Drop only if this is still the reclaiming claim: a resume during the shutdown clears + // `reclaiming` (and takes ownership), so we must not drop the re-acquired claim. + const current = this.claims.get(udid); + if (current === claim && current.reclaiming) this.drop(udid); + }); + }, this.idleReclaimMs); + claim.idleTimer.unref?.(); + } + + private drop(udid: string): void { + const claim = this.claims.get(udid); + if (claim?.idleTimer) clearTimeout(claim.idleTimer); + this.claims.delete(udid); + } + + private async reclaimAll(): Promise { + const reclaim: string[] = []; + for (const [udid, claim] of this.claims) { + if (claim.idleTimer) clearTimeout(claim.idleTimer); + if (claim.bootedByService) reclaim.push(udid); + } + this.claims.clear(); + await Promise.allSettled(reclaim.map((udid) => this.backend.shutdownDevice(udid))); + } +} diff --git a/packages/host/sim/package.json b/packages/host/sim/package.json new file mode 100644 index 00000000..f7faa1e6 --- /dev/null +++ b/packages/host/sim/package.json @@ -0,0 +1,22 @@ +{ + "name": "@linkcode/sim", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "eslint --format=sukka ." + }, + "dependencies": { + "foxts": "^5.8.0", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/host/sim/src/__tests__/client.test.ts b/packages/host/sim/src/__tests__/client.test.ts new file mode 100644 index 00000000..7e2577f7 --- /dev/null +++ b/packages/host/sim/src/__tests__/client.test.ts @@ -0,0 +1,158 @@ +import { PassThrough } from 'node:stream'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SimSidecarClient, SimSidecarError } from '../client'; +import { REQUEST, SCREENSHOT } from '../codec'; + +const mocks = vi.hoisted(() => ({ + spawn: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + spawn: mocks.spawn, +})); + +function frame(type: number, body: Buffer): Buffer { + const header = Buffer.allocUnsafe(5); + header.writeUInt32LE(body.length + 1, 0); + header[4] = type; + return Buffer.concat([header, body]); +} + +function resultFrame(payload: unknown): Buffer { + return frame(0x81, Buffer.from(JSON.stringify(payload))); +} + +type FakeChild = PassThrough & { + stdin: PassThrough; + stdout: PassThrough; + kill: ReturnType; +}; + +function fakeChild(): FakeChild { + const child = new PassThrough() as FakeChild; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.kill = vi.fn(() => { + child.emit('exit', null, null); + return true; + }); + return child; +} + +/** Read the next REQUEST frame the client wrote to the fake child's stdin. */ +function readRequest(child: FakeChild): { requestId: string; op: { type: string } } { + const chunk = child.stdin.read() as Buffer; + expect(chunk.readUInt32LE(0)).toBe(chunk.length - 4); + expect(chunk[4]).toBe(REQUEST); + return JSON.parse(chunk.subarray(5).toString('utf8')) as { + requestId: string; + op: { type: string }; + }; +} + +async function tick(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +beforeEach(() => { + mocks.spawn.mockReset(); +}); + +describe('SimSidecarClient', () => { + it('resolves a request from its RESULT frame by id', async () => { + const child = fakeChild(); + mocks.spawn.mockReturnValue(child); + const client = new SimSidecarClient('/bin/sim'); + + const probing = client.probe(); + await tick(); + const request = readRequest(child); + expect(request.op.type).toBe('probe'); + + child.stdout.write( + resultFrame({ + requestId: request.requestId, + ok: true, + result: { simctlPath: '/usr/bin/simctl', developerDir: '/dev/dir' }, + }), + ); + await expect(probing).resolves.toEqual({ + simctlPath: '/usr/bin/simctl', + developerDir: '/dev/dir', + }); + }); + + it('rejects with the sidecar error code', async () => { + const child = fakeChild(); + mocks.spawn.mockReturnValue(child); + const client = new SimSidecarClient('/bin/sim'); + + const booting = client.boot('U-1'); + await tick(); + const request = readRequest(child); + child.stdout.write( + resultFrame({ + requestId: request.requestId, + ok: false, + error: { code: 'xcodeMissing', message: 'no xcode' }, + }), + ); + await expect(booting).rejects.toMatchObject(new SimSidecarError('xcodeMissing', 'no xcode')); + }); + + it('resolves screenshots from the binary frame', async () => { + const child = fakeChild(); + mocks.spawn.mockReturnValue(child); + const client = new SimSidecarClient('/bin/sim'); + + const shooting = client.screenshot('U-1'); + await tick(); + const request = readRequest(child); + const id = Buffer.from(request.requestId); + child.stdout.write( + frame( + SCREENSHOT, + Buffer.concat([Buffer.from([id.length, 0]), id, Buffer.from([0xff, 0xd8, 0x01])]), + ), + ); + await expect(shooting).resolves.toEqual(Buffer.from([0xff, 0xd8, 0x01])); + }); + + it('fails every pending request when the sidecar dies, then respawns on the next call', async () => { + const first = fakeChild(); + const second = fakeChild(); + mocks.spawn.mockReturnValueOnce(first).mockReturnValueOnce(second); + const client = new SimSidecarClient('/bin/sim'); + + const doomed = client.list(); + await tick(); + first.emit('exit', 1, null); + await expect(doomed).rejects.toThrow('sim sidecar exited'); + + const retried = client.list(); + await tick(); + const request = readRequest(second); + second.stdout.write( + resultFrame({ requestId: request.requestId, ok: true, result: { devices: [] } }), + ); + await expect(retried).resolves.toEqual([]); + expect(mocks.spawn).toHaveBeenCalledTimes(2); + }); + + it('rejects calls without a configured binary and after close', async () => { + const unconfigured = new SimSidecarClient(''); + await expect(unconfigured.probe()).rejects.toThrow('sim sidecar not configured'); + expect(mocks.spawn).not.toHaveBeenCalled(); + + const child = fakeChild(); + mocks.spawn.mockReturnValue(child); + const client = new SimSidecarClient('/bin/sim'); + const pending = client.probe(); + await tick(); + client.close(); + await expect(pending).rejects.toThrow('sim client closed'); + await expect(client.probe()).rejects.toThrow('sim client closed'); + }); +}); diff --git a/packages/host/sim/src/__tests__/codec.test.ts b/packages/host/sim/src/__tests__/codec.test.ts new file mode 100644 index 00000000..8d031494 --- /dev/null +++ b/packages/host/sim/src/__tests__/codec.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { decodeScreenshotFrame, FrameDecoder, MAX_FRAME_LEN, RESULT, writeFrame } from '../codec'; + +function frame(type: number, body: Buffer): Buffer { + const header = Buffer.allocUnsafe(5); + header.writeUInt32LE(body.length + 1, 0); + header[4] = type; + return Buffer.concat([header, body]); +} + +describe('FrameDecoder', () => { + it('reassembles frames split at arbitrary byte boundaries', () => { + const decoder = new FrameDecoder(); + const encoded = Buffer.concat([ + frame(RESULT, Buffer.from('{"a":1}')), + frame(RESULT, Buffer.from('{"b":2}')), + ]); + const frames = []; + for (const byte of encoded) { + for (const decoded of decoder.feed(Buffer.from([byte]))) frames.push(decoded); + } + expect(frames.map((f) => f.body.toString())).toEqual(['{"a":1}', '{"b":2}']); + }); + + it('rejects oversized frame lengths', () => { + const decoder = new FrameDecoder(); + const header = Buffer.allocUnsafe(5); + header.writeUInt32LE(MAX_FRAME_LEN + 1, 0); + header[4] = RESULT; + expect(() => decoder.feed(header)).toThrow('invalid sidecar frame length'); + }); +}); + +describe('writeFrame', () => { + it('rejects bodies over the frame ceiling', () => { + const sink = { write: () => true }; + expect(() => writeFrame(sink as never, RESULT, Buffer.alloc(MAX_FRAME_LEN))).toThrow( + 'sidecar frame too large', + ); + }); +}); + +describe('decodeScreenshotFrame', () => { + it('splits the request id from the image bytes', () => { + const id = Buffer.from('r7'); + const body = Buffer.concat([Buffer.from([id.length, 0]), id, Buffer.from([0xff, 0xd8, 0xff])]); + const decoded = decodeScreenshotFrame(body); + expect(decoded.requestId).toBe('r7'); + expect([...decoded.image]).toEqual([0xff, 0xd8, 0xff]); + }); + + it('rejects truncated bodies', () => { + expect(() => decodeScreenshotFrame(Buffer.from([9]))).toThrow('short screenshot frame'); + expect(() => decodeScreenshotFrame(Buffer.from([9, 0, 0x78]))).toThrow('truncated request id'); + }); +}); diff --git a/packages/host/sim/src/__tests__/sidecar-loop.test.ts b/packages/host/sim/src/__tests__/sidecar-loop.test.ts new file mode 100644 index 00000000..4b379589 --- /dev/null +++ b/packages/host/sim/src/__tests__/sidecar-loop.test.ts @@ -0,0 +1,123 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; +import { SimSidecarClient } from '../client'; + +/** + * The CODE-393 acceptance loop, SDK-driven against the real sidecar and a real simulator: + * boot → install (fixture app compiled on the fly) → launch → screenshot. Opt-in — it needs a + * Mac with full Xcode and boots a device: + * + * LINKCODE_SIM_E2E=1 pnpm exec vitest run packages/host/sim + */ +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..'); +const sidecarPath = + process.env.LINKCODE_SIM_SIDECAR_PATH ?? join(repoRoot, 'target', 'release', 'linkcode-sim'); +const enabled = + process.env.LINKCODE_SIM_E2E === '1' && process.platform === 'darwin' && existsSync(sidecarPath); + +const FIXTURE_BUNDLE_ID = 'ai.linkcode.sim.sdkfixture'; + +const FIXTURE_MAIN_M = ` +#import +@interface AppDelegate : UIResponder +@property (strong, nonatomic) UIWindow *window; +@end +@implementation AppDelegate +- (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; + self.window.backgroundColor = UIColor.systemGreenColor; + [self.window makeKeyAndVisible]; + return YES; +} +@end +int main(int argc, char *argv[]) { + return UIApplicationMain(argc, argv, nil, NSStringFromClass(AppDelegate.class)); +} +`; + +const FIXTURE_INFO_PLIST = ` + + + + CFBundleExecutableFixture + CFBundleIdentifier${FIXTURE_BUNDLE_ID} + CFBundleNameLinkCodeSimSdkFixture + CFBundlePackageTypeAPPL + CFBundleShortVersionString1.0 + CFBundleVersion1 + UIDeviceFamily1 + + +`; + +/** `/usr/bin/xcrun` + a clean SDK env: a foreign DEVELOPER_DIR/SDKROOT (devenv's nix apple-sdk) + * breaks `-sdk iphonesimulator` resolution even through the real xcrun. */ +function buildFixtureApp(root: string): string { + const app = join(root, 'Fixture.app'); + mkdirSync(app, { recursive: true }); + writeFileSync(join(root, 'main.m'), FIXTURE_MAIN_M); + writeFileSync(join(app, 'Info.plist'), FIXTURE_INFO_PLIST); + const env = { ...process.env }; + delete env.DEVELOPER_DIR; + delete env.SDKROOT; + const target = + process.arch === 'arm64' ? 'arm64-apple-ios15.0-simulator' : 'x86_64-apple-ios15.0-simulator'; + execFileSync( + '/usr/bin/xcrun', + [ + '-sdk', + 'iphonesimulator', + 'clang', + '-fobjc-arc', + '-target', + target, + join(root, 'main.m'), + '-framework', + 'UIKit', + '-framework', + 'Foundation', + '-o', + join(app, 'Fixture'), + ], + { env }, + ); + return app; +} + +describe.runIf(enabled)('sidecar loop (real simulator)', () => { + const fixtureRoot = join(tmpdir(), `linkcode-sim-sdk-${process.pid}`); + const client = new SimSidecarClient(sidecarPath); + + afterAll(() => { + client.close(); + rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + it('drives boot → install → launch → screenshot end to end', { timeout: 600000 }, async () => { + await client.probe(); + const devices = await client.list(); + const booted = devices.find((device) => device.state === 'Booted'); + const target = + booted ?? + devices.find((device) => device.name.includes('iPhone') && device.runtime.includes('iOS')); + expect(target, 'no available iPhone simulator').toBeDefined(); + const udid = target!.udid; + + if (!booted) await client.boot(udid); + await client.install(udid, buildFixtureApp(fixtureRoot)); + const pid = await client.launch(udid, FIXTURE_BUNDLE_ID); + expect(pid).toBeGreaterThan(0); + + const image = await client.screenshot(udid); + expect(image.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8])); + expect(image.length).toBeGreaterThan(10000); + + await client.terminate(udid, FIXTURE_BUNDLE_ID); + if (!booted) await client.shutdownDevice(udid); + }); +}); diff --git a/packages/host/sim/src/client.ts b/packages/host/sim/src/client.ts new file mode 100644 index 00000000..e7227319 --- /dev/null +++ b/packages/host/sim/src/client.ts @@ -0,0 +1,228 @@ +import type { ChildProcessByStdio } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import type { Frame } from './codec'; +import { + decodeScreenshotFrame, + FrameDecoder, + REQUEST, + RESULT, + SCREENSHOT, + writeFrame, +} from './codec'; +import type { SimDevice, SimImageFormat, SimProbe } from './schema'; +import { + SimLaunchResultSchema, + SimListResultSchema, + SimProbeSchema, + SimResultSchema, +} from './schema'; + +/** The sidecar child: piped stdin/stdout, inherited stderr (its logs go to the host's stderr). */ +type SidecarChild = ChildProcessByStdio; + +/** A sidecar-classified failure; `code` is a `SimErrorCode` for sidecars of this protocol rev. */ +export class SimSidecarError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = 'SimSidecarError'; + } +} + +interface Pending { + resolve: (outcome: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +/** + * Outer reply deadlines per op. The sidecar enforces its own per-op deadlines + * (`crates/linkcode-sim/src/simctl.rs`) and reports `timeout` errors; these ceilings only catch a + * sidecar that can't reply at all, so each sits above its sidecar-side counterpart. + */ +const DEFAULT_REPLY_TIMEOUT_MS = 90000; +const REPLY_TIMEOUT_MS: Partial> = { + boot: 210000, + install: 150000, +}; + +/** + * Typed client for the `linkcode-sim` sidecar (contract: `crates/linkcode-sim/PROTOCOL.md`). + * One long-lived child serves every request; requests run concurrently sidecar-side, so a slow + * boot never delays a screenshot. Spawned lazily on first use and respawned after a crash. + * + * This client is transport, not policy: device↔session ownership, consent, and reclaim live in + * the engine's simulator service — callers there must not reach around it to this class. + */ +export class SimSidecarClient { + private child: SidecarChild | null = null; + private readonly decoder = new FrameDecoder(); + private readonly pending = new Map(); + private seq = 0; + private closed = false; + + constructor(private readonly binaryPath: string) {} + + async probe(): Promise { + return SimProbeSchema.parse(await this.call('probe', {})); + } + + async list(): Promise { + return SimListResultSchema.parse(await this.call('list', {})).devices; + } + + async boot(udid: string): Promise { + await this.call('boot', { udid }); + } + + async shutdownDevice(udid: string): Promise { + await this.call('shutdown', { udid }); + } + + async install(udid: string, appPath: string): Promise { + await this.call('install', { udid, appPath }); + } + + async launch(udid: string, bundleId: string): Promise { + return SimLaunchResultSchema.parse(await this.call('launch', { udid, bundleId })).pid; + } + + async terminate(udid: string, bundleId: string): Promise { + await this.call('terminate', { udid, bundleId }); + } + + async openUrl(udid: string, url: string): Promise { + await this.call('openUrl', { udid, url }); + } + + async screenshot(udid: string, format: SimImageFormat = 'jpeg'): Promise { + const image = await this.call('screenshot', { udid, format }); + // The sidecar answers a successful screenshot with a binary frame, never a JSON result. + if (!Buffer.isBuffer(image)) throw new Error('sim sidecar sent a non-binary screenshot'); + return image; + } + + /** Release the client and its sidecar. Booted devices keep running server-side. */ + close(): void { + if (this.closed) return; + this.closed = true; + const child = this.child; + this.child = null; + this.decoder.reset(); + this.failAll(new Error('sim client closed')); + // Close stdin (EOF) rather than kill: the sidecar drains queued replies before exiting. + child?.stdin.end(); + } + + private call(type: string, params: Record): Promise { + if (this.closed) return Promise.reject(new Error('sim client closed')); + // Unconfigured binary (see the daemon's `resolveSimSidecarPath`): fail with a clear, stable + // message instead of `spawn('')`, which would surface as a confusing crash on every call. + if (!this.binaryPath) { + return Promise.reject( + new Error('sim sidecar not configured: simulators are unavailable on this host'), + ); + } + const child = this.ensureChild(); + const requestId = `r${++this.seq}`; + const body = Buffer.from(JSON.stringify({ requestId, op: { type, ...params } })); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (!this.pending.delete(requestId)) return; + reject(new Error(`sim sidecar reply timed out: ${type}`)); + }, REPLY_TIMEOUT_MS[type] ?? DEFAULT_REPLY_TIMEOUT_MS); + // Don't let a pending reply keep the host's event loop alive on its own. + timer.unref(); + this.pending.set(requestId, { resolve, reject, timer }); + try { + writeFrame(child.stdin, REQUEST, body); + } catch (error) { + // The child died between ensureChild() and the write: fail this request now instead of + // leaving it registered to wait out the full reply deadline. (Async write failures land on + // the stdin `error` listener below, which fails every pending request the same way.) + this.take(requestId); + reject(new Error(extractErrorMessage(error) ?? 'sim sidecar write failed')); + } + }); + } + + private ensureChild(): SidecarChild { + if (this.child) return this.child; + // windowsHide: daemon-side children must never pop a console window (root AGENTS invariant); + // moot for this macOS-only binary, but kept so every spawn site stays uniform. + const child = spawn(this.binaryPath, [], { + stdio: ['pipe', 'pipe', 'inherit'], + windowsHide: true, + }); + this.child = child; + // Every listener is scoped to THIS child: after a crash a new request spawns a replacement, and + // the old child's delayed `exit`/`error`/`data` must not tear down (or feed stale bytes into) + // the current one. `onChildGone` only fires while `child` is still the live child. + const teardown = (): void => { + if (this.child === child) this.onChildGone(); + }; + child.stdout.on('data', (chunk: Buffer) => { + if (this.child !== child) return; + try { + for (const frame of this.decoder.feed(chunk)) this.handleFrame(frame); + } catch { + // A corrupt stream cannot be resynchronized mid-flight; drop the child and start over. + child.kill(); + teardown(); + } + }); + // A failed spawn (e.g. missing binary) errors the pipes; a broken pipe means the child is + // gone. Without these listeners the unhandled stream error would crash the host process. + child.stdin.on('error', teardown); + child.stdout.on('error', teardown); + child.on('exit', teardown); + child.on('error', teardown); + return child; + } + + private handleFrame(frame: Frame): void { + switch (frame.type) { + case RESULT: { + const parsed = SimResultSchema.parse(JSON.parse(frame.body.toString('utf8'))); + const waiter = this.take(parsed.requestId); + if (!waiter) break; + if (parsed.ok) waiter.resolve(parsed.result); + else waiter.reject(new SimSidecarError(parsed.error.code, parsed.error.message)); + break; + } + case SCREENSHOT: { + const { requestId, image } = decodeScreenshotFrame(frame.body); + this.take(requestId)?.resolve(image); + break; + } + default: + break; + } + } + + private take(requestId: string): Pending | undefined { + const waiter = this.pending.get(requestId); + if (!waiter) return undefined; + clearTimeout(waiter.timer); + this.pending.delete(requestId); + return waiter; + } + + private onChildGone(): void { + this.child = null; + this.decoder.reset(); + this.failAll(new Error('sim sidecar exited')); + } + + private failAll(error: Error): void { + for (const waiter of this.pending.values()) { + clearTimeout(waiter.timer); + waiter.reject(error); + } + this.pending.clear(); + } +} diff --git a/packages/host/sim/src/codec.ts b/packages/host/sim/src/codec.ts new file mode 100644 index 00000000..dab5c68f --- /dev/null +++ b/packages/host/sim/src/codec.ts @@ -0,0 +1,68 @@ +import type { Writable } from 'node:stream'; + +/** + * TypeScript side of the sim sidecar stdio protocol (mirrors `crates/linkcode-sim/src/proto.rs`; + * contract in `crates/linkcode-sim/PROTOCOL.md`). Frame layout: `[u32 LE total][u8 type][body]`, + * where `total = 1 + body.length`. + */ + +// Daemon → sidecar. +export const REQUEST = 0x01; +// Sidecar → daemon. +export const RESULT = 0x81; +export const SCREENSHOT = 0x82; + +export const MAX_FRAME_LEN = 16 * 1024 * 1024; + +export interface Frame { + type: number; + body: Buffer; +} + +/** Reassembles length-prefixed frames from an arbitrarily-chunked byte stream. */ +export class FrameDecoder { + private carry: Buffer = Buffer.alloc(0); + + reset(): void { + this.carry = Buffer.alloc(0); + } + + feed(chunk: Buffer): Frame[] { + this.carry = this.carry.length === 0 ? chunk : Buffer.concat([this.carry, chunk]); + const frames: Frame[] = []; + while (this.carry.length >= 5) { + const total = this.carry.readUInt32LE(0); + if (total === 0 || total > MAX_FRAME_LEN) { + throw new Error(`invalid sidecar frame length: ${total}`); + } + if (this.carry.length < 4 + total) break; + const type = this.carry[4]; + frames.push({ type, body: Buffer.from(this.carry.subarray(5, 4 + total)) }); + this.carry = this.carry.subarray(4 + total); + } + // Copy the remainder off the (possibly large) concatenated buffer so it can be released. + this.carry = Buffer.from(this.carry); + return frames; + } +} + +export function writeFrame(sink: Writable, type: number, body: Buffer): void { + if (body.length + 1 > MAX_FRAME_LEN) { + throw new Error(`sidecar frame too large: ${body.length + 1}`); + } + const header = Buffer.allocUnsafe(5); + header.writeUInt32LE(body.length + 1, 0); + header[4] = type; + sink.write(Buffer.concat([header, body])); +} + +/** Decode a `SCREENSHOT` body: `[u16 LE id_len][request_id][image bytes]`. */ +export function decodeScreenshotFrame(body: Buffer): { requestId: string; image: Buffer } { + if (body.length < 2) throw new Error('short screenshot frame'); + const idLen = body.readUInt16LE(0); + if (body.length < 2 + idLen) throw new Error('truncated request id'); + return { + requestId: body.subarray(2, 2 + idLen).toString('utf8'), + image: body.subarray(2 + idLen), + }; +} diff --git a/packages/host/sim/src/index.ts b/packages/host/sim/src/index.ts new file mode 100644 index 00000000..9c08f513 --- /dev/null +++ b/packages/host/sim/src/index.ts @@ -0,0 +1,2 @@ +export { SimSidecarClient, SimSidecarError } from './client'; +export type { SimDevice, SimErrorCode, SimImageFormat, SimProbe } from './schema'; diff --git a/packages/host/sim/src/schema.ts b/packages/host/sim/src/schema.ts new file mode 100644 index 00000000..84683bcd --- /dev/null +++ b/packages/host/sim/src/schema.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +/** + * JSON bodies of the sim sidecar's `RESULT` frames, validated once at this boundary + * (`crates/linkcode-sim/PROTOCOL.md` is the contract). Op-specific `result` payloads are parsed + * by the client method that issued the request. + */ + +/** Stable failure codes the sidecar classifies into (`src/rpc.rs` `ErrorCode`). */ +export type SimErrorCode = 'xcodeMissing' | 'simctlFailed' | 'timeout' | 'invalidRequest' | 'io'; + +export const SimResultSchema = z.discriminatedUnion('ok', [ + z.object({ requestId: z.string(), ok: z.literal(true), result: z.unknown() }), + z.object({ + requestId: z.string(), + ok: z.literal(false), + // `code` stays an open string so a newer sidecar's codes degrade to opaque errors, not drops. + error: z.object({ code: z.string(), message: z.string() }), + }), +]); +export type SimResult = z.infer; + +export const SimDeviceSchema = z.object({ + udid: z.string(), + name: z.string(), + /** CoreSimulator state string: `Shutdown`, `Booted`, `Booting`, … */ + state: z.string(), + /** Runtime identifier, e.g. `com.apple.CoreSimulator.SimRuntime.iOS-26-5`. */ + runtime: z.string(), + /** Human-readable runtime name (`iOS 26.5`); absent when the runtime section doesn't list it. */ + runtimeName: z.string().optional(), + deviceType: z.string().nullable(), +}); +export type SimDevice = z.infer; + +export const SimListResultSchema = z.object({ devices: z.array(SimDeviceSchema) }); + +export const SimProbeSchema = z.object({ + simctlPath: z.string(), + developerDir: z.string(), +}); +export type SimProbe = z.infer; + +export const SimLaunchResultSchema = z.object({ pid: z.number().int().nullable() }); + +export type SimImageFormat = 'jpeg' | 'png'; diff --git a/packages/host/sim/tsconfig.json b/packages/host/sim/tsconfig.json new file mode 100644 index 00000000..d2b74bb8 --- /dev/null +++ b/packages/host/sim/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1367cb4b..87745335 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: '@linkcode/schema': specifier: workspace:* version: link:../../packages/foundation/schema + '@linkcode/sim': + specifier: workspace:* + version: link:../../packages/host/sim '@linkcode/transport': specifier: workspace:* version: link:../../packages/foundation/transport @@ -1034,6 +1037,22 @@ importers: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' + packages/host/sim: + dependencies: + foxts: + specifier: ^5.8.0 + version: 5.8.0 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 26.1.1 + typescript: + specifier: 'catalog:' + version: '@typescript/typescript6@6.0.2' + packages/integrations/im-render: dependencies: '@linkcode/client-core': diff --git a/tsconfig.json b/tsconfig.json index 55ce4d07..b386b8dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ { "path": "packages/host/assets/tests" }, { "path": "packages/host/engine" }, { "path": "packages/host/engine/tests" }, + { "path": "packages/host/sim" }, { "path": "packages/integrations/im-render" }, { "path": "packages/presentation/i18n" }, { "path": "packages/presentation/ui" },