import { pruneLocalDeviceTools } from "./deviceToolMaintenance.ts"; import { deviceToolInstallMessage } from "@t3tools/contracts"; // The hub runs at origin root; T3 strips its prefix and the Device panel derives stream URLs from it. import { type DeviceHostSummary, type DevicePlatform, type DevicePlatformAvailability, LOCAL_DEVICE_HOST_ID, LOCAL_DEVICE_HOST_LABEL, } from "@t3tools/contracts"; import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveNodeExecutable, type NodeRuntimeUnavailableError, } from "@t3tools/shared/nodeRuntime"; import * as NetService from "@t3tools/shared/Net"; import { isCommandAvailable, resolveCommandPath } from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; import * as DeviceHost from "./DeviceHost.ts"; import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { agentDeviceStateDir, type DeviceToolPaths, ensureAgentDevice, ensureDeviceHub, isAgentDeviceInstalled, isDeviceHubInstalled, deviceToolVersions, DEVICE_HUB_VERSION, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; const DAEMON_READY_TIMEOUT_MS = 30_000; const DAEMON_POLL_MS = 100; const HUB_RESTART_STABLE_UPTIME_MS = 60_000; const HUB_RESTART_MAX_DELAY_MS = 30_000; // Persist hub state so restarts without finalizers can clean up orphaned loopback listeners. const HubStateFile = Schema.Struct({ pid: Schema.Int, port: Schema.Int, entryPath: Schema.String, }); const decodeHubStateFile = Schema.decodeUnknownEffect(Schema.fromJsonString(HubStateFile)); const encodeHubStateFile = Schema.encodeUnknownEffect(Schema.fromJsonString(HubStateFile)); const AgentDeviceDaemonFile = Schema.Struct({ httpPort: Schema.Int, token: Schema.String, pid: Schema.optional(Schema.Int), version: Schema.optional(Schema.String), }); const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); const decodeAgentDeviceFailure = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ error: Schema.Struct({ details: Schema.Struct({ reason: Schema.String, inspection: Schema.optional(Schema.Struct({ reason: Schema.String })), }), }), }), ), ); interface HubProcess { readonly nodePath: string; readonly child: ChildProcessSpawner.ChildProcessHandle; readonly scope: Scope.Closeable; readonly origin: string; readonly startedAtMillis: number; } interface RunningHost { readonly hub: HubProcess; readonly agentDevice: DeviceHost.AgentDeviceEndpoint | null; readonly helpers: DeviceHost.DeviceHostReady["helpers"]; } const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* ( platform: DevicePlatform, ): Effect.fn.Return { const hostPlatform = yield* HostProcess.Platform; if (platform === "ios") { if (hostPlatform !== "darwin") return "iOS Simulators need macOS with Xcode."; if (!(yield* isCommandAvailable("xcrun"))) return "Xcode command line tools were not found."; return null; } const sdk = yield* androidSdk; if (!sdk.root) return "Android SDK was not found. Install it with Android Studio or set ANDROID_HOME to your SDK directory."; if (!sdk.adb) return `Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`; if (!sdk.emulator) return `Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`; if (!sdk.avdmanager) { if (sdk.legacyAvdmanager) 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.`; return `Android SDK Command-line Tools (latest) are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`; } if ((yield* androidJava) === null) return "Android SDK Command-line Tools need Java 17 or later, which was not found. Install Android Studio, or set JAVA_HOME for the T3 Code server."; return null; }); /** * The hub lists AVDs with avdmanager, a Java program. GUI-launched servers * rarely inherit a JDK, so fall back to the one bundled with Android Studio. * Keep the candidates in step with `remoteDeviceEnvironment`. * `undefined` means a working `java` is already on PATH. */ const androidJava = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const environment = yield* HostProcess.Environment; const platform = yield* HostProcess.Platform; // JDK release metadata avoids spawning Java on every device command. const supported = Effect.fn("LocalDeviceHost.supportedJava")(function* (directory: string) { const executable = path.join(directory, "bin", platform === "win32" ? "java.exe" : "java"); if (!(yield* fs.exists(executable).pipe(Effect.orElseSucceed(() => false)))) return false; const release = yield* fs .readFileString(path.join(directory, "release")) .pipe(Effect.orElseSucceed(() => "")); return Number(/^JAVA_VERSION="(\d+)/m.exec(release)?.[1] ?? 0) >= 17; }); const javaHome = environment.JAVA_HOME?.trim(); if (javaHome) return (yield* supported(javaHome)) ? javaHome : null; const home = environment.HOME ?? environment.USERPROFILE ?? ""; const java = yield* resolveCommandPath("java"); if (java && !(platform === "darwin" && java === "/usr/bin/java")) { const resolved = yield* fs.realPath(java).pipe(Effect.orElseSucceed(() => java)); if (yield* supported(path.dirname(path.dirname(resolved)))) return undefined; } if (platform === "darwin") { // /usr/bin/java is a launcher stub that fails unless a JDK is registered here. for (const directory of [ "/Library/Java/JavaVirtualMachines", path.join(home, "Library", "Java", "JavaVirtualMachines"), ]) { const installed = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); for (const name of installed) { const candidate = path.join(directory, name, "Contents", "Home"); if (yield* supported(candidate)) return candidate; } } } const candidates = platform === "win32" ? [ path.join( environment.ProgramFiles ?? "C:\\Program Files", "Android", "Android Studio", "jbr", ), ] : platform === "darwin" ? [ "/Applications/Android Studio.app/Contents/jbr/Contents/Home", path.join(home, "Applications/Android Studio.app/Contents/jbr/Contents/Home"), ] : [ path.join(home, ".local", "opt", "android-studio", "jbr"), "/opt/android-studio/jbr", "/usr/local/android-studio/jbr", "/snap/android-studio/current/jbr", ]; for (const candidate of candidates) { if (yield* supported(candidate)) return candidate; } return null; }); const androidSdk = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const environment = yield* HostProcess.Environment; const platform = yield* HostProcess.Platform; const windows = platform === "win32"; const home = environment.HOME ?? environment.USERPROFILE ?? ""; const explicit = environment.ANDROID_HOME?.trim() || environment.ANDROID_SDK_ROOT?.trim(); const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false)); const adbBinary = windows ? "adb.exe" : "adb"; const avdmanagerBinary = windows ? "avdmanager.bat" : "avdmanager"; const inspect = Effect.fnUntraced(function* (root: string) { const adb = yield* exists(path.join(root, "platform-tools", adbBinary)); const emulator = yield* exists( path.join(root, "emulator", windows ? "emulator.exe" : "emulator"), ); if (!explicit && !adb && !emulator) return null; const avdmanager = yield* exists( path.join(root, "cmdline-tools", "latest", "bin", avdmanagerBinary), ); const legacyAvdmanager = !avdmanager && (yield* exists(path.join(root, "tools", "bin", avdmanagerBinary))); return { root, adb, emulator, avdmanager, legacyAvdmanager }; }); const candidates = explicit ? [explicit] : [ path.join(home, "Library", "Android", "sdk"), path.join(home, "Android", "Sdk"), path.join( environment.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Android", "Sdk", ), ]; for (const root of candidates) { const sdk = yield* inspect(root); if (sdk) return sdk; } // PATH entries can be slow to stat (network shares), so only consult them after the standard locations miss. if (!explicit) { const searchPath = environment.PATH ?? environment.Path ?? ""; for (const directory of searchPath.split(windows ? ";" : ":")) { if (!directory) continue; const adb = path.join(directory, adbBinary); if (!(yield* exists(adb))) continue; const resolved = yield* fs.realPath(adb).pipe(Effect.orElseSucceed(() => adb)); const sdk = yield* inspect(path.dirname(path.dirname(resolved))); if (sdk) return sdk; } } return { root: null, adb: false, emulator: false, avdmanager: false, legacyAvdmanager: false }; }); const deviceHostEnvironment = ( environment: NodeJS.ProcessEnv, sdkRoot: string | null, javaHome: string | null | undefined, hostPlatform: NodeJS.Platform, path: Path.Path, ): NodeJS.ProcessEnv => { return sdkRoot ? { ...environment, ANDROID_HOME: sdkRoot, ...(javaHome ? { JAVA_HOME: javaHome } : {}), PATH: [ path.join(sdkRoot, "platform-tools"), path.join(sdkRoot, "emulator"), environment.PATH ?? environment.Path ?? "", ].join(hostPlatform === "win32" ? ";" : ":"), } : environment; }; const hubEnvironment = Effect.fn("LocalDeviceHost.hubEnvironment")(function* ( environment: NodeJS.ProcessEnv, ) { const env: NodeJS.ProcessEnv = { ...environment, FORCE_COLOR: "0", NO_COLOR: "1" }; const platform = yield* HostProcess.Platform; const uid = yield* HostProcess.UserId; if (env.ANDROID_HOME) { const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; const emulator = path.join( env.ANDROID_HOME, "emulator", platform === "win32" ? "emulator.exe" : "emulator", ); const cameraArgs = yield* Effect.forEach(["front", "back"], (facing) => runner .run({ command: emulator, args: [`-help-camera-${facing}`], env, timeout: Duration.seconds(5), }) .pipe( Effect.map((result) => { const help = `${result.stdout}\n${result.stderr}`; // The hub wires imagefile cameras unconditionally; older SDK emulators reject that mode. return result.code === 0 && /\bemulated\b/.test(help) && !/\bimagefile\b/.test(help) ? `-camera-${facing} emulated` : ""; }), Effect.orElseSucceed(() => ""), ), ); const overrides = cameraArgs.filter(Boolean).join(" "); if (overrides) { env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS = [overrides, env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS] .filter(Boolean) .join(" "); } } if (platform === "linux" && env.XDG_RUNTIME_DIR === undefined && uid !== undefined) { // SSH sessions may omit the directory where the emulator publishes its gRPC token. const runtimeDir = `/run/user/${uid}`; const fs = yield* FileSystem.FileSystem; const stat = yield* fs.stat(runtimeDir).pipe(Effect.option); if ( stat._tag === "Some" && stat.value.type === "Directory" && Option.contains(stat.value.uid, uid) ) { env.XDG_RUNTIME_DIR = runtimeDir; } } return env; }); /** Only a Mac can list Apple simulators; elsewhere the hub's attempt surfaces as an error. */ const hubPlatformArguments = (hostPlatform: NodeJS.Platform) => hostPlatform === "darwin" ? [] : ["--platform", "android"]; export const make = Effect.fn("LocalDeviceHost.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const config = yield* ServerConfig.ServerConfig; const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const net = yield* NetService.NetService; const runner = yield* ProcessRunner.ProcessRunner; const httpClient = yield* HttpClient.HttpClient; const environment = yield* HostProcess.Environment; const workingDirectory = yield* HostProcess.WorkingDirectory; const hostPlatform = yield* HostProcess.Platform; // Resolved on use: the SDK or a JDK may be installed while the server runs. const androidTools = Effect.all([androidSdk, androidJava]).pipe( Effect.map(([sdk, javaHome]) => ({ sdk, environment: deviceHostEnvironment(environment, sdk.root, javaHome, hostPlatform, path), })), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(HostProcess.Environment, environment), Effect.provideService(HostProcess.Platform, hostPlatform), ); const hostEnvironment = Effect.map(androidTools, (tools) => tools.environment); const startLock = yield* Semaphore.make(1); const runningRef = yield* Ref.make(null); const restartDelayRef = yield* Ref.make(0); const hostId = LOCAL_DEVICE_HOST_ID; const platformAvailability = Effect.fn("LocalDeviceHost.platformAvailability")(function* ( platform: DevicePlatform, ): Effect.fn.Return { const reason = yield* platformReason(platform).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); return reason === null ? { platform, available: true } : { platform, available: false, reason }; }); const summary: Effect.Effect = Effect.gen(function* () { const [platforms, hubInstalled, agentDeviceInstalled] = yield* Effect.all([ Effect.all([platformAvailability("ios"), platformAvailability("android")]), isDeviceHubInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ), isAgentDeviceInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ), ]); const running = yield* Ref.get(runningRef); const daemon = running?.agentDevice ? yield* readDaemonFile().pipe(Effect.option) : Option.none(); const hubAlive = running ? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)) : false; const agentAlive = Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false; const tools = yield* deviceToolVersions(config.baseDir, { ...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}), ...(agentAlive && Option.isSome(daemon) && daemon.value.version ? { agent: daemon.value.version } : {}), }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); return { tools, id: hostId, kind: "local", label: LOCAL_DEVICE_HOST_LABEL, platforms, hubInstalled, agentDeviceInstalled, }; }); const stopHub = (hub: HubProcess | undefined) => hub ? Scope.close(hub.scope, Exit.void).pipe(Effect.ignore) : Effect.void; const hubStatePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "hub.json"); const isProcessAlive = (pid: number) => Effect.sync(() => { try { process.kill(pid, 0); return true; } catch { return false; } }); // Check PID and entry path before reaping; the PID may have been recycled. const reapStaleHub = Effect.gen(function* () { const previous = yield* fs .readFileString(hubStatePath()) .pipe(Effect.flatMap(decodeHubStateFile), Effect.option); if (previous._tag === "None") return; const alive = yield* isProcessAlive(previous.value.pid); if (alive) { const commandLine = yield* runner .run({ command: "ps", args: ["-o", "command=", "-p", String(previous.value.pid)], timeout: Duration.seconds(5), timeoutBehavior: "timedOutResult", }) .pipe( Effect.map((result) => result.stdout), Effect.orElseSucceed(() => ""), ); if (commandLine.includes(previous.value.entryPath)) { yield* Effect.logWarning("Stopping a device hub left behind by a previous server", { pid: previous.value.pid, port: previous.value.port, }); yield* Effect.sync(() => { try { process.kill(previous.value.pid, "SIGTERM"); } catch {} }); } } yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); }).pipe(Effect.ignoreCause); const recordHub = (hub: HubProcess, hubTool: DeviceToolPaths) => encodeHubStateFile({ pid: Number(hub.child.pid), port: Number(new URL(hub.origin).port), entryPath: hubTool.entryPath, }).pipe( Effect.flatMap((contents) => writeFileStringAtomically({ filePath: hubStatePath(), contents }), ), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.ignore, ); const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* ( hubTool: DeviceToolPaths, nodePath: string, ): Effect.fn.Return { yield* reapStaleHub; yield* fs .makeDirectory(agentDeviceStateDir(path, config.stateDir), { recursive: true }) .pipe(Effect.ignore); const port = yield* net.reserveLoopbackPort("127.0.0.1").pipe( Effect.mapError( (cause) => new DeviceHost.DeviceHostError({ hostId, step: "reserving a port for the device hub", cause, }), ), ); const origin = `http://127.0.0.1:${port}`; const scope = yield* Scope.make("sequential"); // The hub is not recorded yet; stop it on failure or interruption from spawn onward. return yield* Effect.gen(function* () { const child = yield* spawner .spawn( ChildProcess.make( nodePath, [ hubTool.entryPath, "--port", String(port), "--host", "127.0.0.1", "--hide-sidebar", "--hide-boot-device", ...hubPlatformArguments(hostPlatform), ], { cwd: workingDirectory, detached: false, shell: false, stdout: "pipe", stderr: "pipe", env: yield* hubEnvironment(yield* hostEnvironment).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provideService(HostProcess.Platform, hostPlatform), ), }, ), ) .pipe( Effect.provideService(Scope.Scope, scope), Effect.mapError( (cause) => new DeviceHost.DeviceHostError({ hostId, step: "starting the device hub", cause, }), ), ); const startedAtMillis = yield* Clock.currentTimeMillis; const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath }; yield* Effect.forkIn(observeHubOutput(hub), scope); yield* waitForHttpReady({ baseUrl: origin, path: "/readyz", timeoutMs: HUB_READY_TIMEOUT_MS, makeError: (info) => new DeviceHost.DeviceHostError({ hostId, step: "waiting for the device hub to answer", cause: info.cause, }), }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); yield* recordHub(hub, hubTool); yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port }); return hub; }).pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore))); }); const observeHubOutput = (hub: HubProcess) => hub.child.all.pipe( Stream.decodeText(), Stream.splitLines, Stream.map((line) => line.trim()), Stream.filter((line) => line.length > 0), Stream.runForEach((line) => Effect.logDebug("Device hub output", { pid: Number(hub.child.pid), output: line }), ), Effect.ignoreCause, ); // Waits for the hub's lifetime and re-forks on restart; a span would nest across restarts. const superviseHub: (hub: HubProcess, hubTool: DeviceToolPaths) => Effect.Effect = Effect.fnUntraced( function* (hub: HubProcess, hubTool: DeviceToolPaths) { yield* Effect.result(hub.child.exitCode); const running = yield* Ref.get(runningRef); if (running?.hub.child.pid !== hub.child.pid) return; const uptime = (yield* Clock.currentTimeMillis) - hub.startedAtMillis; const delay = yield* Ref.modify(restartDelayRef, (current) => { if (uptime >= HUB_RESTART_STABLE_UPTIME_MS) return [0, 0]; const next = current === 0 ? 1_000 : Math.min(current * 2, HUB_RESTART_MAX_DELAY_MS); return [current, next]; }); yield* Effect.logWarning("Device hub exited; restarting", { pid: Number(hub.child.pid), delayMs: delay, }); yield* Effect.sleep(Duration.millis(delay)); yield* startLock.withPermits(1)( Effect.gen(function* () { const current = yield* Ref.get(runningRef); if (current?.hub.child.pid !== hub.child.pid) return; const replacement = yield* spawnHub(hubTool, hub.nodePath); yield* Ref.set(runningRef, { ...current, hub: replacement }); yield* Effect.forkDetach(superviseHub(replacement, hubTool)); }), ); }, Effect.catchCause((cause) => Effect.logWarning("Device hub supervisor failed", { cause })), ); const daemonFilePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "daemon.json"); const readDaemonFile = Effect.fn("LocalDeviceHost.readDaemonFile")(function* () { const raw = yield* fs.readFileString(daemonFilePath()); return yield* decodeDaemonFile(raw); }); const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* ( agentTool: Pick, nodePath: string, ): Effect.fn.Return< DeviceHost.AgentDeviceEndpoint, | DeviceHost.DeviceHostError | DeviceHost.DeviceHostTimeoutError | DeviceHost.AgentDeviceStartError > { const stateDir = agentDeviceStateDir(path, config.stateDir); yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore); const existing = yield* readDaemonFile().pipe(Effect.option); const daemonEnvironment: NodeJS.ProcessEnv = { ...(yield* hostEnvironment), AGENT_DEVICE_STATE_DIR: stateDir, AGENT_DEVICE_DAEMON_SERVER_MODE: "http", // Disable the five-minute idle timeout; the server owns shutdown. AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0", AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1", FORCE_COLOR: "0", NO_COLOR: "1", }; const toEndpoint = ( file: typeof AgentDeviceDaemonFile.Type, ): DeviceHost.AgentDeviceEndpoint => ({ baseUrl: `http://127.0.0.1:${file.httpPort}`, token: file.token, entryPath: agentTool.entryPath, }); if (existing._tag === "Some") { const alive = yield* HttpClient.withScope(httpClient) .get(`http://127.0.0.1:${existing.value.httpPort}/health`) .pipe( Effect.timeout(Duration.seconds(2)), Effect.flatMap((response) => response.arrayBuffer.pipe(Effect.as(response.status === 200)), ), Effect.scoped, Effect.orElseSucceed(() => false), ); if (alive) return toEndpoint(existing.value); yield* fs.remove(daemonFilePath(), { force: true }).pipe(Effect.ignore); } // No `daemon start` exists; `devices` is the cheapest command that starts and awaits it. const runResult = yield* runner .run({ command: nodePath, args: [agentTool.entryPath, "devices", "--json"], env: daemonEnvironment, timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS), timeoutBehavior: "timedOutResult", }) .pipe(Effect.result); // Discovery may fail after startup; accept a published endpoint or preserve the CLI failure. const started = yield* readDaemonFile().pipe(Effect.option); if (started._tag === "Some") return toEndpoint(started.value); if (runResult._tag === "Failure") { return yield* new DeviceHost.DeviceHostError({ hostId, step: "starting agent tools", cause: runResult.failure, }); } const result = runResult.success; if (result.timedOut) { return yield* new DeviceHost.DeviceHostTimeoutError({ hostId, timeoutMs: DAEMON_READY_TIMEOUT_MS, }); } if (result.code !== 0) { const failure = decodeAgentDeviceFailure(result.stdout); const details = Option.isSome(failure) ? failure.value.error.details : undefined; const reason = details?.reason === "daemon_registration_unproven" ? details.inspection?.reason === "non-directory" ? "daemon_lock_not_directory" : "daemon_registration_unproven" : details?.reason === "daemon_retirement_unconfirmed" ? "daemon_retirement_unconfirmed" : "command_failed"; return yield* new DeviceHost.AgentDeviceStartError({ hostId, stateDir, exitCode: result.code === null ? null : Number(result.code), reason, cause: result, }); } const deadline = (yield* Clock.currentTimeMillis) + DAEMON_READY_TIMEOUT_MS; while (true) { const file = yield* readDaemonFile().pipe(Effect.option); if (file._tag === "Some") return toEndpoint(file.value); if ((yield* Clock.currentTimeMillis) > deadline) { return yield* new DeviceHost.DeviceHostTimeoutError({ hostId, timeoutMs: DAEMON_READY_TIMEOUT_MS, }); } yield* Effect.sleep(Duration.millis(DAEMON_POLL_MS)); } }); const stopAgentDeviceDaemon = ( agentTool: { readonly entryPath: string; readonly nodePath: string } | null, ) => agentTool ? hostEnvironment.pipe( Effect.flatMap((env) => runner.run({ command: agentTool.nodePath, args: [ agentTool.entryPath, "daemon", "stop", "--state-dir", agentDeviceStateDir(path, config.stateDir), ], env: { ...env, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, timeout: Duration.seconds(10), timeoutBehavior: "timedOutResult", }), ), Effect.ignore, ) : Effect.void; let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ): Effect.fn.Return { const running = yield* Ref.get(runningRef); // A dead hub's record stays until a replacement is published: the agent // daemon outlives the hub, and close needs its endpoint if the restart fails. if (running) { const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)); if (alive) return running; } const nodePath = yield* resolveNodeExecutable( "Local device support", yield* hostEnvironment, ).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(HostProcess.Platform, hostPlatform), ); const installed = yield* isDeviceHubInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); if (!installed) { const inventory = yield* summary; yield* onPhase("installing", deviceToolInstallMessage("device hub", inventory.tools?.hub)); } const hubTool = yield* ensureDeviceHub(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.mapError( (cause) => new DeviceHost.DeviceHostError({ hostId, step: "installing device support", cause, }), ), ); yield* onPhase("starting"); const hub = yield* spawnHub(hubTool, nodePath); const next = yield* Effect.gen(function* () { yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe( Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.ignore, ); const candidate = helperPaths(hubTool); const [axExists, cliExists] = yield* Effect.all([ fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)), ]); const next: RunningHost = { hub, agentDevice: running?.agentDevice ?? null, helpers: { serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null, serveSimCli: cliExists ? candidate.serveSimCli : null, }, }; yield* Ref.set(runningRef, next); return next; }).pipe(Effect.onError(() => stopHub(hub))); yield* Ref.set(restartDelayRef, 0); yield* Effect.forkDetach(superviseHub(hub, hubTool)); return next; }); const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) => startLock.withPermits(1)(ensureHubReady(onPhase).pipe(Effect.map(toReady))); const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) => startLock.withPermits(1)( Effect.gen(function* (): Generator< Effect.Effect< unknown, | DeviceHost.DeviceHostError | DeviceHost.DeviceHostTimeoutError | DeviceHost.AgentDeviceStartError | NodeRuntimeUnavailableError >, DeviceHost.DeviceHostAgentReady > { const running = yield* ensureHubReady(onPhase); if (running.agentDevice) { const agentDevice = yield* startAgentDeviceDaemon( running.agentDevice, running.hub.nodePath, ); yield* Ref.set(runningRef, { ...running, agentDevice }); return { ...toReady(running), agentDevice }; } const installed = yield* isAgentDeviceInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); if (!installed) { const inventory = yield* summary; yield* onPhase( "installing", deviceToolInstallMessage("agent tools", inventory.tools?.agent), ); } const agentTool = yield* ensureAgentDevice(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.mapError( (cause) => new DeviceHost.DeviceHostError({ hostId, step: "installing agent tools", cause, }), ), ); agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; yield* onPhase("starting"); const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe( Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.ignore, ); const next = { ...running, agentDevice }; yield* Ref.set(runningRef, next); return { ...toReady(next), agentDevice }; }), ); const helperPaths = (hubTool: DeviceToolPaths) => { const serveSimDist = path.join( hubTool.installDir, "node_modules", "expo-device-hub", "vendor", "serve-sim", "dist", ); return { serveSimAxSettings: path.join(serveSimDist, "simax", "serve-sim-ax-settings"), serveSimCli: path.join(serveSimDist, "serve-sim.js"), }; }; const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) => androidTools.pipe( Effect.flatMap(({ sdk, environment: env }) => runner.run({ command: command === "emulator" && sdk.root ? path.join( sdk.root, "emulator", hostPlatform === "win32" ? "emulator.exe" : "emulator", ) : command, args, cwd: workingDirectory, env, timeout: Duration.millis(options?.timeoutMs ?? 20_000), timeoutBehavior: "timedOutResult", ...(options?.stdin === undefined ? {} : { stdin: options.stdin }), }), ), Effect.map((result) => ({ stdout: result.stdout, stderr: result.stderr, code: result.timedOut ? 124 : Number(result.code ?? 127), })), Effect.catch((cause) => Effect.succeed({ stdout: "", stderr: String(cause), code: 127 })), ); const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({ hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint, devices: makeDeviceHubOperations({ hostId, origin: running.hub.origin, http: httpClient, run, androidAvailable: platformAvailability("android").pipe( Effect.map((availability) => availability.available), ), }), nodePath: running.hub.nodePath, run, helpers: running.helpers, ...(running.agentDevice ? { releaseAgentSession: releaseAgentDeviceSession(httpClient, running.agentDevice) } : {}), }); const current: DeviceHost.DeviceHost["Service"]["current"] = Ref.get(runningRef).pipe( Effect.map((running) => (running ? toReady(running) : null)), ); const stopAgent: DeviceHost.DeviceHost["Service"]["stopAgent"] = startLock.withPermits(1)( Effect.gen(function* () { yield* stopAgentDeviceDaemon(agentToolRef); yield* Ref.update(runningRef, (running) => running ? { ...running, agentDevice: null } : running, ); }), ); const stop: DeviceHost.DeviceHost["Service"]["stop"] = startLock.withPermits(1)( Effect.gen(function* () { const running = yield* Ref.getAndSet(runningRef, null); yield* stopHub(running?.hub); yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); yield* stopAgentDeviceDaemon(agentToolRef); }), ); yield* Effect.addFinalizer(() => stop); const host: DeviceHost.DeviceHost["Service"] = { id: hostId, label: LOCAL_DEVICE_HOST_LABEL, summary, platformAvailability, ensureReady, ensureAgentReady, current, stopAgent, stop, }; return host; }); export const layer = Layer.effect(DeviceHost.DeviceHost, make()); export const __testing = { AgentDeviceDaemonFile, androidSdk, androidJava, platformReason, deviceHostEnvironment, hubEnvironment, };