| author | |
| committer | |
| log | d2eeacd8ccde4d8763ca315b4cddf2964db93329 |
| tree | 71894c7b2f8f7624592ca34f32bc11c1e1d9bf0f |
| parent | 7734c6d71b6dd5df9c9b4856cff8e419b8837fe8 |
| signature | Signed by PGP key B5690EEEBB952194 |
30 files changed, 1794 insertions(+), 78 deletions(-)
apps/server/package.json+1| ... | ... | @@ -40,6 +40,7 @@ |
| 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", |
apps/server/src/auth/RpcAuthorization.ts+1| ... | ... | @@ -145,6 +145,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 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, |
apps/server/src/device/DeviceActions.test.ts+1| ... | ... | @@ -16,6 +16,7 @@ const makeReady = ( |
| 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) => { |
apps/server/src/device/DeviceActions.ts+1-1| ... | ... | @@ -327,7 +327,7 @@ const serveSimPermissions = ( |
| 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, |
apps/server/src/device/DeviceHost.ts+2-1| ... | ... | @@ -46,11 +46,12 @@ export interface DeviceHubEndpoint { |
| 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) |
apps/server/src/device/DeviceMultiHost.test.ts+47-7| ... | ... | @@ -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", |
| 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", |
| 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", |
| 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", |
| 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( |
apps/server/src/device/DeviceService.test.ts+1| ... | ... | @@ -70,6 +70,7 @@ const fixture = Effect.fn("fixture")(function* ( |
| 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: "" }), |
apps/server/src/device/DeviceService.ts+220-52| ... | ... | @@ -30,6 +30,8 @@ import { |
| 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"; |
| 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< |
| 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) => |
| 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* |
| 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* |
| 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* |
| 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* |
| 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* |
| 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* |
| 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* |
| 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* |
| 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* () { |
| 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* () { |
| 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( |
apps/server/src/device/DeviceToolchain.ts+2-2| ... | ... | @@ -25,9 +25,9 @@ import * as Semaphore from "effect/Semaphore"; |
| 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); |
apps/server/src/device/LocalDeviceHost.ts+1| ... | ... | @@ -644,6 +644,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 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 | }); |
apps/server/src/device/SshDeviceHost.test.ts created+131| ... | ... | @@ -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 | ); |
apps/server/src/device/SshDeviceHost.ts created+434| ... | ... | @@ -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 | }); |
apps/server/src/device/sshDeviceScript.test.ts created+220| ... | ... | @@ -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 | }); |
apps/server/src/device/sshDeviceScript.ts created+192| ... | ... | @@ -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 | `; |
apps/server/src/mcp/McpDeviceToolkit.test.ts+1| ... | ... | @@ -92,6 +92,7 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ |
| 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 |
apps/server/src/mcp/toolkits/device/handlers.ts+3| ... | ... | @@ -134,6 +134,9 @@ const handlers = { |
| 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) |
apps/server/src/ws.ts+4| ... | ... | @@ -2770,6 +2770,10 @@ const makeWsRpcLayer = ( |
| 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", |
apps/web/src/components/device/DeviceHostAvailability.tsx created+27| ... | ... | @@ -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 | } |
apps/web/src/components/settings/DeviceHostsSettings.tsx created+334| ... | ... | @@ -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 | } |
apps/web/src/components/settings/IntegrationsSettings.tsx+66-6| ... | ... | @@ -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 { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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 |
apps/web/src/components/settings/settingsSearch.ts+6| ... | ... | @@ -402,6 +402,12 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 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", |
docs/internals/devices.md+5-7| ... | ... | @@ -3,8 +3,7 @@ |
| 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. |
| 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. |
| 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) |
docs/user/devices.md+28-1| ... | ... | @@ -15,6 +15,9 @@ installed, the setup screen says so and reuses it. |
| 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. |
| 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 |
| 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. |
packages/client-runtime/src/state/device.ts+4| ... | ... | @@ -28,6 +28,10 @@ export function createDeviceEnvironmentAtoms<R, E>( |
| 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, |
packages/contracts/src/device.ts+23-1| ... | ... | @@ -25,6 +25,27 @@ export type DeviceHostId = typeof DeviceHostId.Type; |
| 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; |
| 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; |
| 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. */ |
packages/contracts/src/rpc.ts+10| ... | ... | @@ -189,6 +189,8 @@ import { |
| 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 = { |
| 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 |
| 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( |
| 1380 | 1389 | WsSubscribeDiscoveredLocalServersRpc, |
| 1381 | 1390 | WsDeviceConfigureRpc, |
| 1382 | 1391 | WsDeviceListRpc, |
| 1392 | WsDeviceTestHostRpc, | |
| 1383 | 1393 | WsDeviceOpenRpc, |
| 1384 | 1394 | WsDeviceCloseRpc, |
| 1385 | 1395 | WsDeviceShutdownRpc, |
packages/contracts/src/settings.test.ts+13| ... | ... | @@ -759,3 +759,16 @@ describe("ServerSettings environment icon", () => { |
| 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 | }); |
packages/contracts/src/settings.ts+3| ... | ... | @@ -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({ |
| 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({ |
| 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( |
packages/shared/src/serverSettings.test.ts+10| ... | ... | @@ -21,6 +21,16 @@ import { |
| 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 = { |
pnpm-lock.yaml+3| ... | ... | @@ -540,6 +540,9 @@ importers: |
| 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 |