1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { useAuth } from "@clerk/expo";
3import { useAtomSet, useAtomValue } from "@effect/atom-react";
4import { CameraView, useCameraPermissions } from "expo-camera";
5import * as Haptics from "expo-haptics";
6import {
7 StackActions,
8 useIsFocused,
9 useNavigation,
10 useRoute,
11 type StaticScreenProps,
12} from "@react-navigation/native";
13import type { EnvironmentId } from "@t3tools/contracts";
14import { AsyncResult } from "effect/reactivity";
15import { useCallback, useEffect, useRef, useState } from "react";
16import { Alert, Linking, Pressable, StyleSheet, View } from "react-native";
17import Svg, { Defs, RadialGradient, Rect, Stop } from "react-native-svg";
18import { useSafeAreaInsets } from "react-native-safe-area-context";
19import { cn } from "../../lib/cn";
20import { useUniwindTheme } from "../../lib/useUniwindTheme";
21import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
22import { SettingsScreen } from "../settings/components/SettingsScreen";
23import { AppText as Text, AppTextInput, type AppTextInputProps } from "../../components/AppText";
24import { FrostedCutout } from "../../components/FrostedCutout";
25import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing";
26import {
27 setPendingConnectionError,
28 useRemoteConnections,
29} from "../../state/use-remote-environment-registry";
30import { SymbolView } from "../../components/AppSymbol";
31import { hasCloudPublicConfig } from "../cloud/publicConfig";
32import { CloudEnvironmentRows } from "./CloudEnvironmentRows";
33import { splitEnvironmentSections } from "./environmentSections";
34import { MaterialButton } from "../../components/MaterialButton";
35import { useCloudAuthAvailable } from "../cloud/cloudAvailability";
36import { hubConnection } from "../../connection/hub";
37import { useAtomCommand } from "../../state/use-atom-command";
38import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
39
40type ConnectionsNewRouteParams = {
41 readonly mode?: string;
42 readonly pairingUrl?: string;
43 readonly autoConnect?: string;
44 /** Adds a route to this saved machine instead of a new environment. */
45 readonly routeFor?: EnvironmentId;
46};
47
48export function ConnectionsNewRouteScreen({
49 route,
50}: StaticScreenProps<ConnectionsNewRouteParams | undefined>) {
51 const {
52 connectionPairingUrl,
53 onChangeConnectionPairingUrl,
54 onConnectPress,
55 pairingConnectionError,
56 } = useRemoteConnections();
57 const navigation = useNavigation();
58 const routeName = useRoute().name;
59 const params = route.params ?? {};
60 const t3ConnectAvailable = hasCloudPublicConfig();
61 const cloudAvailable = useCloudAuthAvailable();
62 const connectHub = useAtomCommand(hubConnection.connect, { reportFailure: false });
63 const hub = useAtomValue(hubConnection.stateValueAtom);
64 const preferences = useAtomValue(mobilePreferencesAtom);
65 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
66 // Deep-link prefill exists for development automation only. A production
67 // link must not arrive with attacker-chosen host and token already filled.
68 const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : "";
69 const [step, setStep] = useState<"choose" | "self-hosted" | "direct" | "cloud">(
70 params.routeFor || routePairingUrl ? "direct" : "choose",
71 );
72 const [method, setMethod] = useState<"t3-connect" | "self-hosted">(
73 AsyncResult.isSuccess(preferences) && t3ConnectAvailable
74 ? (preferences.value.connectionService ?? "self-hosted")
75 : "self-hosted",
76 );
77 const shouldAutoConnect =
78 __DEV__ &&
79 routePairingUrl.length > 0 &&
80 (params.autoConnect === "1" || params.autoConnect === "true");
81 const insets = useSafeAreaInsets();
82 const [hostInput, setHostInput] = useState("");
83 const [codeInput, setCodeInput] = useState("");
84 const [isSubmitting, setIsSubmitting] = useState(false);
85 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
86 const screenFocused = useIsFocused();
87 const [scannerLocked, setScannerLocked] = useState(false);
88 // A good scan parks the camera so it can't re-read the code over later edits.
89 const [scanComplete, setScanComplete] = useState(false);
90 const attemptedAutoConnectRef = useRef<string | null>(null);
91
92 const headerIconColor = useUniwindTheme()["--color-icon"];
93
94 const connectDisabled = isSubmitting || hostInput.trim().length === 0;
95
96 useEffect(() => {
97 const { host, code } = parsePairingUrl(connectionPairingUrl);
98 setHostInput(host);
99 setCodeInput(code);
100 }, [connectionPairingUrl]);
101
102 useEffect(() => {
103 if (routePairingUrl.length === 0) {
104 return;
105 }
106
107 const { host, code } = parsePairingUrl(routePairingUrl);
108 setHostInput(host);
109 setCodeInput(code);
110 }, [routePairingUrl]);
111
112 useEffect(() => {
113 if (pairingConnectionError) {
114 setIsSubmitting(false);
115 }
116 }, [pairingConnectionError]);
117
118 const handleHostChange = useCallback((value: string) => {
119 setHostInput(value);
120 setPendingConnectionError(null);
121 }, []);
122
123 const handleCodeChange = useCallback((value: string) => {
124 setCodeInput(value);
125 setPendingConnectionError(null);
126 }, []);
127
128 // The error is shared app state; a stale failure must not greet the next visit.
129 useEffect(() => () => setPendingConnectionError(null), []);
130
131 // Opening the sheet never prompts; a tap on the scan card asks for the camera.
132 const handleScanPress = useCallback(async () => {
133 const permission = await requestCameraPermission();
134 if (permission.granted) {
135 setScanComplete(false);
136 return;
137 }
138 if (permission.canAskAgain) return;
139 Alert.alert(
140 "Camera access needed",
141 "Camera access was denied for this app. Open Settings to enable it.",
142 [
143 { text: "Cancel", style: "cancel" },
144 { text: "Open Settings", onPress: () => void Linking.openSettings() },
145 ],
146 );
147 }, [requestCameraPermission]);
148
149 const handleQrScan = useCallback(
150 ({ data }: { readonly data: string }) => {
151 if (scannerLocked) {
152 return;
153 }
154
155 setScannerLocked(true);
156
157 try {
158 const pairingUrl = extractPairingUrlFromQrPayload(data);
159 const { host, code } = parsePairingUrl(pairingUrl);
160 setHostInput(host);
161 setCodeInput(code);
162 onChangeConnectionPairingUrl(pairingUrl);
163 setScanComplete(true);
164 setScannerLocked(false);
165 } catch (error) {
166 void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
167 Alert.alert(
168 "Invalid QR code",
169 error instanceof Error ? error.message : "Scanned QR code was not recognized.",
170 );
171 setTimeout(() => {
172 setScannerLocked(false);
173 }, 600);
174 }
175 },
176 [onChangeConnectionPairingUrl, scannerLocked],
177 );
178
179 const connectAndClose = useCallback(
180 async (pairingUrl: string, replaceWithHome: boolean) => {
181 setIsSubmitting(true);
182 onChangeConnectionPairingUrl(pairingUrl);
183 try {
184 const result =
185 step === "self-hosted"
186 ? await connectHub({ pairingUrl })
187 : await onConnectPress(pairingUrl, params.routeFor);
188 void Haptics.notificationAsync(
189 result._tag === "Success"
190 ? Haptics.NotificationFeedbackType.Success
191 : Haptics.NotificationFeedbackType.Error,
192 );
193 if (result._tag === "Success") {
194 if (step === "self-hosted") {
195 savePreferences({ connectionService: "self-hosted" });
196 onChangeConnectionPairingUrl("");
197 }
198 if (replaceWithHome || !navigation.canGoBack()) {
199 navigation.dispatch(StackActions.replace("Home"));
200 } else {
201 navigation.goBack();
202 }
203 }
204 } finally {
205 setIsSubmitting(false);
206 }
207 },
208 [
209 connectHub,
210 navigation,
211 onChangeConnectionPairingUrl,
212 onConnectPress,
213 params.routeFor,
214 savePreferences,
215 step,
216 ],
217 );
218
219 const handleSubmit = useCallback(async () => {
220 await connectAndClose(buildPairingUrl(hostInput, codeInput), false);
221 }, [codeInput, connectAndClose, hostInput]);
222
223 useEffect(() => {
224 if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) {
225 return;
226 }
227
228 attemptedAutoConnectRef.current = routePairingUrl;
229 void connectAndClose(routePairingUrl, true);
230 }, [connectAndClose, routePairingUrl, shouldAutoConnect]);
231
232 if (step === "choose") {
233 return (
234 <SettingsScreen formSheet={routeName === "ConnectionsNew"} title="Connect">
235 <ScrollView
236 contentInsetAdjustmentBehavior="automatic"
237 contentContainerClassName="gap-6 px-5 pt-4"
238 >
239 <Text className="text-base text-foreground-muted">
240 Choose how to reach your computers.
241 </Text>
242 <View
243 accessibilityRole="radiogroup"
244 accessibilityLabel="Connection method"
245 className="overflow-hidden rounded-[26px] bg-grouped-card"
246 >
247 {(
248 [
249 ["t3-connect", "T3 Connect", "Sign in with your T3 account."],
250 ["self-hosted", "Self-hosted server", "Connect through your own server."],
251 ] as const
252 ).map(([value, label, description]) => (
253 <Pressable
254 key={value}
255 accessibilityRole="radio"
256 accessibilityLabel={label}
257 accessibilityState={{
258 checked: method === value,
259 disabled: value === "t3-connect" && !t3ConnectAvailable,
260 }}
261 disabled={value === "t3-connect" && !t3ConnectAvailable}
262 onPress={() => setMethod(value)}
263 className="min-h-20 flex-row items-center gap-4 px-4 py-3 active:opacity-70 disabled:opacity-45"
264 >
265 <SymbolView
266 name={method === value ? "circle.inset.filled" : "circle"}
267 size={22}
268 tintColorClassName="accent-primary"
269 />
270 <View className="min-w-0 flex-1 gap-1">
271 <Text className="text-base font-t3-medium text-foreground">{label}</Text>
272 <Text className="text-sm text-foreground-muted">
273 {value === "t3-connect" && !t3ConnectAvailable
274 ? "Unavailable in this app build."
275 : description}
276 </Text>
277 </View>
278 </Pressable>
279 ))}
280 </View>
281 <MaterialButton
282 label="Continue"
283 tone="primary"
284 fullWidth
285 disabled={method === "t3-connect" && !t3ConnectAvailable}
286 onPress={() => {
287 if (method === "t3-connect") {
288 if (cloudAvailable) setStep("cloud");
289 else navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
290 } else setStep("self-hosted");
291 }}
292 />
293 <MaterialButton
294 label="Pair a machine"
295 tone="text"
296 fullWidth
297 onPress={() => setStep("direct")}
298 />
299 </ScrollView>
300 </SettingsScreen>
301 );
302 }
303
304 if (step === "cloud") {
305 return (
306 <SettingsScreen formSheet={routeName === "ConnectionsNew"} title="T3 Connect">
307 <ScrollView
308 contentInsetAdjustmentBehavior="automatic"
309 contentContainerClassName="px-5 pt-4"
310 >
311 <T3ConnectSection />
312 <MaterialButton
313 label="Change method"
314 tone="text"
315 fullWidth
316 onPress={() => setStep("choose")}
317 />
318 </ScrollView>
319 </SettingsScreen>
320 );
321 }
322
323 const pairingError = step === "self-hosted" ? hub.error : pairingConnectionError;
324
325 return (
326 <SettingsScreen
327 formSheet={routeName === "ConnectionsNew"}
328 title={step === "self-hosted" ? "Self-hosted server" : "Pair a machine"}
329 actions={[
330 {
331 accessibilityLabel: isSubmitting ? "Connecting" : "Connect",
332 loading: isSubmitting,
333 icon: "checkmark",
334 tintColor: headerIconColor,
335 disabled: connectDisabled,
336 onPress: () => {
337 void handleSubmit();
338 },
339 },
340 ]}
341 >
342 <ScrollView
343 nestedScrollEnabled
344 contentInsetAdjustmentBehavior="automatic"
345 keyboardShouldPersistTaps="handled"
346 showsVerticalScrollIndicator={false}
347 className="flex-1"
348 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
349 contentContainerStyle={{
350 paddingHorizontal: 16,
351 paddingTop: 4,
352 }}
353 >
354 <View collapsable={false} className="gap-6.5">
355 <PairingScanCard
356 cameraActive={cameraPermission?.granted === true && screenFocused && !scanComplete}
357 onScan={handleQrScan}
358 onScanPress={() => {
359 void handleScanPress();
360 }}
361 />
362
363 <View collapsable={false} className="gap-2">
364 <Text className="px-4 text-sm font-t3-medium text-foreground-muted">Or enter it</Text>
365 <View
366 collapsable={false}
367 className={cn(
368 "overflow-hidden rounded-[26px] border-continuous border bg-grouped-card",
369 pairingError ? "border-danger-foreground" : "border-transparent",
370 )}
371 >
372 <PairingInputRow
373 label={step === "self-hosted" ? "Server address" : "Address"}
374 keyboardType="url"
375 placeholder={
376 step === "self-hosted" ? "https://server.example.com" : "192.168.1.100:3773"
377 }
378 value={hostInput}
379 onChangeText={handleHostChange}
380 />
381 <View className="ml-4 border-t border-border-subtle" />
382 <PairingInputRow
383 label="Pairing code"
384 placeholder="Pairing code"
385 returnKeyType="go"
386 value={codeInput}
387 onChangeText={handleCodeChange}
388 onSubmitEditing={() => {
389 if (!connectDisabled) void handleSubmit();
390 }}
391 />
392 </View>
393 <Text
394 accessibilityLiveRegion="polite"
395 className={cn(
396 "px-4 text-sm leading-normal",
397 pairingError ? "text-danger-foreground" : "text-foreground-muted",
398 )}
399 >
400 {pairingError ??
401 (step === "self-hosted"
402 ? "Create a pairing code on your server. Its machines will appear here."
403 : "For machines on your local network or tailnet. The machine keeps its own provider credentials.")}
404 </Text>
405 </View>
406
407 {!params.routeFor ? (
408 <MaterialButton
409 label="Change method"
410 tone="text"
411 fullWidth
412 onPress={() => setStep("choose")}
413 />
414 ) : null}
415 </View>
416 </ScrollView>
417 </SettingsScreen>
418 );
419}
420
421const SCAN_CARD_HEIGHT = 250;
422const SCAN_RETICLE_SIZE = 150;
423const SCAN_RETICLE_TOP = 36;
424const SCAN_RETICLE_RADIUS = 28;
425
426/**
427 * Live QR scanner: the camera fills the card, blurred everywhere except the
428 * reticle so the code being aimed at stays sharp. Without a camera it shows the
429 * same frame over a dark gradient.
430 */
431function PairingScanCard(props: {
432 readonly cameraActive: boolean;
433 readonly onScan: (result: { readonly data: string }) => void;
434 readonly onScanPress: () => void;
435}) {
436 const light = useAppearancePreferences().themeAppearance === "light";
437 return (
438 <Pressable
439 accessibilityRole={props.cameraActive ? undefined : "button"}
440 accessibilityLabel={props.cameraActive ? undefined : "Scan QR code"}
441 disabled={props.cameraActive}
442 onPress={props.onScanPress}
443 className={cn(
444 "overflow-hidden rounded-[26px] border-continuous",
445 light ? "bg-white" : "bg-black",
446 )}
447 style={{ height: SCAN_CARD_HEIGHT }}
448 >
449 <Svg accessibilityElementsHidden height="100%" style={StyleSheet.absoluteFill} width="100%">
450 <Defs>
451 <RadialGradient id="pairing-scan-backdrop" cx="50%" cy="50%" r="50%">
452 <Stop offset="0%" stopColor={light ? "#f4f4f5" : "#2e2e2e"} />
453 <Stop offset="100%" stopColor={light ? "#d9d9dc" : "#0e0e0e"} />
454 </RadialGradient>
455 </Defs>
456 <Rect fill="url(#pairing-scan-backdrop)" height="100%" width="100%" />
457 </Svg>
458
459 {props.cameraActive ? (
460 <>
461 <CameraView
462 active
463 barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
464 onBarcodeScanned={props.onScan}
465 style={StyleSheet.absoluteFill}
466 />
467 <FrostedCutout
468 cutoutTop={SCAN_RETICLE_TOP}
469 cutoutWidth={SCAN_RETICLE_SIZE}
470 cutoutHeight={SCAN_RETICLE_SIZE}
471 cutoutRadius={SCAN_RETICLE_RADIUS}
472 appearance={light ? "light" : "dark"}
473 />
474 </>
475 ) : null}
476
477 <View
478 pointerEvents="none"
479 className="absolute inset-x-0 items-center gap-3.5"
480 style={{ top: SCAN_RETICLE_TOP }}
481 >
482 <View
483 className={cn(
484 "items-center justify-center gap-2 border-continuous border-[3px]",
485 light ? "border-black/70" : "border-white/80",
486 )}
487 style={{
488 borderRadius: SCAN_RETICLE_RADIUS,
489 height: SCAN_RETICLE_SIZE,
490 width: SCAN_RETICLE_SIZE,
491 }}
492 >
493 {props.cameraActive ? null : (
494 <>
495 <SymbolView
496 name="qrcode.viewfinder"
497 size={44}
498 tintColorClassName={light ? "accent-black/60" : "accent-white/70"}
499 type="monochrome"
500 weight="light"
501 />
502 <Text
503 className={cn("font-t3-medium text-sm", light ? "text-black/70" : "text-white/80")}
504 >
505 Tap to scan
506 </Text>
507 </>
508 )}
509 </View>
510 <Text className={cn("text-center text-sm", light ? "text-black/70" : "text-white/80")}>
511 Scan the code from t3 pair or desktop Connections settings
512 </Text>
513 </View>
514 </Pressable>
515 );
516}
517
518/**
519 * Managed-relay alternative to manual pairing: signed in, the account's
520 * published environments connect with a switch; signed out, one row opens the
521 * T3 Account sheet.
522 */
523function T3ConnectSection() {
524 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
525 const navigation = useNavigation();
526 const { connectedEnvironments, onSetEnvironmentEnabled, onRemoveEnvironmentPress } =
527 useRemoteConnections();
528 const { connectedCloudEnvironments } = splitEnvironmentSections({
529 connectedEnvironments,
530 cloudEnvironments: null,
531 });
532
533 return (
534 <View collapsable={false} className="gap-2">
535 {isSignedIn ? (
536 <CloudEnvironmentRows
537 connectedCloudEnvironments={connectedCloudEnvironments}
538 onSetEnvironmentEnabled={onSetEnvironmentEnabled}
539 onRemoveEnvironment={onRemoveEnvironmentPress}
540 showHeader={false}
541 />
542 ) : (
543 <Pressable
544 accessibilityRole="button"
545 disabled={!isLoaded}
546 onPress={() => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" })}
547 className="min-h-13 flex-row items-center gap-3 rounded-[26px] border-continuous bg-grouped-card px-4 active:opacity-70"
548 >
549 <View className="min-w-0 flex-1 py-3">
550 <Text className="text-base text-foreground">Sign in to T3 Connect</Text>
551 <Text className="text-sm text-foreground-muted">
552 Reach your machines from anywhere, no network setup.
553 </Text>
554 </View>
555 <SymbolView
556 name="chevron.right"
557 size={14}
558 tintColorClassName="accent-chevron"
559 type="monochrome"
560 weight="semibold"
561 />
562 </Pressable>
563 )}
564 </View>
565 );
566}
567
568/** Inline grouped-list field: fixed label column, input filling the rest. */
569function PairingInputRow({
570 label,
571 ...inputProps
572}: Omit<AppTextInputProps, "accessibilityLabel" | "className"> & {
573 readonly label: string;
574}) {
575 return (
576 <View collapsable={false} className="min-h-13 flex-row items-center gap-3 px-4 py-3">
577 <Text
578 accessibilityElementsHidden
579 importantForAccessibility="no-hide-descendants"
580 className="w-28 text-base leading-[23px] text-foreground-muted"
581 >
582 {label}
583 </Text>
584 <AppTextInput
585 {...inputProps}
586 accessibilityLabel={label}
587 autoCapitalize="none"
588 autoCorrect={false}
589 className="min-h-0 flex-1 rounded-none border-0 bg-transparent p-0 text-base leading-[23px]"
590 />
591 </View>
592 );
593}