Stop QR scanning after an attempt ends

The scan screen now keeps its workflow in a small model. When the user
cancels the host confirmation, closes the password prompt, or the attempt
fails, scanning stops until they tap Scan QR code again. Before, the code
still in front of the camera started a new attempt on the next frame.

A host confirmation answer is now tied to the request it answers, so a tap
on one sheet never answers a newer one. Relay hosts can no longer be added
through HostMutations without the confirmation, the add-host modal drops
the reset its per-open mount made redundant, and the fingerprint comment
says what the shortened form shows.
This commit is contained in:
Mohamed Boudra
2026-09-30 13:02:26 +02:00
parent 1f8ce0ee08
commit bddc9a5bd5
7 changed files with 322 additions and 84 deletions
+44 -42
View File
@@ -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<string | null>(null);
const [passwordRequired, setPasswordRequired] = useState<PasswordRequiredPairing | null>(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}
/>
<View style={styles.overlay} pointerEvents="none">
<View style={styles.overlay} pointerEvents="box-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>
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
{scanState.status === "pairing" ? (
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
) : null}
{scanState.status === "stopped" && scanState.error ? (
<Text style={helperTextStyle}>{scanState.error}</Text>
) : null}
{scanState.status === "stopped" ? (
<Button
style={styles.scanAgainButton}
onPress={scan.scanAgain}
testID="pair-scan-again"
>
{t("pairing.connectionMethods.scanQr.title")}
</Button>
) : null}
</View>
</View>
)}
</View>
<PairLinkModal
visible={passwordRequired !== null}
passwordRequired={passwordRequired ?? undefined}
onClose={closePasswordModal}
visible={scanState.status === "passwordRequired"}
passwordRequired={
scanState.status === "passwordRequired" ? scanState.passwordRequired : undefined
}
onClose={scan.closePassword}
onSaved={savePasswordPairing}
/>
</View>
+3 -18
View File
@@ -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(
() => <Link2 size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
@@ -370,19 +358,16 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
);
const header = useMemo<SheetHeader>(() => ({ 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) => {
@@ -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<ConnectionLinkImport | Error>) {
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<void> {
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([]);
});
});
+103
View File
@@ -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<ConnectionLinkImport>;
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<void> {
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;
}
+10 -4
View File
@@ -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, "");
+35 -7
View File
@@ -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()));
+11 -13
View File
@@ -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<HostProfile>;
beginLinkPairing: () => LinkPairing;
renameHost: (serverId: string, label: string) => Promise<void>;
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
@@ -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),