diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 8c41d74841e7..375bda715e12 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -1,9 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, + deregisterManagedRelayEnvironment, managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect } from "react"; @@ -19,6 +25,22 @@ export const managedRelayQueryManager = createManagedRelayQueryManager(managedRe cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }), }); +const managedRelayMutationScheduler = createAtomCommandScheduler(); + +export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand( + managedRelayAtomRuntime, + { + label: "mobile:managed-relay:deregister-environment", + scheduler: managedRelayMutationScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) => + input.accountId, + }, + execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input), + }, +); + const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null")); diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index f4a5b531d026..dbbfaaacb72a 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -4,15 +4,20 @@ import { connectionStatusText, type EnvironmentConnectionPhase, } from "@t3tools/client-runtime/connection"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import { type EnvironmentId, type EnvironmentMachineKind, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useState } from "react"; import { ActivityIndicator, + Alert, Pressable, type NativeSyntheticEvent, type TextLayoutEventData, @@ -29,7 +34,9 @@ import { serverEnvironment } from "../../state/server"; import { ProviderSetupLink } from "../settings/ProviderSetupLink"; import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; +import { deregisterManagedRelayEnvironmentCommand } from "../cloud/managedRelayState"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { useAtomCommand } from "../../state/use-atom-command"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; @@ -87,11 +94,20 @@ function CloudEnvironmentRowsContent( props: CloudEnvironmentRowsProps & { readonly discoveryAvailable?: boolean }, ) { const controller = useConnectionController(); + const managedRelaySession = useAtomValue(managedRelaySessionAtom); + const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { + reportFailure: false, + }); const discoveryAvailable = props.discoveryAvailable ?? true; const availableCloudEnvironments = discoveryAvailable ? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments) : []; const [expandedErrorId, setExpandedErrorId] = useState(null); + // Deregistrations run serially per account, so a second tap queues behind the + // first; every queued row stays disabled until its own command settles. + const [deregisteringEnvironmentIds, setDeregisteringEnvironmentIds] = useState< + ReadonlySet + >(() => new Set()); const hasCloudRows = props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; @@ -109,6 +125,53 @@ function CloudEnvironmentRowsContent( setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); }, []); + const handleDeregisterCloudEnvironment = useCallback( + (environment: RelayClientEnvironmentRecord) => { + Alert.alert( + "Deregister environment?", + `Remove ${environment.label} from your T3 Connect account? This revokes its T3 Connect access and removes its managed tunnel.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Deregister", + style: "destructive", + onPress: async () => { + if (!managedRelaySession) { + Alert.alert( + "Could not deregister environment", + "Sign in to T3 Connect before deregistering an environment.", + ); + return; + } + setDeregisteringEnvironmentIds((current) => + new Set(current).add(environment.environmentId), + ); + const result = await deregisterEnvironment({ + accountId: managedRelaySession.accountId, + environmentId: environment.environmentId, + }); + setDeregisteringEnvironmentIds((current) => { + const next = new Set(current); + next.delete(environment.environmentId); + return next; + }); + if (AsyncResult.isSuccess(result)) { + await controller.refreshRelayEnvironments(); + return; + } + const error = Cause.squash(result.cause); + Alert.alert( + "Could not deregister environment", + error instanceof Error ? error.message : "The environment could not be removed.", + ); + }, + }, + ], + ); + }, + [controller, deregisterEnvironment, managedRelaySession], + ); + const showHeader = props.showHeader ?? true; return ( @@ -160,6 +223,8 @@ function CloudEnvironmentRowsContent( environment={environment} borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0} onConnect={() => handleConnectCloudEnvironment(environment)} + onDeregister={() => handleDeregisterCloudEnvironment(environment.environment)} + deregistering={deregisteringEnvironmentIds.has(environment.environment.environmentId)} errorExpanded={expandedErrorId === environment.environment.environmentId} onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} /> @@ -264,8 +329,10 @@ function ConnectedCloudEnvironmentRow(props: { function CloudEnvironmentRow(props: { readonly environment: RelayEnvironmentView; readonly borderTop: boolean; + readonly deregistering: boolean; readonly errorExpanded: boolean; readonly onConnect: () => void; + readonly onDeregister: () => void; readonly onToggleError: () => void; }) { const presentation = availableCloudEnvironmentPresentation({ @@ -288,6 +355,8 @@ function CloudEnvironmentRow(props: { props.onConnect(); } }} + onDeregister={props.onDeregister} + deregistering={props.deregistering} onToggleError={props.onToggleError} statusText={presentation.statusText} value={false} @@ -300,12 +369,14 @@ function CloudEnvironmentRowShell(props: { readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; readonly connectionState: EnvironmentConnectionPhase; + readonly deregistering?: boolean; readonly disabled?: boolean; readonly errorExpanded: boolean; readonly label: string; /** Absent for environments the relay lists but this device has not connected to. */ readonly machine?: EnvironmentMachineKind; readonly onToggleError: () => void; + readonly onDeregister?: () => void; readonly onValueChange: (enabled: boolean) => void; readonly statusText?: string; readonly value: boolean; @@ -428,11 +499,33 @@ function CloudEnvironmentRowShell(props: { ) : null} - + + + {props.onDeregister ? ( + + {props.deregistering ? ( + + ) : ( + + )} + + ) : null} + ); } diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b2b540a83e2c..622df5a35f18 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -274,8 +274,9 @@ Use `t3 auth --help` and the nested subcommand help pages for the full reference ### Deregister a T3 Connect Environment Open your account menu and choose **T3 Connect** to see every environment registered to your -account. On mobile, open **Settings** → **T3 Connect**. Choose **Deregister** to revoke an -environment's T3 Connect access, remove any managed tunnel, and free its host space. +account. On mobile, open **Settings** → **Environments**, then choose **Deregister** beside the +environment under **T3 Connect**. This revokes the environment's T3 Connect access, removes any +managed tunnel, and frees its host space. Deregistration is an account action and does not need a connection to the environment, so it also works for a server that was wiped or is no longer reachable. Device-local connect and disconnect diff --git a/packages/client-runtime/src/state/relayDiscovery.test.ts b/packages/client-runtime/src/state/relayDiscovery.test.ts new file mode 100644 index 000000000000..f7de9793e968 --- /dev/null +++ b/packages/client-runtime/src/state/relayDiscovery.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Latch from "effect/Latch"; +import * as Layer from "effect/Layer"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { + EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + RelayEnvironmentDiscovery, +} from "../relay/discovery.ts"; +import { createRelayEnvironmentDiscoveryAtoms } from "./relayDiscovery.ts"; + +describe("createRelayEnvironmentDiscoveryAtoms", () => { + it("runs a fresh refresh after the in-flight one when requested mid-flight", async () => { + const firstRefresh = Latch.makeUnsafe(); + let markFirstRefreshStarted!: () => void; + const firstRefreshStarted = new Promise((resolve) => { + markFirstRefreshStarted = resolve; + }); + let refreshes = 0; + const discoveryLayer = Layer.effect( + RelayEnvironmentDiscovery, + Effect.gen(function* () { + const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); + return RelayEnvironmentDiscovery.of({ + state, + refresh: Effect.suspend(() => { + refreshes += 1; + if (refreshes !== 1) return Effect.void; + markFirstRefreshStarted(); + return firstRefresh.await; + }), + }); + }), + ); + const atoms = createRelayEnvironmentDiscoveryAtoms(Atom.runtime(discoveryLayer)); + const registry = AtomRegistry.make(); + + const first = atoms.refresh.run(registry, undefined); + await firstRefreshStarted; + // Simulates a relay mutation that lands while the first pass is running. + const second = atoms.refresh.run(registry, undefined); + firstRefresh.openUnsafe(); + + expect(await first).toMatchObject({ _tag: "Success" }); + expect(await second).toMatchObject({ _tag: "Success" }); + expect(refreshes).toBe(2); + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/relayDiscovery.ts b/packages/client-runtime/src/state/relayDiscovery.ts index bdf217d08800..41de849a7268 100644 --- a/packages/client-runtime/src/state/relayDiscovery.ts +++ b/packages/client-runtime/src/state/relayDiscovery.ts @@ -24,9 +24,13 @@ export function createRelayEnvironmentDiscoveryAtoms( () => RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, ), ).pipe(Atom.withLabel("relay-environment-discovery-value")); + // `latest` rather than `singleFlight`: a refresh requested while one is in + // flight must start a fresh pass once it settles. Callers refresh after + // mutating the relay (linking, deregistering), and joining a pass that began + // before the mutation landed would show the stale list as the final result. const refresh = createRuntimeCommand(runtime, { label: "relay-environment-discovery:refresh", - concurrency: { mode: "singleFlight", key: () => "refresh" }, + concurrency: { mode: "latest", key: () => "refresh" }, execute: (_input: void) => RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe( Effect.flatMap((discovery) => discovery.refresh),