From 81e771c1b0959b8f9afa94302c52cdd767fef8e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:14:09 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(bridge):=20scan()=20=E2=80=94=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- __tests__/lib/shell/bridge-handler.test.ts | 111 +++++++++++++++++++++ __tests__/lib/shell/bridge-scripts.test.ts | 71 ++++++++++++- app/bridge-scan.tsx | 81 +++++++++++++++ app/console.tsx | 2 + components/shell/ConsoleWebView.tsx | 11 +- components/shell/QrScanner.tsx | 33 +++++- lib/shell/bridge/handler.ts | 34 +++++++ lib/shell/bridge/protocol.ts | 22 ++++ lib/shell/bridge/scan-session.ts | 76 ++++++++++++++ lib/shell/bridge/scripts.ts | 7 +- locales/ar.json | 8 +- locales/en.json | 8 +- locales/zh.json | 8 +- 13 files changed, 458 insertions(+), 14 deletions(-) create mode 100644 app/bridge-scan.tsx create mode 100644 lib/shell/bridge/scan-session.ts diff --git a/__tests__/lib/shell/bridge-handler.test.ts b/__tests__/lib/shell/bridge-handler.test.ts index dc91094..2dd4320 100644 --- a/__tests__/lib/shell/bridge-handler.test.ts +++ b/__tests__/lib/shell/bridge-handler.test.ts @@ -1,4 +1,13 @@ import { createBridgeHandler, type BridgeDeps } from "~/lib/shell/bridge/handler"; +import { SCAN_FORMATS } from "~/lib/shell/bridge/protocol"; +import { + SCAN_BUSY_MESSAGE, + SCAN_DENIED_MESSAGE, + currentScan, + requestScan, + settleScan, + type ScanOutcome, +} from "~/lib/shell/bridge/scan-session"; const SERVER = "https://acme.example.com"; const PAGE = `${SERVER}/_console/home`; @@ -11,6 +20,7 @@ function setup(overrides: Partial = {}) { openExternal: jest.fn().mockResolvedValue(undefined), navigate: jest.fn(), resetServer: jest.fn().mockResolvedValue(undefined), + scan: jest.fn().mockResolvedValue("4006381333931"), onEvent: jest.fn(), ...overrides, }; @@ -134,6 +144,107 @@ describe("bridge handler", () => { }); }); + describe("scan", () => { + it("reads every format when the call names none", async () => { + const { handle, deps } = setup(); + for (const args of [undefined, null, {}]) { + await expect(handle(call("scan", args), PAGE)).resolves.toEqual({ + id: "c1", + ok: true, + value: "4006381333931", + }); + } + expect(deps.scan).toHaveBeenCalledTimes(3); + for (const [formats] of (deps.scan as jest.Mock).mock.calls) { + expect(formats).toEqual(SCAN_FORMATS); + } + }); + + it("reads only the formats the call names", async () => { + const { handle, deps } = setup(); + await expect( + handle(call("scan", { formats: ["ean13", "qr", "ean13"] }), PAGE) + ).resolves.toMatchObject({ ok: true }); + expect(deps.scan).toHaveBeenCalledWith(["qr", "ean13"]); + }); + + it.each([ + ["an empty format list", { formats: [] }], + ["an unknown format", { formats: ["qr", "barcode"] }], + ["a format that is not a string", { formats: ["qr", 5] }], + ["formats that are not a list", { formats: "qr" }], + ["an unknown option", { format: ["qr"] }], + ["an extra option", { formats: ["qr"], torch: true }], + ["options that are a list", ["qr"]], + ["options that are a string", "qr"], + ])("refuses %s as bad_args without opening the scanner", async (_label, args) => { + const { handle, deps } = setup(); + await expect(handle(call("scan", args), PAGE)).resolves.toMatchObject({ + ok: false, + error: { code: "bad_args" }, + }); + expect(deps.scan).not.toHaveBeenCalled(); + }); + + it("answers nothing, and opens no scanner, for a page on another origin", async () => { + const { handle, deps } = setup(); + await expect(handle(call("scan"), "https://evil.example.com/")).resolves.toBeNull(); + expect(deps.scan).not.toHaveBeenCalled(); + }); + + describe("through the scan session", () => { + const show = jest.fn(); + const scanThroughSession = () => + setup({ scan: (formats) => requestScan(formats, show) }).handle; + + /** Settle the open scan once the handler has opened it. */ + async function settleOpen(outcome: ScanOutcome) { + await Promise.resolve(); + const open = currentScan(); + if (!open) throw new Error("no scan open"); + settleScan(open.token, outcome); + } + + beforeEach(() => show.mockReset()); + + it("resolves the decoded text", async () => { + const answer = scanThroughSession()(call("scan", { formats: ["code128"] }), PAGE); + await settleOpen({ kind: "result", text: "SKU-0042" }); + await expect(answer).resolves.toEqual({ id: "c1", ok: true, value: "SKU-0042" }); + expect(show).toHaveBeenCalledTimes(1); + }); + + it("resolves null on cancel", async () => { + const answer = scanThroughSession()(call("scan"), PAGE); + await settleOpen({ kind: "cancel" }); + await expect(answer).resolves.toEqual({ id: "c1", ok: true, value: null }); + }); + + it("fails when the camera permission is denied", async () => { + const answer = scanThroughSession()(call("scan"), PAGE); + await settleOpen({ kind: "denied" }); + await expect(answer).resolves.toEqual({ + id: "c1", + ok: false, + error: { code: "failed", message: SCAN_DENIED_MESSAGE }, + }); + }); + + it("refuses a second scan while one is open, and leaves the open one alone", async () => { + const handle = scanThroughSession(); + const first = handle(call("scan", undefined, "c1"), PAGE); + await expect(handle(call("scan", undefined, "c2"), PAGE)).resolves.toEqual({ + id: "c2", + ok: false, + error: { code: "failed", message: SCAN_BUSY_MESSAGE }, + }); + expect(show).toHaveBeenCalledTimes(1); + await settleOpen({ kind: "result", text: "first" }); + await expect(first).resolves.toEqual({ id: "c1", ok: true, value: "first" }); + }); + }); + }); + it("turns a thrown capability into a failed result", async () => { const { handle } = setup({ getPushToken: jest.fn().mockRejectedValue(new Error("APNs down")) }); await expect(handle(call("getPushToken"), PAGE)).resolves.toEqual({ diff --git a/__tests__/lib/shell/bridge-scripts.test.ts b/__tests__/lib/shell/bridge-scripts.test.ts index 97f50a1..9517d60 100644 --- a/__tests__/lib/shell/bridge-scripts.test.ts +++ b/__tests__/lib/shell/bridge-scripts.test.ts @@ -42,6 +42,9 @@ function makePage(origin = "https://acme.example.com") { return { window, posted, cssVars, listeners, run }; } +/** The id of a call the page posted. */ +const idOf = (message: unknown) => (message as { id: string }).id; + describe("toScriptLiteral", () => { it("escapes characters that could break out of a script", () => { expect(toScriptLiteral("\u2028\u2029")).toBe('"\\u003c/script>\\u2028\\u2029"'); @@ -112,18 +115,80 @@ describe("bootstrap script", () => { page.run(buildBootstrapScript(info, SERVER_URL)); const pending = page.window.ObjectStackNative.haptic("success"); expect(page.posted).toEqual([ - { type: "call", id: "c1", method: "haptic", args: { kind: "success" } }, + { type: "call", id: expect.any(String), method: "haptic", args: { kind: "success" } }, ]); - page.run(buildResolveScript({ id: "c1", ok: true, value: null })); + page.run(buildResolveScript({ id: idOf(page.posted[0]), ok: true, value: null })); await expect(pending).resolves.toBeNull(); }); + it("gives every call of a document its own id, under a token of that document", () => { + const page = makePage(); + page.run(buildBootstrapScript(info, SERVER_URL)); + void page.window.ObjectStackNative.getPushToken(); + void page.window.ObjectStackNative.getPushToken(); + const [first, second] = page.posted.map(idOf); + expect(first).toMatch(/^[a-z0-9]+\.1$/); + expect(second).toBe(first.replace(/1$/, "2")); + expect(first.length).toBeLessThanOrEqual(64); + }); + + it("settles no call of a later document with an answer meant for an earlier one", async () => { + // A scan stays open while the page reloads, recovers or goes to a sign-in + // host; each new document numbers its calls from 1 again. + const before = makePage(); + before.run(buildBootstrapScript(info, SERVER_URL)); + void before.window.ObjectStackNative.scan(); + const staleAnswer = buildResolveScript({ id: idOf(before.posted[0]), ok: true, value: "CODE" }); + + for (const origin of ["https://acme.example.com", "https://login.idp.example"]) { + const after = makePage(origin); + after.run(buildBootstrapScript(info, SERVER_URL)); + let settled = false; + const pending = after.window.ObjectStackNative.getPushToken().then(() => (settled = true)); + after.run(staleAnswer); + await Promise.resolve(); + expect(settled).toBe(false); + after.run(buildResolveScript({ id: idOf(after.posted[0]), ok: true, value: null })); + await pending; + expect(settled).toBe(true); + } + }); + + it("posts scan calls with the caller's options, or none", () => { + const page = makePage(); + page.run(buildBootstrapScript({ ...info, capabilities: ["scan"] }, SERVER_URL)); + const api = page.window.ObjectStackNative; + expect(api.has("scan")).toBe(true); + void api.scan(); + void api.scan({ formats: ["ean13", "qr"] }); + expect(page.posted).toEqual([ + { type: "call", id: expect.any(String), method: "scan" }, + { type: "call", id: expect.any(String), method: "scan", args: { formats: ["ean13", "qr"] } }, + ]); + }); + + it("resolves a scan with the decoded text, or null on cancel", async () => { + const page = makePage(); + page.run(buildBootstrapScript(info, SERVER_URL)); + const read = page.window.ObjectStackNative.scan(); + const cancelled = page.window.ObjectStackNative.scan(); + const [readId, cancelId] = page.posted.map(idOf); + page.run(buildResolveScript({ id: readId, ok: true, value: "4006381333931" })); + page.run(buildResolveScript({ id: cancelId, ok: true, value: null })); + await expect(read).resolves.toBe("4006381333931"); + await expect(cancelled).resolves.toBeNull(); + }); + it("rejects failed calls with the error code", async () => { const page = makePage(); page.run(buildBootstrapScript(info, SERVER_URL)); const pending = page.window.ObjectStackNative.navigate("bad"); page.run( - buildResolveScript({ id: "c1", ok: false, error: { code: "bad_args", message: "nope" } }) + buildResolveScript({ + id: idOf(page.posted[0]), + ok: false, + error: { code: "bad_args", message: "nope" }, + }) ); await expect(pending).rejects.toMatchObject({ message: "nope", code: "bad_args" }); }); diff --git a/app/bridge-scan.tsx b/app/bridge-scan.tsx new file mode 100644 index 0000000..6c0babe --- /dev/null +++ b/app/bridge-scan.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; +import type { PermissionStatus } from "expo-camera"; +import { QrScanner } from "~/components/shell/QrScanner"; +import { currentScan, settleScan, type ScanOutcome } from "~/lib/shell/bridge/scan-session"; + +const acceptAny = (code: string) => (code.length > 0 ? code : null); + +/** + * The scanner a Console page opens with the bridge's `scan()`: reads the + * formats of the open scan (`lib/shell/bridge/scan-session.ts`) and settles it + * with the first code read, `null` when the user closes it, or a failure when + * the camera permission is refused. + * + * Every way out settles the scan — the close control, Android back and the + * iOS swipe alike, the last two through the unmount. Opened with no scan for + * it (a link to the route), it closes at once. + */ +export default function BridgeScanScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const scan = React.useRef(currentScan()).current; + const deniedRef = React.useRef(false); + + const leave = React.useCallback(() => { + if (router.canGoBack()) router.back(); + else router.replace("/"); + }, [router]); + + const giveUp = React.useCallback((): ScanOutcome => { + return deniedRef.current ? { kind: "denied" } : { kind: "cancel" }; + }, []); + + React.useEffect(() => { + if (!scan) { + leave(); + return; + } + return () => { + settleScan(scan.token, giveUp()); + }; + }, [scan, leave, giveUp]); + + const onPermissionStatus = React.useCallback((status: PermissionStatus) => { + deniedRef.current = (status as string) === "denied"; + }, []); + + const onResult = React.useCallback( + (text: string) => { + if (scan) settleScan(scan.token, { kind: "result", text }); + leave(); + }, + [scan, leave] + ); + + const onClose = React.useCallback(() => { + if (scan) settleScan(scan.token, giveUp()); + leave(); + }, [scan, leave, giveUp]); + + const barcodeTypes = React.useMemo(() => (scan ? [...scan.formats] : []), [scan]); + + if (!scan) return null; + + return ( + + ); +} diff --git a/app/console.tsx b/app/console.tsx index d585217..36a9bac 100644 --- a/app/console.tsx +++ b/app/console.tsx @@ -10,6 +10,7 @@ import { acquireDeviceToken } from "~/lib/push-notifications"; import { triggerHaptic } from "~/lib/haptics"; import { shellConfig } from "~/lib/shell/config"; import { openExternalUrl } from "~/lib/shell/external"; +import { requestScan } from "~/lib/shell/bridge/scan-session"; import { DEFAULT_ROUTE_RULES } from "~/lib/shell/route-owner"; import { AVAILABLE_NATIVE_SCREENS, NATIVE_SCREENS } from "~/lib/shell/native-screens"; import { useServerStore } from "~/stores/server-store"; @@ -48,6 +49,7 @@ export default function ConsoleScreen() { await reset(); router.replace("/server-config"); }, + scan: (formats) => requestScan(formats, () => router.push("/bridge-scan")), }), [reset, router] ); diff --git a/components/shell/ConsoleWebView.tsx b/components/shell/ConsoleWebView.tsx index 8724d3e..69422d7 100644 --- a/components/shell/ConsoleWebView.tsx +++ b/components/shell/ConsoleWebView.tsx @@ -114,6 +114,11 @@ export const ConsoleWebView = forwardRef(null); + // Counts documents: a new one starts on every load start and every + // re-created WebView. A call's answer goes only to the document that made + // the call. A `scan()` stays open as long as the user takes, and the page + // can be replaced meanwhile (a reload, recovery, a sign-in redirect). + const documentRef = useRef(0); const goTo = useCallback( (route: string) => { @@ -175,6 +180,7 @@ export const ConsoleWebView = forwardRef i + 1); }, []); @@ -262,6 +268,7 @@ export const ConsoleWebView = forwardRef { + documentRef.current += 1; consoleReadyRef.current = false; }, []); @@ -282,8 +289,10 @@ export const ConsoleWebView = forwardRef { + const caller = documentRef.current; void handleMessage(event.nativeEvent.data, event.nativeEvent.url).then((result) => { - if (result) webviewRef.current?.injectJavaScript(buildResolveScript(result)); + if (!result || documentRef.current !== caller) return; + webviewRef.current?.injectJavaScript(buildResolveScript(result)); }); }, [handleMessage] diff --git a/components/shell/QrScanner.tsx b/components/shell/QrScanner.tsx index c277796..97ea001 100644 --- a/components/shell/QrScanner.tsx +++ b/components/shell/QrScanner.tsx @@ -1,6 +1,12 @@ import React from "react"; import { Linking, Pressable, StyleSheet, Text, View } from "react-native"; -import { CameraView, useCameraPermissions, type BarcodeScanningResult } from "expo-camera"; +import { + CameraView, + useCameraPermissions, + type BarcodeScanningResult, + type BarcodeType, + type PermissionStatus, +} from "expo-camera"; import { SafeAreaView } from "react-native-safe-area-context"; import { useTranslation } from "react-i18next"; import { Flashlight, FlashlightOff, X } from "lucide-react-native"; @@ -27,8 +33,19 @@ export interface QrScannerProps { note?: string; /** A way to finish without the camera, e.g. "Enter address manually". */ manualAction?: { label: string; onPress: () => void }; + /** Code formats the camera reads; defaults to QR codes only. */ + barcodeTypes?: BarcodeType[]; + /** + * The permission panel's copy when the default (which speaks of the QR code + * and of entering the address) does not fit the caller. + */ + permissionText?: { body: string; blocked: string }; + /** Told the camera permission's status each time it is read or changes. */ + onPermissionStatus?: (status: PermissionStatus) => void; } +const QR_ONLY: BarcodeType[] = ["qr"]; + const WHITE = "#ffffff"; /** @@ -48,6 +65,9 @@ export function QrScanner({ hint, note, manualAction, + barcodeTypes = QR_ONLY, + permissionText, + onPermissionStatus, }: QrScannerProps) { const { t } = useTranslation(); const [permission, requestPermission] = useCameraPermissions(); @@ -68,6 +88,11 @@ export function QrScanner({ void requestPermission(); }, [permission, requestPermission]); + const status = permission?.status; + React.useEffect(() => { + if (status) onPermissionStatus?.(status); + }, [status, onPermissionStatus]); + React.useEffect( () => () => { if (rejectTimer.current) clearTimeout(rejectTimer.current); @@ -135,7 +160,9 @@ export function QrScanner({ {t("scanner.permission.title")} - {t(blocked ? "scanner.permission.blocked" : "scanner.permission.body")} + {permissionText + ? permissionText[blocked ? "blocked" : "body"] + : t(blocked ? "scanner.permission.blocked" : "scanner.permission.body")} {blocked ? (