From 64efc7c41b7a3b73bf46865233636cdfd603b048 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:51:43 +0000 Subject: [PATCH 1/3] feat(connect): objectos://connect links and the QR scanner screen Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- __tests__/lib/shell/incoming-link.test.ts | 154 ++++++++++++++++ app.config.ts | 29 +++ app/+native-intent.tsx | 4 +- app/connect-scan.tsx | 33 ++++ app/server-config.tsx | 42 ++++- components/shell/QrScanner.tsx | 211 ++++++++++++++++++++++ lib/shell/incoming-link.ts | 126 ++++++++++--- locales/ar.json | 20 ++ locales/en.json | 20 ++ locales/zh.json | 20 ++ package.json | 1 + pnpm-lock.yaml | 63 +++++++ 12 files changed, 694 insertions(+), 29 deletions(-) create mode 100644 app/connect-scan.tsx create mode 100644 components/shell/QrScanner.tsx diff --git a/__tests__/lib/shell/incoming-link.test.ts b/__tests__/lib/shell/incoming-link.test.ts index 0e2a797..7d0157c 100644 --- a/__tests__/lib/shell/incoming-link.test.ts +++ b/__tests__/lib/shell/incoming-link.test.ts @@ -1,7 +1,10 @@ import { + connectRequestFromLink, consoleScreenHref, redirectIncomingPath, routeFromIncomingLink, + serverAddressFromCode, + serverConfigHref, } from "~/lib/shell/incoming-link"; describe("routeFromIncomingLink", () => { @@ -50,6 +53,138 @@ describe("routeFromIncomingLink", () => { }); }); +describe("connectRequestFromLink", () => { + it("normalizes the server param of a connect link", () => { + expect(connectRequestFromLink("objectos://connect?server=https%3A%2F%2Ferp.example.com")).toEqual( + { ok: true, serverUrl: "https://erp.example.com" } + ); + }); + + it("decodes an encoded address and normalizes what it decodes to", () => { + expect( + connectRequestFromLink( + "objectos://connect?server=HTTPS%3A%2F%2FERP.Example.com%3A443%2F_console%2Fapps%2Fcrm" + ) + ).toEqual({ ok: true, serverUrl: "https://erp.example.com" }); + expect(connectRequestFromLink("objectos://connect?server=erp.example.com%3A8443")).toEqual({ + ok: true, + serverUrl: "https://erp.example.com:8443", + }); + // Not encoded at all still parses: the address holds no `&`. + expect(connectRequestFromLink("objectos://connect/?server=https://erp.example.com/")).toEqual({ + ok: true, + serverUrl: "https://erp.example.com", + }); + }); + + it("keeps an unusable address with its reason, for the picker to show", () => { + expect(connectRequestFromLink("objectos://connect?server=http%3A%2F%2Ferp.example.com")).toEqual( + { ok: false, reason: "insecure", value: "http://erp.example.com" } + ); + expect(connectRequestFromLink("objectos://connect?server=ftp%3A%2F%2Ferp.example.com")).toEqual( + { ok: false, reason: "invalid", value: "ftp://erp.example.com" } + ); + expect(connectRequestFromLink("objectos://connect?server=")).toEqual({ + ok: false, + reason: "empty", + value: "", + }); + }); + + it("accepts plain http to a local host, as typing it would", () => { + expect(connectRequestFromLink("objectos://connect?server=http%3A%2F%2F192.168.1.20%3A3000")).toEqual( + { ok: true, serverUrl: "http://192.168.1.20:3000" } + ); + }); + + it("refuses a link without the server param", () => { + expect(connectRequestFromLink("objectos://connect")).toBeNull(); + expect(connectRequestFromLink("objectos://connect?")).toBeNull(); + expect(connectRequestFromLink("objectos://connect?host=erp.example.com")).toBeNull(); + }); + + it("refuses any other param, and a repeated server param", () => { + expect( + connectRequestFromLink("objectos://connect?server=https%3A%2F%2Ferp.example.com&token=abc") + ).toBeNull(); + expect( + connectRequestFromLink("objectos://connect?user=a&server=https%3A%2F%2Ferp.example.com") + ).toBeNull(); + expect( + connectRequestFromLink( + "objectos://connect?server=https%3A%2F%2Fa.example.com&server=https%3A%2F%2Fb.example.com" + ) + ).toBeNull(); + }); + + it("refuses a path after the host and malformed encoding", () => { + expect( + connectRequestFromLink("objectos://connect/extra?server=https%3A%2F%2Ferp.example.com") + ).toBeNull(); + expect(connectRequestFromLink("objectos://connect?server=%E0%A4%A")).toBeNull(); + }); + + it("ignores non-connect hosts", () => { + expect(connectRequestFromLink("objectos://open?server=https%3A%2F%2Ferp.example.com")).toBeNull(); + expect(connectRequestFromLink("objectos://console/apps")).toBeNull(); + }); + + it("does not depend on the scheme of an incoming link (white-label builds)", () => { + expect(connectRequestFromLink("acme-crm://connect?server=erp.example.com")).toEqual({ + ok: true, + serverUrl: "https://erp.example.com", + }); + }); +}); + +describe("serverAddressFromCode", () => { + it("accepts objectos://connect codes", () => { + expect(serverAddressFromCode("objectos://connect?server=https%3A%2F%2Ferp.example.com")).toBe( + "https://erp.example.com" + ); + expect(serverAddressFromCode(" OBJECTOS://connect?server=erp.example.com\n")).toBe( + "https://erp.example.com" + ); + }); + + it("hands an unusable connect address on raw, so the picker shows it with the error", () => { + expect(serverAddressFromCode("objectos://connect?server=http%3A%2F%2Ferp.example.com")).toBe( + "http://erp.example.com" + ); + }); + + it("accepts a plain https server URL as a convenience", () => { + expect(serverAddressFromCode("https://erp.example.com/_console/apps/crm")).toBe( + "https://erp.example.com" + ); + }); + + it("refuses everything else", () => { + for (const code of [ + "http://erp.example.com", + "erp.example.com", + "objectos://open?route=%2Fapps", + "objectos://connect?server=https%3A%2F%2Ferp.example.com&token=abc", + "objectos://connect", + "acme-crm://connect?server=https%3A%2F%2Ferp.example.com", + "https://", + "WIFI:S:office;T:WPA;P:secret;;", + "", + ]) { + expect(serverAddressFromCode(code)).toBeNull(); + } + }); +}); + +describe("serverConfigHref", () => { + it("encodes the address and optional nonce", () => { + expect(serverConfigHref("https://erp.example.com")).toBe( + "/server-config?server=https%3A%2F%2Ferp.example.com" + ); + expect(serverConfigHref("a b&c", 3)).toBe("/server-config?server=a%20b%26c&n=3"); + }); +}); + describe("consoleScreenHref", () => { it("encodes the route and optional nonce", () => { expect(consoleScreenHref("/apps/crm?x=1")).toBe("/console?route=%2Fapps%2Fcrm%3Fx%3D1"); @@ -78,6 +213,25 @@ describe("redirectIncomingPath", () => { expect(redirectIncomingPath("objectos://open?route=%2F..%2F..%2Fapi%2Fv1%2Fme")).toBe("/"); }); + it("opens the server picker for a connect link, never the Console", () => { + expect( + redirectIncomingPath("objectos://connect?server=https%3A%2F%2Ferp.example.com", () => 9) + ).toBe("/server-config?server=https%3A%2F%2Ferp.example.com&n=9"); + }); + + it("opens the picker with the raw value when the connect address is unusable", () => { + expect( + redirectIncomingPath("objectos://connect?server=http%3A%2F%2Ferp.example.com", () => 9) + ).toBe("/server-config?server=http%3A%2F%2Ferp.example.com&n=9"); + }); + + it("sends a connect link with other params to the start screen", () => { + expect( + redirectIncomingPath("objectos://connect?server=https%3A%2F%2Ferp.example.com&token=abc") + ).toBe("/"); + expect(redirectIncomingPath("objectos://connect")).toBe("/"); + }); + it("sends unknown scheme links to the start screen", () => { expect(redirectIncomingPath("objectos://unknown/thing")).toBe("/"); }); diff --git a/app.config.ts b/app.config.ts index 6165dc7..df99846 100644 --- a/app.config.ts +++ b/app.config.ts @@ -1,4 +1,17 @@ import { ExpoConfig, ConfigContext } from "expo/config"; +import ar from "./locales/ar.json"; +import en from "./locales/en.json"; +import zh from "./locales/zh.json"; + +// System permission prompts, localized from the app's own bundles (key +// `permissions.camera`) so one file holds each language's copy. iOS reads them +// from `/InfoPlist.strings`; Android's camera prompt is the system's own +// text. Keys are iOS localization names (`zh` is Simplified Chinese). +const PERMISSION_LOCALES: Record = { + en, + "zh-Hans": zh, + ar, +}; export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, @@ -11,6 +24,12 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ userInterfaceStyle: "automatic", // SDK 55 dropped legacy architecture — New Architecture is always on, so the // `newArchEnabled` flag was removed from ExpoConfig. + locales: Object.fromEntries( + Object.entries(PERMISSION_LOCALES).map(([lproj, bundle]) => [ + lproj, + { ios: { NSCameraUsageDescription: bundle.permissions.camera } }, + ]) + ), splash: { image: "./assets/splash-icon.png", resizeMode: "contain", @@ -35,6 +54,16 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ plugins: [ "expo-router", "expo-web-browser", + [ + // The server QR scanner (screen 6). Camera only: no recording, so no + // microphone prompt and no RECORD_AUDIO permission. + "expo-camera", + { + cameraPermission: en.permissions.camera, + microphonePermission: false, + recordAudioAndroid: false, + }, + ], [ "@sentry/react-native/expo", { diff --git a/app/+native-intent.tsx b/app/+native-intent.tsx index 0fb6dbe..73070c9 100644 --- a/app/+native-intent.tsx +++ b/app/+native-intent.tsx @@ -3,7 +3,9 @@ import { redirectIncomingPath } from "~/lib/shell/incoming-link"; /** * expo-router hook for links that open the app (scheme links, notification * deep links). Maps `objectos://open?route=…` / `objectos://console/…` - * onto the Console screen; see `lib/shell/incoming-link.ts`. + * onto the Console screen, and `objectos://connect?server=…` onto the server + * picker, which probes the address and asks the user to confirm it; see + * `lib/shell/incoming-link.ts`. */ export function redirectSystemPath({ path }: { path: string; initial: boolean }): string { try { diff --git a/app/connect-scan.tsx b/app/connect-scan.tsx new file mode 100644 index 0000000..e8d8daf --- /dev/null +++ b/app/connect-scan.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { QrScanner } from "~/components/shell/QrScanner"; +import { serverAddressFromCode, serverConfigHref } from "~/lib/shell/incoming-link"; + +/** + * Screen 6 — scan the connect QR code from the Console's mobile-app page. + * + * Accepts `objectos://connect?server=…` codes and plain `https://` server URLs + * (`serverAddressFromCode`), and hands the address back to the server picker, + * which probes it and asks the user to confirm. Nothing is saved here. + */ +export default function ConnectScanScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const back = React.useCallback(() => router.back(), [router]); + const handleResult = React.useCallback( + (address: string) => router.dismissTo(serverConfigHref(address, Date.now())), + [router] + ); + + return ( + + ); +} diff --git a/app/server-config.tsx b/app/server-config.tsx index df6fd09..aa87a80 100644 --- a/app/server-config.tsx +++ b/app/server-config.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { KeyboardAvoidingView, Platform, ScrollView, Text, View } from "react-native"; -import { useRouter } from "expo-router"; +import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, View } from "react-native"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { SafeAreaView } from "react-native-safe-area-context"; import { useTranslation } from "react-i18next"; -import { ServerCog } from "lucide-react-native"; +import { ScanQrCode, ServerCog } from "lucide-react-native"; import { ServerConfirm } from "~/components/shell/ServerConfirm"; import { Button } from "~/components/ui/Button"; import { Input } from "~/components/ui/Input"; @@ -44,9 +44,11 @@ const HELP_STEPS = [ * 2. Confirm (screen 7): shows who answered. The server is saved only when the * user confirms. * - * Scan entry point: the QR scanner is not built yet, so screen 5 renders no - * scan button. The scanner lands its button beside the address field and - * hands the scanned address to `runProbe`. + * Scan entry point: the button in the address field opens the scanner + * (`app/connect-scan.tsx`, screen 6). The scanner, and an incoming + * `objectos://connect?server=…` link, come back here as `?server=…&n=…`: the + * address is filled in and probed, so the user always sees the confirm step, + * or the error beside the address that failed. */ export default function ServerConfigScreen() { const router = useRouter(); @@ -54,7 +56,11 @@ export default function ServerConfigScreen() { const colors = useThemeColors(); const current = useServerStore((s) => s.serverUrl); const connect = useServerStore((s) => s.connect); - const [url, setUrl] = React.useState(current ?? shellConfig.defaultServerUrl); + const params = useLocalSearchParams<{ server?: string; n?: string }>(); + const linkedServer = typeof params.server === "string" ? params.server : null; + const [url, setUrl] = React.useState( + linkedServer ?? current ?? shellConfig.defaultServerUrl + ); const [probing, setProbing] = React.useState(false); const [errorMsg, setErrorMsg] = React.useState(null); const [probed, setProbed] = React.useState(null); @@ -86,6 +92,17 @@ export default function ServerConfigScreen() { } }; + // A scanned code or a connect link: fill in the address and probe it. `n` + // changes on every request, so the same address scanned twice probes twice. + const runProbeRef = React.useRef(runProbe); + runProbeRef.current = runProbe; + React.useEffect(() => { + if (linkedServer === null) return; + setUrl(linkedServer); + setProbed(null); + void runProbeRef.current(linkedServer); + }, [linkedServer, params.n]); + const handleConfirm = async () => { if (!probed) return; setConnecting(true); @@ -154,6 +171,17 @@ export default function ServerConfigScreen() { autoCorrect={false} returnKeyType="go" error={!!errorMsg} + rightSlot={ + router.push("/connect-scan")} + > + + + } value={url} onChangeText={(text) => { setUrl(text); diff --git a/components/shell/QrScanner.tsx b/components/shell/QrScanner.tsx new file mode 100644 index 0000000..c277796 --- /dev/null +++ b/components/shell/QrScanner.tsx @@ -0,0 +1,211 @@ +import React from "react"; +import { Linking, Pressable, StyleSheet, Text, View } from "react-native"; +import { CameraView, useCameraPermissions, type BarcodeScanningResult } from "expo-camera"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useTranslation } from "react-i18next"; +import { Flashlight, FlashlightOff, X } from "lucide-react-native"; +import { Button } from "~/components/ui/Button"; + +/** How long "not a … code" stays up after a refused code leaves the frame. */ +export const REJECT_MESSAGE_MS = 3000; + +export interface QrScannerProps { + /** + * Turn a scanned code into a result, or `null` when it is not a code this + * scan accepts. Called for every code the camera reads until one is accepted. + */ + accept: (code: string) => T | null; + /** Called once, with the first accepted result; the scanner then stops reading. */ + onResult: (result: T) => void; + /** The close control (and the way out from the permission panel). */ + onClose: () => void; + /** Shown after a code `accept` refused, e.g. "That isn't a connect QR code". */ + rejectMessage: string; + /** The line under the frame; defaults to "Place the QR code inside the frame". */ + hint?: string; + /** A second line under the hint, e.g. what the code contains. */ + note?: string; + /** A way to finish without the camera, e.g. "Enter address manually". */ + manualAction?: { label: string; onPress: () => void }; +} + +const WHITE = "#ffffff"; + +/** + * Full-screen native QR scanner: the camera behind a frame, with close and + * torch controls, a hint, an optional note and an optional manual way out. + * + * It knows nothing about what the code means — `accept` decides — so the + * server picker's connect scan and the bridge's `scan()` for Console fields + * use the same screen. It asks for camera permission when it opens; when the + * permission is refused for good it offers the system settings instead. + */ +export function QrScanner({ + accept, + onResult, + onClose, + rejectMessage, + hint, + note, + manualAction, +}: QrScannerProps) { + const { t } = useTranslation(); + const [permission, requestPermission] = useCameraPermissions(); + const [torch, setTorch] = React.useState(false); + const [rejected, setRejected] = React.useState(false); + const [done, setDone] = React.useState(false); + // Refs, not state: the camera reports the same code many times a second, and + // a second report can arrive before React re-renders with `done` set. + const doneRef = React.useRef(false); + const lastRejectedRef = React.useRef(null); + const rejectTimer = React.useRef | null>(null); + const askedRef = React.useRef(false); + + React.useEffect(() => { + if (!permission || permission.granted || !permission.canAskAgain) return; + if (askedRef.current) return; + askedRef.current = true; + void requestPermission(); + }, [permission, requestPermission]); + + React.useEffect( + () => () => { + if (rejectTimer.current) clearTimeout(rejectTimer.current); + }, + [] + ); + + const handleScanned = React.useCallback( + ({ data }: BarcodeScanningResult) => { + if (doneRef.current) return; + const result = accept(data); + if (result !== null) { + doneRef.current = true; + setDone(true); + setRejected(false); + onResult(result); + return; + } + // Keep the message up while the refused code stays in view; let it go a + // moment after the camera stops reading it. + if (rejectTimer.current) clearTimeout(rejectTimer.current); + rejectTimer.current = setTimeout(() => { + lastRejectedRef.current = null; + setRejected(false); + }, REJECT_MESSAGE_MS); + if (lastRejectedRef.current === data) return; + lastRejectedRef.current = data; + setRejected(true); + }, + [accept, onResult] + ); + + const closeButton = ( + + + + ); + + const manualButton = manualAction ? ( + + ) : null; + + if (!permission || !permission.granted) { + const blocked = !!permission && !permission.canAskAgain; + return ( + + {closeButton} + {permission ? ( + + + {t("scanner.permission.title")} + + + {t(blocked ? "scanner.permission.blocked" : "scanner.permission.body")} + + {blocked ? ( + + ) : ( + + )} + {manualButton} + + ) : null} + + ); + } + + return ( + + + + + {closeButton} + setTorch((on) => !on)} + className="h-11 w-11 items-center justify-center rounded-full bg-black/50" + > + {torch ? ( + + ) : ( + + )} + + + + + + + {hint ?? t("scanner.hint")} + + {note ? ( + + {note} + + ) : null} + {rejected ? ( + + {rejectMessage} + + ) : null} + + + {manualButton} + + + ); +} diff --git a/lib/shell/incoming-link.ts b/lib/shell/incoming-link.ts index b8a706f..dd704c1 100644 --- a/lib/shell/incoming-link.ts +++ b/lib/shell/incoming-link.ts @@ -1,31 +1,46 @@ /** - * Incoming app links → Console routes. + * Incoming app links → shell screens. * - * The shell answers two link shapes on its URL scheme (default `objectos`): + * The shell answers three link shapes on its URL scheme (default `objectos`): * * objectos://open?route=%2Fapps%2Fcrm%2Fopportunity%2F123 * objectos://console/apps/crm/opportunity/123 + * objectos://connect?server=https%3A%2F%2Ferp.example.com * - * Both open the Console at that route. Anything else lands on the shell's start - * screen rather than on an expo-router "not found" page. + * The first two open the Console at that route. `connect` — the QR code on the + * Console's mobile-app page, or the same link in an invite email — opens the + * server picker with that address and probes it; the server is saved only when + * the user confirms. Anything else lands on the shell's start screen rather + * than on an expo-router "not found" page. */ -import { safeConsoleRoute } from "./console-url"; +import { normalizeServerUrl, safeConsoleRoute } from "./console-url"; const SCHEME_LINK_RE = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)([^?#]*)(\?[^#]*)?/i; -function queryParam(query: string | undefined, name: string): string | null { - if (!query) return null; - for (const pair of query.replace(/^\?/, "").split("&")) { - const eq = pair.indexOf("="); - const key = eq === -1 ? pair : pair.slice(0, eq); - if (key !== name) continue; - try { - return decodeURIComponent((eq === -1 ? "" : pair.slice(eq + 1)).replace(/\+/g, " ")); - } catch { - return null; - } +function decodeParam(value: string): string | null { + try { + return decodeURIComponent(value.replace(/\+/g, " ")); + } catch { + return null; } - return null; +} + +/** Every `key=value` pair of a query, raw (not decoded); empty pairs skipped. */ +function queryPairs(query: string | undefined): [string, string][] { + if (!query) return []; + return query + .replace(/^\?/, "") + .split("&") + .filter((pair) => pair.length > 0) + .map((pair) => { + const eq = pair.indexOf("="); + return eq === -1 ? [pair, ""] : [pair.slice(0, eq), pair.slice(eq + 1)]; + }); +} + +function queryParam(query: string | undefined, name: string): string | null { + const pair = queryPairs(query).find(([key]) => key === name); + return pair ? decodeParam(pair[1]) : null; } /** The Console route an incoming link asks for, or `null`. */ @@ -38,6 +53,71 @@ export function routeFromIncomingLink(link: string): string | null { return null; } +/** The one parameter a `connect` link carries. */ +export const CONNECT_SERVER_PARAM = "server"; + +/** The scheme a connect QR code must use (the Console page writes it). */ +export const CONNECT_CODE_SCHEME = "objectos"; + +/** What a well-formed `connect` link asks for. */ +export type ConnectRequest = + /** The address normalized: probe it and show the confirm step. */ + | { ok: true; serverUrl: string } + /** + * The address is not usable (`normalizeServerUrl`'s reason). The picker + * shows `value` in the address field with that error. + */ + | { ok: false; reason: "empty" | "invalid" | "insecure"; value: string }; + +/** + * Parse a `connect` link. Returns `null` when the link is not one: another + * host, a path after the host, a malformed query, or any parameter besides a + * single `server` (a missing or repeated `server` included). The QR code and + * invite link carry the server address and nothing else — never a credential — + * so a link carrying more is refused rather than half-honoured. + */ +export function connectRequestFromLink(link: string): ConnectRequest | null { + const m = SCHEME_LINK_RE.exec(link.trim()); + if (!m || m[1].toLowerCase() !== "connect") return null; + if (m[2] !== "" && m[2] !== "/") return null; + const pairs = queryPairs(m[3]); + if (pairs.length !== 1 || pairs[0][0] !== CONNECT_SERVER_PARAM) return null; + const value = decodeParam(pairs[0][1]); + if (value === null) return null; + const normalized = normalizeServerUrl(value); + return normalized.ok + ? { ok: true, serverUrl: normalized.serverUrl } + : { ok: false, reason: normalized.reason, value }; +} + +/** + * The server address a scanned QR code carries, or `null` when the code is + * not a connect code. Accepted: `objectos://connect?server=…` (see + * {@link connectRequestFromLink}), and a plain `https://` server URL as a + * convenience. A connect code whose address is unusable still counts — its raw + * value goes to the picker, which shows it with the error. + */ +export function serverAddressFromCode(code: string): string | null { + const text = code.trim(); + if (/^https:\/\//i.test(text)) { + const normalized = normalizeServerUrl(text); + return normalized.ok ? normalized.serverUrl : null; + } + if (!text.toLowerCase().startsWith(`${CONNECT_CODE_SCHEME}://`)) return null; + const request = connectRequestFromLink(text); + if (!request) return null; + return request.ok ? request.serverUrl : request.value; +} + +/** + * The expo-router path that opens the server picker with `address` filled in + * and probed. `nonce` makes a repeated request distinguishable. + */ +export function serverConfigHref(address: string, nonce?: string | number): string { + const n = nonce === undefined ? "" : `&n=${encodeURIComponent(String(nonce))}`; + return `/server-config?${CONNECT_SERVER_PARAM}=${encodeURIComponent(address)}${n}`; +} + /** * The expo-router path that shows `route` in the Console screen. `nonce` * makes a repeated request for the same route distinguishable (tapping two @@ -49,9 +129,10 @@ export function consoleScreenHref(route: string, nonce?: string | number): strin } /** - * expo-router `redirectSystemPath` policy: our app links open the Console, - * other links on our scheme land on the start screen, and anything that is not - * a scheme URL (internal paths, the dev-client launcher) passes through. + * expo-router `redirectSystemPath` policy: route links open the Console, + * `connect` links open the server picker (never a silent connect), other links + * on our scheme land on the start screen, and anything that is not a scheme + * URL (internal paths, the dev-client launcher) passes through. * * Each redirect carries a nonce (`now()`, injectable for tests) so opening the * same link twice navigates both times. @@ -60,5 +141,8 @@ export function redirectIncomingPath(path: string, now: () => number = Date.now) if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) return path; if (path.includes("expo-development-client")) return path; const route = routeFromIncomingLink(path); - return route ? consoleScreenHref(route, now()) : "/"; + if (route) return consoleScreenHref(route, now()); + const connect = connectRequestFromLink(path); + if (connect) return serverConfigHref(connect.ok ? connect.serverUrl : connect.value, now()); + return "/"; } diff --git a/locales/ar.json b/locales/ar.json index 7307f47..3fc099e 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -16,6 +16,7 @@ "label": "عنوان الخادم", "hint": "مثال: https://erp.example.com", "connect": "متابعة", + "scan": "مسح رمز QR", "where": { "title": "أين أجد العنوان؟", "step1": "افتح ObjectOS في متصفح على الكمبيوتر وسجّل الدخول.", @@ -55,5 +56,24 @@ "serverErrorMessage": "أجاب الخادم بالخطأ {{code}}. حاول مرة أخرى بعد قليل.", "retry": "حاول مرة أخرى", "changeServer": "استخدام خادم آخر" + }, + "scanner": { + "hint": "ضع رمز QR داخل الإطار", + "connectNote": "يحتوي على عنوان الخادم فقط، ولا يتضمن أي معلومات حساب", + "manual": "إدخال العنوان يدويًا", + "notConnectCode": "هذا ليس رمز QR للاتصال", + "close": "إغلاق", + "torchOn": "تشغيل المصباح", + "torchOff": "إيقاف المصباح", + "permission": { + "title": "يلزم الوصول إلى الكاميرا", + "body": "اسمح بالوصول إلى الكاميرا لمسح رمز QR.", + "blocked": "الوصول إلى الكاميرا متوقف لتطبيق ObjectOS. فعّله من الإعدادات، أو أدخل العنوان يدويًا.", + "allow": "السماح بالكاميرا", + "settings": "فتح الإعدادات" + } + }, + "permissions": { + "camera": "يستخدم ObjectOS الكاميرا لمسح رمز QR الذي يربطه بالخادم الخاص بك." } } diff --git a/locales/en.json b/locales/en.json index ab4a298..f99105f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -16,6 +16,7 @@ "label": "Server URL", "hint": "Example: https://erp.example.com", "connect": "Continue", + "scan": "Scan QR code", "where": { "title": "Where do I find the address?", "step1": "On a computer, open ObjectOS in your browser and sign in.", @@ -55,5 +56,24 @@ "serverErrorMessage": "The server answered with error {{code}}. Try again in a moment.", "retry": "Try again", "changeServer": "Use a different server" + }, + "scanner": { + "hint": "Place the QR code inside the frame", + "connectNote": "Contains only the server address, no account information", + "manual": "Enter address manually", + "notConnectCode": "This isn't a connect QR code", + "close": "Close", + "torchOn": "Turn on flashlight", + "torchOff": "Turn off flashlight", + "permission": { + "title": "Camera access needed", + "body": "Allow camera access to scan the QR code.", + "blocked": "Camera access is off for ObjectOS. Turn it on in Settings, or enter the address manually.", + "allow": "Allow camera", + "settings": "Open Settings" + } + }, + "permissions": { + "camera": "ObjectOS uses the camera to scan the QR code that connects it to your server." } } diff --git a/locales/zh.json b/locales/zh.json index 617d057..2426c34 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -16,6 +16,7 @@ "label": "服务器地址", "hint": "例如:https://erp.example.com", "connect": "继续", + "scan": "扫描二维码", "where": { "title": "在哪里找到地址?", "step1": "在电脑浏览器中打开 ObjectOS 并登录。", @@ -55,5 +56,24 @@ "serverErrorMessage": "服务器返回错误 {{code}},请稍后重试。", "retry": "重试", "changeServer": "更换服务器" + }, + "scanner": { + "hint": "将二维码放入框内", + "connectNote": "只包含服务器地址,不含任何账号信息", + "manual": "手动输入地址", + "notConnectCode": "不是连接二维码", + "close": "关闭", + "torchOn": "打开手电筒", + "torchOff": "关闭手电筒", + "permission": { + "title": "需要相机权限", + "body": "允许使用相机以扫描二维码。", + "blocked": "ObjectOS 的相机权限已关闭。请在设置中开启,或手动输入地址。", + "allow": "允许使用相机", + "settings": "打开设置" + } + }, + "permissions": { + "camera": "ObjectOS 使用相机扫描二维码,以连接到你的服务器。" } } diff --git a/package.json b/package.json index 0cdb352..e21a740 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@sentry/react-native": "~7.11.0", "clsx": "^2.1.1", "expo": "~55.0.26", + "expo-camera": "~55.0.23", "expo-constants": "~55.0.16", "expo-device": "~55.0.17", "expo-font": "~55.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4e84d2..8b790c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: expo: specifier: ~55.0.26 version: 55.0.26(@babel/core@7.29.7)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.16)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.7)(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-camera: + specifier: ~55.0.23 + version: 55.0.23(@types/emscripten@1.41.6)(expo@55.0.26)(react-native-web@0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0) expo-constants: specifier: ~55.0.16 version: 55.0.16(expo@55.0.26)(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0)) @@ -1773,6 +1776,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/emscripten@1.41.6': + resolution: {integrity: sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==} + '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -2120,6 +2126,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + barcode-detector@3.2.3: + resolution: {integrity: sha512-PLinpWGiOQoQH4X+YObWZ6iXeffXKDM3d4vnOD86481SvukIfvkf7jEE/tud9oI4HTDxtDdlHaFilA5Kn7Xa7A==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2762,6 +2771,17 @@ packages: react: '*' react-native: '*' + expo-camera@55.0.23: + resolution: {integrity: sha512-Qra5QIZv0BOXLLXNfQlOW8NLUx3gLiWKCfpLeguiOwXY6XPj9xIE3AfbrxGa27/KohgrtHy1sgsnLeJB76OGjA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + expo-constants@55.0.16: resolution: {integrity: sha512-Z15/No94UHoogD+pulxjudGAeOHTEIWZgb/vnX48Wx5D+apWTeCbnKxQZZtGQlosvduYL5kaic2/W8U+NHfBQQ==} peerDependencies: @@ -5089,6 +5109,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -5228,6 +5252,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -5549,6 +5577,11 @@ packages: use-sync-external-store: optional: true + zxing-wasm@3.1.6: + resolution: {integrity: sha512-mU7UNuvl+hOT+nlSOQTMHZ2tM8lRMsbjDIp5IO/+HmBl/FOq77q0OSZPJ3cm42A9c1MKU6Uve67FiGdkhsrT9g==} + peerDependencies: + '@types/emscripten': '>=1.39.6' + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -7632,6 +7665,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@types/emscripten@1.41.6': {} + '@types/graceful-fs@4.1.9': dependencies: '@types/node': 25.2.2 @@ -8079,6 +8114,12 @@ snapshots: balanced-match@4.0.4: {} + barcode-detector@3.2.3(@types/emscripten@1.41.6): + dependencies: + zxing-wasm: 3.1.6(@types/emscripten@1.41.6) + transitivePeerDependencies: + - '@types/emscripten' + base64-js@1.5.1: {} baseline-browser-mapping@2.10.34: {} @@ -8837,6 +8878,17 @@ snapshots: - supports-color - typescript + expo-camera@55.0.23(@types/emscripten@1.41.6)(expo@55.0.26)(react-native-web@0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0): + dependencies: + barcode-detector: 3.2.3(@types/emscripten@1.41.6) + expo: 55.0.26(@babel/core@7.29.7)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.16)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.7)(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@types/emscripten' + expo-constants@55.0.16(expo@55.0.26)(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0)): dependencies: '@expo/env': 2.1.2 @@ -11560,6 +11612,8 @@ snapshots: symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} + tailwind-merge@3.6.0: {} tailwindcss@3.4.19(tsx@4.22.4)(yaml@2.9.0): @@ -11704,6 +11758,10 @@ snapshots: type-fest@4.41.0: {} + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -11988,3 +12046,8 @@ snapshots: '@types/react': 19.2.13 react: 19.2.0 use-sync-external-store: 1.6.0(react@19.2.0) + + zxing-wasm@3.1.6(@types/emscripten@1.41.6): + dependencies: + '@types/emscripten': 1.41.6 + type-fest: 5.10.0 From 7bdcd7b8a619520d23b04314eac0cb723c724a41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:53:41 +0000 Subject: [PATCH 2/3] test(connect): scanner states, link prefill; docs and changeset Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- .changeset/156-connect-qr-scanner.md | 20 ++ __tests__/components/QrScanner.test.tsx | 276 ++++++++++++++++++ .../components/ServerConfigScreen.test.tsx | 77 ++++- docs/HYBRID-SHELL.md | 45 ++- 4 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 .changeset/156-connect-qr-scanner.md create mode 100644 __tests__/components/QrScanner.test.tsx diff --git a/.changeset/156-connect-qr-scanner.md b/.changeset/156-connect-qr-scanner.md new file mode 100644 index 0000000..6721498 --- /dev/null +++ b/.changeset/156-connect-qr-scanner.md @@ -0,0 +1,20 @@ +--- +"mobile": minor +--- + +Clause-②: no +Metadata-protocol: none + +Connect by QR code. The server picker has a scan button in the address field +that opens a full-screen camera scanner (close, torch, "Place the QR code +inside the frame", "Contains only the server address, no account +information", "Enter address manually"). It accepts the +`objectos://connect?server=…` code from the Console's mobile-app page, and a +plain `https://` server URL; anything else shows "This isn't a connect QR +code". The same `objectos://connect` link opens the app from an invite email. +Either way the address is probed and the user confirms the server before +anything is saved, also when a server is already saved. A link carrying any +parameter besides `server` is refused, and an unusable address shows in the +field with its error. Adds `expo-camera` (~55.0.18, camera only: no +microphone, no `RECORD_AUDIO`), with the camera permission text localized in +English, Chinese and Arabic. The scanner is a reusable `QrScanner` component. diff --git a/__tests__/components/QrScanner.test.tsx b/__tests__/components/QrScanner.test.tsx new file mode 100644 index 0000000..2cfbb1d --- /dev/null +++ b/__tests__/components/QrScanner.test.tsx @@ -0,0 +1,276 @@ +import React from "react"; +import { Linking } from "react-native"; +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +// Types for the matchers jest.config registers through setupFilesAfterEnv. +import type {} from "@testing-library/react-native/extend-expect"; +import "~/lib/i18n"; + +interface MockPermission { + granted: boolean; + canAskAgain: boolean; + status: string; +} + +// The camera: `CameraView` renders a plain view and records its props, so a +// test can read the torch / scanner settings and "scan" by calling +// `onBarcodeScanned`; `useCameraPermissions` answers with `mockCamera.permission`. +const mockCamera: { + permission: MockPermission | null; + props: Record | null; + request: jest.Mock; +} = { + permission: null, + props: null, + request: jest.fn(), +}; +jest.mock("expo-camera", () => { + const { View } = require("react-native"); + return { + CameraView: (props: Record) => { + mockCamera.props = props; + return ; + }, + useCameraPermissions: () => [mockCamera.permission, mockCamera.request, jest.fn()], + }; +}); + +jest.mock("react-native-safe-area-context", () => { + const { View } = require("react-native"); + return { SafeAreaView: View }; +}); + +const mockBack = jest.fn(); +const mockDismissTo = jest.fn(); +jest.mock("expo-router", () => ({ + useRouter: () => ({ back: mockBack, dismissTo: mockDismissTo, push: jest.fn() }), +})); + +import { QrScanner, REJECT_MESSAGE_MS } from "~/components/shell/QrScanner"; +import ConnectScanScreen from "~/app/connect-scan"; + +const GRANTED: MockPermission = { granted: true, canAskAgain: true, status: "granted" }; +const UNDETERMINED: MockPermission = { granted: false, canAskAgain: true, status: "undetermined" }; +const BLOCKED: MockPermission = { granted: false, canAskAgain: false, status: "denied" }; + +/** Report a code read by the camera. */ +function scan(data: string) { + act(() => { + mockCamera.props?.onBarcodeScanned?.({ type: "qr", data }); + }); +} + +const accept = (code: string) => (code.startsWith("ok:") ? code.slice(3) : null); + +function renderScanner(overrides: Partial>> = {}) { + const props = { + accept, + onResult: jest.fn(), + onClose: jest.fn(), + rejectMessage: "Not a code for this", + note: "Only the address", + manualAction: { label: "Type it", onPress: jest.fn() }, + ...overrides, + }; + render(); + return props; +} + +/** + * Cold-load cost, paid once outside any test's 5 s budget: the first render + * requires and babel-transforms react-native's lazily exported components + * (see ServerConfigScreen.test.tsx for the measurement). Both permission + * branches are rendered so each loads its components here. + */ +const COLD_LOAD_TIMEOUT_MS = 30_000; +beforeAll(() => { + mockCamera.permission = GRANTED; + render( {}} onClose={() => {}} rejectMessage="" />).unmount(); + mockCamera.permission = BLOCKED; + render( {}} onClose={() => {}} rejectMessage="" />).unmount(); +}, COLD_LOAD_TIMEOUT_MS); + +beforeEach(() => { + mockCamera.permission = GRANTED; + mockCamera.props = null; + mockCamera.request.mockReset().mockResolvedValue(GRANTED); + mockBack.mockReset(); + mockDismissTo.mockReset(); +}); + +describe("QrScanner — camera allowed", () => { + it("shows the back camera reading QR codes only, with the hint and the note", () => { + renderScanner(); + expect(screen.getByTestId("qr-scanner-camera")).toBeTruthy(); + expect(mockCamera.props).toMatchObject({ + facing: "back", + enableTorch: false, + barcodeScannerSettings: { barcodeTypes: ["qr"] }, + }); + expect(screen.getByTestId("qr-scanner-hint")).toHaveTextContent( + "Place the QR code inside the frame" + ); + expect(screen.getByTestId("qr-scanner-note")).toHaveTextContent("Only the address"); + expect(screen.queryByTestId("qr-scanner-rejected")).toBeNull(); + expect(mockCamera.request).not.toHaveBeenCalled(); + }); + + it("says so when a code is refused, and keeps scanning", () => { + const props = renderScanner(); + scan("WIFI:S:office;;"); + expect(screen.getByTestId("qr-scanner-rejected")).toHaveTextContent("Not a code for this"); + expect(props.onResult).not.toHaveBeenCalled(); + expect(mockCamera.props?.onBarcodeScanned).toEqual(expect.any(Function)); + }); + + it("lets the refusal go a moment after the camera stops reading that code", () => { + jest.useFakeTimers(); + try { + renderScanner(); + scan("nope"); + act(() => { + jest.advanceTimersByTime(REJECT_MESSAGE_MS - 1); + }); + scan("nope"); // still in view: the message stays and its timer restarts + act(() => { + jest.advanceTimersByTime(REJECT_MESSAGE_MS - 1); + }); + expect(screen.getByTestId("qr-scanner-rejected")).toBeTruthy(); + act(() => { + jest.advanceTimersByTime(1); + }); + expect(screen.queryByTestId("qr-scanner-rejected")).toBeNull(); + } finally { + jest.useRealTimers(); + } + }); + + it("hands over the first accepted code once and stops reading", () => { + const props = renderScanner(); + scan("nope"); + scan("ok:https://erp.example.com"); + // The camera reports the same code again before the screen re-renders. + scan("ok:https://other.example.com"); + + expect(props.onResult).toHaveBeenCalledTimes(1); + expect(props.onResult).toHaveBeenCalledWith("https://erp.example.com"); + expect(screen.queryByTestId("qr-scanner-rejected")).toBeNull(); + expect(mockCamera.props?.onBarcodeScanned).toBeUndefined(); + }); + + it("toggles the torch", () => { + renderScanner(); + const torch = screen.getByTestId("qr-scanner-torch"); + expect(torch).toHaveProp("accessibilityLabel", "Turn on flashlight"); + fireEvent.press(torch); + expect(mockCamera.props?.enableTorch).toBe(true); + expect(screen.getByTestId("qr-scanner-torch")).toHaveProp( + "accessibilityLabel", + "Turn off flashlight" + ); + fireEvent.press(screen.getByTestId("qr-scanner-torch")); + expect(mockCamera.props?.enableTorch).toBe(false); + }); + + it("closes, and offers the manual way out", () => { + const props = renderScanner(); + fireEvent.press(screen.getByTestId("qr-scanner-close")); + expect(props.onClose).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("qr-scanner-manual")).toHaveTextContent("Type it"); + fireEvent.press(screen.getByTestId("qr-scanner-manual")); + expect(props.manualAction.onPress).toHaveBeenCalledTimes(1); + }); + + it("renders no manual button when none is given", () => { + renderScanner({ manualAction: undefined, note: undefined, hint: "Scan the field code" }); + expect(screen.queryByTestId("qr-scanner-manual")).toBeNull(); + expect(screen.queryByTestId("qr-scanner-note")).toBeNull(); + expect(screen.getByTestId("qr-scanner-hint")).toHaveTextContent("Scan the field code"); + }); +}); + +describe("QrScanner — camera permission", () => { + it("shows no camera while the permission is still being read", () => { + mockCamera.permission = null; + const props = renderScanner(); + expect(screen.queryByTestId("qr-scanner-camera")).toBeNull(); + expect(screen.queryByTestId("qr-scanner-permission")).toBeNull(); + fireEvent.press(screen.getByTestId("qr-scanner-close")); + expect(props.onClose).toHaveBeenCalled(); + }); + + it("asks once on open, and again from the allow button", () => { + mockCamera.permission = UNDETERMINED; + mockCamera.request.mockResolvedValue(UNDETERMINED); + renderScanner(); + expect(mockCamera.request).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId("qr-scanner-camera")).toBeNull(); + expect(screen.getByTestId("qr-scanner-permission")).toHaveTextContent( + "Allow camera access to scan the QR code.", + { exact: false } + ); + fireEvent.press(screen.getByTestId("qr-scanner-allow")); + expect(mockCamera.request).toHaveBeenCalledTimes(2); + }); + + it("when refused for good, points to Settings and the manual way, without asking", () => { + mockCamera.permission = BLOCKED; + const openSettings = jest.spyOn(Linking, "openSettings").mockResolvedValue(undefined); + const props = renderScanner(); + + expect(mockCamera.request).not.toHaveBeenCalled(); + expect(screen.queryByTestId("qr-scanner-camera")).toBeNull(); + expect(screen.queryByTestId("qr-scanner-allow")).toBeNull(); + expect(screen.getByTestId("qr-scanner-permission")).toHaveTextContent( + "Turn it on in Settings", + { exact: false } + ); + fireEvent.press(screen.getByTestId("qr-scanner-settings")); + expect(openSettings).toHaveBeenCalledTimes(1); + fireEvent.press(screen.getByTestId("qr-scanner-manual")); + expect(props.manualAction.onPress).toHaveBeenCalledTimes(1); + openSettings.mockRestore(); + }); +}); + +describe("ConnectScanScreen (screen 6)", () => { + it("shows the connect copy", () => { + render(); + expect(screen.getByTestId("qr-scanner-note")).toHaveTextContent( + "Contains only the server address, no account information" + ); + expect(screen.getByTestId("qr-scanner-manual")).toHaveTextContent("Enter address manually"); + }); + + it("refuses a code that is not a connect code", () => { + render(); + scan("objectos://connect?server=https%3A%2F%2Ferp.example.com&token=abc"); + expect(screen.getByTestId("qr-scanner-rejected")).toHaveTextContent( + "This isn't a connect QR code" + ); + expect(mockDismissTo).not.toHaveBeenCalled(); + }); + + it("returns a connect code's address to the picker, which probes and confirms it", () => { + render(); + scan("objectos://connect?server=https%3A%2F%2Ferp.example.com"); + expect(mockDismissTo).toHaveBeenCalledTimes(1); + expect(mockDismissTo.mock.calls[0][0]).toMatch( + /^\/server-config\?server=https%3A%2F%2Ferp\.example\.com&n=\d+$/ + ); + }); + + it("takes a plain https server URL too", () => { + render(); + scan("https://erp.example.com/_console/apps"); + expect(mockDismissTo.mock.calls[0][0]).toMatch( + /^\/server-config\?server=https%3A%2F%2Ferp\.example\.com&n=\d+$/ + ); + }); + + it("goes back to the address field on close and on manual entry", () => { + render(); + fireEvent.press(screen.getByTestId("qr-scanner-close")); + fireEvent.press(screen.getByTestId("qr-scanner-manual")); + expect(mockBack).toHaveBeenCalledTimes(2); + }); +}); diff --git a/__tests__/components/ServerConfigScreen.test.tsx b/__tests__/components/ServerConfigScreen.test.tsx index d526367..b6f899c 100644 --- a/__tests__/components/ServerConfigScreen.test.tsx +++ b/__tests__/components/ServerConfigScreen.test.tsx @@ -6,8 +6,12 @@ import "~/lib/i18n"; import type { ProbeResult } from "~/lib/server-probe"; const mockDismissTo = jest.fn(); +const mockPush = jest.fn(); +// The screen's search params: what a connect link or the scanner hands over. +let mockParams: { server?: string; n?: string } = {}; jest.mock("expo-router", () => ({ - useRouter: () => ({ dismissTo: mockDismissTo, push: jest.fn(), replace: jest.fn() }), + useRouter: () => ({ dismissTo: mockDismissTo, push: mockPush, replace: jest.fn() }), + useLocalSearchParams: () => mockParams, })); jest.mock("react-native-safe-area-context", () => { @@ -58,12 +62,19 @@ beforeAll(() => { }, COLD_LOAD_TIMEOUT_MS); beforeEach(() => { + mockParams = {}; mockProbeServer.mockReset(); mockDismissTo.mockReset(); + mockPush.mockReset(); connect.mockClear(); useServerStore.setState({ serverUrl: null, connect }); }); +/** Let a probe started by an effect settle. */ +async function flush() { + await act(async () => {}); +} + async function submit(address: string) { fireEvent.changeText(screen.getByTestId("server-url-input"), address); await act(async () => { @@ -133,6 +144,70 @@ describe("ServerConfigScreen", () => { expect(screen.getByTestId("server-url-input").props.value).toBe("other.example.com"); }); + it("opens the scanner from the button in the address field", () => { + render(); + const scan = screen.getByTestId("server-scan"); + expect(scan).toHaveProp("accessibilityLabel", "Scan QR code"); + fireEvent.press(scan); + expect(mockPush).toHaveBeenCalledWith("/connect-scan"); + }); + + it("probes a linked or scanned address and shows the confirm step, saving nothing", async () => { + mockProbeServer.mockResolvedValue(OK); + mockParams = { server: "https://erp.example.com", n: "1" }; + render(); + await flush(); + + expect(mockProbeServer).toHaveBeenCalledTimes(1); + expect(mockProbeServer).toHaveBeenCalledWith("https://erp.example.com", expect.anything()); + expect(screen.getByTestId("server-confirm-name")).toHaveTextContent("Acme ERP"); + expect(connect).not.toHaveBeenCalled(); + }); + + it("with a server already saved, still asks; confirming replaces the saved server", async () => { + useServerStore.setState({ serverUrl: "https://old.example.com" }); + mockProbeServer.mockResolvedValue(OK); + mockParams = { server: "https://erp.example.com", n: "1" }; + render(); + await flush(); + expect(useServerStore.getState().serverUrl).toBe("https://old.example.com"); + + await act(async () => { + fireEvent.press(screen.getByTestId("server-confirm")); + }); + expect(connect).toHaveBeenCalledWith("https://www.erp.example.com"); + expect(useServerStore.getState().serverUrl).toBe("https://www.erp.example.com"); + }); + + it("shows an unusable linked address in the field with its error", async () => { + mockProbeServer.mockResolvedValue({ kind: "insecure-http" }); + mockParams = { server: "http://erp.example.com", n: "1" }; + render(); + await flush(); + + expect(screen.getByTestId("server-url-input").props.value).toBe("http://erp.example.com"); + expect(screen.getByTestId("server-url-message")).toHaveTextContent("Use an https:// address", { + exact: false, + }); + expect(screen.queryByTestId("server-confirm")).toBeNull(); + }); + + it("probes again for each new request, even for the same address", async () => { + mockProbeServer.mockResolvedValue({ kind: "unreachable" }); + mockParams = { server: "https://erp.example.com", n: "1" }; + const view = render(); + await flush(); + view.rerender(); + expect(mockProbeServer).toHaveBeenCalledTimes(1); + + mockProbeServer.mockResolvedValue(OK); + mockParams = { server: "https://erp.example.com", n: "2" }; + view.rerender(); + await flush(); + expect(mockProbeServer).toHaveBeenCalledTimes(2); + expect(screen.getByTestId("server-confirm")).toBeTruthy(); + }); + it("shows the where-to-find-it steps and the VPN hint", () => { render(); expect(screen.getByText("Where do I find the address?")).toBeTruthy(); diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md index 180653f..aac2773 100644 --- a/docs/HYBRID-SHELL.md +++ b/docs/HYBRID-SHELL.md @@ -26,11 +26,13 @@ app/ _layout.tsx providers, Sentry, push-tap routing index.tsx waits for the stored server, then redirects server-config.tsx native server picker: address step, then confirm step + connect-scan.tsx QR scanner for the server address (screen 6) console.tsx the Console screen (one long-lived WebView) +native-intent.tsx app links → Console routes components/shell/ ConsoleWebView.tsx WebView + navigation policy + bridge + back button + load states ServerConfirm.tsx "is this your server?" step of the picker + QrScanner.tsx full-screen camera QR scanner (reusable; `accept` decides) OfflineFallback.tsx native page for network / 5xx failures lib/ server-probe.ts anonymous probe of a server before it is saved @@ -40,7 +42,7 @@ lib/shell/ route-owner.ts per-route ownership: web (default) or native native-screens.ts native screens a route rule may target (empty today) navigation-policy.ts what each WebView navigation is allowed to do - incoming-link.ts objectos:// links + incoming-link.ts objectos:// links: Console routes, `connect`, scanned codes config.ts build-time config from app.config.ts `extra` external.ts open URLs in the in-app browser / OS bridge/ protocol, injected scripts, native handler @@ -95,8 +97,43 @@ React Native's `fetch` reports every transport failure as the same `XMLHttpRequest`, which keeps the native error text (iOS `NSURLErrorDomain`, Android `javax.net.ssl` / `java.security.cert`) for the probe to classify. -The QR scanner is not built yet; its button and the `objectos://connect` link -will feed the scanned address into the same probe and confirm steps. +### Connect by QR code or link + +The Console's mobile-app page shows a QR code, and an invite email carries the +same link. It holds the server address and nothing else — never a credential: + +``` +objectos://connect?server=https%3A%2F%2Ferp.example.com +``` + +Both end on the picker's address step with the address filled in, which +probes it at once (`/server-config?server=…&n=…`; `n` makes a repeat of the +same address probe again). **A connect link never connects silently**: the +user always sees the confirm step, or the error beside the address. That holds +when a server is already saved too — confirming replaces it, "Not this server" +keeps it. + +- **The link.** `connectRequestFromLink` (`lib/shell/incoming-link.ts`) reads + the `server` param and normalizes it with `normalizeServerUrl`. A link with + no `server`, a repeated `server`, any other param, or a path after `connect` + is refused and lands on the start screen, like any unknown link. An address + that does not normalize (plain http to a public host, not a URL, empty) still + opens the picker, with the value in the field and its error shown. +- **The scanner.** The scan button in the address field opens + `app/connect-scan.tsx`, which renders `QrScanner` with + `serverAddressFromCode`: it accepts `objectos://connect` codes, and a plain + `https://` server URL as a convenience. Anything else shows "This isn't a + connect QR code" and keeps scanning. The first accepted code goes back to the + picker (`router.dismissTo`) and the scanner stops reading. +- **Camera permission.** The scanner asks on open. Refused for good, it offers + the system Settings and "Enter address manually". The iOS prompt text is + `permissions.camera` in each locale bundle, written by `app.config.ts` into + the `expo-camera` plugin and per-language `InfoPlist.strings`; the plugin is + configured for the camera only (no microphone, no `RECORD_AUDIO`). + +`QrScanner` knows nothing about servers: the caller passes `accept`, the +refusal text and the optional note and manual action, so the bridge's `scan()` +for Console fields can reuse it. Sign-in through an external identity provider: the IdP's host must be listed in `EXPO_PUBLIC_TRUSTED_HOSTS`, or the navigation policy opens it in the system @@ -254,6 +291,8 @@ See [PUSH-NOTIFICATIONS.md](./PUSH-NOTIFICATIONS.md). under `__tests__/lib`. - The picker and its confirm step — component tests under `__tests__/components` (`ServerConfigScreen`, `ServerConfirm`). +- The scanner and screen 6 — `__tests__/components/QrScanner.test.tsx`, with a + mocked `expo-camera` (permission states, refused and accepted codes, torch). - The bootstrap and navigate scripts are executed against a fake `window` in `__tests__/lib/shell/bridge-scripts.test.ts`. - `ConsoleWebView` is tested with a mocked `react-native-webview` From 0f0460473ed89937ab320569b5c64ad798ee41ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:00:51 +0000 Subject: [PATCH 3/3] fix(server-picker): way back to the saved server after a cold-start connect link Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- .changeset/156-connect-qr-scanner.md | 5 +- .../components/ServerConfigScreen.test.tsx | 74 ++++++++++++++++++- app/server-config.tsx | 35 ++++++++- docs/HYBRID-SHELL.md | 5 +- locales/ar.json | 1 + locales/en.json | 1 + locales/zh.json | 1 + 7 files changed, 118 insertions(+), 4 deletions(-) diff --git a/.changeset/156-connect-qr-scanner.md b/.changeset/156-connect-qr-scanner.md index 6721498..9daac04 100644 --- a/.changeset/156-connect-qr-scanner.md +++ b/.changeset/156-connect-qr-scanner.md @@ -13,7 +13,10 @@ information", "Enter address manually"). It accepts the plain `https://` server URL; anything else shows "This isn't a connect QR code". The same `objectos://connect` link opens the app from an invite email. Either way the address is probed and the user confirms the server before -anything is saved, also when a server is already saved. A link carrying any +anything is saved, also when a server is already saved. When a link +cold-starts the app onto the picker while a server is saved, the address step +offers a way back to that server's Console (a "Back to" button, and Android +back). A link carrying any parameter besides `server` is refused, and an unusable address shows in the field with its error. Adds `expo-camera` (~55.0.18, camera only: no microphone, no `RECORD_AUDIO`), with the camera permission text localized in diff --git a/__tests__/components/ServerConfigScreen.test.tsx b/__tests__/components/ServerConfigScreen.test.tsx index b6f899c..1543634 100644 --- a/__tests__/components/ServerConfigScreen.test.tsx +++ b/__tests__/components/ServerConfigScreen.test.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { BackHandler } from "react-native"; import { act, fireEvent, render, screen } from "@testing-library/react-native"; // Types for the matchers jest.config registers through setupFilesAfterEnv. import type {} from "@testing-library/react-native/extend-expect"; @@ -7,10 +8,18 @@ import type { ProbeResult } from "~/lib/server-probe"; const mockDismissTo = jest.fn(); const mockPush = jest.fn(); +// Whether a screen sits behind the picker (pushed over the Console) or not +// (the app cold-started onto it from a connect link). +const mockCanGoBack = jest.fn(() => false); // The screen's search params: what a connect link or the scanner hands over. let mockParams: { server?: string; n?: string } = {}; jest.mock("expo-router", () => ({ - useRouter: () => ({ dismissTo: mockDismissTo, push: mockPush, replace: jest.fn() }), + useRouter: () => ({ + dismissTo: mockDismissTo, + push: mockPush, + replace: jest.fn(), + canGoBack: mockCanGoBack, + }), useLocalSearchParams: () => mockParams, })); @@ -66,6 +75,7 @@ beforeEach(() => { mockProbeServer.mockReset(); mockDismissTo.mockReset(); mockPush.mockReset(); + mockCanGoBack.mockReset().mockReturnValue(false); connect.mockClear(); useServerStore.setState({ serverUrl: null, connect }); }); @@ -208,6 +218,68 @@ describe("ServerConfigScreen", () => { expect(screen.getByTestId("server-confirm")).toBeTruthy(); }); + describe("way back to the saved server", () => { + it("cold-started onto the picker with a server saved: offers it, and confirm's 'Not this server' keeps it", async () => { + useServerStore.setState({ serverUrl: "https://old.example.com" }); + mockProbeServer.mockResolvedValue(OK); + mockParams = { server: "https://erp.example.com", n: "1" }; + render(); + await flush(); + + // Confirm step: no way back there, "Not this server" behaves as before. + expect(screen.queryByTestId("server-back-to-saved")).toBeNull(); + fireEvent.press(screen.getByTestId("server-reject")); + expect(screen.getByTestId("server-url-input").props.value).toBe("https://erp.example.com"); + + const back = screen.getByTestId("server-back-to-saved"); + expect(back).toHaveTextContent("Back to old.example.com"); + fireEvent.press(back); + expect(mockDismissTo).toHaveBeenCalledWith("/console"); + expect(connect).not.toHaveBeenCalled(); + expect(useServerStore.getState().serverUrl).toBe("https://old.example.com"); + }); + + it("Android back on the address step does the same", () => { + useServerStore.setState({ serverUrl: "https://old.example.com" }); + let handler: (() => boolean | null | undefined) | undefined; + const spy = jest.spyOn(BackHandler, "addEventListener").mockImplementation((_e, h) => { + handler = h; + return { remove: jest.fn() }; + }); + try { + render(); + expect(handler?.()).toBe(true); + expect(mockDismissTo).toHaveBeenCalledWith("/console"); + } finally { + spy.mockRestore(); + } + }); + + it("pushed over the Console (a screen to go back to): no extra way back", () => { + useServerStore.setState({ serverUrl: "https://old.example.com" }); + mockCanGoBack.mockReturnValue(true); + const spy = jest.spyOn(BackHandler, "addEventListener"); + try { + render(); + expect(screen.queryByTestId("server-back-to-saved")).toBeNull(); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + + it("with no saved server, nothing changes", () => { + const spy = jest.spyOn(BackHandler, "addEventListener"); + try { + render(); + expect(screen.queryByTestId("server-back-to-saved")).toBeNull(); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + }); + it("shows the where-to-find-it steps and the VPN hint", () => { render(); expect(screen.getByText("Where do I find the address?")).toBeTruthy(); diff --git a/app/server-config.tsx b/app/server-config.tsx index aa87a80..fcfd873 100644 --- a/app/server-config.tsx +++ b/app/server-config.tsx @@ -1,5 +1,13 @@ import React from "react"; -import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, View } from "react-native"; +import { + BackHandler, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + Text, + View, +} from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { SafeAreaView } from "react-native-safe-area-context"; import { useTranslation } from "react-i18next"; @@ -14,6 +22,7 @@ import { type ProbeFailureKind, } from "~/lib/server-probe"; import { shellConfig } from "~/lib/shell/config"; +import { parseUrl } from "~/lib/shell/console-url"; import { useThemeColors } from "~/lib/theme-colors"; import { useServerStore } from "~/stores/server-store"; @@ -49,6 +58,11 @@ const HELP_STEPS = [ * `objectos://connect?server=…` link, come back here as `?server=…&n=…`: the * address is filled in and probed, so the user always sees the confirm step, * or the error beside the address that failed. + * + * Way back: a connect link can cold-start the app straight onto this screen + * while a server is already saved, with nothing behind it to go back to. The + * address step then offers "Back to " (and Android back does the same), + * which opens the saved server's Console. Nothing is saved by leaving. */ export default function ServerConfigScreen() { const router = useRouter(); @@ -119,6 +133,19 @@ export default function ServerConfigScreen() { const handleReject = React.useCallback(() => setProbed(null), []); + // Only when there is a saved server and no screen behind this one: pushed + // over the Console, the back gesture already returns there. + const showWayBack = !!current && !probed && !router.canGoBack(); + const backToSaved = React.useCallback(() => router.dismissTo("/console"), [router]); + React.useEffect(() => { + if (!showWayBack) return; + const sub = BackHandler.addEventListener("hardwareBackPress", () => { + backToSaved(); + return true; + }); + return () => sub.remove(); + }, [showWayBack, backToSaved]); + if (probed) { return ( @@ -213,6 +240,12 @@ export default function ServerConfigScreen() { > {t("serverConfig.connect")} + + {showWayBack && current ? ( + + ) : null} diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md index 1690276..f17796d 100644 --- a/docs/HYBRID-SHELL.md +++ b/docs/HYBRID-SHELL.md @@ -111,7 +111,10 @@ probes it at once (`/server-config?server=…&n=…`; `n` makes a repeat of the same address probe again). **A connect link never connects silently**: the user always sees the confirm step, or the error beside the address. That holds when a server is already saved too — confirming replaces it, "Not this server" -keeps it. +keeps it. A link can cold-start the app onto the picker with nothing behind it; +when a server is saved and the picker cannot go back, the address step offers +"Back to" the saved host (Android back does the same), which opens that +server's Console and saves nothing. - **The link.** `connectRequestFromLink` (`lib/shell/incoming-link.ts`) reads the `server` param and normalizes it with `normalizeServerUrl`. A link with diff --git a/locales/ar.json b/locales/ar.json index 3fc099e..145a897 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -17,6 +17,7 @@ "hint": "مثال: https://erp.example.com", "connect": "متابعة", "scan": "مسح رمز QR", + "backTo": "العودة إلى {{server}}", "where": { "title": "أين أجد العنوان؟", "step1": "افتح ObjectOS في متصفح على الكمبيوتر وسجّل الدخول.", diff --git a/locales/en.json b/locales/en.json index f99105f..02f2930 100644 --- a/locales/en.json +++ b/locales/en.json @@ -17,6 +17,7 @@ "hint": "Example: https://erp.example.com", "connect": "Continue", "scan": "Scan QR code", + "backTo": "Back to {{server}}", "where": { "title": "Where do I find the address?", "step1": "On a computer, open ObjectOS in your browser and sign in.", diff --git a/locales/zh.json b/locales/zh.json index 2426c34..04bf8a2 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -17,6 +17,7 @@ "hint": "例如:https://erp.example.com", "connect": "继续", "scan": "扫描二维码", + "backTo": "返回 {{server}}", "where": { "title": "在哪里找到地址?", "step1": "在电脑浏览器中打开 ObjectOS 并登录。",