Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/mobile/src/features/cloud/managedRelayState.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<ReadonlyArray<RelayClientEnvironmentRecord>>([]),
).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null"));
Expand Down
103 changes: 98 additions & 5 deletions apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -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<string | null>(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<EnvironmentId>
>(() => new Set());
const hasCloudRows =
props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0;

Expand All @@ -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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium connection/CloudEnvironmentRows.tsx:150

After a successful deregistration, the removed environment can remain displayed because controller.refreshRelayEnvironments() may only await an in-flight refresh that started before unlinkEnvironment completed. Ensure the post-delete path schedules a new discovery refresh after that operation finishes, rather than relying on the singleFlight call to start one.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/connection/CloudEnvironmentRows.tsx around line 150:

After a successful deregistration, the removed environment can remain displayed because `controller.refreshRelayEnvironments()` may only await an in-flight refresh that started before `unlinkEnvironment` completed. Ensure the post-delete path schedules a new discovery refresh after that operation finishes, rather than relying on the `singleFlight` call to start one.

return;
Comment thread
cursor[bot] marked this conversation as resolved.
}
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 (
Expand Down Expand Up @@ -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)}
/>
Expand Down Expand Up @@ -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({
Expand All @@ -288,6 +355,8 @@ function CloudEnvironmentRow(props: {
props.onConnect();
}
}}
onDeregister={props.onDeregister}
deregistering={props.deregistering}
onToggleError={props.onToggleError}
statusText={presentation.statusText}
value={false}
Expand All @@ -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;
Expand Down Expand Up @@ -428,11 +499,33 @@ function CloudEnvironmentRowShell(props: {
) : null}
</StatusContainer>
</View>
<ThemedSwitch
disabled={props.disabled}
onValueChange={props.onValueChange}
value={props.value}
/>
<View className="flex-row items-center gap-2">
<ThemedSwitch
disabled={props.disabled || props.deregistering}
onValueChange={props.onValueChange}
value={props.value}
/>
{props.onDeregister ? (
<Pressable
accessibilityLabel={`Deregister ${props.label}`}
accessibilityRole="button"
disabled={props.deregistering}
onPress={props.onDeregister}
className="h-10 w-10 items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70 disabled:opacity-50"
>
{props.deregistering ? (
<ActivityIndicator colorClassName={"accent-danger-foreground"} size="small" />
) : (
<SymbolView
name="trash"
size={14}
tintColorClassName={"accent-danger-foreground"}
type="monochrome"
/>
)}
</Pressable>
) : null}
</View>
</View>
);
}
Expand Down
5 changes: 3 additions & 2 deletions docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions packages/client-runtime/src/state/relayDiscovery.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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();
});
});
6 changes: 5 additions & 1 deletion packages/client-runtime/src/state/relayDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ export function createRelayEnvironmentDiscoveryAtoms<R, E>(
() => 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),
Expand Down
Loading