diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 23b9b94a..d4528c70 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -38,6 +38,9 @@ import type { SessionInfo, SessionNotification, SessionRecord, + SimulatorDevice, + SimulatorImageFormat, + SimulatorStatus, StartOptions, TerminalMetadata, TerminalReplayEvent, @@ -103,6 +106,7 @@ export interface AssetSettledEvent { type AssetProgressCb = (event: AssetProgressEvent) => void; type AssetSettledCb = (event: AssetSettledEvent) => void; type AgentRuntimesChangedCb = (runtimes: AgentRuntimes) => void; +type SimulatorDevicesChangedCb = (devices: SimulatorDevice[]) => void; type ConnectionCloseCb = (error: Error) => void; /** A broadcast about a schedule's or its runs' state — the three `schedule.*` push variants. */ @@ -142,6 +146,7 @@ export class LinkCodeClient { private readonly assetProgressSubs = new Set(); private readonly assetSettledSubs = new Set(); private readonly agentRuntimesChangedSubs = new Set(); + private readonly simulatorDevicesChangedSubs = new Set(); private readonly connectionCloseSubs = new Set(); private unsub: Unsubscribe | null = null; private offClose: Unsubscribe | null = null; @@ -307,6 +312,21 @@ export class LinkCodeClient { case 'agent-runtime.changed': for (const cb of this.agentRuntimesChangedSubs) cb(p.runtimes); break; + case 'simulator.status.result': + this.pending.resolve('simulatorStatus', p.replyTo, p.status); + break; + case 'simulator.listed': + this.pending.resolve('simulatorList', p.replyTo, p.devices); + break; + case 'simulator.launched': + this.pending.resolve('simulatorLaunch', p.replyTo, p.pid); + break; + case 'simulator.screenshotted': + this.pending.resolve('simulatorScreenshot', p.replyTo, { format: p.format, data: p.data }); + break; + case 'simulator.devices.changed': + for (const cb of this.simulatorDevicesChangedSubs) cb(p.devices); + break; case 'asset.listed': this.pending.resolve('assetList', p.replyTo, p.assets); break; @@ -602,6 +622,53 @@ export class LinkCodeClient { return () => this.agentRuntimesChangedSubs.delete(cb); } + /** Whether this host can drive iOS Simulators; gate the whole simulator surface on it. */ + simulatorStatus(): Promise { + return this.control.simulatorStatus(); + } + + simulatorList(): Promise { + return this.control.simulatorList(); + } + + simulatorBoot(sessionId: SessionId, udid: string): Promise { + return this.control.simulatorBoot(sessionId, udid); + } + + simulatorShutdown(sessionId: SessionId, udid: string): Promise { + return this.control.simulatorShutdown(sessionId, udid); + } + + simulatorInstall(sessionId: SessionId, udid: string, appPath: string): Promise { + return this.control.simulatorInstall(sessionId, udid, appPath); + } + + simulatorLaunch(sessionId: SessionId, udid: string, bundleId: string): Promise { + return this.control.simulatorLaunch(sessionId, udid, bundleId); + } + + simulatorTerminate(sessionId: SessionId, udid: string, bundleId: string): Promise { + return this.control.simulatorTerminate(sessionId, udid, bundleId); + } + + simulatorOpenUrl(sessionId: SessionId, udid: string, url: string): Promise { + return this.control.simulatorOpenUrl(sessionId, udid, url); + } + + simulatorScreenshot( + sessionId: SessionId, + udid: string, + format?: SimulatorImageFormat, + ): Promise<{ format: SimulatorImageFormat; data: string }> { + return this.control.simulatorScreenshot(sessionId, udid, format); + } + + /** Pushed after a state-changing simulator command (boot/shutdown) with a fresh device list. */ + subscribeSimulatorDevicesChanged(cb: SimulatorDevicesChangedCb): Unsubscribe { + this.simulatorDevicesChangedSubs.add(cb); + return () => this.simulatorDevicesChangedSubs.delete(cb); + } + setProviderConfig(providers: ProvidersConfig): Promise { return this.control.setProviderConfig(providers); } diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 2a56628c..ec677a6e 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -35,6 +35,9 @@ import type { SessionId, SessionInfo, SessionRecord, + SimulatorDevice, + SimulatorImageFormat, + SimulatorStatus, StartOptions, WirePayload, WorkspaceFile, @@ -563,6 +566,98 @@ export class ControlChannel { })); } + // iOS Simulator (CODE-394). Commands are session-scoped: the engine claims the device for + // `sessionId` (ownership/cap rules) before touching it. Gate the whole surface on + // `simulatorStatus().available`. + + simulatorStatus(): Promise { + return this.sendCorrelated('simulatorStatus', (clientReqId) => ({ + kind: 'simulator.status', + clientReqId, + })); + } + + simulatorList(): Promise { + return this.sendCorrelated('simulatorList', (clientReqId) => ({ + kind: 'simulator.list', + clientReqId, + })); + } + + simulatorBoot(sessionId: SessionId, udid: string): Promise { + return this.sendCorrelated('ack', (clientReqId) => ({ + kind: 'simulator.boot', + clientReqId, + sessionId, + udid, + })); + } + + simulatorShutdown(sessionId: SessionId, udid: string): Promise { + return this.sendCorrelated('ack', (clientReqId) => ({ + kind: 'simulator.shutdown', + clientReqId, + sessionId, + udid, + })); + } + + simulatorInstall(sessionId: SessionId, udid: string, appPath: string): Promise { + return this.sendCorrelated('ack', (clientReqId) => ({ + kind: 'simulator.install', + clientReqId, + sessionId, + udid, + appPath, + })); + } + + /** Resolves with the launched pid (`null` when the host could not report one). */ + simulatorLaunch(sessionId: SessionId, udid: string, bundleId: string): Promise { + return this.sendCorrelated('simulatorLaunch', (clientReqId) => ({ + kind: 'simulator.launch', + clientReqId, + sessionId, + udid, + bundleId, + })); + } + + simulatorTerminate(sessionId: SessionId, udid: string, bundleId: string): Promise { + return this.sendCorrelated('ack', (clientReqId) => ({ + kind: 'simulator.terminate', + clientReqId, + sessionId, + udid, + bundleId, + })); + } + + simulatorOpenUrl(sessionId: SessionId, udid: string, url: string): Promise { + return this.sendCorrelated('ack', (clientReqId) => ({ + kind: 'simulator.open-url', + clientReqId, + sessionId, + udid, + url, + })); + } + + /** Resolves with base64-encoded image bytes in the requested format (default jpeg). */ + simulatorScreenshot( + sessionId: SessionId, + udid: string, + format?: SimulatorImageFormat, + ): Promise<{ format: SimulatorImageFormat; data: string }> { + return this.sendCorrelated('simulatorScreenshot', (clientReqId) => ({ + kind: 'simulator.screenshot', + clientReqId, + sessionId, + udid, + format, + })); + } + private sendCorrelated( kind: K, makePayload: (clientReqId: string) => WirePayload, diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index e326c428..18d895ba 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -19,6 +19,9 @@ import type { SessionId, SessionInfo, SessionRecord, + SimulatorDevice, + SimulatorImageFormat, + SimulatorStatus, TerminalMetadata, WirePayload, WorkspaceFile, @@ -91,6 +94,10 @@ export interface PendingValueMap { terminalList: TerminalMetadata[]; terminalAttach: { terminal: TerminalMetadata; truncated: boolean }; agentLoginStart: string; + simulatorStatus: SimulatorStatus; + simulatorList: SimulatorDevice[]; + simulatorLaunch: number | null; + simulatorScreenshot: { format: SimulatorImageFormat; data: string }; } type PendingMaps = { [K in keyof PendingValueMap]: Map> }; @@ -135,6 +142,10 @@ export class PendingRegistry { terminalList: new Map(), terminalAttach: new Map(), agentLoginStart: new Map(), + simulatorStatus: new Map(), + simulatorList: new Map(), + simulatorLaunch: new Map(), + simulatorScreenshot: new Map(), }; private readonly randomUUID: RandomUUID; diff --git a/packages/foundation/schema/src/model/index.ts b/packages/foundation/schema/src/model/index.ts index 8799c544..e3ec4207 100644 --- a/packages/foundation/schema/src/model/index.ts +++ b/packages/foundation/schema/src/model/index.ts @@ -18,6 +18,7 @@ export * from './question'; export * from './schedule'; export * from './script'; export * from './session'; +export * from './simulator'; export * from './terminal'; export * from './tool-call'; export * from './usage'; diff --git a/packages/foundation/schema/src/model/simulator.ts b/packages/foundation/schema/src/model/simulator.ts new file mode 100644 index 00000000..a4a6605b --- /dev/null +++ b/packages/foundation/schema/src/model/simulator.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +/** + * iOS Simulator model shapes shared by the wire and clients. The host-side source of truth is + * the `linkcode-sim` sidecar (`crates/linkcode-sim/PROTOCOL.md`), reached through the engine's + * simulator service — device state is CoreSimulator's, not ours. + */ + +export const SimulatorDeviceSchema = z.object({ + udid: z.string().min(1), + 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 unknown. */ + runtimeName: z.string().optional(), + deviceType: z.string().nullable(), +}); +export type SimulatorDevice = z.infer; + +/** Whether this host can drive simulators at all (macOS + Xcode with the iOS platform). */ +export const SimulatorStatusSchema = z.object({ + available: z.boolean(), + /** Where simctl lives; present when available. */ + simctlPath: z.string().optional(), + developerDir: z.string().optional(), + /** Why unavailable (e.g. Xcode missing); present when not available. */ + reason: z.string().optional(), +}); +export type SimulatorStatus = z.infer; + +export const SimulatorImageFormatSchema = z.enum(['jpeg', 'png']); +export type SimulatorImageFormat = z.infer; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 37448ff5..9ce58e1f 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -13,6 +13,7 @@ import { WirePayloadSchema } from './payload'; // schema it does not speak. // 43 combines 42's agent.catalog/agent.cataloged with CODE-316's parallel 42 bump for // file.host/file.hosted, keeping every distinct schema on a distinct protocol version. +// 44 adds the simulator.* variants (CODE-394). export const WIRE_PROTOCOL_VERSION = 44 as const; /** Complete wire message: version + unique id + timestamp + payload. */ diff --git a/packages/foundation/schema/src/wire/payload.ts b/packages/foundation/schema/src/wire/payload.ts index 1316ba8f..e6e133e1 100644 --- a/packages/foundation/schema/src/wire/payload.ts +++ b/packages/foundation/schema/src/wire/payload.ts @@ -15,6 +15,7 @@ import { requestWireVariants } from './request'; import { scheduleWireVariants } from './schedule'; import { scriptWireVariants } from './script'; import { sessionWireVariants } from './session'; +import { simulatorWireVariants } from './simulator'; import { terminalWireVariants } from './terminal'; import { workspaceWireVariants } from './workspace'; @@ -37,6 +38,7 @@ export const WirePayloadSchema = z.discriminatedUnion('kind', [ ...artifactWireVariants, ...agentWireVariants, ...terminalWireVariants, + ...simulatorWireVariants, ...keepAliveWireVariants, ]); export type WirePayload = z.infer; diff --git a/packages/foundation/schema/src/wire/simulator.ts b/packages/foundation/schema/src/wire/simulator.ts new file mode 100644 index 00000000..b6bb0437 --- /dev/null +++ b/packages/foundation/schema/src/wire/simulator.ts @@ -0,0 +1,96 @@ +import { z } from 'zod'; +import { SessionIdSchema } from '../model/primitives'; +import { + SimulatorDeviceSchema, + SimulatorImageFormatSchema, + SimulatorStatusSchema, +} from '../model/simulator'; +import { WireRequestIdSchema } from './request'; + +const udid = z.string().min(1); + +/** + * iOS Simulator wire variants. Commands are session-scoped: the engine's simulator service + * claims the device for `sessionId` (ownership, conflict, and per-session cap rules) before + * touching it — clients never talk to the sidecar directly. Void commands reply with the generic + * `request.succeeded`/`request.failed`; `simulator.devices.changed` broadcasts a fresh device + * list after a state-changing command (boot/shutdown), since the engine has no CoreSimulator + * watcher. Screenshot bytes ride base64 in JSON for now — the binary channel is a P1 concern. + */ +export const simulatorWireVariants = [ + z.object({ kind: z.literal('simulator.status'), clientReqId: WireRequestIdSchema }), + z.object({ + kind: z.literal('simulator.status.result'), + replyTo: WireRequestIdSchema, + status: SimulatorStatusSchema, + }), + z.object({ kind: z.literal('simulator.list'), clientReqId: WireRequestIdSchema }), + z.object({ + kind: z.literal('simulator.listed'), + replyTo: WireRequestIdSchema, + devices: z.array(SimulatorDeviceSchema), + }), + z.object({ + kind: z.literal('simulator.devices.changed'), + devices: z.array(SimulatorDeviceSchema), + }), + z.object({ + kind: z.literal('simulator.boot'), + clientReqId: WireRequestIdSchema, + sessionId: SessionIdSchema, + udid, + }), + z.object({ + kind: z.literal('simulator.shutdown'), + clientReqId: WireRequestIdSchema, + sessionId: SessionIdSchema, + udid, + }), + z.object({ + kind: z.literal('simulator.install'), + clientReqId: WireRequestIdSchema, + sessionId: SessionIdSchema, + udid, + appPath: z.string().min(1), + }), + z.object({ + kind: z.literal('simulator.launch'), + clientReqId: WireRequestIdSchema, + sessionId: SessionIdSchema, + udid, + bundleId: z.string().min(1), + }), + z.object({ + kind: z.literal('simulator.launched'), + replyTo: WireRequestIdSchema, + pid: z.number().int().nullable(), + }), + z.object({ + kind: z.literal('simulator.terminate'), + clientReqId: WireRequestIdSchema, + sessionId: SessionIdSchema, + udid, + bundleId: z.string().min(1), + }), + z.object({ + kind: z.literal('simulator.open-url'), + clientReqId: WireRequestIdSchema, + sessionId: SessionIdSchema, + udid, + url: z.string().min(1), + }), + z.object({ + kind: z.literal('simulator.screenshot'), + clientReqId: WireRequestIdSchema, + sessionId: SessionIdSchema, + udid, + format: SimulatorImageFormatSchema.optional(), + }), + z.object({ + kind: z.literal('simulator.screenshotted'), + replyTo: WireRequestIdSchema, + format: SimulatorImageFormatSchema, + /** Base64-encoded image bytes. */ + data: z.string(), + }), +] as const; diff --git a/packages/foundation/schema/tests/contract/wire/simulator.test.ts b/packages/foundation/schema/tests/contract/wire/simulator.test.ts new file mode 100644 index 00000000..8797b306 --- /dev/null +++ b/packages/foundation/schema/tests/contract/wire/simulator.test.ts @@ -0,0 +1,80 @@ +import { WIRE_PROTOCOL_VERSION, WireMessageSchema } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; + +function accepts(payload: unknown): boolean { + return WireMessageSchema.safeParse({ + v: WIRE_PROTOCOL_VERSION, + id: 'message-1', + ts: 0, + payload, + }).success; +} + +describe('simulator wire variants', () => { + it.each([ + { kind: 'simulator.status', clientReqId: 'r1' }, + { + kind: 'simulator.status.result', + replyTo: 'r1', + status: { available: true, simctlPath: '/usr/bin/simctl', developerDir: '/dev' }, + }, + { + kind: 'simulator.status.result', + replyTo: 'r1', + status: { available: false, reason: 'xcode missing' }, + }, + { kind: 'simulator.list', clientReqId: 'r2' }, + { + kind: 'simulator.listed', + replyTo: 'r2', + devices: [ + { + udid: 'U-1', + name: 'iPhone 17', + state: 'Shutdown', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', + runtimeName: 'iOS 26.5', + deviceType: null, + }, + ], + }, + { kind: 'simulator.devices.changed', devices: [] }, + { kind: 'simulator.boot', clientReqId: 'r3', sessionId: 'session-1', udid: 'U-1' }, + { kind: 'simulator.shutdown', clientReqId: 'r4', sessionId: 'session-1', udid: 'U-1' }, + { + kind: 'simulator.install', + clientReqId: 'r5', + sessionId: 'session-1', + udid: 'U-1', + appPath: '/tmp/Fixture.app', + }, + { + kind: 'simulator.launch', + clientReqId: 'r6', + sessionId: 'session-1', + udid: 'U-1', + bundleId: 'com.example.app', + }, + { kind: 'simulator.launched', replyTo: 'r6', pid: 4242 }, + { kind: 'simulator.launched', replyTo: 'r6', pid: null }, + { + kind: 'simulator.open-url', + clientReqId: 'r7', + sessionId: 'session-1', + udid: 'U-1', + url: 'https://example.com', + }, + { kind: 'simulator.screenshot', clientReqId: 'r8', sessionId: 'session-1', udid: 'U-1' }, + { kind: 'simulator.screenshotted', replyTo: 'r8', format: 'jpeg', data: 'aGVsbG8=' }, + ])('accepts $kind through the complete wire envelope', (payload) => { + expect(accepts(payload)).toBe(true); + }); + + it.each([ + { kind: 'simulator.boot', clientReqId: 'r1', sessionId: 'session-1', udid: '' }, + { kind: 'simulator.screenshot', clientReqId: 'r1', sessionId: 'session-1' }, + { kind: 'simulator.screenshotted', replyTo: 'r1', format: 'gif', data: '' }, + ])('rejects malformed $kind', (payload) => { + expect(accepts(payload)).toBe(false); + }); +}); diff --git a/packages/host/engine/src/__tests__/simulator-request-handler.test.ts b/packages/host/engine/src/__tests__/simulator-request-handler.test.ts new file mode 100644 index 00000000..16636c74 --- /dev/null +++ b/packages/host/engine/src/__tests__/simulator-request-handler.test.ts @@ -0,0 +1,165 @@ +import type { SessionId, ValidatedWireMessage, WirePayload } from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; +import { nullthrow } from 'foxts/guard'; +import { asyncNoop, noop } from 'foxts/noop'; +import { describe, expect, it, vi } from 'vitest'; +import type { SimulatorBackend } from '../simulator/backend'; +import { FakeAdapter, settleEngineTasks, startedSessionId } from './fixtures/session-harness'; +import { createTestEngine } from './fixtures/test-engine'; + +function fakeBackend(): SimulatorBackend { + const devices = [ + { + udid: 'U-1', + name: 'iPhone 17', + state: 'Shutdown', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', + deviceType: null, + }, + ]; + return { + probe: vi.fn(() => Promise.resolve({ simctlPath: '/usr/bin/simctl', developerDir: '/dev' })), + list: vi.fn(() => Promise.resolve(devices)), + boot: vi.fn(() => { + devices[0] = { ...devices[0], state: 'Booted' }; + return Promise.resolve(); + }), + shutdownDevice: vi.fn(asyncNoop), + install: vi.fn(asyncNoop), + launch: vi.fn(() => Promise.resolve(77)), + terminate: vi.fn(asyncNoop), + openUrl: vi.fn(asyncNoop), + screenshot: vi.fn(() => Promise.resolve(new Uint8Array([0xff, 0xd8, 0x01]))), + close: vi.fn(noop), + }; +} + +function harness(backend?: SimulatorBackend) { + const sent: WirePayload[] = []; + let handler: ((msg: ValidatedWireMessage) => void) | null = null; + const transport: Transport = { + connect: () => Promise.resolve(), + send(msg: ValidatedWireMessage) { + sent.push(msg.payload); + }, + onMessage(cb) { + handler = cb; + return noop; + }, + onClose: () => noop, + close: noop, + }; + const engine = createTestEngine(transport, { + simulatorBackend: backend, + factory: () => new FakeAdapter(), + }); + async function inject(payload: WirePayload): Promise { + nullthrow(handler, 'engine not started')(createWireMessage(payload)); + await settleEngineTasks(); + } + function reply(replyTo: string): WirePayload | undefined { + return sent.find((p) => 'replyTo' in p && p.replyTo === replyTo); + } + // Simulator commands are session-scoped and the engine rejects unknown sessions, so start a real + // one and return its id. + async function startSession(clientReqId: string): Promise { + await inject({ + kind: 'session.start', + clientReqId, + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + return startedSessionId(sent, clientReqId); + } + return { engine, sent, inject, reply, startSession }; +} + +describe('simulator wire requests', () => { + it('reports unavailable without a backend and rejects commands as unsupported', async () => { + const h = harness(); + await h.engine.start(); + + await h.inject({ kind: 'simulator.status', clientReqId: 'st' }); + expect(h.reply('st')).toMatchObject({ + kind: 'simulator.status.result', + status: { available: false }, + }); + + await h.inject({ + kind: 'simulator.boot', + clientReqId: 'boot', + sessionId: 'session-1' as never, + udid: 'U-1', + }); + expect(h.reply('boot')).toMatchObject({ kind: 'request.failed', code: 'unsupported' }); + await h.engine.stop(); + }); + + it('serves status, list, and the boot → devices.changed flow', async () => { + const h = harness(fakeBackend()); + await h.engine.start(); + + await h.inject({ kind: 'simulator.status', clientReqId: 'st' }); + expect(h.reply('st')).toMatchObject({ + kind: 'simulator.status.result', + status: { available: true, simctlPath: '/usr/bin/simctl' }, + }); + + await h.inject({ kind: 'simulator.list', clientReqId: 'ls' }); + expect(h.reply('ls')).toMatchObject({ + kind: 'simulator.listed', + devices: [{ udid: 'U-1', state: 'Shutdown' }], + }); + + const s1 = await h.startSession('s1'); + await h.inject({ + kind: 'simulator.boot', + clientReqId: 'boot', + sessionId: s1, + udid: 'U-1', + }); + expect(h.reply('boot')).toMatchObject({ kind: 'request.succeeded' }); + const changed = h.sent.find((p) => p.kind === 'simulator.devices.changed'); + expect(changed).toMatchObject({ devices: [{ udid: 'U-1', state: 'Booted' }] }); + await h.engine.stop(); + }); + + it('routes launch and screenshot replies and enforces cross-session ownership', async () => { + const h = harness(fakeBackend()); + await h.engine.start(); + + const s1 = await h.startSession('s1'); + await h.inject({ + kind: 'simulator.launch', + clientReqId: 'go', + sessionId: s1, + udid: 'U-1', + bundleId: 'com.example.app', + }); + expect(h.reply('go')).toMatchObject({ kind: 'simulator.launched', pid: 77 }); + + await h.inject({ + kind: 'simulator.screenshot', + clientReqId: 'shot', + sessionId: s1, + udid: 'U-1', + }); + expect(h.reply('shot')).toMatchObject({ + kind: 'simulator.screenshotted', + format: 'jpeg', + data: Buffer.from([0xff, 0xd8, 0x01]).toString('base64'), + }); + + // The device belongs to s1 now; a *different* live session's command must fail as a conflict. + const s2 = await h.startSession('s2'); + await h.inject({ + kind: 'simulator.open-url', + clientReqId: 'steal', + sessionId: s2, + udid: 'U-1', + url: 'https://example.com', + }); + expect(h.reply('steal')).toMatchObject({ kind: 'request.failed', code: 'conflict' }); + await h.engine.stop(); + }); +}); diff --git a/packages/host/engine/src/__tests__/simulator-service.test.ts b/packages/host/engine/src/__tests__/simulator-service.test.ts index a01c1eaa..6ab30d8c 100644 --- a/packages/host/engine/src/__tests__/simulator-service.test.ts +++ b/packages/host/engine/src/__tests__/simulator-service.test.ts @@ -201,6 +201,38 @@ describe('SimulatorService', () => { expect(service.ownerOf('A')).toBe(S1); }); + it('rejects operations from a session the engine does not know', async () => { + const backend = fakeBackend([device('A', 'Shutdown')]); + const service = new SimulatorService(backend, { hasSession: (id) => id === S1 }); + + await expect(service.boot(S2, 'A')).rejects.toMatchObject({ code: 'not_found' }); + // A rejected fabricated session must not leave a claim behind (it never gets a session-stop). + expect(service.ownerOf('A')).toBeUndefined(); + await expect(service.boot(S1, 'A')).resolves.toBeUndefined(); + }); + + it('rolls back a claim the failed command created', async () => { + const backend = fakeBackend([device('A', 'Shutdown')]); + backend.openUrl.mockRejectedValueOnce(new Error('boom')); + const service = new SimulatorService(backend); + + await expect(service.openUrl(S1, 'A', 'https://example.com')).rejects.toThrow('boom'); + // The failed command never acquired the device, so it is free for another session. + expect(service.ownerOf('A')).toBeUndefined(); + await expect(service.openUrl(S2, 'A', 'https://example.com')).resolves.toBeUndefined(); + }); + + it('keeps a pre-existing claim when a later command fails', async () => { + const backend = fakeBackend([device('A', 'Shutdown')]); + const service = new SimulatorService(backend); + + await service.boot(S1, 'A'); + backend.openUrl.mockRejectedValueOnce(new Error('boom')); + await expect(service.openUrl(S1, 'A', 'https://example.com')).rejects.toThrow('boom'); + // The claim pre-existed this command, so its failure must not release S1's device. + 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); diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index ae140762..4b4a2037 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 { SimulatorRequestHandler } from './simulator/request-handler'; import { SimulatorService } from './simulator/service'; import { TerminalRequestHandler } from './terminal/request-handler'; import { TerminalService } from './terminal/service'; @@ -83,9 +84,9 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( runTask, ); let terminals: TerminalService | undefined; - const simulators = deps.simulatorBackend - ? new SimulatorService(deps.simulatorBackend) - : undefined; + // Constructed after `sessions` (both reference it lazily), mirroring `terminals`, so the + // session-existence predicate can gate simulator claims on a live session. + let simulators: SimulatorService | undefined; const sessions = new SessionOrchestrator( transport, factory, @@ -98,10 +99,14 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( simulators?.releaseSession(sessionId); }, ); + simulators = deps.simulatorBackend + ? new SimulatorService(deps.simulatorBackend, { hasSession: (id) => sessions.has(id) }) + : undefined; terminals = deps.ptyBackend ? new TerminalService(deps.ptyBackend, transport, (id) => sessions.has(id)) : undefined; const terminalRequests = new TerminalRequestHandler(terminals, responder); + const simulatorRequests = new SimulatorRequestHandler(simulators, transport, responder); const workspaces = new WorkspaceRegistry(deps.workspaceStore ?? new InMemoryWorkspaceStore()); const workspaceRequests = new WorkspaceRequestHandler(transport, workspaces, responder); const git = deps.git ?? (yield* GitService.make()); @@ -187,6 +192,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( artifact: artifactRequests, automation: automationRequests, terminal: terminalRequests, + simulator: simulatorRequests, }); let acceptingRequests = false; let unsubscribeRequests: Unsubscribe | undefined; diff --git a/packages/host/engine/src/failure.ts b/packages/host/engine/src/failure.ts index c8f8aeff..c27fbccd 100644 --- a/packages/host/engine/src/failure.ts +++ b/packages/host/engine/src/failure.ts @@ -22,6 +22,7 @@ export type OperationSubsystem = | 'pty' | 'runtime-probe' | 'script' + | 'simulator' | 'store' | 'translator' | 'transport'; diff --git a/packages/host/engine/src/simulator/request-handler.ts b/packages/host/engine/src/simulator/request-handler.ts new file mode 100644 index 00000000..0b3f36f4 --- /dev/null +++ b/packages/host/engine/src/simulator/request-handler.ts @@ -0,0 +1,178 @@ +import type { WirePayload } from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; +import { Effect } from 'effect'; +import type { EngineFailure } from '../failure'; +import { RequestError, toOperationFailure } from '../failure'; +import type { WireResponder } from '../wire/responder'; +import type { SimulatorService } from './service'; + +type SimulatorRequest = Extract< + WirePayload, + { + kind: + | 'simulator.status' + | 'simulator.list' + | 'simulator.boot' + | 'simulator.shutdown' + | 'simulator.install' + | 'simulator.launch' + | 'simulator.terminate' + | 'simulator.open-url' + | 'simulator.screenshot'; + } +>; + +/** + * Translates inbound simulator wire requests into operations on the optional simulator service. + * Ownership, caps, and reclaim all live in the service; this layer only correlates replies and + * broadcasts a fresh device list after state-changing commands (the engine has no CoreSimulator + * watcher, so its own commands are the only change source it can observe). + */ +export class SimulatorRequestHandler { + constructor( + private readonly simulators: SimulatorService | undefined, + private readonly transport: Transport, + private readonly responder: WireResponder, + ) {} + + handle(payload: SimulatorRequest): Effect.Effect { + switch (payload.kind) { + case 'simulator.status': + // Answered even without a backend: `available: false` IS the capability signal clients + // gate their UI on, so it must never surface as an error. + return this.responder.reply( + payload.clientReqId, + simulatorOperation('simulator.status', 'Failed to probe simulators', async () => { + const status = this.simulators + ? await this.simulators.status() + : { available: false, reason: 'simulators are unavailable on this host' }; + this.transport.send( + createWireMessage({ + kind: 'simulator.status.result', + replyTo: payload.clientReqId, + status, + }), + ); + }), + ); + case 'simulator.list': + return this.withSimulators(payload.clientReqId, (simulators) => + simulatorOperation('simulator.list', 'Failed to list simulators', async () => { + const devices = await simulators.list(); + this.transport.send( + createWireMessage({ + kind: 'simulator.listed', + replyTo: payload.clientReqId, + devices, + }), + ); + }), + ); + case 'simulator.boot': + return this.withSimulators(payload.clientReqId, (simulators) => + simulatorOperation('simulator.boot', 'Failed to boot simulator', async () => { + await simulators.boot(payload.sessionId, payload.udid); + this.responder.sendSuccess(payload.clientReqId); + await this.broadcastDevices(simulators); + }), + ); + case 'simulator.shutdown': + return this.withSimulators(payload.clientReqId, (simulators) => + simulatorOperation('simulator.shutdown', 'Failed to shut simulator down', async () => { + await simulators.shutdownDevice(payload.sessionId, payload.udid); + this.responder.sendSuccess(payload.clientReqId); + await this.broadcastDevices(simulators); + }), + ); + case 'simulator.install': + return this.withSimulators(payload.clientReqId, (simulators) => + simulatorOperation('simulator.install', 'Failed to install app', async () => { + await simulators.install(payload.sessionId, payload.udid, payload.appPath); + this.responder.sendSuccess(payload.clientReqId); + }), + ); + case 'simulator.launch': + return this.withSimulators(payload.clientReqId, (simulators) => + simulatorOperation('simulator.launch', 'Failed to launch app', async () => { + const pid = await simulators.launch(payload.sessionId, payload.udid, payload.bundleId); + this.transport.send( + createWireMessage({ + kind: 'simulator.launched', + replyTo: payload.clientReqId, + pid, + }), + ); + }), + ); + case 'simulator.terminate': + return this.withSimulators(payload.clientReqId, (simulators) => + simulatorOperation('simulator.terminate', 'Failed to terminate app', async () => { + await simulators.terminate(payload.sessionId, payload.udid, payload.bundleId); + this.responder.sendSuccess(payload.clientReqId); + }), + ); + case 'simulator.open-url': + return this.withSimulators(payload.clientReqId, (simulators) => + simulatorOperation('simulator.open-url', 'Failed to open URL', async () => { + await simulators.openUrl(payload.sessionId, payload.udid, payload.url); + this.responder.sendSuccess(payload.clientReqId); + }), + ); + case 'simulator.screenshot': + return this.withSimulators(payload.clientReqId, (simulators) => + simulatorOperation('simulator.screenshot', 'Failed to capture screenshot', async () => { + const format = payload.format ?? 'jpeg'; + const image = await simulators.screenshot(payload.sessionId, payload.udid, format); + this.transport.send( + createWireMessage({ + kind: 'simulator.screenshotted', + replyTo: payload.clientReqId, + format, + data: Buffer.from(image).toString('base64'), + }), + ); + }), + ); + default: + return Effect.void; + } + } + + /** Best-effort push after a state-changing command; the command itself already succeeded. */ + private async broadcastDevices(simulators: SimulatorService): Promise { + try { + const devices = await simulators.list(); + this.transport.send(createWireMessage({ kind: 'simulator.devices.changed', devices })); + } catch { + // The next explicit list will reconcile; failing the completed command would be worse. + } + } + + private withSimulators( + replyTo: string, + fn: (simulators: SimulatorService) => Effect.Effect, + ): Effect.Effect { + const operation = this.simulators + ? fn(this.simulators) + : Effect.fail( + new RequestError({ + code: 'unsupported', + message: 'Simulators are not supported on this host', + }), + ); + return this.responder.reply(replyTo, operation); + } +} + +function simulatorOperation( + operation: string, + publicMessage: string, + run: () => Promise, +): Effect.Effect { + return Effect.tryPromise({ + try: run, + catch: (cause) => + toOperationFailure(cause, { subsystem: 'simulator', operation, publicMessage }), + }); +} diff --git a/packages/host/engine/src/simulator/service.ts b/packages/host/engine/src/simulator/service.ts index 9b874565..2135aa41 100644 --- a/packages/host/engine/src/simulator/service.ts +++ b/packages/host/engine/src/simulator/service.ts @@ -1,9 +1,18 @@ import type { SessionId } from '@linkcode/schema'; import { Effect } from 'effect'; +import { extractErrorMessage } from 'foxts/extract-error-message'; import { noop } from 'foxts/noop'; import { RequestError } from '../failure'; import type { SimulatorBackend, SimulatorDeviceInfo, SimulatorImageFormat } from './backend'; +/** Lazily-probed host capability; mirrors the wire's `SimulatorStatus` shape structurally. */ +export interface SimulatorHostStatus { + available: boolean; + simctlPath?: string; + developerDir?: string; + reason?: string; +} + /** 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. */ @@ -35,18 +44,37 @@ interface DeviceClaim { export class SimulatorService { private readonly claims = new Map(); private readonly idleReclaimMs: number; + private readonly hasSession?: (sessionId: SessionId) => boolean; + private probedStatus?: SimulatorHostStatus; constructor( private readonly backend: SimulatorBackend, - opts?: { idleReclaimMs?: number }, + opts?: { idleReclaimMs?: number; hasSession?: (sessionId: SessionId) => boolean }, ) { this.idleReclaimMs = opts?.idleReclaimMs ?? IDLE_RECLAIM_MS; + this.hasSession = opts?.hasSession; } probe(): Promise<{ simctlPath: string; developerDir: string }> { return this.backend.probe(); } + /** + * Whether this host can drive simulators, probed lazily. A success is cached (tooling doesn't + * un-install mid-run); a failure is re-probed on the next ask so installing Xcode heals the + * capability without a daemon restart. + */ + async status(): Promise { + if (this.probedStatus) return this.probedStatus; + try { + const probe = await this.backend.probe(); + this.probedStatus = { available: true, ...probe }; + return this.probedStatus; + } catch (error) { + return { available: false, reason: extractErrorMessage(error) ?? 'probe failed' }; + } + } + /** Read-only; claims are not required to look. */ list(): Promise { return this.backend.list(); @@ -96,23 +124,19 @@ export class SimulatorService { // 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); + return this.withClaim(sessionId, udid, () => this.backend.install(udid, appPath)); } async launch(sessionId: SessionId, udid: string, bundleId: string): Promise { - this.claim(sessionId, udid); - return this.backend.launch(udid, bundleId); + return this.withClaim(sessionId, udid, () => this.backend.launch(udid, bundleId)); } async terminate(sessionId: SessionId, udid: string, bundleId: string): Promise { - this.claim(sessionId, udid); - return this.backend.terminate(udid, bundleId); + return this.withClaim(sessionId, udid, () => this.backend.terminate(udid, bundleId)); } async openUrl(sessionId: SessionId, udid: string, url: string): Promise { - this.claim(sessionId, udid); - return this.backend.openUrl(udid, url); + return this.withClaim(sessionId, udid, () => this.backend.openUrl(udid, url)); } async screenshot( @@ -120,8 +144,7 @@ export class SimulatorService { udid: string, format?: SimulatorImageFormat, ): Promise { - this.claim(sessionId, udid); - return this.backend.screenshot(udid, format); + return this.withClaim(sessionId, udid, () => this.backend.screenshot(udid, format)); } /** Release every device a session holds (the session-stop hook). */ @@ -143,12 +166,36 @@ export class SimulatorService { ); } + /** Run `op` under a claim on `udid`, rolling the claim back if `op` rejects AND this call is what + * created it — a command that failed never actually acquired the device, so it must not keep + * consuming the session's cap or block other sessions. A refreshed (pre-existing) claim is left + * intact. Boot is the deliberate exception: it reconciles ownership itself, since a failed boot + * may still have started the device server-side. */ + private async withClaim(sessionId: SessionId, udid: string, op: () => Promise): Promise { + const created = this.claim(sessionId, udid); + try { + return await op(); + } catch (error) { + if (created && this.claims.get(udid)?.sessionId === sessionId) this.drop(udid); + throw error; + } + } + /** * 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. + * Returns whether it created a new claim (vs. refreshed an existing one). Throws `not_found` for + * an unknown session, `conflict` when another session holds the device, and `limit_exceeded` past + * the per-session cap. */ - private claim(sessionId: SessionId, udid: string): void { + private claim(sessionId: SessionId, udid: string): boolean { + // Reject a stale or fabricated session before it claims a device: the engine emits no + // session-stop for one it never started, so its claim would never be released. + if (this.hasSession && !this.hasSession(sessionId)) { + throw new RequestError({ + code: 'not_found', + message: `session ${sessionId} is not active`, + }); + } const existing = this.claims.get(udid); if (existing) { if (existing.sessionId !== sessionId) { @@ -164,7 +211,7 @@ export class SimulatorService { // 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; + return false; } let held = 0; for (const claim of this.claims.values()) { @@ -177,6 +224,7 @@ export class SimulatorService { }); } this.claims.set(udid, { sessionId, bootedByService: false }); + return true; } private release(udid: string, claim: DeviceClaim): void { diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index e48e31ee..9cbd85a8 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -11,6 +11,7 @@ import type { ArtifactRequestHandler } from '../preview/request-handler'; import type { ScriptRequestHandler } from '../scripts/request-handler'; import type { HistoryRequestHandler } from '../session/history-request-handler'; import type { SessionRequestHandler } from '../session/request-handler'; +import type { SimulatorRequestHandler } from '../simulator/request-handler'; import type { TerminalRequestHandler } from '../terminal/request-handler'; import type { FileRequestHandler } from '../workspace/file-request-handler'; import type { WorkspaceRequestHandler } from '../workspace/request-handler'; @@ -27,6 +28,7 @@ interface RequestHandlers { readonly artifact: ArtifactRequestHandler; readonly automation: AutomationRequestHandler; readonly terminal: TerminalRequestHandler; + readonly simulator: SimulatorRequestHandler; } export class WireRequestRouter { @@ -127,6 +129,17 @@ export class WireRequestRouter { case 'terminal.close': { return this.handlers.terminal.handle(p); } + case 'simulator.status': + case 'simulator.list': + case 'simulator.boot': + case 'simulator.shutdown': + case 'simulator.install': + case 'simulator.launch': + case 'simulator.terminate': + case 'simulator.open-url': + case 'simulator.screenshot': { + return this.handlers.simulator.handle(p); + } case 'agent-login.start': case 'agent-login.submit-code': case 'agent-login.cancel': {