| author | |
| committer | |
| log | 61b25b88d7922a480790066341867f3f8ae42fcb |
| tree | 09f9c135c19b5c8ce437e14bec8ed01da6f8bfe7 |
| parent | 3b836f9942143cdcf1ad7f121e78b59476585781 |
| signature | Signed by PGP key B5690EEEBB952194 |
12 files changed, 222 insertions(+), 30 deletions(-)
apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx+32-7| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device"; | |
| 1 | import { | |
| 2 | deviceToolVersionLabels, | |
| 3 | deviceToolUpdateOwnership, | |
| 4 | deviceToolUpdatePolicy, | |
| 5 | } from "@t3tools/client-runtime/state/device"; | |
| 2 | 6 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | 7 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; |
| 4 | 8 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| ... | ... | @@ -131,6 +135,18 @@ function DevicePreviewScreen({ |
| 131 | 135 | void retryHost({ environmentId, input: { retryHostId: host.id } }); |
| 132 | 136 | }, |
| 133 | 137 | })) ?? []), |
| 138 | ...(state.data?.supportsToolInspection | |
| 139 | ? [ | |
| 140 | { | |
| 141 | id: "check-device-tools", | |
| 142 | title: "Check device tool versions", | |
| 143 | icon: "arrow.clockwise" as const, | |
| 144 | onPress: () => { | |
| 145 | void retryHost({ environmentId, input: { inspectOnly: true } }); | |
| 146 | }, | |
| 147 | }, | |
| 148 | ] | |
| 149 | : []), | |
| 134 | 150 | { |
| 135 | 151 | id: "device-tools", |
| 136 | 152 | title: "Device tool versions", |
| ... | ... | @@ -138,17 +154,26 @@ function DevicePreviewScreen({ |
| 138 | 154 | onPress: () => |
| 139 | 155 | Alert.alert( |
| 140 | 156 | "Device tool versions", |
| 141 | deviceToolVersionLabels( | |
| 142 | state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, | |
| 143 | ).join("\n") + | |
| 157 | deviceToolUpdateOwnership + | |
| 158 | "\n\n" + | |
| 159 | deviceToolUpdatePolicy( | |
| 160 | state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, | |
| 161 | ) + | |
| 162 | "\n\n" + | |
| 163 | deviceToolVersionLabels( | |
| 164 | state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, | |
| 165 | ).join("\n") + | |
| 144 | 166 | "\n" + |
| 145 | (state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? ""), | |
| 167 | (state.data?.hosts.find((host) => host.id === preview?.session.hostId) | |
| 168 | ?.toolInspectionError ?? | |
| 169 | state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? | |
| 170 | ""), | |
| 146 | 171 | ), |
| 147 | 172 | }, |
| 148 | 173 | { |
| 149 | 174 | id: "reload", |
| 150 | 175 | title: "Reload stream", |
| 151 | icon: "arrow.clockwise", | |
| 176 | icon: "arrow.clockwise" as const, | |
| 152 | 177 | disabled: !preview || shuttingDown, |
| 153 | 178 | onPress: () => { |
| 154 | 179 | setInputConnected(false); |
| ... | ... | @@ -178,7 +203,7 @@ function DevicePreviewScreen({ |
| 178 | 203 | { |
| 179 | 204 | id: "rotate", |
| 180 | 205 | title: "Rotate device", |
| 181 | icon: "arrow.clockwise", | |
| 206 | icon: "arrow.clockwise" as const, | |
| 182 | 207 | disabled: !inputConnected, |
| 183 | 208 | onPress: () => streamRef.current?.rotate(), |
| 184 | 209 | }, |
apps/server/src/device/DeviceHost.ts+1| ... | ... | @@ -80,6 +80,7 @@ export class DeviceHost extends Context.Service< |
| 80 | 80 | { |
| 81 | 81 | readonly id: DeviceHostId; |
| 82 | 82 | readonly summary: Effect.Effect<DeviceHostSummary>; |
| 83 | readonly inspect?: Effect.Effect<DeviceHostSummary, DeviceHostError>; | |
| 83 | 84 | readonly platformAvailability: ( |
| 84 | 85 | platform: DevicePlatform, |
| 85 | 86 | ) => Effect.Effect<DevicePlatformAvailability>; |
apps/server/src/device/DeviceService.test.ts+37| ... | ... | @@ -67,6 +67,7 @@ const fixture = Effect.fn("fixture")(function* ( |
| 67 | 67 | bootError?: string, |
| 68 | 68 | failListAfterShutdown = false, |
| 69 | 69 | runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, |
| 70 | inspectError = false, | |
| 70 | 71 | ) { |
| 71 | 72 | const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); |
| 72 | 73 | const starts: string[] = []; |
| ... | ... | @@ -82,6 +83,17 @@ const fixture = Effect.fn("fixture")(function* ( |
| 82 | 83 | run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), |
| 83 | 84 | }; |
| 84 | 85 | const host: DeviceHost.DeviceHost["Service"] = { |
| 86 | ...(inspectError | |
| 87 | ? { | |
| 88 | inspect: Effect.fail( | |
| 89 | new DeviceHost.DeviceHostError({ | |
| 90 | hostId: LOCAL_DEVICE_HOST_ID, | |
| 91 | step: "probe", | |
| 92 | cause: new Error("offline"), | |
| 93 | }), | |
| 94 | ), | |
| 95 | } | |
| 96 | : {}), | |
| 85 | 97 | id: LOCAL_DEVICE_HOST_ID, |
| 86 | 98 | summary: Effect.succeed({ |
| 87 | 99 | id: LOCAL_DEVICE_HOST_ID, |
| ... | ... | @@ -623,3 +635,28 @@ it.effect("host retry exposes actionable failure without internal IDs or diagnos |
| 623 | 635 | }); |
| 624 | 636 | }).pipe(Effect.scoped), |
| 625 | 637 | ); |
| 638 | ||
| 639 | it.effect("version discovery does not grant consent or start device tools", () => | |
| 640 | Effect.gen(function* () { | |
| 641 | const { service, starts, agentStarts, requests } = yield* fixture(); | |
| 642 | const state = yield* service.inspect; | |
| 643 | expect(state.supportsToolInspection).toBe(true); | |
| 644 | expect(state.hostStatus).toBe("disabled"); | |
| 645 | expect(state.hosts).toHaveLength(1); | |
| 646 | expect(starts).toEqual([]); | |
| 647 | expect(agentStarts).toEqual([]); | |
| 648 | expect(requests).toEqual([]); | |
| 649 | }).pipe(Effect.scoped), | |
| 650 | ); | |
| 651 | ||
| 652 | it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () => | |
| 653 | Effect.gen(function* () { | |
| 654 | const { service, starts } = yield* fixture(Effect.void, undefined, false, undefined, true); | |
| 655 | const state = yield* service.inspect; | |
| 656 | expect(state.supportsToolInspection).toBe(true); | |
| 657 | expect(state.hostStatus).toBe("disabled"); | |
| 658 | expect(state.hosts[0]?.hubInstalled).toBe(true); | |
| 659 | expect(state.hosts[0]?.toolInspectionError).toContain("Reconnect the host"); | |
| 660 | expect(starts).toEqual([]); | |
| 661 | }).pipe(Effect.scoped), | |
| 662 | ); |
apps/server/src/device/DeviceService.ts+35| ... | ... | @@ -126,6 +126,7 @@ export class DeviceService extends Context.Service< |
| 126 | 126 | ) => Effect.Effect<DeviceServiceState, DeviceError>; |
| 127 | 127 | /** Refreshes devices only after device support has been enabled. */ |
| 128 | 128 | readonly list: Effect.Effect<DeviceServiceState, DeviceError>; |
| 129 | readonly inspect: Effect.Effect<DeviceServiceState>; | |
| 129 | 130 | readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>; |
| 130 | 131 | readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>; |
| 131 | 132 | readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>; |
| ... | ... | @@ -204,6 +205,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 204 | 205 | const stateRef = yield* SynchronizedRef.make<ServiceState>({ |
| 205 | 206 | state: { |
| 206 | 207 | supportsHostRetry: true, |
| 208 | supportsToolInspection: true, | |
| 207 | 209 | hosts: initialHosts, |
| 208 | 210 | hostStatus: initialSettings.enabled ? "idle" : "disabled", |
| 209 | 211 | hostStatuses: {}, |
| ... | ... | @@ -465,6 +467,38 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 465 | 467 | return (yield* SynchronizedRef.get(stateRef)).state; |
| 466 | 468 | }).pipe(Effect.withSpan("DeviceService.list")); |
| 467 | 469 | |
| 470 | const inspect = Effect.gen(function* () { | |
| 471 | yield* Effect.forEach( | |
| 472 | hosts.values(), | |
| 473 | (host) => | |
| 474 | Effect.gen(function* () { | |
| 475 | const result = yield* (host.inspect ?? host.summary).pipe(Effect.result); | |
| 476 | if (hosts.get(host.id) !== host) return; | |
| 477 | if (result._tag === "Failure") { | |
| 478 | yield* publish((state) => ({ | |
| 479 | ...state, | |
| 480 | hosts: state.hosts.map((value) => | |
| 481 | value.id === host.id | |
| 482 | ? { | |
| 483 | ...value, | |
| 484 | toolInspectionError: | |
| 485 | "Cannot check versions. Reconnect the host and check again. Installed tools have not been changed.", | |
| 486 | } | |
| 487 | : value, | |
| 488 | ), | |
| 489 | })); | |
| 490 | return; | |
| 491 | } | |
| 492 | yield* publish((state) => ({ | |
| 493 | ...state, | |
| 494 | hosts: state.hosts.map((value) => (value.id === host.id ? result.success : value)), | |
| 495 | })); | |
| 496 | }), | |
| 497 | { concurrency: 4 }, | |
| 498 | ); | |
| 499 | return (yield* SynchronizedRef.get(stateRef)).state; | |
| 500 | }); | |
| 501 | ||
| 468 | 502 | const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")( |
| 469 | 503 | function* (hostId) { |
| 470 | 504 | yield* resolveHost(hostId); |
| ... | ... | @@ -866,6 +900,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 866 | 900 | ...DeviceService.of({ |
| 867 | 901 | testHost, |
| 868 | 902 | retryHost, |
| 903 | inspect, | |
| 869 | 904 | agentCli: Effect.fail( |
| 870 | 905 | new DeviceHostUnavailableError({ |
| 871 | 906 | hostId: LOCAL_DEVICE_HOST_ID, |
apps/server/src/device/SshDeviceHost.ts+9-2| ... | ... | @@ -371,7 +371,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( |
| 371 | 371 | Effect.gen(function* () { |
| 372 | 372 | stopped = false; |
| 373 | 373 | if (ready) return ready; |
| 374 | summary = yield* provide(probe(config)); | |
| 374 | summary = yield* provide(probe(config, owner)); | |
| 375 | 375 | yield* onPhase( |
| 376 | 376 | summary.hubInstalled ? "starting" : "installing", |
| 377 | 377 | summary.hubInstalled |
| ... | ... | @@ -424,6 +424,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( |
| 424 | 424 | return { |
| 425 | 425 | id: config.id, |
| 426 | 426 | summary: Effect.sync(() => summary), |
| 427 | inspect: provide(probe(config, owner)).pipe( | |
| 428 | Effect.tap((value) => | |
| 429 | Effect.sync(() => { | |
| 430 | summary = value; | |
| 431 | }), | |
| 432 | ), | |
| 433 | ), | |
| 427 | 434 | current: Effect.sync(() => ready), |
| 428 | 435 | ensureReady, |
| 429 | 436 | ensureAgentReady: (onPhase) => |
| ... | ... | @@ -452,7 +459,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( |
| 452 | 459 | stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), |
| 453 | 460 | stop, |
| 454 | 461 | platformAvailability: (platform) => |
| 455 | provide(probe(config)).pipe( | |
| 462 | provide(probe(config, owner)).pipe( | |
| 456 | 463 | Effect.map((value) => { |
| 457 | 464 | summary = { ...summary, platforms: value.platforms }; |
| 458 | 465 | return value.platforms.find((p) => p.platform === platform)!; |
apps/server/src/ws.ts+8-4| ... | ... | @@ -3456,10 +3456,14 @@ const makeWsRpcLayer = ( |
| 3456 | 3456 | [WS_METHODS.deviceList]: (input) => |
| 3457 | 3457 | observeRpcEffect( |
| 3458 | 3458 | WS_METHODS.deviceList, |
| 3459 | authorizeEffect( | |
| 3460 | requiredScopeForDeviceList(input), | |
| 3461 | input.retryHostId ? deviceService.retryHost(input.retryHostId) : deviceService.list, | |
| 3462 | ), | |
| 3459 | input.inspectOnly | |
| 3460 | ? deviceService.inspect | |
| 3461 | : authorizeEffect( | |
| 3462 | requiredScopeForDeviceList(input), | |
| 3463 | input.retryHostId | |
| 3464 | ? deviceService.retryHost(input.retryHostId) | |
| 3465 | : deviceService.list, | |
| 3466 | ), | |
| 3463 | 3467 | { |
| 3464 | 3468 | "rpc.aggregate": "device", |
| 3465 | 3469 | }, |
apps/web/src/components/device/DeviceToolVersions.tsx+19-1| ... | ... | @@ -1,12 +1,19 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 1 | 2 | import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts"; |
| 2 | 3 | import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover"; |
| 3 | 4 | |
| 4 | 5 | export function DeviceToolVersions({ |
| 5 | 6 | tools, |
| 7 | action, | |
| 6 | 8 | kind, |
| 9 | owner, | |
| 10 | error, | |
| 7 | 11 | }: { |
| 8 | 12 | tools: ToolVersions | undefined; |
| 13 | action?: ReactNode; | |
| 9 | 14 | kind?: keyof ToolVersions; |
| 15 | owner?: string | undefined; | |
| 16 | error?: string | undefined; | |
| 10 | 17 | }) { |
| 11 | 18 | const selected = kind ? tools?.[kind] : undefined; |
| 12 | 19 | const version = |
| ... | ... | @@ -33,7 +40,9 @@ export function DeviceToolVersions({ |
| 33 | 40 | : selected |
| 34 | 41 | ? "Not installed" |
| 35 | 42 | : "Version unknown" |
| 36 | : "Versions"} | |
| 43 | : error | |
| 44 | ? "Versions unavailable" | |
| 45 | : "Versions"} | |
| 37 | 46 | </PopoverTrigger> |
| 38 | 47 | <PopoverPopup align="end" className="w-80"> |
| 39 | 48 | <PopoverTitle className="text-sm">{kind ? label : "Device tools"}</PopoverTitle> |
| ... | ... | @@ -65,6 +74,15 @@ export function DeviceToolVersions({ |
| 65 | 74 | ) : ( |
| 66 | 75 | <p className="mt-3 text-xs text-muted-foreground">Versions have not been checked.</p> |
| 67 | 76 | )} |
| 77 | <p className="mt-4 border-t border-border/50 pt-3 text-xs text-muted-foreground"> | |
| 78 | {owner ? `Managed by ${owner}. ` : ""}Tools update automatically on this host when needed. | |
| 79 | </p> | |
| 80 | {error ? ( | |
| 81 | <p role="status" className="mt-2 text-xs text-destructive"> | |
| 82 | {error} | |
| 83 | </p> | |
| 84 | ) : null} | |
| 85 | {action ? <div className="mt-3">{action}</div> : null} | |
| 68 | 86 | </PopoverPopup> |
| 69 | 87 | </Popover> |
| 70 | 88 | ); |
apps/web/src/components/settings/DeviceHostsSettings.tsx+36-11| ... | ... | @@ -5,7 +5,7 @@ import { Spinner } from "../ui/spinner"; |
| 5 | 5 | import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts"; |
| 6 | 6 | import { randomUUID } from "../../lib/utils"; |
| 7 | 7 | import { useState } from "react"; |
| 8 | import { useDeviceState } from "../../state/device"; | |
| 8 | import { deviceEnvironment, useDeviceState } from "../../state/device"; | |
| 9 | 9 | import { serverEnvironment } from "../../state/server"; |
| 10 | 10 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 11 | 11 | import { Button } from "../ui/button"; |
| ... | ... | @@ -110,6 +110,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null |
| 110 | 110 | </p> |
| 111 | 111 | ) : null} |
| 112 | 112 | <DeviceHostList |
| 113 | environmentLabel={environment.label} | |
| 113 | 114 | environmentId={environment.environmentId} |
| 114 | 115 | hosts={environment.serverConfig?.settings.deviceHosts ?? []} |
| 115 | 116 | busy={projectScope || busy} |
| ... | ... | @@ -158,6 +159,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null |
| 158 | 159 | } |
| 159 | 160 | |
| 160 | 161 | function DeviceHostList({ |
| 162 | environmentLabel, | |
| 161 | 163 | environmentId, |
| 162 | 164 | hosts, |
| 163 | 165 | busy, |
| ... | ... | @@ -166,6 +168,7 @@ function DeviceHostList({ |
| 166 | 168 | checks, |
| 167 | 169 | testConnection, |
| 168 | 170 | }: { |
| 171 | environmentLabel: string; | |
| 169 | 172 | environmentId: EnvironmentId; |
| 170 | 173 | hosts: ReadonlyArray<SshDeviceHostConfig>; |
| 171 | 174 | busy: boolean; |
| ... | ... | @@ -175,6 +178,8 @@ function DeviceHostList({ |
| 175 | 178 | testConnection: ReturnType<typeof useHostConnectionChecks>["testConnection"]; |
| 176 | 179 | }) { |
| 177 | 180 | const { state } = useDeviceState(environmentId); |
| 181 | const retry = useAtomCommand(deviceEnvironment.list); | |
| 182 | const [retrying, setRetrying] = useState<string | null>(null); | |
| 178 | 183 | return ( |
| 179 | 184 | <> |
| 180 | 185 | {hosts.length === 0 ? ( |
| ... | ... | @@ -238,9 +243,11 @@ function DeviceHostList({ |
| 238 | 243 | </div> |
| 239 | 244 | <p className="truncate text-xs text-muted-foreground">{host.target}</p> |
| 240 | 245 | <DeviceToolVersions |
| 246 | owner={environmentLabel} | |
| 247 | error={state.hosts.find((value) => value.id === host.id)?.toolInspectionError} | |
| 241 | 248 | tools={ |
| 242 | (check?.status === "connected" ? check.tools : undefined) ?? | |
| 243 | state.hosts.find((value) => value.id === host.id)?.tools | |
| 249 | state.hosts.find((value) => value.id === host.id)?.tools ?? | |
| 250 | (check?.status === "connected" ? check.tools : undefined) | |
| 244 | 251 | } |
| 245 | 252 | /> |
| 246 | 253 | {check?.status === "local" ? ( |
| ... | ... | @@ -290,14 +297,32 @@ function DeviceHostList({ |
| 290 | 297 | </MenuItem> |
| 291 | 298 | </MenuPopup> |
| 292 | 299 | </Menu> |
| 293 | <Button | |
| 294 | size="sm" | |
| 295 | variant="outline" | |
| 296 | disabled={busy || progress !== null} | |
| 297 | onClick={() => void testConnection(host)} | |
| 298 | > | |
| 299 | Test connection | |
| 300 | </Button> | |
| 300 | {status?.status === "failed" && | |
| 301 | state.supportsHostRetry && | |
| 302 | state.hostStatus !== "disabled" ? ( | |
| 303 | <Button | |
| 304 | size="sm" | |
| 305 | variant="outline" | |
| 306 | disabled={busy || retrying !== null} | |
| 307 | onClick={() => { | |
| 308 | setRetrying(host.id); | |
| 309 | void retry({ environmentId, input: { retryHostId: host.id } }).finally(() => | |
| 310 | setRetrying(null), | |
| 311 | ); | |
| 312 | }} | |
| 313 | > | |
| 314 | {retrying === host.id ? "Retrying…" : "Retry"} | |
| 315 | </Button> | |
| 316 | ) : ( | |
| 317 | <Button | |
| 318 | size="sm" | |
| 319 | variant="outline" | |
| 320 | disabled={busy || progress !== null} | |
| 321 | onClick={() => void testConnection(host)} | |
| 322 | > | |
| 323 | Test connection | |
| 324 | </Button> | |
| 325 | )} | |
| 301 | 326 | </div> |
| 302 | 327 | ); |
| 303 | 328 | })} |
apps/web/src/components/settings/IntegrationsSettings.tsx+22-5| ... | ... | @@ -685,6 +685,21 @@ function DeviceIntegrationControls({ |
| 685 | 685 | } |
| 686 | 686 | }; |
| 687 | 687 | |
| 688 | const checkVersions = state.supportsToolInspection ? ( | |
| 689 | <Button | |
| 690 | size="sm" | |
| 691 | variant="outline" | |
| 692 | disabled={!environmentId || pending !== null || busy} | |
| 693 | onClick={() => { | |
| 694 | if (!environmentId) return; | |
| 695 | setPending("check"); | |
| 696 | void list({ environmentId, input: { inspectOnly: true } }).finally(() => setPending(null)); | |
| 697 | }} | |
| 698 | > | |
| 699 | {pending === "check" ? "Checking…" : "Check versions"} | |
| 700 | </Button> | |
| 701 | ) : null; | |
| 702 | ||
| 688 | 703 | return ( |
| 689 | 704 | <> |
| 690 | 705 | <SettingsRow |
| ... | ... | @@ -695,6 +710,7 @@ function DeviceIntegrationControls({ |
| 695 | 710 | control={ |
| 696 | 711 | <> |
| 697 | 712 | <DeviceToolVersions |
| 713 | action={checkVersions} | |
| 698 | 714 | kind="hub" |
| 699 | 715 | tools={state.hosts.find((host) => host.kind === "local")?.tools} |
| 700 | 716 | /> |
| ... | ... | @@ -758,6 +774,7 @@ function DeviceIntegrationControls({ |
| 758 | 774 | control={ |
| 759 | 775 | <> |
| 760 | 776 | <DeviceToolVersions |
| 777 | action={checkVersions} | |
| 761 | 778 | kind="agent" |
| 762 | 779 | tools={state.hosts.find((host) => host.kind === "local")?.tools} |
| 763 | 780 | /> |
| ... | ... | @@ -780,12 +797,12 @@ function DeviceIntegrationControls({ |
| 780 | 797 | </> |
| 781 | 798 | } |
| 782 | 799 | /> |
| 783 | {state.hostStatus === "failed" && state.hostStatusDetail ? ( | |
| 784 | <p role="alert" className="px-4 py-3 text-xs text-destructive"> | |
| 785 | {state.hostStatusDetail} | |
| 786 | </p> | |
| 800 | {environmentId ? ( | |
| 801 | <DeviceHostUpdates | |
| 802 | state={{ ...state, hosts: state.hosts.filter((host) => host.kind === "local") }} | |
| 803 | environmentId={environmentId} | |
| 804 | /> | |
| 787 | 805 | ) : null} |
| 788 | {environmentId ? <DeviceHostUpdates state={state} environmentId={environmentId} /> : null} | |
| 789 | 806 | <DeviceHostsSettings environmentId={environmentId} /> |
| 790 | 807 | </> |
| 791 | 808 | ); |
docs/user/devices.md+6| ... | ... | @@ -102,3 +102,9 @@ T3 provides discovery, streaming, and control. Arrange app builds, |
| 102 | 102 | installation, and connectivity to development servers such as Metro separately. |
| 103 | 103 | A simulator on another machine cannot reach Metro through your environment's |
| 104 | 104 | localhost without forwarding or another reachable address. |
| 105 | ||
| 106 | ## Device tool updates | |
| 107 | ||
| 108 | The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. Settings → Integrations → Check device tool versions reads installed versions without installing tools or starting devices. | |
| 109 | ||
| 110 | To receive newer tool versions on a remote environment, update that environment's T3 server. Updating only the browser or mobile app does not update the remote server. An offline host keeps its installed files, but an update needs network access before device support can start; T3 does not fall back to an older version. Reconnect the host and use Retry if installation fails. Existing device and agent-access settings are preserved. |
packages/client-runtime/src/state/device.ts+13| ... | ... | @@ -84,3 +84,16 @@ export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) { |
| 84 | 84 | return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`; |
| 85 | 85 | }); |
| 86 | 86 | } |
| 87 | ||
| 88 | export function deviceToolUpdatePolicy(tools: DeviceToolVersions | undefined) { | |
| 89 | if (!tools) return "Versions have not been checked. Reconnect the host and check versions."; | |
| 90 | const outdated = [tools.hub, tools.agent].filter( | |
| 91 | (tool) => | |
| 92 | tool.installedVersions.length > 0 && !tool.installedVersions.includes(tool.requiredVersion), | |
| 93 | ); | |
| 94 | return outdated.length > 0 | |
| 95 | ? "Update pending. Required tools will install automatically when next used. The host needs network access; an older install is not used as a fallback." | |
| 96 | : "Required tools are installed automatically when needed. Checking versions does not install or start anything."; | |
| 97 | } | |
| 98 | export const deviceToolUpdateOwnership = | |
| 99 | "This environment's T3 server chooses device tool versions for itself and its SSH hosts. Update that server to receive newer tool versions; updating only your browser or mobile app does not update a remote server."; |
packages/contracts/src/device.ts+4| ... | ... | @@ -93,6 +93,7 @@ export const DeviceHostSummary = Schema.Struct({ |
| 93 | 93 | label: TrimmedNonEmptyString, |
| 94 | 94 | platforms: Schema.Array(DevicePlatformAvailability), |
| 95 | 95 | tools: Schema.optional(DeviceToolVersions), |
| 96 | toolInspectionError: Schema.optional(Schema.String), | |
| 96 | 97 | hubInstalled: Schema.Boolean, |
| 97 | 98 | agentDeviceInstalled: Schema.Boolean, |
| 98 | 99 | }); |
| ... | ... | @@ -128,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type; |
| 128 | 129 | |
| 129 | 130 | export const DeviceServiceState = Schema.Struct({ |
| 130 | 131 | supportsHostRetry: Schema.optional(Schema.Boolean), |
| 132 | supportsToolInspection: Schema.optional(Schema.Boolean), | |
| 131 | 133 | hosts: Schema.Array(DeviceHostSummary), |
| 132 | 134 | hostStatus: DeviceHostStatus, |
| 133 | 135 | hostStatusDetail: Schema.optional(Schema.String), |
| ... | ... | @@ -152,6 +154,8 @@ export const DeviceServiceState = Schema.Struct({ |
| 152 | 154 | export type DeviceServiceState = typeof DeviceServiceState.Type; |
| 153 | 155 | |
| 154 | 156 | export const DeviceListInput = Schema.Struct({ |
| 157 | /** Read inventory without installing tools or starting helpers. */ | |
| 158 | inspectOnly: Schema.optional(Schema.Boolean), | |
| 155 | 159 | /** Retry this host only, including agent tools if access was already granted. */ |
| 156 | 160 | retryHostId: Schema.optional(DeviceHostId), |
| 157 | 161 | }); |