import { pruneLocalDeviceTools } from "./deviceToolMaintenance.ts";
import { deviceToolInstallMessage } from "@t3tools/contracts";
/**
* The device host that is this machine.
*
* Runs expo-device-hub as a supervised child on a loopback port and starts the
* agent-device daemon in HTTP mode under a T3-owned state directory. Both are
* lazy: the device service requires explicit setup consent before it calls
* ensureReady to install tools or start helper processes.
*
* The hub runs in its standalone mode (origin root). The T3 proxy strips its
* own prefix, and the Device panel derives stream and socket URLs from the
* prefix itself rather than from anything the hub prints.
*/
import {
type DeviceHostSummary,
type DevicePlatform,
type DevicePlatformAvailability,
LOCAL_DEVICE_HOST_ID,
} from "@t3tools/contracts";
import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import {
resolveNodeExecutable,
type NodeRuntimeUnavailableError,
} from "@t3tools/shared/nodeRuntime";
import * as NetService from "@t3tools/shared/Net";
import { isCommandAvailable } 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 Path from "effect/Path";
import * as Option from "effect/Option";
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/unstable/http";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
import * as ServerConfig from "../config.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as DeviceHost from "./DeviceHost.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;
/**
* Written beside the agent-device state so a server that dies without running
* its finalizers (SIGKILL, dev-runner restarts) does not leave a hub bound to
* a loopback port forever. The next start reads it, kills only a process that
* is still that hub, and replaces the file.
*/
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));
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<string | null, never, FileSystem.FileSystem | Path.Path> {
const hostPlatform = yield* HostProcessPlatform;
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.`;
}
return null;
});
/** Resolve the SDK once for both diagnostics and the environment passed to helpers. */
const androidSdk = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const environment = yield* HostProcessEnvironment;
const platform = yield* HostProcessPlatform;
const home = environment.HOME ?? environment.USERPROFILE ?? "";
const explicit = environment.ANDROID_HOME?.trim() || environment.ANDROID_SDK_ROOT?.trim();
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",
),
];
if (!explicit) {
for (const directory of (environment.PATH ?? "").split(platform === "win32" ? ";" : ":")) {
if (!directory) continue;
const resolved = yield* fs
.realPath(path.join(directory, platform === "win32" ? "adb.exe" : "adb"))
.pipe(Effect.option);
if (resolved._tag === "Some") candidates.push(path.dirname(path.dirname(resolved.value)));
}
}
const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false));
for (const root of candidates) {
const adb = yield* exists(
path.join(root, "platform-tools", platform === "win32" ? "adb.exe" : "adb"),
);
const emulator = yield* exists(
path.join(root, "emulator", platform === "win32" ? "emulator.exe" : "emulator"),
);
if (explicit || adb || emulator) {
const avdmanager = yield* exists(
path.join(
root,
"cmdline-tools",
"latest",
"bin",
platform === "win32" ? "avdmanager.bat" : "avdmanager",
),
);
const legacyAvdmanager =
!avdmanager &&
(yield* exists(
path.join(root, "tools", "bin", platform === "win32" ? "avdmanager.bat" : "avdmanager"),
));
return { root, adb, emulator, avdmanager, legacyAvdmanager };
}
}
return { root: null, adb: false, emulator: false, avdmanager: false, legacyAvdmanager: false };
});
const deviceHostEnvironment = (
environment: NodeJS.ProcessEnv,
sdkRoot: string | null,
hostPlatform: NodeJS.Platform,
path: Path.Path,
): NodeJS.ProcessEnv => {
return sdkRoot
? {
...environment,
ANDROID_HOME: sdkRoot,
PATH: [
path.join(sdkRoot, "platform-tools"),
path.join(sdkRoot, "emulator"),
environment.PATH ?? environment.Path ?? "",
].join(hostPlatform === "win32" ? ";" : ":"),
}
: environment;
};
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* HostProcessEnvironment;
const hostPlatform = yield* HostProcessPlatform;
const sdk = yield* androidSdk;
const hostEnvironment = deviceHostEnvironment(environment, sdk.root, hostPlatform, path);
const startLock = yield* Semaphore.make(1);
const runningRef = yield* Ref.make<RunningHost | null>(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<DevicePlatformAvailability> {
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<DeviceHostSummary> = 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: "This machine",
platforms,
hubInstalled,
agentDeviceInstalled,
};
});
const hubEnvironment = (): NodeJS.ProcessEnv => ({
...hostEnvironment,
FORCE_COLOR: "0",
NO_COLOR: "1",
});
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;
}
});
/**
* A hub left behind by a previous server is identified by pid plus the
* command line's entry path, so a recycled pid belonging to something else
* is never touched.
*/
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 {
// Already gone.
}
});
}
}
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((json) => fs.writeFileString(hubStatePath(), json)),
Effect.ignore,
);
const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* (
hubTool: DeviceToolPaths,
nodePath: string,
): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> {
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");
const child = yield* spawner
.spawn(
ChildProcess.make(
nodePath,
[
hubTool.entryPath,
"--port",
String(port),
"--host",
"127.0.0.1",
"--hide-sidebar",
"--hide-boot-device",
],
{
detached: false,
shell: false,
stdout: "pipe",
stderr: "pipe",
env: hubEnvironment(),
},
),
)
.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),
Effect.tapError(() => stopHub(hub)),
);
yield* recordHub(hub, hubTool);
yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port });
return hub;
});
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,
);
/**
* Restart the hub when it dies under us, with the same doubling backoff the
* relay connector uses so a hub that crashes on boot cannot spin.
*/
const superviseHub = (hub: HubProcess, hubTool: DeviceToolPaths): Effect.Effect<void> =>
Effect.gen(function* () {
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));
}),
);
}).pipe(
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);
});
/**
* agent-device auto-starts its daemon on any command. A trivial `devices`
* call in HTTP mode is the documented way to bring it up; its output is the
* daemon.json this reads back.
*/
const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* (
agentTool: DeviceToolPaths,
nodePath: string,
): Effect.fn.Return<DeviceHost.AgentDeviceEndpoint, DeviceHost.DeviceHostTimeoutError> {
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 = {
...hostEnvironment,
AGENT_DEVICE_STATE_DIR: stateDir,
AGENT_DEVICE_DAEMON_SERVER_MODE: "http",
// The daemon idles out after five minutes by default; the server owns
// its lifetime here and stops it explicitly.
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);
}
// There is no `daemon start`; the first command in a state dir spawns the
// daemon and blocks until it answers. `devices` is the cheapest one.
yield* runner
.run({
command: nodePath,
args: [agentTool.entryPath, "devices", "--json"],
env: daemonEnvironment,
timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS),
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.ignore);
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
? runner
.run({
command: agentTool.nodePath,
args: [
agentTool.entryPath,
"daemon",
"stop",
"--state-dir",
agentDeviceStateDir(path, config.stateDir),
],
env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" },
timeout: Duration.seconds(10),
timeoutBehavior: "timedOutResult",
})
.pipe(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<void>,
): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError | NodeRuntimeUnavailableError> {
const running = yield* Ref.get(runningRef);
if (running) {
const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false));
if (alive) return running;
yield* Ref.set(runningRef, null);
}
const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
Effect.provideService(HostProcessPlatform, 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);
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: null,
helpers: {
serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null,
serveSimCli: cliExists ? candidate.serveSimCli : null,
},
};
yield* Ref.set(runningRef, next);
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
| NodeRuntimeUnavailableError
>,
DeviceHost.DeviceHostAgentReady
> {
const running = yield* ensureHubReady(onPhase);
if (running.agentDevice) return { ...toReady(running), agentDevice: 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) =>
runner
.run({
command:
command === "emulator" && sdk.root
? path.join(
sdk.root,
"emulator",
hostPlatform === "win32" ? "emulator.exe" : "emulator",
)
: command,
args,
env: hostEnvironment,
timeout: Duration.millis(options?.timeoutMs ?? 20_000),
timeoutBehavior: "timedOutResult",
...(options?.stdin === undefined ? {} : { stdin: options.stdin }),
})
.pipe(
Effect.map((result) => ({
stdout: result.stdout,
stderr: result.stderr,
code: Number(result.code),
})),
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,
nodePath: running.hub.nodePath,
run,
helpers: running.helpers,
});
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);
}),
);
// Never leave the hub or daemon behind when the server's scope closes.
yield* Effect.addFinalizer(() => stop);
const host: DeviceHost.DeviceHost["Service"] = {
id: hostId,
summary,
platformAvailability,
ensureReady,
ensureAgentReady,
current,
stopAgent,
stop,
};
return host;
});
export const layer = Layer.effect(DeviceHost.DeviceHost, make());
/** Exposed for tests. */
export const __testing = {
AgentDeviceDaemonFile,
androidSdk,
platformReason,
deviceHostEnvironment,
};