authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-20 21:45:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 21:45:19-07:00
log61b25b88d7922a480790066341867f3f8ae42fcb
tree09f9c135c19b5c8ce437e14bec8ed01da6f8bfe7
parent3b836f9942143cdcf1ad7f121e78b59476585781
signature Signed by PGP key B5690EEEBB952194

feat(devices): add read-only update discovery and remote ownership (#12818)


12 files changed, 222 insertions(+), 30 deletions(-)

apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx+32-7
......@@ -1,4 +1,8 @@
1import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device";
1import {
2 deviceToolVersionLabels,
3 deviceToolUpdateOwnership,
4 deviceToolUpdatePolicy,
5} from "@t3tools/client-runtime/state/device";
26import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
37import { createNativeStackNavigator } from "@react-navigation/native-stack";
48import { EnvironmentId, ThreadId } from "@t3tools/contracts";
......@@ -131,6 +135,18 @@ function DevicePreviewScreen({
131135 void retryHost({ environmentId, input: { retryHostId: host.id } });
132136 },
133137 })) ?? []),
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 : []),
134150 {
135151 id: "device-tools",
136152 title: "Device tool versions",
......@@ -138,17 +154,26 @@ function DevicePreviewScreen({
138154 onPress: () =>
139155 Alert.alert(
140156 "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") +
144166 "\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 ""),
146171 ),
147172 },
148173 {
149174 id: "reload",
150175 title: "Reload stream",
151 icon: "arrow.clockwise",
176 icon: "arrow.clockwise" as const,
152177 disabled: !preview || shuttingDown,
153178 onPress: () => {
154179 setInputConnected(false);
......@@ -178,7 +203,7 @@ function DevicePreviewScreen({
178203 {
179204 id: "rotate",
180205 title: "Rotate device",
181 icon: "arrow.clockwise",
206 icon: "arrow.clockwise" as const,
182207 disabled: !inputConnected,
183208 onPress: () => streamRef.current?.rotate(),
184209 },
apps/server/src/device/DeviceHost.ts+1
......@@ -80,6 +80,7 @@ export class DeviceHost extends Context.Service<
8080 {
8181 readonly id: DeviceHostId;
8282 readonly summary: Effect.Effect<DeviceHostSummary>;
83 readonly inspect?: Effect.Effect<DeviceHostSummary, DeviceHostError>;
8384 readonly platformAvailability: (
8485 platform: DevicePlatform,
8586 ) => Effect.Effect<DevicePlatformAvailability>;
apps/server/src/device/DeviceService.test.ts+37
......@@ -67,6 +67,7 @@ const fixture = Effect.fn("fixture")(function* (
6767 bootError?: string,
6868 failListAfterShutdown = false,
6969 runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError,
70 inspectError = false,
7071) {
7172 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
7273 const starts: string[] = [];
......@@ -82,6 +83,17 @@ const fixture = Effect.fn("fixture")(function* (
8283 run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }),
8384 };
8485 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 : {}),
8597 id: LOCAL_DEVICE_HOST_ID,
8698 summary: Effect.succeed({
8799 id: LOCAL_DEVICE_HOST_ID,
......@@ -623,3 +635,28 @@ it.effect("host retry exposes actionable failure without internal IDs or diagnos
623635 });
624636 }).pipe(Effect.scoped),
625637);
638
639it.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
652it.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<
126126 ) => Effect.Effect<DeviceServiceState, DeviceError>;
127127 /** Refreshes devices only after device support has been enabled. */
128128 readonly list: Effect.Effect<DeviceServiceState, DeviceError>;
129 readonly inspect: Effect.Effect<DeviceServiceState>;
129130 readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>;
130131 readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;
131132 readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>;
......@@ -204,6 +205,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
204205 const stateRef = yield* SynchronizedRef.make<ServiceState>({
205206 state: {
206207 supportsHostRetry: true,
208 supportsToolInspection: true,
207209 hosts: initialHosts,
208210 hostStatus: initialSettings.enabled ? "idle" : "disabled",
209211 hostStatuses: {},
......@@ -465,6 +467,38 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
465467 return (yield* SynchronizedRef.get(stateRef)).state;
466468 }).pipe(Effect.withSpan("DeviceService.list"));
467469
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
468502 const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")(
469503 function* (hostId) {
470504 yield* resolveHost(hostId);
......@@ -866,6 +900,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
866900 ...DeviceService.of({
867901 testHost,
868902 retryHost,
903 inspect,
869904 agentCli: Effect.fail(
870905 new DeviceHostUnavailableError({
871906 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* (
371371 Effect.gen(function* () {
372372 stopped = false;
373373 if (ready) return ready;
374 summary = yield* provide(probe(config));
374 summary = yield* provide(probe(config, owner));
375375 yield* onPhase(
376376 summary.hubInstalled ? "starting" : "installing",
377377 summary.hubInstalled
......@@ -424,6 +424,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
424424 return {
425425 id: config.id,
426426 summary: Effect.sync(() => summary),
427 inspect: provide(probe(config, owner)).pipe(
428 Effect.tap((value) =>
429 Effect.sync(() => {
430 summary = value;
431 }),
432 ),
433 ),
427434 current: Effect.sync(() => ready),
428435 ensureReady,
429436 ensureAgentReady: (onPhase) =>
......@@ -452,7 +459,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
452459 stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore),
453460 stop,
454461 platformAvailability: (platform) =>
455 provide(probe(config)).pipe(
462 provide(probe(config, owner)).pipe(
456463 Effect.map((value) => {
457464 summary = { ...summary, platforms: value.platforms };
458465 return value.platforms.find((p) => p.platform === platform)!;
apps/server/src/ws.ts+8-4
......@@ -3456,10 +3456,14 @@ const makeWsRpcLayer = (
34563456 [WS_METHODS.deviceList]: (input) =>
34573457 observeRpcEffect(
34583458 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 ),
34633467 {
34643468 "rpc.aggregate": "device",
34653469 },
apps/web/src/components/device/DeviceToolVersions.tsx+19-1
......@@ -1,12 +1,19 @@
1import type { ReactNode } from "react";
12import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts";
23import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover";
34
45export function DeviceToolVersions({
56 tools,
7 action,
68 kind,
9 owner,
10 error,
711}: {
812 tools: ToolVersions | undefined;
13 action?: ReactNode;
914 kind?: keyof ToolVersions;
15 owner?: string | undefined;
16 error?: string | undefined;
1017}) {
1118 const selected = kind ? tools?.[kind] : undefined;
1219 const version =
......@@ -33,7 +40,9 @@ export function DeviceToolVersions({
3340 : selected
3441 ? "Not installed"
3542 : "Version unknown"
36 : "Versions"}
43 : error
44 ? "Versions unavailable"
45 : "Versions"}
3746 </PopoverTrigger>
3847 <PopoverPopup align="end" className="w-80">
3948 <PopoverTitle className="text-sm">{kind ? label : "Device tools"}</PopoverTitle>
......@@ -65,6 +74,15 @@ export function DeviceToolVersions({
6574 ) : (
6675 <p className="mt-3 text-xs text-muted-foreground">Versions have not been checked.</p>
6776 )}
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}
6886 </PopoverPopup>
6987 </Popover>
7088 );
apps/web/src/components/settings/DeviceHostsSettings.tsx+36-11
......@@ -5,7 +5,7 @@ import { Spinner } from "../ui/spinner";
55import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts";
66import { randomUUID } from "../../lib/utils";
77import { useState } from "react";
8import { useDeviceState } from "../../state/device";
8import { deviceEnvironment, useDeviceState } from "../../state/device";
99import { serverEnvironment } from "../../state/server";
1010import { useAtomCommand } from "../../state/use-atom-command";
1111import { Button } from "../ui/button";
......@@ -110,6 +110,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
110110 </p>
111111 ) : null}
112112 <DeviceHostList
113 environmentLabel={environment.label}
113114 environmentId={environment.environmentId}
114115 hosts={environment.serverConfig?.settings.deviceHosts ?? []}
115116 busy={projectScope || busy}
......@@ -158,6 +159,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
158159}
159160
160161function DeviceHostList({
162 environmentLabel,
161163 environmentId,
162164 hosts,
163165 busy,
......@@ -166,6 +168,7 @@ function DeviceHostList({
166168 checks,
167169 testConnection,
168170}: {
171 environmentLabel: string;
169172 environmentId: EnvironmentId;
170173 hosts: ReadonlyArray<SshDeviceHostConfig>;
171174 busy: boolean;
......@@ -175,6 +178,8 @@ function DeviceHostList({
175178 testConnection: ReturnType<typeof useHostConnectionChecks>["testConnection"];
176179}) {
177180 const { state } = useDeviceState(environmentId);
181 const retry = useAtomCommand(deviceEnvironment.list);
182 const [retrying, setRetrying] = useState<string | null>(null);
178183 return (
179184 <>
180185 {hosts.length === 0 ? (
......@@ -238,9 +243,11 @@ function DeviceHostList({
238243 </div>
239244 <p className="truncate text-xs text-muted-foreground">{host.target}</p>
240245 <DeviceToolVersions
246 owner={environmentLabel}
247 error={state.hosts.find((value) => value.id === host.id)?.toolInspectionError}
241248 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)
244251 }
245252 />
246253 {check?.status === "local" ? (
......@@ -290,14 +297,32 @@ function DeviceHostList({
290297 </MenuItem>
291298 </MenuPopup>
292299 </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 )}
301326 </div>
302327 );
303328 })}
apps/web/src/components/settings/IntegrationsSettings.tsx+22-5
......@@ -685,6 +685,21 @@ function DeviceIntegrationControls({
685685 }
686686 };
687687
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
688703 return (
689704 <>
690705 <SettingsRow
......@@ -695,6 +710,7 @@ function DeviceIntegrationControls({
695710 control={
696711 <>
697712 <DeviceToolVersions
713 action={checkVersions}
698714 kind="hub"
699715 tools={state.hosts.find((host) => host.kind === "local")?.tools}
700716 />
......@@ -758,6 +774,7 @@ function DeviceIntegrationControls({
758774 control={
759775 <>
760776 <DeviceToolVersions
777 action={checkVersions}
761778 kind="agent"
762779 tools={state.hosts.find((host) => host.kind === "local")?.tools}
763780 />
......@@ -780,12 +797,12 @@ function DeviceIntegrationControls({
780797 </>
781798 }
782799 />
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 />
787805 ) : null}
788 {environmentId ? <DeviceHostUpdates state={state} environmentId={environmentId} /> : null}
789806 <DeviceHostsSettings environmentId={environmentId} />
790807 </>
791808 );
docs/user/devices.md+6
......@@ -102,3 +102,9 @@ T3 provides discovery, streaming, and control. Arrange app builds,
102102installation, and connectivity to development servers such as Metro separately.
103103A simulator on another machine cannot reach Metro through your environment's
104104localhost without forwarding or another reachable address.
105
106## Device tool updates
107
108The 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
110To 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) {
8484 return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`;
8585 });
8686}
87
88export 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}
98export 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({
9393 label: TrimmedNonEmptyString,
9494 platforms: Schema.Array(DevicePlatformAvailability),
9595 tools: Schema.optional(DeviceToolVersions),
96 toolInspectionError: Schema.optional(Schema.String),
9697 hubInstalled: Schema.Boolean,
9798 agentDeviceInstalled: Schema.Boolean,
9899});
......@@ -128,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type;
128129
129130export const DeviceServiceState = Schema.Struct({
130131 supportsHostRetry: Schema.optional(Schema.Boolean),
132 supportsToolInspection: Schema.optional(Schema.Boolean),
131133 hosts: Schema.Array(DeviceHostSummary),
132134 hostStatus: DeviceHostStatus,
133135 hostStatusDetail: Schema.optional(Schema.String),
......@@ -152,6 +154,8 @@ export const DeviceServiceState = Schema.Struct({
152154export type DeviceServiceState = typeof DeviceServiceState.Type;
153155
154156export const DeviceListInput = Schema.Struct({
157 /** Read inventory without installing tools or starting helpers. */
158 inspectOnly: Schema.optional(Schema.Boolean),
155159 /** Retry this host only, including agent tools if access was already granted. */
156160 retryHostId: Schema.optional(DeviceHostId),
157161});