1/**
2 * Device - Schemas for first-class iOS Simulator and Android Emulator support.
3 *
4 * The server owns device discovery, the streaming helper (expo-device-hub),
5 * and the agent driver (agent-device). Clients render the live screen from the
6 * server-proxied stream, and agents reach devices through the `device_*` MCP
7 * tools plus the `agent-device` CLI the server preconfigures for them.
8 *
9 * Devices live on a *host*. Only the local host (the machine the server runs
10 * on) exists today; the host id is carried everywhere so SSH and cloud hosts
11 * can be added without changing the client contract.
12 *
13 * @module Device
14 */
15import { Schema } from "effect";
16
17import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
18
19export const DevicePlatform = Schema.Literals(["ios", "android"]);
20export type DevicePlatform = typeof DevicePlatform.Type;
21
22export const DeviceHostId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
23export type DeviceHostId = typeof DeviceHostId.Type;
24
25/** The server machine. Always present; other host kinds are future work. */
26export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId;
27
28/** SSH aliases and key paths are resolved on the environment server. */
29export const SshDeviceHostConfig = Schema.Struct({
30 id: DeviceHostId.check(
31 Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/),
32 Schema.makeFilter((id) => id !== "local" || "The local host id is reserved."),
33 ),
34 label: TrimmedNonEmptyString,
35 target: TrimmedNonEmptyString.check(Schema.isPattern(/^[^\s-][^\s]*$/)),
36 identityFile: Schema.optional(TrimmedNonEmptyString),
37 port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
38});
39export type SshDeviceHostConfig = typeof SshDeviceHostConfig.Type;
40
41export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(
42 Schema.makeFilter(
43 (hosts) =>
44 new Set(hosts.map((host) => host.id)).size === hosts.length ||
45 "Device host ids must be unique.",
46 ),
47);
48
49/** Simulator udid or adb serial (an AVD name while it is not running). */
50export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256));
51export type DeviceId = typeof DeviceId.Type;
52
53export const DeviceSummary = Schema.Struct({
54 hostId: DeviceHostId,
55 id: DeviceId,
56 platform: DevicePlatform,
57 name: TrimmedNonEmptyString,
58 /** OS label such as "iOS 18.0" or "Android 15.0". */
59 version: Schema.String,
60 booted: Schema.Boolean,
61 physical: Schema.Boolean,
62});
63export type DeviceSummary = typeof DeviceSummary.Type;
64
65/**
66 * What the host can do right now. Platforms missing their toolchain are
67 * reported rather than hidden so the picker and the agent can explain why a
68 * platform is absent instead of showing an empty list.
69 */
70export const DevicePlatformAvailability = Schema.Struct({
71 platform: DevicePlatform,
72 available: Schema.Boolean,
73 reason: Schema.optional(Schema.String),
74});
75export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type;
76
77export const DeviceToolVersion = Schema.Struct({
78 requiredVersion: Schema.String,
79 installedVersions: Schema.Array(Schema.String),
80 runningVersion: Schema.NullOr(Schema.String),
81});
82export type DeviceToolVersion = typeof DeviceToolVersion.Type;
83
84export const DeviceToolVersions = Schema.Struct({
85 hub: DeviceToolVersion,
86 agent: DeviceToolVersion,
87});
88export type DeviceToolVersions = typeof DeviceToolVersions.Type;
89
90export const DeviceHostSummary = Schema.Struct({
91 id: DeviceHostId,
92 kind: Schema.Literals(["local", "ssh"]),
93 label: TrimmedNonEmptyString,
94 platforms: Schema.Array(DevicePlatformAvailability),
95 tools: Schema.optional(DeviceToolVersions),
96 toolInspectionError: Schema.optional(Schema.String),
97 hubInstalled: Schema.Boolean,
98 agentDeviceInstalled: Schema.Boolean,
99});
100export type DeviceHostSummary = typeof DeviceHostSummary.Type;
101
102/**
103 * Lifecycle of the helper processes on a host. Tools are installed on first
104 * use, so a fresh install spends a while in `installing` before any device can
105 * stream; the UI shows that instead of an empty picker.
106 */
107export const DeviceHostStatus = Schema.Literals([
108 "disabled",
109 "idle",
110 "installing",
111 "starting",
112 "ready",
113 "failed",
114]);
115export type DeviceHostStatus = typeof DeviceHostStatus.Type;
116
117/**
118 * A device a thread is looking at. One session per (thread, device); the same
119 * device may be open in several threads, since the stream is shared.
120 */
121export const DeviceSession = Schema.Struct({
122 threadId: ThreadId,
123 hostId: DeviceHostId,
124 deviceId: DeviceId,
125 platform: DevicePlatform,
126 openedAt: Schema.String,
127});
128export type DeviceSession = typeof DeviceSession.Type;
129
130export const DeviceServiceState = Schema.Struct({
131 supportsHostRetry: Schema.optional(Schema.Boolean),
132 supportsToolUpdate: Schema.optional(Schema.Boolean),
133 supportsToolInspection: Schema.optional(Schema.Boolean),
134 hosts: Schema.Array(DeviceHostSummary),
135 hostStatus: DeviceHostStatus,
136 hostStatusDetail: Schema.optional(Schema.String),
137 hostStatuses: Schema.Record(
138 DeviceHostId,
139 Schema.Struct({
140 status: DeviceHostStatus,
141 detail: Schema.optional(Schema.String),
142 }),
143 ),
144 devices: Schema.Array(DeviceSummary),
145 sessions: Schema.Array(DeviceSession),
146 bootingDevices: Schema.optional(
147 Schema.Array(Schema.Struct({ ...DeviceSummary.fields, threadId: ThreadId })),
148 ),
149 onboardingCompleted: Schema.Boolean,
150 agentAccessEnabled: Schema.Boolean,
151 /** Origin-relative path the client prefixes to hub routes. */
152 hubBasePath: Schema.String,
153 revision: Schema.Int,
154});
155export type DeviceServiceState = typeof DeviceServiceState.Type;
156
157export const DeviceListInput = Schema.Struct({
158 /** Install this server's pinned tool without enabling access or starting helpers. */
159 updateTool: Schema.optional(Schema.Literals(["hub", "agent"])),
160 /** Read inventory without installing tools or starting helpers. */
161 inspectOnly: Schema.optional(Schema.Boolean),
162 /** Retry this host only, including agent tools if access was already granted. */
163 retryHostId: Schema.optional(DeviceHostId),
164});
165export type DeviceListInput = typeof DeviceListInput.Type;
166
167export const DeviceConfigureInput = Schema.Struct({
168 enabled: Schema.optional(Schema.Boolean),
169 agentAccessEnabled: Schema.optional(Schema.Boolean),
170 onboardingCompleted: Schema.optional(Schema.Boolean),
171});
172export type DeviceConfigureInput = typeof DeviceConfigureInput.Type;
173
174export const DeviceOpenInput = Schema.Struct({
175 threadId: ThreadId,
176 hostId: Schema.optional(DeviceHostId),
177 deviceId: DeviceId,
178 platform: DevicePlatform,
179 /** Boot the simulator or emulator when it is not running. Defaults to true. */
180 boot: Schema.optional(Schema.Boolean),
181});
182export type DeviceOpenInput = typeof DeviceOpenInput.Type;
183
184export const DeviceCloseInput = Schema.Struct({
185 hostId: Schema.optional(DeviceHostId),
186 threadId: ThreadId,
187 /** Omit to close every device session for the thread. */
188 deviceId: Schema.optional(DeviceId),
189 /** Also shut the simulator or emulator down. Defaults to false. */
190 shutdown: Schema.optional(Schema.Boolean),
191});
192export type DeviceCloseInput = typeof DeviceCloseInput.Type;
193
194export const DeviceShutdownInput = Schema.Struct({
195 hostId: Schema.optional(DeviceHostId),
196 deviceId: DeviceId,
197 platform: DevicePlatform,
198});
199export type DeviceShutdownInput = typeof DeviceShutdownInput.Type;
200
201// Device settings and actions. Each setting names the platforms that support
202// it; the panel hides the rest. Values are normalized across platforms where
203// both have the concept (appearance, text size) and platform-specific where
204// only one does.
205
206export const DeviceAppearance = Schema.Literals(["light", "dark"]);
207export type DeviceAppearance = typeof DeviceAppearance.Type;
208
209/**
210 * iOS content-size categories map onto twelve steps; Android `font_scale`
211 * is continuous. Four shared steps cover what people actually reach for.
212 */
213export const DeviceTextSize = Schema.Literals(["small", "default", "large", "extra-large"]);
214export type DeviceTextSize = typeof DeviceTextSize.Type;
215
216export const DeviceColorFilter = Schema.Literals([
217 "none",
218 "grayscale",
219 "red-green",
220 "green-red",
221 "blue-yellow",
222]);
223export type DeviceColorFilter = typeof DeviceColorFilter.Type;
224
225export const DeviceOrientation = Schema.Literals([
226 "portrait",
227 "landscape_left",
228 "portrait_upside_down",
229 "landscape_right",
230]);
231export type DeviceOrientation = typeof DeviceOrientation.Type;
232
233/** Current values as read from the device; `undefined` means unsupported or unread. */
234export const DeviceSettings = Schema.Struct({
235 appearance: Schema.optional(DeviceAppearance),
236 textSize: Schema.optional(DeviceTextSize),
237 reduceMotion: Schema.optional(Schema.Boolean),
238 increaseContrast: Schema.optional(Schema.Boolean),
239 reduceTransparency: Schema.optional(Schema.Boolean),
240 showBorders: Schema.optional(Schema.Boolean),
241 voiceOver: Schema.optional(Schema.Boolean),
242 liquidGlass: Schema.optional(Schema.Literals(["clear", "tinted"])),
243 colorFilter: Schema.optional(DeviceColorFilter),
244 networkEnabled: Schema.optional(Schema.Boolean),
245 location: Schema.optional(
246 Schema.NullOr(Schema.Struct({ latitude: Schema.Number, longitude: Schema.Number })),
247 ),
248});
249export type DeviceSettings = typeof DeviceSettings.Type;
250
251/** The app in the foreground, when the platform can tell us. */
252export const DeviceForegroundApp = Schema.Struct({
253 id: Schema.String,
254 name: Schema.optional(Schema.String),
255 version: Schema.optional(Schema.String),
256});
257export type DeviceForegroundApp = typeof DeviceForegroundApp.Type;
258
259export const DeviceDetail = Schema.Struct({
260 hostId: DeviceHostId,
261 deviceId: DeviceId,
262 settings: DeviceSettings,
263 foregroundApp: Schema.NullOr(DeviceForegroundApp),
264 readAt: Schema.String,
265});
266export type DeviceDetail = typeof DeviceDetail.Type;
267
268export const DevicePermission = Schema.Literals([
269 "camera",
270 "microphone",
271 "photos",
272 "contacts",
273 "calendar",
274 "reminders",
275 "location",
276 "notifications",
277 "motion",
278 "media-library",
279 "faceid",
280]);
281export type DevicePermission = typeof DevicePermission.Type;
282
283const DeviceTarget = {
284 hostId: Schema.optional(DeviceHostId),
285 deviceId: DeviceId,
286};
287
288export const DeviceActionInput = Schema.Union([
289 Schema.Struct({
290 ...DeviceTarget,
291 type: Schema.Literal("setAppearance"),
292 value: DeviceAppearance,
293 }),
294 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setTextSize"), value: DeviceTextSize }),
295 Schema.Struct({
296 ...DeviceTarget,
297 type: Schema.Literal("setToggle"),
298 setting: Schema.Literals([
299 "reduceMotion",
300 "increaseContrast",
301 "reduceTransparency",
302 "showBorders",
303 "voiceOver",
304 "networkEnabled",
305 ]),
306 value: Schema.Boolean,
307 }),
308 Schema.Struct({
309 ...DeviceTarget,
310 type: Schema.Literal("setLiquidGlass"),
311 value: Schema.Literals(["clear", "tinted"]),
312 }),
313 Schema.Struct({
314 ...DeviceTarget,
315 type: Schema.Literal("setColorFilter"),
316 value: DeviceColorFilter,
317 }),
318 Schema.Struct({
319 ...DeviceTarget,
320 type: Schema.Literal("setOrientation"),
321 value: DeviceOrientation,
322 }),
323 Schema.Struct({
324 ...DeviceTarget,
325 type: Schema.Literal("setLocation"),
326 latitude: Schema.Number.check(Schema.isBetween({ minimum: -90, maximum: 90 })),
327 longitude: Schema.Number.check(Schema.isBetween({ minimum: -180, maximum: 180 })),
328 }),
329 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("clearLocation") }),
330 Schema.Struct({
331 ...DeviceTarget,
332 type: Schema.Literal("setPermission"),
333 appId: TrimmedNonEmptyString,
334 permission: DevicePermission,
335 decision: Schema.Literals(["grant", "revoke", "reset"]),
336 }),
337 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("openUrl"), url: TrimmedNonEmptyString }),
338 Schema.Struct({
339 ...DeviceTarget,
340 type: Schema.Literal("launchApp"),
341 appId: TrimmedNonEmptyString,
342 }),
343 Schema.Struct({
344 ...DeviceTarget,
345 type: Schema.Literal("terminateApp"),
346 appId: TrimmedNonEmptyString,
347 }),
348 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("shake") }),
349 Schema.Struct({
350 ...DeviceTarget,
351 type: Schema.Literal("sendPush"),
352 appId: TrimmedNonEmptyString,
353 /** APNs-style payload; a bare string becomes the alert body. */
354 payload: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Unknown)]),
355 }),
356]);
357export type DeviceActionInput = typeof DeviceActionInput.Type;
358export type DeviceActionType = DeviceActionInput["type"];
359
360export const DeviceDetailInput = Schema.Struct(DeviceTarget);
361export type DeviceDetailInput = typeof DeviceDetailInput.Type;
362
363export class DeviceHostUnavailableError extends Schema.TaggedError<DeviceHostUnavailableError>()(
364 "DeviceHostUnavailableError",
365 {
366 hostId: DeviceHostId,
367 reason: Schema.String,
368 cause: Schema.optional(Schema.Defect()),
369 },
370) {
371 override get message(): string {
372 return `Device host ${this.hostId} is unavailable: ${this.reason}`;
373 }
374}
375
376export class DevicePlatformUnavailableError extends Schema.TaggedError<DevicePlatformUnavailableError>()(
377 "DevicePlatformUnavailableError",
378 {
379 hostId: DeviceHostId,
380 platform: DevicePlatform,
381 reason: Schema.String,
382 },
383) {
384 override get message(): string {
385 return `${this.platform} devices are unavailable on host ${this.hostId}: ${this.reason}`;
386 }
387}
388
389export class DeviceNotFoundError extends Schema.TaggedError<DeviceNotFoundError>()(
390 "DeviceNotFoundError",
391 {
392 hostId: DeviceHostId,
393 deviceId: DeviceId,
394 },
395) {
396 override get message(): string {
397 return `Device ${this.deviceId} was not found on host ${this.hostId}.`;
398 }
399}
400
401export class DeviceBootError extends Schema.TaggedError<DeviceBootError>()("DeviceBootError", {
402 hostId: DeviceHostId,
403 deviceId: DeviceId,
404 reason: Schema.Literals(["disk_space", "timeout", "launch_failed"]),
405 cause: Schema.Defect(),
406}) {
407 override get message(): string {
408 const explanation = {
409 disk_space: "There is not enough free disk space on the environment server.",
410 timeout: "The device did not become ready in time.",
411 launch_failed:
412 "The simulator or emulator could not start. Check its configuration on the environment server.",
413 }[this.reason];
414 return `Device ${this.deviceId} failed to boot: ${explanation}`;
415 }
416}
417
418export class DeviceOperationError extends Schema.TaggedError<DeviceOperationError>()(
419 "DeviceOperationError",
420 {
421 operation: Schema.String,
422 reason: Schema.Literals([
423 "command_failed",
424 "request_failed",
425 "invalid_payload",
426 "settings_failed",
427 "hub_rejected",
428 ]),
429 exitCode: Schema.optional(Schema.Number),
430 cause: Schema.Defect(),
431 },
432) {
433 override get message(): string {
434 const explanation = {
435 command_failed: `The device command failed${this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`}.`,
436 request_failed: "Could not communicate with device support. Try refreshing devices.",
437 invalid_payload: "The device request could not be encoded.",
438 settings_failed: "Could not read or save device settings.",
439 hub_rejected: "The device hub could not complete the request.",
440 }[this.reason];
441 return `Device ${this.operation} failed: ${explanation}`;
442 }
443}
444
445export class DeviceActionUnavailableError extends Schema.TaggedError<DeviceActionUnavailableError>()(
446 "DeviceActionUnavailableError",
447 {
448 operation: Schema.String,
449 platform: DevicePlatform,
450 reason: Schema.Literals(["unsupported", "helper_missing"]),
451 },
452) {
453 override get message(): string {
454 return this.reason === "helper_missing"
455 ? `Device ${this.operation} requires a helper missing from this install. Set up device support again.`
456 : `Device ${this.operation} is not supported on ${this.platform}.`;
457 }
458}
459
460export const DeviceError = Schema.Union([
461 DeviceHostUnavailableError,
462 DevicePlatformUnavailableError,
463 DeviceNotFoundError,
464 DeviceBootError,
465 DeviceOperationError,
466 DeviceActionUnavailableError,
467]);
468export type DeviceError = typeof DeviceError.Type;
469
470// MCP tool shapes. Kept next to the RPC shapes so the tool surface and the
471// panel describe devices the same way.
472
473export const DeviceToolListResult = Schema.Struct({
474 hostStatuses: DeviceServiceState.fields.hostStatuses,
475 hosts: Schema.Array(DeviceHostSummary),
476 devices: Schema.Array(DeviceSummary),
477 /** Devices already open in this thread's Device panel. */
478 open: Schema.Array(Schema.Struct({ hostId: DeviceHostId, deviceId: DeviceId })),
479});
480export type DeviceToolListResult = typeof DeviceToolListResult.Type;
481
482export const DeviceToolOpenInput = Schema.Struct({
483 deviceId: Schema.optional(
484 DeviceId.annotate({
485 description:
486 "Simulator udid or emulator serial from device_list. Omit to use the booted device for the platform, or the most recently used one.",
487 }),
488 ),
489 platform: Schema.optional(
490 DevicePlatform.annotate({
491 description: "Required when deviceId is omitted and both platforms are available.",
492 }),
493 ),
494 hostId: Schema.optional(
495 DeviceHostId.annotate({ description: "Device host from device_list. Defaults to local." }),
496 ),
497}).annotate({
498 description:
499 "Boots the device if needed, starts its live stream, and opens the Device panel so the user can watch. Returns how to drive it with the agent-device CLI.",
500});
501export type DeviceToolOpenInput = typeof DeviceToolOpenInput.Type;
502
503export const DeviceToolOpenResult = Schema.Struct({
504 device: DeviceSummary,
505 /** Ready-to-run agent-device invocation pinned to this device. */
506 agentDevice: Schema.Struct({
507 command: Schema.String,
508 /** Flags that pin every command to this device, e.g. `--udid <id>`. */
509 targetArgs: Schema.Array(Schema.String),
510 }),
511 quickStart: Schema.String,
512});
513export type DeviceToolOpenResult = typeof DeviceToolOpenResult.Type;
514
515export const DeviceToolTargetInput = Schema.Struct({
516 deviceId: Schema.optional(
517 DeviceId.annotate({
518 description:
519 "Device from device_list. Omit to use the device most recently opened in this thread.",
520 }),
521 ),
522 hostId: Schema.optional(DeviceHostId),
523});
524export type DeviceToolTargetInput = typeof DeviceToolTargetInput.Type;
525
526export const DeviceToolScreenshotResult = Schema.Struct({
527 device: DeviceSummary,
528 screenshot: Schema.Struct({
529 mimeType: Schema.Literal("image/png"),
530 data: Schema.String,
531 width: Schema.Int,
532 height: Schema.Int,
533 }),
534});
535export type DeviceToolScreenshotResult = typeof DeviceToolScreenshotResult.Type;
536
537export const DeviceToolCloseInput = Schema.Struct({
538 deviceId: Schema.optional(
539 DeviceId.annotate({
540 description: "Device to close. Omit to close every device in this thread.",
541 }),
542 ),
543 hostId: Schema.optional(DeviceHostId),
544 shutdown: Schema.optional(
545 Schema.Boolean.annotate({
546 description: "Also power the simulator or emulator off. Defaults to false.",
547 }),
548 ),
549});
550export type DeviceToolCloseInput = typeof DeviceToolCloseInput.Type;
551
552export class DeviceToolUnavailableError extends Schema.TaggedError<DeviceToolUnavailableError>()(
553 "DeviceToolUnavailableError",
554 {
555 reason: Schema.String,
556 cause: Schema.optional(Schema.Defect()),
557 },
558) {
559 override get message(): string {
560 return this.reason;
561 }
562}
563
564export const DeviceToolError = Schema.Union([
565 DeviceToolUnavailableError,
566 DeviceHostUnavailableError,
567 DevicePlatformUnavailableError,
568 DeviceNotFoundError,
569 DeviceBootError,
570 DeviceOperationError,
571 DeviceActionUnavailableError,
572]);
573export type DeviceToolError = typeof DeviceToolError.Type;
574
575export function deviceToolInstallMessage(name: string, tool: DeviceToolVersion | undefined) {
576 if (!tool) return `Installing ${name}…`;
577 const previous =
578 tool.runningVersion ??
579 [...tool.installedVersions].sort((a, b) => a.localeCompare(b, "en", { numeric: true })).at(-1);
580 return previous && !tool.installedVersions.includes(tool.requiredVersion)
581 ? `Updating ${name} from ${previous} to ${tool.requiredVersion}…`
582 : `Installing ${name} ${tool.requiredVersion}…`;
583}