feat(pairing): QR offer + app scanner

This commit is contained in:
Mohamed Boudra
2026-01-22 09:24:08 +07:00
parent ca8d09434b
commit 7157a43a74
15 changed files with 636 additions and 29 deletions
+1
View File
@@ -330,6 +330,7 @@ export default function RootLayout() {
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>
</HorizontalScrollProvider>
+250
View File
@@ -0,0 +1,250 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Platform, Pressable, Text, View } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
header: {
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
headerTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
headerButtonText: {
color: theme.colors.palette.blue[400],
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
body: {
flex: 1,
paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[6],
},
cameraWrap: {
flex: 1,
overflow: "hidden",
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface2,
},
camera: {
flex: 1,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
},
scanFrame: {
width: 260,
height: 260,
},
corner: {
position: "absolute",
width: 36,
height: 36,
borderColor: theme.colors.palette.blue[400],
},
cornerTL: {
left: 0,
top: 0,
borderLeftWidth: 4,
borderTopWidth: 4,
borderTopLeftRadius: 12,
},
cornerTR: {
right: 0,
top: 0,
borderRightWidth: 4,
borderTopWidth: 4,
borderTopRightRadius: 12,
},
cornerBL: {
left: 0,
bottom: 0,
borderLeftWidth: 4,
borderBottomWidth: 4,
borderBottomLeftRadius: 12,
},
cornerBR: {
right: 0,
bottom: 0,
borderRightWidth: 4,
borderBottomWidth: 4,
borderBottomRightRadius: 12,
},
helperText: {
marginTop: theme.spacing[6],
color: theme.colors.foregroundMuted,
textAlign: "center",
fontSize: theme.fontSize.base,
},
permissionCard: {
marginTop: theme.spacing[6],
padding: theme.spacing[6],
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface2,
gap: theme.spacing[4],
},
permissionTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
permissionBody: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
permissionButton: {
alignSelf: "flex-start",
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.palette.blue[500],
},
permissionButtonText: {
color: theme.colors.palette.white,
fontWeight: theme.fontWeight.semibold,
},
}));
function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
const raw = typeof result.data === "string" ? result.data.trim() : "";
if (!raw) return null;
if (raw.includes("#offer=")) return raw;
return null;
}
export default function PairScanScreen() {
const { theme } = useUnistyles();
const router = useRouter();
const { upsertDaemonFromOfferUrl } = useDaemonRegistry();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null);
useEffect(() => {
if (Platform.OS === "web") return;
if (permission && permission.granted) return;
void requestPermission().catch(() => undefined);
}, [permission, requestPermission]);
const handleScan = useCallback(
async (result: BarcodeScanningResult) => {
if (isPairing) return;
const offerUrl = extractOfferUrlFromScan(result);
if (!offerUrl) return;
if (lastScannedRef.current === offerUrl) return;
lastScannedRef.current = offerUrl;
try {
setIsPairing(true);
await upsertDaemonFromOfferUrl(offerUrl);
router.replace("/settings");
} catch (error) {
lastScannedRef.current = null;
const message = error instanceof Error ? error.message : "Unable to pair host";
Alert.alert("Error", message);
} finally {
setIsPairing(false);
}
},
[isPairing, router, upsertDaemonFromOfferUrl]
);
if (Platform.OS === "web") {
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={() => router.back()}>
<Text style={styles.headerButtonText}>Close</Text>
</Pressable>
</View>
<View style={styles.body}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Not available on web</Text>
<Text style={styles.permissionBody}>
QR scanning isn’t supported in the web build. Use “Paste link” instead.
</Text>
<Pressable style={styles.permissionButton} onPress={() => router.replace("/settings")}>
<Text style={styles.permissionButtonText}>Back to Settings</Text>
</Pressable>
</View>
</View>
</View>
);
}
const granted = Boolean(permission?.granted);
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={() => router.back()}>
<Text style={styles.headerButtonText}>Close</Text>
</Pressable>
</View>
<View style={styles.body}>
{!granted ? (
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Camera permission</Text>
<Text style={styles.permissionBody}>
Allow camera access to scan the pairing QR code from your daemon.
</Text>
<Pressable
style={styles.permissionButton}
onPress={() => void requestPermission()}
>
<Text style={styles.permissionButtonText}>Grant permission</Text>
</Pressable>
</View>
) : (
<View style={styles.cameraWrap}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
onBarcodeScanned={handleScan}
/>
<View style={styles.overlay} pointerEvents="none">
<View style={styles.scanFrame}>
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<Text style={styles.helperText}>
Point your camera at the pairing QR code.
</Text>
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing…
</Text>
) : null}
</View>
</View>
)}
</View>
</View>
);
}
+53 -14
View File
@@ -392,6 +392,7 @@ export default function SettingsScreen() {
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
const [daemonFormMode, setDaemonFormMode] = useState<"choose" | "manual" | "pair">("choose");
const [pairSubMode, setPairSubMode] = useState<"choose" | "paste">("choose");
const [daemonForm, setDaemonForm] = useState<{
id: string | null;
label: string;
@@ -463,6 +464,7 @@ export default function SettingsScreen() {
const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => {
if (profile) {
setDaemonFormMode("manual");
setPairSubMode("choose");
setDaemonForm({
id: profile.id,
label: profile.label,
@@ -471,6 +473,7 @@ export default function SettingsScreen() {
});
} else {
setDaemonFormMode("choose");
setPairSubMode("choose");
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
}
setIsDaemonFormVisible(true);
@@ -479,6 +482,7 @@ export default function SettingsScreen() {
const handleCloseDaemonForm = useCallback(() => {
setIsDaemonFormVisible(false);
setDaemonFormMode("choose");
setPairSubMode("choose");
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
}, []);
@@ -521,6 +525,10 @@ export default function SettingsScreen() {
}
if (daemonFormMode === "pair") {
if (pairSubMode !== "paste") {
Alert.alert("Choose a method", "Select Scan QR or Paste link to pair.");
return;
}
const offer = daemonForm.offerUrl.trim();
if (!offer) {
Alert.alert("Offer required", "Paste the pairing link (…/#offer=...)");
@@ -541,7 +549,7 @@ export default function SettingsScreen() {
}
Alert.alert("Choose a method", "Select Pair or Manual to add a host.");
}, [daemonForm, daemonFormMode, addDaemon, updateDaemon, upsertDaemonFromOfferUrl, handleCloseDaemonForm]);
}, [daemonForm, daemonFormMode, pairSubMode, addDaemon, updateDaemon, upsertDaemonFromOfferUrl, handleCloseDaemonForm]);
const handleRemoveDaemon = useCallback(
(profile: DaemonProfile) => {
@@ -740,7 +748,10 @@ export default function SettingsScreen() {
<View style={styles.formActionsRow}>
<Pressable
style={[styles.formButton, styles.formButtonPrimary]}
onPress={() => setDaemonFormMode("pair")}
onPress={() => {
setDaemonFormMode("pair");
setPairSubMode("choose");
}}
>
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Pair</Text>
</Pressable>
@@ -771,17 +782,39 @@ export default function SettingsScreen() {
{daemonFormMode === "pair" ? (
<View style={styles.formField}>
<Text style={styles.label}>Pairing Link</Text>
<TextInput
style={[styles.input, styles.inputUrl]}
value={daemonForm.offerUrl}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, offerUrl: text }))}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<Text style={styles.label}>Pairing</Text>
<View style={styles.formActionsRow}>
<Pressable
style={[styles.formButton, styles.formButtonPrimary]}
onPress={() => {
handleCloseDaemonForm();
router.push("/pair-scan");
}}
>
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Scan QR</Text>
</Pressable>
<Pressable
style={[styles.formButton, styles.formButtonPrimary]}
onPress={() => setPairSubMode("paste")}
>
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Paste link</Text>
</Pressable>
</View>
{pairSubMode === "paste" ? (
<TextInput
style={[styles.input, styles.inputUrl, { marginTop: 12 }]}
value={daemonForm.offerUrl}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, offerUrl: text }))}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
) : null}
</View>
) : null}
@@ -790,7 +823,13 @@ export default function SettingsScreen() {
<Text style={styles.formButtonText}>Cancel</Text>
</Pressable>
{!daemonForm.id && daemonFormMode !== "choose" ? (
<Pressable style={styles.formButton} onPress={() => setDaemonFormMode("choose")}>
<Pressable
style={styles.formButton}
onPress={() => {
setDaemonFormMode("choose");
setPairSubMode("choose");
}}
>
<Text style={styles.formButtonText}>Back</Text>
</Pressable>
) : null}