Unified · Split
| @@ -40,6 +40,7 @@ · 39 unmodified lines | ||
| 40 | 40 | "@effect/vitest": "catalog:", |
| 41 | 41 | "@t3tools/contracts": "workspace:*", |
| 42 | 42 | "@t3tools/shared": "workspace:*", |
| 43 | + "@t3tools/ssh": "workspace:*", | |
| 43 | 44 | "@t3tools/tailscale": "workspace:*", |
| 44 | 45 | "@t3tools/web": "workspace:*", |
| 45 | 46 | "@types/bun": "1.3.14", |
| @@ -145,6 +145,7 @@ export const RPC_REQUIRED_SCOPES = { · 144 unmodified lines | ||
| 145 | 145 | [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, |
| 146 | 146 | [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, |
| 147 | 147 | [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope, |
| 148 | + [WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope, | |
| 148 | 149 | [WS_METHODS.deviceList]: AuthOrchestrationReadScope, |
| 149 | 150 | [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope, |
| 150 | 151 | [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope, |
| @@ -16,6 +16,7 @@ const makeReady = ( · 15 unmodified lines | ||
| 16 | 16 | ) => { |
| 17 | 17 | const calls: Call[] = []; |
| 18 | 18 | const ready: DeviceHostReady = { |
| 19 | + nodePath: process.execPath, | |
| 19 | 20 | hub: { origin: "http://127.0.0.1:1" }, |
| 20 | 21 | helpers, |
| 21 | 22 | run: (command, args, options) => { |
| @@ -327,7 +327,7 @@ const serveSimPermissions = ( · 326 unmodified lines | ||
| 327 | 327 | reason: "helper_missing", |
| 328 | 328 | }); |
| 329 | 329 | yield* ready |
| 330 | − .run(process.execPath, [ | |
| 330 | + .run(ready.nodePath, [ | |
| 331 | 331 | cli, |
| 332 | 332 | "permissions", |
| 333 | 333 | input.decision, |
| @@ -46,11 +46,12 @@ export interface DeviceHubEndpoint { · 45 unmodified lines | ||
| 46 | 46 | export interface AgentDeviceEndpoint { |
| 47 | 47 | readonly baseUrl: string; |
| 48 | 48 | readonly token: string; |
| 49 | − /** Absolute path of the agent-device entry script for the provider PATH shim. */ | |
| 49 | + /** Host-local path of the agent-device entry script. The provider uses a separate local CLI install. */ | |
| 50 | 50 | readonly entryPath: string; |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | 53 | export interface DeviceHostReady { |
| 54 | + readonly nodePath: string; | |
| 54 | 55 | readonly hub: DeviceHubEndpoint; |
| 55 | 56 | /** |
| 56 | 57 | * Runs a host command (`xcrun`, `adb`, or a helper bundled with the hub) |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 3 | +import * as Deferred from "effect/Deferred"; | |
| 4 | +import * as Fiber from "effect/Fiber"; | |
| 3 | 5 | import * as Effect from "effect/Effect"; |
| 4 | 6 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 5 | 7 | import { ServerSettingsService } from "../serverSettings.ts"; |
| @@ -10,6 +12,7 @@ it.effect("keeps hosts independent when serials collide and another host fails", · 4 unmodified lines | ||
| 10 | 12 | Effect.gen(function* () { |
| 11 | 13 | const host = (id: string, failed = false): DeviceHost["Service"] => { |
| 12 | 14 | const ready = { |
| 15 | + nodePath: process.execPath, | |
| 13 | 16 | hub: { origin: `http://${id}` }, |
| 14 | 17 | agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, |
| 15 | 18 | run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), |
| @@ -20,10 +23,10 @@ it.effect("keeps hosts independent when serials collide and another host fails", · 4 unmodified lines | ||
| 20 | 23 | summary: Effect.succeed({ |
| 21 | 24 | id, |
| 22 | 25 | label: id, |
| 23 | − kind: "local", | |
| 26 | + kind: id === "b" ? "ssh" : "local", | |
| 24 | 27 | hubInstalled: true, |
| 25 | 28 | agentDeviceInstalled: true, |
| 26 | − platforms: [{ platform: "android", available: true }], | |
| 29 | + platforms: id === "b" ? [] : [{ platform: "android", available: true }], | |
| 27 | 30 | }), |
| 28 | 31 | platformAvailability: (platform) => Effect.succeed({ platform, available: true }), |
| 29 | 32 | ensureReady: () => |
| @@ -59,9 +62,19 @@ it.effect("keeps hosts independent when serials collide and another host fails", · 29 unmodified lines | ||
| 59 | 62 | ), |
| 60 | 63 | ); |
| 61 | 64 | const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")])); |
| 62 | − const service = yield* makeWithHosts(hosts).pipe( | |
| 63 | − Effect.provideService(HttpClient.HttpClient, http), | |
| 64 | − ); | |
| 65 | + const writeStarted = yield* Deferred.make<void>(); | |
| 66 | + const finishWrite = yield* Deferred.make<void>(); | |
| 67 | + const order: string[] = []; | |
| 68 | + const service = yield* makeWithHosts(hosts, undefined, () => | |
| 69 | + Effect.gen(function* () { | |
| 70 | + order.push("write started"); | |
| 71 | + yield* Deferred.succeed(writeStarted, undefined); | |
| 72 | + yield* Deferred.await(finishWrite); | |
| 73 | + order.push("write finished"); | |
| 74 | + return "/host-config.json"; | |
| 75 | + }), | |
| 76 | + ).pipe(Effect.provideService(HttpClient.HttpClient, http)); | |
| 77 | + expect(yield* service.agentReadinessIfSupported("b")).not.toBeNull(); | |
| 65 | 78 | const listed = yield* service.list; |
| 66 | 79 | expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]); |
| 67 | 80 | expect(listed.hostStatuses.offline?.status).toBe("failed"); |
| @@ -74,8 +87,35 @@ it.effect("keeps hosts independent when serials collide and another host fails", · 6 unmodified lines | ||
| 74 | 87 | expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]); |
| 75 | 88 | expect(state.hostStatuses.a?.status).toBe("ready"); |
| 76 | 89 | expect(state.hostStatuses.offline?.status).toBe("failed"); |
| 77 | − yield* service.agentReadinessIfSupported("b"); | |
| 78 | − expect((yield* service.state).hostStatuses.b?.status).toBe("ready"); | |
| 90 | + const targeting = yield* service | |
| 91 | + .agentTarget({ threadId, hostId: "b", deviceId: "emulator-5554" }) | |
| 92 | + .pipe(Effect.forkChild); | |
| 93 | + yield* Deferred.await(writeStarted); | |
| 94 | + const replacing = yield* service | |
| 95 | + .withLifecycleLock( | |
| 96 | + Effect.gen(function* () { | |
| 97 | + order.push("replace"); | |
| 98 | + hosts.set("b", host("b")); | |
| 99 | + yield* service.refreshHosts; | |
| 100 | + }), | |
| 101 | + ) | |
| 102 | + .pipe(Effect.forkChild); | |
| 103 | + yield* Deferred.succeed(finishWrite, undefined); | |
| 104 | + yield* Fiber.join(targeting); | |
| 105 | + yield* Fiber.join(replacing); | |
| 106 | + expect(order).toEqual(["write started", "write finished", "replace"]); | |
| 107 | + const replaced = yield* service.state; | |
| 108 | + expect(replaced.sessions).toEqual([]); | |
| 109 | + expect(replaced.devices.map((device) => device.hostId)).toEqual(["a"]); | |
| 110 | + expect(replaced.hostStatuses.b).toBeUndefined(); | |
| 111 | + yield* service.open({ threadId, hostId: "b", deviceId: "emulator-5554", platform: "android" }); | |
| 112 | + hosts.delete("b"); | |
| 113 | + yield* service.refreshHosts; | |
| 114 | + yield* service.setHostStatus("b", { status: "ready" }); | |
| 115 | + expect((yield* service.state).hostStatuses.b).toBeUndefined(); | |
| 116 | + expect((yield* service.state).sessions).toEqual([]); | |
| 117 | + yield* service.agentReadinessIfSupported("a"); | |
| 118 | + expect((yield* service.state).hostStatuses.a?.status).toBe("ready"); | |
| 79 | 119 | yield* service.configure({ enabled: false }); |
| 80 | 120 | expect((yield* service.state).hostStatuses).toEqual({}); |
| 81 | 121 | }).pipe( |
| @@ -70,6 +70,7 @@ const fixture = Effect.fn("fixture")(function* ( · 69 unmodified lines | ||
| 70 | 70 | let booted = false; |
| 71 | 71 | let shutDown = false; |
| 72 | 72 | const ready: DeviceHost.DeviceHostReady = { |
| 73 | + nodePath: process.execPath, | |
| 73 | 74 | hub: { origin: "http://device.test" }, |
| 74 | 75 | helpers: { serveSimAxSettings: null, serveSimCli: null }, |
| 75 | 76 | run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), |
| @@ -30,6 +30,8 @@ import { · 29 unmodified lines | ||
| 30 | 30 | type DeviceSession, |
| 31 | 31 | type DeviceShutdownInput, |
| 32 | 32 | type DeviceSummary, |
| 33 | + type SshDeviceHostConfig, | |
| 34 | + type DeviceHostSummary, | |
| 33 | 35 | LOCAL_DEVICE_HOST_ID, |
| 34 | 36 | type ThreadId, |
| 35 | 37 | } from "@t3tools/contracts"; |
| @@ -60,6 +62,8 @@ import * as ServerSettings from "../serverSettings.ts"; · 24 unmodified lines | ||
| 60 | 62 | import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; |
| 61 | 63 | import * as ProcessRunner from "../processRunner.ts"; |
| 62 | 64 | import * as DeviceHost from "./DeviceHost.ts"; |
| 65 | +import * as SshDeviceHost from "./SshDeviceHost.ts"; | |
| 66 | +import * as Exit from "effect/Exit"; | |
| 63 | 67 | import * as LocalDeviceHost from "./LocalDeviceHost.ts"; |
| 64 | 68 | |
| 65 | 69 | /** Origin-relative prefix the hub is proxied under. See DeviceHubProxy. */ |
| @@ -105,6 +109,9 @@ export class DeviceService extends Context.Service< · 39 unmodified lines | ||
| 105 | 109 | DeviceService, |
| 106 | 110 | { |
| 107 | 111 | readonly agentCli: Effect.Effect<string, DeviceError>; |
| 112 | + readonly testHost: ( | |
| 113 | + config: SshDeviceHostConfig, | |
| 114 | + ) => Effect.Effect<DeviceHostSummary, DeviceError>; | |
| 108 | 115 | readonly agentTarget: (input: { |
| 109 | 116 | threadId: ThreadId; |
| 110 | 117 | hostId: DeviceHostId; |
| @@ -154,6 +161,13 @@ const vendorPrefix = (platform: DevicePlatform) => · 43 unmodified lines | ||
| 154 | 161 | |
| 155 | 162 | export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( |
| 156 | 163 | hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]>, |
| 164 | + testHost: DeviceService["Service"]["testHost"] = (host) => | |
| 165 | + Effect.fail( | |
| 166 | + new DeviceHostUnavailableError({ | |
| 167 | + hostId: host.id, | |
| 168 | + reason: "SSH probing is unavailable in this device service.", | |
| 169 | + }), | |
| 170 | + ), | |
| 157 | 171 | configureAgent: ( |
| 158 | 172 | hostId: DeviceHostId, |
| 159 | 173 | ready: DeviceHost.DeviceHostAgentReady, |
| @@ -183,6 +197,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 23 unmodified lines | ||
| 183 | 197 | const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); |
| 184 | 198 | const statePubSub = yield* PubSub.unbounded<DeviceServiceState>(); |
| 185 | 199 | const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary); |
| 200 | + let publishedHosts = new Map(hosts); | |
| 186 | 201 | const stateRef = yield* SynchronizedRef.make<ServiceState>({ |
| 187 | 202 | state: { |
| 188 | 203 | hosts: initialHosts, |
| @@ -217,13 +232,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 28 unmodified lines | ||
| 217 | 232 | hostId: DeviceHostId, |
| 218 | 233 | status: DeviceServiceState["hostStatuses"][string], |
| 219 | 234 | ) => |
| 220 | − publish((state) => ({ | |
| 221 | − ...state, | |
| 222 | − ...(hostId === LOCAL_DEVICE_HOST_ID | |
| 223 | − ? { hostStatus: status.status, hostStatusDetail: status.detail } | |
| 224 | − : {}), | |
| 225 | − hostStatuses: { ...state.hostStatuses, [hostId]: status }, | |
| 226 | − })); | |
| 235 | + Effect.suspend(() => | |
| 236 | + !hosts.has(hostId) | |
| 237 | + ? SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)) | |
| 238 | + : publish((state) => ({ | |
| 239 | + ...state, | |
| 240 | + ...(hostId === LOCAL_DEVICE_HOST_ID | |
| 241 | + ? { hostStatus: status.status, hostStatusDetail: status.detail } | |
| 242 | + : {}), | |
| 243 | + hostStatuses: { ...state.hostStatuses, [hostId]: status }, | |
| 244 | + })), | |
| 245 | + ); | |
| 227 | 246 | |
| 228 | 247 | const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")( |
| 229 | 248 | function* (hostId) { |
| @@ -245,6 +264,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 15 unmodified lines | ||
| 245 | 264 | (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), |
| 246 | 265 | ), |
| 247 | 266 | ); |
| 267 | + if (hosts.get(host.id) !== host) | |
| 268 | + return yield* new DeviceHostUnavailableError({ | |
| 269 | + hostId: host.id, | |
| 270 | + reason: "Host configuration changed. Retry the operation.", | |
| 271 | + }); | |
| 248 | 272 | const { state } = yield* SynchronizedRef.get(stateRef); |
| 249 | 273 | if (state.hostStatuses[host.id]?.status !== "ready") { |
| 250 | 274 | yield* setHostStatus(host.id, { status: "ready" }); |
| @@ -260,7 +284,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 9 unmodified lines | ||
| 260 | 284 | if (!(yield* readDeviceSettings).enabled) return null; |
| 261 | 285 | const host = yield* resolveHost(hostId); |
| 262 | 286 | const summary = yield* host.summary; |
| 263 | − if (!summary.platforms.some((platform) => platform.available)) return null; | |
| 287 | + if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) | |
| 288 | + return null; | |
| 264 | 289 | return yield* readiness(host.id); |
| 265 | 290 | }); |
| 266 | 291 | |
| @@ -270,7 +295,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 3 unmodified lines | ||
| 270 | 295 | if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null; |
| 271 | 296 | const host = yield* resolveHost(hostId); |
| 272 | 297 | const summary = yield* host.summary; |
| 273 | − if (!summary.platforms.some((platform) => platform.available)) return null; | |
| 298 | + if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) | |
| 299 | + return null; | |
| 274 | 300 | const ready = yield* host |
| 275 | 301 | .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) |
| 276 | 302 | .pipe( |
| @@ -364,11 +390,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 87 unmodified lines | ||
| 364 | 390 | }); |
| 365 | 391 | |
| 366 | 392 | const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) { |
| 393 | + const host = hosts.get(ready.hostId); | |
| 367 | 394 | const { devices, detail } = yield* fetchDevices(ready); |
| 368 | 395 | const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); |
| 369 | 396 | return yield* lifecycleLock.withPermit( |
| 370 | 397 | Effect.gen(function* () { |
| 371 | − if (!(yield* readDeviceSettings).enabled) | |
| 398 | + if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host) | |
| 372 | 399 | return (yield* SynchronizedRef.get(stateRef)).state; |
| 373 | 400 | return yield* publish((state) => ({ |
| 374 | 401 | ...state, |
| @@ -578,6 +605,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 203 unmodified lines | ||
| 578 | 605 | ), |
| 579 | 606 | ); |
| 580 | 607 | } |
| 608 | + if (hosts.get(host.id) !== host) | |
| 609 | + return yield* new DeviceHostUnavailableError({ | |
| 610 | + hostId: host.id, | |
| 611 | + reason: "Host configuration changed. Retry the operation.", | |
| 612 | + }); | |
| 581 | 613 | const openedAt = DateTime.formatIso(yield* DateTime.now); |
| 582 | 614 | const session: DeviceSession = { |
| 583 | 615 | threadId: input.threadId, |
| @@ -758,46 +790,76 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* · 174 unmodified lines | ||
| 758 | 790 | Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)), |
| 759 | 791 | ); |
| 760 | 792 | |
| 761 | − return DeviceService.of({ | |
| 762 | − agentCli: Effect.fail( | |
| 763 | − new DeviceHostUnavailableError({ | |
| 764 | − hostId: LOCAL_DEVICE_HOST_ID, | |
| 765 | − reason: "Agent CLI installation is unavailable in this device service.", | |
| 766 | − }), | |
| 767 | − ), | |
| 768 | − agentTarget: (input) => | |
| 769 | − Effect.gen(function* () { | |
| 770 | − const ready = yield* agentReadinessIfSupported(input.hostId); | |
| 771 | − if (!ready) | |
| 772 | − return yield* new DeviceHostUnavailableError({ | |
| 773 | − hostId: input.hostId, | |
| 774 | − reason: | |
| 775 | − "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.", | |
| 776 | − }); | |
| 777 | − const configPath = yield* configureAgent(input.hostId, ready); | |
| 778 | − return [ | |
| 779 | − "--config", | |
| 780 | − configPath, | |
| 781 | − "--session", | |
| 782 | − agentDeviceSession(input.threadId, input.hostId, input.deviceId), | |
| 783 | − ]; | |
| 784 | − }), | |
| 785 | − state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)), | |
| 786 | − subscribe: PubSub.subscribe(statePubSub), | |
| 787 | − configure, | |
| 788 | − list, | |
| 789 | − open, | |
| 790 | − close, | |
| 791 | − shutdown, | |
| 792 | − detail, | |
| 793 | − action, | |
| 794 | − screenshot, | |
| 795 | − readiness, | |
| 796 | − readinessIfSupported, | |
| 797 | − agentReadinessIfSupported, | |
| 798 | − currentReadiness, | |
| 799 | − sessionsForThread, | |
| 800 | − }); | |
| 793 | + return { | |
| 794 | + ...DeviceService.of({ | |
| 795 | + testHost, | |
| 796 | + agentCli: Effect.fail( | |
| 797 | + new DeviceHostUnavailableError({ | |
| 798 | + hostId: LOCAL_DEVICE_HOST_ID, | |
| 799 | + reason: "Agent CLI installation is unavailable in this device service.", | |
| 800 | + }), | |
| 801 | + ), | |
| 802 | + agentTarget: (input) => | |
| 803 | + Effect.gen(function* () { | |
| 804 | + const host = yield* resolveHost(input.hostId); | |
| 805 | + const ready = yield* agentReadinessIfSupported(input.hostId); | |
| 806 | + if (!ready) | |
| 807 | + return yield* new DeviceHostUnavailableError({ | |
| 808 | + hostId: input.hostId, | |
| 809 | + reason: | |
| 810 | + "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.", | |
| 811 | + }); | |
| 812 | + const configPath = yield* lifecycleLock.withPermit( | |
| 813 | + Effect.gen(function* () { | |
| 814 | + if (hosts.get(host.id) !== host) | |
| 815 | + return yield* new DeviceHostUnavailableError({ | |
| 816 | + hostId: host.id, | |
| 817 | + reason: "Host configuration changed. Retry the operation.", | |
| 818 | + }); | |
| 819 | + return yield* configureAgent(input.hostId, ready); | |
| 820 | + }), | |
| 821 | + ); | |
| 822 | + return [ | |
| 823 | + "--config", | |
| 824 | + configPath, | |
| 825 | + "--session", | |
| 826 | + agentDeviceSession(input.threadId, input.hostId, input.deviceId), | |
| 827 | + ]; | |
| 828 | + }), | |
| 829 | + state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)), | |
| 830 | + subscribe: PubSub.subscribe(statePubSub), | |
| 831 | + configure, | |
| 832 | + list, | |
| 833 | + open, | |
| 834 | + close, | |
| 835 | + shutdown, | |
| 836 | + detail, | |
| 837 | + action, | |
| 838 | + screenshot, | |
| 839 | + readiness, | |
| 840 | + readinessIfSupported, | |
| 841 | + agentReadinessIfSupported, | |
| 842 | + currentReadiness, | |
| 843 | + sessionsForThread, | |
| 844 | + }), | |
| 845 | + setHostStatus, | |
| 846 | + withLifecycleLock: lifecycleLock.withPermit, | |
| 847 | + refreshHosts: Effect.gen(function* () { | |
| 848 | + const summaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); | |
| 849 | + const unchanged = (id: DeviceHostId) => | |
| 850 | + hosts.has(id) && hosts.get(id) === publishedHosts.get(id); | |
| 851 | + yield* publish((state) => ({ | |
| 852 | + ...state, | |
| 853 | + hosts: summaries, | |
| 854 | + hostStatuses: Object.fromEntries( | |
| 855 | + Object.entries(state.hostStatuses).filter(([id]) => unchanged(id)), | |
| 856 | + ), | |
| 857 | + devices: state.devices.filter((device) => unchanged(device.hostId)), | |
| 858 | + sessions: state.sessions.filter((session) => unchanged(session.hostId)), | |
| 859 | + })); | |
| 860 | + publishedHosts = new Map(hosts); | |
| 861 | + }), | |
| 862 | + }; | |
| 801 | 863 | }); |
| 802 | 864 | |
| 803 | 865 | /** @public Service construction is part of the canonical Effect module API. */ |
| @@ -807,7 +869,12 @@ export const make = Effect.gen(function* () { · 3 unmodified lines | ||
| 807 | 869 | const fs = yield* FileSystem.FileSystem; |
| 808 | 870 | const path = yield* Path.Path; |
| 809 | 871 | const runner = yield* ProcessRunner.ProcessRunner; |
| 810 | − const service = yield* makeWithHosts(new Map([[localHost.id, localHost]]), (hostId, ready) => { | |
| 872 | + const settings = yield* ServerSettings.ServerSettingsService; | |
| 873 | + const scope = yield* Scope.Scope; | |
| 874 | + const hosts = new Map<DeviceHostId, DeviceHost.DeviceHost["Service"]>([ | |
| 875 | + [localHost.id, localHost], | |
| 876 | + ]); | |
| 877 | + const configureAgent = (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) => { | |
| 811 | 878 | const file = agentDeviceConfigPath(config.stateDir, hostId, path); |
| 812 | 879 | return writeAgentDeviceConfig(file, ready.agentDevice).pipe( |
| 813 | 880 | Effect.provideService(FileSystem.FileSystem, fs), |
| @@ -822,7 +889,108 @@ export const make = Effect.gen(function* () { · 8 unmodified lines | ||
| 822 | 889 | ), |
| 823 | 890 | Effect.as(file), |
| 824 | 891 | ); |
| 825 | − }); | |
| 892 | + }; | |
| 893 | + const probeContext = | |
| 894 | + yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.probe>>>(); | |
| 895 | + const service = yield* makeWithHosts( | |
| 896 | + hosts, | |
| 897 | + (host) => | |
| 898 | + SshDeviceHost.probe(host).pipe( | |
| 899 | + Effect.provide(probeContext), | |
| 900 | + Effect.mapError( | |
| 901 | + (error) => | |
| 902 | + new DeviceOperationError({ | |
| 903 | + operation: "probe host", | |
| 904 | + reason: "request_failed", | |
| 905 | + cause: error, | |
| 906 | + }), | |
| 907 | + ), | |
| 908 | + ), | |
| 909 | + configureAgent, | |
| 910 | + ); | |
| 911 | + const hostContext = | |
| 912 | + yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>(); | |
| 913 | + const configured = new Map<string, { config: SshDeviceHostConfig; scope: Scope.Closeable }>(); | |
| 914 | + const reconcile = (next: ReadonlyArray<SshDeviceHostConfig>) => | |
| 915 | + Effect.gen(function* () { | |
| 916 | + const removed = yield* service.withLifecycleLock( | |
| 917 | + Effect.gen(function* () { | |
| 918 | + const removed: Array<{ id: string; scope: Scope.Closeable }> = []; | |
| 919 | + for (const [id, previous] of configured) { | |
| 920 | + if ( | |
| 921 | + next.some( | |
| 922 | + (host) => | |
| 923 | + host.id === id && | |
| 924 | + host.label === previous.config.label && | |
| 925 | + host.target === previous.config.target && | |
| 926 | + host.port === previous.config.port && | |
| 927 | + host.identityFile === previous.config.identityFile, | |
| 928 | + ) | |
| 929 | + ) | |
| 930 | + continue; | |
| 931 | + hosts.delete(id); | |
| 932 | + configured.delete(id); | |
| 933 | + removed.push({ id, scope: previous.scope }); | |
| 934 | + } | |
| 935 | + yield* service.refreshHosts; | |
| 936 | + return removed; | |
| 937 | + }), | |
| 938 | + ); | |
| 939 | + // Stop old writers before deleting config files or publishing replacements, without blocking healthy hosts. | |
| 940 | + yield* Effect.forEach( | |
| 941 | + removed, | |
| 942 | + ({ id, scope }) => | |
| 943 | + Effect.gen(function* () { | |
| 944 | + yield* Scope.close(scope, Exit.void); | |
| 945 | + yield* fs | |
| 946 | + .remove(agentDeviceConfigPath(config.stateDir, id, path), { force: true }) | |
| 947 | + .pipe(Effect.ignore); | |
| 948 | + }), | |
| 949 | + { concurrency: 4, discard: true }, | |
| 950 | + ); | |
| 951 | + yield* service.withLifecycleLock( | |
| 952 | + Effect.gen(function* () { | |
| 953 | + for (const host of next) { | |
| 954 | + if (configured.has(host.id)) continue; | |
| 955 | + const hostScope = yield* Scope.fork(scope); | |
| 956 | + const instance = yield* SshDeviceHost.make( | |
| 957 | + host, | |
| 958 | + (ready) => | |
| 959 | + configureAgent(host.id, ready).pipe( | |
| 960 | + Effect.asVoid, | |
| 961 | + Effect.mapError( | |
| 962 | + (error) => | |
| 963 | + new DeviceHost.DeviceHostError({ | |
| 964 | + hostId: host.id, | |
| 965 | + step: "configuring agent access", | |
| 966 | + cause: error, | |
| 967 | + }), | |
| 968 | + ), | |
| 969 | + ), | |
| 970 | + (status, detail) => | |
| 971 | + service | |
| 972 | + .setHostStatus(host.id, { status, ...(detail ? { detail } : {}) }) | |
| 973 | + .pipe(Effect.asVoid), | |
| 974 | + ).pipe(Effect.provideService(Scope.Scope, hostScope), Effect.provide(hostContext)); | |
| 975 | + hosts.set(host.id, instance); | |
| 976 | + configured.set(host.id, { config: host, scope: hostScope }); | |
| 977 | + } | |
| 978 | + yield* service.refreshHosts; | |
| 979 | + }), | |
| 980 | + ); | |
| 981 | + }); | |
| 982 | + const changes = yield* settings.subscribeChanges; | |
| 983 | + yield* reconcile((yield* settings.getSettings).deviceHosts); | |
| 984 | + yield* changes.pipe( | |
| 985 | + Stream.runForEach((value) => reconcile(value.deviceHosts)), | |
| 986 | + Effect.forkIn(scope), | |
| 987 | + ); | |
| 988 | + yield* Effect.addFinalizer(() => | |
| 989 | + Effect.forEach(configured.values(), (value) => Scope.close(value.scope, Exit.void), { | |
| 990 | + discard: true, | |
| 991 | + concurrency: 4, | |
| 992 | + }), | |
| 993 | + ); | |
| 826 | 994 | return { |
| 827 | 995 | ...service, |
| 828 | 996 | agentCli: ensureAgentDevice(config.baseDir).pipe( |
| @@ -25,9 +25,9 @@ import * as Semaphore from "effect/Semaphore"; · 24 unmodified lines | ||
| 25 | 25 | import * as ProcessRunner from "../processRunner.ts"; |
| 26 | 26 | |
| 27 | 27 | const DEVICE_HUB_PACKAGE = "expo-device-hub"; |
| 28 | −const DEVICE_HUB_VERSION = "0.9.0"; | |
| 28 | +export const DEVICE_HUB_VERSION = "0.9.0"; | |
| 29 | 29 | const AGENT_DEVICE_PACKAGE = "agent-device"; |
| 30 | −const AGENT_DEVICE_VERSION = "0.20.10"; | |
| 30 | +export const AGENT_DEVICE_VERSION = "0.20.10"; | |
| 31 | 31 | |
| 32 | 32 | const INSTALL_TIMEOUT = Duration.minutes(10); |
| 33 | 33 | const installLock = Semaphore.makeUnsafe(1); |
| @@ -644,6 +644,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { · 643 unmodified lines | ||
| 644 | 644 | |
| 645 | 645 | const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({ |
| 646 | 646 | hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint, |
| 647 | + nodePath: process.execPath, | |
| 647 | 648 | run, |
| 648 | 649 | helpers: running.helpers, |
| 649 | 650 | }); |
| @@ -0,0 +1,131 @@ | ||
| 1 | +// @effect-diagnostics preferSchemaOverJson:off - the external process fixture emits raw JSON over SSH stdout. | |
| 2 | +import { expect, it } from "@effect/vitest"; | |
| 3 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | +import * as Net from "@t3tools/shared/Net"; | |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | +import * as FileSystem from "effect/FileSystem"; | |
| 7 | +import * as Layer from "effect/Layer"; | |
| 8 | +import * as PlatformError from "effect/PlatformError"; | |
| 9 | +import * as Sink from "effect/Sink"; | |
| 10 | +import * as Stream from "effect/Stream"; | |
| 11 | +import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 12 | +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | |
| 13 | +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 14 | +import * as ServerConfig from "../config.ts"; | |
| 15 | +import * as DeviceHost from "./DeviceHost.ts"; | |
| 16 | +import * as SshDeviceHost from "./SshDeviceHost.ts"; | |
| 17 | + | |
| 18 | +it.effect("preserves installed status after probes and cleans failed agent activation", () => | |
| 19 | + Effect.gen(function* () { | |
| 20 | + const fs = yield* FileSystem.FileSystem; | |
| 21 | + const home = yield* fs.makeTempDirectoryScoped(); | |
| 22 | + const modes: string[] = []; | |
| 23 | + let forwards = 0; | |
| 24 | + let failForward = true; | |
| 25 | + let rejectConfig = true; | |
| 26 | + const spawner = ChildProcessSpawner.make((command) => | |
| 27 | + Effect.gen(function* () { | |
| 28 | + if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); | |
| 29 | + const forwarding = command.args.includes("-N"); | |
| 30 | + let output = ""; | |
| 31 | + if (forwarding) { | |
| 32 | + if (failForward) { | |
| 33 | + failForward = false; | |
| 34 | + return yield* PlatformError.systemError({ | |
| 35 | + _tag: "AlreadyExists", | |
| 36 | + module: "ChildProcess", | |
| 37 | + method: "spawn", | |
| 38 | + description: "Port already bound", | |
| 39 | + }); | |
| 40 | + } | |
| 41 | + forwards++; | |
| 42 | + yield* Effect.addFinalizer(() => | |
| 43 | + Effect.sync(() => { | |
| 44 | + forwards--; | |
| 45 | + }), | |
| 46 | + ); | |
| 47 | + } else { | |
| 48 | + const stdin = command.options.stdin; | |
| 49 | + if ( | |
| 50 | + !stdin || | |
| 51 | + typeof stdin !== "object" || | |
| 52 | + !("stream" in stdin) || | |
| 53 | + !Stream.isStream(stdin.stream) | |
| 54 | + ) | |
| 55 | + return yield* Effect.die("Missing script"); | |
| 56 | + const script = yield* stdin.stream.pipe( | |
| 57 | + Stream.decodeText(), | |
| 58 | + Stream.runFold( | |
| 59 | + () => "", | |
| 60 | + (a, b) => a + b, | |
| 61 | + ), | |
| 62 | + ); | |
| 63 | + const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; | |
| 64 | + modes.push(mode); | |
| 65 | + output = JSON.stringify({ | |
| 66 | + nodePath: "/node", | |
| 67 | + platforms: [{ platform: "ios", available: true }], | |
| 68 | + hubPort: 1234, | |
| 69 | + helpers: { serveSimAxSettings: null, serveSimCli: null }, | |
| 70 | + ...(mode === "agent-start" | |
| 71 | + ? { daemonPort: 1235, token: "fixture", entryPath: "/agent.mjs" } | |
| 72 | + : {}), | |
| 73 | + }); | |
| 74 | + } | |
| 75 | + return ChildProcessSpawner.makeHandle({ | |
| 76 | + pid: ChildProcessSpawner.ProcessId(123), | |
| 77 | + stdout: Stream.make(new TextEncoder().encode(output)), | |
| 78 | + stderr: Stream.empty, | |
| 79 | + all: Stream.empty, | |
| 80 | + exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)), | |
| 81 | + isRunning: Effect.succeed(forwarding), | |
| 82 | + kill: () => Effect.void, | |
| 83 | + stdin: Sink.drain, | |
| 84 | + getInputFd: () => Sink.drain, | |
| 85 | + getOutputFd: () => Stream.empty, | |
| 86 | + unref: Effect.succeed(Effect.void), | |
| 87 | + }); | |
| 88 | + }), | |
| 89 | + ); | |
| 90 | + const host = yield* SshDeviceHost.make( | |
| 91 | + { id: "test", label: "Test", target: "test.example" }, | |
| 92 | + () => | |
| 93 | + rejectConfig | |
| 94 | + ? Effect.fail( | |
| 95 | + new DeviceHost.DeviceHostError({ | |
| 96 | + hostId: "test", | |
| 97 | + step: "configuring agent access", | |
| 98 | + cause: new Error("fixture failure"), | |
| 99 | + }), | |
| 100 | + ) | |
| 101 | + : Effect.void, | |
| 102 | + ).pipe( | |
| 103 | + Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)), | |
| 104 | + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 105 | + Effect.provideService( | |
| 106 | + HttpClient.HttpClient, | |
| 107 | + HttpClient.make((request) => | |
| 108 | + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))), | |
| 109 | + ), | |
| 110 | + ), | |
| 111 | + ); | |
| 112 | + yield* host.ensureReady(() => Effect.void); | |
| 113 | + expect(forwards).toBe(1); | |
| 114 | + expect(modes.filter((mode) => mode === "start")).toHaveLength(2); | |
| 115 | + yield* host.platformAvailability("ios"); | |
| 116 | + expect((yield* host.summary).hubInstalled).toBe(true); | |
| 117 | + const failed = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.result); | |
| 118 | + expect(failed._tag).toBe("Failure"); | |
| 119 | + expect(forwards).toBe(0); | |
| 120 | + expect(modes.at(-1)).toBe("stop-agent"); | |
| 121 | + expect(yield* host.current).toBeNull(); | |
| 122 | + rejectConfig = false; | |
| 123 | + yield* host.ensureAgentReady(() => Effect.void); | |
| 124 | + yield* host.platformAvailability("ios"); | |
| 125 | + expect((yield* host.summary).agentDeviceInstalled).toBe(true); | |
| 126 | + yield* host.stopAgent; | |
| 127 | + expect(forwards).toBe(1); | |
| 128 | + yield* host.stop; | |
| 129 | + expect(forwards).toBe(0); | |
| 130 | + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 131 | +); | |
| @@ -0,0 +1,434 @@ | ||
| 1 | +import * as NodeCrypto from "node:crypto"; | |
| 2 | +import { | |
| 3 | + type DeviceHostSummary, | |
| 4 | + DevicePlatformAvailability, | |
| 5 | + type SshDeviceHostConfig, | |
| 6 | +} from "@t3tools/contracts"; | |
| 7 | +import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; | |
| 8 | +import * as NetService from "@t3tools/shared/Net"; | |
| 9 | +import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; | |
| 10 | +import * as Exit from "effect/Exit"; | |
| 11 | +import * as Effect from "effect/Effect"; | |
| 12 | +import * as FileSystem from "effect/FileSystem"; | |
| 13 | +import * as Path from "effect/Path"; | |
| 14 | +import * as Scope from "effect/Scope"; | |
| 15 | +import * as Semaphore from "effect/Semaphore"; | |
| 16 | +import * as Schema from "effect/Schema"; | |
| 17 | +import * as Stream from "effect/Stream"; | |
| 18 | +import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 19 | +import * as ChildProcess from "effect/unstable/process/ChildProcess"; | |
| 20 | +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 21 | +import * as ServerConfig from "../config.ts"; | |
| 22 | +import * as DeviceHost from "./DeviceHost.ts"; | |
| 23 | +import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; | |
| 24 | + | |
| 25 | +const Probe = Schema.Struct({ | |
| 26 | + nodePath: Schema.String, | |
| 27 | + platforms: Schema.Array(DevicePlatformAvailability), | |
| 28 | +}); | |
| 29 | +const Started = Schema.Struct({ | |
| 30 | + ...Probe.fields, | |
| 31 | + hubPort: Schema.Int, | |
| 32 | + daemonPort: Schema.optionalKey(Schema.Int), | |
| 33 | + token: Schema.optionalKey(Schema.String), | |
| 34 | + entryPath: Schema.optionalKey(Schema.String), | |
| 35 | + helpers: Schema.Struct({ | |
| 36 | + serveSimAxSettings: Schema.NullOr(Schema.String), | |
| 37 | + serveSimCli: Schema.NullOr(Schema.String), | |
| 38 | + }), | |
| 39 | +}); | |
| 40 | +const decodeProbe = Schema.decodeUnknownEffect(Schema.fromJsonString(Probe)); | |
| 41 | +const decodeStarted = Schema.decodeUnknownEffect(Schema.fromJsonString(Started)); | |
| 42 | +const targetFor = (config: SshDeviceHostConfig) => ({ | |
| 43 | + alias: config.target, | |
| 44 | + hostname: config.target, | |
| 45 | + username: null, | |
| 46 | + port: config.port ?? null, | |
| 47 | +}); | |
| 48 | +const identityArgs = (config: SshDeviceHostConfig) => | |
| 49 | + config.identityFile ? ["-i", config.identityFile] : []; | |
| 50 | +const commandArgs = (script: string) => [ | |
| 51 | + "sh", | |
| 52 | + "-c", | |
| 53 | + quoteRemoteArg(remoteDeviceEnvironment + script), | |
| 54 | +]; | |
| 55 | +const bootstrap = ( | |
| 56 | + config: SshDeviceHostConfig, | |
| 57 | + owner: string, | |
| 58 | + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", | |
| 59 | +) => | |
| 60 | + runSshCommand(targetFor(config), { | |
| 61 | + preHostArgs: identityArgs(config), | |
| 62 | + remoteCommandArgs: commandArgs( | |
| 63 | + 'command -v node >/dev/null 2>&1 || { echo "Node is missing from the non-interactive SSH PATH" >&2; exit 1; }; exec node', | |
| 64 | + ), | |
| 65 | + stdin: remoteDeviceScript(owner, mode), | |
| 66 | + timeoutMs: mode === "start" || mode === "agent-start" ? 1_300_000 : 45_000, | |
| 67 | + }).pipe( | |
| 68 | + Effect.mapError( | |
| 69 | + (cause) => new DeviceHost.DeviceHostError({ hostId: config.id, step: mode, cause }), | |
| 70 | + ), | |
| 71 | + ); | |
| 72 | + | |
| 73 | +export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { | |
| 74 | + const result = yield* bootstrap(config, "probe", "probe"); | |
| 75 | + const value = yield* decodeProbe(result.stdout.trim()).pipe( | |
| 76 | + Effect.mapError( | |
| 77 | + (cause) => | |
| 78 | + new DeviceHost.DeviceHostError({ hostId: config.id, step: "reading probe result", cause }), | |
| 79 | + ), | |
| 80 | + ); | |
| 81 | + return { | |
| 82 | + id: config.id, | |
| 83 | + label: config.label, | |
| 84 | + kind: "ssh", | |
| 85 | + hubInstalled: false, | |
| 86 | + agentDeviceInstalled: false, | |
| 87 | + platforms: value.platforms, | |
| 88 | + } satisfies DeviceHostSummary; | |
| 89 | +}); | |
| 90 | + | |
| 91 | +export const make = Effect.fn("SshDeviceHost.make")(function* ( | |
| 92 | + config: SshDeviceHostConfig, | |
| 93 | + onReady: ( | |
| 94 | + ready: DeviceHost.DeviceHostAgentReady, | |
| 95 | + ) => Effect.Effect<void, DeviceHost.DeviceHostError> = () => Effect.void, | |
| 96 | + onStatus: ( | |
| 97 | + status: "starting" | "ready" | "failed", | |
| 98 | + detail?: string, | |
| 99 | + ) => Effect.Effect<void> = () => Effect.void, | |
| 100 | +) { | |
| 101 | + const fs = yield* FileSystem.FileSystem; | |
| 102 | + const path = yield* Path.Path; | |
| 103 | + const server = yield* ServerConfig.ServerConfig; | |
| 104 | + const net = yield* NetService.NetService; | |
| 105 | + const http = yield* HttpClient.HttpClient; | |
| 106 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 107 | + const parentScope = yield* Scope.Scope; | |
| 108 | + const ssh = yield* resolveSshCommand; | |
| 109 | + const environmentId = yield* fs | |
| 110 | + .readFileString(server.environmentIdPath) | |
| 111 | + .pipe(Effect.orElseSucceed(() => server.stateDir)); | |
| 112 | + const owner = NodeCrypto.createHash("sha256") | |
| 113 | + .update(`${environmentId}\0${server.stateDir}\0${config.id}`) | |
| 114 | + .digest("hex") | |
| 115 | + .slice(0, 24); | |
| 116 | + const provide = <A, E>( | |
| 117 | + effect: Effect.Effect< | |
| 118 | + A, | |
| 119 | + E, | |
| 120 | + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | |
| 121 | + >, | |
| 122 | + ) => | |
| 123 | + effect.pipe( | |
| 124 | + Effect.provideService(FileSystem.FileSystem, fs), | |
| 125 | + Effect.provideService(Path.Path, path), | |
| 126 | + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 127 | + ); | |
| 128 | + const lock = yield* Semaphore.make(1); | |
| 129 | + let stopped = false; | |
| 130 | + let activated = false; | |
| 131 | + let wantsAgent = false; | |
| 132 | + let ready: | |
| 133 | + | (DeviceHost.DeviceHostReady & { | |
| 134 | + agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"]; | |
| 135 | + }) | |
| 136 | + | null = null; | |
| 137 | + let connectionScope: Scope.Closeable | null = null; | |
| 138 | + let summary: DeviceHostSummary = { | |
| 139 | + id: config.id, | |
| 140 | + label: config.label, | |
| 141 | + kind: "ssh", | |
| 142 | + hubInstalled: false, | |
| 143 | + agentDeviceInstalled: false, | |
| 144 | + platforms: [], | |
| 145 | + }; | |
| 146 | + | |
| 147 | + const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) => | |
| 148 | + provide( | |
| 149 | + runSshCommand(targetFor(config), { | |
| 150 | + preHostArgs: identityArgs(config), | |
| 151 | + remoteCommandArgs: commandArgs(`exec ${[command, ...args].map(quoteRemoteArg).join(" ")}`), | |
| 152 | + ...(options?.stdin === undefined ? {} : { stdin: options.stdin }), | |
| 153 | + ...(options?.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), | |
| 154 | + }), | |
| 155 | + ).pipe( | |
| 156 | + Effect.map((result) => ({ ...result, code: 0 })), | |
| 157 | + Effect.catch((error) => | |
| 158 | + Effect.succeed({ | |
| 159 | + stdout: "stdout" in error ? (error.stdout ?? "") : "", | |
| 160 | + stderr: error.message, | |
| 161 | + code: "exitCode" in error ? (error.exitCode ?? 127) : 127, | |
| 162 | + }), | |
| 163 | + ), | |
| 164 | + ); | |
| 165 | + | |
| 166 | + const connectOnce = Effect.fn("SshDeviceHost.connectOnce")(function* (): Effect.fn.Return< | |
| 167 | + DeviceHost.DeviceHostReady & { agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"] }, | |
| 168 | + DeviceHost.DeviceHostError | |
| 169 | + > { | |
| 170 | + activated = true; | |
| 171 | + const result = yield* provide(bootstrap(config, owner, wantsAgent ? "agent-start" : "start")); | |
| 172 | + yield* onStatus("starting"); | |
| 173 | + const remote = yield* decodeStarted(result.stdout.trim()).pipe( | |
| 174 | + Effect.mapError( | |
| 175 | + (cause) => | |
| 176 | + new DeviceHost.DeviceHostError({ | |
| 177 | + hostId: config.id, | |
| 178 | + step: "reading host endpoints", | |
| 179 | + cause, | |
| 180 | + }), | |
| 181 | + ), | |
| 182 | + ); | |
| 183 | + summary = { | |
| 184 | + ...summary, | |
| 185 | + platforms: remote.platforms, | |
| 186 | + hubInstalled: true, | |
| 187 | + agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, | |
| 188 | + }; | |
| 189 | + const hubPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe( | |
| 190 | + Effect.mapError( | |
| 191 | + (cause) => | |
| 192 | + new DeviceHost.DeviceHostError({ | |
| 193 | + hostId: config.id, | |
| 194 | + step: "reserving hub port", | |
| 195 | + cause, | |
| 196 | + }), | |
| 197 | + ), | |
| 198 | + ); | |
| 199 | + const daemonPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe( | |
| 200 | + Effect.mapError( | |
| 201 | + (cause) => | |
| 202 | + new DeviceHost.DeviceHostError({ | |
| 203 | + hostId: config.id, | |
| 204 | + step: "reserving daemon port", | |
| 205 | + cause, | |
| 206 | + }), | |
| 207 | + ), | |
| 208 | + ); | |
| 209 | + const scope = yield* Scope.make(); | |
| 210 | + connectionScope = scope; | |
| 211 | + const child = yield* spawner | |
| 212 | + .spawn( | |
| 213 | + ChildProcess.make( | |
| 214 | + ssh, | |
| 215 | + [ | |
| 216 | + ...baseSshArgs(targetFor(config), { batchMode: "yes" }), | |
| 217 | + ...identityArgs(config), | |
| 218 | + "-o", | |
| 219 | + "ExitOnForwardFailure=yes", | |
| 220 | + "-o", | |
| 221 | + "ServerAliveInterval=10", | |
| 222 | + "-o", | |
| 223 | + "ServerAliveCountMax=3", | |
| 224 | + "-N", | |
| 225 | + "-L", | |
| 226 | + `127.0.0.1:${hubPort}:127.0.0.1:${remote.hubPort}`, | |
| 227 | + ...(remote.daemonPort === undefined | |
| 228 | + ? [] | |
| 229 | + : ["-L", `127.0.0.1:${daemonPort}:127.0.0.1:${remote.daemonPort}`]), | |
| 230 | + config.target, | |
| 231 | + ], | |
| 232 | + { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, | |
| 233 | + ), | |
| 234 | + ) | |
| 235 | + .pipe( | |
| 236 | + Effect.provideService(Scope.Scope, scope), | |
| 237 | + Effect.mapError( | |
| 238 | + (cause) => | |
| 239 | + new DeviceHost.DeviceHostError({ hostId: config.id, step: "forwarding ports", cause }), | |
| 240 | + ), | |
| 241 | + ); | |
| 242 | + let stderr = ""; | |
| 243 | + yield* child.stderr.pipe( | |
| 244 | + Stream.decodeText(), | |
| 245 | + Stream.runForEach((chunk) => | |
| 246 | + Effect.sync(() => { | |
| 247 | + stderr = (stderr + chunk).slice(-2000); | |
| 248 | + }), | |
| 249 | + ), | |
| 250 | + Effect.forkIn(scope), | |
| 251 | + ); | |
| 252 | + const next = { | |
| 253 | + nodePath: remote.nodePath, | |
| 254 | + hub: { origin: `http://127.0.0.1:${hubPort}` }, | |
| 255 | + ...(remote.daemonPort !== undefined && | |
| 256 | + remote.token !== undefined && | |
| 257 | + remote.entryPath !== undefined | |
| 258 | + ? { | |
| 259 | + agentDevice: { | |
| 260 | + baseUrl: `http://127.0.0.1:${daemonPort}`, | |
| 261 | + token: remote.token, | |
| 262 | + entryPath: remote.entryPath, | |
| 263 | + }, | |
| 264 | + } | |
| 265 | + : {}), | |
| 266 | + helpers: remote.helpers, | |
| 267 | + run, | |
| 268 | + }; | |
| 269 | + for (const [baseUrl, route] of [ | |
| 270 | + [next.hub.origin, "/readyz"], | |
| 271 | + ...(next.agentDevice ? [[next.agentDevice.baseUrl, "/health"]] : []), | |
| 272 | + ]) { | |
| 273 | + yield* waitForHttpReady({ | |
| 274 | + baseUrl: baseUrl!, | |
| 275 | + path: route!, | |
| 276 | + timeoutMs: 15000, | |
| 277 | + makeError: () => | |
| 278 | + new DeviceHost.DeviceHostError({ | |
| 279 | + hostId: config.id, | |
| 280 | + step: "waiting for SSH forward", | |
| 281 | + cause: new Error(stderr || "Forwarded endpoint did not answer."), | |
| 282 | + }), | |
| 283 | + }).pipe(Effect.provideService(HttpClient.HttpClient, http)); | |
| 284 | + } | |
| 285 | + if (next.agentDevice) yield* onReady({ ...next, agentDevice: next.agentDevice }); | |
| 286 | + ready = next; | |
| 287 | + yield* onStatus("ready"); | |
| 288 | + // Reconnect also repairs helpers that died while SSH itself stayed connected. | |
| 289 | + const unhealthy = Effect.gen(function* () { | |
| 290 | + while (true) { | |
| 291 | + yield* Effect.sleep("10 seconds"); | |
| 292 | + const alive = yield* http.get(`${next.hub.origin}/readyz`).pipe( | |
| 293 | + Effect.timeout("5 seconds"), | |
| 294 | + Effect.map((r) => r.status === 200), | |
| 295 | + Effect.orElseSucceed(() => false), | |
| 296 | + ); | |
| 297 | + const daemonAlive = next.agentDevice | |
| 298 | + ? yield* http.get(`${next.agentDevice!.baseUrl}/health`).pipe( | |
| 299 | + Effect.timeout("5 seconds"), | |
| 300 | + Effect.map((r) => r.status === 200), | |
| 301 | + Effect.orElseSucceed(() => false), | |
| 302 | + ) | |
| 303 | + : true; | |
| 304 | + if (!alive || !daemonAlive) return; | |
| 305 | + } | |
| 306 | + }); | |
| 307 | + yield* Effect.gen(function* () { | |
| 308 | + yield* Effect.raceFirst(child.exitCode.pipe(Effect.ignore), unhealthy); | |
| 309 | + if (stopped || connectionScope !== scope) return; | |
| 310 | + ready = null; | |
| 311 | + yield* onStatus("starting", "Reconnecting to device host…"); | |
| 312 | + yield* Scope.close(scope, Exit.void); | |
| 313 | + let delay = 1000; | |
| 314 | + while (true) { | |
| 315 | + if (stopped || connectionScope !== scope) return; | |
| 316 | + yield* Effect.sleep(delay); | |
| 317 | + const result = yield* lock | |
| 318 | + .withPermit( | |
| 319 | + Effect.suspend(() => (stopped || ready ? Effect.void : connect().pipe(Effect.asVoid))), | |
| 320 | + ) | |
| 321 | + .pipe(Effect.result); | |
| 322 | + if (result._tag === "Success") return; | |
| 323 | + yield* onStatus("failed", result.failure.message); | |
| 324 | + if (connectionScope && connectionScope !== scope) | |
| 325 | + yield* Scope.close(connectionScope, Exit.void); | |
| 326 | + connectionScope = scope; | |
| 327 | + delay = Math.min(delay * 2, 30000); | |
| 328 | + } | |
| 329 | + }).pipe(Effect.forkIn(parentScope)); | |
| 330 | + return next; | |
| 331 | + }); | |
| 332 | + | |
| 333 | + const connect = Effect.fn("SshDeviceHost.connect")(function* () { | |
| 334 | + for (let attempt = 0; ; attempt++) { | |
| 335 | + const result = yield* connectOnce().pipe(Effect.result); | |
| 336 | + if (result._tag === "Success") return result.success; | |
| 337 | + const failedScope = connectionScope; | |
| 338 | + connectionScope = null; | |
| 339 | + if (failedScope) yield* Scope.close(failedScope, Exit.void); | |
| 340 | + // SSH binds after the reservation is released, so a competing bind needs fresh ports. | |
| 341 | + if ( | |
| 342 | + attempt >= 2 || | |
| 343 | + !["forwarding ports", "waiting for SSH forward"].includes(result.failure.step) | |
| 344 | + ) | |
| 345 | + return yield* result.failure; | |
| 346 | + } | |
| 347 | + }); | |
| 348 | + | |
| 349 | + const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) => | |
| 350 | + lock.withPermit( | |
| 351 | + Effect.gen(function* () { | |
| 352 | + stopped = false; | |
| 353 | + if (ready) return ready; | |
| 354 | + summary = yield* provide(probe(config)); | |
| 355 | + yield* onPhase("installing"); | |
| 356 | + return yield* connect().pipe( | |
| 357 | + Effect.tapError(() => | |
| 358 | + connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void, | |
| 359 | + ), | |
| 360 | + ); | |
| 361 | + }), | |
| 362 | + ); | |
| 363 | + const stop = lock.withPermit( | |
| 364 | + Effect.gen(function* () { | |
| 365 | + stopped = true; | |
| 366 | + ready = null; | |
| 367 | + if (connectionScope) yield* Scope.close(connectionScope, Exit.void); | |
| 368 | + connectionScope = null; | |
| 369 | + if (activated) yield* provide(bootstrap(config, owner, "stop")).pipe(Effect.ignore); | |
| 370 | + activated = false; | |
| 371 | + wantsAgent = false; | |
| 372 | + }), | |
| 373 | + ); | |
| 374 | + const changeAgent = (enabled: boolean) => | |
| 375 | + lock.withPermit( | |
| 376 | + Effect.gen(function* () { | |
| 377 | + wantsAgent = enabled; | |
| 378 | + if (enabled && ready?.agentDevice) return { ...ready, agentDevice: ready.agentDevice }; | |
| 379 | + if (!enabled && !ready?.agentDevice) return null; | |
| 380 | + ready = null; | |
| 381 | + const previousScope = connectionScope; | |
| 382 | + connectionScope = null; | |
| 383 | + if (previousScope) yield* Scope.close(previousScope, Exit.void); | |
| 384 | + if (!enabled) yield* provide(bootstrap(config, owner, "stop-agent")); | |
| 385 | + return yield* connect().pipe( | |
| 386 | + Effect.onError(() => | |
| 387 | + Effect.gen(function* () { | |
| 388 | + const failedScope = connectionScope; | |
| 389 | + connectionScope = null; | |
| 390 | + if (failedScope) yield* Scope.close(failedScope, Exit.void); | |
| 391 | + if (enabled) | |
| 392 | + yield* provide(bootstrap(config, owner, "stop-agent")).pipe(Effect.ignore); | |
| 393 | + }), | |
| 394 | + ), | |
| 395 | + ); | |
| 396 | + }), | |
| 397 | + ); | |
| 398 | + yield* Effect.addFinalizer(() => stop); | |
| 399 | + return { | |
| 400 | + id: config.id, | |
| 401 | + summary: Effect.sync(() => summary), | |
| 402 | + current: Effect.sync(() => ready), | |
| 403 | + ensureReady, | |
| 404 | + ensureAgentReady: (onPhase) => | |
| 405 | + onPhase("installing").pipe( | |
| 406 | + Effect.flatMap(() => changeAgent(true)), | |
| 407 | + Effect.flatMap((value) => | |
| 408 | + value?.agentDevice | |
| 409 | + ? Effect.succeed({ ...value, agentDevice: value.agentDevice }) | |
| 410 | + : Effect.fail( | |
| 411 | + new DeviceHost.DeviceHostError({ | |
| 412 | + hostId: config.id, | |
| 413 | + step: "starting agent tools", | |
| 414 | + cause: new Error("Daemon endpoint missing"), | |
| 415 | + }), | |
| 416 | + ), | |
| 417 | + ), | |
| 418 | + ), | |
| 419 | + stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), | |
| 420 | + stop, | |
| 421 | + platformAvailability: (platform) => | |
| 422 | + provide(probe(config)).pipe( | |
| 423 | + Effect.map((value) => { | |
| 424 | + summary = { ...summary, platforms: value.platforms }; | |
| 425 | + return value.platforms.find((p) => p.platform === platform)!; | |
| 426 | + }), | |
| 427 | + Effect.orElseSucceed(() => ({ | |
| 428 | + platform, | |
| 429 | + available: false, | |
| 430 | + reason: "Cannot reach device host. Test its SSH connection in Settings.", | |
| 431 | + })), | |
| 432 | + ), | |
| 433 | + } satisfies DeviceHost.DeviceHost["Service"]; | |
| 434 | +}); | |
| @@ -0,0 +1,220 @@ | ||
| 1 | +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - verifies generated remote scripts using real shell and Node processes. | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | +import { describe, expect, it } from "@effect/vitest"; | |
| 5 | +import * as NodeChildProcess from "node:child_process"; | |
| 6 | +import * as NodeFSP from "node:fs/promises"; | |
| 7 | +import * as NodeOS from "node:os"; | |
| 8 | +import * as NodePath from "node:path"; | |
| 9 | +import * as NodeUtil from "node:util"; | |
| 10 | +import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; | |
| 11 | +import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; | |
| 12 | + | |
| 13 | +const exec = NodeUtil.promisify(NodeChildProcess.execFile); | |
| 14 | + | |
| 15 | +it.effect("finds Android Studio Java for a non-interactive SSH session", () => | |
| 16 | + Effect.gen(function* () { | |
| 17 | + if ((yield* HostProcessPlatform) === "win32") return; | |
| 18 | + yield* Effect.promise(async () => { | |
| 19 | + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-java-")); | |
| 20 | + try { | |
| 21 | + const javaHome = NodePath.join(home, ".local/opt/android-studio/jbr"); | |
| 22 | + await NodeFSP.mkdir(NodePath.join(javaHome, "bin"), { recursive: true }); | |
| 23 | + await NodeFSP.writeFile( | |
| 24 | + NodePath.join(javaHome, "bin/java"), | |
| 25 | + "#!/bin/sh\necho test-java\n", | |
| 26 | + { mode: 0o755 }, | |
| 27 | + ); | |
| 28 | + const result = await exec("/bin/sh", ["-c", `${remoteDeviceEnvironment}\njava`], { | |
| 29 | + env: { HOME: home, PATH: "/nonexistent", JAVA_HOME: "" }, | |
| 30 | + }); | |
| 31 | + expect(result.stdout.trim()).toBe("test-java"); | |
| 32 | + } finally { | |
| 33 | + await NodeFSP.rm(home, { recursive: true, force: true }); | |
| 34 | + } | |
| 35 | + }); | |
| 36 | + }), | |
| 37 | +); | |
| 38 | + | |
| 39 | +it.effect("preserves shell metacharacters and newlines in remote arguments", () => | |
| 40 | + Effect.gen(function* () { | |
| 41 | + if ((yield* HostProcessPlatform) === "win32") return; | |
| 42 | + yield* Effect.promise(async () => { | |
| 43 | + const value = "quotes ' \" ; $(echo expanded) $HOME\nnext line"; | |
| 44 | + const result = await exec("sh", ["-c", `printf %s ${quoteRemoteArg(value)}`]); | |
| 45 | + expect(result.stdout).toBe(value); | |
| 46 | + }); | |
| 47 | + }), | |
| 48 | +); | |
| 49 | + | |
| 50 | +describe("remote helper lifecycle", () => { | |
| 51 | + it.effect("reuses its own healthy helpers and stops only its own runtime", () => | |
| 52 | + Effect.gen(function* () { | |
| 53 | + if ((yield* HostProcessPlatform) === "win32") return; | |
| 54 | + yield* Effect.promise(async () => { | |
| 55 | + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-remote-script-")); | |
| 56 | + const bin = NodePath.join(home, "bin"); | |
| 57 | + await NodeFSP.mkdir(bin); | |
| 58 | + await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); | |
| 59 | + const root = NodePath.join(home, ".t3/device"); | |
| 60 | + const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`); | |
| 61 | + const agentDir = NodePath.join(root, `tools/agent-device@${AGENT_DEVICE_VERSION}`); | |
| 62 | + const hub = NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server/cli.mjs"); | |
| 63 | + const agent = NodePath.join(agentDir, "node_modules/agent-device/bin/agent-device.mjs"); | |
| 64 | + await NodeFSP.mkdir(NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server"), { | |
| 65 | + recursive: true, | |
| 66 | + }); | |
| 67 | + await NodeFSP.mkdir(NodePath.join(agentDir, "node_modules/agent-device/bin"), { | |
| 68 | + recursive: true, | |
| 69 | + }); | |
| 70 | + await NodeFSP.writeFile(NodePath.join(hubDir, ".install-complete"), DEVICE_HUB_VERSION); | |
| 71 | + await NodeFSP.writeFile(NodePath.join(agentDir, ".install-complete"), AGENT_DEVICE_VERSION); | |
| 72 | + await NodeFSP.writeFile( | |
| 73 | + hub, | |
| 74 | + `import http from 'node:http'; import fs from 'node:fs'; | |
| 75 | +if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);} | |
| 76 | +const args=process.argv.slice(2); http.createServer((req,res)=>{res.statusCode=fs.existsSync('unhealthy-'+process.pid)?503:200;res.end('ok');}).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`, | |
| 77 | + ); | |
| 78 | + await NodeFSP.writeFile( | |
| 79 | + agent, | |
| 80 | + `import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import {spawn} from 'node:child_process'; | |
| 81 | +const args=process.argv.slice(2); | |
| 82 | +const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir')+1]; | |
| 83 | +const file=path.join(state,'daemon.json'); | |
| 84 | +if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} } | |
| 85 | +else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); } | |
| 86 | +else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } | |
| 87 | +`, | |
| 88 | + ); | |
| 89 | + const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; | |
| 90 | + const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; | |
| 91 | + let invocation = 0; | |
| 92 | + const invoke = async ( | |
| 93 | + owner: string, | |
| 94 | + mode: "start" | "agent-start" | "stop-agent" | "stop", | |
| 95 | + upgraded = false, | |
| 96 | + ) => { | |
| 97 | + const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); | |
| 98 | + await NodeFSP.writeFile( | |
| 99 | + file, | |
| 100 | + `const originalKill = process.kill; process.kill = (pid, signal) => { if (signal === 'SIGTERM') require('node:fs').appendFileSync(${JSON.stringify(NodePath.join(home, "stops"))}, pid+'\\n'); return originalKill(pid, signal); };\n` + | |
| 101 | + remoteDeviceScript(owner, mode) | |
| 102 | + .replace(DEVICE_HUB_VERSION, upgraded ? nextHubVersion : DEVICE_HUB_VERSION) | |
| 103 | + .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION), | |
| 104 | + ); | |
| 105 | + const result = await exec(process.execPath, [file], { | |
| 106 | + env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, | |
| 107 | + }); | |
| 108 | + return result.stdout ? JSON.parse(result.stdout) : null; | |
| 109 | + }; | |
| 110 | + const template = NodePath.join(home, "hub-template"); | |
| 111 | + await NodeFSP.cp(hubDir, template, { recursive: true }); | |
| 112 | + await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); | |
| 113 | + const installLock = hubDir + ".lock"; | |
| 114 | + await NodeFSP.symlink("2147483647:exited-installer", installLock); | |
| 115 | + await NodeFSP.writeFile( | |
| 116 | + NodePath.join(bin, "npm"), | |
| 117 | + `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, | |
| 118 | + { mode: 0o755 }, | |
| 119 | + ); | |
| 120 | + await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); | |
| 121 | + await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); | |
| 122 | + try { | |
| 123 | + const [manual, concurrent] = await Promise.all([ | |
| 124 | + invoke("one", "start"), | |
| 125 | + invoke("one", "start"), | |
| 126 | + ]); | |
| 127 | + expect(concurrent.hubPort).toBe(manual.hubPort); | |
| 128 | + expect(manual.daemonPort).toBeUndefined(); | |
| 129 | + await expect( | |
| 130 | + NodeFSP.stat(NodePath.join(root, "hosts/one/daemon.json")), | |
| 131 | + ).rejects.toThrow(); | |
| 132 | + const [first, concurrentAgent] = await Promise.all([ | |
| 133 | + invoke("one", "agent-start"), | |
| 134 | + invoke("one", "agent-start"), | |
| 135 | + ]); | |
| 136 | + expect(concurrentAgent.hubPort).toBe(first.hubPort); | |
| 137 | + expect(concurrentAgent.daemonPort).toBe(first.daemonPort); | |
| 138 | + const second = await invoke("two", "agent-start"); | |
| 139 | + const reused = await invoke("one", "agent-start"); | |
| 140 | + expect(reused.hubPort).toBe(first.hubPort); | |
| 141 | + expect(reused.daemonPort).toBe(first.daemonPort); | |
| 142 | + expect(second.hubPort).not.toBe(first.hubPort); | |
| 143 | + expect(second.daemonPort).not.toBe(first.daemonPort); | |
| 144 | + const firstHub = JSON.parse( | |
| 145 | + await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"), | |
| 146 | + ); | |
| 147 | + const secondHub = JSON.parse( | |
| 148 | + await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"), | |
| 149 | + ); | |
| 150 | + await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), ""); | |
| 151 | + let repaired = await invoke("one", "agent-start"); | |
| 152 | + expect(repaired.hubPort).not.toBe(first.hubPort); | |
| 153 | + const stopped = (await NodeFSP.readFile(NodePath.join(home, "stops"), "utf8")) | |
| 154 | + .trim() | |
| 155 | + .split("\n"); | |
| 156 | + expect(stopped).toContain(String(firstHub.pid)); | |
| 157 | + expect(stopped).not.toContain(String(secondHub.pid)); | |
| 158 | + const previousDaemon = JSON.parse( | |
| 159 | + await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), | |
| 160 | + ); | |
| 161 | + for (const [source, name, version] of [ | |
| 162 | + [hubDir, "expo-device-hub", nextHubVersion], | |
| 163 | + [agentDir, "agent-device", nextAgentVersion], | |
| 164 | + ]) { | |
| 165 | + const destination = NodePath.join(root, `tools/${name}@${version}`); | |
| 166 | + await NodeFSP.cp(source!, destination, { recursive: true }); | |
| 167 | + await NodeFSP.writeFile(NodePath.join(destination, ".install-complete"), version!); | |
| 168 | + } | |
| 169 | + const upgraded = await invoke("one", "agent-start", true); | |
| 170 | + expect(upgraded.entryPath).toContain(nextAgentVersion); | |
| 171 | + const upgradedHub = JSON.parse( | |
| 172 | + await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"), | |
| 173 | + ); | |
| 174 | + expect(upgradedHub.entryPath).toContain(nextHubVersion); | |
| 175 | + const upgradedDaemon = JSON.parse( | |
| 176 | + await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), | |
| 177 | + ); | |
| 178 | + expect(upgradedDaemon.pid).not.toBe(previousDaemon.pid); | |
| 179 | + expect(await invoke("one", "agent-start", true)).toEqual(upgraded); | |
| 180 | + await NodeFSP.writeFile( | |
| 181 | + NodePath.join(root, `hosts/one/unhealthy-agent-${upgradedDaemon.pid}`), | |
| 182 | + "", | |
| 183 | + ); | |
| 184 | + repaired = await invoke("one", "agent-start", true); | |
| 185 | + expect( | |
| 186 | + await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"), | |
| 187 | + ).toBe(String(upgradedDaemon.pid)); | |
| 188 | + expect(repaired.daemonPort).not.toBe(upgraded.daemonPort); | |
| 189 | + // Stop still uses the recorded entry when a future pinned package is not installed yet. | |
| 190 | + const originalScript = remoteDeviceScript("one", "stop-agent"); | |
| 191 | + const upgradedStop = NodePath.join(home, "upgraded-stop.cjs"); | |
| 192 | + await NodeFSP.writeFile( | |
| 193 | + upgradedStop, | |
| 194 | + originalScript.replace(AGENT_DEVICE_VERSION, "999.0.0"), | |
| 195 | + ); | |
| 196 | + await exec(process.execPath, [upgradedStop], { | |
| 197 | + env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, | |
| 198 | + }); | |
| 199 | + const daemon = JSON.parse( | |
| 200 | + await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), | |
| 201 | + ); | |
| 202 | + expect( | |
| 203 | + await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"), | |
| 204 | + ).toBe(String(daemon.pid)); | |
| 205 | + expect((await fetch(`http://127.0.0.1:${repaired.hubPort}/readyz`)).ok).toBe(true); | |
| 206 | + await invoke("one", "stop"); | |
| 207 | + expect((await fetch(`http://127.0.0.1:${second.hubPort}/readyz`)).ok).toBe(true); | |
| 208 | + expect( | |
| 209 | + JSON.parse(await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8")) | |
| 210 | + .owner, | |
| 211 | + ).toBe("two"); | |
| 212 | + } finally { | |
| 213 | + await invoke("one", "stop").catch(() => {}); | |
| 214 | + await invoke("two", "stop").catch(() => {}); | |
| 215 | + await NodeFSP.rm(home, { recursive: true, force: true }); | |
| 216 | + } | |
| 217 | + }); | |
| 218 | + }), | |
| 219 | + ); | |
| 220 | +}); | |
| @@ -0,0 +1,192 @@ | ||
| 1 | +import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; | |
| 2 | + | |
| 3 | +export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`; | |
| 4 | + | |
| 5 | +/** Resolve common non-interactive SDK and Node locations without sourcing user shell scripts. */ | |
| 6 | +export const remoteDeviceEnvironment = `export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" | |
| 7 | +if [ -z "$ANDROID_HOME" ]; then | |
| 8 | + if [ -d "$HOME/Library/Android/sdk" ]; then export ANDROID_HOME="$HOME/Library/Android/sdk"; | |
| 9 | + elif [ -d "$HOME/Android/Sdk" ]; then export ANDROID_HOME="$HOME/Android/Sdk"; fi | |
| 10 | +fi | |
| 11 | +if [ -n "$ANDROID_HOME" ]; then export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"; fi | |
| 12 | +if [ -z "$JAVA_HOME" ] && ! command -v java >/dev/null 2>&1; then | |
| 13 | + for device_java_home in "$HOME/.local/opt/android-studio/jbr" /opt/android-studio/jbr /Applications/Android\\ Studio.app/Contents/jbr "$HOME/Applications/Android Studio.app/Contents/jbr"; do | |
| 14 | + if [ -x "$device_java_home/bin/java" ]; then export JAVA_HOME="$device_java_home"; break; fi | |
| 15 | + done | |
| 16 | +fi | |
| 17 | +if [ -n "$JAVA_HOME" ]; then export PATH="$JAVA_HOME/bin:$PATH"; fi | |
| 18 | +`; | |
| 19 | + | |
| 20 | +/** Node runs this on the host. All paths it returns belong to that host. */ | |
| 21 | +export const remoteDeviceScript = ( | |
| 22 | + owner: string, | |
| 23 | + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", | |
| 24 | +) => | |
| 25 | + ` | |
| 26 | +const owner = ${JSON.stringify(owner)}; | |
| 27 | +const mode = ${JSON.stringify(mode)}; | |
| 28 | +const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)}; | |
| 29 | +const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)}; | |
| 30 | +` + | |
| 31 | + String.raw` | |
| 32 | +const fs = require('node:fs'); | |
| 33 | +const path = require('node:path'); | |
| 34 | +const os = require('node:os'); | |
| 35 | +const net = require('node:net'); | |
| 36 | +const { spawn, spawnSync } = require('node:child_process'); | |
| 37 | +const root = path.join(os.homedir(), '.t3', 'device'); | |
| 38 | +const state = path.join(root, 'hosts', owner); | |
| 39 | +const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); | |
| 40 | +const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; | |
| 41 | +const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); }; | |
| 42 | +const stopHub = hub => { | |
| 43 | + if (!hub || hub.owner !== owner) return; | |
| 44 | + const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; | |
| 45 | + if (command.includes(hub.entryPath) && command.includes(String(hub.port))) { | |
| 46 | + try { process.kill(hub.pid, 'SIGTERM'); } catch {} | |
| 47 | + } | |
| 48 | +}; | |
| 49 | +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); | |
| 50 | +const healthy = async (port, route) => { try { return (await fetch('http://127.0.0.1:' + port + route, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; } }; | |
| 51 | +const port = () => new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const value = server.address().port; server.close(() => resolve(value)); }); }); | |
| 52 | +async function acquireLock(lock, complete = () => false) { | |
| 53 | + const deadline = Date.now() + 600000; | |
| 54 | + const token = process.pid + ':' + require('node:crypto').randomUUID(); | |
| 55 | + const owner = () => { try { return fs.readlinkSync(lock); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }; | |
| 56 | + while (true) { | |
| 57 | + try { | |
| 58 | + // Publishing the PID and token is atomic; suspension cannot leave an incomplete owner. | |
| 59 | + fs.symlinkSync(token, lock); | |
| 60 | + return () => { if (owner() === token) fs.unlinkSync(lock); }; | |
| 61 | + } catch (error) { | |
| 62 | + if (error.code !== 'EEXIST') throw error; | |
| 63 | + if (complete()) return null; | |
| 64 | + const previous = owner(); | |
| 65 | + if (previous === null) continue; | |
| 66 | + const pid = Number(previous.split(':')[0]); | |
| 67 | + if (!Number.isSafeInteger(pid) || pid <= 0) throw Error('Invalid device lock at ' + lock); | |
| 68 | + try { process.kill(pid, 0); } catch (error) { | |
| 69 | + if (error.code === 'ESRCH' && owner() === previous) { | |
| 70 | + try { fs.unlinkSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; } | |
| 71 | + continue; | |
| 72 | + } | |
| 73 | + } | |
| 74 | + if (Date.now() > deadline) throw Error('Device operation is locked at ' + lock + '. Check the other installer before removing the lock.'); | |
| 75 | + await sleep(500); | |
| 76 | + } | |
| 77 | + } | |
| 78 | +} | |
| 79 | +async function install(name, version, entry) { | |
| 80 | + const dir = path.join(root, 'tools', name + '@' + version); | |
| 81 | + const file = path.join(dir, 'node_modules', name, entry); | |
| 82 | + const complete = () => fs.existsSync(file) && fs.existsSync(path.join(dir, '.install-complete')) && fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version; | |
| 83 | + if (complete()) return file; | |
| 84 | + fs.mkdirSync(path.dirname(dir), { recursive: true }); | |
| 85 | + const lock = dir + '.lock'; | |
| 86 | + const release = await acquireLock(lock, complete); | |
| 87 | + if (!release) return file; | |
| 88 | + let staging; | |
| 89 | + try { | |
| 90 | + if (complete()) return file; | |
| 91 | + staging = fs.mkdtempSync(path.join(path.dirname(dir), '.install-')); | |
| 92 | + const result = run('npm', ['install', '--prefix', staging, '--no-fund', '--no-audit', name + '@' + version], { timeout: 600000, maxBuffer: 8 * 1024 * 1024 }); | |
| 93 | + if (result.status !== 0) throw Error('Installing ' + name + ': ' + (result.error?.message || result.stderr?.slice(-2000))); | |
| 94 | + if (!fs.existsSync(path.join(staging, 'node_modules', name, entry))) throw Error('Missing installed entry for ' + name); | |
| 95 | + fs.writeFileSync(path.join(staging, '.install-complete'), version); | |
| 96 | + fs.rmSync(dir, { recursive: true, force: true }); | |
| 97 | + fs.renameSync(staging, dir); | |
| 98 | + return file; | |
| 99 | + } finally { | |
| 100 | + if (staging) fs.rmSync(staging, { recursive: true, force: true }); | |
| 101 | + release(); | |
| 102 | + } | |
| 103 | +} | |
| 104 | +(async () => { | |
| 105 | + const ios = process.platform === 'darwin' && run('xcrun', ['simctl', 'help']).status === 0; | |
| 106 | + const android = run('adb', ['version']).status === 0; | |
| 107 | + const platforms = [ | |
| 108 | + { platform: 'ios', available: ios, ...(!ios ? { reason: 'iOS needs macOS with Xcode and working xcrun simctl.' } : {}) }, | |
| 109 | + { platform: 'android', available: android, ...(!android ? { reason: 'Android SDK missing. Set ANDROID_HOME or put adb on the SSH PATH.' } : {}) }, | |
| 110 | + ]; | |
| 111 | + if (mode === 'probe') { | |
| 112 | + if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); | |
| 113 | + if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); | |
| 114 | + console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return; | |
| 115 | + } | |
| 116 | + fs.mkdirSync(state, { recursive: true, mode: 0o700 }); | |
| 117 | + // Serialize starts and stops for this environment/host owner, including agent startup. | |
| 118 | + const hostLock = path.join(state, 'runtime.lock'); | |
| 119 | + const releaseHost = await acquireLock(hostLock); | |
| 120 | + try { | |
| 121 | + const hubFile = path.join(state, 'hub.json'); | |
| 122 | + const daemonFile = path.join(state, 'daemon.json'); | |
| 123 | + const agentFile = path.join(state, 'agent.json'); | |
| 124 | + if (mode === 'stop' || mode === 'stop-agent') { | |
| 125 | + const hub = read(hubFile); | |
| 126 | + if (mode === 'stop' && hub && hub.owner === owner) { | |
| 127 | + stopHub(hub); | |
| 128 | + fs.rmSync(hubFile, { force: true }); | |
| 129 | + } | |
| 130 | + const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs'); | |
| 131 | + if (fs.existsSync(entry)) run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]); | |
| 132 | + return; | |
| 133 | + } | |
| 134 | + if (!ios && !android) throw Error(platforms.map(p => p.reason).join(' ')); | |
| 135 | + fs.mkdirSync(state, { recursive: true, mode: 0o700 }); | |
| 136 | + const hubEntry = await install('expo-device-hub', hubVersion, 'dist/server/cli.mjs'); | |
| 137 | + let hub = read(hubFile); | |
| 138 | + if (!hub || hub.owner !== owner || hub.entryPath !== hubEntry || !await healthy(hub.port, '/readyz')) { | |
| 139 | + stopHub(hub); | |
| 140 | + for (let attempt = 0; attempt < 5; attempt++) { | |
| 141 | + const hubPort = await port(); | |
| 142 | + const log = fs.openSync(path.join(state, 'hub.log'), 'a'); | |
| 143 | + const child = spawn(process.execPath, [hubEntry, '--port', String(hubPort), '--host', '127.0.0.1', '--hide-sidebar', '--hide-boot-device'], { | |
| 144 | + cwd: state, detached: true, stdio: ['ignore', log, log], env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }, | |
| 145 | + }); | |
| 146 | + try { await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); } | |
| 147 | + finally { fs.closeSync(log); } | |
| 148 | + child.unref(); | |
| 149 | + hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry }; | |
| 150 | + write(hubFile, hub); | |
| 151 | + const deadline = Date.now() + 30000; | |
| 152 | + let listening = false; | |
| 153 | + while (child.exitCode === null && child.signalCode === null) { | |
| 154 | + if (await healthy(hub.port, '/readyz')) { listening = true; break; } | |
| 155 | + if (Date.now() > deadline) { stopHub(hub); throw Error('Device hub did not become ready. See ' + path.join(state, 'hub.log')); } | |
| 156 | + await sleep(200); | |
| 157 | + } | |
| 158 | + if (listening) break; | |
| 159 | + // Port reservation and binding happen in different processes. Retry an early exit with a fresh port. | |
| 160 | + fs.rmSync(hubFile, { force: true }); | |
| 161 | + if (attempt === 4) throw Error('Device hub exited before becoming ready. See ' + path.join(state, 'hub.log')); | |
| 162 | + } | |
| 163 | + } | |
| 164 | + let agentResult = {}; | |
| 165 | + if (mode === 'agent-start') { | |
| 166 | + const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs'); | |
| 167 | + const previousAgent = read(agentFile)?.entryPath; | |
| 168 | + let daemon = read(daemonFile); | |
| 169 | + if (daemon && (previousAgent !== agentEntry || !await healthy(daemon.httpPort, '/health'))) { | |
| 170 | + const stopped = run(process.execPath, [previousAgent || agentEntry, 'daemon', 'stop', '--state-dir', state]); | |
| 171 | + if (stopped.status !== 0) throw Error('Could not stop the previous agent-device version.'); | |
| 172 | + fs.rmSync(daemonFile, { force: true }); | |
| 173 | + daemon = null; | |
| 174 | + } | |
| 175 | + if (!daemon) { | |
| 176 | + fs.rmSync(daemonFile, { force: true }); | |
| 177 | + const env = { ...process.env, AGENT_DEVICE_STATE_DIR: state, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', AGENT_DEVICE_NO_UPDATE_NOTIFIER: '1' }; | |
| 178 | + delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG; | |
| 179 | + run(process.execPath, [agentEntry, 'devices', '--json'], { env }); | |
| 180 | + daemon = read(daemonFile); | |
| 181 | + } | |
| 182 | + if (!daemon || !await healthy(daemon.httpPort, '/health')) throw Error('agent-device daemon did not become ready in ' + state); | |
| 183 | + write(agentFile, { entryPath: agentEntry }); | |
| 184 | + agentResult = { daemonPort: daemon.httpPort, token: daemon.token, entryPath: agentEntry }; | |
| 185 | + } | |
| 186 | + const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); | |
| 187 | + const optional = file => fs.existsSync(file) ? file : null; | |
| 188 | + console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult, | |
| 189 | + helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); | |
| 190 | + } finally { releaseHost(); } | |
| 191 | +})().catch(error => { console.error(error.message); process.exitCode = 1; }); | |
| 192 | +`; | |
| @@ -92,6 +92,7 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ · 91 unmodified lines | ||
| 92 | 92 | screenshot: () => Effect.succeed({ device, png }), |
| 93 | 93 | close: () => Effect.void, |
| 94 | 94 | agentCli: Effect.succeed("/cli"), |
| 95 | + testHost: () => Effect.die("not used"), | |
| 95 | 96 | agentTarget: () => Effect.succeed(["--config", "/host.json", "--session", "thread-device"]), |
| 96 | 97 | }); |
| 97 | 98 | |
| @@ -134,6 +134,9 @@ const handlers = { · 133 unmodified lines | ||
| 134 | 134 | .filter((session) => session.threadId === scope.threadId) |
| 135 | 135 | .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId })); |
| 136 | 136 | return { |
| 137 | + hostStatuses: Object.fromEntries( | |
| 138 | + Object.entries(state.hostStatuses).filter(([id]) => !hostId || id === hostId), | |
| 139 | + ), | |
| 137 | 140 | hosts: hostId ? state.hosts.filter((host) => host.id === hostId) : state.hosts, |
| 138 | 141 | devices: hostId |
| 139 | 142 | ? state.devices.filter((device) => device.hostId === hostId) |
| @@ -2770,6 +2770,10 @@ const makeWsRpcLayer = ( · 2769 unmodified lines | ||
| 2770 | 2770 | observeRpcEffect(WS_METHODS.deviceConfigure, deviceService.configure(input), { |
| 2771 | 2771 | "rpc.aggregate": "device", |
| 2772 | 2772 | }), |
| 2773 | + [WS_METHODS.deviceTestHost]: (input) => | |
| 2774 | + observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), { | |
| 2775 | + "rpc.aggregate": "device", | |
| 2776 | + }), | |
| 2773 | 2777 | [WS_METHODS.deviceList]: (_input) => |
| 2774 | 2778 | observeRpcEffect(WS_METHODS.deviceList, deviceService.list, { |
| 2775 | 2779 | "rpc.aggregate": "device", |
| @@ -0,0 +1,27 @@ | ||
| 1 | +import type { DevicePlatformAvailability } from "@t3tools/contracts"; | |
| 2 | +import { Check, Minus } from "lucide-react"; | |
| 3 | +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; | |
| 4 | + | |
| 5 | +export function DeviceHostAvailability({ | |
| 6 | + platforms, | |
| 7 | +}: { | |
| 8 | + platforms: ReadonlyArray<DevicePlatformAvailability>; | |
| 9 | +}) { | |
| 10 | + return ( | |
| 11 | + <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> | |
| 12 | + {platforms.map((platform) => ( | |
| 13 | + <Tooltip key={platform.platform}> | |
| 14 | + <TooltipTrigger render={<span tabIndex={0} className="inline-flex items-center gap-1" />}> | |
| 15 | + {platform.available ? <Check className="size-3" /> : <Minus className="size-3" />} | |
| 16 | + {platform.platform === "ios" ? "iOS" : "Android"}{" "} | |
| 17 | + {platform.available ? "available" : "unavailable"} | |
| 18 | + </TooltipTrigger> | |
| 19 | + <TooltipPopup> | |
| 20 | + {platform.reason ?? | |
| 21 | + (platform.platform === "ios" ? "iOS available" : "Android available")} | |
| 22 | + </TooltipPopup> | |
| 23 | + </Tooltip> | |
| 24 | + ))} | |
| 25 | + </div> | |
| 26 | + ); | |
| 27 | +} | |
| @@ -0,0 +1,334 @@ | ||
| 1 | +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; | |
| 2 | +import { AppleIcon, AndroidIcon } from "../Icons"; | |
| 3 | +import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; | |
| 4 | +import { Spinner } from "../ui/spinner"; | |
| 5 | +import type { | |
| 6 | + DevicePlatformAvailability, | |
| 7 | + EnvironmentId, | |
| 8 | + SshDeviceHostConfig, | |
| 9 | +} from "@t3tools/contracts"; | |
| 10 | +import * as Cause from "effect/Cause"; | |
| 11 | +import { randomUUID } from "../../lib/utils"; | |
| 12 | +import { useState } from "react"; | |
| 13 | +import { deviceEnvironment, useDeviceState } from "../../state/device"; | |
| 14 | +import { serverEnvironment } from "../../state/server"; | |
| 15 | +import { useAtomCommand } from "../../state/use-atom-command"; | |
| 16 | +import { Button } from "../ui/button"; | |
| 17 | +import { Input } from "../ui/input"; | |
| 18 | +import { MoreVertical, PlusIcon } from "lucide-react"; | |
| 19 | +import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; | |
| 20 | +import { SettingsRow } from "./settingsLayout"; | |
| 21 | + | |
| 22 | +/** Host names and identity paths belong to the selected environment, never all environments. */ | |
| 23 | +export function DeviceHostsSettings(props: { | |
| 24 | + environmentId: EnvironmentId | null; | |
| 25 | + hosts: ReadonlyArray<SshDeviceHostConfig>; | |
| 26 | +}) { | |
| 27 | + const update = useAtomCommand(serverEnvironment.updateSettings); | |
| 28 | + const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); | |
| 29 | + const { state } = useDeviceState(props.environmentId); | |
| 30 | + const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null); | |
| 31 | + const [busy, setBusy] = useState(false); | |
| 32 | + const validPort = (port: number | undefined) => | |
| 33 | + port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535); | |
| 34 | + const [checks, setChecks] = useState< | |
| 35 | + Record< | |
| 36 | + string, | |
| 37 | + { pending?: boolean; platforms?: ReadonlyArray<DevicePlatformAvailability>; error?: string } | |
| 38 | + > | |
| 39 | + >({}); | |
| 40 | + const setCheck = (id: string, value: (typeof checks)[string]) => | |
| 41 | + setChecks((current) => ({ ...current, [id]: value })); | |
| 42 | + const save = async (hosts: ReadonlyArray<SshDeviceHostConfig>) => { | |
| 43 | + if (!props.environmentId) return; | |
| 44 | + setBusy(true); | |
| 45 | + try { | |
| 46 | + const saved = await update({ | |
| 47 | + environmentId: props.environmentId, | |
| 48 | + input: { patch: { deviceHosts: hosts } }, | |
| 49 | + }); | |
| 50 | + if (saved._tag === "Success") { | |
| 51 | + setEditing(null); | |
| 52 | + } | |
| 53 | + } finally { | |
| 54 | + setBusy(false); | |
| 55 | + } | |
| 56 | + }; | |
| 57 | + const testConnection = async (host: SshDeviceHostConfig) => { | |
| 58 | + if (!props.environmentId || checks[host.id]?.pending) return; | |
| 59 | + setCheck(host.id, { pending: true }); | |
| 60 | + try { | |
| 61 | + const summary = await test({ environmentId: props.environmentId, input: host }); | |
| 62 | + setCheck( | |
| 63 | + host.id, | |
| 64 | + summary._tag === "Failure" | |
| 65 | + ? { error: Cause.pretty(summary.cause) } | |
| 66 | + : { platforms: summary.value.platforms }, | |
| 67 | + ); | |
| 68 | + } catch (error) { | |
| 69 | + setCheck(host.id, { error: error instanceof Error ? error.message : String(error) }); | |
| 70 | + } | |
| 71 | + }; | |
| 72 | + return ( | |
| 73 | + <SettingsRow | |
| 74 | + id="device-hosts" | |
| 75 | + title="Device hosts" | |
| 76 | + description="Add remote machines with simulator or emulator runtimes installed, and this environment will connect over SSH and set up device tools automatically." | |
| 77 | + control={ | |
| 78 | + <Button | |
| 79 | + size="sm" | |
| 80 | + variant="outline" | |
| 81 | + disabled={busy || !props.environmentId || editing !== null} | |
| 82 | + onClick={() => { | |
| 83 | + setEditing({ id: randomUUID(), label: "", target: "" }); | |
| 84 | + }} | |
| 85 | + > | |
| 86 | + <PlusIcon className="size-3.5" /> Add host | |
| 87 | + </Button> | |
| 88 | + } | |
| 89 | + > | |
| 90 | + <div className="pt-3 pb-2"> | |
| 91 | + {!props.environmentId ? ( | |
| 92 | + <p className="text-sm text-muted-foreground"> | |
| 93 | + Select one connected environment to manage its device hosts. | |
| 94 | + </p> | |
| 95 | + ) : ( | |
| 96 | + <> | |
| 97 | + {props.hosts.map((host) => { | |
| 98 | + const status = state.hostStatuses[host.id]; | |
| 99 | + const check = checks[host.id]; | |
| 100 | + const platforms = | |
| 101 | + check?.platforms ?? | |
| 102 | + state.hosts.find((value) => value.id === host.id)?.platforms ?? | |
| 103 | + []; | |
| 104 | + const progress = check?.pending | |
| 105 | + ? "Checking connection…" | |
| 106 | + : status?.status === "installing" | |
| 107 | + ? "Installing device support…" | |
| 108 | + : status?.status === "starting" | |
| 109 | + ? "Connecting…" | |
| 110 | + : null; | |
| 111 | + const error = | |
| 112 | + check?.error ?? (status?.status === "failed" ? status.detail : undefined); | |
| 113 | + return ( | |
| 114 | + <div | |
| 115 | + key={host.id} | |
| 116 | + className="flex items-center gap-2 border-t border-border/50 py-2.5" | |
| 117 | + > | |
| 118 | + <div className="min-w-0 flex-1"> | |
| 119 | + <div className="flex min-w-0 items-center gap-2"> | |
| 120 | + <p className="truncate text-sm font-medium">{host.label}</p> | |
| 121 | + {platforms | |
| 122 | + .filter((platform) => platform.available) | |
| 123 | + .map((platform) => ( | |
| 124 | + <Tooltip key={platform.platform}> | |
| 125 | + <TooltipTrigger | |
| 126 | + render={ | |
| 127 | + <span | |
| 128 | + tabIndex={0} | |
| 129 | + role="img" | |
| 130 | + aria-label={ | |
| 131 | + platform.platform === "ios" | |
| 132 | + ? "iOS available" | |
| 133 | + : "Android available" | |
| 134 | + } | |
| 135 | + className="shrink-0 text-muted-foreground" | |
| 136 | + /> | |
| 137 | + } | |
| 138 | + > | |
| 139 | + {platform.platform === "ios" ? ( | |
| 140 | + <AppleIcon className="size-3.5" /> | |
| 141 | + ) : ( | |
| 142 | + <AndroidIcon className="size-3.5" /> | |
| 143 | + )} | |
| 144 | + </TooltipTrigger> | |
| 145 | + <TooltipPopup> | |
| 146 | + {platform.platform === "ios" ? "iOS available" : "Android available"} | |
| 147 | + </TooltipPopup> | |
| 148 | + </Tooltip> | |
| 149 | + ))} | |
| 150 | + </div> | |
| 151 | + <p className="truncate text-xs text-muted-foreground">{host.target}</p> | |
| 152 | + {error ? ( | |
| 153 | + <div className="mt-1" role="status"> | |
| 154 | + <details className="text-xs text-destructive"> | |
| 155 | + <summary>Connection failed</summary> | |
| 156 | + <p className="mt-1 whitespace-pre-wrap break-words">{error}</p> | |
| 157 | + </details> | |
| 158 | + </div> | |
| 159 | + ) : null} | |
| 160 | + </div> | |
| 161 | + {progress ? ( | |
| 162 | + <span | |
| 163 | + role="status" | |
| 164 | + className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" | |
| 165 | + > | |
| 166 | + <Spinner className="size-3" /> | |
| 167 | + {progress} | |
| 168 | + </span> | |
| 169 | + ) : null} | |
| 170 | + <Menu> | |
| 171 | + <MenuTrigger | |
| 172 | + render={ | |
| 173 | + <Button | |
| 174 | + size="icon-sm" | |
| 175 | + variant="ghost-muted" | |
| 176 | + disabled={busy} | |
| 177 | + aria-label={host.label + " options"} | |
| 178 | + /> | |
| 179 | + } | |
| 180 | + > | |
| 181 | + <MoreVertical /> | |
| 182 | + </MenuTrigger> | |
| 183 | + <MenuPopup align="end"> | |
| 184 | + <MenuItem | |
| 185 | + onClick={() => { | |
| 186 | + setEditing(host); | |
| 187 | + }} | |
| 188 | + > | |
| 189 | + Edit | |
| 190 | + </MenuItem> | |
| 191 | + <MenuItem | |
| 192 | + variant="destructive" | |
| 193 | + onClick={() => | |
| 194 | + void save(props.hosts.filter((value) => value.id !== host.id)) | |
| 195 | + } | |
| 196 | + > | |
| 197 | + Remove | |
| 198 | + </MenuItem> | |
| 199 | + </MenuPopup> | |
| 200 | + </Menu> | |
| 201 | + <Button | |
| 202 | + size="sm" | |
| 203 | + variant="outline" | |
| 204 | + disabled={busy || progress !== null} | |
| 205 | + onClick={() => void testConnection(host)} | |
| 206 | + > | |
| 207 | + Test connection | |
| 208 | + </Button> | |
| 209 | + </div> | |
| 210 | + ); | |
| 211 | + })} | |
| 212 | + {editing ? ( | |
| 213 | + <form | |
| 214 | + className="space-y-3 border-t border-border/50 py-3" | |
| 215 | + onSubmit={(event) => { | |
| 216 | + event.preventDefault(); | |
| 217 | + void save([...props.hosts.filter((host) => host.id !== editing.id), editing]); | |
| 218 | + }} | |
| 219 | + > | |
| 220 | + <label className="block space-y-1 text-sm"> | |
| 221 | + <span>Name</span> | |
| 222 | + <Input | |
| 223 | + required | |
| 224 | + value={editing.label} | |
| 225 | + disabled={busy} | |
| 226 | + onChange={(event) => setEditing({ ...editing, label: event.target.value })} | |
| 227 | + placeholder="Mac mini" | |
| 228 | + /> | |
| 229 | + </label> | |
| 230 | + <label className="block space-y-1 text-sm"> | |
| 231 | + <span>SSH target</span> | |
| 232 | + <Input | |
| 233 | + required | |
| 234 | + value={editing.target} | |
| 235 | + disabled={busy} | |
| 236 | + onChange={(event) => setEditing({ ...editing, target: event.target.value })} | |
| 237 | + placeholder="user@host or SSH alias" | |
| 238 | + /> | |
| 239 | + </label> | |
| 240 | + <label className="block space-y-1 text-sm"> | |
| 241 | + <span>Identity file, optional</span> | |
| 242 | + <Input | |
| 243 | + value={editing.identityFile ?? ""} | |
| 244 | + disabled={busy} | |
| 245 | + onChange={(event) => { | |
| 246 | + const { identityFile: _, ...rest } = editing; | |
| 247 | + setEditing( | |
| 248 | + event.target.value ? { ...rest, identityFile: event.target.value } : rest, | |
| 249 | + ); | |
| 250 | + }} | |
| 251 | + placeholder="~/.ssh/id_ed25519" | |
| 252 | + /> | |
| 253 | + </label> | |
| 254 | + <label className="block space-y-1 text-sm"> | |
| 255 | + <span>Port, optional</span> | |
| 256 | + <Input | |
| 257 | + type="number" | |
| 258 | + min={1} | |
| 259 | + max={65535} | |
| 260 | + value={editing.port ?? ""} | |
| 261 | + disabled={busy} | |
| 262 | + onChange={(event) => { | |
| 263 | + const { port: _, ...rest } = editing; | |
| 264 | + setEditing( | |
| 265 | + event.target.value ? { ...rest, port: Number(event.target.value) } : rest, | |
| 266 | + ); | |
| 267 | + }} | |
| 268 | + placeholder="SSH config default" | |
| 269 | + /> | |
| 270 | + </label> | |
| 271 | + <div className="flex flex-wrap gap-2"> | |
| 272 | + <Button | |
| 273 | + size="sm" | |
| 274 | + type="submit" | |
| 275 | + disabled={ | |
| 276 | + busy || | |
| 277 | + !editing.label.trim() || | |
| 278 | + !editing.target.trim() || | |
| 279 | + !validPort(editing.port) | |
| 280 | + } | |
| 281 | + > | |
| 282 | + Save host | |
| 283 | + </Button> | |
| 284 | + <Button | |
| 285 | + size="sm" | |
| 286 | + type="button" | |
| 287 | + variant="outline" | |
| 288 | + disabled={ | |
| 289 | + busy || | |
| 290 | + !editing.label.trim() || | |
| 291 | + !editing.target.trim() || | |
| 292 | + !validPort(editing.port) | |
| 293 | + } | |
| 294 | + onClick={() => void testConnection(editing)} | |
| 295 | + > | |
| 296 | + Test connection | |
| 297 | + </Button> | |
| 298 | + <Button | |
| 299 | + size="sm" | |
| 300 | + type="button" | |
| 301 | + variant="ghost" | |
| 302 | + disabled={busy} | |
| 303 | + onClick={() => { | |
| 304 | + setEditing(null); | |
| 305 | + }} | |
| 306 | + > | |
| 307 | + Cancel | |
| 308 | + </Button> | |
| 309 | + </div> | |
| 310 | + {checks[editing.id]?.pending ? ( | |
| 311 | + <span | |
| 312 | + role="status" | |
| 313 | + className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" | |
| 314 | + > | |
| 315 | + <Spinner className="size-3" /> | |
| 316 | + Checking connection… | |
| 317 | + </span> | |
| 318 | + ) : null} | |
| 319 | + {checks[editing.id]?.platforms ? ( | |
| 320 | + <DeviceHostAvailability platforms={checks[editing.id]?.platforms ?? []} /> | |
| 321 | + ) : null} | |
| 322 | + {checks[editing.id]?.error ? ( | |
| 323 | + <p role="alert" className="text-xs text-destructive"> | |
| 324 | + {checks[editing.id]?.error} | |
| 325 | + </p> | |
| 326 | + ) : null} | |
| 327 | + </form> | |
| 328 | + ) : null} | |
| 329 | + </> | |
| 330 | + )} | |
| 331 | + </div> | |
| 332 | + </SettingsRow> | |
| 333 | + ); | |
| 334 | +} | |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { DeviceHostsSettings } from "./DeviceHostsSettings"; | |
| 1 | 2 | /** |
| 2 | 3 | * Integrations settings - preferences for surfaces T3 Code embeds rather than |
| 3 | 4 | * owns. Browser is the first section: the defaults a preview tab opens at, |
| @@ -12,6 +13,7 @@ import { · 8 unmodified lines | ||
| 12 | 13 | type BrowserLinkTarget, |
| 13 | 14 | type BrowserProfile, |
| 14 | 15 | type EnvironmentId, |
| 16 | + type SshDeviceHostConfig, | |
| 15 | 17 | BROWSER_PROFILE_NAME_MAX_LENGTH, |
| 16 | 18 | BROWSER_RECORDING_FRAME_RATES, |
| 17 | 19 | DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, |
| @@ -584,17 +586,74 @@ function AgentBrowserAccessSetting() { · 566 unmodified lines | ||
| 584 | 586 | |
| 585 | 587 | function DeviceIntegrationSettings() { |
| 586 | 588 | const primaryEnvironment = usePrimaryEnvironment(); |
| 587 | − const environmentId = primaryEnvironment?.environmentId ?? null; | |
| 589 | + const { environments } = useEnvironments(); | |
| 590 | + const [selectedId, setSelectedId] = useState<string | null>(null); | |
| 591 | + const selected = | |
| 592 | + environments.find((environment) => environment.environmentId === selectedId) ?? | |
| 593 | + environments.find( | |
| 594 | + (environment) => environment.environmentId === primaryEnvironment?.environmentId, | |
| 595 | + ) ?? | |
| 596 | + environments[0]; | |
| 597 | + const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; | |
| 598 | + const environmentId = connected ? selected.environmentId : null; | |
| 599 | + | |
| 600 | + return ( | |
| 601 | + <SettingsSection id="devices" title="Devices"> | |
| 602 | + {environments.length > 1 ? ( | |
| 603 | + <SettingsRow | |
| 604 | + title="Environment" | |
| 605 | + description="Device support and hosts are shared by all projects in this environment." | |
| 606 | + control={ | |
| 607 | + <Select | |
| 608 | + value={selected?.environmentId ?? ""} | |
| 609 | + onValueChange={(value) => setSelectedId(value)} | |
| 610 | + > | |
| 611 | + <SelectTrigger size="sm" aria-label="Device environment"> | |
| 612 | + <SelectValue>{selected?.label ?? "Select environment"}</SelectValue> | |
| 613 | + </SelectTrigger> | |
| 614 | + <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 615 | + {environments.map((environment) => ( | |
| 616 | + <SelectItem key={environment.environmentId} value={environment.environmentId}> | |
| 617 | + {environment.label} | |
| 618 | + {environment.connection.phase === "connected" ? "" : " · Offline"} | |
| 619 | + </SelectItem> | |
| 620 | + ))} | |
| 621 | + </SelectPopup> | |
| 622 | + </Select> | |
| 623 | + } | |
| 624 | + /> | |
| 625 | + ) : null} | |
| 626 | + <DeviceIntegrationControls | |
| 627 | + key={selected?.environmentId ?? "none"} | |
| 628 | + environmentId={environmentId} | |
| 629 | + hosts={selected?.serverConfig?.settings.deviceHosts ?? []} | |
| 630 | + enabled={selected?.serverConfig?.settings.enableDeviceSupport ?? false} | |
| 631 | + agentAccessEnabled={selected?.serverConfig?.settings.enableAgentDeviceAccess ?? false} | |
| 632 | + /> | |
| 633 | + </SettingsSection> | |
| 634 | + ); | |
| 635 | +} | |
| 636 | + | |
| 637 | +function DeviceIntegrationControls({ | |
| 638 | + environmentId, | |
| 639 | + hosts, | |
| 640 | + enabled, | |
| 641 | + agentAccessEnabled, | |
| 642 | +}: { | |
| 643 | + environmentId: EnvironmentId | null; | |
| 644 | + hosts: ReadonlyArray<SshDeviceHostConfig>; | |
| 645 | + enabled: boolean; | |
| 646 | + agentAccessEnabled: boolean; | |
| 647 | +}) { | |
| 588 | 648 | const { state, loaded } = useDeviceState(environmentId); |
| 589 | 649 | const configure = useAtomCommand(deviceEnvironment.configure); |
| 590 | 650 | const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); |
| 591 | 651 | const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null); |
| 592 | − const enabled = state.hostStatus !== "disabled"; | |
| 593 | 652 | const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; |
| 594 | 653 | const [platformsRevealed, setPlatformsRevealed] = useState(false); |
| 595 | 654 | // Keep diagnostics visible through subsequent agent setup and refresh phases. |
| 596 | 655 | if (platformsRevealed && !enabled) setPlatformsRevealed(false); |
| 597 | − if (!platformsRevealed && state.hostStatus === "ready" && pending !== "hub") { | |
| 656 | + if (enabled && !platformsRevealed && state.hostStatus === "ready" && pending !== "hub") { | |
| 598 | 657 | setPlatformsRevealed(true); |
| 599 | 658 | } |
| 600 | 659 | |
| @@ -615,7 +674,7 @@ function DeviceIntegrationSettings() { · 14 unmodified lines | ||
| 615 | 674 | }; |
| 616 | 675 | |
| 617 | 676 | return ( |
| 618 | − <SettingsSection id="devices" title="Devices"> | |
| 677 | + <> | |
| 619 | 678 | <SettingsRow |
| 620 | 679 | {...searchableSetting("device-hub")} |
| 621 | 680 | description={deviceHubDescription} |
| @@ -674,7 +733,7 @@ function DeviceIntegrationSettings() { · 52 unmodified lines | ||
| 674 | 733 | <> |
| 675 | 734 | {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null} |
| 676 | 735 | <Switch |
| 677 | − checked={state.agentAccessEnabled} | |
| 736 | + checked={agentAccessEnabled} | |
| 678 | 737 | disabled={!loaded || !environmentId || !enabled || busy || pending !== null} |
| 679 | 738 | aria-label="Agent device access" |
| 680 | 739 | onCheckedChange={(checked) => |
| @@ -689,7 +748,8 @@ function DeviceIntegrationSettings() { · 8 unmodified lines | ||
| 689 | 748 | {state.hostStatusDetail} |
| 690 | 749 | </p> |
| 691 | 750 | ) : null} |
| 692 | − </SettingsSection> | |
| 751 | + <DeviceHostsSettings environmentId={environmentId} hosts={hosts} /> | |
| 752 | + </> | |
| 693 | 753 | ); |
| 694 | 754 | } |
| 695 | 755 | |
| @@ -402,6 +402,12 @@ export const SETTINGS_SEARCH_ITEMS = [ · 401 unmodified lines | ||
| 402 | 402 | to: "/settings/projects", |
| 403 | 403 | searchTerms: ["allow open drive preview tools sessions"], |
| 404 | 404 | }, |
| 405 | + { | |
| 406 | + id: "device-hosts", | |
| 407 | + title: "Device hosts", | |
| 408 | + to: "/settings/integrations", | |
| 409 | + searchTerms: ["ssh remote simulator emulator ios android mac mini identity key connection"], | |
| 410 | + }, | |
| 405 | 411 | { |
| 406 | 412 | id: "agent-device-access", |
| 407 | 413 | title: "Agent device access", |
| @@ -3,8 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | The environment server owns simulators and emulators the way it owns |
| 4 | 4 | terminals: discovery, streaming, and agent access all run there, and every |
| 5 | 5 | client reaches them through the environment connection. This is what makes the |
| 6 | −Device panel work over Tailscale and T3 Connect, and what will let a device | |
| 7 | −host on another machine slot in later. | |
| 6 | +Device panel work over Tailscale and T3 Connect, including when an SSH host runs the devices. | |
| 8 | 7 | |
| 9 | 8 | ## Two external tools, one seam |
| 10 | 9 | |
| @@ -21,8 +20,8 @@ native addon, and a crash there must not take the server down. · 10 unmodified lines | ||
| 21 | 20 | Everything platform-specific sits behind |
| 22 | 21 | [`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the |
| 23 | 22 | proxy, and the MCP tools only see a hub origin and an agent-device endpoint. |
| 24 | −An SSH or cloud host would forward those two things to the server and change | |
| 25 | −nothing above it. | |
| 23 | +SSH hosts forward both endpoints to server loopback. Every proxied request | |
| 24 | +also carries the host id; device ids alone are not unique across hosts. | |
| 26 | 25 | |
| 27 | 26 | ## The hub is never exposed |
| 28 | 27 | |
| @@ -57,9 +56,8 @@ screenshot capture and stream tuning. · 28 unmodified lines | ||
| 57 | 56 | The `device_*` toolkit is deliberately four tools: list, open, screenshot, and |
| 58 | 57 | close. Driving happens through the `agent-device` CLI, which has the semantic |
| 59 | 58 | snapshot model agents need and stays current with its own releases. T3 prepends |
| 60 | −a shim directory to the provider's PATH and sets | |
| 61 | −`AGENT_DEVICE_DAEMON_BASE_URL` and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` so the | |
| 62 | −agent never handles the endpoint or token. | |
| 59 | +a shim directory to the provider's PATH. The CLI installs on the environment | |
| 60 | +server even when that server cannot run simulators. Hosts start on demand. | |
| 63 | 61 | |
| 64 | 62 | That environment is fixed when the provider subprocess spawns, so |
| 65 | 63 | [`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts) |
| @@ -15,6 +15,9 @@ installed, the setup screen says so and reuses it. · 14 unmodified lines | ||
| 15 | 15 | |
| 16 | 16 | Choose a running device to watch it, or choose **Start** next to a stopped |
| 17 | 17 | device to boot it. The panel shows when you or an agent starts a device. |
| 18 | +Each device opens in its own tab. Use **+ → Device** to open another, and | |
| 19 | +double-click a tab name or choose **Rename** from its context menu to rename it. | |
| 20 | +Only the visible tab streams video; switching tabs keeps both devices running. | |
| 18 | 21 | Turn off the device hub in **Settings → Integrations → Devices** to stop the |
| 19 | 22 | helper processes; simulators and emulators keep running until you power them |
| 20 | 23 | off. |
| @@ -29,7 +32,8 @@ After installing them, restart the environment server and refresh devices. · 8 unmodified lines | ||
| 29 | 32 | The screen is interactive: click and drag to touch, type while the screen is |
| 30 | 33 | focused, and use the toolbar for Home, Back, and Recents on Android, rotate on |
| 31 | 34 | iOS, and power off. Close the tab to stop watching; the device keeps running |
| 32 | −unless you power it off. | |
| 35 | +unless you power it off. Closed tabs stay closed after a reload. To watch the | |
| 36 | +device again, choose it from **+ → Device**. | |
| 33 | 37 | |
| 34 | 38 | ## Tools |
| 35 | 39 | |
| @@ -61,3 +65,26 @@ The device stream goes through the environment server, so it works over the · 25 unmodified lines | ||
| 61 | 65 | local network, Tailscale, and T3 Connect. Live video needs a secure page |
| 62 | 66 | (HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower |
| 63 | 67 | still-image stream and Android cannot show video. |
| 68 | + | |
| 69 | +## SSH device hosts | |
| 70 | + | |
| 71 | +In Settings → Integrations → Devices, select one connected environment | |
| 72 | +and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with | |
| 73 | +an optional identity file and port. These resolve on the environment server, | |
| 74 | +so use the SSH configuration and keys available there. Password prompts are | |
| 75 | +not supported. | |
| 76 | + | |
| 77 | +**Test connection** checks SSH, Node, npm, and platform tools without installing | |
| 78 | +anything. The first device listing installs pinned device tools on the host. | |
| 79 | +Node 22 or newer and npm must be available to non-interactive SSH commands. | |
| 80 | +T3 checks common Homebrew and Android SDK locations; custom installations need | |
| 81 | +the appropriate PATH and ANDROID_HOME on the host. | |
| 82 | + | |
| 83 | +The picker identifies devices by host when several hosts are configured. | |
| 84 | +Connections recover after interruptions. Removing a host closes its device | |
| 85 | +sessions and stops its T3 helpers when reachable; simulators keep running. | |
| 86 | + | |
| 87 | +T3 provides discovery, streaming, and control. Arrange app builds, | |
| 88 | +installation, and connectivity to development servers such as Metro separately. | |
| 89 | +A simulator on another machine cannot reach Metro through your environment's | |
| 90 | +localhost without forwarding or another reachable address. | |
| @@ -28,6 +28,10 @@ export function createDeviceEnvironmentAtoms<R, E>( · 27 unmodified lines | ||
| 28 | 28 | scheduler, |
| 29 | 29 | concurrency, |
| 30 | 30 | }), |
| 31 | + testHost: createEnvironmentRpcCommand(runtime, { | |
| 32 | + label: "environment-data:device:test-host", | |
| 33 | + tag: WS_METHODS.deviceTestHost, | |
| 34 | + }), | |
| 31 | 35 | list: createEnvironmentRpcCommand(runtime, { |
| 32 | 36 | label: "environment-data:device:list", |
| 33 | 37 | tag: WS_METHODS.deviceList, |
| @@ -25,6 +25,27 @@ export type DeviceHostId = typeof DeviceHostId.Type; · 24 unmodified lines | ||
| 25 | 25 | /** The server machine. Always present; other host kinds are future work. */ |
| 26 | 26 | export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId; |
| 27 | 27 | |
| 28 | +/** SSH aliases and key paths are resolved on the environment server. */ | |
| 29 | +export const SshDeviceHostConfig = Schema.Struct({ | |
| 30 | + id: DeviceHostId.check( | |
| 31 | + Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), | |
| 32 | + Schema.makeFilter((id) => id !== "local" || "The local host id is reserved."), | |
| 33 | + ), | |
| 34 | + label: TrimmedNonEmptyString, | |
| 35 | + target: TrimmedNonEmptyString.check(Schema.isPattern(/^[^\s-][^\s]*$/)), | |
| 36 | + identityFile: Schema.optional(TrimmedNonEmptyString), | |
| 37 | + port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), | |
| 38 | +}); | |
| 39 | +export type SshDeviceHostConfig = typeof SshDeviceHostConfig.Type; | |
| 40 | + | |
| 41 | +export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check( | |
| 42 | + Schema.makeFilter( | |
| 43 | + (hosts) => | |
| 44 | + new Set(hosts.map((host) => host.id)).size === hosts.length || | |
| 45 | + "Device host ids must be unique.", | |
| 46 | + ), | |
| 47 | +); | |
| 48 | + | |
| 28 | 49 | /** Simulator udid or adb serial (an AVD name while it is not running). */ |
| 29 | 50 | export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); |
| 30 | 51 | export type DeviceId = typeof DeviceId.Type; |
| @@ -55,7 +76,7 @@ export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type; · 24 unmodified lines | ||
| 55 | 76 | |
| 56 | 77 | export const DeviceHostSummary = Schema.Struct({ |
| 57 | 78 | id: DeviceHostId, |
| 58 | − kind: Schema.Literals(["local"]), | |
| 79 | + kind: Schema.Literals(["local", "ssh"]), | |
| 59 | 80 | label: TrimmedNonEmptyString, |
| 60 | 81 | platforms: Schema.Array(DevicePlatformAvailability), |
| 61 | 82 | hubInstalled: Schema.Boolean, |
| @@ -424,6 +445,7 @@ export type DeviceError = typeof DeviceError.Type; · 362 unmodified lines | ||
| 424 | 445 | // panel describe devices the same way. |
| 425 | 446 | |
| 426 | 447 | export const DeviceToolListResult = Schema.Struct({ |
| 448 | + hostStatuses: DeviceServiceState.fields.hostStatuses, | |
| 427 | 449 | hosts: Schema.Array(DeviceHostSummary), |
| 428 | 450 | devices: Schema.Array(DeviceSummary), |
| 429 | 451 | /** Devices already open in this thread's Device panel. */ |
| @@ -189,6 +189,8 @@ import { · 188 unmodified lines | ||
| 189 | 189 | DeviceDetailInput, |
| 190 | 190 | DeviceError, |
| 191 | 191 | DeviceListInput, |
| 192 | + SshDeviceHostConfig, | |
| 193 | + DeviceHostSummary, | |
| 192 | 194 | DeviceOpenInput, |
| 193 | 195 | DeviceServiceState, |
| 194 | 196 | DeviceSession, |
| @@ -328,6 +330,7 @@ export const WS_METHODS = { · 133 unmodified lines | ||
| 328 | 330 | // Device methods |
| 329 | 331 | deviceConfigure: "device.configure", |
| 330 | 332 | deviceList: "device.list", |
| 333 | + deviceTestHost: "device.testHost", | |
| 331 | 334 | deviceOpen: "device.open", |
| 332 | 335 | deviceClose: "device.close", |
| 333 | 336 | deviceShutdown: "device.shutdown", |
| @@ -1101,6 +1104,12 @@ const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscov · 767 unmodified lines | ||
| 1101 | 1104 | stream: true, |
| 1102 | 1105 | }); |
| 1103 | 1106 | |
| 1107 | +const WsDeviceTestHostRpc = Rpc.make(WS_METHODS.deviceTestHost, { | |
| 1108 | + payload: SshDeviceHostConfig, | |
| 1109 | + success: DeviceHostSummary, | |
| 1110 | + error: Schema.Union([DeviceError, EnvironmentAuthorizationError]), | |
| 1111 | +}); | |
| 1112 | + | |
| 1104 | 1113 | const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, { |
| 1105 | 1114 | payload: DeviceListInput, |
| 1106 | 1115 | success: DeviceServiceState, |
| @@ -1380,6 +1389,7 @@ export const WsRpcGroup = RpcGroup.make( · 273 unmodified lines | ||
| 1380 | 1389 | WsSubscribeDiscoveredLocalServersRpc, |
| 1381 | 1390 | WsDeviceConfigureRpc, |
| 1382 | 1391 | WsDeviceListRpc, |
| 1392 | + WsDeviceTestHostRpc, | |
| 1383 | 1393 | WsDeviceOpenRpc, |
| 1384 | 1394 | WsDeviceCloseRpc, |
| 1385 | 1395 | WsDeviceShutdownRpc, |
| @@ -759,3 +759,16 @@ describe("ServerSettings environment icon", () => { · 758 unmodified lines | ||
| 759 | 759 | expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux"); |
| 760 | 760 | }); |
| 761 | 761 | }); |
| 762 | + | |
| 763 | +const decodeDeviceHostSettings = Schema.decodeSync(ServerSettings); | |
| 764 | + | |
| 765 | +it("validates remote device hosts and rejects ambiguous host ids", () => { | |
| 766 | + const host = { id: "mini", label: "Mac mini", target: "user@mini", port: 2222 }; | |
| 767 | + expect(decodeDeviceHostSettings({ deviceHosts: [host] }).deviceHosts).toEqual([host]); | |
| 768 | + expect(() => decodeDeviceHostSettings({ deviceHosts: [host, host] })).toThrow(); | |
| 769 | + expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, id: "local" }] })).toThrow(); | |
| 770 | + expect(() => | |
| 771 | + decodeDeviceHostSettings({ deviceHosts: [{ ...host, target: "-oProxyCommand=bad" }] }), | |
| 772 | + ).toThrow(); | |
| 773 | + expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow(); | |
| 774 | +}); | |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { SshDeviceHostConfigs } from "./device.ts"; | |
| 1 | 2 | import * as Effect from "effect/Effect"; |
| 2 | 3 | import * as Duration from "effect/Duration"; |
| 3 | 4 | import * as Schema from "effect/Schema"; |
| @@ -983,6 +984,7 @@ export const ServerSettings = Schema.Struct({ · 979 unmodified lines | ||
| 983 | 984 | enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 984 | 985 | /** Whether the server-local Device panel setup flow has been completed. */ |
| 985 | 986 | deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 987 | + deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), | |
| 986 | 988 | sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( |
| 987 | 989 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), |
| 988 | 990 | ), |
| @@ -1258,6 +1260,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 269 unmodified lines | ||
| 1258 | 1260 | enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), |
| 1259 | 1261 | enableDeviceSupport: Schema.optionalKey(Schema.Boolean), |
| 1260 | 1262 | deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), |
| 1263 | + deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), | |
| 1261 | 1264 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), |
| 1262 | 1265 | sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1263 | 1266 | backgroundActivity: Schema.optionalKey( |
| @@ -21,6 +21,16 @@ import { · 20 unmodified lines | ||
| 21 | 21 | } from "./serverSettings.ts"; |
| 22 | 22 | |
| 23 | 23 | describe("serverSettings helpers", () => { |
| 24 | + it("replaces SSH host lists when saving, editing, and removing hosts", () => { | |
| 25 | + const host = { id: "mini", label: "Mac mini", target: "mini" }; | |
| 26 | + const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] }); | |
| 27 | + expect(saved.deviceHosts).toEqual([host]); | |
| 28 | + const replacement = { ...host, target: "other-mini" }; | |
| 29 | + const edited = applyServerSettingsPatch(saved, { deviceHosts: [replacement] }); | |
| 30 | + expect(edited.deviceHosts).toEqual([replacement]); | |
| 31 | + expect(applyServerSettingsPatch(edited, { deviceHosts: [] }).deviceHosts).toEqual([]); | |
| 32 | + }); | |
| 33 | + | |
| 24 | 34 | it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { |
| 25 | 35 | const project = { id: ProjectId.make("project-actions"), scripts: [] }; |
| 26 | 36 | const action = { |
| @@ -540,6 +540,9 @@ importers: · 539 unmodified lines | ||
| 540 | 540 | '@t3tools/shared': |
| 541 | 541 | specifier: workspace:* |
| 542 | 542 | version: link:../../packages/shared |
| 543 | + '@t3tools/ssh': | |
| 544 | + specifier: workspace:* | |
| 545 | + version: link:../../packages/ssh | |
| 543 | 546 | '@t3tools/tailscale': |
| 544 | 547 | specifier: workspace:* |
| 545 | 548 | version: link:../../packages/tailscale |