diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index a91236579caa..2d05f8343651 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -15,9 +15,17 @@ const makeReady = ( }, ) => { const calls: Call[] = []; + const unused = Effect.die("Device actions run host commands, never device operations"); const ready: DeviceHostReady = { nodePath: process.execPath, hub: { origin: "http://127.0.0.1:1" }, + devices: { + list: unused, + boot: () => unused, + attach: () => unused, + shutdown: () => unused, + screenshot: () => unused, + }, helpers, run: (command, args, options) => { const call = { command, args, ...(options?.stdin ? { stdin: options.stdin } : {}) }; diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index aa82ae83fd68..fdc01ee664f0 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -1,8 +1,11 @@ import type { + DeviceBootError, DeviceHostId, DeviceHostSummary, + DeviceOperationError, DevicePlatform, DevicePlatformAvailability, + DeviceSummary, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -71,9 +74,39 @@ export interface AgentDeviceEndpoint { readonly entryPath: string; } +export type HostDevice = Omit; + +/** Discovery and lifecycle for the devices a ready host can run. */ +export interface DeviceOperations { + readonly list: Effect.Effect< + { + readonly devices: ReadonlyArray; + readonly detail: string | undefined; + /** Some Android virtual devices could not be listed; the rest are. */ + readonly androidDiscoveryIncomplete?: boolean; + }, + DeviceOperationError + >; + /** + * Boots the device and prepares its stream. Android AVDs change id when they + * boot (AVD name to emulator serial), so the returned id is authoritative. + */ + readonly boot: ( + device: HostDevice, + ) => Effect.Effect; + /** Prepares the stream of a device that is already booted. */ + readonly attach: (device: HostDevice) => Effect.Effect; + readonly shutdown: (device: { + readonly id: string; + readonly platform: DevicePlatform; + }) => Effect.Effect; + readonly screenshot: (device: HostDevice) => Effect.Effect; +} + export interface DeviceHostReady { readonly nodePath: string; readonly hub: DeviceHubEndpoint; + readonly devices: DeviceOperations; readonly run: ( command: string, args: ReadonlyArray, diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts new file mode 100644 index 000000000000..529bafba4a9d --- /dev/null +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -0,0 +1,247 @@ +import { + DeviceBootError, + type DeviceHostId, + DeviceOperationError, + type DevicePlatform, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; + +import type { DeviceHostReady, DeviceOperations, HostDevice } from "./DeviceHost.ts"; + +const BOOT_TIMEOUT = Duration.minutes(3); +const SCREENSHOT_TIMEOUT = Duration.seconds(20); + +const HubDevice = Schema.Struct({ + id: Schema.String, + name: Schema.String, + version: Schema.String, + platform: Schema.Literals(["ios", "android"]), + booted: Schema.Boolean, + physical: Schema.Boolean, +}); +const HubDeviceList = Schema.Struct({ + simulators: Schema.Array(HubDevice), + emulators: Schema.Array(HubDevice), + errors: Schema.optional( + Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })), + ), +}); +const HubActionResult = Schema.Struct({ + ok: Schema.Boolean, + id: Schema.optional(Schema.String), + serial: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + errors: Schema.optional( + Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })), + ), +}); + +const vendorPrefix = (platform: DevicePlatform) => + platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; + +/** Device operations for a host that runs expo-device-hub at `origin`. */ +export const makeDeviceHubOperations = (options: { + readonly hostId: DeviceHostId; + readonly origin: string; + readonly http: HttpClient.HttpClient; + readonly run: DeviceHostReady["run"]; + /** Whether to list AVDs, which the hub does not report until they boot. */ + readonly androidAvailable: Effect.Effect; +}): DeviceOperations => { + const { hostId, origin, run } = options; + const httpClient = options.http.pipe(HttpClient.withScope); + + const hubJson = ( + request: HttpClientRequest.HttpClientRequest, + schema: Schema.Codec, + operation: string, + timeout: Duration.Input = Duration.seconds(15), + ) => + httpClient.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), + Effect.scoped, + Effect.timeout(timeout), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation, + reason: "request_failed", + cause, + }), + ), + ); + + const postJson = (path: string, body: Record, operation: string) => + HttpClientRequest.post(`${origin}${path}`).pipe( + HttpClientRequest.bodyJson(body), + Effect.mapError( + (cause) => new DeviceOperationError({ operation, reason: "invalid_payload", cause }), + ), + ); + + // Booting alone does not attach a serve-sim helper; the grid start does both + // and is idempotent for a booted simulator. + const attachIosStream = (udid: string, operation: string) => + postJson(`${vendorPrefix("ios")}/grid/api/start`, { udid }, operation).pipe( + Effect.flatMap((request) => hubJson(request, HubActionResult, "attach stream", BOOT_TIMEOUT)), + ); + + const list: DeviceOperations["list"] = Effect.gen(function* () { + const hubList = yield* hubJson( + HttpClientRequest.get(`${origin}/api/devices`), + HubDeviceList, + "list", + ); + const devices: Array = [...hubList.simulators, ...hubList.emulators]; + const warnings = hubList.errors?.filter( + (error) => + // CoreSimulator can retain deleted devices; their optional usage timestamps are absent. + !( + error.message.startsWith("[apple-utils] Failed to read CoreSimulator plist at ") && + /\/CoreSimulator\/Devices\/[^/]+\/device\.plist:$/.test(error.message) && + /(?:^Error:\s*ENOENT:|\bcode:\s*['"]ENOENT['"])/.test(error.error ?? "") + ), + ); + const details = warnings?.map((error) => error.message) ?? []; + let androidDiscoveryIncomplete = false; + if (yield* options.androidAvailable) { + const avds = yield* run("emulator", ["-list-avds"]); + // Discovered devices stay listed; the AVDs that could not be listed are a warning. + if (avds.code !== 0) { + androidDiscoveryIncomplete = true; + details.push( + `Could not list Android virtual devices (emulator -list-avds, exit code ${avds.code}). Discovered devices remain available.`, + ); + } + for (const name of (avds.code === 0 ? avds.stdout : "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean)) { + if (!devices.some((device) => device.platform === "android" && device.name === name)) { + devices.push({ + id: name, + name, + platform: "android", + version: "Android", + booted: false, + physical: false, + }); + } + } + } + return { + devices, + detail: details.join("\n") || undefined, + androidDiscoveryIncomplete, + }; + }).pipe(Effect.withSpan("DeviceHubOperations.list")); + + const boot: DeviceOperations["boot"] = Effect.fn("DeviceHubOperations.boot")(function* (device) { + const result = yield* postJson( + "/api/devices/boot", + { platform: device.platform, id: device.id, name: device.name }, + "boot", + ).pipe(Effect.flatMap((request) => hubJson(request, HubActionResult, "boot", BOOT_TIMEOUT))); + if (!result.ok) { + // Keep the hub's launch command and nested failure available to agents for recovery. + const diagnostics = [ + result.error, + ...(result.errors ?? []).flatMap((error) => [error.message, error.error]), + ] + .filter((text) => text?.trim()) + .join("\n\n"); + return yield* new DeviceBootError({ + hostId, + deviceId: device.id, + reason: /insufficient.*(?:disk|space)|not enough.*(?:disk|space)|no space left/i.test( + diagnostics, + ) + ? "disk_space" + : /timed? out|timeout/i.test(diagnostics) + ? "timeout" + : "launch_failed", + ...(diagnostics + ? { + diagnostics: + diagnostics.length > 2_000 ? `${diagnostics.slice(0, 1_999)}…` : diagnostics, + } + : {}), + cause: result, + }); + } + if (device.platform === "ios") yield* attachIosStream(device.id, "boot"); + return result.serial ?? result.id ?? device.id; + }); + + const attach: DeviceOperations["attach"] = (device) => + device.platform === "ios" + ? attachIosStream(device.id, "open").pipe(Effect.asVoid) + : Effect.void; + + const shutdown: DeviceOperations["shutdown"] = Effect.fn("DeviceHubOperations.shutdown")( + function* (device) { + const postShutdown = (path: string, body: Record) => + postJson(path, body, "shutdown").pipe( + Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")), + Effect.flatMap((result) => + result.ok + ? Effect.void + : Effect.fail( + new DeviceOperationError({ + operation: "shutdown", + reason: "hub_rejected", + cause: result, + }), + ), + ), + ); + // serve-sim's shutdown closes its in-process capture session before it + // runs `simctl shutdown`; the hub's generic shutdown can leave that + // session cached across a reboot. serve-sim runs simctl bare, though, so + // a simulator that is already off fails there. + yield* device.platform === "ios" + ? postShutdown(`${vendorPrefix("ios")}/grid/api/shutdown`, { udid: device.id }).pipe( + Effect.catch((cause) => + list.pipe( + Effect.flatMap(({ devices }) => + devices.find((candidate) => candidate.id === device.id)?.booted === false + ? Effect.logInfo("iOS simulator was already shut down", { deviceId: device.id }) + : Effect.fail(cause), + ), + ), + ), + ) + : postShutdown("/api/devices/shutdown", { platform: device.platform, id: device.id }); + }, + ); + + const screenshot: DeviceOperations["screenshot"] = (device) => + httpClient + .execute( + HttpClientRequest.post( + `${origin}${vendorPrefix(device.platform)}/api/screenshot?device=${encodeURIComponent(device.id)}`, + ), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.arrayBuffer), + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.scoped, + Effect.timeout(SCREENSHOT_TIMEOUT), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation: "screenshot", + reason: "request_failed", + cause, + }), + ), + Effect.withSpan("DeviceHubOperations.screenshot"), + ); + + return { list, boot, attach, shutdown, screenshot }; +}; diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index cb843d8f3761..3250908299ad 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -7,21 +7,50 @@ import * as Effect from "effect/Effect"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerSettings from "../serverSettings.ts"; import * as DeviceHost from "./DeviceHost.ts"; +import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import * as DeviceService from "./DeviceService.ts"; it.effect("keeps hosts independent when serials collide and another host fails", () => Effect.gen(function* () { + const http = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + simulators: [], + emulators: [ + { + id: "emulator-5554", + name: "Pixel", + version: "36", + platform: "android", + booted: true, + physical: false, + }, + ], + }), + ), + ), + ); const host = (id: string, failed = false): DeviceHost.DeviceHost["Service"] => { + const run = (command: string) => + Effect.succeed({ + stdout: command === "emulator" ? "" : " V....D libx264", + stderr: "", + code: 0, + }); const ready: DeviceHost.DeviceHostAgentReady = { nodePath: process.execPath, hub: { origin: `http://${id}` }, + devices: makeDeviceHubOperations({ + hostId: id, + origin: `http://${id}`, + http, + run, + androidAvailable: Effect.succeed(true), + }), agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, - run: (command) => - Effect.succeed({ - stdout: command === "emulator" ? "" : " V....D libx264", - stderr: "", - code: 0, - }), + run, helpers: { serveSimAxSettings: null, serveSimCli: null }, }; return { @@ -51,26 +80,6 @@ it.effect("keeps hosts independent when serials collide and another host fails", stop: Effect.void, }; }; - const http = HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ - simulators: [], - emulators: [ - { - id: "emulator-5554", - name: "Pixel", - version: "36", - platform: "android", - booted: true, - physical: false, - }, - ], - }), - ), - ), - ); const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")])); const writeStarted = yield* Deferred.make(); const finishWrite = yield* Deferred.make(); @@ -83,7 +92,7 @@ it.effect("keeps hosts independent when serials collide and another host fails", order.push("write finished"); return "/host-config.json"; }), - ).pipe(Effect.provide(NodeCrypto.layer), Effect.provideService(HttpClient.HttpClient, http)); + ).pipe(Effect.provide(NodeCrypto.layer)); expect(yield* service.agentReadinessIfSupported("b")).not.toBeNull(); const listed = yield* service.list; expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 78371e9d51e3..193a42b289a2 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -33,6 +33,7 @@ import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as DeviceHost from "./DeviceHost.ts"; +import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "./DeviceService.ts"; @@ -99,14 +100,75 @@ const fixture = Effect.fn("fixture")(function* ( const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" }); let booted = false; let shutDown = false; + const http = HttpClient.make((request) => + Effect.gen(function* () { + requests.push(request.url); + if (request.url.includes("/api/screenshot")) { + return HttpClientResponse.fromWeb(request, new Response(new Uint8Array([137, 80, 78, 71]))); + } + if (request.url.endsWith("/shutdown")) { + shutDown = true; + booted = false; + return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); + } + if (shutDown && failListAfterShutdown) { + return HttpClientResponse.fromWeb(request, new Response("Discovery busy", { status: 503 })); + } + if (request.url.endsWith("/boot")) { + yield* onBoot; + booted = true; + return HttpClientResponse.fromWeb( + request, + Response.json( + bootError + ? { ok: false, ...(typeof bootError === "string" ? { error: bootError } : bootError) } + : { ok: true, serial: "emulator-5554" }, + ), + ); + } + if (request.url.endsWith("/grid/api/start")) { + return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); + } + return HttpClientResponse.fromWeb( + request, + Response.json({ + errors: yield* Ref.get(discoveryErrors), + simulators: devices.filter((device) => device.platform === "ios"), + emulators: [ + ...devices.filter((device) => device.platform === "android"), + ...(booted + ? [ + { + id: "emulator-5554", + name: "Pixel_API_35", + platform: "android", + version: "Android 15", + booted: true, + physical: false, + }, + ] + : []), + ], + }), + ); + }), + ); + const run = (command: string) => + command === "emulator" + ? Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }) + : Ref.get(encoderResult); const ready: DeviceHost.DeviceHostReady = { nodePath: process.execPath, hub: { origin: "http://device.test" }, + devices: makeDeviceHubOperations({ + hostId: LOCAL_DEVICE_HOST_ID, + origin: "http://device.test", + http, + run, + androidAvailable: Effect.succeed(true), + }), helpers: { serveSimAxSettings: null, serveSimCli: null }, - run: (command) => - command === "emulator" - ? Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }) - : Ref.get(encoderResult), + run, }; const host: DeviceHost.DeviceHost["Service"] = { ...(inspectError @@ -188,71 +250,6 @@ const fixture = Effect.fn("fixture")(function* ( subscribeChanges: Effect.succeed(Stream.empty), }), ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.gen(function* () { - requests.push(request.url); - if (request.url.includes("/api/screenshot")) { - return HttpClientResponse.fromWeb( - request, - new Response(new Uint8Array([137, 80, 78, 71])), - ); - } - if (request.url.endsWith("/shutdown")) { - shutDown = true; - booted = false; - return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); - } - if (shutDown && failListAfterShutdown) { - return HttpClientResponse.fromWeb( - request, - new Response("Discovery busy", { status: 503 }), - ); - } - if (request.url.endsWith("/boot")) { - yield* onBoot; - booted = true; - return HttpClientResponse.fromWeb( - request, - Response.json( - bootError - ? { - ok: false, - ...(typeof bootError === "string" ? { error: bootError } : bootError), - } - : { ok: true, serial: "emulator-5554" }, - ), - ); - } - if (request.url.endsWith("/grid/api/start")) { - return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); - } - return HttpClientResponse.fromWeb( - request, - Response.json({ - errors: yield* Ref.get(discoveryErrors), - simulators: devices.filter((device) => device.platform === "ios"), - emulators: [ - ...devices.filter((device) => device.platform === "android"), - ...(booted - ? [ - { - id: "emulator-5554", - name: "Pixel_API_35", - platform: "android", - version: "Android 15", - booted: true, - physical: false, - }, - ] - : []), - ], - }), - ); - }), - ), - ), ); return { service, @@ -668,29 +665,6 @@ it.effect.each(["shutdown", "close"] as const)( let booted = true; let capture: number | null = null; let generation = 0; - const ready: DeviceHost.DeviceHostReady = { - nodePath: process.execPath, - hub: { origin: "http://device.test" }, - helpers: { serveSimAxSettings: null, serveSimCli: null }, - run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), - }; - const host: DeviceHost.DeviceHost["Service"] = { - id: LOCAL_DEVICE_HOST_ID, - summary: Effect.succeed({ - id: LOCAL_DEVICE_HOST_ID, - kind: "local", - label: "Simulator host", - platforms: [{ platform: "ios", available: true }], - hubInstalled: true, - agentDeviceInstalled: false, - }), - platformAvailability: (platform) => Effect.succeed({ platform, available: true }), - ensureReady: () => Effect.succeed(ready), - ensureAgentReady: () => Effect.die("Agent access is not used in this test"), - current: Effect.succeed(ready), - stopAgent: Effect.void, - stop: Effect.void, - }; const http = HttpClient.make((request) => Effect.sync(() => { const path = new URL(request.url).pathname; @@ -728,9 +702,39 @@ it.effect.each(["shutdown", "close"] as const)( return HttpClientResponse.fromWeb(request, Response.json({ ok: true, id: deviceId })); }), ); + const run = () => Effect.succeed({ code: 0, stdout: "", stderr: "" }); + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + devices: makeDeviceHubOperations({ + hostId: LOCAL_DEVICE_HOST_ID, + origin: "http://device.test", + http, + run, + androidAvailable: Effect.succeed(true), + }), + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run, + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Simulator host", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.die("Agent access is not used in this test"), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe( Effect.provide(NodeCrypto.layer), - Effect.provideService(HttpClient.HttpClient, http), ); const input = { threadId, deviceId, platform: "ios" as const }; yield* service.open(input); @@ -756,29 +760,6 @@ it.effect.each([ const deviceId = DeviceId.make("22222222-2222-2222-2222-222222222222"); const paths: string[] = []; let listed: "booted" | "off" | "missing" = "booted"; - const ready: DeviceHost.DeviceHostReady = { - nodePath: process.execPath, - hub: { origin: "http://device.test" }, - helpers: { serveSimAxSettings: null, serveSimCli: null }, - run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), - }; - const host: DeviceHost.DeviceHost["Service"] = { - id: LOCAL_DEVICE_HOST_ID, - summary: Effect.succeed({ - id: LOCAL_DEVICE_HOST_ID, - kind: "local", - label: "Simulator host", - platforms: [{ platform: "ios", available: true }], - hubInstalled: true, - agentDeviceInstalled: false, - }), - platformAvailability: (platform) => Effect.succeed({ platform, available: true }), - ensureReady: () => Effect.succeed(ready), - ensureAgentReady: () => Effect.die("Agent access is not used in this test"), - current: Effect.succeed(ready), - stopAgent: Effect.void, - stop: Effect.void, - }; const http = HttpClient.make((request) => Effect.sync(() => { const path = new URL(request.url).pathname; @@ -819,9 +800,39 @@ it.effect.each([ throw new Error(`Unexpected hub path: ${path}`); }), ); + const run = () => Effect.succeed({ code: 0, stdout: "", stderr: "" }); + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + devices: makeDeviceHubOperations({ + hostId: LOCAL_DEVICE_HOST_ID, + origin: "http://device.test", + http, + run, + androidAvailable: Effect.succeed(true), + }), + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run, + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Simulator host", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.die("Agent access is not used in this test"), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe( Effect.provide(NodeCrypto.layer), - Effect.provideService(HttpClient.HttpClient, http), ); yield* service.list; const exit = yield* Effect.exit(service.shutdown({ deviceId, platform: "ios" })); @@ -847,11 +858,32 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( (hostId) => Effect.gen(function* () { let enumeration = { code: 1, stdout: "private stdout", stderr: "private stderr" }; + const run = () => Effect.sync(() => enumeration); + const hubDevices = [ + { id: "iphone", name: "iPhone", platform: "ios", physical: false }, + { id: "phone", name: "Pixel", platform: "android", physical: true }, + { id: "emulator-5554", name: "Running_AVD", platform: "android", physical: false }, + ].map((device) => ({ ...device, booted: true, version: "test" })); + const http = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ simulators: hubDevices.slice(0, 1), emulators: hubDevices.slice(1) }), + ), + ), + ); const ready: DeviceHost.DeviceHostReady = { nodePath: process.execPath, hub: { origin: "http://device.test" }, + devices: makeDeviceHubOperations({ + hostId, + origin: "http://device.test", + http, + run, + androidAvailable: Effect.succeed(true), + }), helpers: { serveSimAxSettings: null, serveSimCli: null }, - run: () => Effect.sync(() => enumeration), + run, }; const host: DeviceHost.DeviceHost["Service"] = { id: hostId, @@ -870,19 +902,6 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( stop: Effect.void, stopAgent: Effect.void, }; - const hubDevices = [ - { id: "iphone", name: "iPhone", platform: "ios", physical: false }, - { id: "phone", name: "Pixel", platform: "android", physical: true }, - { id: "emulator-5554", name: "Running_AVD", platform: "android", physical: false }, - ].map((device) => ({ ...device, booted: true, version: "test" })); - const http = HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ simulators: hubDevices.slice(0, 1), emulators: hubDevices.slice(1) }), - ), - ), - ); const service = yield* DeviceService.makeWithHosts(new Map([[hostId, host]])).pipe( Effect.provide(NodeCrypto.layer), Effect.provideService(HttpClient.HttpClient, http), diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 856bf2dff1bf..440cd888d2ca 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -7,7 +7,6 @@ import { type DeviceError, type DeviceHostId, type DeviceId, - DeviceBootError, DeviceHostUnavailableError, DeviceNotFoundError, DeviceOperationError, @@ -37,16 +36,13 @@ import { import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; -import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as ServerSettings from "../serverSettings.ts"; import { isLocalSshDeviceHost, remoteSshDeviceHosts } from "./localSshDeviceHost.ts"; @@ -82,34 +78,6 @@ const sshProbeFailureMessage = (error: DeviceHost.DeviceHostError): string => { return `Connected over SSH, but the device host check failed (exit code ${cause.exitCode}).`; }; -const BOOT_TIMEOUT = Duration.minutes(3); -const SCREENSHOT_TIMEOUT = Duration.seconds(20); - -const HubDevice = Schema.Struct({ - id: Schema.String, - name: Schema.String, - version: Schema.String, - platform: Schema.Literals(["ios", "android"]), - booted: Schema.Boolean, - physical: Schema.Boolean, -}); -const HubDeviceList = Schema.Struct({ - simulators: Schema.Array(HubDevice), - emulators: Schema.Array(HubDevice), - errors: Schema.optional( - Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })), - ), -}); -const HubActionResult = Schema.Struct({ - ok: Schema.Boolean, - id: Schema.optional(Schema.String), - serial: Schema.optional(Schema.String), - error: Schema.optional(Schema.String), - errors: Schema.optional( - Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })), - ), -}); - export interface DeviceScreenshot { readonly device: DeviceSummary; readonly png: Uint8Array; @@ -169,9 +137,6 @@ interface ServiceState { readonly state: DeviceServiceState; } -const vendorPrefix = (platform: DevicePlatform) => - platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; - export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( hosts: ReadonlyMap, testHost: DeviceService["Service"]["testHost"] = (host) => @@ -209,7 +174,6 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ); const initialSettings = yield* readDeviceSettings; - const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const statePubSub = yield* PubSub.unbounded(); const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary); let publishedHosts = new Map(hosts); @@ -388,81 +352,13 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* Effect.orElseSucceed(() => null), ); - const hubJson = ( - request: HttpClientRequest.HttpClientRequest, - schema: Schema.Codec, - operation: string, - timeout: Duration.Input = Duration.seconds(15), - ) => - httpClient.execute(request).pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), - Effect.scoped, - Effect.timeout(timeout), - Effect.mapError( - (cause) => - new DeviceOperationError({ - operation, - reason: "request_failed", - cause, - }), - ), - ); - const fetchDevices = Effect.fn("DeviceService.fetchDevices")(function* (ready: DeviceReadiness) { - const list = yield* hubJson( - HttpClientRequest.get(`${ready.hub.origin}/api/devices`), - HubDeviceList, - "list", - ); - const toSummary = (device: typeof HubDevice.Type): DeviceSummary => ({ - hostId: ready.hostId, - id: device.id, - platform: device.platform, - name: device.name, - version: device.version, - booted: device.booted, - physical: device.physical, - }); - const devices = [...list.simulators, ...list.emulators].map(toSummary); - const warnings = list.errors?.filter( - (error) => - // CoreSimulator can retain deleted devices; their optional usage timestamps are absent. - !( - error.message.startsWith("[apple-utils] Failed to read CoreSimulator plist at ") && - /\/CoreSimulator\/Devices\/[^/]+\/device\.plist:$/.test(error.message) && - /(?:^Error:\s*ENOENT:|\bcode:\s*['"]ENOENT['"])/.test(error.error ?? "") - ), - ); - const details = warnings?.map((error) => error.message) ?? []; - let androidDiscoveryIncomplete = false; - const host = yield* resolveHost(ready.hostId); - if ((yield* host.platformAvailability("android")).available) { - const avds = yield* ready.run("emulator", ["-list-avds"]); - if (avds.code !== 0) { - androidDiscoveryIncomplete = true; - details.push( - `Could not list Android virtual devices (emulator -list-avds, exit code ${avds.code}). Discovered devices remain available.`, - ); - } - for (const name of (avds.code === 0 ? avds.stdout : "") - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean)) { - if (!devices.some((device) => device.platform === "android" && device.name === name)) { - devices.push({ - hostId: ready.hostId, - id: name, - name, - platform: "android", - version: "Android", - booted: false, - physical: false, - }); - } - } - } - return { devices, detail: details.join("\n") || undefined, androidDiscoveryIncomplete }; + const { devices, detail, androidDiscoveryIncomplete } = yield* ready.devices.list; + return { + devices: devices.map((device): DeviceSummary => ({ ...device, hostId: ready.hostId })), + detail, + androidDiscoveryIncomplete, + }; }); const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) { @@ -640,68 +536,6 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* } }); - /** - * Android AVDs change id when they boot (AVD name to emulator serial), so the - * returned id is authoritative. - */ - const boot = Effect.fn("DeviceService.boot")(function* ( - ready: DeviceReadiness, - device: DeviceSummary, - ) { - const result = yield* HttpClientRequest.post(`${ready.hub.origin}/api/devices/boot`).pipe( - HttpClientRequest.bodyJson({ platform: device.platform, id: device.id, name: device.name }), - Effect.mapError( - (cause) => - new DeviceOperationError({ operation: "boot", reason: "invalid_payload", cause }), - ), - Effect.flatMap((request) => hubJson(request, HubActionResult, "boot", BOOT_TIMEOUT)), - ); - if (!result.ok) { - // Keep the hub's launch command and nested failure available to agents for recovery. - const diagnostics = [ - result.error, - ...(result.errors ?? []).flatMap((error) => [error.message, error.error]), - ] - .filter((text) => text?.trim()) - .join("\n\n"); - return yield* new DeviceBootError({ - hostId: ready.hostId, - deviceId: device.id, - reason: /insufficient.*(?:disk|space)|not enough.*(?:disk|space)|no space left/i.test( - diagnostics, - ) - ? "disk_space" - : /timed? out|timeout/i.test(diagnostics) - ? "timeout" - : "launch_failed", - ...(diagnostics - ? { - diagnostics: - diagnostics.length > 2_000 ? `${diagnostics.slice(0, 1_999)}…` : diagnostics, - } - : {}), - cause: result, - }); - } - if (device.platform === "ios") { - // Booting alone does not attach a serve-sim helper; the grid start - // does both and is idempotent for a booted simulator. - yield* HttpClientRequest.post( - `${ready.hub.origin}${vendorPrefix("ios")}/grid/api/start`, - ).pipe( - HttpClientRequest.bodyJson({ udid: device.id }), - Effect.mapError( - (cause) => - new DeviceOperationError({ operation: "boot", reason: "invalid_payload", cause }), - ), - Effect.flatMap((request) => - hubJson(request, HubActionResult, "attach stream", BOOT_TIMEOUT), - ), - ); - } - return result.serial ?? result.id ?? device.id; - }); - const open: DeviceService["Service"]["open"] = Effect.fn("DeviceService.open")(function* (input) { const host = yield* resolveHost(input.hostId); yield* ensurePlatform(host, input.platform); @@ -749,7 +583,7 @@ process.exitCode = result.error ? 1 : (result.status ?? 1);`, booting, ], })); - const bootedId = yield* boot(ready, device).pipe( + const bootedId = yield* ready.devices.boot(device).pipe( Effect.ensuring( publish((current) => ({ ...current, @@ -764,19 +598,8 @@ process.exitCode = result.error ? 1 : (result.status ?? 1);`, if (!device) { return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: bootedId }); } - } else if (device.platform === "ios" && device.booted) { - yield* HttpClientRequest.post( - `${ready.hub.origin}${vendorPrefix("ios")}/grid/api/start`, - ).pipe( - HttpClientRequest.bodyJson({ udid: device.id }), - Effect.mapError( - (cause) => - new DeviceOperationError({ operation: "open", reason: "invalid_payload", cause }), - ), - Effect.flatMap((request) => - hubJson(request, HubActionResult, "attach stream", BOOT_TIMEOUT), - ), - ); + } else if (device.booted) { + yield* ready.devices.attach(device); } if (hosts.get(host.id) !== host) return yield* new DeviceHostUnavailableError({ @@ -823,43 +646,7 @@ process.exitCode = result.error ? 1 : (result.status ?? 1);`, platform: DevicePlatform, ) { const ready = yield* readiness(hostId); - const postShutdown = (path: string, body: Record) => - HttpClientRequest.post(`${ready.hub.origin}${path}`).pipe( - HttpClientRequest.bodyJson(body), - Effect.mapError( - (cause) => - new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }), - ), - Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")), - Effect.flatMap((result) => - result.ok - ? Effect.void - : Effect.fail( - new DeviceOperationError({ - operation: "shutdown", - reason: "hub_rejected", - cause: result, - }), - ), - ), - ); - // serve-sim's shutdown closes its in-process capture session before it runs - // `simctl shutdown`; the hub's generic shutdown can leave that session cached - // across a reboot. serve-sim runs simctl bare, though, so a simulator that is - // already off fails there. - yield* platform === "ios" - ? postShutdown(`${vendorPrefix("ios")}/grid/api/shutdown`, { udid: deviceId }).pipe( - Effect.catch((cause) => - fetchDevices(ready).pipe( - Effect.flatMap(({ devices }) => - devices.find((device) => device.id === deviceId)?.booted === false - ? Effect.logInfo("iOS simulator was already shut down", { deviceId }) - : Effect.fail(cause), - ), - ), - ), - ) - : postShutdown("/api/devices/shutdown", { platform, id: deviceId }); + yield* ready.devices.shutdown({ id: deviceId, platform }); yield* publish((state) => ({ ...state, devices: state.devices.map((device) => @@ -920,23 +707,7 @@ process.exitCode = result.error ? 1 : (result.status ?? 1);`, const screenshot: DeviceService["Service"]["screenshot"] = Effect.fn("DeviceService.screenshot")( function* (input) { const { ready, device } = yield* resolveDevice(input.hostId, input.deviceId); - const url = `${ready.hub.origin}${vendorPrefix(device.platform)}/api/screenshot?device=${encodeURIComponent(device.id)}`; - const png = yield* httpClient.execute(HttpClientRequest.post(url)).pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap((response) => response.arrayBuffer), - Effect.map((buffer) => new Uint8Array(buffer)), - Effect.scoped, - Effect.timeout(SCREENSHOT_TIMEOUT), - Effect.mapError( - (cause) => - new DeviceOperationError({ - operation: "screenshot", - reason: "request_failed", - cause, - }), - ), - ); - return { device, png }; + return { device, png: yield* ready.devices.screenshot(device) }; }, ); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 639c5cfe41a9..8bf5840e3fc2 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -40,6 +40,7 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as DeviceHost from "./DeviceHost.ts"; +import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { agentDeviceStateDir, type DeviceToolPaths, @@ -823,6 +824,15 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({ hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint, + devices: makeDeviceHubOperations({ + hostId, + origin: running.hub.origin, + http: httpClient, + run, + androidAvailable: platformAvailability("android").pipe( + Effect.map((availability) => availability.available), + ), + }), nodePath: running.hub.nodePath, run, helpers: running.helpers, diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 1587fab9804c..936f47230376 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -23,6 +23,7 @@ import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; import * as DeviceHost from "./DeviceHost.ts"; +import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; const Probe = Schema.Struct({ @@ -191,6 +192,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( ), ); + const platformAvailability: DeviceHost.DeviceHost["Service"]["platformAvailability"] = ( + platform, + ) => + provide(probe(config, owner)).pipe( + Effect.map((value) => { + summary = { ...summary, platforms: value.platforms }; + return value.platforms.find((p) => p.platform === platform)!; + }), + Effect.orElseSucceed(() => ({ + platform, + available: false, + reason: "Cannot reach device host. Test its SSH connection in Settings.", + })), + ); + const connectOnce = Effect.fn("SshDeviceHost.connectOnce")(function* (): Effect.fn.Return< DeviceHost.DeviceHostReady & { agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"] }, DeviceHost.DeviceHostError @@ -292,9 +308,19 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( ), Effect.forkIn(scope), ); + const hubOrigin = `http://127.0.0.1:${hubPort}`; const next = { nodePath: remote.nodePath, - hub: { origin: `http://127.0.0.1:${hubPort}` }, + hub: { origin: hubOrigin }, + devices: makeDeviceHubOperations({ + hostId: config.id, + origin: hubOrigin, + http, + run, + androidAvailable: platformAvailability("android").pipe( + Effect.map((availability) => availability.available), + ), + }), ...(remote.daemonPort !== undefined && remote.token !== undefined && remote.entryPath !== undefined @@ -481,17 +507,6 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( ), stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), stop, - platformAvailability: (platform) => - provide(probe(config, owner)).pipe( - Effect.map((value) => { - summary = { ...summary, platforms: value.platforms }; - return value.platforms.find((p) => p.platform === platform)!; - }), - Effect.orElseSucceed(() => ({ - platform, - available: false, - reason: "Cannot reach device host. Test its SSH connection in Settings.", - })), - ), + platformAvailability, } satisfies DeviceHost.DeviceHost["Service"]; }); diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 27c1628691b5..b3b936928869 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -18,8 +18,11 @@ middleware because serve-sim loads private CoreSimulator frameworks through a native addon, and a crash there must not take the server down. Everything platform-specific sits behind -[`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the -proxy, and the MCP tools only see a hub origin and an agent-device endpoint. +[`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service lists, +boots, and shuts down devices through the host's device operations, and the +proxy and the MCP tools only see a hub origin and an agent-device endpoint. +Hosts that run expo-device-hub share one hub-backed implementation of those +operations, so a host whose devices live elsewhere only replaces them. SSH hosts forward both endpoints to server loopback. Every proxied request also carries the host id; device ids alone are not unique across hosts.