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<ConnectionsNewRouteParams | undefined>) { 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<string | null>(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 ( <View collapsable={false} className="flex-1 bg-sheet"> <NativeStackScreenOptions options={{ // Android renders its own in-screen header below instead of the native bar. ...(Platform.OS === "android" ? { headerShown: false } : null), title: showScanner ? "Scan QR Code" : "Add Environment", }} /> {Platform.OS === "android" ? ( <AndroidScreenHeader title={showScanner ? "Scan QR Code" : "Add Environment"} onBack={() => navigation.goBack()} actions={[ { accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code", icon: showScanner ? "xmark" : "camera", onPress: () => { if (showScanner) { closeScanner(); } else { void openScanner(); } }, }, ]} /> ) : ( <NativeHeaderToolbar placement="right"> <NativeHeaderToolbar.Button icon={showScanner ? "xmark" : "qrcode.viewfinder"} onPress={() => { if (showScanner) { closeScanner(); } else { void openScanner(); } }} separateBackground tintColor={headerIconColor} /> </NativeHeaderToolbar> )} <ScrollView contentInsetAdjustmentBehavior="automatic" showsVerticalScrollIndicator={false} className="flex-1" contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 16, }} > <View collapsable={false} className="gap-5"> {showScanner ? ( cameraPermission?.granted ? ( <View className="overflow-hidden rounded-[24px] border-continuous"> <CameraView barcodeScannerSettings={{ barcodeTypes: ["qr"] }} onBarcodeScanned={handleQrScan} style={{ aspectRatio: 1, width: "100%" }} /> </View> ) : ( <View className="items-center gap-3 rounded-[24px] border-continuous bg-card px-5 py-8"> <Text className="text-center text-sm leading-normal text-foreground-muted"> Camera permission is required to scan a QR code. </Text> <ConnectionSheetButton compact icon="camera" label="Allow camera" tone="secondary" onPress={() => { void openScanner(); }} /> </View> ) ) : ( <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4"> <View collapsable={false} className="gap-1.5"> <Text className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted"> Host </Text> <TextInput autoCapitalize="none" autoCorrect={false} keyboardType="url" placeholder="192.168.1.100:8080" value={hostInput} onChangeText={handleHostChange} className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground" /> </View> <View collapsable={false} className="gap-1.5"> <Text className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted"> Pairing code </Text> <TextInput autoCapitalize="none" autoCorrect={false} placeholder="abc-123-xyz" value={codeInput} onChangeText={handleCodeChange} className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground" /> </View> {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null} <ConnectionSheetButton icon="plus" label={isSubmitting ? "Pairing..." : "Add environment"} disabled={connectDisabled} tone="primary" onPress={() => { void handleSubmit(); }} /> </View> )} </View> </ScrollView> </View> );}
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<ConnectionsNewRouteParams | undefined>) {
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<string | null>(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 (
<View collapsable={false} className="flex-1 bg-sheet">
<NativeStackScreenOptions
options={{
// Android renders its own in-screen header below instead of the native bar.
...(Platform.OS === "android" ? { headerShown: false } : null),
title: showScanner ? "Scan QR Code" : "Add Environment",
}}
/>
{Platform.OS === "android" ? (
<AndroidScreenHeader
title={showScanner ? "Scan QR Code" : "Add Environment"}
onBack={() => navigation.goBack()}
actions={[
{
accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code",
icon: showScanner ? "xmark" : "camera",
onPress: () => {
if (showScanner) {
closeScanner();
} else {
void openScanner();
}
},
},
]}
/>
) : (
<NativeHeaderToolbar placement="right">
<NativeHeaderToolbar.Button
icon={showScanner ? "xmark" : "qrcode.viewfinder"}
onPress={() => {
if (showScanner) {
closeScanner();
} else {
void openScanner();
}
}}
separateBackground
tintColor={headerIconColor}
/>
</NativeHeaderToolbar>
)}
<ScrollView
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
className="flex-1"
contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 16,
}}
>
<View collapsable={false} className="gap-5">
{showScanner ? (
cameraPermission?.granted ? (
<View className="overflow-hidden rounded-[24px] border-continuous">
<CameraView
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
onBarcodeScanned={handleQrScan}
style={{ aspectRatio: 1, width: "100%" }}
/>
</View>
) : (
<View className="items-center gap-3 rounded-[24px] border-continuous bg-card px-5 py-8">
<Text className="text-center text-sm leading-normal text-foreground-muted">
Camera permission is required to scan a QR code.
</Text>
<ConnectionSheetButton
compact
icon="camera"
label="Allow camera"
tone="secondary"
onPress={() => {
void openScanner();
}}
/>
</View>
)
) : (
<View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4">
<View collapsable={false} className="gap-1.5">
<Text className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">
Host
</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="192.168.1.100:8080"
value={hostInput}
onChangeText={handleHostChange}
className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground"
/>
</View>
<View collapsable={false} className="gap-1.5">
<Text className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">
Pairing code
</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
placeholder="abc-123-xyz"
value={codeInput}
onChangeText={handleCodeChange}
className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground"
/>
</View>
{pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null}
<ConnectionSheetButton
icon="plus"
label={isSubmitting ? "Pairing..." : "Add environment"}
disabled={connectDisabled}
tone="primary"
onPress={() => {
void handleSubmit();
}}
/>
</View>
)}
</View>
</ScrollView>
</View>
);
}