Skip to content

Commit b62463f

Browse files
feat(console): App lock row on the profile page inside the mobile shell (#29798)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbRXboSqkoBMor9JkZLKx8CK)_ Fixes #29748 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:在手机 App(原生壳)里打开「个人资料」页,如果 App 支持应用锁设置,会多出一行「应用锁」,显示当前是关闭,还是开启以及离开多久后锁定;点这一行打开 App 自带的应用锁设置页。 - **为什么改**:用户习惯在个人设置里找应用锁,此前它只能从 App 的连接切换面板进入。按 objectstack-ai/mobile#145 裁决(方案 A),锁的开关只在 App 原生页面里、经生物识别后才能改,Console 只读不写,所以这一行没有开关。 - **风险与回滚**:只在 App 声明 openAppLockSettings 能力时出现,普通浏览器和旧版 App 页面不变;回滚即还原本 PR,无数据和服务端改动。 - **先看**:`ui/apps/console/src/pages/system/ProfilePage.tsx`(`AppLockCard`,尤其是重新读取状态的 effect)、`ui/packages/mobile/src/nativeHost.ts`(两个可选桥接方法和包装函数)、`ui/packages/i18n/src/locales/ru.ts`(俄语与阿拉伯语的时长句式改成冒号形式以避开格变化)。 - **两分钟验证**:`pnpm --dir ui exec vitest run apps/console/src/pages/system/__tests__/ProfilePage.appLock-29748.test.tsx packages/mobile/src/__tests__/nativeHost.test.tsx`,应全部通过(9 + 72)。 - **需要你做的**:无,席位落地。 ## What changes The shell contract is objectstack-ai/mobile `docs/HYBRID-SHELL.md`, sections "App lock" and "Reading the app lock", read at objectstack-ai/mobile#181 head `77ded7bb`. That PR is not merged to mobile `main` yet. The ruling is objectstack-ai/mobile#145 comment 6593856498, option A: the lock changes only on the shell's native screen, behind biometrics; the Console reads it and opens that screen, and never writes it. - **Profile page** (`/system/profile` and the Account app's `account:profile_card`, which mount the same page): a new `AppLockCard` renders only when `nativeHostCan(useNativeHost(), 'openAppLockSettings')`, the same feature-detect the header's organization button uses. It is one row: the title, and a state line from `getAppLock()`: "Off", "On, locks as soon as you leave the app" for a `0` timeout, or "On, locks DURATION after you leave the app". A tap calls `nativeOpenAppLockSettings()`. There is no toggle. - **Re-read when shown.** No shell event announces a change, so the card reads `getAppLock()` on mount, on `visibilitychange` back to `visible`, and on window `focus`. Only the newest read lands. A read the shell refuses leaves the row without a state line. - **Duration.** The timeout is named in its largest whole unit by `Intl.NumberFormat` (`style: 'unit'`, `unitDisplay: 'long'`) in the UI language, falling back to `useDisplayLocale()`, so no locale pack needs a count family and the machine locale is never used. ru and ar phrase it as "delay: DURATION", because `Intl` names the unit in the nominative case. - **`@object-ui/mobile`**: `'getAppLock'` and `'openAppLockSettings'` join `NativeHostMethod`; `getAppLock?()` and `openAppLockSettings?()` join `ObjectStackNative` as optional members (an older shell lacks them, as with `scan?`); new `NativeAppLock` type; new `nativeGetAppLock()` and `nativeOpenAppLockSettings()` wrappers with the same `unavailable | ok | failed` answer as the others. README documents them. - **`@object-ui/i18n`**: `profile.appLock.{title,off,onImmediately,onAfter}` in all ten packs. In a plain browser, or a shell older than the app lock, nothing changes: no row, no bridge call. ## Contract review - **Widens:** `@object-ui/mobile` gains two `NativeHostMethod` members, two optional `ObjectStackNative` members, the `NativeAppLock` type and two exported wrappers. `@object-ui/i18n` gains four keys under `profile.appLock` in every pack (the built `en` declaration types each leaf). - **Narrows:** nothing. No member, export or key is removed, renamed or made required. The new host members are optional and feature-detected, so a host object written against the old interface still type-checks, and an older shell is unaffected. - **Writes:** none. Neither wrapper sends arguments; the Console has no path that changes the lock, as the ruling requires. - No server, spec or metadata change. ## Tests - New `ProfilePage.appLock-29748.test.tsx` (9 cases) renders the real `ProfilePage` and the real `@object-ui/mobile` reader against a fake `window.ObjectStackNative`: - On with a 5-minute timeout; on with `0` (immediately) and with an hour; off. - A tap calls `openAppLockSettings` once with no arguments, the card holds no switch or checkbox, and no other bridge method is called. - The state is read again on `visibilitychange` and on window `focus` after the lock changed. - A refused read leaves the row with no state line. - CONTROL: no bridge; a bridge without the method; a bridge whose `has()` does not announce it. Each draws no row and calls nothing. - `nativeHost.test.tsx`: both methods join the unavailable / has-says-no / method-missing rows; an ok case for the pair; a refused `openAppLockSettings` ("The app is locked") resolves `failed`. ## Acceptance notes - **The shell sends no change event.** The row re-reads on `visibilitychange` and window `focus`. A native screen pushed inside the same Android activity may fire neither, so after returning from the settings screen the row can show the old state until the page is shown again. This is reported to the seat as a finding for the shell contract, not worked around here. - **Serial with #29735**, which edits the same `NativeHostMethod` union, host interface, `index.ts` and test fake. Whichever lands second merges main and keeps both sets of members. ## Verification Head `75f435ad`, based on `7ba8b11f`. `main` has moved 18 commits since (other keys in the same ten locale packs); `git merge-tree` against it is clean, and CI runs on the merge ref. <details><summary>Red on main, ablation, tests, type-check, lint</summary> - Red on main: with `ProfilePage.tsx` restored to `7ba8b11f` (tree only; restored to HEAD after, `git diff HEAD` empty), the new file ran 6 failed, 3 passed: every shell case red, the three controls green. - Ablation (`scripts/ablation-replace.mjs --delete` of the `visibilitychange` and `focus` listener lines; restored, blob equals HEAD, `git diff HEAD` empty): 1 failed, 8 passed, the re-read case. Vitest aliases `@object-ui/*` to `src`, so no rebuild is involved. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/i18n/ packages/mobile/` plus the five `ProfilePage.*` suites: Test Files 109 passed, Tests 1633 passed, 13 skipped (the i18n suites include `all-locales-key-parity`, the untranslated-identity census and the machine-locale census). - `turbo run type-check --filter=@object-ui/console --filter=@object-ui/mobile --filter=@object-ui/i18n`: 73/73 tasks. - `eslint` on the five changed code files: 0 errors (one pre-existing `set-state-in-effect` warning on the page's name sync, untouched). </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` at `75f435ad` derived 35 families. The 34 non-vitest families were run with exit codes captured before any pipe, with `GITHUB_EVENT_PATH` pointing at this body: - `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test`: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0 - `node scripts/check-closing-keyword-parity.mjs`: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test`: exit 0 - `node scripts/check-comment-mask-corpus.mjs`: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main`: exit 0 - `node scripts/check-empty-changeset.mjs --self-test`: exit 0 - `node scripts/check-sdui-manifest.mjs`: exit 0 - `node scripts/check-sdui-manifest.mjs --self-test`: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test`: exit 0 - `node scripts/release-pending-publish.mjs --self-test`: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys`: exit 0 - `pnpm check:changeset-gate-self-tests`: exit 0 - `pnpm check:closed-ceremony-text`: exit 0 - `pnpm check:cross-package-test-inputs`: exit 0 - `pnpm check:dispatcher-error-vocabulary`: exit 0 - `pnpm check:driver-memory-census`: exit 0 - `pnpm check:engine-double-contract`: exit 0 - `pnpm check:gitlink-declared`: exit 0 - `pnpm check:nul-bytes`: exit 0 - `pnpm check:published-files`: exit 0 - `pnpm check:query-options-erasure`: exit 0 - `pnpm check:refd-timer-probe`: exit 0 - `pnpm check:registry-log-declared`: exit 0 - `pnpm check:rest-log-spy-declared`: exit 0 - `pnpm check:skill-identifier-liveness`: exit 0 - `pnpm check:test-source-alias`: exit 0 - `pnpm check:tier-file-adoption`: exit 0 - `pnpm check:type-check-coverage`: exit 0 - `pnpm check:type-check-debt`: exit 0 - `pnpm check:undeclared-dep-imports`: exit 0 - `pnpm check:watch-hint-literal`: exit 0 - `pnpm check:where-matcher`: exit 0 - `pnpm --dir ui exec vitest run scripts/`: Test Files 170 passed, 2 skipped; Tests 4977 passed, 2 skipped; exit 0 - `pnpm check:new-line-citations` (from `ui/`): 0 new citations </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSM2R4osc3dyaqPHY1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4dc41e6 commit b62463f

17 files changed

Lines changed: 521 additions & 4 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/mobile': patch
3+
'@object-ui/i18n': patch
4+
'@object-ui/console': patch
5+
---
6+
7+
feat(console): the profile page shows the mobile shell's App lock and opens its settings screen
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
Inside the ObjectStack mobile shell, a bridge that offers `openAppLockSettings()` now gets an "App lock" row on the profile page (`/system/profile` and the Account app's profile card). The row shows whether the lock is off, or on and after how long it locks, read from the shell's `getAppLock()`, and a tap opens the shell's native settings screen. The lock is the shell's own setting: the Console reads it and never changes it, so the row has no toggle. No event announces a change, so the page reads it again on mount, when it becomes visible again and when the window regains focus. In a plain browser, or a shell older than the app lock, the page is unchanged.
13+
14+
`@object-ui/mobile` adds the two optional bridge members, the `NativeAppLock` type and the `nativeGetAppLock` and `nativeOpenAppLockSettings` wrappers, which feature-detect with `has()` and never throw, like the other wrappers. `@object-ui/i18n` adds `profile.appLock.*` in all ten locale packs.

‎ui/apps/console/src/pages/system/ProfilePage.tsx‎

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,29 @@ import {
3737
} from '@object-ui/components';
3838
import { useUpload } from '@object-ui/providers';
3939
import { ConsolePageHeader } from '@object-ui/app-shell';
40-
import { useObjectTranslation, type TranslateFn } from '@object-ui/i18n';
40+
import { useDisplayLocale, useObjectTranslation, type TranslateFn } from '@object-ui/i18n';
4141
import { useAdapter, extractFieldErrors, extractWriteErrorMessage } from '@object-ui/react';
4242
import { usePermissions } from '@object-ui/permissions';
4343
import { resolveFieldAffordance } from '@object-ui/core';
44-
import { CheckCircle2, AlertCircle, User, Lock, Upload, Loader2, X, Globe } from 'lucide-react';
44+
import {
45+
nativeGetAppLock,
46+
nativeHostCan,
47+
nativeOpenAppLockSettings,
48+
useNativeHost,
49+
type NativeAppLock,
50+
} from '@object-ui/mobile';
51+
import {
52+
CheckCircle2,
53+
AlertCircle,
54+
User,
55+
Lock,
56+
Upload,
57+
Loader2,
58+
X,
59+
Globe,
60+
Fingerprint,
61+
ChevronRight,
62+
} from 'lucide-react';
4563

4664
/**
4765
* The access the console resolves for the signed-in user, as a translated word
@@ -299,6 +317,9 @@ export function ProfilePage() {
299317
{/* Language — the user's own `sys_user.locale` */}
300318
<LanguageCard userId={user.id} />
301319

320+
{/* App lock — the mobile shell's own setting, shown only inside a shell that offers it */}
321+
<AppLockCard />
322+
302323
{/* Password Change */}
303324
<PasswordCard
304325
changePassword={changePassword}
@@ -310,6 +331,107 @@ export function ProfilePage() {
310331
);
311332
}
312333

