Skip to content
Source
Sign inSign up

devAutoConnect.ts

130 lines5.8 KB

1import type { NavigationState, PartialState } from "@react-navigation/native";
2import { CommonActions, StackActions } from "@react-navigation/native";
3import { Atom } from "effect/reactivity";
4​
5import { findLinkedRoute, updateLinkedRoute } from "../../lib/appLinking";
6import { rootNavigationRef, whenRootNavigationReady } from "../../lib/rootNavigation";
7import { appAtomRegistry } from "../../state/atom-registry";
8import { leaveSettingsPage } from "../settings/settings-navigation";
9import { workspaceHasColumns } from "../layout/workspace/workspace-selection-store";
10import { pairEnvironment } from "../../state/use-remote-environment-registry";
11​
12export type DevAutoConnectAttempt =
13 | { readonly pairingUrl: string; readonly status: "connecting" }
14 | { readonly pairingUrl: string; readonly status: "failed"; readonly error: string | null };
15​
16/** The pairing the dev deep link started, so the form it opens can show its progress. */
17export const devAutoConnectAttemptAtom = Atom.make<DevAutoConnectAttempt | null>(null).pipe(
18 Atom.keepAlive,
19 Atom.withLabel("mobile:dev-auto-connect-attempt"),
20);
21​
22/**
23 * What the form opened by `routePairingUrl` shows of the dev auto-connect. Editing or submitting
24 * (`dismissed`) hides a failure, but a pairing still running keeps Connect busy, so the user
25 * cannot start a second pairing beside it.
26 */
27export function autoConnectReport(
28 attempt: DevAutoConnectAttempt | null,
29 routePairingUrl: string,
30 dismissed: boolean,
31): { readonly connecting: boolean; readonly error: string | null } {
32 if (attempt === null || routePairingUrl.length === 0 || attempt.pairingUrl !== routePairingUrl) {
33 return { connecting: false, error: null };
34 }
35 if (attempt.status === "connecting") return { connecting: true, error: null };
36 return { connecting: false, error: dismissed ? null : attempt.error };
38​
39// The pairing form is mounted both as a root sheet and inside the settings sheet.
40const PAIRING_FORM_ROUTES = ["ConnectionsNew", "SettingsEnvironmentNew"];
41​
42function isAutoConnectFlag(value: unknown): boolean {
43 return value === "1" || value === "true";
45​
46async function connect(pairingUrl: string) {
47 const attempt: DevAutoConnectAttempt = { pairingUrl, status: "connecting" };
48 appAtomRegistry.set(devAutoConnectAttemptAtom, attempt);
49 const outcome = await pairEnvironment(pairingUrl);
50 // A later link replaced or cleared this attempt; its result belongs to no open form.
51 if (appAtomRegistry.get(devAutoConnectAttemptAtom) !== attempt) return;
52 if (!outcome.ok) {
53 appAtomRegistry.set(devAutoConnectAttemptAtom, {
54 pairingUrl,
55 status: "failed",
56 error: outcome.error,
57 });
58 return;
59 }
60 whenRootNavigationReady(() => {
61 // A newer link (with or without the flag) owns the form now.
62 if (appAtomRegistry.get(devAutoConnectAttemptAtom) !== attempt) return;
63 appAtomRegistry.set(devAutoConnectAttemptAtom, null);
64 const current = rootNavigationRef.getCurrentRoute()?.name;
65 if (current === undefined || !PAIRING_FORM_ROUTES.includes(current)) return;
66 // Same exit as the form's own Connect: return to the opener, and on columns show the
67 // new environment on Environments instead of an empty detail.
68 if (!rootNavigationRef.canGoBack()) rootNavigationRef.dispatch(StackActions.replace("Home"));
69 else leaveSettingsPage(rootNavigationRef, workspaceHasColumns(), "SettingsEnvironments");
70 });
72​
73/**
74 * `routeFor` makes a pairing an "Add route" to a saved environment, which skips the guard against
75 * a pairing taking over that environment's id. Only in-app navigation may set it, never a link.
76 */
77export function withoutPairingTarget(
78 state: PartialState<NavigationState> | undefined,
79): PartialState<NavigationState> | undefined {
80 return updateLinkedRoute(state, PAIRING_FORM_ROUTES, (route) => {
81 if (route.params === undefined || !("routeFor" in route.params)) return route;
82 const { routeFor: _routeFor, ...params } = route.params as { readonly routeFor?: unknown };
83 return { ...route, params };
84 });
86​
87/**
88 * `connections/new?pairingUrl=…&autoConnect=1` (or `settings/environment-new?…`) pairs from the
89 * link itself, so a remount of the form never pairs twice. Production builds ignore the flag so
90 * a link can never pair with an attacker-chosen host.
91 */
92export function startDevAutoConnect(
93 state: PartialState<NavigationState> | undefined,
94): PartialState<NavigationState> | undefined {
95 if (!__DEV__) return state;
96 const route = findLinkedRoute(state, PAIRING_FORM_ROUTES);
97 if (route === undefined) return state;
98 const params = (route.params ?? {}) as {
99 readonly autoConnect?: unknown;
100 readonly pairingUrl?: unknown;
101 };
102 const pairingUrl = typeof params.pairingUrl === "string" ? params.pairingUrl.trim() : "";
103 if (isAutoConnectFlag(params.autoConnect) && pairingUrl.length > 0) {
104 void connect(pairingUrl);
105 } else {
106 // Only links put a pairing URL on the form, so a form opened by any other link starts
107 // clean instead of showing an earlier auto-connect's failure for the same URL.
108 appAtomRegistry.set(devAutoConnectAttemptAtom, null);
109 }
110 return state;
112​
113/**
114 * A pairing link that lands on an open pairing form fills that form. React Navigation would push
115 * `ConnectionsNew` over the Settings page's `SettingsEnvironmentNew`, stacking two copies of the
116 * same form. The form keys on the link's pairing URL, so new params remount it with the prefill.
117 */
118export function pairingFormLinkAction(
119 link: PartialState<NavigationState>,
120 current: NavigationState | undefined,
121) {
122 const linked = findLinkedRoute(link, PAIRING_FORM_ROUTES);
123 const open = current?.routes[current.index];
124 if (!linked || !current || !open || !PAIRING_FORM_ROUTES.includes(open.name)) return undefined;
125 return {
126 ...CommonActions.replaceParams(linked.params ?? {}),
127 source: open.key,
128 target: current.key,
129 };