From 979ca66cedf10d685c7486da1929297c3693fe43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 4 Oct 2026 18:11:18 -0700 Subject: [PATCH] feat(clients): reach one environment over several routes (#15467) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/connection/onboarding.ts | 16 +- apps/mobile/src/connection/platform.ts | 90 +++- apps/mobile/src/connection/storage.ts | 14 +- .../mobile/src/features/cloud/cloud-drafts.ts | 7 +- .../connection/ConnectionsNewRouteScreen.tsx | 7 +- .../connection/environmentSections.test.ts | 17 + .../connection/useConnectionController.ts | 6 +- .../settings/EnvironmentRoutesSection.tsx | 305 ++++++++++++ .../SettingsEnvironmentDetailRouteScreen.tsx | 17 +- apps/mobile/src/state/environments.ts | 3 +- .../state/remote-environment-projections.ts | 5 +- .../state/use-remote-environment-registry.ts | 4 +- .../CloudEnvironmentConnectList.test.tsx | 1 + .../cloud/CloudEnvironmentConnectList.tsx | 45 +- .../settings/ConnectionsSettings.tsx | 210 +++++++-- .../settings/EnvironmentRoutesList.tsx | 229 +++++++++ .../components/settings/EnvironmentRow.tsx | 28 +- apps/web/src/connection/onboarding.ts | 11 +- apps/web/src/connection/platform.test.ts | 15 + apps/web/src/connection/platform.ts | 113 +++-- apps/web/src/connection/storage.ts | 14 +- .../src/onboarding/targetEnvironment.logic.ts | 6 +- apps/web/src/state/environments.ts | 3 +- docs/internals/remote.md | 18 + docs/user/remote-access.md | 20 + .../client-runtime/src/connection/catalog.ts | 12 + .../client-runtime/src/connection/driver.ts | 158 ++++++- .../connection/githubRoutingPermissions.ts | 20 +- .../client-runtime/src/connection/index.ts | 2 + .../client-runtime/src/connection/layer.ts | 9 +- .../src/connection/onboarding.test.ts | 27 +- .../src/connection/onboarding.ts | 55 ++- .../src/connection/outdatedHostUpdate.ts | 29 +- .../src/connection/registry.test.ts | 437 +++++++++++++++--- .../client-runtime/src/connection/registry.ts | 395 +++++++++++----- .../src/connection/routes.test.ts | 62 +++ .../client-runtime/src/connection/routes.ts | 208 +++++++++ .../src/connection/supervisor.test.ts | 365 ++++++++++++++- .../src/connection/supervisor.ts | 151 +++++- .../client-runtime/src/connection/wakeups.ts | 12 +- .../src/platform/capabilities.ts | 6 + .../src/platform/persistence.ts | 18 +- .../src/platform/storageDocument.test.ts | 58 ++- .../src/platform/storageDocument.ts | 128 +++-- .../client-runtime/src/state/connections.ts | 23 + .../client-runtime/src/state/presentation.ts | 3 +- .../src/state/threads-atoms.test.ts | 2 + packages/shared/src/hostClassification.ts | 13 + 48 files changed, 2963 insertions(+), 434 deletions(-) create mode 100644 apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx create mode 100644 apps/web/src/components/settings/EnvironmentRoutesList.tsx create mode 100644 packages/client-runtime/src/connection/routes.test.ts create mode 100644 packages/client-runtime/src/connection/routes.ts diff --git a/apps/mobile/src/connection/onboarding.ts b/apps/mobile/src/connection/onboarding.ts index fdf1f527feb7321bb443c97912eb5fde0b57db8b..5e443dfac82d919550989b30891922586830cc4b 100644 --- a/apps/mobile/src/connection/onboarding.ts +++ b/apps/mobile/src/connection/onboarding.ts @@ -13,10 +13,20 @@ const onboardingScheduler = createAtomCommandScheduler(); export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, { label: "mobile:connection:connect-pairing-url", scheduler: onboardingScheduler, - concurrency: { mode: "singleFlight", key: (pairingUrl: string) => pairingUrl }, - execute: (pairingUrl: string) => + concurrency: { + mode: "singleFlight", + // Adding a route to a different machine with the same link is its own + // operation: it must check its own expected machine. + key: (input: { readonly pairingUrl: string; readonly expectedEnvironmentId?: EnvironmentId }) => + JSON.stringify([input.pairingUrl, input.expectedEnvironmentId ?? null]), + }, + execute: (input: { + readonly pairingUrl: string; + /** Set when adding a route to this saved machine. */ + readonly expectedEnvironmentId?: EnvironmentId; + }) => ConnectionOnboarding.ConnectionOnboarding.pipe( - Effect.flatMap((onboarding) => onboarding.registerPairing({ pairingUrl })), + Effect.flatMap((onboarding) => onboarding.registerPairing(input)), ), }); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 251264c9c9ea70881a24783aeb132ea23ed46bff..90a4d12226a2de312884cb6262a2196096c2bed3 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -82,29 +82,75 @@ const connectivityLayer = Connectivity.layer({ ), }); +/** + * Wakes connections when the device moves between networks while staying + * online, such as Wi-Fi to cellular. Connectivity only reports online or + * offline, so leaving home on cellular would otherwise go unnoticed until the + * LAN socket times out. + */ +const networkPathChanges = Stream.callback<"network-changed">((queue) => + Effect.acquireRelease( + Effect.sync(() => { + let active = true; + let previous: Network.NetworkStateType | undefined; + const record = (state: Network.NetworkState) => { + const type = state.isConnected === true ? state.type : undefined; + if (previous !== undefined && type !== undefined && type !== previous) { + Queue.offerUnsafe(queue, "network-changed"); + } + previous = type ?? previous; + }; + // The listener reports changes only, so seed the current type; without + // it the first Wi-Fi to cellular move would go unnoticed. + void Network.getNetworkStateAsync() + .then((state) => { + if (active && previous === undefined && state.isConnected === true) { + previous = state.type; + } + }) + .catch(() => undefined); + const subscription = Network.addNetworkStateListener(record); + return { + remove: () => { + active = false; + subscription.remove(); + }, + }; + }), + (subscription) => Effect.sync(() => subscription.remove()), + ).pipe(Effect.asVoid), +); + const wakeupsLayer = Wakeups.layer({ - changes: Stream.merge( - Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) => - Effect.acquireRelease( - Effect.sync(() => { - let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null; - return AppState.addEventListener("change", (state) => { - if (state === "background") { - backgroundedAtMs = Date.now(); - return; - } - if (state === "active") { - Queue.offerUnsafe(queue, mobileApplicationActiveWakeup(backgroundedAtMs, Date.now())); - backgroundedAtMs = null; - } - }); - }), - (subscription) => Effect.sync(() => subscription.remove()), - ).pipe(Effect.asVoid), - ), - managedRelayAccountChanges(appAtomRegistry).pipe( - Stream.map(() => "credentials-changed" as const), - ), + changes: Stream.mergeAll( + [ + Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) => + Effect.acquireRelease( + Effect.sync(() => { + let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null; + return AppState.addEventListener("change", (state) => { + if (state === "background") { + backgroundedAtMs = Date.now(); + return; + } + if (state === "active") { + Queue.offerUnsafe( + queue, + mobileApplicationActiveWakeup(backgroundedAtMs, Date.now()), + ); + backgroundedAtMs = null; + } + }); + }), + (subscription) => Effect.sync(() => subscription.remove()), + ).pipe(Effect.asVoid), + ), + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), + ), + networkPathChanges, + ], + { concurrency: "unbounded" }, ), }); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index d2fbdd8cd9a96024c96341a63c9725cfd7620d68..6c1fd326a7b6be09db90e2c47b88c9aef918986c 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -3,6 +3,7 @@ import { registerConnectionInCatalog, removeConnectionFromCatalog, setConnectionEnabledInCatalog, + setRoutesInCatalog, removeCatalogValue, replaceCatalogValue, Persistence, @@ -27,6 +28,7 @@ function targetPersistenceError( | "list-targets" | "list-disabled-targets" | "register-connection" + | "set-connection-routes" | "remove-connection" | "set-connection-enabled", error: ConnectionTransientError, @@ -57,13 +59,17 @@ export const connectionStorageLayer = Layer.effectContext( ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ - register: (registration) => + register: (registration, routes) => catalog - .update((document) => registerConnectionInCatalog(document, registration)) + .update((document) => registerConnectionInCatalog(document, registration, routes)) .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), - remove: (target) => + setRoutes: (environmentId, routes) => catalog - .update((document) => removeConnectionFromCatalog(document, target)) + .update((document) => setRoutesInCatalog(document, environmentId, routes)) + .pipe(Effect.mapError((error) => targetPersistenceError("set-connection-routes", error))), + remove: (environmentId) => + catalog + .update((document) => removeConnectionFromCatalog(document, environmentId)) .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), setEnabled: (environmentId, enabled) => catalog diff --git a/apps/mobile/src/features/cloud/cloud-drafts.ts b/apps/mobile/src/features/cloud/cloud-drafts.ts index 1cc94da52bb333855d6a991ddc13c1cf5827d242..9adc1ee9503d0d79ef934dbd99e4562c140042b2 100644 --- a/apps/mobile/src/features/cloud/cloud-drafts.ts +++ b/apps/mobile/src/features/cloud/cloud-drafts.ts @@ -1,4 +1,4 @@ -import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { EnvironmentRegistry, connectionRoutes } from "@t3tools/client-runtime/connection"; import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -27,7 +27,10 @@ export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntim const entries = yield* SubscriptionRef.get(registry.entries); const environmentIds = new Set( [...entries.values()] - .filter((entry) => entry.target._tag === "RelayConnectionTarget") + // Only environments that sign-out removes entirely lose their drafts. + .filter((entry) => + connectionRoutes(entry).every((route) => route.target._tag === "RelayConnectionTarget"), + ) .map((entry) => entry.target.environmentId), ); // Credentials are already revoked. A failed backup must leave the local diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 1cf2dfd6b69b1f784506a635e26432546e8a3dc4..b46f5433ff60bdaefe4407211269ce73810d2631 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -6,6 +6,7 @@ import { useRoute, type StaticScreenProps, } from "@react-navigation/native"; +import type { EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Linking, Platform, View } from "react-native"; @@ -23,6 +24,8 @@ type ConnectionsNewRouteParams = { readonly mode?: string; readonly pairingUrl?: string; readonly autoConnect?: string; + /** Adds a route to this saved machine instead of a new environment. */ + readonly routeFor?: EnvironmentId; }; export function ConnectionsNewRouteScreen({ @@ -158,7 +161,7 @@ export function ConnectionsNewRouteScreen({ setIsSubmitting(true); onChangeConnectionPairingUrl(pairingUrl); try { - const result = await onConnectPress(pairingUrl); + const result = await onConnectPress(pairingUrl, params.routeFor); if (AsyncResult.isSuccess(result)) { if (replaceWithHome || !navigation.canGoBack()) { navigation.dispatch(StackActions.replace("Home")); @@ -170,7 +173,7 @@ export function ConnectionsNewRouteScreen({ setIsSubmitting(false); } }, - [navigation, onChangeConnectionPairingUrl, onConnectPress], + [navigation, onChangeConnectionPairingUrl, onConnectPress, params.routeFor], ); const handleSubmit = useCallback(async () => { diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 3eff54259a0309bc6f8b342aad2aba754db1d6dd..1293e789323593a3cb73137fdf75ada6841028d0 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -157,4 +157,21 @@ describe("mobile environment settings sections", () => { expect(sections.connectedCloudEnvironments).toEqual([cloud]); expect(sections.availableCloudEnvironments).toEqual([]); }); + + it("offers T3 Connect for a machine saved only over the LAN, as an added route", () => { + const local = connectedEnvironment({ + environmentId: "environment-desk", + isRelayManaged: false, + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [local], + cloudEnvironments: [cloudEnvironment("environment-desk")], + }); + + expect(sections.localEnvironments).toEqual([local]); + expect( + sections.availableCloudEnvironments.map((environment) => environment.environmentId), + ).toEqual([EnvironmentId.make("environment-desk")]); + }); }); diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index 78bf8a5a470263ee729df5da488f7355832f91d8..6445a6ff71cbec69304f1003b51bf00e871c9824 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -69,7 +69,11 @@ export function useConnectionController() { ); const connectPairingUrl = useCallback( - (pairingUrl: string) => connectPairingUrlMutation(pairingUrl), + (pairingUrl: string, expectedEnvironmentId?: EnvironmentId) => + connectPairingUrlMutation({ + pairingUrl, + ...(expectedEnvironmentId === undefined ? {} : { expectedEnvironmentId }), + }), [connectPairingUrlMutation], ); const connectRelayEnvironment = useCallback( diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7fcb99fb68069cc522668292761f850003658c1f --- /dev/null +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -0,0 +1,305 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type ConnectionRoute, + connectionRouteAddress, + connectionRouteId, + connectionRouteLabel, + connectionRoutes, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Pressable, View } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { environmentCatalog } from "../../connection/catalog"; +import { environmentSession } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { SettingsSection } from "./components/SettingsSection"; + +const ROW_HEIGHT = 64; + +/** + * The ways this device reaches an environment, preferred first. The first + * route that answers is used, and the connection moves back up the list when + * a better route is reachable again. Edit shows drag handles and remove + * buttons; Add route pairs this machine over another address. + */ +export function EnvironmentRoutesSection({ + environmentId, + connected, + onAddRoute, +}: { + readonly environmentId: EnvironmentId; + readonly connected: boolean; + readonly onAddRoute: () => void; +}) { + const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId); + const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId)); + const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder"); + const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal"); + const [editing, setEditing] = useState(false); + const saved = entry === undefined ? [] : connectionRoutes(entry); + const savedIds = saved.map((route) => connectionRouteId(route.target)); + // A dropped order shows until the catalog matches it. + const [pending, setPending] = useState | null>(null); + const order = + pending !== null && + pending.length === savedIds.length && + pending.some((id, index) => id !== savedIds[index]) + ? pending + : savedIds; + const [drag, setDrag] = useState<{ readonly id: string; readonly translation: number } | null>( + null, + ); + if (entry === undefined) return null; + + const byId = new Map(saved.map((route) => [connectionRouteId(route.target), route])); + const routes = order.flatMap((id) => byId.get(id) ?? []); + const activeRouteId = + connected && Option.isSome(prepared) ? connectionRouteId(prepared.value.target) : null; + + const commit = (next: ReadonlyArray) => { + setPending(next); + void reorder({ environmentId, routeIds: next }).then((result) => { + if (result._tag === "Failure") setPending(null); + }); + }; + const move = (from: number, to: number) => { + if (to < 0 || to >= order.length || from === to) return; + const next = [...order]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved!); + commit(next); + }; + const dropIndex = (from: number, translation: number) => + Math.max(0, Math.min(order.length - 1, from + Math.round(translation / ROW_HEIGHT))); + const confirmRemove = (route: ConnectionRoute) => + Alert.alert( + `Remove ${connectionRouteLabel(route)}?`, + connectionRouteAddress(route) ?? undefined, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => + void removeRoute({ environmentId, routeId: connectionRouteId(route.target) }), + }, + ], + ); + + const dragFrom = drag === null ? -1 : order.indexOf(drag.id); + const dragTo = drag === null ? -1 : dropIndex(dragFrom, drag.translation); + + return ( + 1 ? ( + setEditing((value) => !value)} + className="px-2 py-1 active:opacity-70" + > + + {editing ? "Done" : "Edit"} + + + ) : undefined + } + > + {routes.map((route, index) => { + const id = connectionRouteId(route.target); + // Rows between the lifted row and its drop slot shift to make room. + const shift = + dragFrom === -1 || index === dragFrom + ? 0 + : dragFrom < dragTo && index > dragFrom && index <= dragTo + ? -ROW_HEIGHT + : dragFrom > dragTo && index < dragFrom && index >= dragTo + ? ROW_HEIGHT + : 0; + return ( + setDrag({ id, translation: 0 })} + onDragMove={(translation) => setDrag({ id, translation })} + onDragEnd={(translation, cancelled) => { + setDrag(null); + if (!cancelled) move(index, dropIndex(index, translation)); + }} + onStep={(direction) => move(index, direction === "up" ? index - 1 : index + 1)} + // The last route goes with the machine, which is "Remove" on the row. + onRemove={routes.length > 1 ? () => confirmRemove(route) : undefined} + /> + ); + })} + + + Add route + + + ); +} + +function RouteRow(props: { + readonly route: ConnectionRoute; + readonly position: number; + readonly count: number; + readonly inUse: boolean; + readonly editing: boolean; + readonly offset: number; + readonly lifted: boolean; + readonly onDragStart: () => void; + readonly onDragMove: (translation: number) => void; + readonly onDragEnd: (translation: number, cancelled: boolean) => void; + readonly onStep: (direction: "up" | "down") => void; + readonly onRemove: (() => void) | undefined; +}) { + const { route, lifted, offset } = props; + const label = connectionRouteLabel(route); + const address = connectionRouteAddress(route); + const style = useAnimatedStyle(() => ({ + transform: [ + { + translateY: lifted + ? offset + : withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System }), + }, + ], + zIndex: lifted ? 1 : 0, + })); + return ( + + {props.editing && props.onRemove ? ( + + + + ) : null} + part !== null) + .join(", ")} + className="min-w-0 flex-1 gap-0.5 px-4" + > + + {label} + {props.inUse ? ( + In use + ) : null} + + {address !== null ? ( + + {address} + + ) : null} + + {props.editing ? ( + 1} + canMoveDown={props.position < props.count} + onStart={props.onDragStart} + onMove={props.onDragMove} + onEnd={props.onDragEnd} + onStep={props.onStep} + /> + ) : null} + + ); +} + +/** Pan recognition wins over the settings scroll view only inside the handle. */ +function DragHandle(props: { + readonly title: string; + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; + readonly onStart: () => void; + readonly onMove: (translation: number) => void; + readonly onEnd: (translation: number, cancelled: boolean) => void; + readonly onStep: (direction: "up" | "down") => void; +}) { + const latest = useRef(props); + useEffect(() => { + latest.current = props; + }); + const translation = useRef(0); + const gesture = useMemo( + () => + Gesture.Pan() + .minDistance(0) + .shouldCancelWhenOutside(false) + .runOnJS(true) + .onStart(() => { + translation.current = 0; + latest.current.onStart(); + }) + .onUpdate((event) => { + translation.current = event.translationY; + latest.current.onMove(event.translationY); + }) + .onFinalize((_, success) => latest.current.onEnd(translation.current, !success)), + [], + ); + return ( + + { + if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up"); + if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down"); + }} + style={{ width: 48, height: ROW_HEIGHT, alignItems: "center", justifyContent: "center" }} + > + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index be334da900ab2c1b9029642299dea1be72ce84a9..1e8e6c73ff0f9b89d5fb3c013440ab2bf3fe26cd 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import type { StaticScreenProps } from "@react-navigation/native"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -15,6 +15,7 @@ import { environmentSession } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; +import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection"; import { SettingsActionRow } from "./components/SettingsActionRow"; import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; @@ -41,6 +42,7 @@ export function SettingsEnvironmentDetailRouteScreen({ function EnvironmentDetail({ environmentId }: { readonly environmentId: EnvironmentId }) { const insets = useSafeAreaInsets(); + const navigation = useNavigation(); const connections = useRemoteConnections(); const environment = connections.connectedEnvironments.find( (entry) => entry.environmentId === environmentId, @@ -167,6 +169,19 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm onUpdate={connections.onUpdateEnvironment} /> + + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { + screen: "SettingsEnvironmentNew", + params: { routeFor: environmentId }, + }, + }) + } + /> {!connected ? ( Connect this environment to manage it. diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts index 88d80631ad31e73def79d0d5b152715017b56739..776a615f46afb9ec679f522da19dd548fda01550 100644 --- a/apps/mobile/src/state/environments.ts +++ b/apps/mobile/src/state/environments.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { connectionCatalogDisplayUrl, + hasRelayRoute, type EnvironmentPresentation as BaseEnvironmentPresentation, } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -26,7 +27,7 @@ export function projectEnvironmentPresentation( environmentId, label: presentation.entry.target.label, displayUrl: connectionCatalogDisplayUrl(presentation.entry), - relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", + relayManaged: hasRelayRoute(presentation.entry), }; } diff --git a/apps/mobile/src/state/remote-environment-projections.ts b/apps/mobile/src/state/remote-environment-projections.ts index b1315c299d660f8fcee7b2da60295f4825e7b7ad..48b9649f5475b118e6c9f991a3ddc37b0e9e915e 100644 --- a/apps/mobile/src/state/remote-environment-projections.ts +++ b/apps/mobile/src/state/remote-environment-projections.ts @@ -52,7 +52,10 @@ export function createRemoteEnvironmentProjectionAtoms(input: { : displayUrl.replace(/^http:/, "ws:") : new URL(socketUrl).origin; const authorization = prepared?.httpAuthorization ?? null; - const relayManaged = presentation.entry.target._tag === "RelayConnectionTarget"; + // Credentials follow the route in use, which may not be the preferred one. + const relayManaged = prepared + ? prepared.target._tag === "RelayConnectionTarget" + : presentation.entry.target._tag === "RelayConnectionTarget"; previousEntry = presentation.entry; previousPrepared = prepared; diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 6a2b72c8f8c73572b93b1e561a3934918d5f085a..3a8a1022d665073ee826321c210f563eb0de6fad 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -110,10 +110,10 @@ export function useRemoteConnections() { }, []); const onConnectPress = useCallback( - async (pairingUrl?: string) => { + async (pairingUrl?: string, expectedEnvironmentId?: EnvironmentId) => { const nextPairingUrl = pairingUrl ?? connectionPairingUrl; setPendingConnectionError(null); - const result = await controller.connectPairingUrl(nextPairingUrl); + const result = await controller.connectPairingUrl(nextPairingUrl, expectedEnvironmentId); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); const message = diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx index 157861f1eaa62632938ad742ef2953b91f7c18bc..d7f2e030cb58ecf40dcf2a38e7ba08952a1ee8e2 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx @@ -327,6 +327,7 @@ describe("cloud onboarding discovery", () => { { environmentId: newMachineId, connection: { phase: "connected", error: null, traceId: null }, + relayManaged: true, }, ]} showSavedEnvironments diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 5022031e98e621c7edcc0450a7d351c8b333ea80..fe5eb7836239e9dba028163140cccbf342bdadc8 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -48,6 +48,8 @@ function discoveredCompatibilityError( export interface SavedCloudEnvironmentConnection { readonly environmentId: EnvironmentId; readonly connection: EnvironmentConnectionPresentation; + /** False for a machine saved over another route (LAN, Tailscale, SSH) only. */ + readonly relayManaged: boolean; /** Present once connected; carries the user's icon override. */ readonly serverConfig?: ServerConfig | null; } @@ -121,7 +123,15 @@ export function CloudEnvironmentConnectRows({ ReadonlySet >(new Set()); const savedById = new Map( - savedEnvironments.map((environment) => [environment.environmentId, environment]), + savedEnvironments + .filter((environment) => environment.relayManaged) + .map((environment) => [environment.environmentId, environment]), + ); + // Saved over another route only: T3 Connect would be an added fallback. + const savedWithoutRelay = new Set( + savedEnvironments + .filter((environment) => !environment.relayManaged) + .map((environment) => environment.environmentId), ); useEffect(() => { @@ -153,8 +163,12 @@ export function CloudEnvironmentConnectRows({ if (result._tag === "Success") { toastManager.add({ type: "success", - title: "Environment added", - description: `Connecting to ${environment.label} through T3 Connect.`, + title: savedWithoutRelay.has(environment.environmentId) + ? "T3 Connect route added" + : "Environment added", + description: savedWithoutRelay.has(environment.environmentId) + ? `${environment.label} falls back to T3 Connect when its other routes are unreachable.` + : `Connecting to ${environment.label} through T3 Connect.`, }); return true; } @@ -182,10 +196,14 @@ export function CloudEnvironmentConnectRows({ return false; }; + // During onboarding selection a machine saved over another route already + // has its own row elsewhere, and selecting it must not add a T3 Connect + // route as a side effect, so it is left out here. const visibleEnvironments = [...environmentsState.environments.values()].filter( ({ environment }) => environment.environmentId !== primaryEnvironmentId && - (showSavedEnvironments || !savedById.has(environment.environmentId)), + (showSavedEnvironments || !savedById.has(environment.environmentId)) && + !(selection && savedWithoutRelay.has(environment.environmentId)), ); const selectNewComputers = useEffectEvent(() => { const seen = selection?.autoSelectedComputers; @@ -337,19 +355,22 @@ export function CloudEnvironmentConnectRows({ : availability === "checking" ? "bg-warning" : "bg-muted-foreground/35"; + const notAdded = savedWithoutRelay.has(environment.environmentId) + ? "Saved without T3 Connect" + : "Not added"; const statusText = unsupported && !savedEnvironment - ? "T3 Connect · Not added · Client not supported" + ? `T3 Connect · ${notAdded} · Client not supported` : savedConnection ? savedConnection.statusText : availability === "online" - ? "T3 Connect · Not added · Relay online" + ? `T3 Connect · ${notAdded} · Relay online` : availability === "offline" - ? "T3 Connect · Not added · Relay offline" + ? `T3 Connect · ${notAdded} · Relay offline` : availability === "checking" - ? "T3 Connect · Not added · Checking relay status…" + ? `T3 Connect · ${notAdded} · Checking relay status…` : (Option.getOrNull(error)?.message ?? - "T3 Connect · Not added · Relay status unavailable"); + `T3 Connect · ${notAdded} · Relay status unavailable`); if (selection) { return (