import { CameraView, useCameraPermissions } from "expo-camera"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; type ConnectionsNewRouteParams = { readonly mode?: string; readonly pairingUrl?: string; readonly autoConnect?: string; }; export function ConnectionsNewRouteScreen({ route, }: StaticScreenProps) { const { connectionPairingUrl, onChangeConnectionPairingUrl, onConnectPress, pairingConnectionError, } = useRemoteConnections(); const navigation = useNavigation(); const params = route.params ?? {}; // Deep-link prefill exists for development automation only. A production // link must not arrive with attacker-chosen host and token already filled. const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : ""; const shouldAutoConnect = __DEV__ && routePairingUrl.length > 0 && (params.autoConnect === "1" || params.autoConnect === "true"); const insets = useSafeAreaInsets(); const [hostInput, setHostInput] = useState(""); const [codeInput, setCodeInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [scannerLocked, setScannerLocked] = useState(false); const attemptedAutoConnectRef = useRef(null); const headerIconColor = useThemeColor("--color-icon"); const connectDisabled = isSubmitting || hostInput.trim().length === 0; useEffect(() => { const { host, code } = parsePairingUrl(connectionPairingUrl); setHostInput(host); setCodeInput(code); }, [connectionPairingUrl]); useEffect(() => { if (routePairingUrl.length === 0) { return; } const { host, code } = parsePairingUrl(routePairingUrl); setHostInput(host); setCodeInput(code); }, [routePairingUrl]); useEffect(() => { if (pairingConnectionError) { setIsSubmitting(false); } }, [pairingConnectionError]); const handleHostChange = useCallback((value: string) => { setHostInput(value); }, []); const handleCodeChange = useCallback((value: string) => { setCodeInput(value); }, []); const openScanner = useCallback(async () => { if (cameraPermission?.granted) { setScannerLocked(false); setShowScanner(true); return; } const permission = await requestCameraPermission(); if (permission.granted) { setScannerLocked(false); setShowScanner(true); return; } if (permission.canAskAgain) { Alert.alert( "Camera access needed", "Allow camera access to scan an environment pairing QR code.", ); return; } Alert.alert( "Camera access needed", "Camera access was denied for this app. Open Settings to enable it.", [ { text: "Cancel", style: "cancel" }, { text: "Open Settings", onPress: () => void Linking.openSettings() }, ], ); }, [cameraPermission?.granted, requestCameraPermission]); const closeScanner = useCallback(() => { setShowScanner(false); setScannerLocked(false); }, []); const handleQrScan = useCallback( ({ data }: { readonly data: string }) => { if (scannerLocked) { return; } setScannerLocked(true); try { const pairingUrl = extractPairingUrlFromQrPayload(data); const { host, code } = parsePairingUrl(pairingUrl); setHostInput(host); setCodeInput(code); onChangeConnectionPairingUrl(pairingUrl); setShowScanner(false); } catch (error) { Alert.alert( "Invalid QR code", error instanceof Error ? error.message : "Scanned QR code was not recognized.", ); } finally { setTimeout(() => { setScannerLocked(false); }, 600); } }, [onChangeConnectionPairingUrl, scannerLocked], ); const connectAndClose = useCallback( async (pairingUrl: string, replaceWithHome: boolean) => { setIsSubmitting(true); onChangeConnectionPairingUrl(pairingUrl); try { const result = await onConnectPress(pairingUrl); if (AsyncResult.isSuccess(result)) { if (replaceWithHome || !navigation.canGoBack()) { navigation.dispatch(StackActions.replace("Home")); } else { navigation.goBack(); } } } finally { setIsSubmitting(false); } }, [navigation, onChangeConnectionPairingUrl, onConnectPress], ); const handleSubmit = useCallback(async () => { await connectAndClose(buildPairingUrl(hostInput, codeInput), false); }, [codeInput, connectAndClose, hostInput]); useEffect(() => { if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) { return; } attemptedAutoConnectRef.current = routePairingUrl; void connectAndClose(routePairingUrl, true); }, [connectAndClose, routePairingUrl, shouldAutoConnect]); return ( {Platform.OS === "android" ? ( navigation.goBack()} actions={[ { accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code", icon: showScanner ? "xmark" : "camera", onPress: () => { if (showScanner) { closeScanner(); } else { void openScanner(); } }, }, ]} /> ) : ( { if (showScanner) { closeScanner(); } else { void openScanner(); } }} separateBackground tintColor={headerIconColor} /> )} {showScanner ? ( cameraPermission?.granted ? ( ) : ( Camera permission is required to scan a QR code. { void openScanner(); }} /> ) ) : ( Host Pairing code {pairingConnectionError ? : null} { void handleSubmit(); }} /> )} ); }