Skip to content
Source
Sign inSign up

SimlockDeviceHost.ts

712 lines26.0 KB

1/**
2 * Alpha: devices leased from a Simlock gateway the user runs. Opening a device
3 * takes a lease through the gateway's agent-device front, so agents drive it
4 * with the same session; closing it returns the device to the pool, wiped.
5 * A lease left by a server that crashed ends one TTL after its last renewal.
6 */
7import {
8 DeviceBootError,
9 type DeviceHostSummary,
10 DeviceHostUnavailableError,
11 DeviceOperationError,
12 type DevicePlatform,
13 type DevicePlatformAvailability,
14 type SettingsSecretValue,
15 type SimlockDeviceHostConfig,
16} from "@t3tools/contracts";
17import * as Cause from "effect/Cause";
18import * as Clock from "effect/Clock";
19import * as Duration from "effect/Duration";
20import * as Effect from "effect/Effect";
21import * as FiberMap from "effect/FiberMap";
22import * as FileSystem from "effect/FileSystem";
23import * as Option from "effect/Option";
24import * as Path from "effect/Path";
25import * as Random from "effect/Random";
26import * as Schema from "effect/Schema";
27import * as Semaphore from "effect/Semaphore";
28import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
29import * as HostProcess from "@t3tools/shared/HostProcess";
30import { truncate } from "@t3tools/shared/String";
31import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
32​
33import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
34import * as ServerConfig from "../config.ts";
35import * as ProcessRunner from "../processRunner.ts";
36import { requireSettingsSecret } from "../secrets/settingsSecretValue.ts";
37import { writeAgentDeviceConfig } from "./AgentDeviceTarget.ts";
38import * as DeviceHost from "./DeviceHost.ts";
39​
40const CATALOG_TTL_MS = 60_000;
41const REQUEST_TIMEOUT = Duration.seconds(15);
42// The gateway queues a lease request for up to five minutes when every device is busy.
43const OPEN_TIMEOUT = Duration.minutes(7);
44const COMMAND_TIMEOUT = Duration.minutes(2);
45// Renewal keeps a watched but idle device; a lease this server stops renewing ends after LEASE_TTL.
46const RENEW_INTERVAL = Duration.minutes(2);
47const LEASE_TTL_MS = 10 * 60_000;
48const STREAM_PATH = /^\/vendor\/serve-sim\/helper\/([^/]+)\/stream\.avcc$/;
49const INPUT_PATH = "/vendor/serve-sim/helper/ws";
50​
51const Catalog = Schema.Struct({
52 platforms: Schema.Array(
53 Schema.Struct({
54 platform: Schema.String,
55 modelRuntimes: Schema.Record(Schema.String, Schema.Array(Schema.String)),
56 modelClasses: Schema.optional(Schema.Record(Schema.String, Schema.String)),
57 modelWorkers: Schema.optional(Schema.Record(Schema.String, Schema.Array(Schema.String))),
58 }),
59 ),
60});
61const decodeCatalog = Schema.decodeUnknownEffect(Catalog);
62const decodeSessionRecord = Schema.decodeUnknownEffect(
63 Schema.fromJsonString(Schema.Struct({ leaseId: Schema.String })),
64);
65const CliFailure = Schema.Struct({
66 error: Schema.Struct({
67 message: Schema.String,
68 hint: Schema.optional(Schema.String),
69 logPath: Schema.optional(Schema.String),
70 // An open bag; `line` holds the daemon's raw response when agent-device could not parse it.
71 details: Schema.optional(Schema.Unknown),
72 }),
73});
74const responseLine = (details: unknown) =>
75 typeof details === "object" &&
76 details !== null &&
77 "line" in details &&
78 typeof details.line === "string"
79 ? details.line
80 : undefined;
81const decodeCliFailure = Schema.decodeUnknownOption(Schema.fromJsonString(CliFailure));
82const describeCliFailure = ({ error }: typeof CliFailure.Type) => {
83 const line = responseLine(error.details);
84 return [
85 error.message,
86 error.hint,
87 line ? `The daemon answered: ${truncate(line, 300)}` : undefined,
88 error.logPath ? `Log: ${error.logPath}` : undefined,
89 ]
90 .filter((part) => part !== undefined)
91 .join(" ");
92};
93​
94interface Lease {
95 readonly leaseId: string;
96 readonly platform: DevicePlatform;
97 readonly name: string;
98 readonly version: string;
99 readonly session: string;
100 readonly dir: string;
101 readonly configPath: string;
102 readonly installDirectory: string;
103 /** The token the lease was opened with; ending it must not depend on reading the secret again. */
104 readonly token: string;
106​
107const catalogId = (platform: DevicePlatform, model: string) => `simlock:${platform}:${model}`;
108const parseCatalogId = (id: string) => {
109 const match = /^simlock:(ios|android):(.+)$/.exec(id);
110 return match ? { platform: match[1] as DevicePlatform, model: match[2]! } : null;
111};
112const platformVersion = (platform: DevicePlatform, runtime: string | undefined) =>
113 `${platform === "ios" ? "iOS" : "Android"}${runtime ? ` ${runtime}` : ""}`;
114​
115const AGENT_DEVICE_PATH = "/v1/agent-device";
116const sameOrigin = (left: string, right: string) => {
117 try {
118 return new URL(left).origin === new URL(right).origin;
119 } catch {
120 return false;
121 }
122};
123​
124/**
125 * The gateway and token this machine's agent-device uses, from agent-device's own remote-daemon
126 * variables, which Simlock tells its clients to set. Null unless they point at a Simlock gateway.
127 */
128export const machineSimlockToken = (environment: NodeJS.ProcessEnv) => {
129 const base = environment.AGENT_DEVICE_DAEMON_BASE_URL?.trim().replace(/\/+$/, "");
130 const agentToken = environment.AGENT_DEVICE_DAEMON_AUTH_TOKEN?.trim();
131 if (!base?.endsWith(AGENT_DEVICE_PATH) || !agentToken) return null;
132 const gatewayUrl = base.slice(0, -AGENT_DEVICE_PATH.length);
133 return URL.canParse(gatewayUrl) ? { gatewayUrl, agentToken } : null;
134};
135​
136/**
137 * The saved token, else this machine's agent-device token; each only for the gateway it belongs to,
138 * so a URL being edited never receives either. A saved secret reference that cannot be read fails
139 * instead of falling back to this machine's token.
140 */
141export const resolveSimlockToken = Effect.fn("SimlockDeviceHost.resolveToken")(function* (
142 secrets: SecretResolver.SecretResolver["Service"],
143 gatewayUrl: string,
144 saved: { readonly gatewayUrl: string; readonly agentToken: SettingsSecretValue } | undefined,
145 environment: NodeJS.ProcessEnv,
146) {
147 if (saved?.agentToken && sameOrigin(saved.gatewayUrl, gatewayUrl))
148 return {
149 agentToken: yield* requireSettingsSecret(secrets, saved.agentToken),
150 source: "settings" as const,
151 };
152 const machine = machineSimlockToken(environment);
153 return machine && sameOrigin(machine.gatewayUrl, gatewayUrl)
154 ? { agentToken: machine.agentToken, source: "env" as const }
155 : null;
156});
157​
158/** What went wrong talking to a gateway, without the gateway's own text. */
159export const describeGatewayFailure = (cause: unknown, gatewayUrl: string, tokenOwner: string) => {
160 if (Cause.isTimeoutError(cause)) return `Could not reach the Simlock gateway at ${gatewayUrl}.`;
161 if (!HttpClientError.isHttpClientError(cause))
162 return `The Simlock gateway at ${gatewayUrl} sent an answer T3 could not read.`;
163 if (cause.reason._tag === "StatusCodeError") {
164 const status = cause.reason.response.status;
165 return status === 401 || status === 403
166 ? `The Simlock gateway refused the agent token for ${tokenOwner}.`
167 : `The Simlock gateway at ${gatewayUrl} answered HTTP ${status}.`;
168 }
169 return cause.reason._tag === "TransportError"
170 ? `Could not reach the Simlock gateway at ${gatewayUrl}.`
171 : `The Simlock gateway at ${gatewayUrl} sent an answer T3 could not read.`;
172};
173​
174const decodeIdentity = Schema.decodeUnknownEffect(
175 Schema.Struct({ name: Schema.String, role: Schema.String }),
176);
177​
178/** Who the gateway says a token is, via GET /v1/identity. */
179export const simlockIdentity = Effect.fn("SimlockDeviceHost.identity")(function* (
180 gatewayUrl: string,
181 agentToken: string,
182) {
183 const http = yield* HttpClient.HttpClient;
184 return yield* http
185 .execute(
186 HttpClientRequest.get(`${gatewayUrl.replace(/\/$/, "")}/v1/identity`).pipe(
187 HttpClientRequest.bearerToken(agentToken),
188 ),
189 )
190 .pipe(
191 Effect.flatMap(HttpClientResponse.filterStatusOk),
192 Effect.flatMap((response) => response.json),
193 Effect.flatMap(decodeIdentity),
194 Effect.scoped,
195 Effect.timeout(REQUEST_TIMEOUT),
196 );
197});
198​
199/** T3's own agent-device CLI, installed on first use. */
200export type AgentDeviceCli = Effect.Effect<
201 { readonly nodePath: string; readonly entryPath: string },
202 DeviceHost.DeviceHostError | NodeRuntimeUnavailableError
203>;
204​
205export const make = Effect.fn("SimlockDeviceHost.make")(function* (
206 config: SimlockDeviceHostConfig,
207 agentDeviceCli: AgentDeviceCli,
208) {
209 const http = yield* HttpClient.HttpClient;
210 const fs = yield* FileSystem.FileSystem;
211 const path = yield* Path.Path;
212 const runner = yield* ProcessRunner.ProcessRunner;
213 const server = yield* ServerConfig.ServerConfig;
214 const services = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
215 const environment = yield* HostProcess.Environment;
216 const secrets = yield* SecretResolver.SecretResolver;
217 const hostId = config.id;
218 const gateway = config.gatewayUrl.replace(/\/$/, "");
219 const agentDeviceUrl = `${gateway}/v1/agent-device`;
220 const hostDir = path.join(server.stateDir, "device", "simlock", hostId);
221 const leases = new Map<string, Lease>();
222 let starting = 0;
223 // Bumped by every stop, so an open that finishes after one returns its lease instead of keeping it.
224 let stops = 0;
225 const slotLock = yield* Semaphore.make(1);
226 const keepAlive = yield* FiberMap.make<string>();
227 let catalog: {
228 readonly at: number;
229 readonly devices: ReadonlyArray<DeviceHost.HostDevice>;
230 } | null = null;
231 // The last answer from the gateway; empty until it gives one.
232 let platforms: ReadonlyArray<DevicePlatformAvailability> = [];
233 let ready: DeviceHost.DeviceHostReady | null = null;
234 let cli: { readonly nodePath: string; readonly entryPath: string } | null = null;
235​
236 const unavailable = (reason: string, cause?: unknown) =>
237 new DeviceHostUnavailableError({
238 hostId,
239 hostLabel: config.label,
240 reason,
241 ...(cause === undefined ? {} : { cause }),
242 });
243​
244 // Read on each use, so a rotated or unlocked secret applies without rebuilding the host.
245 const currentToken = resolveSimlockToken(secrets, config.gatewayUrl, config, environment).pipe(
246 Effect.mapError((error) => unavailable(error.message, error)),
247 Effect.flatMap((token) =>
248 token
249 ? Effect.succeed(token.agentToken)
250 : Effect.fail(unavailable(`Add an agent token for ${config.label} in Settings.`)),
251 ),
252 );
253​
254 const request = (
255 agentToken: string,
256 method: "GET" | "POST" | "DELETE",
257 route: string,
258 body?: Readonly<Record<string, unknown>>,
259 ) =>
260 http
261 .execute(
262 (method === "GET"
263 ? HttpClientRequest.get(`${gateway}${route}`)
264 : method === "POST"
265 ? HttpClientRequest.post(`${gateway}${route}`).pipe(
266 HttpClientRequest.bodyJsonUnsafe(body ?? {}),
267 )
268 : HttpClientRequest.delete(`${gateway}${route}`)
269 ).pipe(HttpClientRequest.bearerToken(agentToken)),
270 )
271 .pipe(
272 Effect.flatMap(HttpClientResponse.filterStatusOk),
273 Effect.flatMap((response) => response.json),
274 Effect.scoped,
275 Effect.timeout(REQUEST_TIMEOUT),
276 );
277​
278 const agentEnvironment: NodeJS.ProcessEnv = Object.fromEntries(
279 Object.entries({
280 ...environment,
281 ELECTRON_RUN_AS_NODE: "1",
282 AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1",
283 NO_COLOR: "1",
284 FORCE_COLOR: "0",
285 }).filter(
286 ([name]) =>
287 !/^AGENT_DEVICE_(DAEMON_BASE_URL|DAEMON_AUTH_TOKEN|CONFIG|STATE_DIR|SESSION)$/.test(name),
288 ),
289 );
290​
291 /** Runs T3's own agent-device CLI against the lease's session; its failure message is the reason. */
292 const agentDevice = Effect.fn("SimlockDeviceHost.agentDevice")(function* (
293 lease: Pick<Lease, "session" | "configPath">,
294 args: ReadonlyArray<string>,
295 options: { readonly cwd?: string; readonly timeout?: Duration.Input } = {},
296 ) {
297 if (!cli) return yield* unavailable(`${config.label} is not ready.`);
298 const result = yield* runner
299 .run({
300 command: cli.nodePath,
301 args: [
302 cli.entryPath,
303 ...args,
304 "--session",
305 lease.session,
306 "--config",
307 lease.configPath,
308 "--json",
309 ],
310 env: agentEnvironment,
311 ...(options.cwd ? { cwd: options.cwd } : {}),
312 timeout: options.timeout ?? COMMAND_TIMEOUT,
313 })
314 .pipe(Effect.mapError((cause) => unavailable("agent-device could not run.", cause)));
315 if (result.code !== 0) {
316 const failure = decodeCliFailure(result.stdout);
317 return yield* unavailable(
318 Option.match(failure, {
319 onNone: () => `agent-device ${args[0]} failed (exit code ${result.code ?? "none"}).`,
320 onSome: describeCliFailure,
321 }),
322 );
323 }
324 return result.stdout;
325 });
326​
327 const fetchCatalog = Effect.gen(function* () {
328 const now = yield* Clock.currentTimeMillis;
329 if (catalog && now - catalog.at < CATALOG_TTL_MS) return catalog.devices;
330 const agentToken = yield* currentToken;
331 const decoded = yield* request(agentToken, "GET", "/v1/catalog").pipe(
332 Effect.flatMap(decodeCatalog),
333 Effect.mapError((cause) =>
334 unavailable(describeGatewayFailure(cause, gateway, config.label), cause),
335 ),
336 );
337 const byPlatform = decoded.platforms.map((entry) => {
338 if (entry.platform !== "ios" && entry.platform !== "android") return null;
339 const platform: DevicePlatform = entry.platform;
340 return Object.entries(entry.modelRuntimes).flatMap(([model, runtimes]) => {
341 const kind = entry.modelClasses?.[model];
342 const workers = entry.modelWorkers?.[model];
343 if (runtimes.length === 0 || (workers !== undefined && workers.length === 0)) return [];
344 if (kind !== undefined && kind !== "phone" && kind !== "tablet") return [];
345 return [
346 {
347 id: catalogId(platform, model),
348 platform,
349 name: model,
350 version: platformVersion(platform, runtimes.at(-1)),
351 booted: false,
352 physical: false,
353 } satisfies DeviceHost.HostDevice,
354 ];
355 });
356 });
357 const devices = byPlatform.flatMap((entry) => entry ?? []);
358 // A platform with nothing to offer is usually its workers reconnecting; caching that would
359 // hide it for the TTL, so only a catalog with devices on every platform is kept.
360 catalog =
361 devices.length > 0 && byPlatform.every((entry) => entry === null || entry.length > 0)
362 ? { at: now, devices }
363 : null;
364 return devices;
365 });
366​
367 const loadCatalog = fetchCatalog.pipe(
368 Effect.tap((devices) =>
369 Effect.sync(() => {
370 platforms = (["ios", "android"] as const).map((platform) =>
371 devices.some((device) => device.platform === platform)
372 ? { platform, available: true }
373 : {
374 platform,
375 available: false,
376 reason: `The pool has no ${platform === "ios" ? "iOS" : "Android"} devices.`,
377 },
378 );
379 }),
380 ),
381 Effect.tapError((error) =>
382 Effect.sync(() => {
383 platforms = (["ios", "android"] as const).map((platform) => ({
384 platform,
385 available: false,
386 reason: error.reason,
387 }));
388 }),
389 ),
390 );
391​
392 const forget = (lease: Lease) =>
393 Effect.suspend(() => {
394 leases.delete(lease.leaseId);
395 return fs.remove(lease.dir, { recursive: true, force: true }).pipe(Effect.ignore);
396 });
397​
398 /** Ends the lease through its session, or over HTTP when the session is gone. */
399 const release = Effect.fn("SimlockDeviceHost.release")(function* (lease: Lease) {
400 yield* agentDevice(lease, ["close"]).pipe(
401 Effect.catch(() =>
402 request(lease.token, "DELETE", `/v1/leases/${encodeURIComponent(lease.leaseId)}`),
403 ),
404 Effect.tapError((cause) =>
405 Effect.logWarning("Could not release Simlock lease", { leaseId: lease.leaseId, cause }),
406 ),
407 Effect.ignore,
408 );
409 yield* forget(lease);
410 });
411​
412 /** Renews until the lease ends on its own or reaches its maximum age, then releases it. */
413 const renewLoop = Effect.fn("SimlockDeviceHost.renewLoop")(function* (lease: Lease) {
414 const deadline =
415 (yield* Clock.currentTimeMillis) +
416 Duration.toMillis(Duration.minutes(config.maxLeaseMinutes));
417 while (true) {
418 const left = deadline - (yield* Clock.currentTimeMillis);
419 yield* Effect.sleep(Duration.millis(Math.min(left, Duration.toMillis(RENEW_INTERVAL))));
420 if ((yield* Clock.currentTimeMillis) >= deadline) break;
421 const renewed = yield* request(
422 lease.token,
423 "POST",
424 `/v1/leases/${encodeURIComponent(lease.leaseId)}/renew`,
425 { ttlMs: LEASE_TTL_MS },
426 ).pipe(Effect.result);
427 if (renewed._tag === "Success") continue;
428 const error = renewed.failure;
429 // Simlock ended it (lease_lost, an agent's close, or its TTL): stop listing it.
430 if (
431 error._tag === "HttpClientError" &&
432 error.reason._tag === "StatusCodeError" &&
433 error.reason.response.status === 404
434 ) {
435 return yield* forget(lease);
436 }
437 yield* Effect.logWarning("Could not renew Simlock lease", { leaseId: lease.leaseId, error });
438 }
439 yield* Effect.logInfo("Releasing Simlock lease at its maximum age", {
440 leaseId: lease.leaseId,
441 });
442 yield* release(lease);
443 });
444​
445 const reserveSlot = slotLock.withPermit(
446 Effect.suspend(() => {
447 if (leases.size + starting >= config.maxLeases) {
448 return Effect.fail(
449 unavailable(
450 `${config.label} is limited to ${config.maxLeases} ${config.maxLeases === 1 ? "device" : "devices"} at once. Close one in the Device panel first.`,
451 ),
452 );
453 }
454 starting++;
455 return Effect.void;
456 }),
457 );
458​
459 const openLease = Effect.fn("SimlockDeviceHost.openLease")(function* (
460 device: DeviceHost.HostDevice,
461 context: DeviceHost.DeviceOpenContext | undefined,
462 ) {
463 const target = parseCatalogId(device.id);
464 if (!target) {
465 const reason = "This Simlock device is no longer leased. Open it again from the list.";
466 return yield* new DeviceBootError({
467 hostId,
468 deviceId: device.id,
469 reason: "launch_failed",
470 diagnostics: reason,
471 cause: new Error(reason),
472 });
473 }
474 const suffix = (yield* Random.nextIntBetween(0, 0xffffff)).toString(16).padStart(6, "0");
475 const session = `t3-${suffix}`;
476 const dir = path.join(hostDir, suffix);
477 const configPath = path.join(dir, "agent-device.json");
478 const agentToken = yield* currentToken;
479 yield* writeAgentDeviceConfig(configPath, {
480 baseUrl: agentDeviceUrl,
481 token: agentToken,
482 stateDir: dir,
483 }).pipe(
484 Effect.provide(services),
485 Effect.mapError((cause) => unavailable("Could not write the agent-device config.", cause)),
486 );
487 // agent-device ties installs to the directory that opened the session.
488 const installDirectory = context?.workspaceRoot
489 ? yield* fs.realPath(context.workspaceRoot).pipe(Effect.orElseSucceed(() => dir))
490 : dir;
491 const pending = { session, configPath };
492 const stopsAtOpen = stops;
493 return yield* Effect.gen(function* () {
494 yield* agentDevice(pending, ["connect", "proxy", "--daemon-base-url", agentDeviceUrl]);
495 yield* agentDevice(
496 pending,
497 ["open", "--platform", target.platform, "--device", target.model],
498 { cwd: installDirectory, timeout: OPEN_TIMEOUT },
499 );
500 const record = yield* fs
501 .readFileString(path.join(dir, "remote-connections", `${session}.json`))
502 .pipe(
503 Effect.flatMap(decodeSessionRecord),
504 Effect.mapError((cause) => unavailable("agent-device recorded no Simlock lease.", cause)),
505 );
506 const opened: Lease = {
507 leaseId: record.leaseId,
508 platform: target.platform,
509 name: target.model,
510 version: device.version,
511 session,
512 dir,
513 configPath,
514 installDirectory,
515 token: agentToken,
516 };
517 if (stops !== stopsAtOpen)
518 return yield* unavailable("Device support stopped while the device was opening.");
519 leases.set(opened.leaseId, opened);
520 yield* FiberMap.run(keepAlive, opened.leaseId, renewLoop(opened));
521 return opened.leaseId;
522 }).pipe(
523 // A failed or interrupted open may still hold a lease: close the session either way.
524 Effect.onExit((exit) =>
525 exit._tag === "Success"
526 ? Effect.void
527 : agentDevice(pending, ["close"]).pipe(
528 Effect.ignore,
529 Effect.andThen(fs.remove(dir, { recursive: true, force: true }).pipe(Effect.ignore)),
530 ),
531 ),
532 Effect.mapError((error) =>
533 error._tag === "DeviceHostUnavailableError"
534 ? new DeviceBootError({
535 hostId,
536 deviceId: device.id,
537 reason: "launch_failed",
538 diagnostics: truncate(error.reason, 1_997),
539 cause: new Error(error.reason),
540 })
541 : error,
542 ),
543 );
544 });
545​
546 const leaseFor = (deviceId: string) => Option.fromNullishOr(leases.get(deviceId));
547 const operationError = (operation: string) => (cause: unknown) =>
548 new DeviceOperationError({ operation, reason: "request_failed", cause });
549​
550 const screenshotLease = Effect.fn("SimlockDeviceHost.screenshot")(
551 function* (lease: Lease) {
552 const file = path.join(
553 lease.dir,
554 `screenshot-${(yield* Random.nextIntBetween(0, 0xffffff)).toString(16)}.png`,
555 );
556 yield* agentDevice(lease, ["screenshot", file]);
557 return yield* fs
558 .readFile(file)
559 .pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)));
560 },
561 Effect.mapError(operationError("screenshot")),
562 );
563​
564 const devices: DeviceHost.DeviceOperations = {
565 list: loadCatalog.pipe(
566 Effect.mapError(operationError("list")),
567 Effect.map((available) => ({
568 devices: [
569 ...available,
570 ...[...leases.values()].map((lease): DeviceHost.HostDevice => ({
571 id: lease.leaseId,
572 platform: lease.platform,
573 name: lease.name,
574 version: lease.version,
575 booted: true,
576 physical: false,
577 })),
578 ],
579 detail: undefined,
580 })),
581 ),
582 boot: (device, context) =>
583 leases.has(device.id)
584 ? Effect.succeed(device.id)
585 : Effect.acquireUseRelease(
586 reserveSlot,
587 () => openLease(device, context),
588 () => Effect.sync(() => void starting--),
589 ),
590 attach: () => Effect.void,
591 shutdown: (device) =>
592 Option.match(leaseFor(device.id), {
593 onNone: () => Effect.void,
594 onSome: (lease) =>
595 FiberMap.remove(keepAlive, lease.leaseId).pipe(Effect.andThen(release(lease))),
596 }),
597 screenshot: (device) =>
598 Option.match(leaseFor(device.id), {
599 onNone: () =>
600 Effect.fail(operationError("screenshot")(new Error("The Simlock lease has ended"))),
601 onSome: screenshotLease,
602 }),
603 agentEndpoint: (deviceId) =>
604 Option.match(leaseFor(deviceId), {
605 onNone: () => Effect.fail(unavailable("This Simlock lease has ended.")),
606 onSome: (lease) =>
607 Effect.succeed({
608 baseUrl: agentDeviceUrl,
609 token: lease.token,
610 agentDeviceId: null,
611 session: lease.session,
612 stateDir: lease.dir,
613 installDirectory: lease.installDirectory,
614 }),
615 }),
616 endsDevices: true,
617 };
618​
619 /**
620 * The client speaks serve-sim to every Simlock lease: its AVCC stream and its input
621 * socket map to the lease's own stream and input routes, which take the agent token.
622 */
623 const upstream: NonNullable<DeviceHost.DeviceHostReady["upstream"]> = (hubPath, search) =>
624 Effect.sync(() => {
625 const stream = STREAM_PATH.exec(hubPath);
626 let deviceId: string | null;
627 try {
628 deviceId = stream
629 ? decodeURIComponent(stream[1]!)
630 : hubPath === INPUT_PATH
631 ? search.get("device")
632 : null;
633 } catch {
634 return null;
635 }
636 const lease = deviceId === null ? undefined : leases.get(deviceId);
637 if (!lease) return null;
638 return {
639 url: `${gateway}/v1/leases/${encodeURIComponent(lease.leaseId)}/${stream ? "stream" : "input"}`,
640 headers: { authorization: `Bearer ${lease.token}` },
641 protocols: [],
642 };
643 });
644​
645 const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = Effect.fn(
646 "SimlockDeviceHost.ensureReady",
647 )(function* (onPhase) {
648 if (ready) return ready;
649 // A missing token or unreachable gateway does not stop readiness: listing
650 // and opening devices say what is wrong instead.
651 yield* onPhase("installing");
652 cli = yield* agentDeviceCli;
653 const { nodePath } = cli;
654 ready = {
655 nodePath,
656 hub: { origin: gateway },
657 devices,
658 upstream,
659 // Simlock relays only simctl, adb, and devicectl, and only to a lease's owner.
660 run: () =>
661 Effect.succeed({
662 stdout: "",
663 stderr: "Simlock devices cannot run host commands.",
664 code: 127,
665 }),
666 helpers: { serveSimAxSettings: null, serveSimCli: null },
667 };
668 return ready;
669 });
670​
671 const stopAll = Effect.gen(function* () {
672 stops++;
673 yield* FiberMap.clear(keepAlive);
674 yield* Effect.forEach([...leases.values()], release, { discard: true, concurrency: 4 });
675 }).pipe(Effect.ignore);
676 yield* Effect.addFinalizer(() => stopAll);
677​
678 const platformAvailability = (platform: DevicePlatform) =>
679 loadCatalog.pipe(
680 Effect.ignore,
681 Effect.map(
682 (): DevicePlatformAvailability =>
683 platforms.find((candidate) => candidate.platform === platform) ?? {
684 platform,
685 available: false,
686 },
687 ),
688 );
689​
690 const summary: Effect.Effect<DeviceHostSummary> = Effect.sync(() => ({
691 id: hostId,
692 label: config.label,
693 kind: "simlock" as const,
694 platforms,
695 hubInstalled: true,
696 agentDeviceInstalled: true,
697 }));
698​
699 return DeviceHost.DeviceHost.of({
700 id: hostId,
701 label: config.label,
702 summary,
703 platformAvailability,
704 ensureReady,
705 ensureAgentReady: Effect.fn("SimlockDeviceHost.ensureAgentReady")(function* (onPhase) {
706 return { ...(yield* ensureReady(onPhase)), agentDevice: null };
707 }),
708 current: Effect.sync(() => ready),
709 stopAgent: Effect.void,
710 stop: stopAll,
711 });
712});