334+
/**
335+
* The timeout as a localized duration ("5 minutes", "1 hour"), in the largest
336+
* whole unit. `Intl` names the unit and its plural, so no locale pack carries
337+
* a count family for it. The unit is named in the sentence's own language (the
338+
* UI language); a tag `Intl` refuses falls back to the display locale.
339+
*/
340+
function formatLockTimeout(seconds: number, sentenceLocale: string, displayLocale: string): string {
341+
const [value, unit] =
342+
seconds % 3600 === 0 ? [seconds / 3600, 'hour'] : seconds % 60 === 0 ? [seconds / 60, 'minute'] : [seconds, 'second'];
343+
const options: Intl.NumberFormatOptions = { style: 'unit', unit, unitDisplay: 'long' };
344+
try {
345+
return new Intl.NumberFormat(sentenceLocale, options).format(value);
346+
} catch {
347+
return new Intl.NumberFormat(displayLocale, options).format(value);
348+
}
349+
}
350+
351+
/**
352+
* The mobile shell's app lock (objectstack-ai/mobile `docs/HYBRID-SHELL.md`,
353+
* "App lock"), as one row that opens the shell's native settings screen.
354+
*
355+
* - Read-only by ruling (objectstack-ai/mobile#145, option A): the lock changes
356+
* only on the native screen, behind biometrics. ⛔ No Console-side toggle, and
357+
* no bridge call here writes anything.
358+
* - Drawn only when the shell announces and carries `openAppLockSettings`. A
359+
* plain browser, or a shell older than the app lock, draws nothing.
360+
* - The state comes from `getAppLock()`. No event announces a change, so it is
361+
* read again whenever the page is shown: on mount, when the document becomes
362+
* visible again, and when the window regains focus. A read the shell does not
363+
* answer leaves the row without a state line rather than guessing one.
364+
*/
365+
function AppLockCard() {
366+
const { t, language } = useObjectTranslation();
367+
const displayLocale = useDisplayLocale();
368+
const available = nativeHostCan(useNativeHost(), 'openAppLockSettings');
369+
const [lock, setLock] = useState<NativeAppLock | null>(null);
370+
371+
useEffect(() => {
372+
if (!available) return;
373+
let latest = 0;
374+
let mounted = true;
375+
const read = () => {
376+
const seq = ++latest;
377+
void nativeGetAppLock().then((result) => {
378+
// Only the newest read lands: two reads in flight may answer out of order.
379+
if (!mounted || seq !== latest) return;
380+
setLock(result.status === 'ok' ? result.value : null);
381+
});
382+
};
383+
const onVisibility = () => {
384+
if (document.visibilityState === 'visible') read();
385+
};
386+
read();
387+
document.addEventListener('visibilitychange', onVisibility);
388+
window.addEventListener('focus', read);
389+
return () => {
390+
mounted = false;
391+
document.removeEventListener('visibilitychange', onVisibility);
392+
window.removeEventListener('focus', read);
393+
};
394+
}, [available]);
395+
396+
if (!available) return null;
397+
398+
const state =
399+
lock === null
400+
? null
401+
: !lock.enabled
402+
? t('profile.appLock.off', { defaultValue: 'Off' })
403+
: lock.timeoutSeconds <= 0
404+
? t('profile.appLock.onImmediately', { defaultValue: 'On, locks as soon as you leave the app' })
405+
: t('profile.appLock.onAfter', {
406+
defaultValue: 'On, locks {{duration}} after you leave the app',
407+
duration: formatLockTimeout(lock.timeoutSeconds, language || displayLocale, displayLocale),
408+
});
409+
410+
return (
411+
<Card data-testid="profile-app-lock">
412+
<button
413+
type="button"
414+
onClick={() => void nativeOpenAppLockSettings()}
415+
className="flex w-full min-h-11 items-center gap-3 rounded-[inherit] px-4 py-4 text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-6"
416+
data-testid="profile-app-lock-row"
417+
>
418+
<Fingerprint className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
419+
<span className="min-w-0 flex-1">
420+
<span className="block text-base font-semibold sm:text-lg">
421+
{t('profile.appLock.title', { defaultValue: 'App lock' })}
422+
</span>
423+
{state !== null && (
424+
<span className="block text-sm text-muted-foreground" data-testid="profile-app-lock-state">
425+
{state}
426+
</span>
427+
)}
428+
</span>
429+
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
430+
</button>
431+
</Card>
432+
);
433+
}
434+
313435
/**
314436
* Name a language in its own language.
315437
*

0 commit comments

Comments
 (0)