1/**
2 * Device discovery, per-thread device sessions, and the state stream clients
3 * render the Device panel from.
4 *
5 * Discovery and boot go through expo-device-hub's JSON API rather than
6 * shelling out to simctl and adb here: the hub already normalizes both
7 * platforms into one device shape and is the process that has to know a
8 * device is booted before it can stream it. Sessions are the server's own
9 * bookkeeping — which thread is looking at which device — so the panel and
10 * the `device_*` tools agree, and so a `device_open` from an agent surfaces in
11 * every connected client the way `preview_open` does.
12 */
13import {
14 type DeviceActionInput,
15 type DeviceCloseInput,
16 type DeviceConfigureInput,
17 type DeviceDetail,
18 type DeviceDetailInput,
19 type DeviceError,
20 type DeviceHostId,
21 type DeviceId,
22 DeviceBootError,
23 DeviceHostUnavailableError,
24 DeviceNotFoundError,
25 DeviceOperationError,
26 type DeviceOpenInput,
27 type DevicePlatform,
28 DevicePlatformUnavailableError,
29 type DeviceServiceState,
30 type DeviceSession,
31 type DeviceShutdownInput,
32 type DeviceSummary,
33 type SshDeviceHostConfig,
34 type DeviceHostSummary,
35 LOCAL_DEVICE_HOST_ID,
36 type ThreadId,
37} from "@t3tools/contracts";
38import * as FileSystem from "effect/FileSystem";
39import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
40import * as Path from "effect/Path";
41import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts";
42import * as ServerConfig from "../config.ts";
43import {
44 agentDeviceConfigPath,
45 agentDeviceSession,
46 writeAgentDeviceConfig,
47} from "./AgentDeviceTarget.ts";
48import * as Context from "effect/Context";
49import * as Crypto from "effect/Crypto";
50import * as DateTime from "effect/DateTime";
51import * as Duration from "effect/Duration";
52import * as Effect from "effect/Effect";
53import * as Layer from "effect/Layer";
54import * as PubSub from "effect/PubSub";
55import * as Schema from "effect/Schema";
56import * as Scope from "effect/Scope";
57import * as Semaphore from "effect/Semaphore";
58import * as Stream from "effect/Stream";
59import * as SynchronizedRef from "effect/SynchronizedRef";
60import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
61
62import * as ServerSettings from "../serverSettings.ts";
63import { isLocalSshDeviceHost, remoteSshDeviceHosts } from "./localSshDeviceHost.ts";
64
65import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
66import * as ProcessRunner from "../processRunner.ts";
67import * as DeviceHost from "./DeviceHost.ts";
68import * as SshDeviceHost from "./SshDeviceHost.ts";
69import * as Exit from "effect/Exit";
70import * as LocalDeviceHost from "./LocalDeviceHost.ts";
71
72/** Origin-relative prefix the hub is proxied under. See DeviceHubProxy. */
73export const DEVICE_HUB_ROUTE_PREFIX = "/api/device-hub";
74
75const BOOT_TIMEOUT = Duration.minutes(3);
76const SCREENSHOT_TIMEOUT = Duration.seconds(20);
77
78const HubDevice = Schema.Struct({
79 id: Schema.String,
80 name: Schema.String,
81 version: Schema.String,
82 platform: Schema.Literals(["ios", "android"]),
83 booted: Schema.Boolean,
84 physical: Schema.Boolean,
85});
86const HubDeviceList = Schema.Struct({
87 simulators: Schema.Array(HubDevice),
88 emulators: Schema.Array(HubDevice),
89 errors: Schema.optional(Schema.Array(Schema.Struct({ message: Schema.String }))),
90});
91const HubActionResult = Schema.Struct({
92 ok: Schema.Boolean,
93 id: Schema.optional(Schema.String),
94 serial: Schema.optional(Schema.String),
95 error: Schema.optional(Schema.String),
96});
97
98export interface DeviceScreenshot {
99 readonly device: DeviceSummary;
100 readonly png: Uint8Array;
101}
102
103export interface DeviceReadiness extends DeviceHost.DeviceHostReady {
104 readonly hostId: DeviceHostId;
105}
106
107export interface DeviceAgentReadiness extends DeviceReadiness {
108 readonly agentDevice: DeviceHost.AgentDeviceEndpoint;
109}
110
111export class DeviceService extends Context.Service<
112 DeviceService,
113 {
114 readonly agentCli: Effect.Effect<string, DeviceError>;
115 readonly testHost: (
116 config: SshDeviceHostConfig,
117 ) => Effect.Effect<DeviceHostSummary, DeviceError>;
118 readonly agentTarget: (input: {
119 threadId: ThreadId;
120 hostId: DeviceHostId;
121 deviceId: DeviceId;
122 }) => Effect.Effect<ReadonlyArray<string>, DeviceError>;
123 readonly state: Effect.Effect<DeviceServiceState>;
124 readonly subscribe: Effect.Effect<PubSub.Subscription<DeviceServiceState>, never, Scope.Scope>;
125 readonly configure: (
126 input: DeviceConfigureInput,
127 ) => Effect.Effect<DeviceServiceState, DeviceError>;
128 /** Refreshes devices only after device support has been enabled. */
129 readonly list: Effect.Effect<DeviceServiceState, DeviceError>;
130 readonly updateTool: (tool: "hub" | "agent") => Effect.Effect<DeviceServiceState, DeviceError>;
131 readonly inspect: Effect.Effect<DeviceServiceState>;
132 readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>;
133 readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;
134 readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>;
135 readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect<void, DeviceError>;
136 /** Current settings and foreground app for one device. */
137 readonly detail: (input: DeviceDetailInput) => Effect.Effect<DeviceDetail, DeviceError>;
138 /** Runs one action, then returns the refreshed detail. */
139 readonly action: (input: DeviceActionInput) => Effect.Effect<DeviceDetail, DeviceError>;
140 readonly screenshot: (input: {
141 readonly hostId?: DeviceHostId | undefined;
142 readonly deviceId: DeviceId;
143 }) => Effect.Effect<DeviceScreenshot, DeviceError>;
144 /** Host endpoints for the proxy and the provider environment. */
145 readonly readiness: (hostId?: DeviceHostId) => Effect.Effect<DeviceReadiness, DeviceError>;
146 /**
147 * `readiness` only when the host can run at least one platform; a machine
148 * with no simulator toolchain never installs or starts anything.
149 */
150 readonly readinessIfSupported: (
151 hostId?: DeviceHostId,
152 ) => Effect.Effect<DeviceReadiness | null, DeviceError>;
153 readonly agentReadinessIfSupported: (
154 hostId?: DeviceHostId,
155 ) => Effect.Effect<DeviceAgentReadiness | null, DeviceError>;
156 readonly currentReadiness: (hostId?: DeviceHostId) => Effect.Effect<DeviceReadiness | null>;
157 readonly sessionsForThread: (threadId: ThreadId) => Effect.Effect<ReadonlyArray<DeviceSession>>;
158 }
159>()("t3/device/DeviceService") {}
160
161interface ServiceState {
162 readonly state: DeviceServiceState;
163}
164
165const vendorPrefix = (platform: DevicePlatform) =>
166 platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
167
168export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (
169 hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]>,
170 testHost: DeviceService["Service"]["testHost"] = (host) =>
171 Effect.fail(
172 new DeviceHostUnavailableError({
173 hostId: host.id,
174 reason: "SSH probing is unavailable in this device service.",
175 }),
176 ),
177 configureAgent: (
178 hostId: DeviceHostId,
179 ready: DeviceHost.DeviceHostAgentReady,
180 ) => Effect.Effect<string, DeviceError> = (hostId) =>
181 Effect.fail(
182 new DeviceHostUnavailableError({
183 hostId,
184 reason: "Agent configuration is unavailable in this device service.",
185 }),
186 ),
187 installTool?: (tool: "hub" | "agent") => Effect.Effect<unknown, DeviceError>,
188) {
189 const settings = yield* ServerSettings.ServerSettingsService;
190 const crypto = yield* Crypto.Crypto;
191 const lifecycleLock = yield* Semaphore.make(1);
192 const readDeviceSettings = settings.getSettings.pipe(
193 Effect.map((value) => ({
194 enabled: value.enableDeviceSupport,
195 agentAccessEnabled: value.enableAgentDeviceAccess,
196 onboardingCompleted: value.deviceOnboardingCompleted,
197 })),
198 Effect.mapError(
199 (cause) =>
200 new DeviceOperationError({ operation: "settings", reason: "settings_failed", cause }),
201 ),
202 );
203 const initialSettings = yield* readDeviceSettings;
204
205 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
206 const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();
207 const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);
208 let publishedHosts = new Map(hosts);
209 const stateRef = yield* SynchronizedRef.make<ServiceState>({
210 state: {
211 supportsHostRetry: true,
212 supportsToolUpdate: installTool !== undefined,
213 supportsToolInspection: true,
214 hosts: initialHosts,
215 hostStatus: initialSettings.enabled ? "idle" : "disabled",
216 hostStatuses: {},
217 devices: [],
218 sessions: [],
219 onboardingCompleted: initialSettings.onboardingCompleted,
220 agentAccessEnabled: initialSettings.agentAccessEnabled,
221 hubBasePath: DEVICE_HUB_ROUTE_PREFIX,
222 revision: 0,
223 },
224 });
225
226 const publish = (update: (state: DeviceServiceState) => DeviceServiceState) =>
227 SynchronizedRef.updateAndGetEffect(stateRef, ({ state }) => {
228 const next = { ...update(state), revision: state.revision + 1 };
229 return PubSub.publish(statePubSub, next).pipe(Effect.as({ state: next }));
230 }).pipe(Effect.map(({ state }) => state));
231
232 const resolveHost = (hostId: DeviceHostId | undefined) =>
233 Effect.gen(function* () {
234 const id = hostId ?? LOCAL_DEVICE_HOST_ID;
235 const host = hosts.get(id);
236 if (!host) {
237 return yield* new DeviceHostUnavailableError({ hostId: id, reason: "Unknown host." });
238 }
239 return host;
240 });
241
242 const setHostStatus = (
243 hostId: DeviceHostId,
244 status: DeviceServiceState["hostStatuses"][string],
245 ) =>
246 Effect.suspend(() =>
247 !hosts.has(hostId)
248 ? SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state))
249 : publish((state) => ({
250 ...state,
251 ...(hostId === LOCAL_DEVICE_HOST_ID
252 ? { hostStatus: status.status, hostStatusDetail: status.detail }
253 : {}),
254 hostStatuses: { ...state.hostStatuses, [hostId]: status },
255 })),
256 );
257
258 const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(
259 function* (hostId) {
260 const host = yield* resolveHost(hostId);
261 if (!(yield* readDeviceSettings).enabled) {
262 return yield* new DeviceHostUnavailableError({
263 hostId: host.id,
264 reason:
265 "Device support is off. Enable it in the Device panel before installing or starting device tools.",
266 });
267 }
268 const ready = yield* host
269 .ensureReady((status, detail) =>
270 setHostStatus(host.id, { status, detail }).pipe(Effect.asVoid),
271 )
272 .pipe(
273 Effect.tapError((error) =>
274 setHostStatus(host.id, { status: "failed", detail: error.message }),
275 ),
276 Effect.mapError(
277 (error) =>
278 new DeviceHostUnavailableError({
279 hostId: host.id,
280 reason:
281 error._tag === "NodeRuntimeUnavailableError"
282 ? nodeRuntimeUnavailableMessage("Local device support")
283 : error.step === "probe"
284 ? "Could not connect to this host over SSH."
285 : `Device support failed during ${error.step}.`,
286 cause: error,
287 }),
288 ),
289 );
290 if (hosts.get(host.id) !== host)
291 return yield* new DeviceHostUnavailableError({
292 hostId: host.id,
293 reason: "Host configuration changed. Retry the operation.",
294 });
295 const { state } = yield* SynchronizedRef.get(stateRef);
296 if (state.hostStatuses[host.id]?.status !== "ready") {
297 yield* setHostStatus(host.id, { status: "ready" });
298 }
299 return { hostId: host.id, ...ready };
300 },
301 lifecycleLock.withPermit,
302 );
303
304 const readinessIfSupported: DeviceService["Service"]["readinessIfSupported"] = Effect.fn(
305 "DeviceService.readinessIfSupported",
306 )(function* (hostId) {
307 if (!(yield* readDeviceSettings).enabled) return null;
308 const host = yield* resolveHost(hostId);
309 const summary = yield* host.summary;
310 if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
311 return null;
312 return yield* readiness(host.id);
313 });
314
315 const agentReadinessIfSupported: DeviceService["Service"]["agentReadinessIfSupported"] =
316 Effect.fn("DeviceService.agentReadinessIfSupported")(function* (hostId) {
317 const deviceSettings = yield* readDeviceSettings;
318 if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null;
319 const host = yield* resolveHost(hostId);
320 const summary = yield* host.summary;
321 if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
322 return null;
323 const ready = yield* host
324 .ensureAgentReady((phase, detail) =>
325 setHostStatus(host.id, { status: phase, detail }).pipe(Effect.asVoid),
326 )
327 .pipe(
328 Effect.tapError((error) =>
329 setHostStatus(host.id, { status: "failed", detail: error.message }),
330 ),
331 Effect.mapError(
332 (error) =>
333 new DeviceHostUnavailableError({
334 hostId: host.id,
335 reason:
336 error._tag === "NodeRuntimeUnavailableError"
337 ? nodeRuntimeUnavailableMessage("Local device support")
338 : error._tag === "DeviceHostTimeoutError"
339 ? `Agent tools did not start within ${error.timeoutMs} ms.`
340 : error._tag === "AgentDeviceStartError"
341 ? error.message
342 : error.step === "probe"
343 ? "Could not connect to this host over SSH."
344 : `Device support failed during ${error.step}.`,
345 cause: error,
346 }),
347 ),
348 );
349 const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);
350 yield* publish((state) => ({ ...state, hosts: hostSummaries }));
351 yield* setHostStatus(host.id, { status: "ready" });
352 return { hostId: host.id, ...ready };
353 }, lifecycleLock.withPermit);
354
355 const currentReadiness: DeviceService["Service"]["currentReadiness"] = (hostId) =>
356 resolveHost(hostId).pipe(
357 Effect.flatMap((host) =>
358 host.current.pipe(Effect.map((ready) => (ready ? { hostId: host.id, ...ready } : null))),
359 ),
360 Effect.orElseSucceed(() => null),
361 );
362
363 const hubJson = <A, I>(
364 request: HttpClientRequest.HttpClientRequest,
365 schema: Schema.Codec<A, I>,
366 operation: string,
367 timeout: Duration.Input = Duration.seconds(15),
368 ) =>
369 httpClient.execute(request).pipe(
370 Effect.flatMap(HttpClientResponse.filterStatusOk),
371 Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
372 Effect.scoped,
373 Effect.timeout(timeout),
374 Effect.mapError(
375 (cause) =>
376 new DeviceOperationError({
377 operation,
378 reason: "request_failed",
379 cause,
380 }),
381 ),
382 );
383
384 const fetchDevices = Effect.fn("DeviceService.fetchDevices")(function* (ready: DeviceReadiness) {
385 const list = yield* hubJson(
386 HttpClientRequest.get(`${ready.hub.origin}/api/devices`),
387 HubDeviceList,
388 "list",
389 );
390 const toSummary = (device: typeof HubDevice.Type): DeviceSummary => ({
391 hostId: ready.hostId,
392 id: device.id,
393 platform: device.platform,
394 name: device.name,
395 version: device.version,
396 booted: device.booted,
397 physical: device.physical,
398 });
399 const devices = [...list.simulators, ...list.emulators].map(toSummary);
400 const host = yield* resolveHost(ready.hostId);
401 if ((yield* host.platformAvailability("android")).available) {
402 const avds = yield* ready.run("emulator", ["-list-avds"]);
403 if (avds.code !== 0) {
404 return yield* new DeviceOperationError({
405 operation: "list",
406 reason: "command_failed",
407 exitCode: avds.code,
408 cause: avds,
409 });
410 }
411 for (const name of avds.stdout
412 .split(/\r?\n/)
413 .map((line) => line.trim())
414 .filter(Boolean)) {
415 if (!devices.some((device) => device.platform === "android" && device.name === name)) {
416 devices.push({
417 hostId: ready.hostId,
418 id: name,
419 name,
420 platform: "android",
421 version: "Android",
422 booted: false,
423 physical: false,
424 });
425 }
426 }
427 }
428 return { devices, detail: list.errors?.map((error) => error.message).join("\n") || undefined };
429 });
430
431 const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) {
432 const host = hosts.get(ready.hostId);
433 const { devices, detail } = yield* fetchDevices(ready);
434 const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);
435 return yield* lifecycleLock.withPermit(
436 Effect.gen(function* () {
437 if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host)
438 return (yield* SynchronizedRef.get(stateRef)).state;
439 return yield* publish((state) => ({
440 ...state,
441 hosts: hostSummaries,
442 ...(ready.hostId === LOCAL_DEVICE_HOST_ID ? { hostStatusDetail: detail } : {}),
443 devices: [
444 ...state.devices.filter((device) => device.hostId !== ready.hostId),
445 ...devices,
446 ],
447 hostStatuses: {
448 ...state.hostStatuses,
449 [ready.hostId]: { status: "ready", ...(detail ? { detail } : {}) },
450 },
451 }));
452 }),
453 );
454 });
455
456 const list: DeviceService["Service"]["list"] = Effect.gen(function* () {
457 if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;
458 yield* Effect.forEach(
459 hosts.values(),
460 (host) =>
461 Effect.gen(function* () {
462 const ready = yield* readinessIfSupported(host.id);
463 if (ready) yield* refresh(ready);
464 }).pipe(
465 Effect.catch((error) =>
466 setHostStatus(host.id, {
467 status: "failed",
468 detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message,
469 }),
470 ),
471 ),
472 { concurrency: 4 },
473 );
474 return (yield* SynchronizedRef.get(stateRef)).state;
475 }).pipe(Effect.withSpan("DeviceService.list"));
476
477 const inspect = Effect.gen(function* () {
478 yield* Effect.forEach(
479 hosts.values(),
480 (host) =>
481 Effect.gen(function* () {
482 const result = yield* (host.inspect ?? host.summary).pipe(Effect.result);
483 if (hosts.get(host.id) !== host) return;
484 if (result._tag === "Failure") {
485 yield* publish((state) => ({
486 ...state,
487 hosts: state.hosts.map((value) =>
488 value.id === host.id
489 ? {
490 ...value,
491 toolInspectionError:
492 "Cannot check versions. Reconnect the host and check again. Installed tools have not been changed.",
493 }
494 : value,
495 ),
496 }));
497 return;
498 }
499 yield* publish((state) => ({
500 ...state,
501 hosts: state.hosts.map((value) => (value.id === host.id ? result.success : value)),
502 }));
503 }),
504 { concurrency: 4 },
505 );
506 return (yield* SynchronizedRef.get(stateRef)).state;
507 });
508
509 const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")(
510 function* (hostId) {
511 yield* resolveHost(hostId);
512 if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;
513 yield* Effect.gen(function* () {
514 const ready =
515 (yield* agentReadinessIfSupported(hostId)) ?? (yield* readinessIfSupported(hostId));
516 if (ready) yield* refresh(ready);
517 }).pipe(
518 Effect.catch((error) =>
519 setHostStatus(hostId, {
520 status: "failed",
521 detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message,
522 }),
523 ),
524 );
525 return (yield* SynchronizedRef.get(stateRef)).state;
526 },
527 );
528
529 const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(
530 function* (input) {
531 const currentSettings = yield* readDeviceSettings;
532 const nextEnabled = input.enabled ?? currentSettings.enabled;
533 const nextAgentAccess = input.agentAccessEnabled ?? currentSettings.agentAccessEnabled;
534 yield* lifecycleLock.withPermit(
535 Effect.gen(function* () {
536 yield* settings
537 .updateSettings({
538 ...(input.enabled === undefined ? {} : { enableDeviceSupport: input.enabled }),
539 ...(input.agentAccessEnabled === undefined
540 ? {}
541 : { enableAgentDeviceAccess: input.agentAccessEnabled }),
542 ...(input.onboardingCompleted === undefined
543 ? {}
544 : { deviceOnboardingCompleted: input.onboardingCompleted }),
545 })
546 .pipe(
547 Effect.mapError(
548 (cause) =>
549 new DeviceOperationError({
550 operation: "configure",
551 reason: "settings_failed",
552 cause,
553 }),
554 ),
555 );
556 if (!nextEnabled) {
557 yield* Effect.forEach(hosts.values(), (host) => host.stop, { discard: true });
558 } else if (input.agentAccessEnabled === false) {
559 yield* Effect.forEach(hosts.values(), (host) => host.stopAgent, { discard: true });
560 }
561 yield* publish((state) => ({
562 ...state,
563 hostStatus: nextEnabled ? "idle" : "disabled",
564 hostStatusDetail: undefined,
565 hostStatuses: {},
566 devices: nextEnabled ? state.devices : [],
567 sessions: nextEnabled ? state.sessions : [],
568 bootingDevices: nextEnabled ? state.bootingDevices : [],
569 agentAccessEnabled: nextAgentAccess,
570 onboardingCompleted: input.onboardingCompleted ?? state.onboardingCompleted,
571 }));
572 }),
573 );
574 if (nextEnabled && nextAgentAccess && input.agentAccessEnabled === true) {
575 yield* agentReadinessIfSupported();
576 }
577 return yield* list;
578 },
579 );
580
581 const findDevice = (
582 state: DeviceServiceState,
583 hostId: DeviceHostId,
584 deviceId: DeviceId,
585 ): DeviceSummary | undefined =>
586 state.devices.find((device) => device.hostId === hostId && device.id === deviceId);
587
588 const ensurePlatform = Effect.fn("DeviceService.ensurePlatform")(function* (
589 host: DeviceHost.DeviceHost["Service"],
590 platform: DevicePlatform,
591 ) {
592 const availability = yield* host.platformAvailability(platform);
593 if (!availability.available) {
594 return yield* new DevicePlatformUnavailableError({
595 hostId: host.id,
596 platform,
597 reason: availability.reason ?? "Platform toolchain missing.",
598 });
599 }
600 });
601
602 /**
603 * Boot through the hub so its device list and the streaming helper both see
604 * the device come up. Android AVDs change id when they boot (AVD name to
605 * emulator serial), so the returned id is authoritative.
606 */
607 const boot = Effect.fn("DeviceService.boot")(function* (
608 ready: DeviceReadiness,
609 device: DeviceSummary,
610 ) {
611 const result = yield* HttpClientRequest.post(`${ready.hub.origin}/api/devices/boot`).pipe(
612 HttpClientRequest.bodyJson({ platform: device.platform, id: device.id, name: device.name }),
613 Effect.mapError(
614 (cause) =>
615 new DeviceOperationError({ operation: "boot", reason: "invalid_payload", cause }),
616 ),
617 Effect.flatMap((request) => hubJson(request, HubActionResult, "boot", BOOT_TIMEOUT)),
618 );
619 if (!result.ok) {
620 return yield* new DeviceBootError({
621 hostId: ready.hostId,
622 deviceId: device.id,
623 reason: /insufficient.*(?:disk|space)|not enough.*(?:disk|space)|no space left/i.test(
624 result.error ?? "",
625 )
626 ? "disk_space"
627 : /timed? out|timeout/i.test(result.error ?? "")
628 ? "timeout"
629 : "launch_failed",
630 cause: result,
631 });
632 }
633 if (device.platform === "ios") {
634 // Booting alone does not attach a serve-sim helper; the grid start
635 // does both and is idempotent for a booted simulator.
636 yield* HttpClientRequest.post(
637 `${ready.hub.origin}${vendorPrefix("ios")}/grid/api/start`,
638 ).pipe(
639 HttpClientRequest.bodyJson({ udid: device.id }),
640 Effect.mapError(
641 (cause) =>
642 new DeviceOperationError({ operation: "boot", reason: "invalid_payload", cause }),
643 ),
644 Effect.flatMap((request) =>
645 hubJson(request, HubActionResult, "attach stream", BOOT_TIMEOUT),
646 ),
647 );
648 }
649 return result.serial ?? result.id ?? device.id;
650 });
651
652 const open: DeviceService["Service"]["open"] = Effect.fn("DeviceService.open")(function* (input) {
653 const host = yield* resolveHost(input.hostId);
654 yield* ensurePlatform(host, input.platform);
655 const ready = yield* readiness(host.id);
656 let state = yield* refresh(ready);
657 let device = findDevice(state, host.id, input.deviceId);
658 if (!device) {
659 return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: input.deviceId });
660 }
661 if (!device.booted && input.boot !== false) {
662 const booting = { ...device, threadId: input.threadId };
663 yield* publish((current) => ({
664 ...current,
665 bootingDevices: [
666 ...(current.bootingDevices ?? []).filter(
667 (entry) => entry.hostId !== booting.hostId || entry.id !== booting.id,
668 ),
669 booting,
670 ],
671 }));
672 const bootedId = yield* boot(ready, device).pipe(
673 Effect.ensuring(
674 publish((current) => ({
675 ...current,
676 bootingDevices: (current.bootingDevices ?? []).filter(
677 (entry) => entry.hostId !== booting.hostId || entry.id !== booting.id,
678 ),
679 })),
680 ),
681 );
682 state = yield* refresh(ready);
683 device = findDevice(state, host.id, bootedId) ?? findDevice(state, host.id, device.id);
684 if (!device) {
685 return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: bootedId });
686 }
687 } else if (device.platform === "ios" && device.booted) {
688 // A simulator booted outside T3 has no helper attached yet.
689 yield* HttpClientRequest.post(
690 `${ready.hub.origin}${vendorPrefix("ios")}/grid/api/start`,
691 ).pipe(
692 HttpClientRequest.bodyJson({ udid: device.id }),
693 Effect.mapError(
694 (cause) =>
695 new DeviceOperationError({ operation: "open", reason: "invalid_payload", cause }),
696 ),
697 Effect.flatMap((request) =>
698 hubJson(request, HubActionResult, "attach stream", BOOT_TIMEOUT),
699 ),
700 );
701 }
702 if (hosts.get(host.id) !== host)
703 return yield* new DeviceHostUnavailableError({
704 hostId: host.id,
705 reason: "Host configuration changed. Retry the operation.",
706 });
707 const openedAt = DateTime.formatIso(yield* DateTime.now);
708 const session: DeviceSession = {
709 threadId: input.threadId,
710 hostId: host.id,
711 deviceId: device.id,
712 platform: device.platform,
713 openedAt,
714 };
715 yield* lifecycleLock.withPermit(
716 Effect.gen(function* () {
717 if (!(yield* readDeviceSettings).enabled)
718 return yield* new DeviceHostUnavailableError({
719 hostId: host.id,
720 reason: "Device support was turned off while the device was opening.",
721 });
722 yield* publish((current) => ({
723 ...current,
724 sessions: [
725 ...current.sessions.filter(
726 (existing) =>
727 !(
728 existing.threadId === session.threadId &&
729 existing.hostId === session.hostId &&
730 existing.deviceId === session.deviceId
731 ),
732 ),
733 session,
734 ],
735 }));
736 }),
737 );
738 return session;
739 });
740
741 const shutdownDevice = Effect.fn("DeviceService.shutdownDevice")(function* (
742 hostId: DeviceHostId,
743 deviceId: DeviceId,
744 platform: DevicePlatform,
745 ) {
746 const ready = yield* readiness(hostId);
747 const postShutdown = (path: string, body: Record<string, string>) =>
748 HttpClientRequest.post(`${ready.hub.origin}${path}`).pipe(
749 HttpClientRequest.bodyJson(body),
750 Effect.mapError(
751 (cause) =>
752 new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }),
753 ),
754 Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")),
755 Effect.flatMap((result) =>
756 result.ok
757 ? Effect.void
758 : Effect.fail(
759 new DeviceOperationError({
760 operation: "shutdown",
761 reason: "hub_rejected",
762 cause: result,
763 }),
764 ),
765 ),
766 );
767 // serve-sim's shutdown closes its in-process capture session before it runs
768 // `simctl shutdown`; the hub's generic shutdown can leave that session cached
769 // across a reboot. serve-sim runs simctl bare, though, so a simulator that is
770 // already off fails there. Accept that failure only when the hub confirms
771 // the simulator is off; a failure on a running one still surfaces.
772 yield* platform === "ios"
773 ? postShutdown(`${vendorPrefix("ios")}/grid/api/shutdown`, { udid: deviceId }).pipe(
774 Effect.catch((cause) =>
775 fetchDevices(ready).pipe(
776 Effect.flatMap(({ devices }) =>
777 devices.find((device) => device.id === deviceId)?.booted === false
778 ? Effect.logInfo("iOS simulator was already shut down", { deviceId })
779 : Effect.fail(cause),
780 ),
781 ),
782 ),
783 )
784 : postShutdown("/api/devices/shutdown", { platform, id: deviceId });
785 yield* publish((state) => ({
786 ...state,
787 devices: state.devices.map((device) =>
788 device.hostId === ready.hostId && device.id === deviceId
789 ? { ...device, booted: false }
790 : device,
791 ),
792 sessions: state.sessions.filter(
793 (session) => !(session.hostId === ready.hostId && session.deviceId === deviceId),
794 ),
795 }));
796 // Discovery can stall while an emulator saves its snapshot. A failed
797 // refresh must not turn an accepted shutdown into an action failure.
798 yield* refresh(ready).pipe(
799 Effect.catch((cause) =>
800 Effect.logWarning("Device discovery unavailable after shutdown", { cause }),
801 ),
802 );
803 });
804
805 const close: DeviceService["Service"]["close"] = Effect.fn("DeviceService.close")(
806 function* (input) {
807 const { state } = yield* SynchronizedRef.get(stateRef);
808 const closing = state.sessions.filter(
809 (session) =>
810 session.threadId === input.threadId &&
811 (input.hostId === undefined || session.hostId === input.hostId) &&
812 (input.deviceId === undefined || session.deviceId === input.deviceId),
813 );
814 if (closing.length === 0) return;
815 yield* publish((current) => ({
816 ...current,
817 sessions: current.sessions.filter((session) => !closing.includes(session)),
818 }));
819 if (input.shutdown) {
820 yield* Effect.forEach(
821 closing,
822 (session) => shutdownDevice(session.hostId, session.deviceId, session.platform),
823 { discard: true },
824 );
825 }
826 },
827 );
828
829 const shutdown: DeviceService["Service"]["shutdown"] = Effect.fn("DeviceService.shutdown")(
830 function* (input) {
831 const host = yield* resolveHost(input.hostId);
832 yield* shutdownDevice(host.id, input.deviceId, input.platform);
833 // Sessions on a powered-off device are stale in every thread.
834 yield* publish((current) => ({
835 ...current,
836 sessions: current.sessions.filter(
837 (session) => !(session.hostId === host.id && session.deviceId === input.deviceId),
838 ),
839 }));
840 },
841 );
842
843 const screenshot: DeviceService["Service"]["screenshot"] = Effect.fn("DeviceService.screenshot")(
844 function* (input) {
845 const { ready, device } = yield* resolveDevice(input.hostId, input.deviceId);
846 const url = `${ready.hub.origin}${vendorPrefix(device.platform)}/api/screenshot?device=${encodeURIComponent(device.id)}`;
847 const png = yield* httpClient.execute(HttpClientRequest.post(url)).pipe(
848 Effect.flatMap(HttpClientResponse.filterStatusOk),
849 Effect.flatMap((response) => response.arrayBuffer),
850 Effect.map((buffer) => new Uint8Array(buffer)),
851 Effect.scoped,
852 Effect.timeout(SCREENSHOT_TIMEOUT),
853 Effect.mapError(
854 (cause) =>
855 new DeviceOperationError({
856 operation: "screenshot",
857 reason: "request_failed",
858 cause,
859 }),
860 ),
861 );
862 return { device, png };
863 },
864 );
865
866 const resolveDevice = Effect.fn("DeviceService.resolveDevice")(function* (
867 hostId: DeviceHostId | undefined,
868 deviceId: DeviceId,
869 ) {
870 const host = yield* resolveHost(hostId);
871 const ready = yield* readiness(host.id);
872 const { state } = yield* SynchronizedRef.get(stateRef);
873 const device =
874 findDevice(state, host.id, deviceId) ?? findDevice(yield* refresh(ready), host.id, deviceId);
875 if (!device) return yield* new DeviceNotFoundError({ hostId: host.id, deviceId });
876 return { ready, device };
877 });
878
879 const detail: DeviceService["Service"]["detail"] = Effect.fn("DeviceService.detail")(
880 function* (input) {
881 const { ready, device } = yield* resolveDevice(input.hostId, input.deviceId);
882 const read = yield* readDeviceDetail(ready, device.platform, device.id);
883 return {
884 hostId: ready.hostId,
885 deviceId: device.id,
886 settings: read.settings,
887 foregroundApp: read.foregroundApp,
888 readAt: DateTime.formatIso(yield* DateTime.now),
889 };
890 },
891 );
892
893 const action: DeviceService["Service"]["action"] = Effect.fn("DeviceService.action")(
894 function* (input) {
895 const { ready, device } = yield* resolveDevice(input.hostId, input.deviceId);
896 yield* runDeviceAction(ready, device.platform, input);
897 return yield* detail({ hostId: ready.hostId, deviceId: device.id });
898 },
899 );
900
901 const sessionsForThread: DeviceService["Service"]["sessionsForThread"] = (threadId) =>
902 SynchronizedRef.get(stateRef).pipe(
903 Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)),
904 );
905
906 return {
907 ...DeviceService.of({
908 testHost,
909 updateTool: (tool) =>
910 lifecycleLock.withPermit(
911 Effect.gen(function* () {
912 if (!installTool)
913 return yield* new DeviceOperationError({
914 operation: "update device tool",
915 reason: "request_failed",
916 cause: new Error("Tool installation is unavailable in this device service."),
917 });
918 yield* installTool(tool);
919 return yield* inspect;
920 }),
921 ),
922 retryHost,
923 inspect,
924 agentCli: Effect.fail(
925 new DeviceHostUnavailableError({
926 hostId: LOCAL_DEVICE_HOST_ID,
927 reason: "Agent CLI installation is unavailable in this device service.",
928 }),
929 ),
930 agentTarget: (input) =>
931 Effect.gen(function* () {
932 const host = yield* resolveHost(input.hostId);
933 const ready = yield* agentReadinessIfSupported(input.hostId);
934 if (!ready)
935 return yield* new DeviceHostUnavailableError({
936 hostId: input.hostId,
937 reason:
938 "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.",
939 });
940 const configPath = yield* lifecycleLock.withPermit(
941 Effect.gen(function* () {
942 if (hosts.get(host.id) !== host)
943 return yield* new DeviceHostUnavailableError({
944 hostId: host.id,
945 reason: "Host configuration changed. Retry the operation.",
946 });
947 return yield* configureAgent(input.hostId, ready);
948 }),
949 );
950 const session = yield* agentDeviceSession(
951 input.threadId,
952 input.hostId,
953 input.deviceId,
954 ).pipe(Effect.provideService(Crypto.Crypto, crypto));
955 return ["--config", configPath, "--session", session];
956 }),
957 state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
958 subscribe: PubSub.subscribe(statePubSub),
959 configure,
960 list,
961 open,
962 close,
963 shutdown,
964 detail,
965 action,
966 screenshot,
967 readiness,
968 readinessIfSupported,
969 agentReadinessIfSupported,
970 currentReadiness,
971 sessionsForThread,
972 }),
973 setHostStatus,
974 withLifecycleLock: lifecycleLock.withPermit,
975 refreshHosts: Effect.gen(function* () {
976 const summaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);
977 const unchanged = (id: DeviceHostId) =>
978 hosts.has(id) && hosts.get(id) === publishedHosts.get(id);
979 yield* publish((state) => ({
980 ...state,
981 hosts: summaries,
982 hostStatuses: Object.fromEntries(
983 Object.entries(state.hostStatuses).filter(([id]) => unchanged(id)),
984 ),
985 devices: state.devices.filter((device) => unchanged(device.hostId)),
986 sessions: state.sessions.filter((session) => unchanged(session.hostId)),
987 }));
988 publishedHosts = new Map(hosts);
989 }),
990 };
991});
992
993/** @public Service construction is part of the canonical Effect module API. */
994export const make = Effect.gen(function* () {
995 const localHost = yield* DeviceHost.DeviceHost;
996 const config = yield* ServerConfig.ServerConfig;
997 const fs = yield* FileSystem.FileSystem;
998 const path = yield* Path.Path;
999 const runner = yield* ProcessRunner.ProcessRunner;
1000 const settings = yield* ServerSettings.ServerSettingsService;
1001 const scope = yield* Scope.Scope;
1002 const hosts = new Map<DeviceHostId, DeviceHost.DeviceHost["Service"]>([
1003 [localHost.id, localHost],
1004 ]);
1005 const crypto = yield* Crypto.Crypto;
1006 const configPath = (hostId: DeviceHostId) =>
1007 agentDeviceConfigPath(config.stateDir, hostId, path).pipe(
1008 Effect.provideService(Crypto.Crypto, crypto),
1009 );
1010 const configureAgent = (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) =>
1011 Effect.gen(function* () {
1012 const file = yield* configPath(hostId);
1013 yield* writeAgentDeviceConfig(file, ready.agentDevice);
1014 return file;
1015 }).pipe(
1016 Effect.provideService(FileSystem.FileSystem, fs),
1017 Effect.provideService(Path.Path, path),
1018 Effect.mapError(
1019 (cause) =>
1020 new DeviceOperationError({
1021 operation: "configure agent",
1022 reason: "settings_failed",
1023 cause,
1024 }),
1025 ),
1026 );
1027 const probeContext =
1028 yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.probe>>>();
1029 const localTargetContext =
1030 yield* Effect.context<Effect.Services<ReturnType<typeof isLocalSshDeviceHost>>>();
1031 const service = yield* makeWithHosts(
1032 hosts,
1033 (host) =>
1034 Effect.gen(function* () {
1035 if (yield* isLocalSshDeviceHost(host).pipe(Effect.provide(localTargetContext))) {
1036 return yield* localHost.summary;
1037 }
1038 return yield* SshDeviceHost.probe(host).pipe(Effect.provide(probeContext));
1039 }).pipe(
1040 Effect.mapError(
1041 (error) =>
1042 new DeviceOperationError({
1043 operation: "probe host",
1044 reason: "request_failed",
1045 cause: error,
1046 }),
1047 ),
1048 ),
1049 configureAgent,
1050 (tool) =>
1051 (tool === "hub" ? ensureDeviceHub(config.baseDir) : ensureAgentDevice(config.baseDir)).pipe(
1052 Effect.provideService(FileSystem.FileSystem, fs),
1053 Effect.provideService(Path.Path, path),
1054 Effect.provideService(ProcessRunner.ProcessRunner, runner),
1055 Effect.mapError(
1056 (cause) =>
1057 new DeviceOperationError({
1058 operation: "update device tool",
1059 reason: "command_failed",
1060 cause,
1061 }),
1062 ),
1063 ),
1064 );
1065 const hostContext =
1066 yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>();
1067 const configured = new Map<string, { config: SshDeviceHostConfig; scope: Scope.Closeable }>();
1068 const reconcile = (configuredHosts: ReadonlyArray<SshDeviceHostConfig>) =>
1069 Effect.gen(function* () {
1070 const next = yield* remoteSshDeviceHosts(configuredHosts).pipe(
1071 Effect.provide(localTargetContext),
1072 );
1073 const removed = yield* service.withLifecycleLock(
1074 Effect.gen(function* () {
1075 const removed: Array<{ id: string; scope: Scope.Closeable }> = [];
1076 for (const [id, previous] of configured) {
1077 if (
1078 next.some(
1079 (host) =>
1080 host.id === id &&
1081 host.label === previous.config.label &&
1082 host.target === previous.config.target &&
1083 host.port === previous.config.port &&
1084 host.identityFile === previous.config.identityFile,
1085 )
1086 )
1087 continue;
1088 hosts.delete(id);
1089 configured.delete(id);
1090 removed.push({ id, scope: previous.scope });
1091 }
1092 yield* service.refreshHosts;
1093 return removed;
1094 }),
1095 );
1096 // Stop old writers before deleting config files or publishing replacements, without blocking healthy hosts.
1097 yield* Effect.forEach(
1098 removed,
1099 ({ id, scope }) =>
1100 Effect.gen(function* () {
1101 yield* Scope.close(scope, Exit.void);
1102 yield* fs.remove(yield* configPath(id), { force: true }).pipe(Effect.ignore);
1103 }),
1104 { concurrency: 4, discard: true },
1105 );
1106 yield* service.withLifecycleLock(
1107 Effect.gen(function* () {
1108 for (const host of next) {
1109 if (configured.has(host.id)) continue;
1110 const hostScope = yield* Scope.fork(scope);
1111 const instance = yield* SshDeviceHost.make(
1112 host,
1113 (ready) =>
1114 configureAgent(host.id, ready).pipe(
1115 Effect.asVoid,
1116 Effect.mapError(
1117 (error) =>
1118 new DeviceHost.DeviceHostError({
1119 hostId: host.id,
1120 step: "configuring agent access",
1121 cause: error,
1122 }),
1123 ),
1124 ),
1125 (status, detail) =>
1126 service
1127 .setHostStatus(host.id, { status, ...(detail ? { detail } : {}) })
1128 .pipe(Effect.asVoid),
1129 ).pipe(Effect.provideService(Scope.Scope, hostScope), Effect.provide(hostContext));
1130 hosts.set(host.id, instance);
1131 configured.set(host.id, { config: host, scope: hostScope });
1132 }
1133 yield* service.refreshHosts;
1134 }),
1135 );
1136 });
1137 const changes = yield* settings.subscribeChanges;
1138 yield* reconcile((yield* settings.getSettings).deviceHosts);
1139 yield* changes.pipe(
1140 Stream.runForEach((value) => reconcile(value.deviceHosts)),
1141 Effect.forkIn(scope),
1142 );
1143 yield* Effect.addFinalizer(() =>
1144 Effect.forEach(configured.values(), (value) => Scope.close(value.scope, Exit.void), {
1145 discard: true,
1146 concurrency: 4,
1147 }),
1148 );
1149 return {
1150 ...service,
1151 agentCli: resolveNodeExecutable("Device automation").pipe(
1152 Effect.andThen(ensureAgentDevice(config.baseDir)),
1153 Effect.provideService(FileSystem.FileSystem, fs),
1154 Effect.provideService(Path.Path, path),
1155 Effect.provideService(ProcessRunner.ProcessRunner, runner),
1156 Effect.map((tool) => tool.entryPath),
1157 Effect.mapError((error) =>
1158 error._tag === "NodeRuntimeUnavailableError"
1159 ? new DeviceHostUnavailableError({
1160 hostId: LOCAL_DEVICE_HOST_ID,
1161 reason: nodeRuntimeUnavailableMessage("Device automation"),
1162 cause: error,
1163 })
1164 : new DeviceOperationError({
1165 operation: "install agent CLI",
1166 reason: "command_failed",
1167 cause: error,
1168 }),
1169 ),
1170 ),
1171 };
1172});
1173
1174export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer));
1175
1176/** State stream for WS subscribers: current snapshot first, then every change. */
1177export const stateStream = (service: DeviceService["Service"]): Stream.Stream<DeviceServiceState> =>
1178 Stream.unwrap(
1179 Effect.gen(function* () {
1180 // Subscribe before reading the snapshot so no change between the two
1181 // is lost; the scope lives as long as the stream does.
1182 const subscription = yield* service.subscribe;
1183 const initial = yield* service.state;
1184 return Stream.concat(Stream.make(initial), Stream.fromSubscription(subscription));
1185 }),
1186 ).pipe(Stream.scoped);