From 61b25b88d7922a480790066341867f3f8ae42fcb Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 20 Sep 2026 21:45:19 -0700
Subject: [PATCH] feat(devices): add read-only update discovery and remote
ownership (#12818)
---
.../devices/DevicePreviewRouteScreen.tsx | 39 ++++++++++++---
apps/server/src/device/DeviceHost.ts | 1 +
apps/server/src/device/DeviceService.test.ts | 37 +++++++++++++++
apps/server/src/device/DeviceService.ts | 35 ++++++++++++++
apps/server/src/device/SshDeviceHost.ts | 11 ++++-
apps/server/src/ws.ts | 12 +++--
.../components/device/DeviceToolVersions.tsx | 20 +++++++-
.../settings/DeviceHostsSettings.tsx | 47 ++++++++++++++-----
.../settings/IntegrationsSettings.tsx | 27 +++++++++--
docs/user/devices.md | 6 +++
packages/client-runtime/src/state/device.ts | 13 +++++
packages/contracts/src/device.ts | 4 ++
12 files changed, 222 insertions(+), 30 deletions(-)
diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx
index 9af958c82b7842ac72ce6d8fd0e7f404e3124590..8d2a6700005077097d2abe5ef267ba8e8ecbe84d 100644
--- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx
+++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx
@@ -1,4 +1,8 @@
-import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device";
+import {
+ deviceToolVersionLabels,
+ deviceToolUpdateOwnership,
+ deviceToolUpdatePolicy,
+} from "@t3tools/client-runtime/state/device";
import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
@@ -131,6 +135,18 @@ function DevicePreviewScreen({
void retryHost({ environmentId, input: { retryHostId: host.id } });
},
})) ?? []),
+ ...(state.data?.supportsToolInspection
+ ? [
+ {
+ id: "check-device-tools",
+ title: "Check device tool versions",
+ icon: "arrow.clockwise" as const,
+ onPress: () => {
+ void retryHost({ environmentId, input: { inspectOnly: true } });
+ },
+ },
+ ]
+ : []),
{
id: "device-tools",
title: "Device tool versions",
@@ -138,17 +154,26 @@ function DevicePreviewScreen({
onPress: () =>
Alert.alert(
"Device tool versions",
- deviceToolVersionLabels(
- state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,
- ).join("\n") +
+ deviceToolUpdateOwnership +
+ "\n\n" +
+ deviceToolUpdatePolicy(
+ state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,
+ ) +
+ "\n\n" +
+ deviceToolVersionLabels(
+ state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,
+ ).join("\n") +
"\n" +
- (state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? ""),
+ (state.data?.hosts.find((host) => host.id === preview?.session.hostId)
+ ?.toolInspectionError ??
+ state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ??
+ ""),
),
},
{
id: "reload",
title: "Reload stream",
- icon: "arrow.clockwise",
+ icon: "arrow.clockwise" as const,
disabled: !preview || shuttingDown,
onPress: () => {
setInputConnected(false);
@@ -178,7 +203,7 @@ function DevicePreviewScreen({
{
id: "rotate",
title: "Rotate device",
- icon: "arrow.clockwise",
+ icon: "arrow.clockwise" as const,
disabled: !inputConnected,
onPress: () => streamRef.current?.rotate(),
},
diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts
index 0aa784b7149dbb4c6d82237170d50e8e952a6632..14537985b9b852d12499ecc698ade49574e8e055 100644
--- a/apps/server/src/device/DeviceHost.ts
+++ b/apps/server/src/device/DeviceHost.ts
@@ -80,6 +80,7 @@ export class DeviceHost extends Context.Service<
{
readonly id: DeviceHostId;
readonly summary: Effect.Effect;
+ readonly inspect?: Effect.Effect;
readonly platformAvailability: (
platform: DevicePlatform,
) => Effect.Effect;
diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts
index b3ff08aea1cef986c8acf35d0ba3a55ea0e40e5f..ce57c724f8be269b1c9e3d3096d2340ffbed303d 100644
--- a/apps/server/src/device/DeviceService.test.ts
+++ b/apps/server/src/device/DeviceService.test.ts
@@ -67,6 +67,7 @@ const fixture = Effect.fn("fixture")(function* (
bootError?: string,
failListAfterShutdown = false,
runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError,
+ inspectError = false,
) {
const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
const starts: string[] = [];
@@ -82,6 +83,17 @@ const fixture = Effect.fn("fixture")(function* (
run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }),
};
const host: DeviceHost.DeviceHost["Service"] = {
+ ...(inspectError
+ ? {
+ inspect: Effect.fail(
+ new DeviceHost.DeviceHostError({
+ hostId: LOCAL_DEVICE_HOST_ID,
+ step: "probe",
+ cause: new Error("offline"),
+ }),
+ ),
+ }
+ : {}),
id: LOCAL_DEVICE_HOST_ID,
summary: Effect.succeed({
id: LOCAL_DEVICE_HOST_ID,
@@ -623,3 +635,28 @@ it.effect("host retry exposes actionable failure without internal IDs or diagnos
});
}).pipe(Effect.scoped),
);
+
+it.effect("version discovery does not grant consent or start device tools", () =>
+ Effect.gen(function* () {
+ const { service, starts, agentStarts, requests } = yield* fixture();
+ const state = yield* service.inspect;
+ expect(state.supportsToolInspection).toBe(true);
+ expect(state.hostStatus).toBe("disabled");
+ expect(state.hosts).toHaveLength(1);
+ expect(starts).toEqual([]);
+ expect(agentStarts).toEqual([]);
+ expect(requests).toEqual([]);
+ }).pipe(Effect.scoped),
+);
+
+it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () =>
+ Effect.gen(function* () {
+ const { service, starts } = yield* fixture(Effect.void, undefined, false, undefined, true);
+ const state = yield* service.inspect;
+ expect(state.supportsToolInspection).toBe(true);
+ expect(state.hostStatus).toBe("disabled");
+ expect(state.hosts[0]?.hubInstalled).toBe(true);
+ expect(state.hosts[0]?.toolInspectionError).toContain("Reconnect the host");
+ expect(starts).toEqual([]);
+ }).pipe(Effect.scoped),
+);
diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts
index bb06ef66731bcbf28cfd67f2aec36e64e25bffd7..ddbe07ad9de430dc52a523f8cd0b49046445a1ae 100644
--- a/apps/server/src/device/DeviceService.ts
+++ b/apps/server/src/device/DeviceService.ts
@@ -126,6 +126,7 @@ export class DeviceService extends Context.Service<
) => Effect.Effect;
/** Refreshes devices only after device support has been enabled. */
readonly list: Effect.Effect;
+ readonly inspect: Effect.Effect;
readonly retryHost: (hostId: DeviceHostId) => Effect.Effect;
readonly open: (input: DeviceOpenInput) => Effect.Effect;
readonly close: (input: DeviceCloseInput) => Effect.Effect;
@@ -204,6 +205,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
const stateRef = yield* SynchronizedRef.make({
state: {
supportsHostRetry: true,
+ supportsToolInspection: true,
hosts: initialHosts,
hostStatus: initialSettings.enabled ? "idle" : "disabled",
hostStatuses: {},
@@ -465,6 +467,38 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
return (yield* SynchronizedRef.get(stateRef)).state;
}).pipe(Effect.withSpan("DeviceService.list"));
+ const inspect = Effect.gen(function* () {
+ yield* Effect.forEach(
+ hosts.values(),
+ (host) =>
+ Effect.gen(function* () {
+ const result = yield* (host.inspect ?? host.summary).pipe(Effect.result);
+ if (hosts.get(host.id) !== host) return;
+ if (result._tag === "Failure") {
+ yield* publish((state) => ({
+ ...state,
+ hosts: state.hosts.map((value) =>
+ value.id === host.id
+ ? {
+ ...value,
+ toolInspectionError:
+ "Cannot check versions. Reconnect the host and check again. Installed tools have not been changed.",
+ }
+ : value,
+ ),
+ }));
+ return;
+ }
+ yield* publish((state) => ({
+ ...state,
+ hosts: state.hosts.map((value) => (value.id === host.id ? result.success : value)),
+ }));
+ }),
+ { concurrency: 4 },
+ );
+ return (yield* SynchronizedRef.get(stateRef)).state;
+ });
+
const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")(
function* (hostId) {
yield* resolveHost(hostId);
@@ -866,6 +900,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
...DeviceService.of({
testHost,
retryHost,
+ inspect,
agentCli: Effect.fail(
new DeviceHostUnavailableError({
hostId: LOCAL_DEVICE_HOST_ID,
diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts
index 9ac2fa2580faf5f5dd42e55cd1e03bf5818f3096..4ed6d740215c437fdf05e1d8c5e440b0b6cdea54 100644
--- a/apps/server/src/device/SshDeviceHost.ts
+++ b/apps/server/src/device/SshDeviceHost.ts
@@ -371,7 +371,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
Effect.gen(function* () {
stopped = false;
if (ready) return ready;
- summary = yield* provide(probe(config));
+ summary = yield* provide(probe(config, owner));
yield* onPhase(
summary.hubInstalled ? "starting" : "installing",
summary.hubInstalled
@@ -424,6 +424,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
return {
id: config.id,
summary: Effect.sync(() => summary),
+ inspect: provide(probe(config, owner)).pipe(
+ Effect.tap((value) =>
+ Effect.sync(() => {
+ summary = value;
+ }),
+ ),
+ ),
current: Effect.sync(() => ready),
ensureReady,
ensureAgentReady: (onPhase) =>
@@ -452,7 +459,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore),
stop,
platformAvailability: (platform) =>
- provide(probe(config)).pipe(
+ provide(probe(config, owner)).pipe(
Effect.map((value) => {
summary = { ...summary, platforms: value.platforms };
return value.platforms.find((p) => p.platform === platform)!;
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 9194846d62bb87582e5dd1ac1457bf23efe4d56c..05b680b441f7304d83b6d13cf9632917a151c9ad 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -3456,10 +3456,14 @@ const makeWsRpcLayer = (
[WS_METHODS.deviceList]: (input) =>
observeRpcEffect(
WS_METHODS.deviceList,
- authorizeEffect(
- requiredScopeForDeviceList(input),
- input.retryHostId ? deviceService.retryHost(input.retryHostId) : deviceService.list,
- ),
+ input.inspectOnly
+ ? deviceService.inspect
+ : authorizeEffect(
+ requiredScopeForDeviceList(input),
+ input.retryHostId
+ ? deviceService.retryHost(input.retryHostId)
+ : deviceService.list,
+ ),
{
"rpc.aggregate": "device",
},
diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx
index 2ed11f7d9204d9d54dfa1dce7191cbf52674ff82..0501c8370bdc06dffe4666ddf7ab2b2a01195a49 100644
--- a/apps/web/src/components/device/DeviceToolVersions.tsx
+++ b/apps/web/src/components/device/DeviceToolVersions.tsx
@@ -1,12 +1,19 @@
+import type { ReactNode } from "react";
import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts";
import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover";
export function DeviceToolVersions({
tools,
+ action,
kind,
+ owner,
+ error,
}: {
tools: ToolVersions | undefined;
+ action?: ReactNode;
kind?: keyof ToolVersions;
+ owner?: string | undefined;
+ error?: string | undefined;
}) {
const selected = kind ? tools?.[kind] : undefined;
const version =
@@ -33,7 +40,9 @@ export function DeviceToolVersions({
: selected
? "Not installed"
: "Version unknown"
- : "Versions"}
+ : error
+ ? "Versions unavailable"
+ : "Versions"}
{kind ? label : "Device tools"}
@@ -65,6 +74,15 @@ export function DeviceToolVersions({
) : (
Versions have not been checked.
)}
+
+ {owner ? `Managed by ${owner}. ` : ""}Tools update automatically on this host when needed.
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {action ? {action}
: null}
);
diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx
index d81ce316705b257478fad1f886c7c99cb37adf80..ffed54e14643ca3a6dbebf2491f43b42a71108ef 100644
--- a/apps/web/src/components/settings/DeviceHostsSettings.tsx
+++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx
@@ -5,7 +5,7 @@ import { Spinner } from "../ui/spinner";
import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts";
import { randomUUID } from "../../lib/utils";
import { useState } from "react";
-import { useDeviceState } from "../../state/device";
+import { deviceEnvironment, useDeviceState } from "../../state/device";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { Button } from "../ui/button";
@@ -110,6 +110,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
) : null}
;
busy: boolean;
@@ -175,6 +178,8 @@ function DeviceHostList({
testConnection: ReturnType["testConnection"];
}) {
const { state } = useDeviceState(environmentId);
+ const retry = useAtomCommand(deviceEnvironment.list);
+ const [retrying, setRetrying] = useState(null);
return (
<>
{hosts.length === 0 ? (
@@ -238,9 +243,11 @@ function DeviceHostList({
{host.target}
value.id === host.id)?.toolInspectionError}
tools={
- (check?.status === "connected" ? check.tools : undefined) ??
- state.hosts.find((value) => value.id === host.id)?.tools
+ state.hosts.find((value) => value.id === host.id)?.tools ??
+ (check?.status === "connected" ? check.tools : undefined)
}
/>
{check?.status === "local" ? (
@@ -290,14 +297,32 @@ function DeviceHostList({
- void testConnection(host)}
- >
- Test connection
-
+ {status?.status === "failed" &&
+ state.supportsHostRetry &&
+ state.hostStatus !== "disabled" ? (
+ {
+ setRetrying(host.id);
+ void retry({ environmentId, input: { retryHostId: host.id } }).finally(() =>
+ setRetrying(null),
+ );
+ }}
+ >
+ {retrying === host.id ? "Retrying…" : "Retry"}
+
+ ) : (
+ void testConnection(host)}
+ >
+ Test connection
+
+ )}
);
})}
diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx
index 5f2e149d17fdcbcc2b8ee074f03046d115a2fe38..851ca7439f994148f7c1392fdfa35fe6beb3d46a 100644
--- a/apps/web/src/components/settings/IntegrationsSettings.tsx
+++ b/apps/web/src/components/settings/IntegrationsSettings.tsx
@@ -685,6 +685,21 @@ function DeviceIntegrationControls({
}
};
+ const checkVersions = state.supportsToolInspection ? (
+ {
+ if (!environmentId) return;
+ setPending("check");
+ void list({ environmentId, input: { inspectOnly: true } }).finally(() => setPending(null));
+ }}
+ >
+ {pending === "check" ? "Checking…" : "Check versions"}
+
+ ) : null;
+
return (
<>
host.kind === "local")?.tools}
/>
@@ -758,6 +774,7 @@ function DeviceIntegrationControls({
control={
<>
host.kind === "local")?.tools}
/>
@@ -780,12 +797,12 @@ function DeviceIntegrationControls({
>
}
/>
- {state.hostStatus === "failed" && state.hostStatusDetail ? (
-
- {state.hostStatusDetail}
-
+ {environmentId ? (
+ host.kind === "local") }}
+ environmentId={environmentId}
+ />
) : null}
- {environmentId ? : null}
>
);
diff --git a/docs/user/devices.md b/docs/user/devices.md
index b388688bae29883738ce94c64ff311dad9f75c61..02e06d2bdffab86d006ddf05fdedaa15064319c1 100644
--- a/docs/user/devices.md
+++ b/docs/user/devices.md
@@ -102,3 +102,9 @@ T3 provides discovery, streaming, and control. Arrange app builds,
installation, and connectivity to development servers such as Metro separately.
A simulator on another machine cannot reach Metro through your environment's
localhost without forwarding or another reachable address.
+
+## Device tool updates
+
+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.
+
+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.
diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts
index 5e08de264de165463149ba9774f4847f7a722d20..a9f5d5bb2ebde5b9ba21050c90e3ba1993e24b54 100644
--- a/packages/client-runtime/src/state/device.ts
+++ b/packages/client-runtime/src/state/device.ts
@@ -84,3 +84,16 @@ export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) {
return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`;
});
}
+
+export function deviceToolUpdatePolicy(tools: DeviceToolVersions | undefined) {
+ if (!tools) return "Versions have not been checked. Reconnect the host and check versions.";
+ const outdated = [tools.hub, tools.agent].filter(
+ (tool) =>
+ tool.installedVersions.length > 0 && !tool.installedVersions.includes(tool.requiredVersion),
+ );
+ return outdated.length > 0
+ ? "Update pending. Required tools will install automatically when next used. The host needs network access; an older install is not used as a fallback."
+ : "Required tools are installed automatically when needed. Checking versions does not install or start anything.";
+}
+export const deviceToolUpdateOwnership =
+ "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.";
diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts
index afcc04dbefb317e513df95ed5e3bbbef3dcb278c..20b49f850123a6b419e7082772d43431a75abf50 100644
--- a/packages/contracts/src/device.ts
+++ b/packages/contracts/src/device.ts
@@ -93,6 +93,7 @@ export const DeviceHostSummary = Schema.Struct({
label: TrimmedNonEmptyString,
platforms: Schema.Array(DevicePlatformAvailability),
tools: Schema.optional(DeviceToolVersions),
+ toolInspectionError: Schema.optional(Schema.String),
hubInstalled: Schema.Boolean,
agentDeviceInstalled: Schema.Boolean,
});
@@ -128,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type;
export const DeviceServiceState = Schema.Struct({
supportsHostRetry: Schema.optional(Schema.Boolean),
+ supportsToolInspection: Schema.optional(Schema.Boolean),
hosts: Schema.Array(DeviceHostSummary),
hostStatus: DeviceHostStatus,
hostStatusDetail: Schema.optional(Schema.String),
@@ -152,6 +154,8 @@ export const DeviceServiceState = Schema.Struct({
export type DeviceServiceState = typeof DeviceServiceState.Type;
export const DeviceListInput = Schema.Struct({
+ /** Read inventory without installing tools or starting helpers. */
+ inspectOnly: Schema.optional(Schema.Boolean),
/** Retry this host only, including agent tools if access was already granted. */
retryHostId: Schema.optional(DeviceHostId),
});
--
2.54.0