1025 lines · 37.9 KB · TypeScript
RawDownload
/** * Device discovery, per-thread device sessions, and the state stream clients * render the Device panel from. * * Discovery and boot go through expo-device-hub's JSON API rather than * shelling out to simctl and adb here: the hub already normalizes both * platforms into one device shape and is the process that has to know a * device is booted before it can stream it. Sessions are the server's own * bookkeeping — which thread is looking at which device — so the panel and * the `device_*` tools agree, and so a `device_open` from an agent surfaces in * every connected client the way `preview_open` does. */import {  type DeviceActionInput,  type DeviceCloseInput,  type DeviceConfigureInput,  type DeviceDetail,  type DeviceDetailInput,  type DeviceError,  type DeviceHostId,  type DeviceId,  DeviceBootError,  DeviceHostUnavailableError,  DeviceNotFoundError,  DeviceOperationError,  type DeviceOpenInput,  type DevicePlatform,  DevicePlatformUnavailableError,  type DeviceServiceState,  type DeviceSession,  type DeviceShutdownInput,  type DeviceSummary,  type SshDeviceHostConfig,  type DeviceHostSummary,  LOCAL_DEVICE_HOST_ID,  type ThreadId,} from "@t3tools/contracts";import * as FileSystem from "effect/FileSystem";import * as Path from "effect/Path";import { ensureAgentDevice } from "./DeviceToolchain.ts";import * as ServerConfig from "../config.ts";import {  agentDeviceConfigPath,  agentDeviceSession,  writeAgentDeviceConfig,} from "./AgentDeviceTarget.ts";import * as Context from "effect/Context";import * as DateTime from "effect/DateTime";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as PubSub from "effect/PubSub";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 * as SynchronizedRef from "effect/SynchronizedRef";import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as ServerSettings from "../serverSettings.ts"; import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";import * as ProcessRunner from "../processRunner.ts";import * as DeviceHost from "./DeviceHost.ts";import * as SshDeviceHost from "./SshDeviceHost.ts";import * as Exit from "effect/Exit";import * as LocalDeviceHost from "./LocalDeviceHost.ts"; /** Origin-relative prefix the hub is proxied under. See DeviceHubProxy. */export const DEVICE_HUB_ROUTE_PREFIX = "/api/device-hub"; const BOOT_TIMEOUT = Duration.minutes(3);const SCREENSHOT_TIMEOUT = Duration.seconds(20); const HubDevice = Schema.Struct({  id: Schema.String,  name: Schema.String,  version: Schema.String,  platform: Schema.Literals(["ios", "android"]),  booted: Schema.Boolean,  physical: Schema.Boolean,});const HubDeviceList = Schema.Struct({  simulators: Schema.Array(HubDevice),  emulators: Schema.Array(HubDevice),  errors: Schema.optional(Schema.Array(Schema.Struct({ message: Schema.String }))),});const HubActionResult = Schema.Struct({  ok: Schema.Boolean,  id: Schema.optional(Schema.String),  serial: Schema.optional(Schema.String),  error: Schema.optional(Schema.String),}); export interface DeviceScreenshot {  readonly device: DeviceSummary;  readonly png: Uint8Array;} export interface DeviceReadiness extends DeviceHost.DeviceHostReady {  readonly hostId: DeviceHostId;} export interface DeviceAgentReadiness extends DeviceReadiness {  readonly agentDevice: DeviceHost.AgentDeviceEndpoint;} export class DeviceService extends Context.Service<  DeviceService,  {    readonly agentCli: Effect.Effect<string, DeviceError>;    readonly testHost: (      config: SshDeviceHostConfig,    ) => Effect.Effect<DeviceHostSummary, DeviceError>;    readonly agentTarget: (input: {      threadId: ThreadId;      hostId: DeviceHostId;      deviceId: DeviceId;    }) => Effect.Effect<ReadonlyArray<string>, DeviceError>;    readonly state: Effect.Effect<DeviceServiceState>;    readonly subscribe: Effect.Effect<PubSub.Subscription<DeviceServiceState>, never, Scope.Scope>;    readonly configure: (      input: DeviceConfigureInput,    ) => Effect.Effect<DeviceServiceState, DeviceError>;    /** Refreshes devices only after device support has been enabled. */    readonly list: Effect.Effect<DeviceServiceState, DeviceError>;    readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;    readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>;    readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect<void, DeviceError>;    /** Current settings and foreground app for one device. */    readonly detail: (input: DeviceDetailInput) => Effect.Effect<DeviceDetail, DeviceError>;    /** Runs one action, then returns the refreshed detail. */    readonly action: (input: DeviceActionInput) => Effect.Effect<DeviceDetail, DeviceError>;    readonly screenshot: (input: {      readonly hostId?: DeviceHostId | undefined;      readonly deviceId: DeviceId;    }) => Effect.Effect<DeviceScreenshot, DeviceError>;    /** Host endpoints for the proxy and the provider environment. */    readonly readiness: (hostId?: DeviceHostId) => Effect.Effect<DeviceReadiness, DeviceError>;    /**     * `readiness` only when the host can run at least one platform; a machine     * with no simulator toolchain never installs or starts anything.     */    readonly readinessIfSupported: (      hostId?: DeviceHostId,    ) => Effect.Effect<DeviceReadiness | null, DeviceError>;    readonly agentReadinessIfSupported: (      hostId?: DeviceHostId,    ) => Effect.Effect<DeviceAgentReadiness | null, DeviceError>;    readonly currentReadiness: (hostId?: DeviceHostId) => Effect.Effect<DeviceReadiness | null>;    readonly sessionsForThread: (threadId: ThreadId) => Effect.Effect<ReadonlyArray<DeviceSession>>;  }>()("t3/device/DeviceService") {} interface ServiceState {  readonly state: DeviceServiceState;} const vendorPrefix = (platform: DevicePlatform) =>  platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (  hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]>,  testHost: DeviceService["Service"]["testHost"] = (host) =>    Effect.fail(      new DeviceHostUnavailableError({        hostId: host.id,        reason: "SSH probing is unavailable in this device service.",      }),    ),  configureAgent: (    hostId: DeviceHostId,    ready: DeviceHost.DeviceHostAgentReady,  ) => Effect.Effect<string, DeviceError> = (hostId) =>    Effect.fail(      new DeviceHostUnavailableError({        hostId,        reason: "Agent configuration is unavailable in this device service.",      }),    ),) {  const settings = yield* ServerSettings.ServerSettingsService;  const lifecycleLock = yield* Semaphore.make(1);  const readDeviceSettings = settings.getSettings.pipe(    Effect.map((value) => ({      enabled: value.enableDeviceSupport,      agentAccessEnabled: value.enableAgentDeviceAccess,      onboardingCompleted: value.deviceOnboardingCompleted,    })),    Effect.mapError(      (cause) =>        new DeviceOperationError({ operation: "settings", reason: "settings_failed", cause }),    ),  );  const initialSettings = yield* readDeviceSettings;   const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);  const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();  const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);  let publishedHosts = new Map(hosts);  const stateRef = yield* SynchronizedRef.make<ServiceState>({    state: {      hosts: initialHosts,      hostStatus: initialSettings.enabled ? "idle" : "disabled",      hostStatuses: {},      devices: [],      sessions: [],      onboardingCompleted: initialSettings.onboardingCompleted,      agentAccessEnabled: initialSettings.agentAccessEnabled,      hubBasePath: DEVICE_HUB_ROUTE_PREFIX,      revision: 0,    },  });   const publish = (update: (state: DeviceServiceState) => DeviceServiceState) =>    SynchronizedRef.updateAndGetEffect(stateRef, ({ state }) => {      const next = { ...update(state), revision: state.revision + 1 };      return PubSub.publish(statePubSub, next).pipe(Effect.as({ state: next }));    }).pipe(Effect.map(({ state }) => state));   const resolveHost = (hostId: DeviceHostId | undefined) =>    Effect.gen(function* () {      const id = hostId ?? LOCAL_DEVICE_HOST_ID;      const host = hosts.get(id);      if (!host) {        return yield* new DeviceHostUnavailableError({ hostId: id, reason: "Unknown host." });      }      return host;    });   const setHostStatus = (    hostId: DeviceHostId,    status: DeviceServiceState["hostStatuses"][string],  ) =>    Effect.suspend(() =>      !hosts.has(hostId)        ? SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state))        : publish((state) => ({            ...state,            ...(hostId === LOCAL_DEVICE_HOST_ID              ? { hostStatus: status.status, hostStatusDetail: status.detail }              : {}),            hostStatuses: { ...state.hostStatuses, [hostId]: status },          })),    );   const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(    function* (hostId) {      const host = yield* resolveHost(hostId);      if (!(yield* readDeviceSettings).enabled) {        return yield* new DeviceHostUnavailableError({          hostId: host.id,          reason:            "Device support is off. Enable it in the Device panel before installing or starting device tools.",        });      }      const ready = yield* host        .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid))        .pipe(          Effect.tapError((error) =>            setHostStatus(host.id, { status: "failed", detail: error.message }),          ),          Effect.mapError(            (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),          ),        );      if (hosts.get(host.id) !== host)        return yield* new DeviceHostUnavailableError({          hostId: host.id,          reason: "Host configuration changed. Retry the operation.",        });      const { state } = yield* SynchronizedRef.get(stateRef);      if (state.hostStatuses[host.id]?.status !== "ready") {        yield* setHostStatus(host.id, { status: "ready" });      }      return { hostId: host.id, ...ready };    },    lifecycleLock.withPermit,  );   const readinessIfSupported: DeviceService["Service"]["readinessIfSupported"] = Effect.fn(    "DeviceService.readinessIfSupported",  )(function* (hostId) {    if (!(yield* readDeviceSettings).enabled) return null;    const host = yield* resolveHost(hostId);    const summary = yield* host.summary;    if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))      return null;    return yield* readiness(host.id);  });   const agentReadinessIfSupported: DeviceService["Service"]["agentReadinessIfSupported"] =    Effect.fn("DeviceService.agentReadinessIfSupported")(function* (hostId) {      const deviceSettings = yield* readDeviceSettings;      if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null;      const host = yield* resolveHost(hostId);      const summary = yield* host.summary;      if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))        return null;      const ready = yield* host        .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))        .pipe(          Effect.tapError((error) =>            setHostStatus(host.id, { status: "failed", detail: error.message }),          ),          Effect.mapError(            (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),          ),        );      const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);      yield* publish((state) => ({ ...state, hosts: hostSummaries }));      yield* setHostStatus(host.id, { status: "ready" });      return { hostId: host.id, ...ready };    }, lifecycleLock.withPermit);   const currentReadiness: DeviceService["Service"]["currentReadiness"] = (hostId) =>    resolveHost(hostId).pipe(      Effect.flatMap((host) =>        host.current.pipe(Effect.map((ready) => (ready ? { hostId: host.id, ...ready } : null))),      ),      Effect.orElseSucceed(() => null),    );   const hubJson = <A, I>(    request: HttpClientRequest.HttpClientRequest,    schema: Schema.Codec<A, I>,    operation: string,    timeout: Duration.Input = Duration.seconds(15),  ) =>    httpClient.execute(request).pipe(      Effect.flatMap(HttpClientResponse.filterStatusOk),      Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),      Effect.scoped,      Effect.timeout(timeout),      Effect.mapError(        (cause) =>          new DeviceOperationError({            operation,            reason: "request_failed",            cause,          }),      ),    );   const fetchDevices = Effect.fn("DeviceService.fetchDevices")(function* (ready: DeviceReadiness) {    const list = yield* hubJson(      HttpClientRequest.get(`${ready.hub.origin}/api/devices`),      HubDeviceList,      "list",    );    const toSummary = (device: typeof HubDevice.Type): DeviceSummary => ({      hostId: ready.hostId,      id: device.id,      platform: device.platform,      name: device.name,      version: device.version,      booted: device.booted,      physical: device.physical,    });    const devices = [...list.simulators, ...list.emulators].map(toSummary);    const host = yield* resolveHost(ready.hostId);    if ((yield* host.platformAvailability("android")).available) {      const avds = yield* ready.run("emulator", ["-list-avds"]);      if (avds.code !== 0) {        return yield* new DeviceOperationError({          operation: "list",          reason: "command_failed",          exitCode: avds.code,          cause: avds,        });      }      for (const name of avds.stdout        .split(/\r?\n/)        .map((line) => line.trim())        .filter(Boolean)) {        if (!devices.some((device) => device.platform === "android" && device.name === name)) {          devices.push({            hostId: ready.hostId,            id: name,            name,            platform: "android",            version: "Android",            booted: false,            physical: false,          });        }      }    }    return { devices, detail: list.errors?.map((error) => error.message).join("\n") || undefined };  });   const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) {    const host = hosts.get(ready.hostId);    const { devices, detail } = yield* fetchDevices(ready);    const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);    return yield* lifecycleLock.withPermit(      Effect.gen(function* () {        if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host)          return (yield* SynchronizedRef.get(stateRef)).state;        return yield* publish((state) => ({          ...state,          hosts: hostSummaries,          ...(ready.hostId === LOCAL_DEVICE_HOST_ID ? { hostStatusDetail: detail } : {}),          devices: [            ...state.devices.filter((device) => device.hostId !== ready.hostId),            ...devices,          ],          hostStatuses: {            ...state.hostStatuses,            [ready.hostId]: { status: "ready", ...(detail ? { detail } : {}) },          },        }));      }),    );  });   const list: DeviceService["Service"]["list"] = Effect.gen(function* () {    if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;    yield* Effect.forEach(      hosts.values(),      (host) =>        Effect.gen(function* () {          const ready = yield* readinessIfSupported(host.id);          if (ready) yield* refresh(ready);        }).pipe(          Effect.catch((error) =>            setHostStatus(host.id, { status: "failed", detail: error.message }),          ),        ),      { concurrency: 4 },    );    return (yield* SynchronizedRef.get(stateRef)).state;  }).pipe(Effect.withSpan("DeviceService.list"));   const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(    function* (input) {      const currentSettings = yield* readDeviceSettings;      const nextEnabled = input.enabled ?? currentSettings.enabled;      const nextAgentAccess = input.agentAccessEnabled ?? currentSettings.agentAccessEnabled;      yield* lifecycleLock.withPermit(        Effect.gen(function* () {          yield* settings            .updateSettings({              ...(input.enabled === undefined ? {} : { enableDeviceSupport: input.enabled }),              ...(input.agentAccessEnabled === undefined                ? {}                : { enableAgentDeviceAccess: input.agentAccessEnabled }),              ...(input.onboardingCompleted === undefined                ? {}                : { deviceOnboardingCompleted: input.onboardingCompleted }),            })            .pipe(              Effect.mapError(                (cause) =>                  new DeviceOperationError({                    operation: "configure",                    reason: "settings_failed",                    cause,                  }),              ),            );          if (!nextEnabled) {            yield* Effect.forEach(hosts.values(), (host) => host.stop, { discard: true });          } else if (input.agentAccessEnabled === false) {            yield* Effect.forEach(hosts.values(), (host) => host.stopAgent, { discard: true });          }          yield* publish((state) => ({            ...state,            hostStatus: nextEnabled ? "idle" : "disabled",            hostStatusDetail: undefined,            hostStatuses: {},            devices: nextEnabled ? state.devices : [],            sessions: nextEnabled ? state.sessions : [],            bootingDevices: nextEnabled ? state.bootingDevices : [],            agentAccessEnabled: nextAgentAccess,            onboardingCompleted: input.onboardingCompleted ?? state.onboardingCompleted,          }));        }),      );      if (nextEnabled && nextAgentAccess && input.agentAccessEnabled === true) {        yield* agentReadinessIfSupported();      }      return yield* list;    },  );   const findDevice = (    state: DeviceServiceState,    hostId: DeviceHostId,    deviceId: DeviceId,  ): DeviceSummary | undefined =>    state.devices.find((device) => device.hostId === hostId && device.id === deviceId);   const ensurePlatform = Effect.fn("DeviceService.ensurePlatform")(function* (    host: DeviceHost.DeviceHost["Service"],    platform: DevicePlatform,  ) {    const availability = yield* host.platformAvailability(platform);    if (!availability.available) {      return yield* new DevicePlatformUnavailableError({

Lines 1–500 of 1025.