apps/server/src/device/SimlockDeviceHost.ts
/**
* Alpha: devices leased from a Simlock gateway the user runs. Opening a device
* takes a lease through the gateway's agent-device front, so agents drive it
* with the same session; closing it returns the device to the pool, wiped.
* A lease left by a server that crashed ends one TTL after its last renewal.
*/
import {
DeviceBootError,
type DeviceHostSummary,
DeviceHostUnavailableError,
DeviceOperationError,
type DevicePlatform,
type SimlockDeviceHostConfig,
} from "@t3tools/contracts";
import * as Clock from "effect/Clock";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FiberMap from "effect/FiberMap";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Random from "effect/Random";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
import * as HostProcess from "@t3tools/shared/HostProcess";
import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
import * as ServerConfig from "../config.ts";
import * as ProcessRunner from "../processRunner.ts";
import { writeAgentDeviceConfig } from "./AgentDeviceTarget.ts";
import * as DeviceHost from "./DeviceHost.ts";
const CATALOG_TTL_MS = 60_000;
const REQUEST_TIMEOUT = Duration.seconds(15);
// The gateway queues a lease request for up to five minutes when every device is busy.
const OPEN_TIMEOUT = Duration.minutes(7);
const COMMAND_TIMEOUT = Duration.minutes(2);
// Renewal keeps a watched but idle device; a lease this server stops renewing ends after LEASE_TTL.
const RENEW_INTERVAL = Duration.minutes(2);
const LEASE_TTL_MS = 10 * 60_000;
const STREAM_PATH = /^\/vendor\/serve-sim\/helper\/([^/]+)\/stream\.avcc$/;
const INPUT_PATH = "/vendor/serve-sim/helper/ws";
const Catalog = Schema.Struct({
platforms: Schema.Array(
Schema.Struct({
platform: Schema.String,
modelRuntimes: Schema.Record(Schema.String, Schema.Array(Schema.String)),
modelClasses: Schema.optional(Schema.Record(Schema.String, Schema.String)),
modelWorkers: Schema.optional(Schema.Record(Schema.String, Schema.Array(Schema.String))),
}),
),
});
const decodeCatalog = Schema.decodeUnknownEffect(Catalog);
const decodeSessionRecord = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ leaseId: Schema.String })),
);
const decodeCliFailure = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({
error: Schema.Struct({ message: Schema.String, hint: Schema.optional(Schema.String) }),
}),
),
);
interface Lease {
readonly leaseId: string;
readonly platform: DevicePlatform;
readonly name: string;
readonly version: string;
readonly session: string;
readonly dir: string;
readonly configPath: string;
readonly installDirectory: string;
}
const catalogId = (platform: DevicePlatform, model: string) => `simlock:${platform}:${model}`;
const parseCatalogId = (id: string) => {
const match = /^simlock:(ios|android):(.+)$/.exec(id);
return match ? { platform: match[1] as DevicePlatform, model: match[2]! } : null;
};
const platformVersion = (platform: DevicePlatform, runtime: string | undefined) =>
`${platform === "ios" ? "iOS" : "Android"}${runtime ? ` ${runtime}` : ""}`;
/** T3's own agent-device CLI, installed on first use. */
export type AgentDeviceCli = Effect.Effect<
{ readonly nodePath: string; readonly entryPath: string },
DeviceHost.DeviceHostError | NodeRuntimeUnavailableError
>;
export const make = Effect.fn("SimlockDeviceHost.make")(function* (
config: SimlockDeviceHostConfig,
agentDeviceCli: AgentDeviceCli,
) {
const http = yield* HttpClient.HttpClient;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const runner = yield* ProcessRunner.ProcessRunner;
const server = yield* ServerConfig.ServerConfig;
const services = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
const environment = yield* HostProcess.Environment;
const hostId = config.id;
const gateway = config.gatewayUrl.replace(/\/$/, "");
const agentDeviceUrl = `${gateway}/v1/agent-device`;
const hostDir = path.join(server.stateDir, "device", "simlock", hostId);
const leases = new Map<string, Lease>();
let starting = 0;
// Bumped by every stop, so an open that finishes after one returns its lease instead of keeping it.
let stops = 0;
const slotLock = yield* Semaphore.make(1);
const keepAlive = yield* FiberMap.make<string>();
let catalog: {
readonly at: number;
readonly devices: ReadonlyArray<DeviceHost.HostDevice>;
} | null = null;
let ready: DeviceHost.DeviceHostReady | null = null;
let cli: { readonly nodePath: string; readonly entryPath: string } | null = null;
const unavailable = (reason: string, cause?: unknown) =>
new DeviceHostUnavailableError({
hostId,
hostLabel: config.label,
reason,
...(cause === undefined ? {} : { cause }),
});
const request = (
method: "GET" | "POST" | "DELETE",
route: string,
body?: Readonly<Record<string, unknown>>,
) =>
http
.execute(
(method === "GET"
? HttpClientRequest.get(`${gateway}${route}`)
: method === "POST"
? HttpClientRequest.post(`${gateway}${route}`).pipe(
HttpClientRequest.bodyJsonUnsafe(body ?? {}),
)
: HttpClientRequest.delete(`${gateway}${route}`)
).pipe(HttpClientRequest.bearerToken(config.agentToken)),
)
.pipe(
Effect.flatMap(HttpClientResponse.filterStatusOk),
Effect.flatMap((response) => response.json),
Effect.scoped,
Effect.timeout(REQUEST_TIMEOUT),
);
const agentEnvironment: NodeJS.ProcessEnv = Object.fromEntries(
Object.entries({
...environment,
ELECTRON_RUN_AS_NODE: "1",
AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1",
NO_COLOR: "1",
FORCE_COLOR: "0",
}).filter(
([name]) =>
!/^AGENT_DEVICE_(DAEMON_BASE_URL|DAEMON_AUTH_TOKEN|CONFIG|STATE_DIR|SESSION)$/.test(name),
),
);
/** Runs T3's own agent-device CLI against the lease's session; its failure message is the reason. */
const agentDevice = Effect.fn("SimlockDeviceHost.agentDevice")(function* (
lease: Pick<Lease, "session" | "configPath">,
args: ReadonlyArray<string>,
options: { readonly cwd?: string; readonly timeout?: Duration.Input } = {},
) {
if (!cli) return yield* unavailable(`${config.label} is not ready.`);
const result = yield* runner
.run({
command: cli.nodePath,
args: [
cli.entryPath,
...args,
"--session",
lease.session,
"--config",
lease.configPath,
"--json",
],
env: agentEnvironment,
...(options.cwd ? { cwd: options.cwd } : {}),
timeout: options.timeout ?? COMMAND_TIMEOUT,
})
.pipe(Effect.mapError((cause) => unavailable("agent-device could not run.", cause)));
if (result.code !== 0) {
const failure = decodeCliFailure(result.stdout);
return yield* unavailable(
Option.match(failure, {
onNone: () => `agent-device ${args[0]} failed (exit code ${result.code ?? "none"}).`,
onSome: ({ error }) => (error.hint ? `${error.message} ${error.hint}` : error.message),
}),
);
}
return result.stdout;
});
const loadCatalog = Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
if (catalog && now - catalog.at < CATALOG_TTL_MS) return catalog.devices;
if (!config.agentToken) {
return yield* unavailable(`Add an agent token for ${config.label} in Settings.`);
}
const decoded = yield* request("GET", "/v1/catalog").pipe(
Effect.flatMap(decodeCatalog),
Effect.mapError((cause) =>
unavailable(
`Could not reach the Simlock gateway at ${gateway}, or it refused the agent token.`,
cause,
),
),
);
const devices = decoded.platforms.flatMap((entry) => {
if (entry.platform !== "ios" && entry.platform !== "android") return [];
const platform: DevicePlatform = entry.platform;
return Object.entries(entry.modelRuntimes).flatMap(([model, runtimes]) => {
const kind = entry.modelClasses?.[model];
const workers = entry.modelWorkers?.[model];
if (runtimes.length === 0 || (workers !== undefined && workers.length === 0)) return [];
if (kind !== undefined && kind !== "phone" && kind !== "tablet") return [];
return [
{
id: catalogId(platform, model),
platform,
name: model,
version: platformVersion(platform, runtimes.at(-1)),
booted: false,
physical: false,
} satisfies DeviceHost.HostDevice,
];
});
});
catalog = { at: now, devices };
return devices;
});
const forget = (lease: Lease) =>
Effect.suspend(() => {
leases.delete(lease.leaseId);
return fs.remove(lease.dir, { recursive: true, force: true }).pipe(Effect.ignore);
});
/** Ends the lease through its session, or over HTTP when the session is gone. */
const release = Effect.fn("SimlockDeviceHost.release")(function* (lease: Lease) {
yield* agentDevice(lease, ["close"]).pipe(
Effect.catch(() => request("DELETE", `/v1/leases/${encodeURIComponent(lease.leaseId)}`)),
Effect.tapError((cause) =>
Effect.logWarning("Could not release Simlock lease", { leaseId: lease.leaseId, cause }),
),
Effect.ignore,
);
yield* forget(lease);
});
/** Renews until the lease ends on its own or reaches its maximum age, then releases it. */
const renewLoop = Effect.fn("SimlockDeviceHost.renewLoop")(function* (lease: Lease) {
const deadline =
(yield* Clock.currentTimeMillis) +
Duration.toMillis(Duration.minutes(config.maxLeaseMinutes));
while (true) {
const left = deadline - (yield* Clock.currentTimeMillis);
yield* Effect.sleep(Duration.millis(Math.min(left, Duration.toMillis(RENEW_INTERVAL))));
if ((yield* Clock.currentTimeMillis) >= deadline) break;
const renewed = yield* request(
"POST",
`/v1/leases/${encodeURIComponent(lease.leaseId)}/renew`,
{ ttlMs: LEASE_TTL_MS },
).pipe(Effect.result);
if (renewed._tag === "Success") continue;
const error = renewed.failure;
// Simlock ended it (lease_lost, an agent's close, or its TTL): stop listing it.
if (
error._tag === "HttpClientError" &&
error.reason._tag === "StatusCodeError" &&
error.reason.response.status === 404
) {
return yield* forget(lease);
}
yield* Effect.logWarning("Could not renew Simlock lease", { leaseId: lease.leaseId, error });
}
yield* Effect.logInfo("Releasing Simlock lease at its maximum age", {
leaseId: lease.leaseId,
});
yield* release(lease);
});
const reserveSlot = slotLock.withPermit(
Effect.suspend(() => {
if (leases.size + starting >= config.maxLeases) {
return Effect.fail(
unavailable(
`${config.label} is limited to ${config.maxLeases} ${config.maxLeases === 1 ? "device" : "devices"} at once. Close one in the Device panel first.`,
),
);
}
starting++;
return Effect.void;
}),
);
const openLease = Effect.fn("SimlockDeviceHost.openLease")(function* (
device: DeviceHost.HostDevice,
context: DeviceHost.DeviceOpenContext | undefined,
) {
const target = parseCatalogId(device.id);
if (!target) {
return yield* new DeviceBootError({
hostId,
deviceId: device.id,
reason: "launch_failed",
cause: new Error("This Simlock device is no longer leased. Open it again from the list."),
});
}
const suffix = (yield* Random.nextIntBetween(0, 0xffffff)).toString(16).padStart(6, "0");
const session = `t3-${suffix}`;
const dir = path.join(hostDir, suffix);
const configPath = path.join(dir, "agent-device.json");
yield* writeAgentDeviceConfig(configPath, {
baseUrl: agentDeviceUrl,
token: config.agentToken,
stateDir: dir,
}).pipe(
Effect.provide(services),
Effect.mapError((cause) => unavailable("Could not write the agent-device config.", cause)),
);
// agent-device ties installs to the directory that opened the session.
const installDirectory = context?.workspaceRoot
? yield* fs.realPath(context.workspaceRoot).pipe(Effect.orElseSucceed(() => dir))
: dir;
const pending = { session, configPath };
const stopsAtOpen = stops;
return yield* Effect.gen(function* () {
yield* agentDevice(pending, ["connect", "proxy", "--daemon-base-url", agentDeviceUrl]);
yield* agentDevice(
pending,
["open", "--platform", target.platform, "--device", target.model],
{ cwd: installDirectory, timeout: OPEN_TIMEOUT },
);
const record = yield* fs
.readFileString(path.join(dir, "remote-connections", `${session}.json`))
.pipe(
Effect.flatMap(decodeSessionRecord),
Effect.mapError((cause) => unavailable("agent-device recorded no Simlock lease.", cause)),
);
const opened: Lease = {
leaseId: record.leaseId,
platform: target.platform,
name: target.model,
version: device.version,
session,
dir,
configPath,
installDirectory,
};
if (stops !== stopsAtOpen)
return yield* unavailable("Device support stopped while the device was opening.");
leases.set(opened.leaseId, opened);
yield* FiberMap.run(keepAlive, opened.leaseId, renewLoop(opened));
return opened.leaseId;
}).pipe(
// A failed or interrupted open may still hold a lease: close the session either way.
Effect.onExit((exit) =>
exit._tag === "Success"
? Effect.void
: agentDevice(pending, ["close"]).pipe(
Effect.ignore,
Effect.andThen(fs.remove(dir, { recursive: true, force: true }).pipe(Effect.ignore)),
),
),
Effect.mapError((error) =>
error._tag === "DeviceHostUnavailableError"
? new DeviceBootError({
hostId,
deviceId: device.id,
reason: "launch_failed",
cause: new Error(error.reason),
})
: error,
),
);
});
const leaseFor = (deviceId: string) => Option.fromNullishOr(leases.get(deviceId));
const operationError = (operation: string) => (cause: unknown) =>
new DeviceOperationError({ operation, reason: "request_failed", cause });
const screenshotLease = Effect.fn("SimlockDeviceHost.screenshot")(
function* (lease: Lease) {
const file = path.join(
lease.dir,
`screenshot-${(yield* Random.nextIntBetween(0, 0xffffff)).toString(16)}.png`,
);
yield* agentDevice(lease, ["screenshot", file]);
return yield* fs
.readFile(file)
.pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)));
},
Effect.mapError(operationError("screenshot")),
);
const devices: DeviceHost.DeviceOperations = {
list: loadCatalog.pipe(
Effect.mapError(operationError("list")),
Effect.map((available) => ({
devices: [
...available,
...[...leases.values()].map((lease): DeviceHost.HostDevice => ({
id: lease.leaseId,
platform: lease.platform,
name: lease.name,
version: lease.version,
booted: true,
physical: false,
})),
],
detail: undefined,
})),
),
boot: (device, context) =>
leases.has(device.id)
? Effect.succeed(device.id)
: Effect.acquireUseRelease(
reserveSlot,
() => openLease(device, context),
() => Effect.sync(() => void starting--),
),
attach: () => Effect.void,
shutdown: (device) =>
Option.match(leaseFor(device.id), {
onNone: () => Effect.void,
onSome: (lease) =>
FiberMap.remove(keepAlive, lease.leaseId).pipe(Effect.andThen(release(lease))),
}),
screenshot: (device) =>
Option.match(leaseFor(device.id), {
onNone: () =>
Effect.fail(operationError("screenshot")(new Error("The Simlock lease has ended"))),
onSome: screenshotLease,
}),
agentEndpoint: (deviceId) =>
Option.match(leaseFor(deviceId), {
onNone: () => Effect.fail(unavailable("This Simlock lease has ended.")),
onSome: (lease) =>
Effect.succeed({
baseUrl: agentDeviceUrl,
token: config.agentToken,
agentDeviceId: null,
session: lease.session,
stateDir: lease.dir,
installDirectory: lease.installDirectory,
}),
}),
endsDevices: true,
};
/**
* The client speaks serve-sim to every Simlock lease: its AVCC stream and its input
* socket map to the lease's own stream and input routes, which take the agent token.
*/
const upstream: NonNullable<DeviceHost.DeviceHostReady["upstream"]> = (hubPath, search) =>
Effect.sync(() => {
const stream = STREAM_PATH.exec(hubPath);
let deviceId: string | null;
try {
deviceId = stream
? decodeURIComponent(stream[1]!)
: hubPath === INPUT_PATH
? search.get("device")
: null;
} catch {
return null;
}
const lease = deviceId === null ? undefined : leases.get(deviceId);
if (!lease) return null;
return {
url: `${gateway}/v1/leases/${encodeURIComponent(lease.leaseId)}/${stream ? "stream" : "input"}`,
headers: { authorization: `Bearer ${config.agentToken}` },
protocols: [],
};
});
const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = Effect.fn(
"SimlockDeviceHost.ensureReady",
)(function* (onPhase) {
if (ready) return ready;
// A missing token or unreachable gateway does not stop readiness: listing
// and opening devices say what is wrong instead.
yield* onPhase("installing");
cli = yield* agentDeviceCli;
const { nodePath } = cli;
ready = {
nodePath,
hub: { origin: gateway },
devices,
upstream,
// Simlock relays only simctl, adb, and devicectl, and only to a lease's owner.
run: () =>
Effect.succeed({
stdout: "",
stderr: "Simlock devices cannot run host commands.",
code: 127,
}),
helpers: { serveSimAxSettings: null, serveSimCli: null },
};
return ready;
});
const stopAll = Effect.gen(function* () {
stops++;
yield* FiberMap.clear(keepAlive);
yield* Effect.forEach([...leases.values()], release, { discard: true, concurrency: 4 });
}).pipe(Effect.ignore);
yield* Effect.addFinalizer(() => stopAll);
const platformAvailability = (platform: DevicePlatform) =>
Effect.succeed({ platform, available: true });
const summary: Effect.Effect<DeviceHostSummary> = Effect.succeed({
id: hostId,
label: config.label,
kind: "simlock" as const,
platforms: [
{ platform: "ios" as const, available: true },
{ platform: "android" as const, available: true },
],
hubInstalled: true,
agentDeviceInstalled: true,
});
return DeviceHost.DeviceHost.of({
id: hostId,
label: config.label,
summary,
platformAvailability,
ensureReady,
ensureAgentReady: Effect.fn("SimlockDeviceHost.ensureAgentReady")(function* (onPhase) {
return { ...(yield* ensureReady(onPhase)), agentDevice: null };
}),
current: Effect.sync(() => ready),
stopAgent: Effect.void,
stop: stopAll,
});
});