1import { pruneLocalDeviceTools } from "./deviceToolMaintenance.ts";
2import { deviceToolInstallMessage } from "@t3tools/contracts";
3/**
4 * The device host that is this machine.
5 *
6 * Runs expo-device-hub as a supervised child on a loopback port and starts the
7 * agent-device daemon in HTTP mode under a T3-owned state directory. Both are
8 * lazy: the device service requires explicit setup consent before it calls
9 * ensureReady to install tools or start helper processes.
10 *
11 * The hub runs in its standalone mode (origin root). The T3 proxy strips its
12 * own prefix, and the Device panel derives stream and socket URLs from the
13 * prefix itself rather than from anything the hub prints.
14 */
15import {
16 type DeviceHostSummary,
17 type DevicePlatform,
18 type DevicePlatformAvailability,
19 LOCAL_DEVICE_HOST_ID,
20} from "@t3tools/contracts";
21import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
22import * as HostProcess from "@t3tools/shared/HostProcess";
23import {
24 resolveNodeExecutable,
25 type NodeRuntimeUnavailableError,
26} from "@t3tools/shared/nodeRuntime";
27import * as NetService from "@t3tools/shared/Net";
28import { isCommandAvailable } from "@t3tools/shared/shell";
29import * as Clock from "effect/Clock";
30import * as Duration from "effect/Duration";
31import * as Effect from "effect/Effect";
32import * as Exit from "effect/Exit";
33import * as FileSystem from "effect/FileSystem";
34import * as Layer from "effect/Layer";
35import * as Option from "effect/Option";
36import * as Path from "effect/Path";
37import * as Ref from "effect/Ref";
38import * as Schema from "effect/Schema";
39import * as Scope from "effect/Scope";
40import * as Semaphore from "effect/Semaphore";
41import * as Stream from "effect/Stream";
42import { HttpClient } from "effect/http";
43import * as ChildProcess from "effect/process/ChildProcess";
44import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
45
46import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
47import * as ServerConfig from "../config.ts";
48import * as ProcessRunner from "../processRunner.ts";
49import * as DeviceHost from "./DeviceHost.ts";
50import {
51 agentDeviceStateDir,
52 type DeviceToolPaths,
53 ensureAgentDevice,
54 ensureDeviceHub,
55 isAgentDeviceInstalled,
56 isDeviceHubInstalled,
57 deviceToolVersions,
58 DEVICE_HUB_VERSION,
59} from "./DeviceToolchain.ts";
60
61const HUB_READY_TIMEOUT_MS = 30_000;
62const DAEMON_READY_TIMEOUT_MS = 30_000;
63const DAEMON_POLL_MS = 100;
64const HUB_RESTART_STABLE_UPTIME_MS = 60_000;
65const HUB_RESTART_MAX_DELAY_MS = 30_000;
66
67/**
68 * Written beside the agent-device state so a server that dies without running
69 * its finalizers (SIGKILL, dev-runner restarts) does not leave a hub bound to
70 * a loopback port forever. The next start reads it, kills only a process that
71 * is still that hub, and replaces the file.
72 */
73const HubStateFile = Schema.Struct({
74 pid: Schema.Int,
75 port: Schema.Int,
76 entryPath: Schema.String,
77});
78const decodeHubStateFile = Schema.decodeUnknownEffect(Schema.fromJsonString(HubStateFile));
79const encodeHubStateFile = Schema.encodeUnknownEffect(Schema.fromJsonString(HubStateFile));
80
81const AgentDeviceDaemonFile = Schema.Struct({
82 httpPort: Schema.Int,
83 token: Schema.String,
84 pid: Schema.optional(Schema.Int),
85 version: Schema.optional(Schema.String),
86});
87const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));
88
89const decodeAgentDeviceFailure = Schema.decodeUnknownOption(
90 Schema.fromJsonString(
91 Schema.Struct({
92 error: Schema.Struct({
93 details: Schema.Struct({
94 reason: Schema.String,
95 inspection: Schema.optional(Schema.Struct({ reason: Schema.String })),
96 }),
97 }),
98 }),
99 ),
100);
101
102interface HubProcess {
103 readonly nodePath: string;
104 readonly child: ChildProcessSpawner.ChildProcessHandle;
105 readonly scope: Scope.Closeable;
106 readonly origin: string;
107 readonly startedAtMillis: number;
108}
109
110interface RunningHost {
111 readonly hub: HubProcess;
112 readonly agentDevice: DeviceHost.AgentDeviceEndpoint | null;
113 readonly helpers: DeviceHost.DeviceHostReady["helpers"];
114}
115
116const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* (
117 platform: DevicePlatform,
118): Effect.fn.Return<string | null, never, FileSystem.FileSystem | Path.Path> {
119 const hostPlatform = yield* HostProcess.Platform;
120 if (platform === "ios") {
121 if (hostPlatform !== "darwin") return "iOS Simulators need macOS with Xcode.";
122 if (!(yield* isCommandAvailable("xcrun"))) return "Xcode command line tools were not found.";
123 return null;
124 }
125 const sdk = yield* androidSdk;
126 if (!sdk.root)
127 return "Android SDK was not found. Install it with Android Studio or set ANDROID_HOME to your SDK directory.";
128 if (!sdk.adb)
129 return `Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`;
130 if (!sdk.emulator)
131 return `Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`;
132 if (!sdk.avdmanager) {
133 if (sdk.legacyAvdmanager)
134 return `The Android SDK command-line tools in ${sdk.root} appear to be an older, unsupported version. Install Android SDK Command-line Tools (latest) in Android Studio's SDK Manager under SDK Tools.`;
135 return `Android SDK Command-line Tools (latest) are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`;
136 }
137 return null;
138});
139
140/** Resolve the SDK once for both diagnostics and the environment passed to helpers. */
141const androidSdk = Effect.gen(function* () {
142 const fs = yield* FileSystem.FileSystem;
143 const path = yield* Path.Path;
144 const environment = yield* HostProcess.Environment;
145 const platform = yield* HostProcess.Platform;
146 const home = environment.HOME ?? environment.USERPROFILE ?? "";
147 const explicit = environment.ANDROID_HOME?.trim() || environment.ANDROID_SDK_ROOT?.trim();
148 const candidates = explicit
149 ? [explicit]
150 : [
151 path.join(home, "Library", "Android", "sdk"),
152 path.join(home, "Android", "Sdk"),
153 path.join(
154 environment.LOCALAPPDATA ?? path.join(home, "AppData", "Local"),
155 "Android",
156 "Sdk",
157 ),
158 ];
159 if (!explicit) {
160 for (const directory of (environment.PATH ?? "").split(platform === "win32" ? ";" : ":")) {
161 if (!directory) continue;
162 const resolved = yield* fs
163 .realPath(path.join(directory, platform === "win32" ? "adb.exe" : "adb"))
164 .pipe(Effect.option);
165 if (resolved._tag === "Some") candidates.push(path.dirname(path.dirname(resolved.value)));
166 }
167 }
168 const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false));
169 for (const root of candidates) {
170 const adb = yield* exists(
171 path.join(root, "platform-tools", platform === "win32" ? "adb.exe" : "adb"),
172 );
173 const emulator = yield* exists(
174 path.join(root, "emulator", platform === "win32" ? "emulator.exe" : "emulator"),
175 );
176 if (explicit || adb || emulator) {
177 const avdmanager = yield* exists(
178 path.join(
179 root,
180 "cmdline-tools",
181 "latest",
182 "bin",
183 platform === "win32" ? "avdmanager.bat" : "avdmanager",
184 ),
185 );
186 const legacyAvdmanager =
187 !avdmanager &&
188 (yield* exists(
189 path.join(root, "tools", "bin", platform === "win32" ? "avdmanager.bat" : "avdmanager"),
190 ));
191 return { root, adb, emulator, avdmanager, legacyAvdmanager };
192 }
193 }
194 return { root: null, adb: false, emulator: false, avdmanager: false, legacyAvdmanager: false };
195});
196
197const deviceHostEnvironment = (
198 environment: NodeJS.ProcessEnv,
199 sdkRoot: string | null,
200 hostPlatform: NodeJS.Platform,
201 path: Path.Path,
202): NodeJS.ProcessEnv => {
203 return sdkRoot
204 ? {
205 ...environment,
206 ANDROID_HOME: sdkRoot,
207 PATH: [
208 path.join(sdkRoot, "platform-tools"),
209 path.join(sdkRoot, "emulator"),
210 environment.PATH ?? environment.Path ?? "",
211 ].join(hostPlatform === "win32" ? ";" : ":"),
212 }
213 : environment;
214};
215
216const hubEnvironment = Effect.fn("LocalDeviceHost.hubEnvironment")(function* (
217 environment: NodeJS.ProcessEnv,
218) {
219 const env: NodeJS.ProcessEnv = { ...environment, FORCE_COLOR: "0", NO_COLOR: "1" };
220 const platform = yield* HostProcess.Platform;
221 const uid = yield* HostProcess.UserId;
222 if (platform === "linux" && env.XDG_RUNTIME_DIR === undefined && uid !== undefined) {
223 // SSH sessions may omit the directory where the emulator publishes its gRPC token.
224 const runtimeDir = `/run/user/${uid}`;
225 const fs = yield* FileSystem.FileSystem;
226 const stat = yield* fs.stat(runtimeDir).pipe(Effect.option);
227 if (
228 stat._tag === "Some" &&
229 stat.value.type === "Directory" &&
230 Option.contains(stat.value.uid, uid)
231 ) {
232 env.XDG_RUNTIME_DIR = runtimeDir;
233 }
234 }
235 return env;
236});
237
238export const make = Effect.fn("LocalDeviceHost.make")(function* () {
239 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
240 const config = yield* ServerConfig.ServerConfig;
241 const path = yield* Path.Path;
242 const fs = yield* FileSystem.FileSystem;
243 const net = yield* NetService.NetService;
244 const runner = yield* ProcessRunner.ProcessRunner;
245 const httpClient = yield* HttpClient.HttpClient;
246 const environment = yield* HostProcess.Environment;
247 const hostPlatform = yield* HostProcess.Platform;
248 const sdk = yield* androidSdk;
249 const hostEnvironment = deviceHostEnvironment(environment, sdk.root, hostPlatform, path);
250 const startLock = yield* Semaphore.make(1);
251 const runningRef = yield* Ref.make<RunningHost | null>(null);
252 const restartDelayRef = yield* Ref.make(0);
253 const hostId = LOCAL_DEVICE_HOST_ID;
254
255 const platformAvailability = Effect.fn("LocalDeviceHost.platformAvailability")(function* (
256 platform: DevicePlatform,
257 ): Effect.fn.Return<DevicePlatformAvailability> {
258 const reason = yield* platformReason(platform).pipe(
259 Effect.provideService(FileSystem.FileSystem, fs),
260 Effect.provideService(Path.Path, path),
261 );
262 return reason === null ? { platform, available: true } : { platform, available: false, reason };
263 });
264
265 const summary: Effect.Effect<DeviceHostSummary> = Effect.gen(function* () {
266 const [platforms, hubInstalled, agentDeviceInstalled] = yield* Effect.all([
267 Effect.all([platformAvailability("ios"), platformAvailability("android")]),
268 isDeviceHubInstalled(config.baseDir).pipe(
269 Effect.provideService(FileSystem.FileSystem, fs),
270 Effect.provideService(Path.Path, path),
271 ),
272 isAgentDeviceInstalled(config.baseDir).pipe(
273 Effect.provideService(FileSystem.FileSystem, fs),
274 Effect.provideService(Path.Path, path),
275 ),
276 ]);
277 const running = yield* Ref.get(runningRef);
278 const daemon = running?.agentDevice
279 ? yield* readDaemonFile().pipe(Effect.option)
280 : Option.none();
281 const hubAlive = running
282 ? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false))
283 : false;
284 const agentAlive =
285 Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false;
286 const tools = yield* deviceToolVersions(config.baseDir, {
287 ...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}),
288 ...(agentAlive && Option.isSome(daemon) && daemon.value.version
289 ? { agent: daemon.value.version }
290 : {}),
291 }).pipe(
292 Effect.provideService(FileSystem.FileSystem, fs),
293 Effect.provideService(Path.Path, path),
294 );
295 return {
296 tools,
297 id: hostId,
298 kind: "local",
299 label: "This machine",
300 platforms,
301 hubInstalled,
302 agentDeviceInstalled,
303 };
304 });
305
306 const stopHub = (hub: HubProcess | undefined) =>
307 hub ? Scope.close(hub.scope, Exit.void).pipe(Effect.ignore) : Effect.void;
308
309 const hubStatePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "hub.json");
310
311 const isProcessAlive = (pid: number) =>
312 Effect.sync(() => {
313 try {
314 process.kill(pid, 0);
315 return true;
316 } catch {
317 return false;
318 }
319 });
320
321 /**
322 * A hub left behind by a previous server is identified by pid plus the
323 * command line's entry path, so a recycled pid belonging to something else
324 * is never touched.
325 */
326 const reapStaleHub = Effect.gen(function* () {
327 const previous = yield* fs
328 .readFileString(hubStatePath())
329 .pipe(Effect.flatMap(decodeHubStateFile), Effect.option);
330 if (previous._tag === "None") return;
331 const alive = yield* isProcessAlive(previous.value.pid);
332 if (alive) {
333 const commandLine = yield* runner
334 .run({
335 command: "ps",
336 args: ["-o", "command=", "-p", String(previous.value.pid)],
337 timeout: Duration.seconds(5),
338 timeoutBehavior: "timedOutResult",
339 })
340 .pipe(
341 Effect.map((result) => result.stdout),
342 Effect.orElseSucceed(() => ""),
343 );
344 if (commandLine.includes(previous.value.entryPath)) {
345 yield* Effect.logWarning("Stopping a device hub left behind by a previous server", {
346 pid: previous.value.pid,
347 port: previous.value.port,
348 });
349 yield* Effect.sync(() => {
350 try {
351 process.kill(previous.value.pid, "SIGTERM");
352 } catch {
353 // Already gone.
354 }
355 });
356 }
357 }
358 yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore);
359 }).pipe(Effect.ignoreCause);
360
361 const recordHub = (hub: HubProcess, hubTool: DeviceToolPaths) =>
362 encodeHubStateFile({
363 pid: Number(hub.child.pid),
364 port: Number(new URL(hub.origin).port),
365 entryPath: hubTool.entryPath,
366 }).pipe(
367 Effect.flatMap((contents) =>
368 writeFileStringAtomically({ filePath: hubStatePath(), contents }),
369 ),
370 Effect.provideService(FileSystem.FileSystem, fs),
371 Effect.provideService(Path.Path, path),
372 Effect.ignore,
373 );
374
375 const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* (
376 hubTool: DeviceToolPaths,
377 nodePath: string,
378 ): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> {
379 yield* reapStaleHub;
380 yield* fs
381 .makeDirectory(agentDeviceStateDir(path, config.stateDir), { recursive: true })
382 .pipe(Effect.ignore);
383 const port = yield* net.reserveLoopbackPort("127.0.0.1").pipe(
384 Effect.mapError(
385 (cause) =>
386 new DeviceHost.DeviceHostError({
387 hostId,
388 step: "reserving a port for the device hub",
389 cause,
390 }),
391 ),
392 );
393 const origin = `http://127.0.0.1:${port}`;
394 const scope = yield* Scope.make("sequential");
395 // On failure or interrupt, from the spawn on: the hub is not recorded yet,
396 // so nothing else stops it.
397 return yield* Effect.gen(function* () {
398 const child = yield* spawner
399 .spawn(
400 ChildProcess.make(
401 nodePath,
402 [
403 hubTool.entryPath,
404 "--port",
405 String(port),
406 "--host",
407 "127.0.0.1",
408 "--hide-sidebar",
409 "--hide-boot-device",
410 ],
411 {
412 detached: false,
413 shell: false,
414 stdout: "pipe",
415 stderr: "pipe",
416 env: yield* hubEnvironment(hostEnvironment).pipe(
417 Effect.provideService(FileSystem.FileSystem, fs),
418 Effect.provideService(HostProcess.Platform, hostPlatform),
419 ),
420 },
421 ),
422 )
423 .pipe(
424 Effect.provideService(Scope.Scope, scope),
425 Effect.mapError(
426 (cause) =>
427 new DeviceHost.DeviceHostError({
428 hostId,
429 step: "starting the device hub",
430 cause,
431 }),
432 ),
433 );
434 const startedAtMillis = yield* Clock.currentTimeMillis;
435 const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath };
436 yield* Effect.forkIn(observeHubOutput(hub), scope);
437 yield* waitForHttpReady({
438 baseUrl: origin,
439 path: "/readyz",
440 timeoutMs: HUB_READY_TIMEOUT_MS,
441 makeError: (info) =>
442 new DeviceHost.DeviceHostError({
443 hostId,
444 step: "waiting for the device hub to answer",
445 cause: info.cause,
446 }),
447 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
448 yield* recordHub(hub, hubTool);
449 yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port });
450 return hub;
451 }).pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)));
452 });
453
454 const observeHubOutput = (hub: HubProcess) =>
455 hub.child.all.pipe(
456 Stream.decodeText(),
457 Stream.splitLines,
458 Stream.map((line) => line.trim()),
459 Stream.filter((line) => line.length > 0),
460 Stream.runForEach((line) =>
461 Effect.logDebug("Device hub output", { pid: Number(hub.child.pid), output: line }),
462 ),
463 Effect.ignoreCause,
464 );
465
466 /**
467 * Restart the hub when it dies under us, with the same doubling backoff the
468 * relay connector uses so a hub that crashes on boot cannot spin.
469 */
470 const superviseHub = (hub: HubProcess, hubTool: DeviceToolPaths): Effect.Effect<void> =>
471 Effect.gen(function* () {
472 yield* Effect.result(hub.child.exitCode);
473 const running = yield* Ref.get(runningRef);
474 if (running?.hub.child.pid !== hub.child.pid) return;
475 const uptime = (yield* Clock.currentTimeMillis) - hub.startedAtMillis;
476 const delay = yield* Ref.modify(restartDelayRef, (current) => {
477 if (uptime >= HUB_RESTART_STABLE_UPTIME_MS) return [0, 0];
478 const next = current === 0 ? 1_000 : Math.min(current * 2, HUB_RESTART_MAX_DELAY_MS);
479 return [current, next];
480 });
481 yield* Effect.logWarning("Device hub exited; restarting", {
482 pid: Number(hub.child.pid),
483 delayMs: delay,
484 });
485 yield* Effect.sleep(Duration.millis(delay));
486 yield* startLock.withPermits(1)(
487 Effect.gen(function* () {
488 const current = yield* Ref.get(runningRef);
489 if (current?.hub.child.pid !== hub.child.pid) return;
490 const replacement = yield* spawnHub(hubTool, hub.nodePath);
491 yield* Ref.set(runningRef, { ...current, hub: replacement });
492 yield* Effect.forkDetach(superviseHub(replacement, hubTool));
493 }),
494 );
495 }).pipe(
496 Effect.catchCause((cause) => Effect.logWarning("Device hub supervisor failed", { cause })),
497 );
498
499 const daemonFilePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "daemon.json");
500
501 const readDaemonFile = Effect.fn("LocalDeviceHost.readDaemonFile")(function* () {
502 const raw = yield* fs.readFileString(daemonFilePath());
503 return yield* decodeDaemonFile(raw);
504 });
505
506 /**
507 * agent-device auto-starts its daemon on any command. A trivial `devices`
508 * call in HTTP mode is the documented way to bring it up; its output is the
509 * daemon.json this reads back.
510 */
511 const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* (
512 agentTool: DeviceToolPaths,
513 nodePath: string,
514 ): Effect.fn.Return<
515 DeviceHost.AgentDeviceEndpoint,
516 | DeviceHost.DeviceHostError
517 | DeviceHost.DeviceHostTimeoutError
518 | DeviceHost.AgentDeviceStartError
519 > {
520 const stateDir = agentDeviceStateDir(path, config.stateDir);
521 yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore);
522 const existing = yield* readDaemonFile().pipe(Effect.option);
523 const daemonEnvironment: NodeJS.ProcessEnv = {
524 ...hostEnvironment,
525 AGENT_DEVICE_STATE_DIR: stateDir,
526 AGENT_DEVICE_DAEMON_SERVER_MODE: "http",
527 // The daemon idles out after five minutes by default; the server owns
528 // its lifetime here and stops it explicitly.
529 AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0",
530 AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1",
531 FORCE_COLOR: "0",
532 NO_COLOR: "1",
533 };
534 const toEndpoint = (
535 file: typeof AgentDeviceDaemonFile.Type,
536 ): DeviceHost.AgentDeviceEndpoint => ({
537 baseUrl: `http://127.0.0.1:${file.httpPort}`,
538 token: file.token,
539 entryPath: agentTool.entryPath,
540 });
541 if (existing._tag === "Some") {
542 const alive = yield* HttpClient.withScope(httpClient)
543 .get(`http://127.0.0.1:${existing.value.httpPort}/health`)
544 .pipe(
545 Effect.timeout(Duration.seconds(2)),
546 Effect.flatMap((response) =>
547 response.arrayBuffer.pipe(Effect.as(response.status === 200)),
548 ),
549 Effect.scoped,
550 Effect.orElseSucceed(() => false),
551 );
552 if (alive) return toEndpoint(existing.value);
553 yield* fs.remove(daemonFilePath(), { force: true }).pipe(Effect.ignore);
554 }
555 // There is no `daemon start`; the first command in a state dir spawns the
556 // daemon and blocks until it answers. `devices` is the cheapest one.
557 const runResult = yield* runner
558 .run({
559 command: nodePath,
560 args: [agentTool.entryPath, "devices", "--json"],
561 env: daemonEnvironment,
562 timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS),
563 timeoutBehavior: "timedOutResult",
564 })
565 .pipe(Effect.result);
566 // Discovery can fail after the daemon starts. A published endpoint still
567 // lets agents connect; absent one, preserve the CLI failure instead of polling.
568 const started = yield* readDaemonFile().pipe(Effect.option);
569 if (started._tag === "Some") return toEndpoint(started.value);
570 if (runResult._tag === "Failure") {
571 return yield* new DeviceHost.DeviceHostError({
572 hostId,
573 step: "starting agent tools",
574 cause: runResult.failure,
575 });
576 }
577 const result = runResult.success;
578 if (result.timedOut) {
579 return yield* new DeviceHost.DeviceHostTimeoutError({
580 hostId,
581 timeoutMs: DAEMON_READY_TIMEOUT_MS,
582 });
583 }
584 if (result.code !== 0) {
585 const failure = decodeAgentDeviceFailure(result.stdout);
586 const details = Option.isSome(failure) ? failure.value.error.details : undefined;
587 const reason =
588 details?.reason === "daemon_registration_unproven"
589 ? details.inspection?.reason === "non-directory"
590 ? "daemon_lock_not_directory"
591 : "daemon_registration_unproven"
592 : details?.reason === "daemon_retirement_unconfirmed"
593 ? "daemon_retirement_unconfirmed"
594 : "command_failed";
595 return yield* new DeviceHost.AgentDeviceStartError({
596 hostId,
597 stateDir,
598 exitCode: result.code === null ? null : Number(result.code),
599 reason,
600 cause: result,
601 });
602 }
603 const deadline = (yield* Clock.currentTimeMillis) + DAEMON_READY_TIMEOUT_MS;
604 while (true) {
605 const file = yield* readDaemonFile().pipe(Effect.option);
606 if (file._tag === "Some") return toEndpoint(file.value);
607 if ((yield* Clock.currentTimeMillis) > deadline) {
608 return yield* new DeviceHost.DeviceHostTimeoutError({
609 hostId,
610 timeoutMs: DAEMON_READY_TIMEOUT_MS,
611 });
612 }
613 yield* Effect.sleep(Duration.millis(DAEMON_POLL_MS));
614 }
615 });
616
617 const stopAgentDeviceDaemon = (
618 agentTool: { readonly entryPath: string; readonly nodePath: string } | null,
619 ) =>
620 agentTool
621 ? runner
622 .run({
623 command: agentTool.nodePath,
624 args: [
625 agentTool.entryPath,
626 "daemon",
627 "stop",
628 "--state-dir",
629 agentDeviceStateDir(path, config.stateDir),
630 ],
631 env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" },
632 timeout: Duration.seconds(10),
633 timeoutBehavior: "timedOutResult",
634 })
635 .pipe(Effect.ignore)
636 : Effect.void;
637
638 let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null;
639
640 const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* (
641 onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
642 ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError | NodeRuntimeUnavailableError> {
643 const running = yield* Ref.get(runningRef);
644 if (running) {
645 const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false));
646 if (alive) return running;
647 yield* Ref.set(runningRef, null);
648 }
649 const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe(
650 Effect.provideService(FileSystem.FileSystem, fs),
651 Effect.provideService(Path.Path, path),
652 Effect.provideService(HostProcess.Platform, hostPlatform),
653 );
654 const installed = yield* isDeviceHubInstalled(config.baseDir).pipe(
655 Effect.provideService(FileSystem.FileSystem, fs),
656 Effect.provideService(Path.Path, path),
657 );
658 if (!installed) {
659 const inventory = yield* summary;
660 yield* onPhase("installing", deviceToolInstallMessage("device hub", inventory.tools?.hub));
661 }
662 const hubTool = yield* ensureDeviceHub(config.baseDir).pipe(
663 Effect.provideService(FileSystem.FileSystem, fs),
664 Effect.provideService(Path.Path, path),
665 Effect.provideService(ProcessRunner.ProcessRunner, runner),
666 Effect.mapError(
667 (cause) =>
668 new DeviceHost.DeviceHostError({
669 hostId,
670 step: "installing device support",
671 cause,
672 }),
673 ),
674 );
675 yield* onPhase("starting");
676 const hub = yield* spawnHub(hubTool, nodePath);
677 // Until the hub is in runningRef, nothing else stops it, so publishing it
678 // is part of the guarded step.
679 const next = yield* Effect.gen(function* () {
680 yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe(
681 Effect.provideService(Path.Path, path),
682 Effect.provideService(ProcessRunner.ProcessRunner, runner),
683 Effect.ignore,
684 );
685 const candidate = helperPaths(hubTool);
686 const [axExists, cliExists] = yield* Effect.all([
687 fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)),
688 fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)),
689 ]);
690 const next: RunningHost = {
691 hub,
692 agentDevice: null,
693 helpers: {
694 serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null,
695 serveSimCli: cliExists ? candidate.serveSimCli : null,
696 },
697 };
698 yield* Ref.set(runningRef, next);
699 return next;
700 }).pipe(Effect.onError(() => stopHub(hub)));
701 yield* Ref.set(restartDelayRef, 0);
702 yield* Effect.forkDetach(superviseHub(hub, hubTool));
703 return next;
704 });
705
706 const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) =>
707 startLock.withPermits(1)(ensureHubReady(onPhase).pipe(Effect.map(toReady)));
708
709 const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) =>
710 startLock.withPermits(1)(
711 Effect.gen(function* (): Generator<
712 Effect.Effect<
713 unknown,
714 | DeviceHost.DeviceHostError
715 | DeviceHost.DeviceHostTimeoutError
716 | DeviceHost.AgentDeviceStartError
717 | NodeRuntimeUnavailableError
718 >,
719 DeviceHost.DeviceHostAgentReady
720 > {
721 const running = yield* ensureHubReady(onPhase);
722 if (running.agentDevice) return { ...toReady(running), agentDevice: running.agentDevice };
723 const installed = yield* isAgentDeviceInstalled(config.baseDir).pipe(
724 Effect.provideService(FileSystem.FileSystem, fs),
725 Effect.provideService(Path.Path, path),
726 );
727 if (!installed) {
728 const inventory = yield* summary;
729 yield* onPhase(
730 "installing",
731 deviceToolInstallMessage("agent tools", inventory.tools?.agent),
732 );
733 }
734 const agentTool = yield* ensureAgentDevice(config.baseDir).pipe(
735 Effect.provideService(FileSystem.FileSystem, fs),
736 Effect.provideService(Path.Path, path),
737 Effect.provideService(ProcessRunner.ProcessRunner, runner),
738 Effect.mapError(
739 (cause) =>
740 new DeviceHost.DeviceHostError({
741 hostId,
742 step: "installing agent tools",
743 cause,
744 }),
745 ),
746 );
747 agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath };
748 yield* onPhase("starting");
749 const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath);
750 yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe(
751 Effect.provideService(Path.Path, path),
752 Effect.provideService(ProcessRunner.ProcessRunner, runner),
753 Effect.ignore,
754 );
755 const next = { ...running, agentDevice };
756 yield* Ref.set(runningRef, next);
757 return { ...toReady(next), agentDevice };
758 }),
759 );
760
761 const helperPaths = (hubTool: DeviceToolPaths) => {
762 const serveSimDist = path.join(
763 hubTool.installDir,
764 "node_modules",
765 "expo-device-hub",
766 "vendor",
767 "serve-sim",
768 "dist",
769 );
770 return {
771 serveSimAxSettings: path.join(serveSimDist, "simax", "serve-sim-ax-settings"),
772 serveSimCli: path.join(serveSimDist, "serve-sim.js"),
773 };
774 };
775
776 const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) =>
777 runner
778 .run({
779 command:
780 command === "emulator" && sdk.root
781 ? path.join(
782 sdk.root,
783 "emulator",
784 hostPlatform === "win32" ? "emulator.exe" : "emulator",
785 )
786 : command,
787 args,
788 env: hostEnvironment,
789 timeout: Duration.millis(options?.timeoutMs ?? 20_000),
790 timeoutBehavior: "timedOutResult",
791 ...(options?.stdin === undefined ? {} : { stdin: options.stdin }),
792 })
793 .pipe(
794 Effect.map((result) => ({
795 stdout: result.stdout,
796 stderr: result.stderr,
797 code: Number(result.code),
798 })),
799 Effect.catch((cause) => Effect.succeed({ stdout: "", stderr: String(cause), code: 127 })),
800 );
801
802 const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({
803 hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,
804 nodePath: running.hub.nodePath,
805 run,
806 helpers: running.helpers,
807 });
808
809 const current: DeviceHost.DeviceHost["Service"]["current"] = Ref.get(runningRef).pipe(
810 Effect.map((running) => (running ? toReady(running) : null)),
811 );
812
813 const stopAgent: DeviceHost.DeviceHost["Service"]["stopAgent"] = startLock.withPermits(1)(
814 Effect.gen(function* () {
815 yield* stopAgentDeviceDaemon(agentToolRef);
816 yield* Ref.update(runningRef, (running) =>
817 running ? { ...running, agentDevice: null } : running,
818 );
819 }),
820 );
821
822 const stop: DeviceHost.DeviceHost["Service"]["stop"] = startLock.withPermits(1)(
823 Effect.gen(function* () {
824 const running = yield* Ref.getAndSet(runningRef, null);
825 yield* stopHub(running?.hub);
826 yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore);
827 yield* stopAgentDeviceDaemon(agentToolRef);
828 }),
829 );
830
831 // Never leave the hub or daemon behind when the server's scope closes.
832 yield* Effect.addFinalizer(() => stop);
833
834 const host: DeviceHost.DeviceHost["Service"] = {
835 id: hostId,
836 summary,
837 platformAvailability,
838 ensureReady,
839 ensureAgentReady,
840 current,
841 stopAgent,
842 stop,
843 };
844 return host;
845});
846
847export const layer = Layer.effect(DeviceHost.DeviceHost, make());
848
849/** Exposed for tests. */
850export const __testing = {
851 AgentDeviceDaemonFile,
852 androidSdk,
853 platformReason,
854 deviceHostEnvironment,
855 hubEnvironment,
856};