diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index 9f67cfc4a2..6aea5225d6 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View } from "react-native"; import { useLocalSearchParams, useRouter, type Href } from "expo-router"; @@ -6,11 +6,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { CameraView, useCameraPermissions } from "expo-camera"; import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera"; -import { getHostRuntimeStore, type PasswordRequiredPairing } from "@/runtime/host-runtime"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { openPairScan } from "@/hosts/pair-scan-model"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { isWeb } from "@/constants/platform"; import { BackHeader } from "@/components/headers/back-header"; import { PairLinkModal } from "@/components/pair-link-modal"; +import { Button } from "@/components/ui/button"; const styles = StyleSheet.create((theme) => ({ container: { @@ -73,6 +75,9 @@ const styles = StyleSheet.create((theme) => ({ borderBottomWidth: 4, borderBottomRightRadius: 12, }, + scanAgainButton: { + marginTop: theme.spacing[6], + }, helperText: { marginTop: theme.spacing[6], color: theme.colors.foregroundMuted, @@ -108,16 +113,6 @@ const styles = StyleSheet.create((theme) => ({ }, })); -function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null { - const raw = typeof result.data === "string" ? result.data.trim() : ""; - if (!raw) return null; - - if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://")) - return raw; - - return null; -} - export default function PairScanScreen() { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -129,9 +124,6 @@ export default function PairScanScreen() { const source = typeof params.source === "string" ? params.source : "settings"; const [permission, requestPermission] = useCameraPermissions(); - const [isPairing, setIsPairing] = useState(false); - const [scanError, setScanError] = useState(null); - const [passwordRequired, setPasswordRequired] = useState(null); const navigateToPairedHost = useCallback( (serverId: string) => { @@ -144,6 +136,19 @@ export default function PairScanScreen() { [router, source], ); + const [scan] = useState(() => + openPairScan({ + importConnectionLink: (link) => + getHostRuntimeStore().importConnectionLink( + link, + source === "onboarding" ? "hostRoot" : "hostSettings", + ), + onConnected: navigateToPairedHost, + }), + ); + const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState); + useEffect(() => () => scan.close(), [scan]); + const closeToSource = useCallback(() => { try { router.back(); @@ -159,30 +164,12 @@ export default function PairScanScreen() { }, [permission, requestPermission]); const handleScan = useCallback( - (result: BarcodeScanningResult) => { - if (isPairing) return; - const offerUrl = extractOfferUrlFromScan(result); - if (!offerUrl) return; - - const store = getHostRuntimeStore(); - if (passwordRequired) return; - setIsPairing(true); - setScanError(null); - void store - .importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings") - .then((outcome) => { - if (outcome.status === "connected") navigateToPairedHost(outcome.serverId); - if (outcome.status === "password_required") setPasswordRequired(outcome); - return outcome; - }) - .catch((error) => setScanError(error instanceof Error ? error.message : String(error))) - .finally(() => setIsPairing(false)); - }, - [isPairing, navigateToPairedHost, passwordRequired, source], + (result: BarcodeScanningResult) => + scan.scan(typeof result.data === "string" ? result.data : ""), + [scan], ); const handleRouterBack = useCallback(() => router.back(), [router]); - const closePasswordModal = useCallback(() => setPasswordRequired(null), []); const savePasswordPairing = useCallback( ({ serverId }: { serverId: string }) => navigateToPairedHost(serverId), [navigateToPairedHost], @@ -240,23 +227,38 @@ export default function PairScanScreen() { barcodeScannerSettings={BARCODE_SCANNER_SETTINGS} onBarcodeScanned={handleScan} /> - + - {isPairing ? {t("pairing.scan.pairing")} : null} - {scanError ? {scanError} : null} + {scanState.status === "pairing" ? ( + {t("pairing.scan.pairing")} + ) : null} + {scanState.status === "stopped" && scanState.error ? ( + {scanState.error} + ) : null} + {scanState.status === "stopped" ? ( + + ) : null} )} diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index f00278d4c1..bf364f1040 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -325,18 +325,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0); const advancedTarget = useRef(new PairingTargetTracker("", true)); - const clearInput = useCallback(() => { - setHost(""); - setPort("6767"); - setUseTls(false); - setPassword(""); - setIsPasswordVisible(false); - setIsAdvancedOpen(false); - setAdvancedUri(""); - advancedTarget.current = new PairingTargetTracker("", true); - bumpInputResetKey(); - }, []); - const connectIcon = useMemo( () => , [theme.colors.accentForeground], @@ -370,19 +358,16 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod ); const header = useMemo(() => ({ title: t("pairing.direct.title") }), [t]); + // Each open mounts a fresh form (see AddHostModal), so closing needs no reset. const handleClose = useCallback(() => { if (isSaving) return; - clearInput(); - setErrorMessage(""); onClose(); - }, [isSaving, clearInput, onClose]); + }, [isSaving, onClose]); const handleCancel = useCallback(() => { if (isSaving) return; - clearInput(); - setErrorMessage(""); (onCancel ?? onClose)(); - }, [isSaving, clearInput, onCancel, onClose]); + }, [isSaving, onCancel, onClose]); const handleSaveRelay = useCallback( async (relayUri: string) => { diff --git a/packages/app/src/hosts/pair-scan-model.test.ts b/packages/app/src/hosts/pair-scan-model.test.ts new file mode 100644 index 0000000000..0ca69b9d24 --- /dev/null +++ b/packages/app/src/hosts/pair-scan-model.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ConnectionLinkImport, LinkPairing } from "@/runtime/host-runtime"; +import { openPairScan, type PairScanModel } from "./pair-scan-model"; + +const LINK = "https://app.paseo.sh/#offer=abc"; +const PAIRING: LinkPairing = { submit: async () => ({ status: "cancelled" }) }; + +/** The host runtime's side of a scan: each import resolves with the next queued outcome. */ +function createScanner(outcomes: Array) { + const imports: string[] = []; + const connected: string[] = []; + const scan = openPairScan({ + importConnectionLink: async (link) => { + imports.push(link); + const outcome = outcomes.shift(); + if (!outcome) throw new Error("No outcome queued for this import"); + if (outcome instanceof Error) throw outcome; + return outcome; + }, + onConnected: (serverId) => connected.push(serverId), + }); + return { scan, imports, connected }; +} + +async function settled(scan: PairScanModel): Promise { + await vi.waitFor(() => expect(scan.getState().status).not.toBe("pairing")); +} + +describe("openPairScan", () => { + it("stops scanning after Cancel until the user scans again", async () => { + const { scan, imports } = createScanner([{ status: "cancelled" }, { status: "cancelled" }]); + + scan.scan(LINK); + await settled(scan); + + // The code is still in front of the camera. + scan.scan(LINK); + scan.scan(LINK); + await Promise.resolve(); + expect(imports).toEqual([LINK]); + expect(scan.getState()).toEqual({ status: "stopped", error: null }); + + scan.scanAgain(); + scan.scan(LINK); + await settled(scan); + expect(imports).toEqual([LINK, LINK]); + }); + + it("starts one attempt per code while pairing, and ignores codes that are not pairing links", async () => { + const { scan, imports, connected } = createScanner([ + { status: "connected", serverId: "srv_scan" }, + ]); + + scan.scan("https://example.com/not-a-pairing-link"); + expect(scan.getState()).toEqual({ status: "scanning" }); + + scan.scan(LINK); + scan.scan(LINK); + await settled(scan); + + expect(imports).toEqual([LINK]); + expect(connected).toEqual(["srv_scan"]); + scan.scan(LINK); + expect(imports).toEqual([LINK]); + }); + + it("ends the attempt when the password prompt closes", async () => { + const { scan, imports } = createScanner([ + { status: "password_required", link: LINK, pairing: PAIRING }, + ]); + + scan.scan(LINK); + await settled(scan); + expect(scan.getState()).toEqual({ + status: "passwordRequired", + passwordRequired: { link: LINK, pairing: PAIRING }, + }); + scan.scan(LINK); + + scan.closePassword(); + scan.scan(LINK); + + expect(scan.getState()).toEqual({ status: "stopped", error: null }); + expect(imports).toEqual([LINK]); + }); + + it("stops with the error when an attempt fails", async () => { + const { scan, imports } = createScanner([new Error("Server disconnected")]); + + scan.scan(LINK); + await settled(scan); + scan.scan(LINK); + + expect(scan.getState()).toEqual({ status: "stopped", error: "Server disconnected" }); + expect(imports).toEqual([LINK]); + }); + + it("does not navigate for an attempt that finishes after the screen closed", async () => { + let finish: (outcome: ConnectionLinkImport) => void = () => {}; + const connected: string[] = []; + const scan = openPairScan({ + importConnectionLink: () => + new Promise((resolve) => { + finish = resolve; + }), + onConnected: (serverId) => connected.push(serverId), + }); + + scan.scan(LINK); + scan.close(); + finish({ status: "connected", serverId: "srv_scan" }); + await Promise.resolve(); + + expect(connected).toEqual([]); + }); +}); diff --git a/packages/app/src/hosts/pair-scan-model.ts b/packages/app/src/hosts/pair-scan-model.ts new file mode 100644 index 0000000000..4dca380543 --- /dev/null +++ b/packages/app/src/hosts/pair-scan-model.ts @@ -0,0 +1,103 @@ +import type { ConnectionLinkImport, PasswordRequiredPairing } from "@/runtime/host-runtime"; + +/** + * One visit to the QR scan screen. The camera reports every frame that shows + * a code, so an attempt that ends (Cancel on the host confirmation, a closed + * password prompt, or an error) stops scanning until the user taps scan again. + * Without that, the code still in front of the camera starts a new attempt on + * the next frame. + */ +export type PairScanState = + | { status: "scanning" } + | { status: "pairing" } + | { status: "passwordRequired"; passwordRequired: PasswordRequiredPairing } + | { status: "stopped"; error: string | null } + | { status: "connected" }; + +export interface PairScanModel { + getState: () => PairScanState; + subscribe: (listener: () => void) => () => void; + /** Data from a QR code the camera saw. Only a scanning session starts an attempt. */ + scan: (data: string) => void; + /** The user asked to scan again after an attempt ended. */ + scanAgain: () => void; + /** The user closed the password prompt, which ends the attempt. */ + closePassword: () => void; + close: () => void; +} + +export interface PairScanDependencies { + importConnectionLink: (link: string) => Promise; + onConnected: (serverId: string) => void; +} + +export function openPairScan(deps: PairScanDependencies): PairScanModel { + let state: PairScanState = { status: "scanning" }; + let closed = false; + const listeners = new Set<() => void>(); + + function publish(next: PairScanState): void { + if (closed) return; + state = next; + for (const listener of listeners) { + listener(); + } + } + + async function pair(link: string): Promise { + publish({ status: "pairing" }); + try { + const outcome = await deps.importConnectionLink(link); + if (closed) return; + if (outcome.status === "connected") { + publish({ status: "connected" }); + deps.onConnected(outcome.serverId); + return; + } + if (outcome.status === "password_required") { + const { link: passwordLink, pairing } = outcome; + publish({ status: "passwordRequired", passwordRequired: { link: passwordLink, pairing } }); + return; + } + publish({ status: "stopped", error: null }); + } catch (error) { + publish({ status: "stopped", error: error instanceof Error ? error.message : String(error) }); + } + } + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + scan(data) { + if (state.status !== "scanning") return; + const link = pairingLinkFromScan(data); + if (!link) return; + void pair(link); + }, + scanAgain() { + if (state.status !== "stopped") return; + publish({ status: "scanning" }); + }, + closePassword() { + if (state.status !== "passwordRequired") return; + publish({ status: "stopped", error: null }); + }, + close() { + closed = true; + listeners.clear(); + }, + }; +} + +function pairingLinkFromScan(data: string): string | null { + const raw = data.trim(); + if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://")) { + return raw; + } + return null; +} diff --git a/packages/app/src/runtime/host-confirmation.ts b/packages/app/src/runtime/host-confirmation.ts index ac08673511..8fac1031ff 100644 --- a/packages/app/src/runtime/host-confirmation.ts +++ b/packages/app/src/runtime/host-confirmation.ts @@ -10,6 +10,8 @@ import { * changes the key, relay, or TLS setting of a saved one. */ export interface HostConfirmationRequest { + /** Identifies this question, so an answer shown for it never answers a newer one. */ + id: number; serverId: string; /** Grouped daemon public key the user can compare with the one their daemon shows. */ keyFingerprint: string; @@ -29,6 +31,7 @@ interface PendingConfirmation { */ export class HostConfirmations { private pending: PendingConfirmation | null = null; + private nextRequestId = 1; private listeners = new Set<() => void>(); getPending(): HostConfirmationRequest | null { @@ -42,9 +45,10 @@ export class HostConfirmations { }; } - answer(approved: boolean): void { + /** Answers the request with `requestId`; an answer for any other request is ignored. */ + answer(requestId: number, approved: boolean): void { const current = this.pending; - if (!current) return; + if (!current || current.request.id !== requestId) return; this.pending = null; this.emit(); current.resolve(approved); @@ -56,6 +60,7 @@ export class HostConfirmations { const savedHost = savedHosts.find((host) => host.serverId === offer.serverId); if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true); return this.ask({ + id: this.nextRequestId++, serverId: offer.serverId, keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64), relayEndpoint: connection.relayEndpoint, @@ -80,8 +85,9 @@ export class HostConfirmations { } /** - * Shows the key material itself (not a re-hash) in groups of four, so two - * different keys never show the same value. + * Shows the key material itself (not a re-hash) in groups of four. Keys longer + * than 16 characters show only their first and last 8, so two keys that share + * both ends show the same value. */ function formatDaemonKeyFingerprint(daemonPublicKeyB64: string): string { const normalized = daemonPublicKeyB64.replace(/[^A-Za-z0-9]/g, ""); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 07dd5a9993..a3ea10f48b 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -522,12 +522,19 @@ function answerHostConfirmations( if (!pending) return; requests.push(pending); store.answerHostConfirmation( + pending.id, typeof answer === "function" ? answer(pending, requests.length - 1) : answer, ); }); return requests; } +function requirePendingConfirmation(store: HostRuntimeStore): HostConfirmationRequest { + const pending = store.getPendingHostConfirmation(); + if (!pending) throw new Error("Expected a pending host confirmation"); + return pending; +} + function createPairingStore( input: { hostname?: string; @@ -3696,12 +3703,14 @@ describe("HostRuntimeStore", () => { expect(requests).toEqual([ ...Array.from({ length: 3 }, () => ({ + id: expect.any(Number), serverId: "srv_offer", keyFingerprint: "pkte stof fer", relayEndpoint: "relay.paseo.sh:443", kind: "newHost", })), { + id: expect.any(Number), serverId: "srv_relay", keyFingerprint: "AAAA", relayEndpoint: "relay.example:443", @@ -3832,13 +3841,36 @@ describe("HostRuntimeStore", () => { expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_second" }), ); - store.answerHostConfirmation(true); + store.answerHostConfirmation(requirePendingConfirmation(store).id, true); await expect(second).resolves.toEqual({ status: "connected", serverId: "srv_second" }); expect(store.getPendingHostConfirmation()).toBeNull(); expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_second"]); store.syncHosts([]); }); + it("ignores an answer for a host confirmation that a newer one replaced", async () => { + const store = createPairingStore(); + const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"); + await vi.waitFor(() => + expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }), + ); + const firstRequest = requirePendingConfirmation(store); + const second = store.importConnectionLink( + encodeOfferUrl(makeOffer({ serverId: "srv_second" })), + "openProject", + ); + await expect(first).resolves.toEqual({ status: "cancelled" }); + + // Connect tapped on the first sheet as the second link arrives. + store.answerHostConfirmation(firstRequest.id, true); + + const secondRequest = requirePendingConfirmation(store); + expect(secondRequest.serverId).toBe("srv_second"); + store.answerHostConfirmation(secondRequest.id, false); + await expect(second).resolves.toEqual({ status: "cancelled" }); + expect(store.getHosts()).toEqual([]); + }); + it("waits for saved hosts to load before deciding whether a pairing link asks", async () => { const savedHost = makeHost({ serverId: "srv_offer", @@ -3891,12 +3923,8 @@ describe("HostRuntimeStore", () => { const store = createPairingStore({ hostname: "mbp" }); answerHostConfirmations(store, true); - await store.upsertRelayConnection({ - serverId: "srv_offer", - relayEndpoint: "relay.paseo.sh:443", - daemonPublicKeyB64: "pk_test_offer", - label: "Custom name", - }); + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); + await store.renameHost("srv_offer", "Custom name"); await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 0607609730..1e0fdb7cad 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1924,7 +1924,7 @@ export class HostRuntimeStore { }); } - async upsertRelayConnection(input: { + private async upsertRelayConnection(input: { serverId: string; relayEndpoint: string; useTls?: boolean; @@ -1973,8 +1973,8 @@ export class HostRuntimeStore { return this.hostConfirmations.subscribe(listener); } - answerHostConfirmation(approved: boolean): void { - this.hostConfirmations.answer(approved); + answerHostConfirmation(requestId: number, approved: boolean): void { + this.hostConfirmations.answer(requestId, approved); } /** @@ -2813,8 +2813,15 @@ export function useHostConfirmation(): { () => store.getPendingHostConfirmation(), () => store.getPendingHostConfirmation(), ); + // The answer is bound to the request this render shows, so a tap never + // answers a request that replaced it. return useMemo( - () => ({ pending, answer: (approved: boolean) => store.answerHostConfirmation(approved) }), + () => ({ + pending, + answer: (approved: boolean) => { + if (pending) store.answerHostConfirmation(pending.id, approved); + }, + }), [pending, store], ); } @@ -2839,14 +2846,6 @@ export interface HostMutations { daemonPort?: number; label?: string; }) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; - upsertRelayConnection: (input: { - serverId: string; - relayEndpoint: string; - useTls?: boolean; - daemonPublicKeyB64: string; - label?: string; - password?: string; - }) => Promise; beginLinkPairing: () => LinkPairing; renameHost: (serverId: string, label: string) => Promise; setHostColor: (serverId: string, color: HostColor) => Promise; @@ -2862,7 +2861,6 @@ export function useHostMutations(): HostMutations { upsertDirectConnection: (input) => store.upsertDirectConnection(input), probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input), probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input), - upsertRelayConnection: (input) => store.upsertRelayConnection(input), beginLinkPairing: () => store.beginLinkPairing(), renameHost: (serverId, label) => store.renameHost(serverId, label), setHostColor: (serverId, color) => store.setHostColor(serverId, color),