Skip to content
Source
Sign inSign up

ConnectionsNewRouteScreen.tsx

411 lines14.0 KB

1import { ListRow } from "../../components/ui/ListRow";
2import * as Dialog from "../../components/ui/Dialog";
3import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
4import { useAuth } from "@clerk/expo";
5import { CameraView, useCameraPermissions } from "expo-camera";
6import * as Haptics from "expo-haptics";
7import {
8 StackActions,
9 useIsFocused,
10 useNavigation,
11 type StaticScreenProps,
12} from "@react-navigation/native";
13import type { EnvironmentId } from "@t3tools/contracts";
14import { useAtomValue } from "@effect/atom-react";
15import { useState } from "react";
16import { 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/ui/AppText";
24import { FrostedCutout } from "../../components/ui/FrostedCutout";
25import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing";
26import { useRemoteConnections } from "../../state/use-remote-environment-registry";
27import { SymbolView } from "../../components/ui/AppSymbol";
28import { hasCloudPublicConfig } from "../cloud/publicConfig";
29import { CloudEnvironmentRows } from "./CloudEnvironmentRows";
30import { autoConnectReport, devAutoConnectAttemptAtom } from "./devAutoConnect";
31import { splitEnvironmentSections } from "./environmentSections";
32import { leaveSettingsPage } from "../settings/settings-navigation";
33import { workspaceHasColumns } from "../layout/workspace/workspace-selection-store";
34​
35type ConnectionsNewRouteParams = {
36 readonly mode?: string;
37 readonly pairingUrl?: string;
38 readonly autoConnect?: string;
39 readonly routeFor?: EnvironmentId;
40};
41​
42export function ConnectionsNewRouteScreen({
43 route,
44}: StaticScreenProps<ConnectionsNewRouteParams | undefined>) {
45 const params = route.params ?? {};
46 // Dev-only prefill prevents production links from supplying an attacker-chosen host and token.
47 const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : "";
48 // A new prefill link replaces whatever the form held.
49 return (
50 <PairingForm
51 key={routePairingUrl}
52 routePairingUrl={routePairingUrl}
53 routeFor={params.routeFor}
54 />
55 );
57​
58function PairingForm({
59 routePairingUrl,
60 routeFor,
61}: {
62 readonly routePairingUrl: string;
63 readonly routeFor: EnvironmentId | undefined;
64}) {
65 const { connectionPairingUrl, onChangeConnectionPairingUrl, onConnectPress } =
66 useRemoteConnections();
67 const navigation = useNavigation();
68 const insets = useSafeAreaInsets();
69 const [initialPairing] = useState(() => parsePairingUrl(routePairingUrl || connectionPairingUrl));
70 const [hostInput, setHostInput] = useState(initialPairing.host);
71 const [codeInput, setCodeInput] = useState(initialPairing.code);
72 const [isSubmitting, setIsSubmitting] = useState(false);
73 const [submitError, setSubmitError] = useState<string | null>(null);
74 // The user's own edit or submit dismisses the dev auto-connect report.
75 const [ownAttempt, setOwnAttempt] = useState(false);
76 const autoConnect = autoConnectReport(
77 useAtomValue(devAutoConnectAttemptAtom),
78 routePairingUrl,
79 ownAttempt,
80 );
81 const connecting = isSubmitting || autoConnect.connecting;
82 const pairingConnectionError = submitError ?? autoConnect.error;
83 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
84 const screenFocused = useIsFocused();
85 const [scannerLocked, setScannerLocked] = useState(false);
86 // A good scan parks the camera so it can't re-read the code over later edits.
87 const [scanComplete, setScanComplete] = useState(false);
88​
89 const headerIconColor = useUniwindTheme()["--color-icon"];
90​
91 const connectDisabled = connecting || hostInput.trim().length === 0;
92​
93 const clearError = () => {
94 setSubmitError(null);
95 setOwnAttempt(true);
96 };
97​
98 const handleHostChange = (value: string) => {
99 setHostInput(value);
100 clearError();
101 };
102​
103 const handleCodeChange = (value: string) => {
104 setCodeInput(value);
105 clearError();
106 };
107​
108 const handleScanPress = async () => {
109 const permission = await requestCameraPermission();
110 if (permission.granted) {
111 setScanComplete(false);
112 return;
113 }
114 if (permission.canAskAgain) return;
115 Dialog.alert(
116 "Camera access needed",
117 "Camera access was denied for this app. Open Settings to enable it.",
118 [
119 { text: "Cancel", style: "cancel" },
120 { text: "Open Settings", onPress: () => void Linking.openSettings() },
121 ],
122 );
123 };
124​
125 const handleQrScan = ({ data }: { readonly data: string }) => {
126 if (scannerLocked) {
127 return;
128 }
129​
130 setScannerLocked(true);
131​
132 try {
133 const pairingUrl = extractPairingUrlFromQrPayload(data);
134 const { host, code } = parsePairingUrl(pairingUrl);
135 setHostInput(host);
136 setCodeInput(code);
137 onChangeConnectionPairingUrl(pairingUrl);
138 setScanComplete(true);
139 setScannerLocked(false);
140 } catch (error) {
141 void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
142 Dialog.alert(
143 "Invalid QR code",
144 error instanceof Error ? error.message : "Scanned QR code was not recognized.",
145 );
146 setTimeout(() => {
147 setScannerLocked(false);
148 }, 600);
149 }
150 };
151​
152 const handleSubmit = async () => {
153 const pairingUrl = buildPairingUrl(hostInput, codeInput);
154 setOwnAttempt(true);
155 setSubmitError(null);
156 setIsSubmitting(true);
157 onChangeConnectionPairingUrl(pairingUrl);
158 const outcome = await onConnectPress(pairingUrl, routeFor);
159 setIsSubmitting(false);
160 setSubmitError(outcome.error);
161 void Haptics.notificationAsync(
162 outcome.ok
163 ? Haptics.NotificationFeedbackType.Success
164 : Haptics.NotificationFeedbackType.Error,
165 );
166 if (outcome.ok) {
167 // Replacing with Home would nest it inside any sheet below; return to the opener.
168 if (!navigation.canGoBack()) {
169 navigation.dispatch(StackActions.replace("Home"));
170 } else {
171 // Added from the Settings list, the new environment shows on Environments.
172 leaveSettingsPage(navigation, workspaceHasColumns(), "SettingsEnvironments");
173 }
174 }
175 };
176​
177 return (
178 <SettingsScreen
179 title="Add environment"
180 actions={[
181 {
182 accessibilityLabel: connecting ? "Connecting" : "Add environment",
183 loading: connecting,
184 icon: "checkmark",
185 tintColor: headerIconColor,
186 disabled: connectDisabled,
187 onPress: () => {
188 void handleSubmit();
189 },
190 },
191 ]}
192 >
193 <ScrollView
194 nestedScrollEnabled
195 contentInsetAdjustmentBehavior="automatic"
196 keyboardShouldPersistTaps="handled"
197 showsVerticalScrollIndicator={false}
198 className="flex-1"
199 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
200 contentContainerStyle={{
201 paddingHorizontal: 16,
202 paddingTop: 4,
203 }}
204 >
205 <View collapsable={false} className="gap-6.5">
206 <PairingScanCard
207 cameraActive={cameraPermission?.granted === true && screenFocused && !scanComplete}
208 onScan={handleQrScan}
209 onScanPress={() => {
210 void handleScanPress();
211 }}
212 />
213​
214 <View collapsable={false} className="gap-2">
215 <Text className="px-4 text-sm font-t3-medium text-foreground-muted">Or enter it</Text>
216 <View
217 collapsable={false}
218 className={cn(
219 "overflow-hidden rounded-[26px] border-continuous border bg-grouped-card",
220 pairingConnectionError ? "border-danger-foreground" : "border-transparent",
221 )}
222 >
223 <PairingInputRow
224 label="Address"
225 keyboardType="url"
226 placeholder="192.168.1.100:3773"
227 value={hostInput}
228 onChangeText={handleHostChange}
229 />
230 <View className="ml-4 border-t border-border-subtle" />
231 <PairingInputRow
232 label="Code"
233 placeholder="Pairing code"
234 returnKeyType="go"
235 value={codeInput}
236 onChangeText={handleCodeChange}
237 onSubmitEditing={() => {
238 if (!connectDisabled) void handleSubmit();
239 }}
240 />
241 </View>
242 <Text
243 accessibilityLiveRegion="polite"
244 className={cn(
245 "px-4 text-sm leading-normal",
246 pairingConnectionError ? "text-danger-foreground" : "text-foreground-muted",
247 )}
248 >
249 {pairingConnectionError ??
250 "For machines on your local network or tailnet. The machine keeps its own provider credentials."}
251 </Text>
252 </View>
253​
254 {hasCloudPublicConfig() ? <T3ConnectSection /> : null}
255 </View>
256 </ScrollView>
257 </SettingsScreen>
258 );
260​
261const SCAN_CARD_HEIGHT = 250;
262const SCAN_RETICLE_SIZE = 150;
263const SCAN_RETICLE_TOP = 36;
264const SCAN_RETICLE_RADIUS = 28;
265​
266function PairingScanCard(props: {
267 readonly cameraActive: boolean;
268 readonly onScan: (result: { readonly data: string }) => void;
269 readonly onScanPress: () => void;
270}) {
271 const light = useAppearancePreferences().themeAppearance === "light";
272 return (
273 <Pressable
274 accessibilityRole={props.cameraActive ? undefined : "button"}
275 accessibilityLabel={props.cameraActive ? undefined : "Scan QR code"}
276 disabled={props.cameraActive}
277 onPress={props.onScanPress}
278 className={cn(
279 "overflow-hidden rounded-[26px] border-continuous",
280 light ? "bg-white" : "bg-black",
281 )}
282 style={{ height: SCAN_CARD_HEIGHT }}
283 >
284 <Svg accessibilityElementsHidden height="100%" style={StyleSheet.absoluteFill} width="100%">
285 <Defs>
286 <RadialGradient id="pairing-scan-backdrop" cx="50%" cy="50%" r="50%">
287 <Stop offset="0%" stopColor={light ? "#f4f4f5" : "#2e2e2e"} />
288 <Stop offset="100%" stopColor={light ? "#d9d9dc" : "#0e0e0e"} />
289 </RadialGradient>
290 </Defs>
291 <Rect fill="url(#pairing-scan-backdrop)" height="100%" width="100%" />
292 </Svg>
293​
294 {props.cameraActive ? (
295 <>
296 <CameraView
297 active
298 barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
299 onBarcodeScanned={props.onScan}
300 style={StyleSheet.absoluteFill}
301 />
302 <FrostedCutout
303 cutoutTop={SCAN_RETICLE_TOP}
304 cutoutWidth={SCAN_RETICLE_SIZE}
305 cutoutHeight={SCAN_RETICLE_SIZE}
306 cutoutRadius={SCAN_RETICLE_RADIUS}
307 appearance={light ? "light" : "dark"}
308 />
309 </>
310 ) : null}
311​
312 <View
313 pointerEvents="none"
314 className="absolute inset-x-0 items-center gap-3.5"
315 style={{ top: SCAN_RETICLE_TOP }}
316 >
317 <View
318 className={cn(
319 "items-center justify-center gap-2 border-continuous border-[3px]",
320 light ? "border-black/70" : "border-white/80",
321 )}
322 style={{
323 borderRadius: SCAN_RETICLE_RADIUS,
324 height: SCAN_RETICLE_SIZE,
325 width: SCAN_RETICLE_SIZE,
326 }}
327 >
328 {props.cameraActive ? null : (
329 <>
330 <SymbolView
331 name="qrcode.viewfinder"
332 size={44}
333 tintColorClassName={light ? "accent-black/60" : "accent-white/70"}
334 type="monochrome"
335 weight="light"
336 />
337 <Text
338 className={cn("font-t3-medium text-sm", light ? "text-black/70" : "text-white/80")}
339 >
340 Tap to scan
341 </Text>
342 </>
343 )}
344 </View>
345 <Text className={cn("text-center text-sm", light ? "text-black/70" : "text-white/80")}>
346 Scan the code from t3 pair or desktop Connections settings
347 </Text>
348 </View>
349 </Pressable>
350 );
352​
353function T3ConnectSection() {
354 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
355 const navigation = useNavigation();
356 const { connectedEnvironments, onSetEnvironmentEnabled, onRemoveEnvironmentPress } =
357 useRemoteConnections();
358 const { connectedCloudEnvironments } = splitEnvironmentSections({
359 connectedEnvironments,
360 cloudEnvironments: null,
361 });
362​
363 return (
364 <View collapsable={false} className="gap-2">
365 <Text className="px-4 text-sm font-t3-medium text-foreground-muted">
366 Or use the managed relay
367 </Text>
368 {isSignedIn ? (
369 <CloudEnvironmentRows
370 connectedCloudEnvironments={connectedCloudEnvironments}
371 onSetEnvironmentEnabled={onSetEnvironmentEnabled}
372 onRemoveEnvironment={onRemoveEnvironmentPress}
373 showHeader={false}
374 />
375 ) : (
376 <ListRow
377 label="Sign in to T3 Connect"
378 description="Reach your machines from anywhere, no network setup."
379 disabled={!isLoaded}
380 onPress={() => navigation.navigate("SettingsAuth")}
381 />
382 )}
383 </View>
384 );
386​
387function PairingInputRow({
388 label,
389 ...inputProps
390}: Omit<AppTextInputProps, "accessibilityLabel" | "className"> & {
391 readonly label: string;
392}) {
393 return (
394 <View collapsable={false} className="h-13 flex-row items-center gap-3 px-4">
395 <Text
396 accessibilityElementsHidden
397 importantForAccessibility="no-hide-descendants"
398 className="w-20 text-base leading-[23px] text-foreground-muted"
399 >
400 {label}
401 </Text>
402 <AppTextInput
403 {...inputProps}
404 accessibilityLabel={label}
405 autoCapitalize="none"
406 autoCorrect={false}
407 className="min-h-0 flex-1 rounded-none border-0 bg-transparent p-0 text-base leading-[23px]"
408 />
409 </View>
410 );