/** * Device - Schemas for first-class iOS Simulator and Android Emulator support. * * The server owns device discovery, the streaming helper (expo-device-hub), * and the agent driver (agent-device). Clients render the live screen from the * server-proxied stream, and agents reach devices through the `device_*` MCP * tools plus the `agent-device` CLI the server preconfigures for them. * * Devices live on a *host*. Only the local host (the machine the server runs * on) exists today; the host id is carried everywhere so SSH and cloud hosts * can be added without changing the client contract. * * @module Device */ import { Schema } from "effect"; import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const DevicePlatform = Schema.Literals(["ios", "android"]); export type DevicePlatform = typeof DevicePlatform.Type; export const DeviceHostId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); export type DeviceHostId = typeof DeviceHostId.Type; /** The server machine. Always present; other host kinds are future work. */ export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId; /** SSH aliases and key paths are resolved on the environment server. */ export const SshDeviceHostConfig = Schema.Struct({ id: DeviceHostId.check( Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), Schema.makeFilter((id) => id !== "local" || "The local host id is reserved."), ), label: TrimmedNonEmptyString, target: TrimmedNonEmptyString.check(Schema.isPattern(/^[^\s-][^\s]*$/)), identityFile: Schema.optional(TrimmedNonEmptyString), port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), }); export type SshDeviceHostConfig = typeof SshDeviceHostConfig.Type; export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check( Schema.makeFilter( (hosts) => new Set(hosts.map((host) => host.id)).size === hosts.length || "Device host ids must be unique.", ), ); /** Simulator udid or adb serial (an AVD name while it is not running). */ export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); export type DeviceId = typeof DeviceId.Type; export const DeviceSummary = Schema.Struct({ hostId: DeviceHostId, id: DeviceId, platform: DevicePlatform, name: TrimmedNonEmptyString, /** OS label such as "iOS 18.0" or "Android 15.0". */ version: Schema.String, booted: Schema.Boolean, physical: Schema.Boolean, }); export type DeviceSummary = typeof DeviceSummary.Type; /** * What the host can do right now. Platforms missing their toolchain are * reported rather than hidden so the picker and the agent can explain why a * platform is absent instead of showing an empty list. */ export const DevicePlatformAvailability = Schema.Struct({ platform: DevicePlatform, available: Schema.Boolean, reason: Schema.optional(Schema.String), }); export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type; export const DeviceHostSummary = Schema.Struct({ id: DeviceHostId, kind: Schema.Literals(["local", "ssh"]), label: TrimmedNonEmptyString, platforms: Schema.Array(DevicePlatformAvailability), hubInstalled: Schema.Boolean, agentDeviceInstalled: Schema.Boolean, }); export type DeviceHostSummary = typeof DeviceHostSummary.Type; /** * Lifecycle of the helper processes on a host. Tools are installed on first * use, so a fresh install spends a while in `installing` before any device can * stream; the UI shows that instead of an empty picker. */ export const DeviceHostStatus = Schema.Literals([ "disabled", "idle", "installing", "starting", "ready", "failed", ]); export type DeviceHostStatus = typeof DeviceHostStatus.Type; /** * A device a thread is looking at. One session per (thread, device); the same * device may be open in several threads, since the stream is shared. */ export const DeviceSession = Schema.Struct({ threadId: ThreadId, hostId: DeviceHostId, deviceId: DeviceId, platform: DevicePlatform, openedAt: Schema.String, }); export type DeviceSession = typeof DeviceSession.Type; export const DeviceServiceState = Schema.Struct({ hosts: Schema.Array(DeviceHostSummary), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), hostStatuses: Schema.Record( DeviceHostId, Schema.Struct({ status: DeviceHostStatus, detail: Schema.optional(Schema.String), }), ), devices: Schema.Array(DeviceSummary), sessions: Schema.Array(DeviceSession), bootingDevices: Schema.optional( Schema.Array(Schema.Struct({ ...DeviceSummary.fields, threadId: ThreadId })), ), onboardingCompleted: Schema.Boolean, agentAccessEnabled: Schema.Boolean, /** Origin-relative path the client prefixes to hub routes. */ hubBasePath: Schema.String, revision: Schema.Int, }); export type DeviceServiceState = typeof DeviceServiceState.Type; export const DeviceListInput = Schema.Struct({}); export type DeviceListInput = typeof DeviceListInput.Type; export const DeviceConfigureInput = Schema.Struct({ enabled: Schema.optional(Schema.Boolean), agentAccessEnabled: Schema.optional(Schema.Boolean), onboardingCompleted: Schema.optional(Schema.Boolean), }); export type DeviceConfigureInput = typeof DeviceConfigureInput.Type; export const DeviceOpenInput = Schema.Struct({ threadId: ThreadId, hostId: Schema.optional(DeviceHostId), deviceId: DeviceId, platform: DevicePlatform, /** Boot the simulator or emulator when it is not running. Defaults to true. */ boot: Schema.optional(Schema.Boolean), }); export type DeviceOpenInput = typeof DeviceOpenInput.Type; export const DeviceCloseInput = Schema.Struct({ hostId: Schema.optional(DeviceHostId), threadId: ThreadId, /** Omit to close every device session for the thread. */ deviceId: Schema.optional(DeviceId), /** Also shut the simulator or emulator down. Defaults to false. */ shutdown: Schema.optional(Schema.Boolean), }); export type DeviceCloseInput = typeof DeviceCloseInput.Type; export const DeviceShutdownInput = Schema.Struct({ hostId: Schema.optional(DeviceHostId), deviceId: DeviceId, platform: DevicePlatform, }); export type DeviceShutdownInput = typeof DeviceShutdownInput.Type; // Device settings and actions. Each setting names the platforms that support // it; the panel hides the rest. Values are normalized across platforms where // both have the concept (appearance, text size) and platform-specific where // only one does. export const DeviceAppearance = Schema.Literals(["light", "dark"]); export type DeviceAppearance = typeof DeviceAppearance.Type; /** * iOS content-size categories map onto twelve steps; Android `font_scale` * is continuous. Four shared steps cover what people actually reach for. */ export const DeviceTextSize = Schema.Literals(["small", "default", "large", "extra-large"]); export type DeviceTextSize = typeof DeviceTextSize.Type; export const DeviceColorFilter = Schema.Literals([ "none", "grayscale", "red-green", "green-red", "blue-yellow", ]); export type DeviceColorFilter = typeof DeviceColorFilter.Type; export const DeviceOrientation = Schema.Literals([ "portrait", "landscape_left", "portrait_upside_down", "landscape_right", ]); export type DeviceOrientation = typeof DeviceOrientation.Type; /** Current values as read from the device; `undefined` means unsupported or unread. */ export const DeviceSettings = Schema.Struct({ appearance: Schema.optional(DeviceAppearance), textSize: Schema.optional(DeviceTextSize), reduceMotion: Schema.optional(Schema.Boolean), increaseContrast: Schema.optional(Schema.Boolean), reduceTransparency: Schema.optional(Schema.Boolean), showBorders: Schema.optional(Schema.Boolean), voiceOver: Schema.optional(Schema.Boolean), liquidGlass: Schema.optional(Schema.Literals(["clear", "tinted"])), colorFilter: Schema.optional(DeviceColorFilter), networkEnabled: Schema.optional(Schema.Boolean), location: Schema.optional( Schema.NullOr(Schema.Struct({ latitude: Schema.Number, longitude: Schema.Number })), ), }); export type DeviceSettings = typeof DeviceSettings.Type; /** The app in the foreground, when the platform can tell us. */ export const DeviceForegroundApp = Schema.Struct({ id: Schema.String, name: Schema.optional(Schema.String), version: Schema.optional(Schema.String), }); export type DeviceForegroundApp = typeof DeviceForegroundApp.Type; export const DeviceDetail = Schema.Struct({ hostId: DeviceHostId, deviceId: DeviceId, settings: DeviceSettings, foregroundApp: Schema.NullOr(DeviceForegroundApp), readAt: Schema.String, }); export type DeviceDetail = typeof DeviceDetail.Type; export const DevicePermission = Schema.Literals([ "camera", "microphone", "photos", "contacts", "calendar", "reminders", "location", "notifications", "motion", "media-library", "faceid", ]); export type DevicePermission = typeof DevicePermission.Type; const DeviceTarget = { hostId: Schema.optional(DeviceHostId), deviceId: DeviceId, }; export const DeviceActionInput = Schema.Union([ Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setAppearance"), value: DeviceAppearance, }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setTextSize"), value: DeviceTextSize }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setToggle"), setting: Schema.Literals([ "reduceMotion", "increaseContrast", "reduceTransparency", "showBorders", "voiceOver", "networkEnabled", ]), value: Schema.Boolean, }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setLiquidGlass"), value: Schema.Literals(["clear", "tinted"]), }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setColorFilter"), value: DeviceColorFilter, }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setOrientation"), value: DeviceOrientation, }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setLocation"), latitude: Schema.Number.check(Schema.isBetween({ minimum: -90, maximum: 90 })), longitude: Schema.Number.check(Schema.isBetween({ minimum: -180, maximum: 180 })), }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("clearLocation") }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setPermission"), appId: TrimmedNonEmptyString, permission: DevicePermission, decision: Schema.Literals(["grant", "revoke", "reset"]), }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("openUrl"), url: TrimmedNonEmptyString }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("launchApp"), appId: TrimmedNonEmptyString, }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("terminateApp"), appId: TrimmedNonEmptyString, }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("shake") }), Schema.Struct({ ...DeviceTarget, type: Schema.Literal("sendPush"), appId: TrimmedNonEmptyString, /** APNs-style payload; a bare string becomes the alert body. */ payload: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Unknown)]), }), ]); export type DeviceActionInput = typeof DeviceActionInput.Type; export type DeviceActionType = DeviceActionInput["type"]; export const DeviceDetailInput = Schema.Struct(DeviceTarget); export type DeviceDetailInput = typeof DeviceDetailInput.Type; export class DeviceHostUnavailableError extends Schema.TaggedError()( "DeviceHostUnavailableError", { hostId: DeviceHostId, reason: Schema.String, }, ) { override get message(): string { return `Device host ${this.hostId} is unavailable: ${this.reason}`; } } export class DevicePlatformUnavailableError extends Schema.TaggedError()( "DevicePlatformUnavailableError", { hostId: DeviceHostId, platform: DevicePlatform, reason: Schema.String, }, ) { override get message(): string { return `${this.platform} devices are unavailable on host ${this.hostId}: ${this.reason}`; } } export class DeviceNotFoundError extends Schema.TaggedError()( "DeviceNotFoundError", { hostId: DeviceHostId, deviceId: DeviceId, }, ) { override get message(): string { return `Device ${this.deviceId} was not found on host ${this.hostId}.`; } } export class DeviceBootError extends Schema.TaggedError()("DeviceBootError", { hostId: DeviceHostId, deviceId: DeviceId, reason: Schema.Literals(["disk_space", "timeout", "launch_failed"]), cause: Schema.Defect(), }) { override get message(): string { const explanation = { disk_space: "There is not enough free disk space on the environment server.", timeout: "The device did not become ready in time.", launch_failed: "The simulator or emulator could not start. Check its configuration on the environment server.", }[this.reason]; return `Device ${this.deviceId} failed to boot: ${explanation}`; } } export class DeviceOperationError extends Schema.TaggedError()( "DeviceOperationError", { operation: Schema.String, reason: Schema.Literals([ "command_failed", "request_failed", "invalid_payload", "settings_failed", "hub_rejected", ]), exitCode: Schema.optional(Schema.Number), cause: Schema.Defect(), }, ) { override get message(): string { const explanation = { command_failed: `The device command failed${this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`}.`, request_failed: "Could not communicate with device support. Try refreshing devices.", invalid_payload: "The device request could not be encoded.", settings_failed: "Could not read or save device settings.", hub_rejected: "The device hub could not complete the request.", }[this.reason]; return `Device ${this.operation} failed: ${explanation}`; } } export class DeviceActionUnavailableError extends Schema.TaggedError()( "DeviceActionUnavailableError", { operation: Schema.String, platform: DevicePlatform, reason: Schema.Literals(["unsupported", "helper_missing"]), }, ) { override get message(): string { return this.reason === "helper_missing" ? `Device ${this.operation} requires a helper missing from this install. Set up device support again.` : `Device ${this.operation} is not supported on ${this.platform}.`; } } export const DeviceError = Schema.Union([ DeviceHostUnavailableError, DevicePlatformUnavailableError, DeviceNotFoundError, DeviceBootError, DeviceOperationError, DeviceActionUnavailableError, ]); export type DeviceError = typeof DeviceError.Type; // MCP tool shapes. Kept next to the RPC shapes so the tool surface and the // panel describe devices the same way. export const DeviceToolListResult = Schema.Struct({ hostStatuses: DeviceServiceState.fields.hostStatuses, hosts: Schema.Array(DeviceHostSummary), devices: Schema.Array(DeviceSummary), /** Devices already open in this thread's Device panel. */ open: Schema.Array(Schema.Struct({ hostId: DeviceHostId, deviceId: DeviceId })), }); export type DeviceToolListResult = typeof DeviceToolListResult.Type; export const DeviceToolOpenInput = Schema.Struct({ deviceId: Schema.optional( DeviceId.annotate({ description: "Simulator udid or emulator serial from device_list. Omit to use the booted device for the platform, or the most recently used one.", }), ), platform: Schema.optional( DevicePlatform.annotate({ description: "Required when deviceId is omitted and both platforms are available.", }), ), hostId: Schema.optional( DeviceHostId.annotate({ description: "Device host from device_list. Defaults to local." }), ), }).annotate({ description: "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.", }); export type DeviceToolOpenInput = typeof DeviceToolOpenInput.Type; export const DeviceToolOpenResult = Schema.Struct({ device: DeviceSummary, /** Ready-to-run agent-device invocation pinned to this device. */ agentDevice: Schema.Struct({ command: Schema.String, /** Flags that pin every command to this device, e.g. `--udid `. */ targetArgs: Schema.Array(Schema.String), }), quickStart: Schema.String, }); export type DeviceToolOpenResult = typeof DeviceToolOpenResult.Type; export const DeviceToolTargetInput = Schema.Struct({ deviceId: Schema.optional( DeviceId.annotate({ description: "Device from device_list. Omit to use the device most recently opened in this thread.", }), ), hostId: Schema.optional(DeviceHostId), }); export type DeviceToolTargetInput = typeof DeviceToolTargetInput.Type; export const DeviceToolScreenshotResult = Schema.Struct({ device: DeviceSummary, screenshot: Schema.Struct({ mimeType: Schema.Literal("image/png"), data: Schema.String, width: Schema.Int, height: Schema.Int, }), }); export type DeviceToolScreenshotResult = typeof DeviceToolScreenshotResult.Type; export const DeviceToolCloseInput = Schema.Struct({ deviceId: Schema.optional( DeviceId.annotate({ description: "Device to close. Omit to close every device in this thread.", }), ), hostId: Schema.optional(DeviceHostId), shutdown: Schema.optional( Schema.Boolean.annotate({ description: "Also power the simulator or emulator off. Defaults to false.", }), ), }); export type DeviceToolCloseInput = typeof DeviceToolCloseInput.Type; export class DeviceToolUnavailableError extends Schema.TaggedError()( "DeviceToolUnavailableError", { reason: Schema.String, }, ) { override get message(): string { return this.reason; } } export const DeviceToolError = Schema.Union([ DeviceToolUnavailableError, DeviceHostUnavailableError, DevicePlatformUnavailableError, DeviceNotFoundError, DeviceBootError, DeviceOperationError, DeviceActionUnavailableError, ]); export type DeviceToolError = typeof DeviceToolError.Type;