Skip to content

feat(bridge): scan() — native barcode / QR scanner for Console fields - #170

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-146-bridge-scan
Nov 2, 2026
Merged

os-zhuang merged 5 commits into
mainfrom
claude/issue-146-bridge-scan

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #146

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:Console 页面可以调用 ObjectStackNative.scan(),打开原生全屏扫码界面(带手电筒和关闭按钮),拿回条码或二维码的文字;用户关闭则返回 null。
  • 为什么改:让 Console 的文本、查找字段能用手机摄像头扫码录入。直接复用 Connect by QR code: objectos://connect?server=… links and the native scanner screen #156 已合入的扫码界面,不新增任何权限或插件配置。
  • 风险与回滚:桥接应答现在只回给发起调用的那个页面(调用 id 带页面令牌,页面已被替换就丢弃应答),所有桥接方法都受此影响,但 Console 不读 id。回滚即 revert 本 PR。
  • 先看:components/shell/ConsoleWebView.tsx(onMessage 里丢弃过期应答的判断)、lib/shell/bridge/handler.ts(scan 参数校验)、app/bridge-scan.tsx(每种退出方式都会结算调用)。
  • 两分钟验证:npx jest --ci __tests__/lib/shell __tests__/components/QrScanner.test.tsx __tests__/components/ConsoleWebView.test.tsx,预期全部通过。
  • 需要你做的:真机验证(未做):在手机上打开一个调用 scan() 的 Console 页面,扫一个条码,确认文字回填;关闭扫码界面时返回 null;拒绝相机权限后调用以 failed 失败。

What changes

  • scan(opts?: { formats?: string[] }): Promise<string | null>, announced as capability scan, following "Adding a method": BRIDGE_METHODS, the handler with argument validation, the bootstrap wrapper, and the docs (docs/HYBRID-SHELL.md, new "Scanning codes" section).
  • Formats use expo-camera's BarcodeType names (SCAN_FORMATS in protocol.ts; a compile-time pin in the protocol test keeps them inside BarcodeType). Without formats, every listed format is read. An empty list, an unknown name, a non-string, a non-array or any extra option is bad_args, and no scanner opens.
  • The scanner is QrScanner from Connect by QR code: objectos://connect?server=… links and the native scanner screen #156, reused rather than forked. Its props grow by three optional ones, all needed by the bridge use: barcodeTypes (defaults to ["qr"], so screen 6 is unchanged), permissionText (the default panel copy speaks of entering the address), and onPermissionStatus.
  • New route app/bridge-scan.tsx. It reads the open scan from lib/shell/bridge/scan-session.ts, never from its URL. Every way out settles the call: the close control, and Android back and the iOS swipe through the unmount. Opened with no scan (a link straight to the route), it closes at once.
  • Errors use the existing envelope. Permission denied is failed ("Camera permission denied"). No new error type.
  • Concurrency: a second call is refused. A scan() while one is open is failed ("A scan is already open"), and the open scan is left alone, so a double tap cannot close the scanner under the user.
  • The origin gate is unchanged. scan goes through the same trusted check as every method; a page on another origin gets no answer and no scanner (tested).

Answers stay with the calling document

A scan stays open for as long as the user takes, and the page can be replaced in the meantime: a reload, recovery after a failure, or a redirect. Bridge answers are now scoped to the document that made the call. Call ids carry a per-document token, and the shell sends no answer to a re-created WebView. An in-app navigation does not count as a new document, so a call open across a route change still settles. The tests below pin both ends.

Landing points outside the claimed file surface

  • app/console.tsx: one line added (plus its import). It is the real producer of the bridge's capabilities, which ConsoleWebView only passes through, so scan has to be wired there.
  • components/shell/ConsoleWebView.tsx: the document guard above. The answer plumbing needs it to meet "the answer must not be injected into a different document".
  • Locales: all ten bundles, because feat(i18n): ship the Console's ten locales and follow the phone language #169 (13d5b01) landed first; origin/main was merged in, not rebased. Key parity was verified across the ten.
  • app/connect-scan.tsx: not touched. The RTL root direction sits in QrScanner, which is the root of both scan routes.

Tests

Gates on head f856d04, each run under the verify lock, with exit codes captured before any pipe:

  • npx tsc --noEmit: exit 0
  • npx eslint . --ext .ts,.tsx: exit 0, 0 warnings
  • npx jest --ci --coverage: exit 0, Test Suites 33/33, Tests 519/519
  • Cold: npx jest --ci --no-cache --maxWorkers=2 __tests__/components/QrScanner.test.tsx __tests__/components/ConsoleWebView.test.tsx: exit 0, Tests 56/56. The scanner tests reuse the file's existing beforeAll warm-up.

New coverage:

  • the handler: default formats, narrowed formats, eight bad_args shapes, a foreign origin, and through the real session: text, cancel to null, denied to failed, busy to failed;
  • scan-session.test.ts;
  • the bootstrap wrapper and per-document ids, and answers staying with the calling document;
  • ConsoleWebView: an answer injected while its document is live, still settled after an in-app navigation, dropped after a re-created WebView;
  • QrScanner props and BridgeScanScreen: formats, copy, text, close, unmount, denied by close and by unmount, closed before the prompt, opened with no scan;
  • RTL on both scanner roots and on both scan routes under ar.
Ablations (each mutation verified on disk by scripts/ablation-replace.mjs, restored to the HEAD blob, git diff HEAD empty)
  • ConsoleWebView guard if (!result || documentRef.current !== caller) return; replaced by if (!result) return;: 2 failed, 25 passed. Red: the two document-scoping cases.
  • Bootstrap id doc + "." + seq replaced by "c" + seq: 2 failed, 26 passed. Red: the two per-document id cases.
  • QrScanner camera root without style={{ direction }}: 2 failed, 27 passed. Red: the camera RTL case and the two-route RTL case.

