Skip to content

feat: biometric app lock with a native overlay and a read-only bridge - #181

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-145-app-lock
Nov 3, 2026
Merged

hotlong merged 5 commits into
mainfrom
claude/issue-145-app-lock

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #145

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:App 新增「应用锁」。用户在原生设置页开启后,回到 App 时需要刷脸、指纹或手机密码才能看到内容;超时可选立即、1、5、15、60 分钟。
  • 为什么改:审核材料要求的原生能力之一。按裁决 A,开关只能在原生设置页改,改动要过生物识别;网页只能读状态、打开设置页,不能关锁。
  • 风险与回滚:新增原生依赖 expo-screen-capture,需要重新出包。开锁后安卓禁止截屏和录屏。默认关闭,不开就没有影响;回滚即撤销本 PR。本 PR 已合并 main 的最新改动(fix(shell): enforce the navigation policy when a top-frame load starts #179、feat(bridge): pickImage, share and openFile with bytes #180),改动行数超过 3000,须由你亲手合并。
  • 先看:lib/app-lock.ts(锁的判定和唯一写入口)、lib/shell/bridge/handler.ts(桥接只读的两个方法与锁屏时拒绝)、app/_layout.tsx(遮罩是覆盖层,不是路由)。
  • 两分钟验证:npx jest --ci __tests__/components/ConsoleScreen.test.tsx -t "app lock",预期 5 项全过,其中一项把所有桥接方法带着「关锁」参数调用一遍,锁设置不变。
  • 需要你做的:在真机(iOS 与 Android 各一台)上跑一遍下列设备检查,结果回帖即可:
    1. 上锁:开启应用锁、选「立即」,切到后台再回来,遮罩盖住 Console。
    2. 解锁:Face ID / Touch ID / 指纹通过后遮罩消失,Console 页面没有重新加载(停留在原来的记录页、表单内容还在)。
    3. 密码回退:生物识别取消或失败后,用手机密码能解锁。
    4. 多任务快照:开锁时在多任务界面看 App,iOS 为模糊、安卓为空白。
    5. 设置页要求生物识别:在设置页开、关或改超时,都会先弹验证;取消验证则设置不变。

What changes

Option A of the ruling on this card (Ruling: batch #1 item 2 · letter A · maintainer 「同意」): the app-lock setting changes only on a native settings screen, behind biometrics, and the bridge can only read it.

  • The lock (stores/security-store.ts, lib/app-lock.ts, reusing the 1.x modules). A pure machine, stepAppLock: unlocked → covered (background with the lock on) → locked on a return past the timeout, or back to unlocked within it; locked → unlocking → unlocked on a successful prompt. A cold start with the lock on starts locked. inactive alone (the Face ID prompt itself, Control Center) never locks. A background seen while a prompt runs is held, because Android's passcode fallback is an activity of its own, and counts only if the app is still in the background when the prompt ends.
  • The setting. Off by default; on with a timeout of immediately, 1, 5 (default), 15 or 60 minutes. Stored as one versioned record in SecureStore and read synchronously before the first frame. No record means off. A record that does not check out, or a keychain read that throws, fails closed (on, default timeout).
  • The one writer, changeAppLockSettings, asks for Face ID / Touch ID / fingerprint (device passcode as fallback), then stores, then commits. Turning the lock on needs a screen lock on the device. Only the new screen app/app-lock-settings.tsx calls it.
  • The overlay (components/shell/AppLockOverlay.tsx) is an opaque view drawn above the navigation stack in app/_layout.tsx, as a sibling. It is not a route and nothing reloads, so the Console's load state machine sees no event. While it is up: the stack is hidden from screen readers, Android back does nothing (the WebView's back handler steps aside), the switcher sheet closes, and on iOS the in-app browser is dismissed (both are presented above every view).
  • The bridge. getAppLock() resolves { enabled, timeoutSeconds }; openAppLockSettings() opens the native screen. Both are announced as capabilities and pass the origin gate and the excursion gate like every method. There is no write method. While locked, methods that bring up native UI (openExternal, scan, showConnectionSwitcher, and feat(bridge): pickImage, share and openFile with bytes #180's pickImage, share and openFile, plus openAppLockSettings) answer failed ("The app is locked"). The rest still answer, so a Console booting under the lock on a cold start still gets its push token.
  • Snapshot protection while the lock is on, via expo-screen-capture (SDK 55, new native dependency): iOS blurs the app-switcher snapshot natively on resign-active. On Android, FLAG_SECURE is set (blank recents thumbnail; it also blocks screenshots and screen recording while the lock is on). Two permissions the module declares for a screenshot listener the shell does not use, READ_MEDIA_IMAGES and DETECT_SCREEN_CAPTURE, are blocked in app.config.ts. Its READ_EXTERNAL_STORAGE (API 32 and below) is not blocked, because feat(bridge): pickImage, share and openFile with bytes #180's expo-image-picker and expo-file-system declare it too.
  • The prompt now says "Unlock ObjectOS" (was "Unlock ObjectStack"). All new strings are in all ten bundles, including permissions.faceId, the Face ID usage text written into each language's InfoPlist.strings like the camera's.
  • Where the settings entry lives: an "App lock" row (with On/Off) in the Switch organization sheet, beside "App settings". It is the shell's only native menu over the Console, it is reachable from the failure page too, and it needs no Console change. The Console's profile-page entry (has("openAppLockSettings")) is the follow-up consumer card.
  • docs/HYBRID-SHELL.md: new "App lock" section, "Reading the app lock" under the bridge, the interface, Console-side integration, testing and known limits.

Landing points outside the claim's file surface, and why: app/console.tsx (the bridge capabilities are wired there), components/shell/ConnectionSwitcher.tsx (the settings entry), components/shell/ConsoleWebView.tsx (one line: its Android back handler steps aside while locked), app.config.ts (the Face ID usage string per language, and the blocked permissions) with its test __tests__/lib/app-config.test.ts from #180, package.json and pnpm-lock.yaml (expo-screen-capture, the only producer of a native app-switcher blur and of FLAG_SECURE in this build).

MDM's lockServer is unrelated and untouched.

Merged with main (merge commit 77ded7b, parents 654490f and ddcf2d5; no rebase). This brings in #179 (navigation policy at load start) and #180 (pickImage, share, openFile). Both intents are kept: BRIDGE_METHODS carries both sets, the iOS permission texts carry camera, photos and Face ID, and every locale key from both sides is in all ten bundles (three-way merged, key parity checked). pnpm-lock.yaml was regenerated by pnpm from main's and differs from it only by expo-screen-capture. The Android back-handler line sits beside #179's guards.

Tests

On merge head 77ded7b:

  • pnpm install --frozen-lockfile: exit 0
  • npx tsc --noEmit: exit 0
  • npx eslint . --ext .ts,.tsx: exit 0
  • npx jest --ci --coverage: exit 0, 49/49 suites, 1048/1048 tests. Coverage: app-lock.ts 98.38 % statements, security-store.ts 100 %, AppLockOverlay.tsx 100 %, handler.ts 100 %, protocol.ts 100 %.

New or extended: security-store.test.ts (the timeout and every overlay state), app-lock.test.ts (stored record, fail-closed reads, the monitor including the held background, unlock outcomes, the writer, snapshot protection per platform), biometric-auth-level.test.ts, bridge-handler.test.ts (the two methods, the origin and excursion gates, refusals while locked including pickImage, share and openFile, and the refused list pinned exactly), app-config.test.ts (Face ID text in every language, the blocked permissions), bridge-protocol.test.ts and bridge-scripts.test.ts (the lock surface is exactly the two read-only methods), AppLockOverlay.test.tsx, AppLockSettingsScreen.test.tsx, ConnectionSwitcher.test.tsx (the row), ConsoleWebView.test.tsx (back under the lock), and ConsoleScreen.test.tsx "the app lock". That last one runs the Console screen's real wiring. It shows that the WebView keeps one mount, is never reloaded and is still ready after a lock and unlock. It also sends every method the handler dispatches on (read from the handler's source), plus write-shaped names, with write-shaped arguments, and checks the setting did not move.

Cold variant for the new component tests (no transform cache, run in band, each file alone)

On 77ded7b:

AppLockOverlay.test.tsx         exit 0  11/11 tests  10.1 s  (slowest single test 19 ms)
AppLockSettingsScreen.test.tsx  exit 0  13/13 tests  10.1 s  (slowest 94 ms)
ConsoleScreen.test.tsx          exit 0  18/18 tests  18.8 s  (slowest 687 ms, a pre-existing one)

The first-render cost is paid in each file's beforeAll warm-up (30 s budget), as in the existing component suites. Timings are from a shared container.

Ablation: "no bridge method changes the lock"

Run on the pre-merge head 654490f; the merge changed neither the two tests nor the paths they exercise, apart from adding #180's methods to the lists they read. Both legs mutate the committed tree through ablation-replace.mjs (anchor must hit, blob must change, restore proven against the HEAD blob).

  • Leg A: plant "setAppLock" in BRIDGE_METHODS (protocol.ts, blob e1ec622 to 241b889). bridge-protocol.test.ts goes red: "has no app-lock method beyond reading it and opening the native screen", 1 failed / 24 passed. Restored: blob == HEAD, git diff HEAD empty.
  • Leg B: a lock-writing path under an innocent name. Add case "updatePreferences" to the handler (blob 5397738 to a500405) and wire it in app/console.tsx to the store's commit (blob 27eadd2 to adc21da). ConsoleScreen.test.tsx goes red: "no bridge method changes the lock, whatever it is sent", expected {"enabled": true, "timeoutSeconds": 300}, received false. Both files restored to their HEAD blobs; git status --porcelain empty.
  • The first try at leg B was refused by the tool before running anything: the replacement still contained its anchor. It was re-anchored and run as above.
  • After restoring: both suites are green again, 41/41.

Acceptance notes

  • "Requires biometrics" uses the device passcode as fallback for changes as well as for unlocking. Someone who can unlock with the passcode could change the setting anyway, and a biometrics-only rule would lock out devices without enrolled biometrics.
  • On a return within the timeout, the cover shows briefly while the timeout is judged. This avoids the opposite flash: Console content showing for a frame before a lock.
  • With a short timeout, every trip out of the app counts. On Android that includes the in-app browser's Custom Tab, a file chooser and a file input's camera. On iOS those stay in the app and do not count. Documented under Known limits.
  • No event tells the Console the setting changed. The ruling exposes only the two methods, so the Console re-reads getAppLock() when it needs it.
  • The device checks above are not verified here. Nothing in this PR was run on a device.

Verification

Local gates on 77ded7b as listed under Tests. CI on 77ded7b: Lint & Type Check (lint-typecheck.yml: tsc, eslint) success; Code Quality (ci.yml: npx jest --ci --coverage) success.

The pull's additions plus deletions now exceed 3,000 lines, so the maintainer merges it.


Generated by Claude Code

Stack both intents with #179 (navigation policy at load start) and #180
(pickImage, share, openFile): BRIDGE_METHODS carries both sets, the app
lock also refuses pickImage, share and openFile while locked, every
locale key from both sides is in all ten bundles, and the iOS permission
texts carry camera, photos and Face ID. pnpm-lock.yaml regenerated by
pnpm from main's. READ_EXTERNAL_STORAGE is no longer blocked: the image
picker and the file system module declare it too.

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, maintainer merges. The PR is over the 3000-line human-merge threshold (+2930 / −159). Head-sha: 77ded7bbae2c3c1bd22b4dd0bf2f79a6ed1fbf9a

席位意见: 实现符合裁决 A(批 #1 第 2 项)。

  • 锁开关只有原生设置页一个写入口,改之前要先验证生物识别。
  • 桥接只有两个只读方法:getAppLock、openAppLockSettings。
  • 两腿消融都转红:
    • 加入 setAppLock:红;
    • 换个名字偷偷写锁:红。
  • 锁定时,所有会调出原生界面的方法都会被拒绝,feat(bridge): pickImage, share and openFile with bytes #180 新增的三个也包括在内。
  • 遮罩是叠加层,不是路由,WebView 不会重新加载。
  • 本 PR 新增原生模块 expo-screen-capture,需要重新出包。
  • 建议合并。速读、真机检查五项和风险都在 PR 正文里。

席位核对

  1. Shape: draft → main, first line Fixes #145. Clause-②: no and Metadata-protocol: none are present, plus a changeset. The seat rewrote the body after the landing merge (signed trailer).
  2. Ruling compliance (6593856498, A): BRIDGE_METHODS on the head adds exactly getAppLock and openAppLockSettings, with no write method. The ablation legs show the two pinning tests go red.
  3. Gates: check-runs on head 77ded7b are Code Quality success and Lint & Type Check success, and mergeable_state is clean. Local runs: jest --ci --coverage 49/49 suites, 1048 tests; pnpm install --frozen-lockfile exit 0.
  4. Landing merge (77ded7b, parents 654490f and ddcf2d5):
  5. Accepted deviations:
    • Landing points outside the claim's file surface, each named in the body.
    • timeoutSeconds used as the field name.
    • The device passcode is accepted as a fallback for changes.
  6. Out of scope:
  7. Landing: the path surface is not governed, but the diff is 3089 lines (SKILL.md 路径分叉). The PR waits for the maintainer's approval and merge, and the seat does not enable auto-merge.

@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 3, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 03:16
@hotlong
hotlong added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 427cb49 Nov 3, 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 3, 2026
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.

Biometric app lock: cover the Console with a native lock screen after N minutes in the background

2 participants