Skip to content
Source
Sign inSign up

device.ts

736 lines25.2 KB

1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import * as SchemaTransformation from "effect/SchemaTransformation";
4​
5import { ThreadId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6import { SettingsSecretValue } from "./settingsSecretValue.ts";
7​
8export const DevicePlatform = Schema.Literals(["ios", "android"]);
9export type DevicePlatform = typeof DevicePlatform.Type;
10​
11export const DeviceHostId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
12export type DeviceHostId = typeof DeviceHostId.Type;
13​
14export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId;
15export const LOCAL_DEVICE_HOST_LABEL = "This machine";
16​
17const ConfiguredDeviceHostId = DeviceHostId.check(
18 Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/),
19 Schema.makeFilter((id) => id !== "local" || "The local host id is reserved."),
20);
21​
22export const SshDeviceHostConfig = Schema.Struct({
23 // Hosts saved before EAS hosts existed carry no kind.
24 kind: Schema.Literal("ssh").pipe(Schema.withDecodingDefaultKey(Effect.succeed("ssh" as const))),
25 id: ConfiguredDeviceHostId,
26 label: TrimmedNonEmptyString,
27 target: TrimmedNonEmptyString.check(Schema.isPattern(/^[^\s-][^\s]*$/)),
28 identityFile: Schema.optional(TrimmedNonEmptyString),
29 port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
30});
31export type SshDeviceHostConfig = typeof SshDeviceHostConfig.Type;
32​
33/**
34 * Cloud simulators from EAS Simulator. Sessions bill the Expo account until
35 * they stop, so every one is bounded. The Expo credential is not stored here.
36 */
37export const EasDeviceHostConfig = Schema.Struct({
38 kind: Schema.Literal("eas"),
39 id: ConfiguredDeviceHostId,
40 label: TrimmedNonEmptyString,
41 /** EAS project that sessions run under, when the project's app config does not name one. */
42 appId: Schema.optional(TrimmedNonEmptyString),
43 /**
44 * Expo access token, kept in the secret store, or the secret provider to read
45 * it from. When empty the server uses EXPO_TOKEN, then the `eas login` session.
46 * This is `secretSetting` from settings.ts, which imports this module.
47 */
48 accessToken: SettingsSecretValue.pipe(
49 Schema.withDecodingDefaultKey(Effect.succeed("")),
50 Schema.annotateKey({ settingsSecret: true }),
51 ),
52 /** EAS allows at most 40 minutes on free accounts and 115 on paid ones. */
53 maxSessionMinutes: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 115 })).pipe(
54 Schema.withDecodingDefaultKey(Effect.succeed(30)),
55 ),
56 idleMinutes: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 60 })).pipe(
57 Schema.withDecodingDefaultKey(Effect.succeed(10)),
58 ),
59 maxConcurrentSessions: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })).pipe(
60 Schema.withDecodingDefaultKey(Effect.succeed(2)),
61 ),
62}).check(
63 // EAS requires the idle limit to be smaller than the session limit.
64 Schema.makeFilter(
65 (host: { readonly idleMinutes: number; readonly maxSessionMinutes: number }) =>
66 host.idleMinutes < host.maxSessionMinutes ||
67 "Idle minutes must be smaller than session minutes.",
68 ),
69);
70export type EasDeviceHostConfig = typeof EasDeviceHostConfig.Type;
71​
72/**
73 * Alpha: devices leased from a Simlock gateway (https://incubator.callstack.com/simlock).
74 * T3 never installs Simlock; the gateway and its workers are the user's own.
75 */
76export const SimlockDeviceHostConfig = Schema.Struct({
77 kind: Schema.Literal("simlock"),
78 id: ConfiguredDeviceHostId,
79 label: TrimmedNonEmptyString,
80 /** The gateway's origin, such as https://gateway.example.ts.net:4700. */
81 gatewayUrl: TrimmedNonEmptyString.check(Schema.isPattern(/^https?:\/\/[^\s/]+\/?$/)),
82 /**
83 * Leases, drives, watches, and takes input on its own leases. Kept in the secret store, or the
84 * secret provider to read it from.
85 */
86 agentToken: SettingsSecretValue.pipe(
87 Schema.withDecodingDefaultKey(Effect.succeed("")),
88 Schema.annotateKey({ settingsSecret: true }),
89 ),
90 maxLeases: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })).pipe(
91 Schema.withDecodingDefaultKey(Effect.succeed(2)),
92 ),
93 /** A lease the Device panel keeps alive ends this long after it started. */
94 maxLeaseMinutes: Schema.Int.check(Schema.isBetween({ minimum: 5, maximum: 240 })).pipe(
95 Schema.withDecodingDefaultKey(Effect.succeed(60)),
96 ),
97});
98export type SimlockDeviceHostConfig = typeof SimlockDeviceHostConfig.Type;
99​
100export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig | SimlockDeviceHostConfig;
101​
102const uniqueHostIds = Schema.makeFilter(
103 (hosts: ReadonlyArray<{ readonly id: string }>) =>
104 new Set(hosts.map((host) => host.id)).size === hosts.length ||
105 "Device host ids must be unique.",
106);
107​
108export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(uniqueHostIds);
109​
110/**
111 * Kept apart from the SSH hosts in settings: servers and clients from before
112 * EAS hosts decode `deviceHosts` as SSH hosts and would reject the whole file
113 * or snapshot over one EAS entry.
114 */
115export const EasDeviceHostConfigs = Schema.Array(EasDeviceHostConfig).check(uniqueHostIds);
116​
117/** Kept apart from the other host lists for the same reason as EAS hosts. */
118export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).check(uniqueHostIds);
119​
120export const DeviceEasAccountInput = Schema.Struct({
121 /** A saved EAS host whose stored token is checked when no new token is given. */
122 hostId: Schema.optional(DeviceHostId),
123 accessToken: Schema.optional(SettingsSecretValue),
124});
125export type DeviceEasAccountInput = typeof DeviceEasAccountInput.Type;
126​
127export const DeviceEasAccount = Schema.Struct({
128 signedInAs: Schema.String,
129 credentialSource: Schema.Literals(["settings", "env", "eas-login"]),
130 apps: Schema.Array(Schema.Struct({ id: Schema.String, fullName: Schema.String })),
131});
132export type DeviceEasAccount = typeof DeviceEasAccount.Type;
133​
134export const DeviceSimlockIdentityInput = Schema.Struct({
135 /** Without one, the gateway this machine's agent-device points at. */
136 gatewayUrl: Schema.optional(TrimmedString),
137 /** A saved Simlock host whose stored token is checked when no new token is given. */
138 hostId: Schema.optional(DeviceHostId),
139 agentToken: Schema.optional(SettingsSecretValue),
140});
141export type DeviceSimlockIdentityInput = typeof DeviceSimlockIdentityInput.Type;
142​
143export const DeviceSimlockIdentity = Schema.Struct({
144 gatewayUrl: Schema.String,
145 /** The label the gateway gave the token. */
146 signedInAs: Schema.String,
147 /** `env`: this machine's AGENT_DEVICE_DAEMON_AUTH_TOKEN, used because it targets this gateway. */
148 tokenSource: Schema.Literals(["entered", "settings", "env"]),
149});
150export type DeviceSimlockIdentity = typeof DeviceSimlockIdentity.Type;
151​
152export class DeviceSimlockIdentityError extends Schema.TaggedError<DeviceSimlockIdentityError>()(
153 "DeviceSimlockIdentityError",
154 { reason: Schema.String },
155) {
156 override get message(): string {
157 return this.reason;
158 }
160​
161/** Checking an Expo credential failed; no device host is involved yet. */
162export class DeviceEasAccountError extends Schema.TaggedError<DeviceEasAccountError>()(
163 "DeviceEasAccountError",
164 // No cause: this error reaches the browser, and the reason already says what Expo answered.
165 { reason: Schema.String },
166) {
167 override get message(): string {
168 return this.reason;
169 }
171​
172export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256));
173export type DeviceId = typeof DeviceId.Type;
174​
175export const DeviceSummary = Schema.Struct({
176 hostId: DeviceHostId,
177 id: DeviceId,
178 platform: DevicePlatform,
179 name: TrimmedNonEmptyString,
180 version: Schema.String,
181 booted: Schema.Boolean,
182 physical: Schema.Boolean,
183});
184export type DeviceSummary = typeof DeviceSummary.Type;
185​
186export const DevicePlatformAvailability = Schema.Struct({
187 platform: DevicePlatform,
188 available: Schema.Boolean,
189 reason: Schema.optional(Schema.String),
190});
191export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type;
192​
193export const DeviceToolVersion = Schema.Struct({
194 requiredVersion: Schema.String,
195 installedVersions: Schema.Array(Schema.String),
196 runningVersion: Schema.NullOr(Schema.String),
197});
198export type DeviceToolVersion = typeof DeviceToolVersion.Type;
199​
200export const DeviceToolVersions = Schema.Struct({
201 hub: DeviceToolVersion,
202 agent: DeviceToolVersion,
203});
204export type DeviceToolVersions = typeof DeviceToolVersions.Type;
205​
206const deviceHostSummaryFields = {
207 id: DeviceHostId,
208 label: TrimmedNonEmptyString,
209 platforms: Schema.Array(DevicePlatformAvailability),
210 tools: Schema.optional(DeviceToolVersions),
211 toolInspectionError: Schema.optional(Schema.String),
212 hubInstalled: Schema.Boolean,
213 agentDeviceInstalled: Schema.Boolean,
214};
215const MachineDeviceHostSummary = Schema.Struct({
216 ...deviceHostSummaryFields,
217 kind: Schema.Literals(["local", "ssh"]),
218});
219const EasDeviceHostSummary = Schema.Struct({
220 ...deviceHostSummaryFields,
221 kind: Schema.Literal("eas"),
222});
223const SimlockDeviceHostSummary = Schema.Struct({
224 ...deviceHostSummaryFields,
225 kind: Schema.Literal("simlock"),
226});
227export const DeviceHostSummary = Schema.Union([
228 MachineDeviceHostSummary,
229 EasDeviceHostSummary,
230 SimlockDeviceHostSummary,
231]);
232export type DeviceHostSummary = typeof DeviceHostSummary.Type;
233​
234export const DeviceHostStatus = Schema.Literals([
235 "disabled",
236 "idle",
237 "installing",
238 "starting",
239 "ready",
240 "failed",
241]);
242export type DeviceHostStatus = typeof DeviceHostStatus.Type;
243​
244export const DeviceSession = Schema.Struct({
245 threadId: ThreadId,
246 hostId: DeviceHostId,
247 deviceId: DeviceId,
248 platform: DevicePlatform,
249 openedAt: Schema.String,
250});
251export type DeviceSession = typeof DeviceSession.Type;
252​
253export const DeviceHostStatuses = Schema.Record(
254 DeviceHostId,
255 Schema.Struct({
256 status: DeviceHostStatus,
257 detail: Schema.optional(Schema.String),
258 androidDiscoveryIncomplete: Schema.optional(Schema.Boolean),
259 incompletePlatforms: Schema.optional(Schema.Array(DevicePlatform)),
260 }),
261);
262​
263const deviceServiceStateFields = {
264 supportsHostRetry: Schema.optional(Schema.Boolean),
265 supportsToolUpdate: Schema.optional(Schema.Boolean),
266 supportsToolInspection: Schema.optional(Schema.Boolean),
267 /** Servers from before EAS hosts ignore `easDeviceHosts`, so clients offer them only when set. */
268 supportsEasHosts: Schema.optional(Schema.Boolean),
269 /** Servers from before Simlock hosts ignore `simlockDeviceHosts`. */
270 supportsSimlockHosts: Schema.optional(Schema.Boolean),
271 hostStatus: DeviceHostStatus,
272 hostStatusDetail: Schema.optional(Schema.String),
273 hostStatuses: DeviceHostStatuses,
274 devices: Schema.Array(DeviceSummary),
275 sessions: Schema.Array(DeviceSession),
276 bootingDevices: Schema.optional(
277 Schema.Array(Schema.Struct({ ...DeviceSummary.fields, threadId: ThreadId })),
278 ),
279 onboardingCompleted: Schema.Boolean,
280 agentAccessEnabled: Schema.Boolean,
281 hubBasePath: Schema.String,
282 revision: Schema.Int,
283};
284​
285/**
286 * EAS host summaries travel as `easHosts`: clients from before EAS hosts
287 * decode `hosts` with a `local | ssh` kind and would reject the whole state.
288 * Decoded, every host is in `hosts`.
289 */
290const DeviceServiceStateWire = Schema.Struct({
291 ...deviceServiceStateFields,
292 hosts: Schema.Array(MachineDeviceHostSummary),
293 easHosts: Schema.optional(Schema.Array(EasDeviceHostSummary)),
294 simlockHosts: Schema.optional(Schema.Array(SimlockDeviceHostSummary)),
295});
296const DeviceServiceStateDecoded = Schema.Struct({
297 ...deviceServiceStateFields,
298 hosts: Schema.Array(DeviceHostSummary),
299});
300​
301export const DeviceServiceState = DeviceServiceStateWire.pipe(
302 Schema.decodeTo(
303 Schema.toType(DeviceServiceStateDecoded),
304 SchemaTransformation.transform<
305 typeof DeviceServiceStateDecoded.Type,
306 typeof DeviceServiceStateWire.Type
307 >({
308 decode: ({ easHosts, simlockHosts, ...state }) => ({
309 ...state,
310 hosts: [...state.hosts, ...(easHosts ?? []), ...(simlockHosts ?? [])],
311 }),
312 encode: (state) => ({
313 ...state,
314 hosts: state.hosts.filter(
315 (host): host is typeof MachineDeviceHostSummary.Type =>
316 host.kind === "local" || host.kind === "ssh",
317 ),
318 easHosts: state.hosts.filter(
319 (host): host is typeof EasDeviceHostSummary.Type => host.kind === "eas",
320 ),
321 simlockHosts: state.hosts.filter(
322 (host): host is typeof SimlockDeviceHostSummary.Type => host.kind === "simlock",
323 ),
324 }),
325 }),
326 ),
327);
328export type DeviceServiceState = typeof DeviceServiceState.Type;
329​
330export const DeviceListInput = Schema.Struct({
331 updateTool: Schema.optional(Schema.Literals(["hub", "agent"])),
332 inspectOnly: Schema.optional(Schema.Boolean),
333 retryHostId: Schema.optional(DeviceHostId),
334});
335export type DeviceListInput = typeof DeviceListInput.Type;
336​
337export const DeviceConfigureInput = Schema.Struct({
338 enabled: Schema.optional(Schema.Boolean),
339 agentAccessEnabled: Schema.optional(Schema.Boolean),
340 onboardingCompleted: Schema.optional(Schema.Boolean),
341});
342export type DeviceConfigureInput = typeof DeviceConfigureInput.Type;
343​
344export const DeviceOpenInput = Schema.Struct({
345 threadId: ThreadId,
346 hostId: Schema.optional(DeviceHostId),
347 deviceId: DeviceId,
348 platform: DevicePlatform,
349 /** Boot the simulator or emulator when it is not running. Defaults to true. */
350 boot: Schema.optional(Schema.Boolean),
351});
352export type DeviceOpenInput = typeof DeviceOpenInput.Type;
353​
354export const DeviceCloseInput = Schema.Struct({
355 hostId: Schema.optional(DeviceHostId),
356 threadId: ThreadId,
357 /** Omit to close every device session for the thread. */
358 deviceId: Schema.optional(DeviceId),
359 /** Also shut the simulator or emulator down. Defaults to false. */
360 shutdown: Schema.optional(Schema.Boolean),
361});
362export type DeviceCloseInput = typeof DeviceCloseInput.Type;
363​
364export const DeviceShutdownInput = Schema.Struct({
365 hostId: Schema.optional(DeviceHostId),
366 deviceId: DeviceId,
367 platform: DevicePlatform,
368});
369export type DeviceShutdownInput = typeof DeviceShutdownInput.Type;
370​
371export const DeviceAppearance = Schema.Literals(["light", "dark"]);
372export type DeviceAppearance = typeof DeviceAppearance.Type;
373​
374export const DeviceTextSize = Schema.Literals(["small", "default", "large", "extra-large"]);
375export type DeviceTextSize = typeof DeviceTextSize.Type;
376​
377export const DeviceColorFilter = Schema.Literals([
378 "none",
379 "grayscale",
380 "red-green",
381 "green-red",
382 "blue-yellow",
383]);
384export type DeviceColorFilter = typeof DeviceColorFilter.Type;
385​
386export const DeviceOrientation = Schema.Literals([
387 "portrait",
388 "landscape_left",
389 "portrait_upside_down",
390 "landscape_right",
391]);
392export type DeviceOrientation = typeof DeviceOrientation.Type;
393​
394export const DeviceSettings = Schema.Struct({
395 appearance: Schema.optional(DeviceAppearance),
396 textSize: Schema.optional(DeviceTextSize),
397 reduceMotion: Schema.optional(Schema.Boolean),
398 increaseContrast: Schema.optional(Schema.Boolean),
399 reduceTransparency: Schema.optional(Schema.Boolean),
400 showBorders: Schema.optional(Schema.Boolean),
401 voiceOver: Schema.optional(Schema.Boolean),
402 liquidGlass: Schema.optional(Schema.Literals(["clear", "tinted"])),
403 colorFilter: Schema.optional(DeviceColorFilter),
404 networkEnabled: Schema.optional(Schema.Boolean),
405 location: Schema.optional(
406 Schema.NullOr(Schema.Struct({ latitude: Schema.Number, longitude: Schema.Number })),
407 ),
408});
409export type DeviceSettings = typeof DeviceSettings.Type;
410​
411export const DeviceForegroundApp = Schema.Struct({
412 id: Schema.String,
413 name: Schema.optional(Schema.String),
414 version: Schema.optional(Schema.String),
415});
416export type DeviceForegroundApp = typeof DeviceForegroundApp.Type;
417​
418export const DeviceDetail = Schema.Struct({
419 hostId: DeviceHostId,
420 deviceId: DeviceId,
421 settings: DeviceSettings,
422 foregroundApp: Schema.NullOr(DeviceForegroundApp),
423 readAt: Schema.String,
424});
425export type DeviceDetail = typeof DeviceDetail.Type;
426​
427export const DevicePermission = Schema.Literals([
428 "camera",
429 "microphone",
430 "photos",
431 "contacts",
432 "calendar",
433 "reminders",
434 "location",
435 "notifications",
436 "motion",
437 "media-library",
438 "faceid",
439]);
440export type DevicePermission = typeof DevicePermission.Type;
441​
442const DeviceTarget = {
443 hostId: Schema.optional(DeviceHostId),
444 deviceId: DeviceId,
445};
446​
447export const DeviceActionInput = Schema.Union([
448 Schema.Struct({
449 ...DeviceTarget,
450 type: Schema.Literal("setAppearance"),
451 value: DeviceAppearance,
452 }),
453 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setTextSize"), value: DeviceTextSize }),
454 Schema.Struct({
455 ...DeviceTarget,
456 type: Schema.Literal("setToggle"),
457 setting: Schema.Literals([
458 "reduceMotion",
459 "increaseContrast",
460 "reduceTransparency",
461 "showBorders",
462 "voiceOver",
463 "networkEnabled",
464 ]),
465 value: Schema.Boolean,
466 }),
467 Schema.Struct({
468 ...DeviceTarget,
469 type: Schema.Literal("setLiquidGlass"),
470 value: Schema.Literals(["clear", "tinted"]),
471 }),
472 Schema.Struct({
473 ...DeviceTarget,
474 type: Schema.Literal("setColorFilter"),
475 value: DeviceColorFilter,
476 }),
477 Schema.Struct({
478 ...DeviceTarget,
479 type: Schema.Literal("setOrientation"),
480 value: DeviceOrientation,
481 }),
482 Schema.Struct({
483 ...DeviceTarget,
484 type: Schema.Literal("setLocation"),
485 latitude: Schema.Number.check(Schema.isBetween({ minimum: -90, maximum: 90 })),
486 longitude: Schema.Number.check(Schema.isBetween({ minimum: -180, maximum: 180 })),
487 }),
488 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("clearLocation") }),
489 Schema.Struct({
490 ...DeviceTarget,
491 type: Schema.Literal("setPermission"),
492 appId: TrimmedNonEmptyString,
493 permission: DevicePermission,
494 decision: Schema.Literals(["grant", "revoke", "reset"]),
495 }),
496 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("openUrl"), url: TrimmedNonEmptyString }),
497 Schema.Struct({
498 ...DeviceTarget,
499 type: Schema.Literal("launchApp"),
500 appId: TrimmedNonEmptyString,
501 }),
502 Schema.Struct({
503 ...DeviceTarget,
504 type: Schema.Literal("terminateApp"),
505 appId: TrimmedNonEmptyString,
506 }),
507 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("shake") }),
508 Schema.Struct({
509 ...DeviceTarget,
510 type: Schema.Literal("sendPush"),
511 appId: TrimmedNonEmptyString,
512 payload: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Unknown)]),
513 }),
514]);
515export type DeviceActionInput = typeof DeviceActionInput.Type;
516export type DeviceActionType = DeviceActionInput["type"];
517​
518export const DeviceDetailInput = Schema.Struct(DeviceTarget);
519export type DeviceDetailInput = typeof DeviceDetailInput.Type;
520​
521export class DeviceHostUnavailableError extends Schema.TaggedError<DeviceHostUnavailableError>()(
522 "DeviceHostUnavailableError",
523 {
524 hostId: DeviceHostId,
525 /** The host's name in Settings, which the message shows instead of its id. */
526 hostLabel: Schema.optional(Schema.String),
527 reason: Schema.String,
528 cause: Schema.optional(Schema.Defect()),
529 },
530) {
531 override get message(): string {
532 return `${this.hostLabel ?? `Device host ${this.hostId}`} is unavailable: ${this.reason}`;
533 }
535​
536export class DevicePlatformUnavailableError extends Schema.TaggedError<DevicePlatformUnavailableError>()(
537 "DevicePlatformUnavailableError",
538 {
539 hostId: DeviceHostId,
540 platform: DevicePlatform,
541 reason: Schema.String,
542 },
543) {
544 override get message(): string {
545 return `${this.platform} devices are unavailable on host ${this.hostId}: ${this.reason}`;
546 }
548​
549export class DeviceNotFoundError extends Schema.TaggedError<DeviceNotFoundError>()(
550 "DeviceNotFoundError",
551 {
552 hostId: DeviceHostId,
553 deviceId: DeviceId,
554 },
555) {
556 override get message(): string {
557 return `Device ${this.deviceId} was not found on host ${this.hostId}.`;
558 }
560​
561export class DeviceBootError extends Schema.TaggedError<DeviceBootError>()("DeviceBootError", {
562 hostId: DeviceHostId,
563 deviceId: DeviceId,
564 reason: Schema.Literals(["disk_space", "timeout", "launch_failed"]),
565 diagnostics: Schema.optional(Schema.String.check(Schema.isMaxLength(2_000))),
566 cause: Schema.Defect(),
567}) {
568 override get message(): string {
569 const explanation = {
570 disk_space: "There is not enough free disk space on its device host.",
571 timeout: "The device did not become ready in time.",
572 launch_failed: "The simulator or emulator could not start.",
573 }[this.reason];
574 const summary = `Device ${this.deviceId} failed to boot: ${explanation}`;
575 return this.diagnostics
576 ? `${summary}\n\n${this.diagnostics}`
577 : `${summary} The device host sent no details. Try again or open another device; if it keeps failing, check the device on that host.`;
578 }
580​
581export class DeviceOperationError extends Schema.TaggedError<DeviceOperationError>()(
582 "DeviceOperationError",
583 {
584 operation: Schema.String,
585 reason: Schema.Literals([
586 "command_failed",
587 "request_failed",
588 "invalid_payload",
589 "settings_failed",
590 "hub_rejected",
591 ]),
592 exitCode: Schema.optional(Schema.Number),
593 cause: Schema.Defect(),
594 },
595) {
596 override get message(): string {
597 const explanation = {
598 command_failed: `The device command failed${this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`}.`,
599 request_failed: "Could not communicate with device support. Try refreshing devices.",
600 invalid_payload: "The device request could not be encoded.",
601 settings_failed: "Could not read or save device settings.",
602 hub_rejected: "The device hub could not complete the request.",
603 }[this.reason];
604 return `Device ${this.operation} failed: ${explanation}`;
605 }
607​
608export class DeviceActionUnavailableError extends Schema.TaggedError<DeviceActionUnavailableError>()(
609 "DeviceActionUnavailableError",
610 {
611 operation: Schema.String,
612 platform: DevicePlatform,
613 reason: Schema.Literals(["unsupported", "helper_missing"]),
614 },
615) {
616 override get message(): string {
617 return this.reason === "helper_missing"
618 ? `Device ${this.operation} requires a helper missing from this install. Set up device support again.`
619 : `Device ${this.operation} is not supported on ${this.platform}.`;
620 }
622​
623export const DeviceError = Schema.Union([
624 DeviceHostUnavailableError,
625 DevicePlatformUnavailableError,
626 DeviceNotFoundError,
627 DeviceBootError,
628 DeviceOperationError,
629 DeviceActionUnavailableError,
630]);
631export type DeviceError = typeof DeviceError.Type;
632​
633export const DeviceToolListResult = Schema.Struct({
634 hostStatuses: DeviceHostStatuses,
635 hosts: Schema.Array(DeviceHostSummary),
636 devices: Schema.Array(DeviceSummary),
637 open: Schema.Array(Schema.Struct({ hostId: DeviceHostId, deviceId: DeviceId })),
638});
639export type DeviceToolListResult = typeof DeviceToolListResult.Type;
640​
641export const DeviceToolOpenInput = Schema.Struct({
642 deviceId: Schema.optional(
643 DeviceId.annotate({
644 description: "Device id from `device_list`. Default: the booted or last used device.",
645 }),
646 ),
647 platform: Schema.optional(
648 DevicePlatform.annotate({
649 description: "Needed with no `deviceId` when both platforms exist.",
650 }),
651 ),
652 hostId: Schema.optional(
653 DeviceHostId.annotate({ description: "Host from `device_list`. Default local." }),
654 ),
655});
656export type DeviceToolOpenInput = typeof DeviceToolOpenInput.Type;
657​
658export const DeviceToolOpenResult = Schema.Struct({
659 device: DeviceSummary,
660 agentDevice: Schema.Struct({
661 command: Schema.String,
662 targetArgs: Schema.Array(Schema.String),
663 session: Schema.String,
664 }),
665 quickStart: Schema.String,
666});
667export type DeviceToolOpenResult = typeof DeviceToolOpenResult.Type;
668​
669export const DeviceToolTargetInput = Schema.Struct({
670 deviceId: Schema.optional(
671 DeviceId.annotate({
672 description: "Device. Default: the last one opened here.",
673 }),
674 ),
675 hostId: Schema.optional(DeviceHostId),
676});
677export type DeviceToolTargetInput = typeof DeviceToolTargetInput.Type;
678​
679export const DeviceToolScreenshotResult = Schema.Struct({
680 device: DeviceSummary,
681 screenshot: Schema.Struct({
682 mimeType: Schema.Literal("image/png"),
683 data: Schema.String,
684 width: Schema.Int,
685 height: Schema.Int,
686 }),
687});
688export type DeviceToolScreenshotResult = typeof DeviceToolScreenshotResult.Type;
689​
690export const DeviceToolCloseInput = Schema.Struct({
691 deviceId: Schema.optional(
692 DeviceId.annotate({
693 description: "Device to close. None: close all here.",
694 }),
695 ),
696 hostId: Schema.optional(DeviceHostId),
697 shutdown: Schema.optional(
698 Schema.Boolean.annotate({
699 description: "Also turn it off. Default false.",
700 }),
701 ),
702});
703export type DeviceToolCloseInput = typeof DeviceToolCloseInput.Type;
704​
705export class DeviceToolUnavailableError extends Schema.TaggedError<DeviceToolUnavailableError>()(
706 "DeviceToolUnavailableError",
707 {
708 reason: Schema.String,
709 cause: Schema.optional(Schema.Defect()),
710 },
711) {
712 override get message(): string {
713 return this.reason;
714 }
716​
717export const DeviceToolError = Schema.Union([
718 DeviceToolUnavailableError,
719 DeviceHostUnavailableError,
720 DevicePlatformUnavailableError,
721 DeviceNotFoundError,
722 DeviceBootError,
723 DeviceOperationError,
724 DeviceActionUnavailableError,
725]);
726export type DeviceToolError = typeof DeviceToolError.Type;
727​
728export function deviceToolInstallMessage(name: string, tool: DeviceToolVersion | undefined) {
729 if (!tool) return `Installing ${name}…`;
730 const previous =
731 tool.runningVersion ??
732 [...tool.installedVersions].sort((a, b) => a.localeCompare(b, "en", { numeric: true })).at(-1);
733 return previous && !tool.installedVersions.includes(tool.requiredVersion)
734 ? `Updating ${name} from ${previous} to ${tool.requiredVersion}…`
735 : `Installing ${name} ${tool.requiredVersion}…`;