Mobile's jest runs the TypeScript sources through babel-jest (no dist/), so there was no build step to prove.

Acceptance notes

  • Default with no formats = every listed format. The card does not say. This reading matches scan() for "barcode / QR" field entry; a caller that wants QR only passes ["qr"].
  • Denied vs cancel. Leaving the scanner while the camera status reads denied is failed. Leaving it before the prompt is answered (status undetermined) is null. After Settings, the permission hook does not re-read on foreground (expo-modules-core usePermission reads once on mount), so a Settings round trip needs the scan reopened.
  • RTL (seat ask): NOT MEASURED whether the app root's direction reaches a pushed screen across react-native-screens; the test renderer has no screen boundary to measure it on. As asked for that case, feat(i18n): ship the Console's ten locales and follow the phone language #169's root pattern (useLayoutDirection) is now on both QrScanner roots.
  • iOS camera purpose string (permissions.camera, all ten bundles) now names both uses ("QR codes and barcodes, such as the code that connects it to your server"). The old text named only the connect code, which a Console field scan would contradict. The plugin config is untouched.
  • Device check: not done here. See 「需要你做的」.

Verification

Head f5f3225 (patch round 1 on top of f856d04, which merged origin/main aef6993). Not verified on a device.


Generated by Claude Code

Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv
Co-authored-by: Claude <noreply@anthropic.com>
Answers go only to the document that made the call: the per-document call
id covers a replaced page, and a re-created WebView is never sent one. An
in-app navigation keeps its open calls.

Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

Seat verdict: ACCEPT — Head-sha: f5f32256c6b2affd6cfe4a61b7ca8b5b09d264b5

维护者速读

  • 改了什么: Console 页面可以调用 ObjectStackNative.scan(),打开原生全屏扫码(就是 Connect by QR code: objectos://connect?server=… links and the native scanner screen #156 的扫码界面,带手电筒和关闭),拿回条码或二维码的文字;用户关闭则返回 null。拒绝相机权限时调用失败(failed);扫码中再调一次也会被拒绝,正在进行的扫码不受影响。桥接应答现在只回给发起调用的那个页面。
  • 为什么改: 让 Console 的文本、查找字段能用手机摄像头扫码录入。
  • 风险与回滚:
    • 应答只回给发起调用的页面,这条规则作用于所有桥接方法;Console 不读调用 id,不受影响。
    • App 内跳转不算换页面,跨一次站内跳转的调用照样能拿到结果,有测试钉住。
    • 不新增权限或插件配置,iOS 相机用途说明的文字改为同时提到两种用途。
    • 回滚就是 revert 本 PR。
  • 先看: lib/shell/bridge/handler.ts(scan 参数校验)→ app/bridge-scan.tsx(每种退出方式都会结算调用)→ components/shell/ConsoleWebView.tsx(应答送达判断)。
  • 两分钟验证: 运行 npx jest --ci __tests__/lib/shell __tests__/components/QrScanner.test.tsx __tests__/components/ConsoleWebView.test.tsx。
  • 需要你做的: ① 真机上打开一个调用 scan() 的 Console 页面:扫码后文字回填,关闭返回 null,拒绝相机权限则失败。本 PR 未上真机。② 审批并合并。

席位意见: 范围与卡面一致,复用扫码组件,没有另做一份。复核第一轮发现一处:原生侧按 onLoadStart 判断换页,而安卓上站内跳转也会触发它,会导致应答被丢。修复轮已去掉这一判断,只靠页面侧令牌,并加了测试。消融两腿都转红:去掉页面令牌,2 个用例红;加回 onLoadStart 判断,1 个用例红。建议合并。

席位核对

  1. Shape: draft → main. The first line is Fixes #146. Clause-②: no and Metadata-protocol: none are present, and the changeset is patch. The seat rewrote the body (signed trailer).
  2. Premise, measured:
  3. Gates:
    • Check-runs on head f5f3225: Code Quality success, Lint & Type Check success.
    • mergeable_state is clean.
    • Local jest --ci --coverage: 33/33 suites, 519 tests. The cold variant is green too.
  4. Accepted deviations:
    • app/console.tsx gets one capability line; it is the real producer of the capability list.
    • ConsoleWebView.tsx changes the answer-delivery plumbing.
    • The iOS camera purpose string is reworded in all ten bundles.
    • docs/HYBRID-SHELL.md is corrected to match the delivery guarantee.
  5. Out of scope:
  6. Landing: the path surface is NOT governed. The maintainer merges.

@objectstack-fleet objectstack-fleet Bot added the needs-user-decision Blocked on a maintainer decision — do not dispatch; on a PR: awaiting the maintainer's review label Nov 2, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 13:26
@os-zhuang
os-zhuang merged commit 92a0a26 into main Nov 2, 2026
2 checks passed
@objectstack-fleet objectstack-fleet Bot removed the needs-user-decision Blocked on a maintainer decision — do not dispatch; on a PR: awaiting the maintainer's review label Nov 2, 2026
os-justin pushed a commit that referenced this pull request Nov 2, 2026
Reconciles #170 (bridge scan(), per-WebView answer guard) with the load
state machine. #170's documentRef and the onMessage answer guard stay; the
counter is bumped in the machine's `recreate` effect, the one place a new
WebView is created, and nowhere tied to onLoadStart (still unwired, as in the
#167 reconcile). Two #170 tests drove a load start through the
`onLoadStart` prop; they now deliver it the way react-native-webview's
WebViewShared does (onLoadStart if wired, then onNavigationStateChange).
Docs stack #170's Scanning codes and wire-format text beside Load states.

Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bridge: scan() — barcode / QR scanning for Console fields

2 participants