1import {
2 DeviceBootError,
3 type DeviceHostId,
4 DeviceOperationError,
5 type DevicePlatform,
6} from "@t3tools/contracts";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
12
13import type { DeviceHostReady, DeviceOperations, HostDevice } from "./DeviceHost.ts";
14
15const BOOT_TIMEOUT = Duration.minutes(3);
16const SCREENSHOT_TIMEOUT = Duration.seconds(20);
17// Let the hub's 30 s simctl timeout report its cause first.
18const LIST_TIMEOUT_SECONDS = 40;
19// expo-device-hub 0.19.1 reports a failed simulator listing only as text; its error ids are hashes.
20const SIMULATOR_LIST_FAILED =
21 /^\[apple-utils\] (?:Failed to (?:run `xcrun simctl list devices|parse JSON output:|list devices:)|`xcrun simctl list devices --json` did not respond within )/;
22// The hub passes numeric display IDs that Xcode 26 rejects; iOS 27 needs them for multiple displays.
23const runsOnXcode26Runtime = (version: string) => Number(/^iOS (\d+)/.exec(version)?.[1]) <= 26;
24const DEFAULT_DISPLAY_SCREENSHOT = `f=$(mktemp -t t3-screenshot) || exit 1
25trap 'rm -f "$f"' 0
26xcrun simctl io "$1" screenshot --type=png "$f" >/dev/null && base64 -i "$f"`;
27
28const HubDevice = Schema.Struct({
29 id: Schema.String,
30 name: Schema.String,
31 version: Schema.String,
32 platform: Schema.Literals(["ios", "android"]),
33 booted: Schema.Boolean,
34 physical: Schema.Boolean,
35});
36const HubDeviceList = Schema.Struct({
37 simulators: Schema.Array(HubDevice),
38 emulators: Schema.Array(HubDevice),
39 errors: Schema.optional(
40 Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })),
41 ),
42});
43const HubActionResult = Schema.Struct({
44 ok: Schema.Boolean,
45 id: Schema.optional(Schema.String),
46 serial: Schema.optional(Schema.String),
47 error: Schema.optional(Schema.String),
48 errors: Schema.optional(
49 Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })),
50 ),
51});
52
53// Hub diagnostics can carry a whole stderr dump.
54const boundDiagnostics = (text: string) =>
55 text.length > 2_000 ? `${text.slice(0, 1_999)}…` : text;
56
57// The hub's `error` is a util.inspect of Node's exec error; the stack and the
58// inspected fields follow the first frame, so keep the lines before it.
59const errorHead = (error: string) => {
60 const lines = error.split(/\r?\n/);
61 const firstFrame = lines.findIndex((line) => /^\s+at /.test(line));
62 return (firstFrame === -1 ? lines : lines.slice(0, firstFrame))
63 .map((line) => line.trim())
64 .filter(Boolean)
65 .join(" ");
66};
67
68const vendorPrefix = (platform: DevicePlatform) =>
69 platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
70
71/** Device operations for a host that runs expo-device-hub at `origin`. */
72export const makeDeviceHubOperations = (options: {
73 readonly hostId: DeviceHostId;
74 readonly origin: string;
75 readonly http: HttpClient.HttpClient;
76 readonly run: DeviceHostReady["run"];
77 /** Android gates listing AVDs, which the hub does not report until they boot. */
78 readonly platformAvailable: (platform: DevicePlatform) => Effect.Effect<boolean>;
79}): DeviceOperations => {
80 const { hostId, origin, run } = options;
81 const httpClient = options.http.pipe(HttpClient.withScope);
82
83 const fetchJson = Effect.fn("DeviceHubOperations.fetchJson")(function* <A, I>(
84 request: HttpClientRequest.HttpClientRequest,
85 schema: Schema.Codec<A, I>,
86 ) {
87 return yield* httpClient
88 .execute(request)
89 .pipe(
90 Effect.flatMap(HttpClientResponse.filterStatusOk),
91 Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
92 Effect.scoped,
93 );
94 });
95
96 const hubJson = <A, I>(
97 request: HttpClientRequest.HttpClientRequest,
98 schema: Schema.Codec<A, I>,
99 operation: string,
100 timeout: Duration.Input = Duration.seconds(15),
101 ) =>
102 fetchJson(request, schema).pipe(
103 Effect.timeout(timeout),
104 Effect.mapError(
105 (cause) => new DeviceOperationError({ operation, reason: "request_failed", cause }),
106 ),
107 );
108
109 const postJson = (path: string, body: Record<string, string>, operation: string) =>
110 HttpClientRequest.post(`${origin}${path}`).pipe(
111 HttpClientRequest.bodyJson(body),
112 Effect.mapError(
113 (cause) => new DeviceOperationError({ operation, reason: "invalid_payload", cause }),
114 ),
115 );
116
117 // Booting alone does not attach a serve-sim helper; the grid start does both
118 // and is idempotent for a booted simulator.
119 const attachIosStream = (udid: string, operation: string) =>
120 postJson(`${vendorPrefix("ios")}/grid/api/start`, { udid }, operation).pipe(
121 Effect.flatMap((request) => hubJson(request, HubActionResult, "attach stream", BOOT_TIMEOUT)),
122 );
123
124 const list: DeviceOperations["list"] = Effect.gen(function* () {
125 const listed = yield* fetchJson(
126 HttpClientRequest.get(`${origin}/api/devices`),
127 HubDeviceList,
128 ).pipe(
129 Effect.mapError(
130 (cause) => new DeviceOperationError({ operation: "list", reason: "request_failed", cause }),
131 ),
132 Effect.timeoutOption(Duration.seconds(LIST_TIMEOUT_SECONDS)),
133 );
134 if (Option.isNone(listed)) {
135 return {
136 devices: [],
137 detail: `The device hub did not answer within ${LIST_TIMEOUT_SECONDS} seconds.`,
138 incompletePlatforms: yield* Effect.filter(["ios", "android"] as const, (platform) =>
139 options.platformAvailable(platform),
140 ),
141 };
142 }
143 const hubList = listed.value;
144 const devices: Array<HostDevice> = [...hubList.simulators, ...hubList.emulators];
145 const androidAvailable = yield* options.platformAvailable("android");
146 const warnings = hubList.errors?.filter(
147 (error) =>
148 // The host summary already explains a missing Android SDK; the hub's failed probes are noise.
149 !(!androidAvailable && error.message.startsWith("[android-utils] ")) &&
150 // CoreSimulator can retain deleted devices; their optional usage timestamps are absent.
151 !(
152 error.message.startsWith("[apple-utils] Failed to read CoreSimulator plist at ") &&
153 /\/CoreSimulator\/Devices\/[^/]+\/device\.plist:$/.test(error.message) &&
154 /(?:^Error:\s*ENOENT:|\bcode:\s*['"]ENOENT['"])/.test(error.error ?? "")
155 ),
156 );
157 const details =
158 warnings?.map((error) => {
159 const head = errorHead(error.error ?? "");
160 return boundDiagnostics(head ? `${error.message} ${head}` : error.message);
161 }) ?? [];
162 let androidDiscoveryIncomplete = false;
163 if (androidAvailable) {
164 const avds = yield* run("emulator", ["-list-avds"]);
165 // Discovered devices stay listed; the AVDs that could not be listed are a warning.
166 if (avds.code !== 0) {
167 androidDiscoveryIncomplete = true;
168 details.push(
169 `Could not list Android virtual devices (emulator -list-avds, exit code ${avds.code}). Discovered devices remain available.`,
170 );
171 }
172 for (const name of (avds.code === 0 ? avds.stdout : "")
173 .split(/\r?\n/)
174 .map((line) => line.trim())
175 .filter(Boolean)) {
176 if (!devices.some((device) => device.platform === "android" && device.name === name)) {
177 devices.push({
178 id: name,
179 name,
180 platform: "android",
181 version: "Android",
182 booted: false,
183 physical: false,
184 });
185 }
186 }
187 }
188 const simulatorsUnlisted =
189 hubList.simulators.length === 0 &&
190 (hubList.errors ?? []).some((error) => SIMULATOR_LIST_FAILED.test(error.message));
191 return {
192 devices,
193 detail: details.join("\n") || undefined,
194 androidDiscoveryIncomplete,
195 ...(simulatorsUnlisted ? { incompletePlatforms: ["ios"] as const } : {}),
196 };
197 }).pipe(Effect.withSpan("DeviceHubOperations.list"));
198
199 const boot: DeviceOperations["boot"] = Effect.fn("DeviceHubOperations.boot")(function* (device) {
200 const result = yield* postJson(
201 "/api/devices/boot",
202 { platform: device.platform, id: device.id, name: device.name },
203 "boot",
204 ).pipe(Effect.flatMap((request) => hubJson(request, HubActionResult, "boot", BOOT_TIMEOUT)));
205 if (!result.ok) {
206 // Keep the hub's launch command and nested failure available to agents for recovery.
207 const diagnostics = [
208 result.error,
209 ...(result.errors ?? []).flatMap((error) => [error.message, error.error]),
210 ]
211 .filter((text) => text?.trim())
212 .join("\n\n");
213 return yield* new DeviceBootError({
214 hostId,
215 deviceId: device.id,
216 reason: /insufficient.*(?:disk|space)|not enough.*(?:disk|space)|no space left/i.test(
217 diagnostics,
218 )
219 ? "disk_space"
220 : /timed? out|timeout/i.test(diagnostics)
221 ? "timeout"
222 : "launch_failed",
223 ...(diagnostics ? { diagnostics: boundDiagnostics(diagnostics) } : {}),
224 cause: result,
225 });
226 }
227 if (device.platform === "ios") yield* attachIosStream(device.id, "boot");
228 return result.serial ?? result.id ?? device.id;
229 });
230
231 const attach: DeviceOperations["attach"] = (device) =>
232 device.platform === "ios"
233 ? attachIosStream(device.id, "open").pipe(Effect.asVoid)
234 : Effect.void;
235
236 const shutdown: DeviceOperations["shutdown"] = Effect.fn("DeviceHubOperations.shutdown")(
237 function* (device) {
238 const postShutdown = (path: string, body: Record<string, string>) =>
239 postJson(path, body, "shutdown").pipe(
240 Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")),
241 Effect.flatMap((result) =>
242 result.ok
243 ? Effect.void
244 : Effect.fail(
245 new DeviceOperationError({
246 operation: "shutdown",
247 reason: "hub_rejected",
248 cause: result,
249 }),
250 ),
251 ),
252 );
253 // serve-sim's shutdown closes its in-process capture session before it
254 // runs `simctl shutdown`; the hub's generic shutdown can leave that
255 // session cached across a reboot. serve-sim runs simctl bare, though, so
256 // a simulator that is already off fails there.
257 yield* device.platform === "ios"
258 ? postShutdown(`${vendorPrefix("ios")}/grid/api/shutdown`, { udid: device.id }).pipe(
259 Effect.catch((cause) =>
260 list.pipe(
261 Effect.flatMap(({ devices }) =>
262 devices.find((candidate) => candidate.id === device.id)?.booted === false
263 ? Effect.logInfo("iOS simulator was already shut down", { deviceId: device.id })
264 : Effect.fail(cause),
265 ),
266 ),
267 ),
268 )
269 : postShutdown("/api/devices/shutdown", { platform: device.platform, id: device.id });
270 },
271 );
272
273 const defaultDisplayScreenshot = Effect.fn("DeviceHubOperations.defaultDisplayScreenshot")(
274 function* (udid: string) {
275 const capture = yield* run("sh", ["-c", DEFAULT_DISPLAY_SCREENSHOT, "sh", udid], {
276 timeoutMs: Duration.toMillis(SCREENSHOT_TIMEOUT),
277 maxOutputBytes: 64 * 1024 * 1024,
278 });
279 if (capture.code !== 0) {
280 return yield* new DeviceOperationError({
281 operation: "screenshot",
282 reason: "command_failed",
283 exitCode: capture.code,
284 cause: capture.stderr.trim(),
285 });
286 }
287 return new Uint8Array(Buffer.from(capture.stdout, "base64"));
288 },
289 );
290
291 const screenshot: DeviceOperations["screenshot"] = (device) =>
292 httpClient
293 .execute(
294 HttpClientRequest.post(
295 `${origin}${vendorPrefix(device.platform)}/api/screenshot?device=${encodeURIComponent(device.id)}`,
296 ),
297 )
298 .pipe(
299 Effect.flatMap(HttpClientResponse.filterStatusOk),
300 Effect.flatMap((response) => response.arrayBuffer),
301 Effect.map((buffer) => new Uint8Array(buffer)),
302 Effect.scoped,
303 Effect.timeout(SCREENSHOT_TIMEOUT),
304 Effect.mapError(
305 (cause) =>
306 new DeviceOperationError({
307 operation: "screenshot",
308 reason:
309 HttpClientError.isHttpClientError(cause) && cause.reason._tag === "StatusCodeError"
310 ? "hub_rejected"
311 : "request_failed",
312 cause,
313 }),
314 ),
315 Effect.catchIf(
316 (error) =>
317 device.platform === "ios" &&
318 runsOnXcode26Runtime(device.version) &&
319 error.reason === "hub_rejected",
320 () => defaultDisplayScreenshot(device.id),
321 ),
322 Effect.withSpan("DeviceHubOperations.screenshot"),
323 );
324
325 return { list, boot, attach, shutdown, screenshot };
326};