diff --git a/README.md b/README.md index 4159ef3..246ee13 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,8 @@ > 本机活动审计,以及由 Agent 通过在线 direct call,或由 Gateway durable mailbox 入队并在 > App 启动/回到前台后显式拉取投递、在 SQLite 中保留最近 1,000 条并可选发出 > 固定隐私提醒的设备本地信箱;单条正文支持最多 64,000 字符的 Markdown、用户主动安全加载的 HTTPS -> 图片、用户点按后交给系统打开的有界 HTTPS 链接、紧急程度、可选 Agent 发送时间、全文搜索、六种排序、 -> 单条/全部已读。App 现有六个本地页面的 -> 无障碍语义自动化基线 -> 与持久化/并发幂等测试。 +> 图片、用户点按后交给系统打开的有界 HTTPS 链接、紧急程度、可选 Agent 发送时间、全文搜索、列表排序、 +> 未读筛选与单条/全部已读。页面支持跟随系统的浅深主题,具有无障碍语义自动化基线与持久化/并发幂等测试。 > 本地执行还包含确认前 caller/global admission、inline 结果字节上限、claim 后取消/到期复检和 > emergency disable 的进行中命令取消。 > SDK expose 现在为每个公开工具同时提供输入/输出 JSON Schema,并只注册静态配置完整的 App/媒体 @@ -119,7 +117,10 @@ pnpm start 三环境配置、SDK RN 子入口漂移、secret/license/dependency 检查、Expo 依赖一致性、strict typecheck、 零 warning lint、unit/component 和本地/SDK transport 契约测试。 -安装 App 后可在首页“网关连接设置”中填写纯 HTTPS origin 和 Tool Bridge API key。API key 不应写入 +主导航为信箱、设备。信箱提供固定搜索/筛选工具栏和独立阅读页,排序与批量操作进入本地操作面板; +设备总览分别进入连接配置、授权与安全、能力、运行详情和活动记录。 + +安装 App 后可在“设备 → 连接配置”中填写纯 HTTPS origin 和 Tool Bridge API key。API key 不应写入 `.env`、`EXPO_PUBLIC_*`、源码或 URL;保存时 App 会先停止旧连接,再把 key 写入系统 SecureStore。 SDK `deviceId` 默认由设备硬件标识(Android ID / iOS IDFV)经单向摘要派生为稳定短 ID,跨重装保持 不变;也可在同一表单中自定义(字母、数字、`.`、`_`、`-`,最长 64 字符)。设备声明挂载到 @@ -172,21 +173,9 @@ mise exec node@22.23.1 -- pnpm --package=eas-cli@22.0.0 dlx eas build --platform EAS `preview` profile 固定 Node 22.23.1、`APP_VARIANT=preview`、preview environment 与 APK 输出。EAS 环境中的 `EXPO_PUBLIC_*` 都会进入客户端,不能存放凭证、token 或私钥; -`EXPO_PUBLIC_GATEWAY_ORIGIN` 只可作为非秘密 URL 预置,首页本机 URL 配置优先。未配置 media/link +`EXPO_PUBLIC_GATEWAY_ORIGIN` 只可作为非秘密 URL 预置,本机连接配置优先。未配置 media/link 变量时,相应能力保持 unavailable。 -Android development debug APK 构建完成、API 36 emulator 已启动且另一个终端正在运行 `pnpm start` -时,可以执行可重复 UI smoke: - -```bash -pnpm verify:android:emulator -``` - -该脚本会卸载 emulator 中的 dev application id 后重新安装 APK,并验证安装后权限、首页状态、动态 -能力、local-only 通知/timer 边界、紧急停用重启持久化、六个标签页的唯一语义,以及关键页面在 200% -系统字号下的名称、选中状态和操作最小尺寸;不会操作 preview/production 包,也不替代 TalkBack、VoiceOver -或真机验收。 - Android 需要 Java 17;iOS 需要 macOS、Xcode 26.4+ 与 CocoaPods。涉及 push、后台、相机、音频、 位置或权限的功能仍必须按 [DOD](llmdoc/delivery/definition-of-done.mdx) 留下双端真机证据。 diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 91220a4..d2c225c 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,9 +1,9 @@ import { Tabs } from 'expo-router' -import { Platform } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Icon } from '@/ui/components/Icon' import { TAB_ICONS, TAB_OPTIONS } from '@/ui/navigation' -import { colors } from '@/ui/theme' +import { useTheme } from '@/ui/theme' import type { IconName } from '@/ui/components/Icon' import type { ColorValue } from 'react-native' @@ -15,24 +15,29 @@ function tabIcon(inactive: IconName, active: IconName) { } export default function TabsLayout() { + const { colors } = useTheme() + const insets = useSafeAreaInsets() return ( @@ -43,13 +48,6 @@ export default function TabsLayout() { tabBarIcon: tabIcon(TAB_ICONS.index.inactive, TAB_ICONS.index.active), }} /> - { void setControlMode('disabled') }} onEnable={() => { void setControlMode('ask_every_time') }} + onOpenActivity={() => { router.navigate('/activity') }} onOpenCapabilities={() => { router.navigate('/capabilities') }} - onOpenCameraSettings={() => { void openCameraSettings() }} + onOpenConnection={() => { router.navigate('/connection') }} + onOpenControls={() => { router.navigate('/controls') }} onOpenMedia={() => { router.navigate('/media') }} - onOpenNotificationSettings={() => { void openNotificationSettings() }} onOpenStatus={() => { router.navigate('/status') }} - onRequestNotificationPermission={() => { void requestNotificationPermission() }} - onRequestCameraPermission={() => { void requestCameraPermission() }} - onSaveGatewayConfiguration={saveGatewayConfiguration} - onSetBackgroundRuntime={enabled => { void setBackgroundRuntimeEnabled(enabled) }} - onSetControlMode={mode => { void setControlMode(mode) }} snapshot={snapshot} /> ) diff --git a/app/_layout.tsx b/app/_layout.tsx index 6ffe0e5..0b6e083 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,7 +4,7 @@ import { StatusBar } from 'expo-status-bar' import { RuntimeProvider, useRuntime } from '@/runtime/RuntimeProvider' import { CameraCaptureModal } from '@/ui/components/CameraCaptureModal' import { PendingConfirmationModal } from '@/ui/components/PendingConfirmationModal' -import { colors } from '@/ui/theme' +import { useTheme } from '@/ui/theme' export default function RootLayout() { return ( @@ -15,6 +15,7 @@ export default function RootLayout() { } function RootContent() { + const { colors, isDark } = useTheme() const { approveConfirmation, failCameraCapture, @@ -24,7 +25,7 @@ function RootContent() { } = useRuntime() return ( <> - + { router.back() }} onClearAuditHistory={clearAuditHistory} records={snapshot.auditRecords} /> diff --git a/app/connection.tsx b/app/connection.tsx new file mode 100644 index 0000000..d217382 --- /dev/null +++ b/app/connection.tsx @@ -0,0 +1,18 @@ +import { router, useIsFocused } from 'expo-router' + +import { useRuntime } from '@/runtime/RuntimeProvider' +import { ConnectionSettingsScreen } from '@/ui/screens/ConnectionSettingsScreen' + +export default function ConnectionSettingsRoute() { + const focused = useIsFocused() + const { clearGatewayConfiguration, saveGatewayConfiguration, snapshot } = useRuntime() + return ( + { router.back() }} + onClear={clearGatewayConfiguration} + onSave={saveGatewayConfiguration} + snapshot={snapshot} + /> + ) +} diff --git a/app/controls.tsx b/app/controls.tsx new file mode 100644 index 0000000..7a8a194 --- /dev/null +++ b/app/controls.tsx @@ -0,0 +1,32 @@ +import { router, useIsFocused } from 'expo-router' + +import { useRuntime } from '@/runtime/RuntimeProvider' +import { ControlSettingsScreen } from '@/ui/screens/ControlSettingsScreen' + +export default function ControlSettingsRoute() { + const focused = useIsFocused() + const { + openCameraSettings, + openNotificationSettings, + requestCameraPermission, + requestNotificationPermission, + setBackgroundRuntimeEnabled, + setControlMode, + snapshot, + } = useRuntime() + return ( + { router.back() }} + onEmergencyDisable={() => { void setControlMode('disabled') }} + onEnable={() => { void setControlMode('ask_every_time') }} + onOpenCameraSettings={() => { void openCameraSettings() }} + onOpenNotificationSettings={() => { void openNotificationSettings() }} + onRequestNotificationPermission={() => { void requestNotificationPermission() }} + onRequestCameraPermission={() => { void requestCameraPermission() }} + onSetBackgroundRuntime={enabled => { void setBackgroundRuntimeEnabled(enabled) }} + onSetControlMode={mode => { void setControlMode(mode) }} + snapshot={snapshot} + /> + ) +} diff --git a/eslint.config.mjs b/eslint.config.mjs index b578494..818ba33 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,12 +6,15 @@ export default defineConfig([ { ignores: [ '.expo/**', + '.llmdoc-tmp/**', 'android/**', 'coverage/**', 'dist/**', 'ios/**', 'node_modules/**', ], + }, + { rules: { 'import/order': ['error', { alphabetize: { caseInsensitive: true, order: 'asc' }, diff --git a/llmdoc/capabilities/architecture.mdx b/llmdoc/capabilities/architecture.mdx index 080edbd..970faf6 100644 --- a/llmdoc/capabilities/architecture.mdx +++ b/llmdoc/capabilities/architecture.mdx @@ -158,13 +158,14 @@ adapter。registry 在展示与执行前读取真实 probe,并在返回 SDK - SQLite v4 专用 `inbox_messages` 以 source command unique key 和 `inbox_ + SHA-256(commandId)` 去重; insert/回读/裁剪在同一 exclusive transaction,写时维持 1,000 条硬上限。v3 旧行迁移为 markdown/normal, sentAt 保持 NULL。 -- repository 在用户明确提交搜索后,对全部保留集参数化查询 title/body/sourceLabel/caller,按六值本地枚举 +- repository 在用户明确提交搜索后,对全部保留集参数化查询 title/body/sourceLabel/caller,并按本地视图选项 + 过滤未读;过滤后按六值本地枚举 排序后最多投影 100 条;支持单条/全表 mark-read。sentAt 缺值时只为排序 fallback 到 receivedAt,不补造 显示字段。 - Markdown 由 `markdown-it/browser` 产出 token,再经 React Native 白名单 renderer;不用 WebView/HTML/ JavaScript,裸 URL 不 linkify。只有显式且通过本地 policy 的 HTTPS 链接才在用户点按后交给系统; 该路径不复用或改变 media、`phone/apps` 的 hostname allowlist。渲染另限制 nesting、600 tokens - 和每条 4 张图片,默认三行摘要且一次只展开一条。 + 和每条 4 张图片;列表只展示纯文本摘要,正文在单条详情页查看。 - 图片展开和点击前零网络;每次点按只授权该图片的一次请求和有界 redirect 链。任意 hostname 只要逐跳/ 最终满足标准端口 HTTPS、无 userinfo/fragment/IP literal 即可;安全的跨 hostname redirect 允许继续。 resolver 使用 `credentials: omit` + manual redirect,并限制 3 次 redirect、20 秒、PNG/JPEG、3 MiB、 diff --git a/llmdoc/capabilities/bounded-linking-handoffs.mdx b/llmdoc/capabilities/bounded-linking-handoffs.mdx index aa815f7..6fb8515 100644 --- a/llmdoc/capabilities/bounded-linking-handoffs.mdx +++ b/llmdoc/capabilities/bounded-linking-handoffs.mdx @@ -84,8 +84,8 @@ Android merged manifest 为地图 handler discovery 增加 `` 中的 `V - unit 与 local runtime contract 覆盖 schema/builder、confirmation、probe、取消/到期 zero-open、脱敏和 重复 `commandId` 单次 handoff;它们使用注入 adapter,不是 OS/第三方 App E2E。 -- Android clean build 只能证明对应 commit 的 package visibility query 可合并;emulator smoke 只断言能力页 - 可发现 `open_map`,没有实际调用 `Linking.openURL`。 +- Android clean build 只能证明对应 commit 的 package visibility query 可合并;能力页可见 `open_map` + 不证明实际调用 `Linking.openURL` 或系统交接成功。 - iOS simulator、双端 native build 与真机 handoff 必须按当前 evidence 记录判断;没有对应证据时,不能 声称 provider 选择、地图显示、导航结果或后台/锁屏行为已验收。 - production transport、mailbox/revoke 与 `open_map` 本地 handler 是不同层,仍受上游阻塞。 diff --git a/llmdoc/capabilities/local-device-inbox.mdx b/llmdoc/capabilities/local-device-inbox.mdx index 488c575..170a684 100644 --- a/llmdoc/capabilities/local-device-inbox.mdx +++ b/llmdoc/capabilities/local-device-inbox.mdx @@ -21,6 +21,7 @@ code: - src/storage/deviceMailboxJournalRepository.ts - src/runtime/applicationRuntime.ts - src/ui/screens/InboxScreen.tsx + - src/ui/screens/InboxMessageScreen.tsx - src/ui/components/SafeMarkdown.tsx - src/capabilities/productivity/notificationAdapter.ts - app.config.ts @@ -127,11 +128,15 @@ SQLite schema v5 另增 `device_operation_journal`,字段只有 - 排序只能来自本地六值枚举:`received_desc`、`received_asc`、`sent_desc`、`sent_asc`、 `unread_first`、`read_first`。收件/发送时间用 message id 确定性打破平局;未读/已读优先组内按收件时间 倒序。 -- 默认是空搜索 + `received_desc`。输入搜索词不会逐键触发查询,用户须按键盘 search 或“搜索”按钮明确 - 提交;搜索与排序是进程内 view option,不写入 SQLite。“最近半小时”只按当前最多 100 条结果的 - `receivedAt` 计算展示计数,不是 retention 或投递时限。 -- 单条 mark-read 只更新仍未读的目标;mark-all 对表内全部保留消息执行,不受当前搜索或 100 条投影限制, +- 默认是空搜索 + `received_desc` + 全部消息。内部 `InboxViewOptions.unreadOnly` 只有 `true` 启用未读 + 过滤,省略或 `false` 规范化为全部消息;它是本地视图选项,不增加远程 capability 输入字段。 +- 搜索、未读过滤与排序都先应用于全部保留行,最后才限制 UI 投影数量;SQLite 与内存实现保持这一顺序, + 不能从最近 100 条中再筛选未读,否则较早未读消息会被近期已读消息挤掉。 +- 输入搜索词不会逐键触发查询,用户须明确提交;搜索、排序与未读过滤是进程内 view option,不写入 SQLite。 +- 单条 mark-read 只更新仍未读的目标;mark-all 对表内全部保留消息执行,不受当前搜索、未读过滤或 100 条投影限制, 并返回 SQLite 实际 changes。全局未读数也查询整个表。 +- 本地操作 sheet 明示全部已读/清空的作用域包含当前筛选外的消息;清空仍需独立的范围确认,不因收进 + sheet 而直接删除,也不改变命令防重放与其他数据域。 ## Markdown 与图片加载 @@ -149,7 +154,7 @@ raw HTML 只作为文字,未知 token 不能获得任意组件或属性。 校验。系统拒绝时只显示本地通用失败提示,不在提示中回显不受信 URL 或 hostname。这是用户 主动的系统链接交接,不允许远程投递自动打开 App,也不是任意 App UI 自动化;它不改变 `phone/apps.open_url`、media 或 link 构建时 hostname allowlist。 -- 列表默认只展示三行去标记纯文本摘要;页面一次只展开一条消息。展开正文只生成图片 alt/hostname 与 +- 列表只展示去标记纯文本摘要,用户进入单条详情页后查看 Markdown 正文。正文只生成图片 alt/hostname 与 “加载图片”按钮,每条最多 4 张;列表、搜索、排序、摘要和仅展开 Markdown 都不发起图片网络请求。 - 只有用户点按具体图片后才调用 resolver;每次点按只授权该图片的一次受控出站请求及其有界 redirect 链, 不形成 hostname 持久信任、不授权其他图片或自动预取,失败后重试也需要再次点按。 @@ -193,7 +198,7 @@ push token,不是 remote notification 或 U-6 wake hint。 服务端数据或调用通知取消 API。 - command 防重放记录与内容生命周期分离。用户清空后,同一 `commandId` replay 返回原持久化终态,handler 不再执行,因此不会重建已清空消息;crash 遗留 running command 恢复为 `result_unknown` 时也不重放写入。 -- `ApplicationRuntime` 为 inbox 维护独立 revision。search/sort view option、clear、单条/全部 mark-read 以及 +- `ApplicationRuntime` 为 inbox 维护独立 revision。search/sort/unread view option、clear、单条/全部 mark-read 以及 成功 commit 的回调都会递增;refresh 捕获 revision,并在组合发布查询结果与全局未读数前复检,拒绝旧 查询或写操作之前启动的迟到 snapshot。 diff --git a/llmdoc/capabilities/local-notifications.mdx b/llmdoc/capabilities/local-notifications.mdx index 7ae29bb..a71c772 100644 --- a/llmdoc/capabilities/local-notifications.mdx +++ b/llmdoc/capabilities/local-notifications.mdx @@ -116,8 +116,8 @@ Android 仍声明 `POST_NOTIFICATIONS`,因为本地通知需要系统授权。 脱敏和 `scheduled` 语义;信箱另覆盖 commit 后提醒与失败不回滚。它们使用注入 adapter,不是系统 UI E2E。 - registry contract 明确只有 `phone/inbox.deliver` 投影 `delivery: both`,`phone/productivity.notify` 没有 `delivery` metadata;这不证明 mailbox Gateway path 或本地通知已在真机呈现。 -- Android clean build 与 merged manifest 只能证明对应 commit 的 local-only final config 可生成;emulator - smoke 只证明 fresh permission 映射和 capability 投影,未请求权限、调度、呈现或点击通知。 +- Android clean build 与 merged manifest 只能证明对应 commit 的 local-only final config 可生成;权限状态与 + capability 投影的界面检查不证明实际请求权限、调度、呈现或点击通知。 - iOS/Android 的 simulator、native build 与真机结果必须按当前 evidence 记录判断;APNs entitlement 静态 移除不等于 iOS local notification 行为已验证。 diff --git a/llmdoc/capabilities/local-timers.mdx b/llmdoc/capabilities/local-timers.mdx index 53b269b..97589e4 100644 --- a/llmdoc/capabilities/local-timers.mdx +++ b/llmdoc/capabilities/local-timers.mdx @@ -80,7 +80,7 @@ reconcile 的稳定规则: `accuracy: system_determined`。cancel 只表示定向清理路径完成;status 是当前数据库/pending 快照。 - timer 的任何 result、UI 或审计都不得声称 fired、delivered、presented、clicked 或 on-time。 - Node/Jest 证明本地状态机与竞态补偿;Android/iOS clean build 仅在对应当前证据存在时证明原生配置可编译; - emulator smoke 只证明 capability 可见。它们都不证明实际系统 schedule、双端真机、Doze、reboot、 + 能力页的可见性检查只证明 capability 投影。它们都不证明实际系统 schedule、双端真机、Doze、reboot、 呈现或准时性。 ## 事实真源 diff --git a/llmdoc/delivery/definition-of-done.mdx b/llmdoc/delivery/definition-of-done.mdx index be2d0e3..afa549a 100644 --- a/llmdoc/delivery/definition-of-done.mdx +++ b/llmdoc/delivery/definition-of-done.mdx @@ -93,7 +93,7 @@ pairing、短期 ticket、真实 Gateway、mailbox/push 或撤销闭环。 Direct 仍有可见预览;结果只有受保护对象引用,Gateway 保留/读取授权、失败脱敏与 crash 孤儿清理有效。 - **位置**:确认和权限之后才采集;时间、精度、approximate/mocked 状态诚实;stale fix 被拒绝;后台不 升级为持续定位;地图 handoff 不回显目标或声称导航完成。 -- **设备信箱**:在线 call 的 schema、专用有界表、全保留集搜索/排序、已读、Markdown 白名单、逐图有界 +- **设备信箱**:在线 call 的 schema、专用有界表、全保留集搜索/未读过滤/排序、已读、Markdown 白名单、逐图有界 加载、显式 HTTPS 链接的点按后系统交接/不合规拒绝/失败反馈、内容清除与防重放都通过;真实 Gateway、双端真机的链接交互/提醒和 U-5/U-6 离线路径分别验收。 - **local notification**:固定内容、权限只在本地请求、前台/授权/channel availability、幂等和脱敏通过; @@ -101,7 +101,8 @@ pairing、短期 ticket、真实 Gateway、mailbox/push 或撤销闭环。 - **timer**:输入窗口、SQLite ownership/capacity、确定性 identifier、补偿/恢复/reconcile/disabled 通过; 双端真机覆盖后台、锁屏、kill、低电量、权限撤销与到点 cancel,Android 另覆盖 reboot/Doze。 - **Activity 与 accessibility**:近期本地 audit 与仅审计清除不影响防重放/凭证;语义、焦点、target size、 - 对比度和公告脱敏通过自动化,TalkBack/VoiceOver/Switch Access/Dynamic Type 仍需双端人工证据。 + 双主题实际文字/交互边界对比度和公告脱敏通过自动化;系统外观切换保留未提交表单与交互状态。 + TalkBack/VoiceOver/Switch Access/Dynamic Type 仍需双端人工证据。 各能力的精确限值、状态机和已知剩余风险由 `capabilities/` 与 `runtime/` 专题文档维护,避免在 DOD 中 复制会漂移的实现细节。 diff --git a/llmdoc/delivery/engineering-baseline.mdx b/llmdoc/delivery/engineering-baseline.mdx index 97a5888..083354b 100644 --- a/llmdoc/delivery/engineering-baseline.mdx +++ b/llmdoc/delivery/engineering-baseline.mdx @@ -135,7 +135,7 @@ APNs/FCM 及不需要的 badge/remote transport。每次 Expo 模块升级都要 | React UI | React Native Testing Library:确认、权限、错误和 accessibility 语义。 | | 原生模块 | Android native test / XCTest:平台 adapter;JS mock 不替代。 | | consumer contract | 官方 SDK + fake WebSocket/fetch:realtime 公共入口、mailbox claim/complete/journal barrier 和本地适配;不等于真实 Gateway。 | -| UI E2E | Maestro 或仓库 smoke:驱动界面;不证明硬件真实发声、拍照、后台或 push。 | +| 页面交互 | 驱动界面并记录操作结果;不证明硬件真实发声、拍照、后台或 push。 | | 真机矩阵 | Android/iOS 设备 + 脱敏日志:权限、后台、锁屏、DND、弱网、系统物理效果。 | PR 至少执行 frozen install 与仓库 `pnpm verify`;配置/依赖还需双端 clean build,平台/敏感能力还需对应 diff --git a/llmdoc/delivery/verification-and-claims.mdx b/llmdoc/delivery/verification-and-claims.mdx index 871a0a7..cb72824 100644 --- a/llmdoc/delivery/verification-and-claims.mdx +++ b/llmdoc/delivery/verification-and-claims.mdx @@ -14,7 +14,6 @@ code: - package.json - scripts/verify-docs.mjs - scripts/verify-app-config.mjs - - scripts/verify-android-emulator.mjs --- # 验证与结论写作指南 @@ -40,10 +39,6 @@ Android 权限变更需要同时检查三层:Expo config introspection、clean 安装后的 `dumpsys package`。`blockedPermissions` 能移除依赖 manifest 声明,但 development debug manifest 仍可能以更高优先级重新引入权限;不能只凭 config 输出作最终结论。 -已有 debug APK、唯一且完成 boot 的 emulator 和运行中的 Metro 时,执行 -`pnpm verify:android:emulator`。该脚本只操作 dev application id,并验证安装后权限、首页、动态能力和 -紧急停用重启持久化;这属于 emulator UI smoke,不是原生 instrumentation 或真机证据。 - 要交付不依赖 Metro 的内部 Android 安装包,使用 `pnpm build:android:preview`,并验证 package 是 `ai.tokenroll.toolbridgemobile.preview`、APK 含 `assets/index.android.bundle`、签名可验证且显式 MainActivity 能冷启动。当前 release build 使用 debug test key,只能称为 preview/internal artifact; diff --git a/llmdoc/delivery/verification-evidence.mdx b/llmdoc/delivery/verification-evidence.mdx index 080b857..9956998 100644 --- a/llmdoc/delivery/verification-evidence.mdx +++ b/llmdoc/delivery/verification-evidence.mdx @@ -193,11 +193,7 @@ clean prebuild/autolinking/Metro export 只证明生成配置或 bundle;Gradle 可编译;模拟器 build 不证明真机签名、权限和物理行为。构建受网络或本机工具链阻塞时,记录已完成层级, 不要把 prebuild 提升为 native build。 -## Android emulator 与 Preview APK - -在已有 development debug APK、唯一且完成 boot 的 emulator 与运行中的 Metro 条件下,可执行 -`pnpm verify:android:emulator`。它只操作 development application id,验证安装后权限、页面/语义、动态能力、 -紧急停用与重启等 UI smoke;它不是 native instrumentation、真机或另一平台证据。 +## Android Preview APK 内部 Android 离线安装包使用 `pnpm build:android:preview`。验收至少确认: diff --git a/llmdoc/integration/manual-gateway-configuration.mdx b/llmdoc/integration/manual-gateway-configuration.mdx index 718a465..dd94906 100644 --- a/llmdoc/integration/manual-gateway-configuration.mdx +++ b/llmdoc/integration/manual-gateway-configuration.mdx @@ -17,13 +17,15 @@ code: - src/gateway/sdkDeviceTransport.ts - src/runtime/applicationRuntime.ts - src/ui/components/GatewayConfigurationCard.tsx + - src/ui/screens/ConnectionSettingsScreen.tsx + - app/connection.tsx --- # 手工 Gateway 配置参考 ## 当前用途 -正式 pairing/U-2 尚未交付时,首页允许用户手工输入 Gateway HTTPS origin 与 API key,直接驱动 +正式 pairing/U-2 尚未交付时,设备总览的“连接配置”二级页允许用户手工输入 Gateway HTTPS origin 与 API key,直接驱动 `@tool-bridge/sdk/device@0.21.0` realtime 连接和 active-only mailbox drain。它是内测 fallback,不是 pairing、设备凭证签发、最小 scope、 rotation/revoke 或 U-3 短期 ticket。 @@ -74,8 +76,8 @@ realtime/mailbox transports.updateConfiguration(null) -> apply current AppState / control mode ``` -SecureStore 写入或删除失败时不得自动重连;错误文案不包含底层 secret。Disabled 状态允许本地保存或 -清除,但新 transport 保持 suspended,直到用户恢复控制模式。 +SecureStore 写入或删除失败时不得自动重连;错误文案不包含底层 secret。控制器在 Disabled 状态允许本地保存或 +清除,但新 transport 保持 suspended,直到用户恢复控制模式;当前连接配置 UI 在停用态隐藏表单并显示恢复提示。 ## 代码位置 diff --git a/llmdoc/integration/sdk-device-transport.mdx b/llmdoc/integration/sdk-device-transport.mdx index da3173f..271bdce 100644 --- a/llmdoc/integration/sdk-device-transport.mdx +++ b/llmdoc/integration/sdk-device-transport.mdx @@ -67,7 +67,7 @@ code: 只有当前 call context 含未过期 upload capability 时,transport 才把窄 `call.uploadObject` 作为内存 `CapabilityInvocationServices` 下传,token 不进入本地 command、SQLite 或日志。 -首页现已提供手工 HTTPS origin + API key 内测入口。它经 +设备总览的“连接配置”页提供手工 HTTPS origin + API key 内测入口。它经 `ManualGatewayConfigurationController` 按“停止旧连接 -> 写/清 SecureStore -> 应用新 origin”切换,失败时 保持断开。输入、身份、存储与证据边界见 `llmdoc/integration/manual-gateway-configuration.mdx`。 diff --git a/llmdoc/integration/trusted-grants.mdx b/llmdoc/integration/trusted-grants.mdx index 7170f39..2928a51 100644 --- a/llmdoc/integration/trusted-grants.mdx +++ b/llmdoc/integration/trusted-grants.mdx @@ -26,8 +26,8 @@ code: ## 背景 -当前 `trusted_session` 只是 SQLite `settings` 中的全局 control mode 字符串,没有生产 UI、TTL、 -capability scope 或 credential-instance binding。0.21.0 SDK call 已提供网关签发的 caller 与权威期限, +当前 `trusted_session` 只是 SQLite `settings` 中的全局 control mode 字符串,本地授权 UI 可以选择该模式, +但没有 TTL、capability scope 或 credential-instance binding。0.21.0 SDK call 已提供网关签发的 caller 与权威期限, 移动端以 `caller.keyId` 作为稳定调用主体;该字段仍不是当前 device credential identity/generation。 产品方向已经明确:用户信任的是与设备建立认证关系的 Gateway Credential,而不是经该 Gateway 发起调用的 diff --git a/llmdoc/integration/upstream-and-platform-gaps.mdx b/llmdoc/integration/upstream-and-platform-gaps.mdx index 33dbc47..cf2ba26 100644 --- a/llmdoc/integration/upstream-and-platform-gaps.mdx +++ b/llmdoc/integration/upstream-and-platform-gaps.mdx @@ -53,7 +53,7 @@ lifecycle。精确用法与证据边界见 `llmdoc/integration/sdk-device-transp - 真实 gateway compatibility matrix、CLI/管理入口与端到端 revoke。 因此可以声称“官方 SDK 前台 consumer wiring 已集成”,不能声称“已配对”“真实 gateway 已兼容”或 -“后台可达”。用户现在可以在首页手工保存 HTTPS origin + API key 作为内测 fallback;它不提供设备 +“后台可达”。用户现在可以在设备总览下的“连接配置”页手工保存 HTTPS origin + API key 作为内测 fallback;它不提供设备 credential 的签发、最小 scope、rotation、revoke 或短期 ticket。没有 origin 为 `unconfigured`;有 origin 无 credential 为 `credentials_required`;只有收到真实 SDK ready 才是 online。 diff --git a/llmdoc/meta.json b/llmdoc/meta.json index 910db84..f2e75a3 100644 --- a/llmdoc/meta.json +++ b/llmdoc/meta.json @@ -6,79 +6,79 @@ }, "documents": { "architecture.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "capabilities/architecture.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/bounded-linking-handoffs.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "capabilities/bounded-media-source.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/foreground-camera-capture.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "capabilities/local-device-inbox.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/local-notifications.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "capabilities/local-timers.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/eas-project-binding.mdx": { "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" }, "delivery/evidence-language.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/github-preview-release.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/verification-and-claims.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "integration/manual-gateway-configuration.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "integration/sdk-device-transport.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "integration/trusted-grants.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "integration/upstream-and-platform-gaps.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "runtime/accessibility-semantics.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "runtime/activity-history.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "runtime/architecture.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "runtime/command-retention.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "runtime/safety-boundaries.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "product/requirements-and-roadmap.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/engineering-baseline.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/definition-of-done.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/verification-evidence.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" } }, "convergence": { diff --git a/llmdoc/runtime/accessibility-semantics.mdx b/llmdoc/runtime/accessibility-semantics.mdx index f83ce3e..44cf75c 100644 --- a/llmdoc/runtime/accessibility-semantics.mdx +++ b/llmdoc/runtime/accessibility-semantics.mdx @@ -1,5 +1,5 @@ --- -description: React Native 共享 UI 的可访问性语义、公告敏感边界及自动化证据上限。 +description: React Native 系统浅深主题、共享 UI 与导航的可访问性语义、公告敏感边界及自动化证据上限。 kind: reference relations: requires: @@ -12,24 +12,44 @@ code: paths: - src/ui/components/Screen.tsx - src/ui/components/StatusCard.tsx + - src/ui/components/SectionHeading.tsx - src/ui/components/AccessibleAction.tsx + - src/ui/components/ActionSheet.tsx - src/ui/components/PendingConfirmationModal.tsx - src/ui/components/CameraCaptureModal.tsx - src/ui/accessibility.ts - src/ui/navigation.ts + - src/ui/theme.ts + - app/_layout.tsx + - app/(tabs)/_layout.tsx + - app/(tabs)/settings.tsx + - src/ui/screens/SettingsScreen.tsx - src/ui/**/__tests__/** - - scripts/verify-android-emulator.mjs --- -# Accessibility semantics 自动化基线 +# 系统主题与 Accessibility semantics 基线 + +## 系统外观 + +- 已决定:系统外观是唯一主题来源,不保存独立主题偏好。共享语义色覆盖浅色和深色,页面、导航、 + 状态栏及全局弹窗同步跟随;切换主题只更新样式,不重挂载页面或运行时,不丢弃未提交表单与待确认状态。 +- 主操作和危险操作分别使用配对的前景/背景语义色;危险操作不能复用浅色面板文字颜色推断可读性。 + 颜色回归必须检查组件实际采用的文字组合,而不是未用于渲染的候选配色。 ## 共享语义 -- `Screen` 暴露唯一页面 header,并只在页面获得 focus 时请求辅助技术焦点;普通 snapshot/rerender 不 - 反复抢焦点。`StatusCard` title 是 header,`StatusRow` 将 label/value 合成单一可访问名称并隐藏重复的 +- `Screen` 暴露唯一页面主标题,并只在页面获得 focus 时请求辅助技术焦点;普通 snapshot/rerender 不 + 反复抢焦点。`StatusCard` 与 `SectionHeading` title 是 header,`StatusRow` 将 label/value 合成单一可访问名称并隐藏重复的 视觉子文本,同时允许系统字号和换行。 +- 常规页面的主标题与 toolbar 固定在内容滚动区之外;消息阅读页把主标题放入可滚动正文,避免长标题 + 挤压阅读空间,返回操作仍留在顶部。两种布局都保留同一主标题语义与 focus 规则。 - `AccessibleAction` 统一 button role、上下文唯一 label、必要 hint、busy/disabled state,以及至少 - 48dp 的 minWidth/minHeight。四个 tab 显式使用“状态/能力/媒体/活动标签页”唯一 label,并投影 selected。 + 48dp 的 minWidth/minHeight。主导航只保留信箱、设备两个 tab,显式使用“信箱/设备标签页” + 唯一 label,并投影 selected;信箱仍为落地首页。设备 tab 保留内部 `settings` route,作为连接、授权安全、 + 活动记录、状态、能力与媒体的总览入口,不把内部 route 名当作用户可见名称。活动是低频管理页面, + 从设备总览进入并可返回设备,不再占用底部 tab。 +- `ActionSheet` 仅承载用户主动打开的本地排序和批量操作;打开时聚焦标题,关闭后由组件或调用页恢复 + 触发控件焦点,忙碌时可禁止关闭。它不接收远程命令裁决,不能替代全局 `PendingConfirmationModal`。 - Activity destructive confirmation 打开时聚焦确认标题,取消/完成后返回清除触发按钮;组件测试覆盖 focus 往返和普通更新不抢焦点。 - 远程 command 的 `PendingConfirmationModal` 挂在 root layout,不受当前 tab 与首页滚动位置影响;打开时 @@ -49,16 +69,17 @@ code: announcement;全局 confirmation 只公告待处理数量。detail 可作为用户主动查看的 modal 视觉/语义内容, 但 action label 只包含 caller 与 capability。attention 倒计时和媒体 progress 只视觉更新,不改变 semantic key,因此不会高频播报。 -- 颜色回归 gate 要求普通文本对比至少 4.5:1,交互边界/控件文字至少 3:1;视觉状态不能只依赖颜色。 +- 浅色和深色的颜色回归 gate 都要求实际文字组合对比至少 4.5:1,交互边界至少 3:1;视觉状态不能只依赖颜色。 ## 自动化证据与上限 - component tests 覆盖 page/card header、StatusRow 关联、操作唯一名称/role/hint/state、48dp、focus、公告 去重及不公告高频/敏感内容;相机组件测试覆盖 Direct call 自动分支和非 Direct 快门/复核分支;theme test - 覆盖文本和交互边界 contrast gate。`SafeMarkdown` 组件测试另覆盖跨样式单链接、相邻链接和通用失败 + 覆盖双主题文本和交互边界 contrast gate,系统主题组件测试覆盖切换后保留未提交表单及危险操作实际配色。 + `SafeMarkdown` 组件测试另覆盖跨样式单链接、相邻链接和通用失败 `alert`;这不证明 TalkBack/VoiceOver 实际朗读或真机系统 handoff。 -- Android emulator semantic smoke 应验证 tab 的唯一 accessibility label/selected state,保存原字号后设为 - 200%,force-stop/relaunch,逐页滚动并操作 Activity clear/cancel/confirm,复核关键 action bounds 至少 +- 页面交互验收应检查两个 tab 的唯一 accessibility label/selected state,保存原字号后设为 + 200%,force-stop/relaunch,经设备总览进入活动记录并操作 clear/cancel/confirm、返回设备,复核关键 action bounds 至少 48dp,最后恢复原字号。 - UIAutomator 只观察语义树、selected state、bounds 和点击,不能替代 Android TalkBack、VoiceOver、 Switch Access、iOS Dynamic Type 或双端真机人工证据;不能据此声称实际朗读、手势/rotor 顺序、平台 @@ -71,8 +92,9 @@ code: - primitives:`src/ui/components/Screen.tsx`、`StatusCard.tsx`、`AccessibleAction.tsx` - focus/announcement:`src/ui/accessibility.ts` - tab labels:`src/ui/navigation.ts` +- system theme:`src/ui/theme.ts`、`app/_layout.tsx`、`app/(tabs)/_layout.tsx` - tests:`src/ui/components/__tests__/accessibilityComponents.test.tsx`、 `src/ui/components/__tests__/PendingConfirmationModal.test.tsx`、`src/ui/__tests__/accessibility.test.tsx`、 `src/ui/components/__tests__/CameraCaptureModal.test.tsx`、`src/ui/components/__tests__/SafeMarkdown.test.tsx`、 - `src/ui/__tests__/themeContrast.test.ts` + `src/ui/__tests__/themeContrast.test.ts`、`src/ui/__tests__/systemTheme.test.tsx` - 验收:`llmdoc/delivery/definition-of-done.mdx`、`llmdoc/delivery/verification-evidence.mdx` diff --git a/llmdoc/runtime/activity-history.mdx b/llmdoc/runtime/activity-history.mdx index f91236b..719d206 100644 --- a/llmdoc/runtime/activity-history.mdx +++ b/llmdoc/runtime/activity-history.mdx @@ -1,5 +1,5 @@ --- -description: 本地 Activity 审计投影、5,000 条保留上限、仅审计清除与防重放并发边界。 +description: 设备总览下活动记录入口、本地 Activity 审计投影、5,000 条保留上限、仅审计清除与防重放并发边界。 kind: reference relations: requires: @@ -15,14 +15,16 @@ code: - src/storage/auditRepository.ts - src/runtime/applicationRuntime.ts - src/ui/screens/ActivityScreen.tsx + - app/activity.tsx - test/contract/audit-history.contract.test.ts - - scripts/verify-android-emulator.mjs --- # 本地 Activity 与仅审计历史清除 ## 展示与保留 +- 已决定:活动记录是低频设备管理页面,通过设备总览进入 `app/activity.tsx`,并提供返回设备的操作; + 不作为底部 tab。入口迁移不改变审计查询、清除或防重放语义。 - Activity 页面按 occurredAt 倒序展示最近 100 条本地 audit metadata:caller subject id、occurredAt、 path/tool、effect/risk、decision 与 outcomeCode。 - 页面不展示 command arguments、完整 outcome、commandId、坐标、URL、message/purpose、credential 或 @@ -54,16 +56,16 @@ code: - repository/component/local contract 覆盖写时裁剪、clear 的唯一 SQL、真实删除数、失败显示、DELETE 后 新记录,以及“副作用一次 -> clear -> 同 id replay 仍一次并新增 replayed audit”;runtime 代码复核确认 revision 会阻止 clear 前启动的 refresh 回写旧列表。 -- Android emulator fresh-install smoke 应覆盖 Activity 的 100/5,000 范围文案、打开确认后取消、再次确认及 - 真实“已清除 0 条”结果。它没有生成有记录的 SQLite clear,也不是 iOS、真机或服务端审计证据。 +- 页面交互验收应从设备总览进入 Activity,覆盖 100/5,000 范围文案、打开确认后取消、再次确认和返回设备。 + 空历史的真实“已清除 0 条”结果不能替代有记录的 SQLite clear,也不构成另一平台或服务端审计证据。 ## 事实真源 - audit model/上限:`src/audit/types.ts` - SQLite repository:`src/storage/auditRepository.ts` - runtime revision:`src/runtime/applicationRuntime.ts` -- Activity UI:`src/ui/screens/ActivityScreen.tsx` +- Activity UI:`app/activity.tsx`、`src/ui/screens/ActivityScreen.tsx` - replay contract:`test/contract/audit-history.contract.test.ts` - 产品与验收:`llmdoc/product/requirements-and-roadmap.mdx`、 `llmdoc/delivery/definition-of-done.mdx` -- emulator 证据方法:`llmdoc/delivery/verification-evidence.mdx` +- 证据记录方法:`llmdoc/delivery/verification-evidence.mdx` diff --git a/llmdoc/runtime/architecture.mdx b/llmdoc/runtime/architecture.mdx index 0d606b3..c22d437 100644 --- a/llmdoc/runtime/architecture.mdx +++ b/llmdoc/runtime/architecture.mdx @@ -65,7 +65,7 @@ transport 变化产生隐蔽副作用或泄漏敏感参数。 - `src/gateway/sdkDeviceMailboxTransport.ts`:组装官方 `createDeviceMailboxProcessor`,把 registry 中 `delivery: both` 的 operation 经 claim/lease/complete 送入同一个 executor;只在初始化/配置完成且 active 或回到 active 时触发一次最多 20 项的 drain,不用 timer 触发新 drain,也没有 push 或后台轮询。 -- `src/gateway/manualGatewayConfigurationController.ts`:协调首页手工 Gateway URL/API key 的配置切换; +- `src/gateway/manualGatewayConfigurationController.ts`:协调“连接配置”页手工 Gateway URL/API key 的配置切换; 保存和清除都先停止旧 transport,SecureStore 失败时保持断开。 - `src/runtime/localCommandExecutor.ts` (`LocalCommandExecutor`):唯一的本地 command 执行入口和安全顺序。 - `src/capabilities/runtime/runtimeCapabilities.ts`:按当前 gateway credential principal 投影活动命令并请求 @@ -158,7 +158,8 @@ mailbox 在上述本地执行链之外还增加一层 SDK operation journal: ## 控制模式、Direct call 与 `when_locked` 的当前边界 -- 设置页当前提供 `ask_every_time`、`trusted_session` 与 `direct_call`,并可紧急切换到 `disabled`。 +- 设备总览下的“授权与安全”页提供 `ask_every_time`、`trusted_session` 与 `direct_call`,并可紧急切换到 `disabled`; + 设备总览也保留紧急停用/恢复入口。 `trusted_session` 只跳过低/中风险逐次确认;`direct_call` 会跳过包括 high-risk `always` 在内的逐次确认。 - 当前存储只是一个持久化的全局 control mode 字符串;没有 TTL、capability scope 或已认证 Gateway Credential 实例绑定。因此 `trusted_session` 和 `direct_call` 都不是未来正式 credential-bound grant, @@ -200,16 +201,16 @@ mailbox 在上述本地执行链之外还增加一层 SDK operation journal: - `inbox_messages` 是 Markdown 正文专用域。v4 为 v3 旧行设置 `format = markdown`、`urgency = normal`, 并保留 `sent_at = NULL`;receivedAt 是本机事实,sentAt 是可选 Agent 元数据,发送时间排序可以 fallback 到 receivedAt,但不能写回或冒充 Agent 时间。 -- repository 先在全部 1,000 条保留集上参数化搜索 title/body/sourceLabel/caller,再用六值本地枚举生成 +- repository 先在全部 1,000 条保留集上参数化搜索 title/body/sourceLabel/caller 并应用可选未读过滤,再用六值本地枚举生成 收件/发送/已读排序 SQL,最后投影最多 100 条;搜索由用户明确提交,不按输入字符实时查询。mark-all 与 - 未读总数始终针对全表,不受当前搜索结果影响。 + 未读总数始终针对全表,不受当前搜索或未读过滤结果影响。 - clear 的唯一 SQL 是 `DELETE FROM inbox_messages`;commands/audit/timers/settings/identity/credential 保持不变,也不撤销 gateway、删除服务端数据或取消通知。command 终态保留,因此同一 commandId replay 不再次执行 handler,也不会重建已清空内容。 - mark-read 只更新仍未读的目标,mark-all 返回 SQLite 实际 changes。`ApplicationRuntime` 使用独立 inbox - revision;search/sort view option、clear、单条/全部 mark-read 和消息 commit 都先使旧 refresh 失效,再 + revision;search/sort/unread view option、clear、单条/全部 mark-read 和消息 commit 都先使旧 refresh 失效,再 从数据库组合刷新查询结果与全局未读数,避免迟到 snapshot 覆盖新状态。 -- 默认列表只显示三行纯文本摘要,且一次只展开一条 Markdown。展开只创建图片占位,点按前 resolver 零调用; +- 列表只显示纯文本摘要,单条详情页展示 Markdown 正文。正文只创建图片占位,点按前 resolver 零调用; 每次点按只授权该图片的一次请求与 redirect 链。resolver 允许逐跳合规的跨 hostname HTTPS redirect; 已解析图片在取消、失败、Image error 或组件卸载时释放私有 cache。 - 裸 URL 不 linkify,不合规 Markdown href 不产生 `link`。显式合规 HTTPS 在 render 时校验,只在用户 diff --git a/llmdoc/runtime/safety-boundaries.mdx b/llmdoc/runtime/safety-boundaries.mdx index 8339e68..1bd5712 100644 --- a/llmdoc/runtime/safety-boundaries.mdx +++ b/llmdoc/runtime/safety-boundaries.mdx @@ -1,5 +1,5 @@ --- -description: 远程命令、本地裁决、凭证、敏感数据、平台权限与能力执行不可绕过的安全边界。 +description: 本机授权与权限入口、远程命令裁决、凭证、敏感数据及能力执行不可绕过的安全边界。 kind: architecture relations: related: @@ -13,6 +13,8 @@ relations: code: paths: - app.config.ts + - app/controls.tsx + - src/ui/screens/ControlSettingsScreen.tsx - src/runtime/** - src/capabilities/** - src/inbox/** @@ -28,6 +30,8 @@ code: - 高风险能力默认拒绝,并声明 effect、risk、confirmation、前后台条件、queue policy 与平台支持。 - 不实现隐藏录音/拍摄、绕过锁屏、后台常驻监控或任意第三方 App UI 自动化。 - capability 必须由实际 probe 得出;不能只按 OS 名称推断,也不能以模拟成功替代 unavailable。 +- 设备总览下的“授权与安全”页根据 capability snapshot 的 status/reason 展示可请求或需去系统设置的权限入口; + 页面迁移不能改成按平台名称猜权限,也不能使远程命令自动触发系统授权。 - 副作用前必须完成 runtime schema、过期/取消、probe、policy、所需确认与持久化 claim。 - 用户确认后仍要重新检查过期、取消、probe 和 policy,避免等待期间状态变化造成越权执行。 - SQLite claim 返回后必须最后复检取消/到期;每个本地 capability 的 admission 必须在 confirmation 前 @@ -142,8 +146,9 @@ code: push 或后台轮询;本地 notification 或 Android 后台服务不能充当 push wake hint 或最终送达证据。 - 本地 `inbox_messages` 是用户内容域,Gateway operation mailbox 是投递/执行恢复层;U-5 当前只能称移动 consumer 接入,真实 Gateway/双端联合验收仍缺失,U-6 仍未实现。 -- 信箱搜索必须在全部最多 1,000 条保留行上参数化过滤和本地枚举排序后才限制到 100 条 UI 投影;搜索 - wildcard 必须转义,排序不得拼接用户字符串。全局未读和全部已读不得被当前搜索或显示上限截断。 +- 信箱搜索与未读过滤必须在全部最多 1,000 条保留行上应用,再按本地枚举排序后限制到 100 条 UI 投影; + 搜索 wildcard 必须转义,排序不得拼接用户字符串。全局未读、全部已读和清空作用域不得被当前搜索、 + 未读过滤或显示上限截断;本地批量操作应明示这一范围。 ## 事实真源 diff --git a/package.json b/package.json index e15ab69..386f401 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "verify:docs": "node scripts/verify-docs.mjs", "verify:config": "node scripts/verify-app-config.mjs", "verify:dependency-mitigations": "node scripts/verify-dependency-mitigations.mjs", - "verify:android:emulator": "node scripts/verify-android-emulator.mjs", "verify:app-icons": "node scripts/verify-app-icons.mjs", "verify:doctor": "expo-doctor", "verify:expo": "expo install --check", diff --git a/scripts/verify-android-emulator.mjs b/scripts/verify-android-emulator.mjs deleted file mode 100644 index a3ff775..0000000 --- a/scripts/verify-android-emulator.mjs +++ /dev/null @@ -1,368 +0,0 @@ -import { execFile } from 'node:child_process' -import { access } from 'node:fs/promises' -import { promisify } from 'node:util' - -const execFileAsync = promisify(execFile) -const appId = 'ai.tokenroll.toolbridgemobile.dev' -const apkPath = 'android/app/build/outputs/apk/debug/app-debug.apk' -const devServerUrl = process.env.EXPO_DEV_SERVER_URL ?? 'http://localhost:8081' -const devServerPort = new URL(devServerUrl).port || '80' -const forbiddenPermissions = [ - 'android.permission.ACCESS_BACKGROUND_LOCATION', - 'android.permission.RECEIVE_BOOT_COMPLETED', - 'android.permission.READ_APP_BADGE', - 'android.permission.READ_EXTERNAL_STORAGE', - 'android.permission.RECORD_AUDIO', - 'android.permission.SCHEDULE_EXACT_ALARM', - 'android.permission.SYSTEM_ALERT_WINDOW', - 'android.permission.USE_BIOMETRIC', - 'android.permission.USE_FINGERPRINT', - 'android.permission.WRITE_EXTERNAL_STORAGE', - 'com.google.android.c2dm.permission.RECEIVE', - 'com.sec.android.provider.badge.permission.READ', - 'com.sec.android.provider.badge.permission.WRITE', -] -const forbiddenRemoteNotificationComponents = [ - 'expo.modules.notifications.service.ExpoFirebaseMessagingService', - 'com.google.firebase.iid.FirebaseInstanceIdReceiver', - 'com.google.firebase.messaging.FirebaseMessagingService', - 'com.google.firebase.provider.FirebaseInitProvider', -] - -async function adb(...args) { - const { stdout } = await execFileAsync('adb', args, { maxBuffer: 20 * 1024 * 1024 }) - return stdout -} - -function delay(milliseconds) { - return new Promise(resolve => { setTimeout(resolve, milliseconds) }) -} - -async function dumpUi() { - await adb('shell', 'uiautomator', 'dump', '/sdcard/tool-bridge-window.xml') - return adb('shell', 'cat', '/sdcard/tool-bridge-window.xml') -} - -function nodeWithAttribute(source, attribute, predicate) { - for (const match of source.matchAll(/]*>/gu)) { - const node = match[0] - const value = new RegExp(`${attribute}="([^"]*)"`, 'u').exec(node)?.[1] - if (value !== undefined && predicate(value)) return node - } - return null -} - -function tapPoint(node) { - const bounds = /bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/u.exec(node) - if (bounds === null) throw new Error(`UI node 缺少 bounds: ${node}`) - const [, left, top, right, bottom] = bounds.map(Number) - if ([left, top, right, bottom].some(value => !Number.isFinite(value))) { - throw new Error(`UI node bounds 无效: ${node}`) - } - return [Math.round((left + right) / 2), Math.round((top + bottom) / 2)] -} - -function nodeHeightDp(node, density) { - const bounds = /bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/u.exec(node) - if (bounds === null) throw new Error(`UI node 缺少 bounds: ${node}`) - const top = Number(bounds[2]) - const bottom = Number(bounds[4]) - return (bottom - top) / (density / 160) -} - -function requireSelectedTab(source, accessibilityLabel) { - const node = nodeWithAttribute(source, 'content-desc', value => value === accessibilityLabel) - if (node === null) throw new Error(`找不到 tab 语义节点: ${accessibilityLabel}`) - if (!node.includes('selected="true"')) throw new Error(`tab 未投影 selected=true: ${accessibilityLabel}`) -} - -async function tapNode(node) { - const [x, y] = tapPoint(node) - await adb('shell', 'input', 'tap', String(x), String(y)) -} - -async function waitForUi(predicate, label, timeoutMs = 20_000) { - const deadline = Date.now() + timeoutMs - let lastDump = '' - while (Date.now() < deadline) { - try { - lastDump = await dumpUi() - if (predicate(lastDump)) return lastDump - } catch { - // Activity 切换或 bundle 加载期间 uiautomator 可短暂失败。 - } - await delay(500) - } - throw new Error(`等待 UI 失败: ${label}\n${lastDump.slice(0, 2_000)}`) -} - -function hasText(source, text) { - return nodeWithAttribute(source, 'text', value => value === text) !== null -} - -async function tapByDescription(description) { - const source = await dumpUi() - const node = nodeWithAttribute(source, 'content-desc', value => ( - value === description || value.endsWith(`, ${description}`) - )) - if (node === null) throw new Error(`找不到 accessibility 节点: ${description}`) - await tapNode(node) -} - -async function launchApp() { - const deepLink = `toolbridgemobile-dev://expo-development-client/?url=${encodeURIComponent(devServerUrl)}` - await adb( - 'shell', - 'am', - 'start', - '-W', - '-a', - 'android.intent.action.VIEW', - '-d', - deepLink, - appId, - ) - - let source = await waitForUi(current => ( - hasText(current, 'Continue') - || hasText(current, '设备裁决优先') - ), 'development client 或 App 首页') - if (hasText(source, 'Continue')) { - const continueNode = nodeWithAttribute(source, 'text', value => value === 'Continue') - if (continueNode === null) throw new Error('development client Continue 节点消失') - await tapNode(continueNode) - source = await waitForUi(current => ( - nodeWithAttribute(current, 'content-desc', value => value === 'Close') !== null - ), 'development client Close') - const closeNode = nodeWithAttribute(source, 'content-desc', value => value === 'Close') - if (closeNode === null) throw new Error('development client Close 节点消失') - await tapNode(closeNode) - } - return waitForUi(current => hasText(current, '设备裁决优先'), 'Tool Bridge Mobile 首页') -} - -async function ensureMetro() { - try { - const response = await fetch(`${devServerUrl}/status`, { signal: AbortSignal.timeout(2_000) }) - if (!response.ok) throw new Error(`HTTP ${response.status}`) - } catch (error) { - throw new Error( - `development server 不可达: ${devServerUrl};请先运行 pnpm start。${String(error)}`, - ) - } -} - -await access(apkPath) -await ensureMetro() -const devices = (await adb('devices')).split('\n').filter(line => /\tdevice$/u.test(line)) -if (devices.length !== 1 || !devices[0]?.startsWith('emulator-')) { - throw new Error(`需要且只允许一个已启动 Android emulator,当前: ${devices.join(', ')}`) -} -if ((await adb('shell', 'getprop', 'sys.boot_completed')).trim() !== '1') { - throw new Error('Android emulator 尚未完成启动') -} - -await adb('logcat', '-c') -await execFileAsync('adb', ['uninstall', appId]).catch(() => undefined) -await adb('install', apkPath) -await adb('reverse', `tcp:${devServerPort}`, `tcp:${devServerPort}`) - -const packageInfo = await adb('shell', 'dumpsys', 'package', appId) -for (const expected of ['versionCode=2 minSdk=24 targetSdk=36', 'versionName=0.0.2']) { - if (!packageInfo.includes(expected)) throw new Error(`安装包信息缺少: ${expected}`) -} -for (const forbidden of forbiddenPermissions) { - if (packageInfo.includes(forbidden)) throw new Error(`最终安装包不得声明 ${forbidden}`) -} -for (const forbidden of forbiddenRemoteNotificationComponents) { - if (packageInfo.includes(forbidden)) throw new Error(`local-only App 不得注册 ${forbidden}`) -} -for (const expected of [ - 'android.permission.ACCESS_COARSE_LOCATION', - 'android.permission.ACCESS_FINE_LOCATION', - 'android.permission.POST_NOTIFICATIONS', - 'android.permission.VIBRATE', -]) { - if (!packageInfo.includes(expected)) throw new Error(`最终安装包缺少 ${expected}`) -} - -let source = await launchApp() -for (const expected of ['ready', 'unconfigured', 'ask_every_time']) { - if (!hasText(source, expected)) throw new Error(`首页缺少运行时状态: ${expected}`) -} -for (const tabLabel of ['状态标签页', '信箱标签页', '能力标签页', '媒体标签页', '活动标签页']) { - if (nodeWithAttribute(source, 'content-desc', value => value === tabLabel) === null) { - throw new Error(`首页缺少唯一 tab accessibility label: ${tabLabel}`) - } -} -requireSelectedTab(source, '状态标签页') - -await tapByDescription('信箱标签页') -source = await waitForUi(current => hasText(current, '最近还没有 Agent 来信。'), '信箱页') -requireSelectedTab(source, '信箱标签页') - -await tapByDescription('能力标签页') -source = await waitForUi(current => hasText(current, 'phone/apps.can_open_url'), '能力页') -requireSelectedTab(source, '能力标签页') -for (let attempt = 0; attempt < 24 && !hasText(source, 'phone/location.current'); attempt += 1) { - await adb('shell', 'input', 'swipe', '540', '1750', '540', '1150', '250') - await delay(200) - source = await dumpUi() -} -if (!hasText(source, 'phone/location.current')) { - throw new Error('能力页未显示 phone/location.current') -} -for ( - let attempt = 0; - attempt < 6 && !source.includes('permission_required: foreground_location_permission_required'); - attempt += 1 -) { - await adb('shell', 'input', 'swipe', '540', '1750', '540', '1250', '250') - await delay(200) - source = await dumpUi() -} -if (!source.includes('permission_required: foreground_location_permission_required')) { - throw new Error('未授权位置能力没有显示 permission_required') -} -for (let attempt = 0; attempt < 16 && !hasText(source, 'phone/location.open_map'); attempt += 1) { - await adb('shell', 'input', 'swipe', '540', '1600', '540', '1350', '200') - await delay(200) - source = await dumpUi() -} -if (!hasText(source, 'phone/location.open_map')) { - throw new Error('能力页未显示 phone/location.open_map') -} -for ( - let attempt = 0; - attempt < 24 && !hasText(source, 'phone/productivity.notify'); - attempt += 1 -) { - await adb('shell', 'input', 'swipe', '540', '1650', '540', '1200', '200') - await delay(200) - source = await dumpUi() -} -if (!hasText(source, 'phone/productivity.notify')) { - throw new Error('能力页未显示 phone/productivity.notify') -} -for ( - let attempt = 0; - attempt < 8 && !source.includes('unavailable: notification_permission_requestable'); - attempt += 1 -) { - await adb('shell', 'input', 'swipe', '540', '1650', '540', '1350', '200') - await delay(200) - source = await dumpUi() -} -if (!source.includes('unavailable: notification_permission_requestable')) { - throw new Error('fresh install 的通知能力没有显示未授权且仅本地可请求') -} -for (const timerCapability of [ - 'phone/productivity.timer_start', - 'phone/productivity.timer_cancel', - 'phone/productivity.timer_status', -]) { - for (let attempt = 0; attempt < 12 && !hasText(source, timerCapability); attempt += 1) { - await adb('shell', 'input', 'swipe', '540', '1650', '540', '1250', '200') - await delay(200) - source = await dumpUi() - } - if (!hasText(source, timerCapability)) { - throw new Error(`能力页未显示 ${timerCapability}`) - } -} - -await tapByDescription('状态标签页') -await waitForUi(current => hasText(current, '设备裁决优先'), '返回状态页') -await tapByDescription('紧急停用远程能力') -await waitForUi(current => ( - hasText(current, 'disabled') && hasText(current, '恢复为每次确认') -), '紧急停用状态') -await adb('shell', 'am', 'force-stop', appId) -source = await launchApp() -if (!hasText(source, 'disabled') || !hasText(source, '恢复为每次确认')) { - throw new Error('紧急停用状态未在进程重启后恢复') -} -await tapByDescription('恢复为每次确认') -await waitForUi(current => hasText(current, 'ask_every_time'), '恢复控制模式') - -await tapByDescription('活动标签页') -source = await waitForUi(current => ( - hasText(current, '暂无远程调用记录。') - && current.includes('最近 100 条调用元数据') - && current.includes('最多保留 5,000 条') -), '本地活动历史范围') -await tapByDescription('清除本机活动历史') -source = await waitForUi(current => ( - hasText(current, '确认清除当前活动历史?') - && current.includes('不会清除防重放记录、计时器、设置、installation identity 或凭证') -), '活动历史 destructive confirmation') -await tapByDescription('取消清除活动历史') -await waitForUi(current => ( - nodeWithAttribute(current, 'content-desc', value => value === '清除本机活动历史') !== null -), '取消清除活动历史') -await tapByDescription('清除本机活动历史') -await waitForUi(current => hasText(current, '确认清除当前活动历史?'), '再次确认清除活动历史') -await tapByDescription('确认清除活动历史') -await waitForUi(current => ( - hasText(current, '已清除 0 条本机活动历史;后续调用会继续记录。') -), '清除空活动历史的真实结果') - -const fontScaleSource = (await adb('shell', 'settings', 'get', 'system', 'font_scale')).trim() -const originalFontScale = /^\d+(?:\.\d+)?$/u.test(fontScaleSource) ? fontScaleSource : '1.0' -const densityOutput = await adb('shell', 'wm', 'density') -const densityMatch = /Override density: (\d+)/u.exec(densityOutput) - ?? /Physical density: (\d+)/u.exec(densityOutput) -if (densityMatch === null) throw new Error(`无法读取 emulator density: ${densityOutput}`) -const density = Number(densityMatch[1]) - -await adb('shell', 'settings', 'put', 'system', 'font_scale', '2.0') -try { - await adb('shell', 'am', 'force-stop', appId) - source = await launchApp() - requireSelectedTab(source, '状态标签页') - - await tapByDescription('媒体标签页') - source = await waitForUi(current => hasText(current, '暂无 App 自有媒体会话。'), '200% 字号媒体页') - requireSelectedTab(source, '媒体标签页') - - await tapByDescription('活动标签页') - source = await waitForUi(current => ( - nodeWithAttribute(current, 'content-desc', value => value === '清除本机活动历史') !== null - ), '200% 字号活动页') - requireSelectedTab(source, '活动标签页') - const clearNode = nodeWithAttribute(source, 'content-desc', value => value === '清除本机活动历史') - if (clearNode === null || nodeHeightDp(clearNode, density) < 48) { - throw new Error(`200% 字号清除按钮不足 48dp: ${clearNode ?? 'missing'}`) - } - await tapNode(clearNode) - source = await waitForUi(current => ( - nodeWithAttribute(current, 'content-desc', value => value === '取消清除活动历史') !== null - && nodeWithAttribute(current, 'content-desc', value => value === '确认清除活动历史') !== null - ), '200% 字号 destructive confirmation controls') - for (const actionLabel of ['取消清除活动历史', '确认清除活动历史']) { - const actionNode = nodeWithAttribute(source, 'content-desc', value => value === actionLabel) - if (actionNode === null || nodeHeightDp(actionNode, density) < 48) { - throw new Error(`200% 字号操作不足 48dp: ${actionLabel}`) - } - } - await tapByDescription('取消清除活动历史') - - await tapByDescription('能力标签页') - source = await waitForUi(current => hasText(current, 'phone/apps.can_open_url'), '200% 字号能力页') - requireSelectedTab(source, '能力标签页') - - await tapByDescription('状态标签页') - source = await waitForUi(current => hasText(current, '设备裁决优先'), '200% 字号状态页') - requireSelectedTab(source, '状态标签页') -} finally { - await adb('shell', 'settings', 'put', 'system', 'font_scale', originalFontScale) - await adb('shell', 'am', 'force-stop', appId) - await launchApp() -} - -const logcat = await adb('logcat', '-d', '-t', '1200') -if (/FATAL EXCEPTION:[\s\S]*ai\.tokenroll\.toolbridgemobile\.dev/u.test(logcat)) { - throw new Error('Android smoke 期间发生 App FATAL EXCEPTION') -} - -console.log('Android emulator smoke 通过:安装/启动、local-only 通知与 timer 边界、动态能力、紧急停用持久化、活动历史清除确认及 200% 字号语义交互。') diff --git a/src/inbox/types.ts b/src/inbox/types.ts index eeb7503..4b09236 100644 --- a/src/inbox/types.ts +++ b/src/inbox/types.ts @@ -16,6 +16,7 @@ export type InboxSort = export type InboxViewOptions = Readonly<{ searchQuery: string sort: InboxSort + unreadOnly?: boolean }> export const DEFAULT_INBOX_VIEW_OPTIONS: InboxViewOptions = { @@ -55,5 +56,5 @@ export function normalizeInboxViewOptions(options: InboxViewOptions): InboxViewO if (searchQuery.length > LOCAL_INBOX_SEARCH_LIMIT) { throw new Error(`信箱搜索词不能超过 ${LOCAL_INBOX_SEARCH_LIMIT} 个字符`) } - return { searchQuery, sort: options.sort } + return { searchQuery, sort: options.sort, ...(options.unreadOnly === true ? { unreadOnly: true } : {}) } } diff --git a/src/runtime/applicationRuntime.ts b/src/runtime/applicationRuntime.ts index 3c87fb7..aeed5d8 100644 --- a/src/runtime/applicationRuntime.ts +++ b/src/runtime/applicationRuntime.ts @@ -402,6 +402,7 @@ export class ApplicationRuntime { if ( normalized.searchQuery === this.#inboxViewOptions.searchQuery && normalized.sort === this.#inboxViewOptions.sort + && normalized.unreadOnly === this.#inboxViewOptions.unreadOnly ) return this.#inboxViewOptions = normalized this.#inboxRevision += 1 diff --git a/src/storage/__tests__/inboxRepository.test.ts b/src/storage/__tests__/inboxRepository.test.ts index a5f07df..09888c2 100644 --- a/src/storage/__tests__/inboxRepository.test.ts +++ b/src/storage/__tests__/inboxRepository.test.ts @@ -1,4 +1,4 @@ -import { LOCAL_INBOX_RETENTION_LIMIT } from '@/inbox/types' +import { LOCAL_INBOX_RETENTION_LIMIT, normalizeInboxViewOptions } from '@/inbox/types' import { MemoryInboxRepository, SqliteInboxRepository } from '../inboxRepository' @@ -92,6 +92,47 @@ describe('SqliteInboxRepository', () => { ) }) + test('未读 SQL 将未读条件与参数化搜索合取,先过滤再限制数量', async () => { + const raw = { getAllAsync: jest.fn(async (..._arguments: unknown[]) => [row]) } + const repository = new SqliteInboxRepository({ raw } as unknown as MobileDatabase) + await repository.list({ searchQuery: 'Daily%_', sort: 'received_desc', unreadOnly: true }, 100) + const [query, ...parameters] = raw.getAllAsync.mock.calls[0] ?? [] + expect(query).toMatch(/WHERE read_at IS NULL AND \([\s\S]*\) ORDER BY[\s\S]*LIMIT \?/u) + expect(parameters).toEqual([...Array(5).fill('%Daily!%!_%'), 100]) + + await repository.list({ searchQuery: '', sort: 'received_desc', unreadOnly: true }, 100) + expect(raw.getAllAsync.mock.calls[1]?.[0]).toContain('WHERE read_at IS NULL ORDER BY') + expect(raw.getAllAsync.mock.calls[1]?.slice(1)).toEqual([100]) + expect(normalizeInboxViewOptions({ searchQuery: ' Daily ', sort: 'received_desc', unreadOnly: true })) + .toEqual({ searchQuery: 'Daily', sort: 'received_desc', unreadOnly: true }) + expect(normalizeInboxViewOptions({ searchQuery: '', sort: 'received_desc', unreadOnly: false })) + .toEqual({ searchQuery: '', sort: 'received_desc' }) + }) + + test('超过一屏近期已读消息时,仍能查到更早的未读;已读后退出未读结果', async () => { + const repository = new MemoryInboxRepository() + for (let index = 0; index < 102; index += 1) { + const messageId = `inbox_${index}` + await repository.add({ + body: '测试正文', callerDisplayName: null, callerSubjectId: 'caller', category: 'message', + format: 'markdown', messageId, receivedAt: new Date(Date.UTC(2026, 8, 1, 0, index)).toISOString(), + sentAt: null, sourceCommandId: `command_${index}`, sourceLabel: null, + title: index === 0 ? '较早的未读' : '近期已读', urgency: 'normal', + }) + if (index !== 0) await repository.markRead(messageId, '2026-09-02T00:00:00.000Z') + } + const all = await repository.list({ searchQuery: '', sort: 'received_desc' }, 100) + expect(all).toHaveLength(100) + expect(all.some(message => message.messageId === 'inbox_0')).toBe(false) + await expect(repository.list({ searchQuery: '', sort: 'received_desc', unreadOnly: true }, 100)) + .resolves.toEqual([expect.objectContaining({ messageId: 'inbox_0' })]) + await expect(repository.list({ searchQuery: '近期', sort: 'received_desc', unreadOnly: true }, 100)) + .resolves.toEqual([]) + await repository.markRead('inbox_0', '2026-09-02T00:00:00.000Z') + await expect(repository.list({ searchQuery: '', sort: 'received_desc', unreadOnly: true }, 100)) + .resolves.toEqual([]) + }) + test('内存契约同样按 source command 幂等并保留刚写入项的 1,000 条硬上限', async () => { const repository = new MemoryInboxRepository() for (let index = 0; index <= LOCAL_INBOX_RETENTION_LIMIT; index += 1) { diff --git a/src/storage/inboxRepository.ts b/src/storage/inboxRepository.ts index b97528e..9fc3043 100644 --- a/src/storage/inboxRepository.ts +++ b/src/storage/inboxRepository.ts @@ -169,13 +169,16 @@ export class SqliteInboxRepository implements InboxRepository { async list(options: InboxViewOptions, limit: number): Promise { const boundedLimit = Math.max(1, Math.min(limit, 200)) const query = options.searchQuery.trim() - const where = query === '' ? '' : `WHERE ( + const searchClause = query === '' ? '' : `( title LIKE ? ESCAPE '!' COLLATE NOCASE OR body LIKE ? ESCAPE '!' COLLATE NOCASE OR source_label LIKE ? ESCAPE '!' COLLATE NOCASE OR caller_display_name LIKE ? ESCAPE '!' COLLATE NOCASE OR caller_subject_id LIKE ? ESCAPE '!' COLLATE NOCASE )` + // 过滤必须先于 LIMIT,避免近期已读消息挤掉较早的未读消息。 + const clauses = [options.unreadOnly === true ? 'read_at IS NULL' : '', searchClause].filter(Boolean) + const where = clauses.length === 0 ? '' : `WHERE ${clauses.join(' AND ')}` const parameters = query === '' ? [] : Array(5).fill(searchPattern(query)) const rows = await this.database.raw.getAllAsync( `SELECT ${INBOX_COLUMNS} FROM inbox_messages @@ -247,6 +250,7 @@ export class MemoryInboxRepository implements InboxRepository { async list(options: InboxViewOptions, limit: number): Promise { return [...this.records.values()] + .filter(message => options.unreadOnly !== true || message.readAt === null) .filter(message => messageMatches(message, options.searchQuery.trim())) .sort((left, right) => compareInboxMessages(options.sort, left, right)) .slice(0, Math.max(1, Math.min(limit, 200))) diff --git a/src/ui/__tests__/navigation.test.ts b/src/ui/__tests__/navigation.test.ts index cff8be7..7883e39 100644 --- a/src/ui/__tests__/navigation.test.ts +++ b/src/ui/__tests__/navigation.test.ts @@ -1,10 +1,10 @@ import { TAB_OPTIONS, TAB_ORDER } from '../navigation' describe('tab accessibility labels', () => { - test('三个主 tab 使用稳定、唯一且带上下文的可访问名称', () => { + test('两个主 tab 使用稳定、唯一且带上下文的可访问名称', () => { const tabs = TAB_ORDER.map(name => TAB_OPTIONS[name]) - expect(tabs.map(tab => tab.title)).toEqual(['信箱', '活动', '设置']) - expect(new Set(tabs.map(tab => tab.tabBarAccessibilityLabel)).size).toBe(3) + expect(tabs.map(tab => tab.title)).toEqual(['信箱', '设备']) + expect(new Set(tabs.map(tab => tab.tabBarAccessibilityLabel)).size).toBe(2) expect(tabs.every(tab => tab.tabBarAccessibilityLabel.endsWith('标签页'))).toBe(true) }) }) diff --git a/src/ui/__tests__/systemTheme.test.tsx b/src/ui/__tests__/systemTheme.test.tsx new file mode 100644 index 0000000..6f851ff --- /dev/null +++ b/src/ui/__tests__/systemTheme.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render } from '@testing-library/react-native' +import { StyleSheet, useColorScheme } from 'react-native' + +import { AccessibleAction } from '../components/AccessibleAction' +import { GatewayConfigurationCard } from '../components/GatewayConfigurationCard' +import { Screen } from '../components/Screen' +import { darkColors, lightColors } from '../theme' + +jest.mock('react-native/Libraries/Utilities/useColorScheme', () => ({ + __esModule: true, + default: jest.fn(() => 'light'), +})) + +const colorScheme = jest.mocked(useColorScheme) + +describe('系统主题切换', () => { + afterEach(() => { colorScheme.mockReturnValue('light') }) + + test('外观变化更新表单颜色并保留未提交内容,不触发保存', async () => { + const onSave = jest.fn(async () => undefined) + const ui = () => ( + + + + ) + const rendered = await render(ui()) + const input = () => rendered.getByLabelText('Gateway HTTPS URL') + await fireEvent.changeText(input(), 'https://draft.example.com') + expect(StyleSheet.flatten(input().props.style).color).toBe(lightColors.text) + + colorScheme.mockReturnValue('dark') + await rendered.rerender(ui()) + expect(input().props.value).toBe('https://draft.example.com') + expect(StyleSheet.flatten(input().props.style).color).toBe(darkColors.text) + expect(onSave).not.toHaveBeenCalled() + + colorScheme.mockReturnValue('light') + await rendered.rerender(ui()) + expect(input().props.value).toBe('https://draft.example.com') + expect(StyleSheet.flatten(input().props.style).color).toBe(lightColors.text) + }) + + test.each([['light', lightColors], ['dark', darkColors]] as const)('%s 危险按钮使用专用前景色', async (scheme, colors) => { + colorScheme.mockReturnValue(scheme) + const rendered = await render() + expect(StyleSheet.flatten(rendered.getByText('停用').props.style).color).toBe(colors.onDanger) + expect(StyleSheet.flatten(rendered.getByRole('button', { name: '停用' }).props.style).backgroundColor).toBe(colors.danger) + }) +}) diff --git a/src/ui/__tests__/themeContrast.test.ts b/src/ui/__tests__/themeContrast.test.ts index b06763e..51f3c1e 100644 --- a/src/ui/__tests__/themeContrast.test.ts +++ b/src/ui/__tests__/themeContrast.test.ts @@ -1,4 +1,4 @@ -import { colors } from '../theme' +import { darkColors, lightColors } from '../theme' function relativeLuminance(color: string): number { const channels = color.slice(1).match(/../gu)?.map(channel => Number.parseInt(channel, 16) / 255) @@ -16,22 +16,30 @@ function contrast(left: string, right: string): number { / (Math.min(leftLuminance, rightLuminance) + 0.05) } -describe('theme contrast regression gate', () => { +describe.each([['light', lightColors], ['dark', darkColors]] as const)('%s theme contrast', (_scheme, colors) => { test.each([ ['text/background', colors.text, colors.background], ['muted/background', colors.muted, colors.background], ['text/panel', colors.text, colors.panel], ['muted/panel', colors.muted, colors.panel], - ])('%s 普通文字对比不低于 4.5:1', (_name, foreground, background) => { + ['muted/elevated', colors.muted, colors.panelElevated], + ['primary/soft', colors.primary, colors.primarySoft], + ['success/soft', colors.success, colors.successSoft], + ['warning/soft', colors.warning, colors.warningSoft], + ['danger/soft', colors.danger, colors.dangerSoft], + ['text/dangerSoft', colors.text, colors.dangerSoft], + ['muted/dangerSoft', colors.muted, colors.dangerSoft], + ['primary action', colors.onPrimary, colors.primary], + ['danger action', colors.onDanger, colors.danger], + ])('%s 实际文字组合对比不低于 4.5:1', (_name, foreground, background) => { expect(contrast(foreground, background)).toBeGreaterThanOrEqual(4.5) }) test.each([ ['outline/panel', colors.outline, colors.panel], ['outline/background', colors.outline, colors.background], - ['primary action', colors.background, colors.primary], - ['danger action', colors.background, colors.danger], - ])('%s 交互边界或控件文字对比不低于 3:1', (_name, foreground, background) => { + ['outline/elevated', colors.outline, colors.panelElevated], + ])('%s 交互边界对比不低于 3:1', (_name, foreground, background) => { expect(contrast(foreground, background)).toBeGreaterThanOrEqual(3) }) }) diff --git a/src/ui/components/AccessibleAction.tsx b/src/ui/components/AccessibleAction.tsx index 66ad851..3dd2ae6 100644 --- a/src/ui/components/AccessibleAction.tsx +++ b/src/ui/components/AccessibleAction.tsx @@ -2,7 +2,7 @@ import { forwardRef } from 'react' import { Pressable, StyleSheet, Text, View } from 'react-native' import { Icon, type IconName } from '@/ui/components/Icon' -import { colors, radius, spacing } from '@/ui/theme' +import { useTheme, useThemedStyles, type ThemeColors, radius, spacing } from '@/ui/theme' import type { AccessibilityRole, StyleProp, TextStyle, ViewStyle } from 'react-native' @@ -25,12 +25,6 @@ type AccessibleActionProps = Readonly<{ visualLabel?: string }> -const VARIANT_TEXT_COLOR: Readonly> = { - danger: colors.text, - primary: colors.background, - secondary: colors.text, -} - // 统一 button role、上下文唯一 label、hint、busy/disabled state 与至少 48dp 目标尺寸。 // variant 只影响视觉;无障碍语义完全由 label/hint/state 决定。 export const AccessibleAction = forwardRef, AccessibleActionProps>(function AccessibleAction({ @@ -47,8 +41,14 @@ export const AccessibleAction = forwardRef, A variant = 'primary', visualLabel = label, }: AccessibleActionProps, ref) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) + const variantStyles = useThemedStyles(createVariantStyles) + const textColors: Readonly> = { + danger: colors.onDanger, primary: colors.onPrimary, secondary: colors.text, + } const unavailable = disabled || busy - const resolvedTextColor = StyleSheet.flatten(textStyle)?.color ?? VARIANT_TEXT_COLOR[variant] + const resolvedTextColor = StyleSheet.flatten(textStyle)?.color ?? textColors[variant] return ( , A ) }) -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ action: { alignItems: 'center', borderRadius: radius.md, @@ -98,8 +98,9 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, label: { - fontSize: 16, - fontWeight: '800', + flexShrink: 1, + fontSize: 15, + fontWeight: '600', textAlign: 'center', }, pressed: { @@ -107,14 +108,14 @@ const styles = StyleSheet.create({ }, selected: { borderColor: colors.primary, - borderWidth: 2, + borderWidth: 1, }, unavailable: { opacity: 0.5, }, }) -const variantStyles = StyleSheet.create({ +const createVariantStyles = (colors: ThemeColors) => StyleSheet.create({ danger: { backgroundColor: colors.danger, }, @@ -122,7 +123,7 @@ const variantStyles = StyleSheet.create({ backgroundColor: colors.primary, }, secondary: { - backgroundColor: colors.panelElevated, + backgroundColor: colors.panel, borderColor: colors.outline, borderWidth: 1, }, diff --git a/src/ui/components/ActionSheet.tsx b/src/ui/components/ActionSheet.tsx new file mode 100644 index 0000000..b98d0b0 --- /dev/null +++ b/src/ui/components/ActionSheet.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef } from 'react' +import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' + +import { focusAccessibilityElement } from '@/ui/accessibility' +import { Icon } from '@/ui/components/Icon' +import { spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' + +import type { PropsWithChildren, RefObject } from 'react' + +type ActionSheetProps = PropsWithChildren | null> + title: string + visible: boolean +}>> + +// 仅承载用户主动打开的本地操作;远程裁决仍使用专用 PendingConfirmationModal。 +export function ActionSheet({ children, dismissible = true, onClose, returnFocusRef, title, visible }: ActionSheetProps) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) + const headingRef = useRef(null) + const wasVisible = useRef(false) + + useEffect(() => { + if (visible) void focusAccessibilityElement(headingRef.current) + else if (wasVisible.current && returnFocusRef?.current) { + void focusAccessibilityElement(returnFocusRef.current) + } + wasVisible.current = visible + }, [returnFocusRef, title, visible]) + + return ( + { if (dismissible) onClose() }} statusBarTranslucent transparent visible={visible}> + + + + + + {title} + [styles.close, pressed ? styles.pressed : null]} + > + + + + + {children} + + + + + ) +} + +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + backdrop: { alignItems: 'center', backgroundColor: 'rgba(7, 12, 20, 0.48)', flex: 1, justifyContent: 'flex-end' }, + close: { alignItems: 'center', borderRadius: 24, justifyContent: 'center', minHeight: 48, minWidth: 48 }, + content: { gap: spacing.md, paddingHorizontal: spacing.xl, paddingBottom: spacing.xxl }, + handle: { alignSelf: 'center', backgroundColor: colors.outline, borderRadius: 2, height: 4, marginTop: spacing.md, width: 32 }, + header: { alignItems: 'center', flexDirection: 'row', gap: spacing.md, paddingLeft: spacing.xl, paddingRight: spacing.sm, paddingVertical: spacing.md }, + pressed: { backgroundColor: colors.panelElevated }, + sheet: { backgroundColor: colors.panel, borderTopLeftRadius: 28, borderTopRightRadius: 28, maxHeight: '88%', maxWidth: 620, width: '100%' }, + title: { color: colors.text, flex: 1, fontSize: 22, fontWeight: '700' }, +}) diff --git a/src/ui/components/CameraCaptureModal.tsx b/src/ui/components/CameraCaptureModal.tsx index d5791a1..0dcbd5b 100644 --- a/src/ui/components/CameraCaptureModal.tsx +++ b/src/ui/components/CameraCaptureModal.tsx @@ -14,7 +14,7 @@ import { import { SafeAreaView } from 'react-native-safe-area-context' import { AccessibleAction } from '@/ui/components/AccessibleAction' -import { colors, radius, spacing } from '@/ui/theme' +import { radius, spacing, useThemedStyles, type ThemeColors } from '@/ui/theme' import type { CameraCaptureFailure, @@ -42,6 +42,7 @@ export function CameraCaptureModal({ onSubmit, request, }: CameraCaptureModalProps) { + const styles = useThemedStyles(createStyles) const cameraRef = useRef(null) const automaticCommandRef = useRef(null) const reviewPhotoRef = useRef(null) @@ -271,7 +272,7 @@ function discardPhoto(photo: Readonly<{ uri: string }>): void { } } -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ actions: { flexDirection: 'row', flexWrap: 'wrap', @@ -308,19 +309,23 @@ const styles = StyleSheet.create({ backgroundColor: colors.panel, borderBottomColor: colors.border, borderBottomWidth: 1, - gap: spacing.xs, - padding: spacing.lg, + gap: spacing.sm, + padding: spacing.xl, }, heading: { color: colors.text, - fontSize: 24, + fontSize: 28, fontWeight: '800', + letterSpacing: -0.6, }, mode: { + backgroundColor: colors.warningSoft, + borderRadius: radius.sm, color: colors.warning, fontSize: 13, lineHeight: 19, marginTop: spacing.xs, + padding: spacing.md, }, overlay: { backgroundColor: 'rgba(8, 17, 31, 0.9)', @@ -339,7 +344,7 @@ const styles = StyleSheet.create({ flex: 1, }, statusText: { - color: colors.text, + color: '#ffffff', fontSize: 14, fontWeight: '700', paddingHorizontal: spacing.lg, diff --git a/src/ui/components/EmptyState.tsx b/src/ui/components/EmptyState.tsx new file mode 100644 index 0000000..67ff55f --- /dev/null +++ b/src/ui/components/EmptyState.tsx @@ -0,0 +1,44 @@ +import { StyleSheet, Text, View } from 'react-native' + +import { Icon, type IconName } from '@/ui/components/Icon' +import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' + +export function EmptyState({ icon, title, description }: Readonly<{ + description?: string + icon: IconName + title: string +}>) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) + return ( + + + {title} + {description === undefined ? null : {description}} + + ) +} + +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + container: { + alignItems: 'center', + backgroundColor: colors.panel, + borderColor: colors.border, + borderRadius: radius.lg, + borderWidth: 1, + gap: spacing.md, + paddingHorizontal: spacing.xxl, + paddingVertical: 44, + }, + icon: { + alignItems: 'center', + backgroundColor: colors.primarySoft, + borderRadius: 20, + height: 64, + justifyContent: 'center', + marginBottom: spacing.sm, + width: 64, + }, + title: { color: colors.text, fontSize: 16, fontWeight: '600', textAlign: 'center' }, + description: { color: colors.muted, fontSize: 14, lineHeight: 22, maxWidth: 320, textAlign: 'center' }, +}) diff --git a/src/ui/components/GatewayConfigurationCard.tsx b/src/ui/components/GatewayConfigurationCard.tsx index 142fb8d..5bd3284 100644 --- a/src/ui/components/GatewayConfigurationCard.tsx +++ b/src/ui/components/GatewayConfigurationCard.tsx @@ -3,12 +3,14 @@ import { StyleSheet, Text, TextInput, View } from 'react-native' import { useDiscreteAccessibilityAnnouncement } from '@/ui/accessibility' import { AccessibleAction } from '@/ui/components/AccessibleAction' +import { Icon } from '@/ui/components/Icon' import { StatusCard, StatusRow } from '@/ui/components/StatusCard' -import { colors, radius, spacing } from '@/ui/theme' +import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' import type { ManualGatewayConfigurationInput } from '@/identity/manualGatewayCredential' type GatewayConfigurationCardProps = Readonly<{ + appearance?: 'card' | 'page' currentOrigin: string | null defaultDeviceId: string | null onClear(): Promise @@ -20,11 +22,14 @@ function safeFeedback(error: unknown, fallback: string): string { } export function GatewayConfigurationCard({ + appearance = 'card', currentOrigin, defaultDeviceId, onClear, onSave, }: GatewayConfigurationCardProps) { + const styles = useThemedStyles(createStyles) + const { colors } = useTheme() const [apiKey, setApiKey] = useState('') const [confirmingClear, setConfirmingClear] = useState(false) const [deviceIdInput, setDeviceIdInput] = useState('') @@ -78,61 +83,76 @@ export function GatewayConfigurationCard({ } } - return ( - + const content = ( + <> + {appearance === 'card' ? + + + + + 连接你的工具网络 + 使用网关地址与密钥连接此设备 + + : null} - 暂时使用手工 URL + API key,不经过 pairing。API key 只写入系统安全存储,界面不会回显。 - - Gateway HTTPS URL - { - setOriginInput(value) - setOriginDirty(true) - }} - placeholder="https://gateway.example.com" - placeholderTextColor={colors.muted} - spellCheck={false} - style={styles.input} - value={displayedOrigin} - /> - API key - - 设备 ID(可选) - - - 只能包含字母、数字、“.”、“_”或“-”,最长 64 个字符;设备将挂载到 device/phone/设备ID。 + API key 只保存在系统安全存储中,保存后不会回显。 + + Gateway HTTPS URL + { + setOriginInput(value) + setOriginDirty(true) + }} + placeholder="https://gateway.example.com" + placeholderTextColor={colors.muted} + spellCheck={false} + style={styles.input} + value={displayedOrigin} + /> + + + API key + + + + 设备 ID(可选) + + + 只能包含字母、数字、“.”、“_”或“-”,最长 64 个字符;设备将挂载到 device/phone/设备ID。 + + {feedback}} - + ) + return appearance === 'page' + ? {content} + : {content} } -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + pageForm: { gap: spacing.xl }, + connectionHeader: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.md, + paddingBottom: spacing.sm, + }, + connectionIcon: { + alignItems: 'center', + backgroundColor: colors.primarySoft, + borderRadius: radius.md, + height: 48, + justifyContent: 'center', + width: 48, + }, + connectionCopy: { flex: 1, gap: spacing.xs }, + connectionTitle: { color: colors.text, fontSize: 17, fontWeight: '700' }, + field: { gap: spacing.sm }, + deviceField: { + borderTopColor: colors.border, + borderTopWidth: 1, + gap: spacing.sm, + marginTop: spacing.xs, + paddingTop: spacing.lg, + }, actionRow: { flexDirection: 'row', flexWrap: 'wrap', @@ -202,7 +250,7 @@ const styles = StyleSheet.create({ lineHeight: 21, }, confirmation: { - backgroundColor: colors.panelElevated, + backgroundColor: colors.dangerSoft, borderColor: colors.danger, borderRadius: radius.md, borderWidth: 1, @@ -230,7 +278,7 @@ const styles = StyleSheet.create({ }, input: { backgroundColor: colors.panelElevated, - borderColor: colors.outline, + borderColor: colors.border, borderRadius: radius.md, borderWidth: 1, color: colors.text, diff --git a/src/ui/components/Icon.tsx b/src/ui/components/Icon.tsx index e15d94e..38f1fa4 100644 --- a/src/ui/components/Icon.tsx +++ b/src/ui/components/Icon.tsx @@ -1,6 +1,6 @@ import Ionicons from '@expo/vector-icons/Ionicons' -import { colors } from '@/ui/theme' +import { useTheme } from '@/ui/theme' import type { ComponentProps } from 'react' @@ -9,6 +9,16 @@ type IoniconName = ComponentProps['name'] // 语义化图标名 → Ionicons glyph。屏幕代码只引用语义名, // 换图标库时只改这一处映射。图标默认对辅助技术隐藏(父级已有文字语义)。 const ICONS = { + close: 'close-outline', + device: 'phone-portrait-outline', + deviceActive: 'phone-portrait', + filter: 'options-outline', + info: 'information-circle-outline', + key: 'key-outline', + more: 'ellipsis-horizontal', + read: 'checkmark-done-outline', + shield: 'shield-checkmark-outline', + unread: 'mail-unread-outline', activity: 'time-outline', activityActive: 'time', alert: 'alert-circle', @@ -47,14 +57,15 @@ const ICONS = { export type IconName = keyof typeof ICONS export function Icon({ - color = colors.text, + color, name, size = 20, }: Readonly<{ color?: string; name: IconName; size?: number }>) { + const { colors } = useTheme() return ( (null) const commandId = confirmation?.commandId ?? null @@ -58,6 +61,12 @@ export function PendingConfirmationModal({ style={styles.dialog} > + + + + + 需要你的允许 + 等待本地确认 @@ -66,20 +75,25 @@ export function PendingConfirmationModal({ ? '1 条命令等待处理' : `${confirmations.length} 条命令等待处理;当前显示最早的一条`} - - - - + + + + + + {confirmation.description} {confirmation.details.map(detail => ( ))} - - 只裁决当前这一条命令。允许后仍会重新检查期限、权限与设备状态;完整参数不会写入普通审计日志。 - + + 仅允许这一次 + + 只裁决当前这一条命令。允许后仍会重新检查期限、权限与设备状态;完整参数不会写入普通审计日志。 + + StyleSheet.create({ + requestHeader: { alignItems: 'center', flexDirection: 'row', gap: spacing.md }, + requestIcon: { + alignItems: 'center', + backgroundColor: colors.warningSoft, + borderRadius: radius.md, + height: 48, + justifyContent: 'center', + width: 48, + }, + requestLabel: { color: colors.warning, fontSize: 13, fontWeight: '700' }, + requestDetails: { + backgroundColor: colors.panelElevated, + borderRadius: radius.md, + gap: spacing.md, + padding: spacing.lg, + }, + permissionScope: { + borderColor: colors.border, + borderRadius: radius.md, + borderWidth: 1, + gap: spacing.xs, + padding: spacing.md, + }, + scopeTitle: { color: colors.text, fontSize: 14, fontWeight: '700' }, action: { flexBasis: 120, flexGrow: 1, @@ -122,7 +160,7 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(0, 0, 0, 0.72)', flex: 1, justifyContent: 'center', - padding: spacing.xl, + padding: spacing.lg, }, content: { gap: spacing.md, @@ -135,7 +173,7 @@ const styles = StyleSheet.create({ }, dialog: { backgroundColor: colors.panel, - borderColor: colors.outline, + borderColor: colors.border, borderRadius: 24, borderWidth: 1, maxHeight: '88%', @@ -150,11 +188,12 @@ const styles = StyleSheet.create({ }, heading: { color: colors.text, - fontSize: 25, + fontSize: 28, fontWeight: '800', + letterSpacing: -0.7, }, queueSummary: { - color: colors.warning, + color: colors.muted, fontSize: 14, fontWeight: '700', lineHeight: 20, diff --git a/src/ui/components/Pill.tsx b/src/ui/components/Pill.tsx index e9c3bf4..a07143c 100644 --- a/src/ui/components/Pill.tsx +++ b/src/ui/components/Pill.tsx @@ -1,16 +1,9 @@ import { StyleSheet, Text, View } from 'react-native' -import { colors, radius, spacing } from '@/ui/theme' +import { useTheme, useThemedStyles, type ThemeColors, radius, spacing } from '@/ui/theme' export type PillTone = 'positive' | 'neutral' | 'caution' | 'danger' -const toneColor: Readonly> = { - caution: colors.warning, - danger: colors.danger, - neutral: colors.muted, - positive: colors.primary, -} - // 紧凑的状态徽标:状态点 + label + value。颜色不是唯一信号—— // 文本本身即含义,满足对比度与非仅颜色依赖的无障碍要求。 export function Pill({ @@ -18,6 +11,11 @@ export function Pill({ tone = 'neutral', value, }: Readonly<{ label: string; tone?: PillTone; value: string }>) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) + const toneColor: Readonly> = { + caution: colors.warning, danger: colors.danger, neutral: colors.muted, positive: colors.success, + } return ( StyleSheet.create({ dot: { borderRadius: 4, height: 8, @@ -74,12 +72,13 @@ const styles = StyleSheet.create({ flexGrow: 1, flexShrink: 1, gap: spacing.xs, + flexBasis: 130, minWidth: 96, paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, + paddingVertical: spacing.md, }, value: { fontSize: 16, - fontWeight: '800', + fontWeight: '600', }, }) diff --git a/src/ui/components/SafeMarkdown.tsx b/src/ui/components/SafeMarkdown.tsx index 73acc6b..719b3b2 100644 --- a/src/ui/components/SafeMarkdown.tsx +++ b/src/ui/components/SafeMarkdown.tsx @@ -5,7 +5,7 @@ import { Image, StyleSheet, Text, View } from 'react-native' import { validateInboxImageSource } from '@/inbox/imagePolicy' import { validateInboxLink } from '@/inbox/linkOpener' import { AccessibleAction } from '@/ui/components/AccessibleAction' -import { colors, radius, spacing } from '@/ui/theme' +import { radius, spacing, useThemedStyles, type ThemeColors } from '@/ui/theme' import type { InboxImageSourceResolver, ResolvedInboxImage } from '@/inbox/imageSource' import type { InboxLinkOpener } from '@/inbox/linkOpener' @@ -53,6 +53,7 @@ type SafeMarkdownProps = Readonly<{ }> export function SafeMarkdown({ imageResolver, linkOpener, markdown }: SafeMarkdownProps) { + const styles = useThemedStyles(createStyles) const [linkFailure, setLinkFailure] = useState(null) const tokens = useMemo(() => markdownParser.parse(markdown, {}), [markdown]) const tokenCount = tokens.reduce((count, token) => count + 1 + (token.children?.length ?? 0), 0) @@ -137,7 +138,7 @@ export function SafeMarkdown({ imageResolver, linkOpener, markdown }: SafeMarkdo {listPrefix === null ? null : {listPrefix}} {headingLevel === 0 ? content : ( - + ) { + const styles = useThemedStyles(createStyles) const styledText = group.parts.map(part => ( - {part.text} + {part.text} )) if (group.href === null) return {styledText} @@ -285,7 +287,7 @@ function InlineTextGroup({ ) } -function inlineTextStyle(style: InlineStyle) { +function inlineTextStyle(style: InlineStyle, styles: ReturnType) { return [ style.bold ? styles.bold : null, style.italic ? styles.italic : null, @@ -294,7 +296,7 @@ function inlineTextStyle(style: InlineStyle) { ] } -function headingStyle(level: number) { +function headingStyle(level: number, styles: ReturnType) { if (level <= 1) return styles.heading1 if (level === 2) return styles.heading2 return styles.heading3 @@ -309,6 +311,7 @@ function SafeMarkdownImage({ imageResolver: InboxImageSourceResolver source: string }>) { + const styles = useThemedStyles(createStyles) const [failure, setFailure] = useState(false) const [loading, setLoading] = useState(false) const [resolved, setResolved] = useState(null) @@ -391,27 +394,32 @@ function safeDisplayHost(source: string): string | null { } } -const styles = StyleSheet.create({ - block: { marginBottom: spacing.sm }, +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + block: { marginBottom: spacing.lg }, blockContent: { flex: 1, gap: spacing.sm }, blockquote: { - borderLeftColor: colors.outline, + backgroundColor: colors.panelElevated, + borderLeftColor: colors.primary, borderLeftWidth: 3, paddingLeft: spacing.md, + paddingRight: spacing.md, + paddingVertical: spacing.sm, }, bold: { fontWeight: '800' }, codeBlock: { - backgroundColor: colors.background, - borderRadius: radius.sm, + backgroundColor: colors.panelElevated, + borderColor: colors.border, + borderRadius: radius.md, + borderWidth: 1, color: colors.text, fontFamily: 'monospace', fontSize: 13, - lineHeight: 19, - padding: spacing.md, + lineHeight: 21, + padding: spacing.lg, }, - heading1: { color: colors.text, fontSize: 22, fontWeight: '800', lineHeight: 29 }, - heading2: { color: colors.text, fontSize: 19, fontWeight: '800', lineHeight: 26 }, - heading3: { color: colors.text, fontSize: 17, fontWeight: '800', lineHeight: 24 }, + heading1: { color: colors.text, fontSize: 25, fontWeight: '800', letterSpacing: -0.5, lineHeight: 34, marginTop: spacing.sm }, + heading2: { color: colors.text, fontSize: 21, fontWeight: '700', letterSpacing: -0.3, lineHeight: 30, marginTop: spacing.sm }, + heading3: { color: colors.text, fontSize: 18, fontWeight: '700', lineHeight: 27, marginTop: spacing.xs }, image: { borderRadius: radius.sm, maxHeight: 360, width: '100%' }, imageAlt: { color: colors.text, fontSize: 14, fontWeight: '700' }, imageFailure: { color: colors.warning, fontSize: 13, lineHeight: 19 }, @@ -423,17 +431,17 @@ const styles = StyleSheet.create({ gap: spacing.sm, padding: spacing.md, }, - inlineCode: { backgroundColor: colors.background, fontFamily: 'monospace' }, - inlineText: { color: colors.text, fontSize: 15, lineHeight: 22 }, + inlineCode: { backgroundColor: colors.panelElevated, fontFamily: 'monospace', fontSize: 14 }, + inlineText: { color: colors.text, fontSize: 17, lineHeight: 29 }, italic: { fontStyle: 'italic' }, invalidLink: { color: colors.muted, textDecorationLine: 'none' }, listItem: { flexDirection: 'row' }, - listPrefix: { color: colors.primary, fontSize: 15, lineHeight: 22, minWidth: 24 }, + listPrefix: { color: colors.primary, fontSize: 17, lineHeight: 29, minWidth: 28 }, linkFailure: { color: colors.warning, fontSize: 13, lineHeight: 19 }, note: { color: colors.muted, fontSize: 12, lineHeight: 18 }, - paragraph: { color: colors.text, fontSize: 15, lineHeight: 22 }, + paragraph: { color: colors.text, fontSize: 17, lineHeight: 29 }, root: { gap: spacing.xs }, - rule: { backgroundColor: colors.border, height: 1, marginVertical: spacing.sm }, + rule: { backgroundColor: colors.border, height: 1, marginVertical: spacing.lg }, strike: { textDecorationLine: 'line-through' }, underlined: { color: colors.primary, textDecorationLine: 'underline' }, }) diff --git a/src/ui/components/Screen.tsx b/src/ui/components/Screen.tsx index c1c0b27..c3c9c52 100644 --- a/src/ui/components/Screen.tsx +++ b/src/ui/components/Screen.tsx @@ -1,21 +1,26 @@ import { useEffect, useRef } from 'react' -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { focusAccessibilityElement } from '@/ui/accessibility' import { MINIMUM_ACCESSIBLE_TARGET_SIZE } from '@/ui/components/AccessibleAction' import { Icon } from '@/ui/components/Icon' -import { colors, radius, spacing } from '@/ui/theme' +import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' -import type { PropsWithChildren } from 'react' +import type { PropsWithChildren, ReactNode } from 'react' type ScreenProps = PropsWithChildren void) | undefined title: string + titlePlacement?: 'header' | 'content' + titleSize?: 'large' | 'compact' + tone?: 'default' | 'reading' + toolbar?: ReactNode }>> export function Screen({ @@ -24,109 +29,118 @@ export function Screen({ description, eyebrow, focused = true, + headerAccessory, onBack, title, + titlePlacement = 'header', + titleSize = 'large', + tone = 'default', + toolbar, }: ScreenProps) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) const headingRef = useRef(null) useEffect(() => { if (focused) void focusAccessibilityElement(headingRef.current) }, [focused]) + const heading = ( + + {eyebrow === undefined ? null : ( + {eyebrow} + )} + {title} + {description === undefined ? null : {description}} + + ) + return ( - - - {onBack === undefined ? null : ( - [styles.backButton, pressed ? styles.backButtonPressed : null]} - > - - {backLabel} - - )} - - {eyebrow === undefined ? null : ( - - {eyebrow} - - )} - {title} - {description === undefined ? null : ( - {description} - )} + + + + + {onBack === undefined ? null : ( + [styles.backButton, pressed ? styles.pressed : null]} + > + + {titlePlacement === 'content' ? {backLabel} : null} + + )} + {titlePlacement === 'header' ? heading : } + {headerAccessory === undefined ? null : {headerAccessory}} + + {toolbar === undefined ? null : {toolbar}} - {children} - + + {titlePlacement === 'content' ? heading : null} + {children} + + ) } -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + accessory: { alignItems: 'flex-end', justifyContent: 'center' }, backButton: { alignItems: 'center', - alignSelf: 'flex-start', borderRadius: radius.sm, - columnGap: spacing.xs, flexDirection: 'row', + gap: spacing.sm, + justifyContent: 'center', marginLeft: -spacing.sm, minHeight: MINIMUM_ACCESSIBLE_TARGET_SIZE, + minWidth: MINIMUM_ACCESSIBLE_TARGET_SIZE, paddingHorizontal: spacing.sm, }, - backButtonPressed: { - opacity: 0.6, - }, - backLabel: { - color: colors.primary, - fontSize: 16, - fontWeight: '700', - }, + backLabel: { color: colors.text, fontSize: 15, fontWeight: '600' }, + chrome: { backgroundColor: colors.panel, borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth }, + compactHeading: { fontSize: 21, letterSpacing: 0 }, content: { - gap: spacing.lg, + alignSelf: 'center', + gap: spacing.xl, + maxWidth: 720, paddingBottom: spacing.xxl, paddingHorizontal: spacing.xl, - paddingTop: spacing.lg, - }, - description: { - color: colors.muted, - fontSize: 15, - lineHeight: 22, - }, - eyebrow: { - color: colors.primary, - fontSize: 11, - fontWeight: '800', - letterSpacing: 1.5, + paddingTop: spacing.xl, + width: '100%', }, - eyebrowBadge: { - alignSelf: 'flex-start', - backgroundColor: colors.panelElevated, - borderColor: colors.border, - borderRadius: 999, - borderWidth: 1, - paddingHorizontal: spacing.md, - paddingVertical: 5, - }, - headerBlock: { + description: { color: colors.muted, fontSize: 14, lineHeight: 21 }, + eyebrow: { color: colors.primary, fontSize: 11, fontWeight: '700', letterSpacing: 1.5 }, + fill: { flex: 1 }, + header: { + alignItems: 'center', + alignSelf: 'center', + flexDirection: 'row', gap: spacing.sm, + maxWidth: 720, + minHeight: 72, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.md, + width: '100%', }, - heading: { - color: colors.text, - fontSize: 30, - fontWeight: '800', - letterSpacing: -0.5, - }, - safeArea: { - backgroundColor: colors.background, - flex: 1, - }, + heading: { color: colors.text, fontSize: 32, fontWeight: '700', letterSpacing: -0.8 }, + headingBlock: { flexShrink: 1, flexGrow: 1, gap: spacing.sm }, + pressed: { opacity: 0.6 }, + reading: { backgroundColor: colors.panel }, + safeArea: { backgroundColor: colors.background, flex: 1 }, + toolbar: { alignSelf: 'center', maxWidth: 720, paddingHorizontal: spacing.xl, paddingBottom: spacing.md, width: '100%' }, }) diff --git a/src/ui/components/SectionHeading.tsx b/src/ui/components/SectionHeading.tsx new file mode 100644 index 0000000..64565ca --- /dev/null +++ b/src/ui/components/SectionHeading.tsx @@ -0,0 +1,19 @@ +import { StyleSheet, Text, View } from 'react-native' + +import { spacing, useThemedStyles, type ThemeColors } from '@/ui/theme' + +export function SectionHeading({ title, detail }: Readonly<{ detail?: string; title: string }>) { + const styles = useThemedStyles(createStyles) + return ( + + {title} + {detail === undefined ? null : {detail}} + + ) +} + +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + row: { alignItems: 'baseline', flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, justifyContent: 'space-between', marginTop: spacing.sm }, + title: { color: colors.text, fontSize: 15, fontWeight: '600' }, + detail: { color: colors.muted, flexShrink: 1, fontSize: 12, fontVariant: ['tabular-nums'] }, +}) diff --git a/src/ui/components/SettingToggle.tsx b/src/ui/components/SettingToggle.tsx index b07aa1c..5c26ff4 100644 --- a/src/ui/components/SettingToggle.tsx +++ b/src/ui/components/SettingToggle.tsx @@ -1,7 +1,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native' import { MINIMUM_ACCESSIBLE_TARGET_SIZE } from '@/ui/components/AccessibleAction' -import { colors } from '@/ui/theme' +import { useThemedStyles, type ThemeColors } from '@/ui/theme' // 带说明文本的开关行。用 switch role 表达当前状态,整行可点击,满足最小触控尺寸。 export function SettingToggle({ @@ -17,6 +17,7 @@ export function SettingToggle({ onToggle(next: boolean): void value: boolean }>) { + const styles = useThemedStyles(createStyles) return ( StyleSheet.create({ description: { color: colors.muted, fontSize: 13, @@ -61,7 +62,7 @@ const styles = StyleSheet.create({ gap: 3, }, thumb: { - backgroundColor: colors.text, + backgroundColor: '#ffffff', borderRadius: 11, height: 22, width: 22, @@ -73,13 +74,14 @@ const styles = StyleSheet.create({ alignSelf: 'flex-end', }, track: { + flexShrink: 0, borderRadius: 15, height: 30, padding: 4, width: 52, }, trackOff: { - backgroundColor: colors.border, + backgroundColor: colors.outline, }, trackOn: { backgroundColor: colors.primary, diff --git a/src/ui/components/StatusCard.tsx b/src/ui/components/StatusCard.tsx index 0d93a8b..8f717b7 100644 --- a/src/ui/components/StatusCard.tsx +++ b/src/ui/components/StatusCard.tsx @@ -1,7 +1,7 @@ import { StyleSheet, Text, View } from 'react-native' import { Icon, type IconName } from '@/ui/components/Icon' -import { colors, radius, spacing } from '@/ui/theme' +import { useTheme, useThemedStyles, type ThemeColors, radius, spacing } from '@/ui/theme' import type { PropsWithChildren } from 'react' @@ -11,6 +11,8 @@ export function StatusCard({ title, tone = 'neutral', }: PropsWithChildren>) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) return ( @@ -27,6 +29,7 @@ export function StatusCard({ } export function StatusRow({ label, value }: Readonly<{ label: string; value: string }>) { + const styles = useThemedStyles(createStyles) return ( StyleSheet.create({ card: { backgroundColor: colors.panel, borderColor: colors.border, @@ -62,6 +65,7 @@ const styles = StyleSheet.create({ padding: spacing.lg, }, cardDanger: { + backgroundColor: colors.dangerSoft, borderColor: colors.danger, }, header: { @@ -71,7 +75,7 @@ const styles = StyleSheet.create({ }, iconBadge: { alignItems: 'center', - backgroundColor: colors.panelElevated, + backgroundColor: colors.primarySoft, borderRadius: radius.sm, height: 30, justifyContent: 'center', @@ -96,13 +100,15 @@ const styles = StyleSheet.create({ flexBasis: 160, flexGrow: 2, flexShrink: 1, - fontSize: 14, + fontSize: 13, + lineHeight: 20, + fontVariant: ['tabular-nums'], textAlign: 'right', }, title: { color: colors.text, flexShrink: 1, - fontSize: 17, + fontSize: 16, fontWeight: '700', }, }) diff --git a/src/ui/components/__tests__/ActionSheet.test.tsx b/src/ui/components/__tests__/ActionSheet.test.tsx new file mode 100644 index 0000000..fac8d50 --- /dev/null +++ b/src/ui/components/__tests__/ActionSheet.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render } from '@testing-library/react-native' +import { Text } from 'react-native' + +import { focusAccessibilityElement } from '@/ui/accessibility' + +import { ActionSheet } from '../ActionSheet' + +jest.mock('@/ui/accessibility', () => ({ focusAccessibilityElement: jest.fn() })) + +describe('本地操作面板', () => { + test('打开后有明确标题和关闭入口;关闭只回调本地关闭操作', async () => { + const onClose = jest.fn() + const rendered = await render(操作范围) + rendered.getByRole('header', { name: '信箱操作' }) + expect(focusAccessibilityElement).toHaveBeenCalled() + await fireEvent.press(rendered.getByRole('button', { name: '关闭信箱操作' })) + expect(onClose).toHaveBeenCalledTimes(1) + await rendered.rerender(操作范围) + expect(rendered.queryByRole('button', { name: '关闭信箱操作' })).toBeNull() + }) + + test('操作执行中关闭入口表达禁用,不能提前关闭', async () => { + const onClose = jest.fn() + const rendered = await render(正在清空) + const close = rendered.getByRole('button', { name: '关闭信箱操作' }) + expect(close.props.accessibilityState.disabled).toBe(true) + await fireEvent.press(close) + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/src/ui/navigation.ts b/src/ui/navigation.ts index 16be914..1da6171 100644 --- a/src/ui/navigation.ts +++ b/src/ui/navigation.ts @@ -1,21 +1,19 @@ import type { IconName } from '@/ui/components/Icon' -// 主导航只保留三个高频入口:信箱(落地首页)、活动、设置。 -// 状态、能力、媒体降级为设置页内的二级页面,不再占用 tab bar。 +// 主导航只保留两个高频入口:信箱(落地首页)、设备。 +// 活动、状态、能力、媒体作为设备页内的二级页面,不再占用 tab bar。 export const TAB_OPTIONS = { - activity: { tabBarAccessibilityLabel: '活动标签页', title: '活动' }, index: { tabBarAccessibilityLabel: '信箱标签页', title: '信箱' }, - settings: { tabBarAccessibilityLabel: '设置标签页', title: '设置' }, + settings: { tabBarAccessibilityLabel: '设备标签页', title: '设备' }, } as const -export const TAB_ORDER = ['index', 'activity', 'settings'] as const +export const TAB_ORDER = ['index', 'settings'] as const // 每个 tab 的选中/未选中图标;tab 布局据此渲染 Ionicons, // 图标只是文字标签的视觉补充,不承担无障碍语义。 export const TAB_ICONS = { - activity: { active: 'activityActive', inactive: 'activity' }, index: { active: 'inboxActive', inactive: 'inbox' }, - settings: { active: 'settingsActive', inactive: 'settings' }, + settings: { active: 'deviceActive', inactive: 'device' }, } as const satisfies Record< (typeof TAB_ORDER)[number], Readonly<{ active: IconName; inactive: IconName }> diff --git a/src/ui/screens/ActivityScreen.tsx b/src/ui/screens/ActivityScreen.tsx index 38ee193..0753a74 100644 --- a/src/ui/screens/ActivityScreen.tsx +++ b/src/ui/screens/ActivityScreen.tsx @@ -6,25 +6,29 @@ import { useDiscreteAccessibilityAnnouncement, } from '@/ui/accessibility' import { AccessibleAction } from '@/ui/components/AccessibleAction' -import { Icon } from '@/ui/components/Icon' +import { EmptyState } from '@/ui/components/EmptyState' import { Screen } from '@/ui/components/Screen' +import { SectionHeading } from '@/ui/components/SectionHeading' import { StatusCard, StatusRow } from '@/ui/components/StatusCard' -import { colors, radius, spacing } from '@/ui/theme' +import { radius, spacing, useThemedStyles, type ThemeColors } from '@/ui/theme' import type { AuditRecord } from '@/audit/types' import type { Pressable, Text as NativeText } from 'react-native' type ActivityScreenProps = Readonly<{ focused?: boolean + onBack?: (() => void) | undefined onClearAuditHistory: () => Promise records: readonly AuditRecord[] }> export function ActivityScreen({ focused = true, + onBack, onClearAuditHistory, records, }: ActivityScreenProps) { + const styles = useThemedStyles(createStyles) const [confirmingClear, setConfirmingClear] = useState(false) const [feedback, setFeedback] = useState(null) const [isClearing, setIsClearing] = useState(false) @@ -63,33 +67,35 @@ export function ActivityScreen({ return ( - {records.map(record => { - const allowed = record.decision === 'allowed' - return ( - - - - - - - - - ) - })} + {records.length === 0 ? ( - - - 暂无远程调用记录。 + + ) : ( + + {records.map(record => ( + + {record.occurredAt} + + + + + + + + + + 决策允许不代表执行成功,请以结果为准。 + + ))} - ) : null} + )} + 显示最近 100 条,本机最多保留 5,000 条;不展示参数、正文或结果载荷。 {!confirmingClear ? ( ) : ( @@ -145,7 +151,12 @@ export function ActivityScreen({ ) } -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + timeline: { gap: spacing.md }, + timestamp: { color: colors.muted, fontSize: 12 }, + resultBlock: { backgroundColor: colors.panelElevated, borderRadius: radius.sm, padding: spacing.md, gap: spacing.sm }, + boundaries: { gap: spacing.xs }, + timeHint: { color: colors.muted, fontSize: 12, lineHeight: 18 }, actionRow: { flexDirection: 'row', flexWrap: 'wrap', @@ -169,21 +180,6 @@ const styles = StyleSheet.create({ fontSize: 18, fontWeight: '800', }, - empty: { - color: colors.muted, - fontSize: 15, - textAlign: 'center', - }, - emptyCard: { - alignItems: 'center', - backgroundColor: colors.panel, - borderColor: colors.border, - borderRadius: radius.lg, - borderWidth: 1, - gap: spacing.md, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.xxl, - }, feedback: { backgroundColor: colors.panel, borderColor: colors.warning, diff --git a/src/ui/screens/CapabilitiesScreen.tsx b/src/ui/screens/CapabilitiesScreen.tsx index 65ea202..fd9182c 100644 --- a/src/ui/screens/CapabilitiesScreen.tsx +++ b/src/ui/screens/CapabilitiesScreen.tsx @@ -1,10 +1,11 @@ import { StyleSheet, Text, View } from 'react-native' import { useDiscreteAccessibilityAnnouncement } from '@/ui/accessibility' -import { Icon } from '@/ui/components/Icon' +import { EmptyState } from '@/ui/components/EmptyState' import { Screen } from '@/ui/components/Screen' +import { SectionHeading } from '@/ui/components/SectionHeading' import { StatusCard, StatusRow } from '@/ui/components/StatusCard' -import { colors, radius, spacing } from '@/ui/theme' +import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' import type { CapabilitySnapshot } from '@/capabilities/types' @@ -13,6 +14,8 @@ export function CapabilitiesScreen({ focused = true, onBack, }: Readonly<{ capabilities: readonly CapabilitySnapshot[]; focused?: boolean; onBack?: (() => void) | undefined }>) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) const availabilityKey = capabilities.map(({ availability, descriptor }) => ( `${descriptor.path}.${descriptor.tool}:${availability.status}:${'reason' in availability ? availability.reason @@ -25,16 +28,29 @@ export function CapabilitiesScreen({ return ( + + + {capabilities.filter(item => item.availability.status === 'available').length} + 当前可用 + + + {capabilities.length} + 已探测能力 + + + {capabilities.map(({ availability, descriptor }) => { const capability = `${descriptor.path}.${descriptor.tool}` const available = availability.status === 'available' return ( + {available ? '可用' : availability.status === 'permission_required' ? '需要授权' : '暂不可用'} {descriptor.description} - - 运行时尚未完成能力探测。 - + ) : null} ) } -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + summary: { flexDirection: 'row', gap: spacing.md, backgroundColor: colors.primarySoft, borderRadius: radius.lg, padding: spacing.xl }, + summaryMetric: { flex: 1, gap: spacing.xs }, + metric: { color: colors.primary, fontSize: 32, fontWeight: '700', fontVariant: ['tabular-nums'] }, + metricLabel: { color: colors.muted, fontSize: 13 }, + availabilityBadge: { alignSelf: 'flex-start', borderRadius: radius.sm, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, fontSize: 12, fontWeight: '600' }, description: { color: colors.text, lineHeight: 20, }, - empty: { - color: colors.warning, - fontSize: 15, - textAlign: 'center', - }, - emptyCard: { - alignItems: 'center', - backgroundColor: colors.panel, - borderColor: colors.border, - borderRadius: radius.lg, - borderWidth: 1, - gap: spacing.md, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.xxl, - }, }) diff --git a/src/ui/screens/ConnectionSettingsScreen.tsx b/src/ui/screens/ConnectionSettingsScreen.tsx new file mode 100644 index 0000000..65ede01 --- /dev/null +++ b/src/ui/screens/ConnectionSettingsScreen.tsx @@ -0,0 +1,44 @@ +import { StyleSheet, Text, View } from 'react-native' + +import { GatewayConfigurationCard } from '@/ui/components/GatewayConfigurationCard' +import { Icon } from '@/ui/components/Icon' +import { Screen } from '@/ui/components/Screen' +import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' + +import type { ManualGatewayConfigurationInput } from '@/identity/manualGatewayCredential' +import type { ApplicationSnapshot } from '@/runtime/applicationRuntime' + +export function ConnectionSettingsScreen({ focused = true, onBack, onClear, onSave, snapshot }: Readonly<{ + focused?: boolean + onBack(): void + onClear(): Promise + onSave(input: ManualGatewayConfigurationInput): Promise + snapshot: ApplicationSnapshot +}>) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) + const connected = snapshot.transportState === 'ready' + return ( + + + + + {connected ? '已连接网关' : '网关尚未就绪'} + {connected ? '设备连接已就绪,执行仍受本机授权约束。' : '保存网关配置后,设备会尝试建立连接。'} + + + {snapshot.controlMode === 'disabled' ? ( + 远程能力已停用。请返回设备页恢复为每次确认后,再调整连接配置。 + ) : ( + + )} + + ) +} + +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + status: { flexDirection: 'row', alignItems: 'center', gap: spacing.lg, padding: spacing.xl, borderRadius: radius.lg, backgroundColor: colors.primarySoft }, + statusCopy: { flex: 1, gap: spacing.xs }, + title: { color: colors.text, fontSize: 17, fontWeight: '600' }, + description: { color: colors.muted, fontSize: 14, lineHeight: 22 }, +}) diff --git a/src/ui/screens/ControlSettingsScreen.tsx b/src/ui/screens/ControlSettingsScreen.tsx new file mode 100644 index 0000000..16d2dd2 --- /dev/null +++ b/src/ui/screens/ControlSettingsScreen.tsx @@ -0,0 +1,269 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' + +import { useDiscreteAccessibilityAnnouncement } from '@/ui/accessibility' +import { AccessibleAction } from '@/ui/components/AccessibleAction' +import { Icon } from '@/ui/components/Icon' +import { Screen } from '@/ui/components/Screen' +import { SectionHeading } from '@/ui/components/SectionHeading' +import { SettingToggle } from '@/ui/components/SettingToggle' +import { StatusCard } from '@/ui/components/StatusCard' +import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' + +import type { ControlMode } from '@/commands/types' +import type { ApplicationSnapshot } from '@/runtime/applicationRuntime' + +const CONTROL_MODE_OPTIONS: readonly Readonly<{ + hint: string + label: string + mode: Exclude +}>[] = [ + { + hint: '每条有副作用的命令都在设备上逐次确认', + label: '每次确认', + mode: 'ask_every_time', + }, + { + hint: '低/中风险命令直接执行,高风险仍逐次确认', + label: '信任会话(非高危直调)', + mode: 'trusted_session', + }, + { + hint: '跳过逐次确认;系统权限、平台限制与紧急停用仍然有效', + label: '允许直接调用(含高危)', + mode: 'direct_call', + }, +] + +type ControlSettingsScreenProps = Readonly<{ + focused?: boolean + onBack(): void + onEmergencyDisable(): void + onEnable(): void + onOpenCameraSettings(): void + onOpenNotificationSettings(): void + onRequestNotificationPermission(): void + onRequestCameraPermission(): void + onSetBackgroundRuntime(enabled: boolean): void + onSetControlMode(mode: ControlMode): void + snapshot: ApplicationSnapshot +}> + +export function ControlSettingsScreen({ + focused = true, + onBack, + onEmergencyDisable, + onEnable, + onOpenCameraSettings, + onOpenNotificationSettings, + onRequestNotificationPermission, + onRequestCameraPermission, + onSetBackgroundRuntime, + onSetControlMode, + snapshot, +}: ControlSettingsScreenProps) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) + const isDisabled = snapshot.controlMode === 'disabled' + const notificationAvailability = snapshot.capabilities.find(({ descriptor }) => ( + descriptor.path === 'phone/productivity' && descriptor.tool === 'notify' + ))?.availability + const notificationSettingsRequired = notificationAvailability?.status === 'unavailable' + && ( + notificationAvailability.reason === 'notification_permission_denied' + || notificationAvailability.reason === 'notification_channel_disabled' + ) + const notificationPermissionRequestable = notificationAvailability?.status === 'unavailable' + && notificationAvailability.reason === 'notification_permission_requestable' + const cameraAvailability = snapshot.capabilities.find(({ descriptor }) => ( + descriptor.path === 'phone/camera' && descriptor.tool === 'capture_photo' + ))?.availability + const cameraPermissionRequestable = cameraAvailability?.status === 'permission_required' + && cameraAvailability.reason === 'camera_permission_required' + const cameraSettingsRequired = cameraAvailability?.status === 'unavailable' + && cameraAvailability.reason === 'camera_permission_denied' + + useDiscreteAccessibilityAnnouncement( + `control-mode:${snapshot.controlMode}`, + `控制模式已变为 ${snapshot.controlMode}`, + ) + useDiscreteAccessibilityAnnouncement( + `background:${snapshot.backgroundRuntimeEnabled}`, + snapshot.backgroundRuntimeEnabled ? '后台运行已开启' : '后台运行已关闭', + ) + + return ( + + + {isDisabled ? ( + + + 当前处于紧急停用状态:所有新命令在本地策略层被拒绝。恢复后才能调整其他设置。 + + + + ) : ( + <> + + + + 选择 Agent 命令在本机的裁决强度。 + + {CONTROL_MODE_OPTIONS.map(option => { + const active = snapshot.controlMode === option.mode + return ( + { onSetControlMode(option.mode) }} + style={({ pressed }) => [styles.modeOption, active ? styles.modeOptionActive : null, pressed ? styles.navRowPressed : null]} + > + + {option.label} + {option.hint} + + + + ) + })} + {snapshot.controlMode === 'direct_call' ? ( + + + + 直接调用模式下高特权工具(shell、剪贴板、任意 URL/Intent)可被 Agent 直接执行; + 前台相机也会在可见预览就绪后自动拍摄并上传。请仅在你完全信任当前网关与 Agent 时启用。 + + + ) : null} + + + + + + + + 下面只显示当前可由本机请求或调整的权限。完整可用性以设备能力页的实际探测为准。 + + {cameraPermissionRequestable ? ( + + + 相机只用于前台可见预览和单张拍摄;不会申请麦克风、图库或后台相机权限。 + + + + ) : null} + + {cameraSettingsRequired ? ( + + + 系统相机权限已被永久拒绝;远程命令和直接调用模式都不能绕过该设置。 + + + + ) : null} + + {notificationPermissionRequestable ? ( + + + Tool Bridge 只在你主动允许后创建可见的即时通知;远程命令不会弹出系统权限框。 + + + + ) : null} + + {notificationSettingsRequired ? ( + + + 系统通知权限或 Tool Bridge 本地通知 channel 已关闭;远程命令无权改变该设置。 + + + + ) : null} + + + + 立即拒绝所有新命令并停止仍可撤销的本地副作用。系统权限与用户拒绝始终优先。 + + + + + )} + + ) +} + +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + modeList: { gap: spacing.md }, + modeOption: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, minHeight: 80, borderWidth: 1, borderColor: colors.outline, borderRadius: radius.md, padding: spacing.lg }, + modeOptionActive: { borderColor: colors.primary, backgroundColor: colors.primarySoft }, + modeCopy: { flex: 1, gap: spacing.xs }, + modeTitle: { color: colors.text, fontSize: 15, fontWeight: '600' }, + modeTitleActive: { color: colors.primary }, + modeDescription: { color: colors.muted, fontSize: 13, lineHeight: 20 }, + body: { + color: colors.muted, + fontSize: 15, + lineHeight: 22, + }, + navRowPressed: { + opacity: 0.7, + }, + warningNote: { + alignItems: 'flex-start', + backgroundColor: colors.warningSoft, + borderRadius: radius.sm, + columnGap: spacing.sm, + flexDirection: 'row', + padding: spacing.md, + }, + warningText: { + color: colors.warning, + flexShrink: 1, + fontSize: 13, + lineHeight: 19, + }, +}) diff --git a/src/ui/screens/HomeScreen.tsx b/src/ui/screens/HomeScreen.tsx index 5a4fe87..e5ad81c 100644 --- a/src/ui/screens/HomeScreen.tsx +++ b/src/ui/screens/HomeScreen.tsx @@ -2,10 +2,12 @@ import { StyleSheet, Text, View } from 'react-native' import { useDiscreteAccessibilityAnnouncement } from '@/ui/accessibility' import { AccessibleAction } from '@/ui/components/AccessibleAction' +import { Icon } from '@/ui/components/Icon' import { Pill, type PillTone } from '@/ui/components/Pill' import { Screen } from '@/ui/components/Screen' +import { SectionHeading } from '@/ui/components/SectionHeading' import { StatusCard, StatusRow } from '@/ui/components/StatusCard' -import { colors, radius, spacing } from '@/ui/theme' +import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' import type { ControlMode } from '@/commands/types' import type { ApplicationSnapshot } from '@/runtime/applicationRuntime' @@ -47,6 +49,8 @@ export function HomeScreen({ onStopAttention, snapshot, }: HomeScreenProps) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) useDiscreteAccessibilityAnnouncement( `control-mode:${snapshot.controlMode}`, `控制模式已变为 ${snapshot.controlMode}`, @@ -73,14 +77,21 @@ export function HomeScreen({ return ( {snapshot.error === null ? null : {snapshot.error}} + + + + {snapshot.transportState === 'ready' ? '设备已连接' : '设备尚未就绪'} + {snapshot.transportState === 'ready' ? '远程命令仍受本机策略与系统权限约束。' : '前往设备页查看网关配置与连接状态。'} + + )} + {snapshot.attentionSession !== null || snapshot.timers.length > 0 ? : null} {snapshot.attentionSession === null ? null : ( @@ -151,9 +163,9 @@ export function HomeScreen({ ))} @@ -161,7 +173,11 @@ export function HomeScreen({ ) } -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + connectionHero: { flexDirection: 'row', alignItems: 'center', gap: spacing.lg, backgroundColor: colors.panel, borderRadius: radius.lg, padding: spacing.xl, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.border }, + connectionIcon: { width: 60, height: 60, borderRadius: 20, backgroundColor: colors.primarySoft, alignItems: 'center', justifyContent: 'center' }, + connectionCopy: { flex: 1, gap: spacing.sm }, + connectionTitle: { fontSize: 22, fontWeight: '700', color: colors.text }, error: { backgroundColor: colors.panel, borderColor: colors.danger, diff --git a/src/ui/screens/InboxMessageScreen.tsx b/src/ui/screens/InboxMessageScreen.tsx index 7875f41..4450fea 100644 --- a/src/ui/screens/InboxMessageScreen.tsx +++ b/src/ui/screens/InboxMessageScreen.tsx @@ -11,7 +11,7 @@ import { formatAbsoluteTime, formatRelativeTime, } from '@/ui/inboxFormat' -import { colors, radius, spacing } from '@/ui/theme' +import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' import type { InboxImageSourceResolver, ResolvedInboxImage } from '@/inbox/imageSource' import type { InboxLinkOpener } from '@/inbox/linkOpener' @@ -36,6 +36,8 @@ export function InboxMessageScreen({ onOpenLink, onResolveImage, }: InboxMessageScreenProps) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) const imageResolver = useMemo(() => ({ resolve: onResolveImage, }), [onResolveImage]) @@ -79,40 +81,53 @@ export function InboxMessageScreen({ focused={focused} onBack={onBack} title={message.title} + titlePlacement="content" + tone="reading" > - - {message.urgency === 'critical' || message.urgency === 'high' ? ( - - {URGENCY_LABEL[message.urgency]} - - ) : null} - {caller} + + {caller.slice(0, 1).toLocaleUpperCase()} + + + + {message.urgency === 'critical' || message.urgency === 'high' ? ( + + {URGENCY_LABEL[message.urgency]} + + ) : null} + {caller} + + + {relative} + + {message.sourceLabel === null ? null : ( + 内容来源(Agent 提供):{message.sourceLabel} + )} - - {relative} · {formatAbsoluteTime(message.receivedAt)} - - {message.sourceLabel === null ? null : ( - 内容来源(Agent 提供):{message.sourceLabel} - )} - + + + ) } -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + article: { paddingBottom: spacing.xxl }, + senderAvatar: { alignItems: 'center', justifyContent: 'center', width: 36, height: 36, borderRadius: 18, backgroundColor: colors.primarySoft }, + senderInitial: { color: colors.primary, fontSize: 14, fontWeight: '700' }, + senderDetails: { flex: 1, gap: spacing.xs }, caller: { color: colors.text, flexShrink: 1, @@ -139,7 +154,9 @@ const styles = StyleSheet.create({ paddingVertical: spacing.xxl, }, metaBlock: { - gap: spacing.sm, + alignItems: 'flex-start', + flexDirection: 'row', + gap: spacing.md, }, metaLine: { alignItems: 'center', @@ -153,13 +170,13 @@ const styles = StyleSheet.create({ }, time: { color: colors.muted, - fontSize: 14, + fontSize: 12, }, urgencyCritical: { - backgroundColor: colors.danger, + backgroundColor: colors.dangerSoft, }, urgencyHigh: { - backgroundColor: colors.warning, + backgroundColor: colors.warningSoft, }, urgencyTag: { borderRadius: radius.sm, @@ -167,7 +184,7 @@ const styles = StyleSheet.create({ paddingVertical: 2, }, urgencyText: { - color: colors.background, + color: colors.onDanger, fontSize: 12, fontWeight: '800', }, diff --git a/src/ui/screens/InboxScreen.tsx b/src/ui/screens/InboxScreen.tsx index 4317acb..88855b1 100644 --- a/src/ui/screens/InboxScreen.tsx +++ b/src/ui/screens/InboxScreen.tsx @@ -6,6 +6,8 @@ import { useDiscreteAccessibilityAnnouncement, } from '@/ui/accessibility' import { AccessibleAction } from '@/ui/components/AccessibleAction' +import { ActionSheet } from '@/ui/components/ActionSheet' +import { EmptyState } from '@/ui/components/EmptyState' import { Icon } from '@/ui/components/Icon' import { Screen } from '@/ui/components/Screen' import { @@ -13,7 +15,7 @@ import { formatRelativeTime, markdownSummary, } from '@/ui/inboxFormat' -import { colors, radius, spacing } from '@/ui/theme' +import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' import type { InboxMessage, @@ -22,6 +24,7 @@ import type { } from '@/inbox/types' import type { Pressable as PressableType, Text as NativeText } from 'react-native' + const SORT_OPTIONS: readonly Readonly<{ label: string; value: InboxSort }>[] = [ { label: '最新', value: 'received_desc' }, { label: '最早', value: 'received_asc' }, @@ -45,6 +48,7 @@ function MessageListItem({ now, onOpen, }: Readonly<{ message: InboxMessage; now?: Date | undefined; onOpen(): void }>) { + const styles = useThemedStyles(createStyles) const caller = message.callerDisplayName ?? message.callerSubjectId const unread = message.readAt === null const relative = formatRelativeTime(message.receivedAt, now) @@ -53,9 +57,7 @@ function MessageListItem({ const accessibilityLabel = [ unread ? '未读' : '已读', showUrgency ? URGENCY_LABEL[message.urgency] : null, - message.title, - `来自 ${caller}`, - relative, + message.title, `来自 ${caller}`, relative, ].filter(Boolean).join(',') return ( [styles.item, pressed ? styles.itemPressed : null]} > - - - - - {message.title} - - {relative} - + - {showUrgency ? ( - - {URGENCY_LABEL[message.urgency]} - - ) : null} - {caller} + + {caller} + {relative} - {summary === '' ? null : ( - {summary} - )} + {message.title} + {summary === '' ? null : {summary}} + {showUrgency ? {URGENCY_LABEL[message.urgency]}优先级 : null} - ) } @@ -112,6 +92,13 @@ export function InboxScreen({ unreadCount, viewOptions, }: InboxScreenProps) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) + const [sheet, setSheet] = useState<'more' | 'sort' | null>(null) + const unreadOnly = viewOptions.unreadOnly === true + const moreTriggerRef = useRef>(null) + const sortTriggerRef = useRef>(null) + const previousSheet = useRef(null) const [confirmingClear, setConfirmingClear] = useState(false) const [feedback, setFeedback] = useState(null) const [isClearing, setIsClearing] = useState(false) @@ -131,6 +118,13 @@ export function InboxScreen({ wasConfirming.current = confirmingClear }, [confirmingClear]) + useEffect(() => { + if (sheet === null && previousSheet.current !== null) { + void focusAccessibilityElement(previousSheet.current === 'sort' ? sortTriggerRef.current : moreTriggerRef.current) + } + previousSheet.current = sheet + }, [sheet]) + useDiscreteAccessibilityAnnouncement( `inbox-unread:${unreadCount}`, `信箱未读数量已变为 ${unreadCount}`, @@ -160,7 +154,7 @@ export function InboxScreen({ setIsUpdatingView(true) setFeedback(null) try { - await onViewOptionsChange({ searchQuery: searchText, sort }) + await onViewOptionsChange({ searchQuery: searchText, sort, ...(unreadOnly ? { unreadOnly: true } : {}) }) } catch { setFeedback('排序失败;当前结果顺序未被确认更改。') } finally { @@ -173,7 +167,7 @@ export function InboxScreen({ setIsUpdatingView(true) setFeedback(null) try { - await onViewOptionsChange({ searchQuery: query, sort: viewOptions.sort }) + await onViewOptionsChange({ searchQuery: query, sort: viewOptions.sort, ...(unreadOnly ? { unreadOnly: true } : {}) }) } catch { setFeedback('搜索失败;当前结果可能仍是上一次查询。') } finally { @@ -181,6 +175,23 @@ export function InboxScreen({ } } + const changeUnreadFilter = async (onlyUnread: boolean) => { + if (isUpdatingView || onlyUnread === unreadOnly) return + setIsUpdatingView(true) + setFeedback(null) + try { + await onViewOptionsChange({ + searchQuery: viewOptions.searchQuery, + sort: viewOptions.sort, + ...(onlyUnread ? { unreadOnly: true } : {}), + }) + } catch { + setFeedback('筛选失败;仍显示原来的消息范围。') + } finally { + setIsUpdatingView(false) + } + } + const clearInbox = async () => { if (isClearing) return setIsClearing(true) @@ -197,331 +208,162 @@ export function InboxScreen({ } const searching = viewOptions.searchQuery !== '' - - return ( - + const currentSort = SORT_OPTIONS.find(option => option.value === viewOptions.sort)?.label ?? '最新' + const closeSheet = () => { + if (isClearing || isMarkingAll || isUpdatingView) return + setConfirmingClear(false) + setSheet(null) + } + const toolbar = ( + { void applySearch(searchText) }} - placeholder="搜索标题、正文、来源或调用方" + placeholder="搜索消息" placeholderTextColor={colors.muted} returnKeyType="search" style={styles.searchInput} value={searchText} /> {searchText === '' ? null : ( - { - setSearchText('') - void applySearch('') - }} - > - + { setSearchText(''); void applySearch('') }}> + )} - - - {SORT_OPTIONS.map(option => { - const selected = option.value === viewOptions.sort - return ( + + + {[false, true].map(onlyUnread => ( { void changeSort(option.value) }} - style={[styles.sortChip, selected ? styles.sortChipSelected : null]} + key={String(onlyUnread)} + onPress={() => { void changeUnreadFilter(onlyUnread) }} + style={[styles.filterTab, unreadOnly === onlyUnread ? styles.filterTabSelected : null]} > - - {option.label} - + {onlyUnread ? '未读' : '全部'} + {onlyUnread && unreadCount > 0 ? {unreadCount} : null} - ) - })} - - {unreadCount === 0 ? null : ( - { void markAllRead() }} - style={styles.markAllButton} - > - - {isMarkingAll ? '标记中…' : '全部已读'} - - - )} - - - {messages.length === 0 ? ( - - - - {searching ? '没有匹配的本机信箱消息。' : '最近还没有 Agent 来信。'} - - - ) : ( - - {messages.map(message => ( - { onOpenMessage(message.messageId) }} - /> ))} - )} - - {feedback === null ? null : {feedback}} + setSheet('sort')} ref={sortTriggerRef} style={styles.sortButton}> + {currentSort} + + + + ) + const feedbackText = feedback === null ? null : {feedback} - {!confirmingClear ? ( - { - setFeedback(null) - setConfirmingClear(true) - }} - ref={clearTriggerRef} - variant="secondary" - /> - ) : ( - - - 确认清空本机信箱? - - - 此操作不可恢复,只删除本机信箱消息。它不会删除 command 防重放记录、活动审计、设置或凭证;同一 commandId 重放也不会重建已清空的消息。 - - - { - setConfirmingClear(false) - setFeedback(null) - }} - style={styles.flexButton} - variant="secondary" - visualLabel="取消" - /> - { void clearInbox() }} - style={styles.flexButton} - variant="danger" - visualLabel={isClearing ? '正在清空…' : '确认清空'} - /> + return ( + <> + setSheet('more')} ref={moreTriggerRef} style={styles.iconButton}> + + + )} + title="信箱" + tone="reading" + toolbar={toolbar} + > + {searching ? “{viewOptions.searchQuery}”{unreadOnly ? '的未读结果' : '的搜索结果'} : null} + {messages.length === 0 ? ( + + ) : ( + + {messages.map(message => onOpenMessage(message.messageId)} />)} - - )} - + )} + {sheet === null ? feedbackText : null} + + + {sheet === 'sort' ? ( + + {SORT_OPTIONS.map(option => ( + { void changeSort(option.value) }} + role="radio" + selected={viewOptions.sort === option.value} + variant="secondary" + visualLabel={option.label} + /> + ))} + + ) : confirmingClear ? ( + + 确认清空本机信箱? + 此操作不可恢复,只删除本机信箱消息。它不会删除 command 防重放记录、活动审计、设置或凭证;同一 commandId 重放也不会重建已清空的消息。 + + { setConfirmingClear(false); setFeedback(null) }} style={styles.flexButton} variant="secondary" visualLabel="取消" /> + { void clearInbox() }} style={styles.flexButton} variant="danger" visualLabel={isClearing ? '正在清空…' : '确认清空'} /> + + + ) : ( + + {unreadCount === 0 ? null : { void markAllRead() }} variant="secondary" visualLabel={isMarkingAll ? '标记中…' : '全部标为已读'} />} + { setFeedback(null); setConfirmingClear(true) }} ref={clearTriggerRef} variant="secondary" /> + 操作范围为本机全部消息,包含当前筛选以外的消息。 + + )} + {feedbackText} + + ) } -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ actionRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.md }, - confirmation: { - backgroundColor: colors.panel, - borderColor: colors.danger, - borderRadius: radius.lg, - borderWidth: 1, - gap: spacing.md, - padding: spacing.lg, - }, - confirmationBody: { color: colors.text, fontSize: 15, lineHeight: 22 }, - confirmationTitle: { color: colors.text, fontSize: 18, fontWeight: '800' }, - empty: { color: colors.muted, fontSize: 15, textAlign: 'center' }, - emptyCard: { - alignItems: 'center', - backgroundColor: colors.panel, - borderColor: colors.border, - borderRadius: radius.lg, - borderWidth: 1, - gap: spacing.md, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.xxl, - }, - feedback: { - backgroundColor: colors.panel, - borderColor: colors.warning, - borderRadius: radius.md, - borderWidth: 1, - color: colors.warning, - fontSize: 14, - lineHeight: 20, - overflow: 'hidden', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.md, - }, - flexButton: { flexBasis: 140, flexGrow: 1 }, - item: { - alignItems: 'center', - backgroundColor: colors.panel, - borderColor: colors.border, - borderRadius: radius.lg, - borderWidth: 1, - columnGap: spacing.sm, - flexDirection: 'row', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.md, - }, - itemBody: { - flexGrow: 1, - flexShrink: 1, - gap: spacing.xs, - }, - itemCaller: { - color: colors.muted, - flexShrink: 1, - fontSize: 13, - }, - itemHeader: { - alignItems: 'center', - columnGap: spacing.sm, - flexDirection: 'row', - }, - itemMetaLine: { - alignItems: 'center', - columnGap: spacing.sm, - flexDirection: 'row', - }, - itemPressed: { - opacity: 0.7, - }, - itemSummary: { - color: colors.muted, - fontSize: 14, - lineHeight: 20, - }, - itemTime: { - color: colors.muted, - fontSize: 12, - }, - itemTitle: { - color: colors.text, - flexGrow: 1, - flexShrink: 1, - fontSize: 16, - fontWeight: '600', - }, - itemTitleUnread: { - fontWeight: '800', - }, - list: { - gap: spacing.sm, - }, - markAllButton: { - alignItems: 'center', - borderRadius: radius.sm, - justifyContent: 'center', - minHeight: 36, - paddingHorizontal: spacing.md, - }, - markAllText: { - color: colors.primary, - fontSize: 14, - fontWeight: '700', - }, - searchBar: { - alignItems: 'center', - backgroundColor: colors.panelElevated, - borderColor: colors.outline, - borderRadius: radius.md, - borderWidth: 1, - columnGap: spacing.sm, - flexDirection: 'row', - paddingHorizontal: spacing.lg, - }, - searchInput: { - color: colors.text, - flexGrow: 1, - flexShrink: 1, - fontSize: 16, - minHeight: 48, - paddingVertical: spacing.md, - }, - sortChip: { - alignItems: 'center', - borderColor: colors.outline, - borderRadius: 999, - borderWidth: 1, - justifyContent: 'center', - minHeight: 36, - paddingHorizontal: spacing.md, - }, - sortChipSelected: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - sortChipText: { - color: colors.muted, - fontSize: 13, - fontWeight: '700', - }, - sortChipTextSelected: { - color: colors.background, - }, - sortRow: { - alignItems: 'center', - columnGap: spacing.sm, - flexDirection: 'row', - flexWrap: 'wrap', - rowGap: spacing.sm, - }, - sortSpacer: { - flexGrow: 1, - }, - unreadDot: { - backgroundColor: 'transparent', - borderRadius: 5, - height: 10, - width: 10, - }, - unreadDotActive: { - backgroundColor: colors.primary, - }, - urgencyCritical: { - backgroundColor: colors.danger, - color: colors.background, - }, - urgencyHigh: { - backgroundColor: colors.warning, - color: colors.background, - }, - urgencyTag: { - borderRadius: radius.sm, - fontSize: 11, - fontWeight: '800', - overflow: 'hidden', - paddingHorizontal: spacing.sm, - paddingVertical: 1, - }, + confirmationBody: { color: colors.muted, fontSize: 15, lineHeight: 24 }, + confirmationTitle: { color: colors.text, fontSize: 20, fontWeight: '700' }, + feedback: { color: colors.warning, backgroundColor: colors.warningSoft, borderRadius: radius.sm, padding: spacing.md, fontSize: 14, lineHeight: 21 }, + flexButton: { flexBasis: 120, flexGrow: 1 }, + sheetContent: { gap: spacing.md }, + scopeNote: { color: colors.muted, fontSize: 13, lineHeight: 20 }, + iconButton: { minWidth: 48, minHeight: 48, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md }, + item: { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth, paddingVertical: spacing.xl }, + itemBody: { gap: spacing.sm }, + itemMetaLine: { alignItems: 'center', gap: spacing.sm, flexDirection: 'row' }, + itemCaller: { color: colors.muted, flex: 1, fontSize: 13, lineHeight: 18 }, + itemCallerUnread: { color: colors.text, fontWeight: '600' }, + itemTime: { color: colors.muted, fontSize: 12, flexShrink: 0 }, + itemTitle: { color: colors.text, fontSize: 18, fontWeight: '500', lineHeight: 26, letterSpacing: -0.2 }, + itemTitleUnread: { fontWeight: '700' }, + itemSummary: { color: colors.muted, fontSize: 14, lineHeight: 21 }, + itemPressed: { opacity: 0.65 }, + list: { marginTop: -spacing.lg }, + toolbar: { gap: spacing.xs }, + searchBar: { alignItems: 'center', backgroundColor: colors.panelElevated, borderRadius: radius.md, gap: spacing.sm, flexDirection: 'row', paddingLeft: spacing.md }, + searchInput: { color: colors.text, flex: 1, fontSize: 16, minHeight: 48, paddingVertical: spacing.md, paddingRight: spacing.md }, + filterRow: { alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm, flexDirection: 'row' }, + filterTabs: { flexDirection: 'row', gap: spacing.lg }, + filterTab: { alignItems: 'center', flexDirection: 'row', gap: spacing.sm, minHeight: 48, minWidth: 48, justifyContent: 'center', paddingHorizontal: spacing.xs, borderBottomWidth: 2, borderBottomColor: 'transparent' }, + filterTabSelected: { borderBottomColor: colors.primary }, + filterText: { color: colors.muted, fontSize: 15, fontWeight: '600' }, + filterTextSelected: { color: colors.primary }, + unreadCount: { color: colors.primary, backgroundColor: colors.primarySoft, fontSize: 11, fontWeight: '700', borderRadius: 6, overflow: 'hidden', paddingHorizontal: 6, paddingVertical: 2 }, + sortButton: { alignItems: 'center', flexDirection: 'row', gap: spacing.sm, minHeight: 48, paddingHorizontal: spacing.sm }, + sortLabel: { color: colors.muted, fontSize: 13 }, + resultSummary: { color: colors.muted, fontSize: 13, lineHeight: 20 }, + unreadDot: { backgroundColor: 'transparent', borderRadius: 3, height: 6, width: 6 }, + unreadDotActive: { backgroundColor: colors.primary }, + urgencyCritical: { color: colors.danger }, + urgencyHigh: { color: colors.warning }, + urgencyTag: { alignSelf: 'flex-start', fontSize: 11, fontWeight: '600' }, }) diff --git a/src/ui/screens/MediaScreen.tsx b/src/ui/screens/MediaScreen.tsx index a247272..2242a74 100644 --- a/src/ui/screens/MediaScreen.tsx +++ b/src/ui/screens/MediaScreen.tsx @@ -2,10 +2,11 @@ import { StyleSheet, Text, View } from 'react-native' import { useDiscreteAccessibilityAnnouncement } from '@/ui/accessibility' import { AccessibleAction, type ActionVariant } from '@/ui/components/AccessibleAction' +import { EmptyState } from '@/ui/components/EmptyState' import { Icon, type IconName } from '@/ui/components/Icon' import { Screen } from '@/ui/components/Screen' import { StatusCard, StatusRow } from '@/ui/components/StatusCard' -import { colors, radius, spacing } from '@/ui/theme' +import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' import type { MediaSessionSnapshot } from '@/capabilities/media/controller' @@ -26,6 +27,8 @@ export function MediaScreen({ onStop, session, }: MediaScreenProps) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) useDiscreteAccessibilityAnnouncement( `media:${session?.sessionId ?? 'none'}:${session?.state ?? 'none'}`, session === null ? '当前没有媒体会话' : `媒体状态已变为 ${session.state}`, @@ -33,21 +36,24 @@ export function MediaScreen({ return ( {session === null ? ( - - - 暂无 App 自有媒体会话。 - + ) : ( - - - + + + + {session.sourceHost} + + + 0 ? Math.max(0, Math.min(100, session.currentTimeSeconds / session.durationSeconds * 100)) : 0}%` }]} /> + )} + + + 仅播放设备允许的来源;完整 URL 不在此显示。 )} @@ -99,6 +108,7 @@ function Action({ variant?: ActionVariant visualLabel: string }>) { + const styles = useThemedStyles(createStyles) return ( StyleSheet.create({ + artwork: { alignItems: 'center', justifyContent: 'center', backgroundColor: colors.primarySoft, borderRadius: radius.lg, paddingVertical: spacing.xxl, gap: spacing.lg }, + disc: { alignItems: 'center', justifyContent: 'center', width: 132, height: 132, borderRadius: 66, borderWidth: 1, borderColor: colors.primary, backgroundColor: colors.panel }, + source: { color: colors.primary, fontSize: 13 }, + progressTrack: { height: 6, borderRadius: 3, backgroundColor: colors.panelElevated, overflow: 'hidden' }, + progressFill: { height: 6, borderRadius: 3, backgroundColor: colors.primary }, + privacyNote: { color: colors.muted, fontSize: 12, lineHeight: 18 }, action: { flexBasis: 120, flexGrow: 1, @@ -122,19 +138,4 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', gap: spacing.md, }, - empty: { - color: colors.muted, - fontSize: 15, - textAlign: 'center', - }, - emptyCard: { - alignItems: 'center', - backgroundColor: colors.panel, - borderColor: colors.border, - borderRadius: radius.lg, - borderWidth: 1, - gap: spacing.md, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.xxl, - }, }) diff --git a/src/ui/screens/SettingsScreen.tsx b/src/ui/screens/SettingsScreen.tsx index 69150a1..76eeef2 100644 --- a/src/ui/screens/SettingsScreen.tsx +++ b/src/ui/screens/SettingsScreen.tsx @@ -2,336 +2,149 @@ import { Pressable, StyleSheet, Text, View } from 'react-native' import { useDiscreteAccessibilityAnnouncement } from '@/ui/accessibility' import { AccessibleAction } from '@/ui/components/AccessibleAction' -import { GatewayConfigurationCard } from '@/ui/components/GatewayConfigurationCard' import { Icon, type IconName } from '@/ui/components/Icon' import { Screen } from '@/ui/components/Screen' -import { SettingToggle } from '@/ui/components/SettingToggle' -import { StatusCard } from '@/ui/components/StatusCard' -import { colors, radius, spacing } from '@/ui/theme' +import { SectionHeading } from '@/ui/components/SectionHeading' +import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ui/theme' import type { ControlMode } from '@/commands/types' -import type { ManualGatewayConfigurationInput } from '@/identity/manualGatewayCredential' import type { ApplicationSnapshot } from '@/runtime/applicationRuntime' -const CONTROL_MODE_OPTIONS: readonly Readonly<{ - hint: string - label: string - mode: Exclude -}>[] = [ - { - hint: '每条有副作用的命令都在设备上逐次确认', - label: '每次确认', - mode: 'ask_every_time', - }, - { - hint: '低/中风险命令直接执行,高风险仍逐次确认', - label: '信任会话(非高危直调)', - mode: 'trusted_session', - }, - { - hint: '所有命令(含高风险)直接执行,不再询问;仅紧急停用可中断', - label: '允许直接调用(含高危)', - mode: 'direct_call', - }, -] +const MODE_LABELS: Record = { + ask_every_time: '每次确认', + direct_call: '直接调用', + disabled: '远程能力已停用', + trusted_session: '信任会话', +} type SettingsScreenProps = Readonly<{ focused?: boolean - onClearGatewayConfiguration(): Promise onEmergencyDisable(): void onEnable(): void + onOpenActivity(): void onOpenCapabilities(): void - onOpenCameraSettings(): void + onOpenConnection(): void + onOpenControls(): void onOpenMedia(): void - onOpenNotificationSettings(): void onOpenStatus(): void - onRequestNotificationPermission(): void - onRequestCameraPermission(): void - onSaveGatewayConfiguration(input: ManualGatewayConfigurationInput): Promise - onSetBackgroundRuntime(enabled: boolean): void - onSetControlMode(mode: ControlMode): void snapshot: ApplicationSnapshot }> export function SettingsScreen({ focused = true, - onClearGatewayConfiguration, onEmergencyDisable, onEnable, + onOpenActivity, onOpenCapabilities, - onOpenCameraSettings, + onOpenConnection, + onOpenControls, onOpenMedia, - onOpenNotificationSettings, onOpenStatus, - onRequestNotificationPermission, - onRequestCameraPermission, - onSaveGatewayConfiguration, - onSetBackgroundRuntime, - onSetControlMode, snapshot, }: SettingsScreenProps) { - const isDisabled = snapshot.controlMode === 'disabled' - const notificationAvailability = snapshot.capabilities.find(({ descriptor }) => ( - descriptor.path === 'phone/productivity' && descriptor.tool === 'notify' - ))?.availability - const notificationSettingsRequired = notificationAvailability?.status === 'unavailable' - && ( - notificationAvailability.reason === 'notification_permission_denied' - || notificationAvailability.reason === 'notification_channel_disabled' - ) - const notificationPermissionRequestable = notificationAvailability?.status === 'unavailable' - && notificationAvailability.reason === 'notification_permission_requestable' - const cameraAvailability = snapshot.capabilities.find(({ descriptor }) => ( - descriptor.path === 'phone/camera' && descriptor.tool === 'capture_photo' - ))?.availability - const cameraPermissionRequestable = cameraAvailability?.status === 'permission_required' - && cameraAvailability.reason === 'camera_permission_required' - const cameraSettingsRequired = cameraAvailability?.status === 'unavailable' - && cameraAvailability.reason === 'camera_permission_denied' + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) + const disabled = snapshot.controlMode === 'disabled' + const connected = snapshot.transportState === 'ready' + const deviceId = snapshot.deviceId ?? snapshot.defaultDeviceId + const availableCount = snapshot.capabilities.filter(item => item.availability.status === 'available').length useDiscreteAccessibilityAnnouncement( - `control-mode:${snapshot.controlMode}`, - `控制模式已变为 ${snapshot.controlMode}`, - ) - useDiscreteAccessibilityAnnouncement( - `background:${snapshot.backgroundRuntimeEnabled}`, - snapshot.backgroundRuntimeEnabled ? '后台运行已开启' : '后台运行已关闭', + `device-control:${snapshot.controlMode}`, + `控制模式已变为 ${MODE_LABELS[snapshot.controlMode]}`, ) return ( - - - - 查看运行时状态、已探测的设备能力与媒体会话。这些页面只读,不改变裁决配置。 - - - - - - - {isDisabled ? ( - - - 当前处于紧急停用状态:所有新命令在本地策略层被拒绝。恢复后才能调整其他设置。 - - - - ) : ( - <> - - - 选择 Agent 命令在本机的裁决强度。 - - {CONTROL_MODE_OPTIONS.map(option => { - const active = snapshot.controlMode === option.mode - return ( - { onSetControlMode(option.mode) }} - variant={active ? 'primary' : 'secondary'} - {...(active ? { icon: 'positive' as const } : {})} - /> - ) - })} - {snapshot.controlMode === 'direct_call' ? ( - - - - 直接调用模式下高特权工具(shell、剪贴板、任意 URL/Intent)可被 Agent 直接执行; - 前台相机也会在可见预览就绪后自动拍摄并上传。请仅在你完全信任当前网关与 Agent 时启用。 - - - ) : null} - - - - - - - - - {cameraPermissionRequestable ? ( - - - 相机只用于前台可见预览和单张拍摄;不会申请麦克风、图库或后台相机权限。 - - - - ) : null} - - {cameraSettingsRequired ? ( - - - 系统相机权限已被永久拒绝;远程命令和直接调用模式都不能绕过该设置。 - - - - ) : null} + + + + 当前设备 + {deviceId ?? '设备身份准备中'} + + + {connected ? '已连接网关' : '网关尚未就绪'} + + {deviceId === null ? '初始化完成后显示本机设备 ID。' : '这是 Agent 连接与识别这台设备时使用的 ID。'} + - {notificationPermissionRequestable ? ( - - - Tool Bridge 只在你主动允许后创建可见的即时通知;远程命令不会弹出系统权限框。 - - - - ) : null} + + + + + - {notificationSettingsRequired ? ( - - - 系统通知权限或 Tool Bridge 本地通知 channel 已关闭;远程命令无权改变该设置。 - - - - ) : null} + {snapshot.controlMode === 'direct_call' ? ( + + + 直接调用已开启,包括高风险命令。系统权限与设备限制仍然有效;可随时紧急停用。 + + ) : null} + + + + + + + + - - - 立即拒绝所有新命令并停止仍可撤销的本地副作用。系统权限与用户拒绝始终优先。 - - - - - )} + + + {disabled ? '新命令当前均被拒绝。恢复后,仍需在本机逐次确认。' : '立即拒绝新命令,并停止仍可撤销的本地操作。'} + ) } -function NavRow({ - hint, - icon, - label, - onPress, -}: Readonly<{ hint: string; icon: IconName; label: string; onPress(): void }>) { +function NavRow({ description, icon, label, onPress }: Readonly<{ + description: string + icon: IconName + label: string + onPress(): void +}>) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) return ( [styles.navRow, pressed ? styles.navRowPressed : null]} + style={({ pressed }) => [styles.row, pressed ? styles.pressed : null]} > - - + + + {label} + {description} - {label} ) } -const styles = StyleSheet.create({ - body: { - color: colors.muted, - fontSize: 15, - lineHeight: 22, - }, - navRow: { - alignItems: 'center', - backgroundColor: colors.panelElevated, - borderColor: colors.outline, - borderRadius: radius.md, - borderWidth: 1, - columnGap: spacing.md, - flexDirection: 'row', - minHeight: 48, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.md, - }, - navRowIcon: { - alignItems: 'center', - backgroundColor: colors.panel, - borderRadius: radius.sm, - height: 30, - justifyContent: 'center', - width: 30, - }, - navRowLabel: { - color: colors.text, - flexGrow: 1, - flexShrink: 1, - fontSize: 16, - fontWeight: '700', - }, - navRowPressed: { - opacity: 0.7, - }, - warningNote: { - alignItems: 'flex-start', - backgroundColor: colors.panelElevated, - borderRadius: radius.sm, - columnGap: spacing.sm, - flexDirection: 'row', - padding: spacing.md, - }, - warningText: { - color: colors.warning, - flexShrink: 1, - fontSize: 13, - lineHeight: 19, - }, +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + identity: { alignItems: 'center', paddingVertical: spacing.xxl, paddingHorizontal: spacing.lg, gap: spacing.sm }, + deviceMark: { alignItems: 'center', justifyContent: 'center', width: 88, height: 88, borderRadius: 28, backgroundColor: colors.primarySoft, marginBottom: spacing.md }, + deviceCaption: { color: colors.muted, fontSize: 12, fontWeight: '600' }, + deviceId: { color: colors.text, fontSize: 25, fontWeight: '700', textAlign: 'center' }, + connectionStatus: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingVertical: spacing.xs }, + connectionLabel: { color: colors.muted, fontSize: 13, fontWeight: '600' }, + connectedLabel: { color: colors.success }, + identityNote: { color: colors.muted, fontSize: 12, lineHeight: 18, textAlign: 'center' }, + group: { backgroundColor: colors.panel, borderRadius: radius.lg, overflow: 'hidden' }, + row: { minHeight: 76, paddingHorizontal: spacing.lg, paddingVertical: spacing.lg, gap: spacing.lg, flexDirection: 'row', alignItems: 'center', borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth }, + rowCopy: { flex: 1, gap: spacing.xs }, + rowTitle: { color: colors.text, fontSize: 16, fontWeight: '600' }, + rowDescription: { color: colors.muted, fontSize: 13, lineHeight: 19 }, + pressed: { backgroundColor: colors.panelElevated }, + warning: { flexDirection: 'row', gap: spacing.sm, backgroundColor: colors.warningSoft, borderRadius: radius.md, padding: spacing.md }, + warningText: { flex: 1, color: colors.warning, fontSize: 13, lineHeight: 20 }, + safetyControl: { gap: spacing.sm, paddingTop: spacing.sm }, + safetyNote: { color: colors.muted, fontSize: 12, lineHeight: 18, textAlign: 'center' }, }) diff --git a/src/ui/screens/__tests__/ActivityScreen.test.tsx b/src/ui/screens/__tests__/ActivityScreen.test.tsx index a757d49..4c56dc3 100644 --- a/src/ui/screens/__tests__/ActivityScreen.test.tsx +++ b/src/ui/screens/__tests__/ActivityScreen.test.tsx @@ -22,8 +22,9 @@ const record: AuditRecord = { describe('ActivityScreen', () => { test('显示来源、时间、能力边界、决策和结果,但不展示载荷字段', async () => { + const onBack = jest.fn() const rendered = await render( - , + , ) rendered.getByRole('header', { name: '活动' }) @@ -37,6 +38,8 @@ describe('ActivityScreen', () => { rendered.getByText(/最近 100 条/) rendered.getByText(/最多保留 5,000 条/) expect(rendered.queryByText('command_01')).toBeNull() + await fireEvent.press(rendered.getByRole('button', { name: '设备' })) + expect(onBack).toHaveBeenCalledTimes(1) }) test('清除前明确二次确认;取消不会删除', async () => { diff --git a/src/ui/screens/__tests__/ConnectionSettingsScreen.test.tsx b/src/ui/screens/__tests__/ConnectionSettingsScreen.test.tsx new file mode 100644 index 0000000..29dfe86 --- /dev/null +++ b/src/ui/screens/__tests__/ConnectionSettingsScreen.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render } from '@testing-library/react-native' + +import { ConnectionSettingsScreen } from '../ConnectionSettingsScreen' + +import type { ApplicationSnapshot } from '@/runtime/applicationRuntime' + +const snapshot: ApplicationSnapshot = { + appState: 'active', + attentionSession: null, + auditRecords: [], + backgroundRuntimeEnabled: false, + cameraCaptureRequest: null, + capabilities: [], + controlMode: 'ask_every_time', + defaultDeviceId: null, + deviceId: null, + error: null, + gatewayOrigin: null, + installationId: 'installation_00000000-0000-4000-8000-000000000000', + inboxMessages: [], + inboxUnreadCount: 0, + inboxViewOptions: { searchQuery: '', sort: 'received_desc' }, + mediaSession: null, + mountPath: null, + pendingConfirmations: [], + phase: 'ready', + reachability: 'unconfigured', + timers: [], + transportDiagnostic: null, + transportIssue: null, + transportState: 'unconfigured', +} + +describe('连接配置页面', () => { + test('独立页提供返回入口且网关表单继续向真实保存回调提交', async () => { + const onBack = jest.fn() + const onSave = jest.fn(async () => undefined) + const screen = await render( undefined)} onSave={onSave} snapshot={snapshot} />) + await fireEvent.changeText(screen.getByLabelText('Gateway HTTPS URL'), 'https://gateway.example.com') + await fireEvent.changeText(screen.getByLabelText('Tool Bridge API key'), 'test_secret') + await fireEvent.press(screen.getByRole('button', { name: '保存 Gateway URL 和 API key 并连接' })) + expect(onSave).toHaveBeenCalledWith({ origin: 'https://gateway.example.com', apiKey: 'test_secret' }) + await fireEvent.press(screen.getByRole('button', { name: '设备' })) + expect(onBack).toHaveBeenCalledTimes(1) + }) + + test('停用时不展示凭证编辑入口,要求先回设备页恢复', async () => { + const screen = await render( undefined)} onSave={jest.fn(async () => undefined)} snapshot={{ ...snapshot, controlMode: 'disabled' }} />) + expect(screen.queryByLabelText('Tool Bridge API key')).toBeNull() + screen.getByText(/请返回设备页恢复为每次确认/) + }) +}) diff --git a/src/ui/screens/__tests__/ControlSettingsScreen.test.tsx b/src/ui/screens/__tests__/ControlSettingsScreen.test.tsx new file mode 100644 index 0000000..7c20331 --- /dev/null +++ b/src/ui/screens/__tests__/ControlSettingsScreen.test.tsx @@ -0,0 +1,189 @@ +import { fireEvent, render } from '@testing-library/react-native' + +import { ControlSettingsScreen } from '../ControlSettingsScreen' + +import type { ApplicationSnapshot } from '@/runtime/applicationRuntime' +import type { ComponentProps } from 'react' + +type TestProps = Omit, 'onBack'> + +function renderControls(props: TestProps) { + return render() +} + +const snapshot: ApplicationSnapshot = { + appState: 'active', + attentionSession: null, + auditRecords: [], + backgroundRuntimeEnabled: false, + cameraCaptureRequest: null, + capabilities: [], + controlMode: 'ask_every_time', + defaultDeviceId: null, + deviceId: null, + error: null, + gatewayOrigin: null, + installationId: 'installation_00000000-0000-4000-8000-000000000000', + inboxMessages: [], + inboxUnreadCount: 0, + inboxViewOptions: { searchQuery: '', sort: 'received_desc' }, + mediaSession: null, + mountPath: null, + pendingConfirmations: [], + phase: 'ready', + reachability: 'unconfigured', + timers: [], + transportDiagnostic: null, + transportIssue: null, + transportState: 'unconfigured', +} + +const baseHandlers = { + onEmergencyDisable: jest.fn(), + onEnable: jest.fn(), + onOpenCameraSettings: jest.fn(), + onOpenNotificationSettings: jest.fn(), + onRequestNotificationPermission: jest.fn(), + onRequestCameraPermission: jest.fn(), + onSetBackgroundRuntime: jest.fn(), + onSetControlMode: jest.fn(), +} + +describe('ControlSettingsScreen', () => { + test('切换控制模式为直接调用并给出高特权提示', async () => { + const onSetControlMode = jest.fn() + const rendered = await renderControls({ ...baseHandlers, onSetControlMode, snapshot }) + await fireEvent.press(rendered.getByRole('button', { name: '允许直接调用(含高危)' })) + expect(onSetControlMode).toHaveBeenCalledWith('direct_call') + }) + + test('direct_call 模式展示高特权工具警告', async () => { + const rendered = await renderControls({ + ...baseHandlers, + snapshot: { ...snapshot, controlMode: 'direct_call' }, + }) + rendered.getByText(/高特权工具(shell、剪贴板、任意 URL\/Intent)/) + rendered.getByText(/前台相机也会在可见预览就绪后自动拍摄并上传/) + }) + + test('后台运行开关切换并传出新值', async () => { + const onSetBackgroundRuntime = jest.fn() + const rendered = await renderControls({ ...baseHandlers, onSetBackgroundRuntime, snapshot }) + await fireEvent.press(rendered.getByRole('switch', { name: '允许后台运行' })) + expect(onSetBackgroundRuntime).toHaveBeenCalledWith(true) + }) + + test('后台开关反映当前 checked 状态', async () => { + const rendered = await renderControls({ + ...baseHandlers, + snapshot: { ...snapshot, backgroundRuntimeEnabled: true }, + }) + const toggle = rendered.getByRole('switch', { name: '允许后台运行' }) + expect(toggle.props.accessibilityState).toMatchObject({ checked: true }) + }) + + test('紧急停用触发回调', async () => { + const onEmergencyDisable = jest.fn() + const rendered = await renderControls({ ...baseHandlers, onEmergencyDisable, snapshot }) + await fireEvent.press(rendered.getByRole('button', { name: '紧急停用远程能力' })) + expect(onEmergencyDisable).toHaveBeenCalledTimes(1) + }) + + test('Disabled 状态只提供恢复入口,隐藏其他设置', async () => { + const onEnable = jest.fn() + const rendered = await renderControls({ + ...baseHandlers, + onEnable, + snapshot: { ...snapshot, controlMode: 'disabled', reachability: 'disabled' }, + }) + expect(rendered.queryByRole('switch', { name: '允许后台运行' })).toBeNull() + await fireEvent.press(rendered.getByRole('button', { name: '恢复为每次确认' })) + expect(onEnable).toHaveBeenCalledTimes(1) + }) + + test('系统仍允许请求的通知权限只能由本地 UI 触发', async () => { + const onRequestNotificationPermission = jest.fn() + const rendered = await renderControls({ + ...baseHandlers, + onRequestNotificationPermission, + snapshot: { + ...snapshot, + capabilities: [{ + availability: { reason: 'notification_permission_requestable', status: 'unavailable' }, + descriptor: { + confirmation: 'when_locked', + description: '创建本地通知', + effect: 'write', + limits: { maxResultBytes: 2_048, rate: { maxGlobal: 10, maxPerCaller: 5, windowSeconds: 60 } }, + path: 'phone/productivity', + queuePolicy: 'reject_offline', + risk: 'medium', + tool: 'notify', + }, + }], + }, + }) + await fireEvent.press(rendered.getByRole('button', { name: '启用本地通知' })) + expect(onRequestNotificationPermission).toHaveBeenCalledTimes(1) + }) + + test('相机权限可请求时只能由本地 UI 启用', async () => { + const onRequestCameraPermission = jest.fn() + const rendered = await renderControls({ + ...baseHandlers, + onRequestCameraPermission, + snapshot: { + ...snapshot, + capabilities: [{ + availability: { + permission: 'camera', + reason: 'camera_permission_required', + status: 'permission_required', + }, + descriptor: { + confirmation: 'always', + description: '拍摄照片', + effect: 'write', + limits: { maxResultBytes: 4_096, rate: { maxGlobal: 4, maxPerCaller: 2, windowSeconds: 60 } }, + path: 'phone/camera', + queuePolicy: 'reject_offline', + risk: 'high', + tool: 'capture_photo', + }, + }], + }, + }) + await fireEvent.press(rendered.getByRole('button', { name: '启用前台相机' })) + expect(onRequestCameraPermission).toHaveBeenCalledTimes(1) + }) + + test('拒绝相机权限后只允许去系统设置,不重新请求权限;独立页可以返回设备', async () => { + const onBack = jest.fn() + const onOpenCameraSettings = jest.fn() + const onRequestCameraPermission = jest.fn() + const screen = await render() + expect(screen.queryByRole('button', { name: '启用前台相机' })).toBeNull() + expect(onRequestCameraPermission).not.toHaveBeenCalled() + await fireEvent.press(screen.getByRole('button', { name: '打开相机设置' })) + expect(onOpenCameraSettings).toHaveBeenCalledTimes(1) + await fireEvent.press(screen.getByRole('button', { name: '设备' })) + expect(onBack).toHaveBeenCalledTimes(1) + }) + +}) diff --git a/src/ui/screens/__tests__/HomeScreen.test.tsx b/src/ui/screens/__tests__/HomeScreen.test.tsx index 9fef236..9420e0e 100644 --- a/src/ui/screens/__tests__/HomeScreen.test.tsx +++ b/src/ui/screens/__tests__/HomeScreen.test.tsx @@ -57,10 +57,10 @@ describe('HomeScreen', () => { rendered.getByLabelText('后台运行:已开启') }) - test('提供前往设置入口', async () => { + test('提供返回设备管理入口', async () => { const onOpenSettings = jest.fn() const rendered = await renderHome(readySnapshot, { onOpenSettings }) - await fireEvent.press(rendered.getByRole('button', { name: '前往设置' })) + await fireEvent.press(rendered.getByRole('button', { name: '返回设备管理' })) expect(onOpenSettings).toHaveBeenCalledTimes(1) }) diff --git a/src/ui/screens/__tests__/InboxScreen.test.tsx b/src/ui/screens/__tests__/InboxScreen.test.tsx index c4e53c3..a4484d9 100644 --- a/src/ui/screens/__tests__/InboxScreen.test.tsx +++ b/src/ui/screens/__tests__/InboxScreen.test.tsx @@ -61,8 +61,8 @@ describe('InboxScreen', () => { rendered.getByRole('button', { name: '已读,产品更新,来自 caller_daily,1 小时前' }) // 摘要是纯视觉补充,对读屏软件隐藏(整行以合成 label 呈现),需显式包含隐藏元素查询。 rendered.getByText(/今日重点 三条 值得阅读 的更新。/, { includeHiddenElements: true }) - // 未读计数投影到 eyebrow 徽标(视觉补充,对读屏隐藏)。 - rendered.getByText('1 条未读', { includeHiddenElements: true }) + // 未读计数显示在过滤工具栏。 + rendered.getByText('1') // 敏感 command id 不出现在列表中。 expect(rendered.queryByText(/sensitive_command_id/)).toBeNull() }) @@ -90,6 +90,7 @@ describe('InboxScreen', () => { sort: 'received_desc', })) + await fireEvent.press(rendered.getByRole('button', { name: '选择信箱排序' })) await fireEvent.press(rendered.getByRole('radio', { name: '信箱排序:未读优先' })) await waitFor(() => expect(onViewOptionsChange).toHaveBeenCalledWith({ searchQuery: 'Daily', @@ -101,6 +102,7 @@ describe('InboxScreen', () => { const onMarkAllRead = jest.fn(async () => 1) const rendered = await render() + await fireEvent.press(rendered.getByRole('button', { name: '信箱更多操作' })) await fireEvent.press(rendered.getByRole('button', { name: '将本机信箱全部标为已读' })) await waitFor(() => rendered.getByText('已将 1 条本机信箱消息标为已读。')) expect(onMarkAllRead).toHaveBeenCalledTimes(1) @@ -108,6 +110,7 @@ describe('InboxScreen', () => { test('无未读时不展示全部已读入口', async () => { const rendered = await render() + await fireEvent.press(rendered.getByRole('button', { name: '信箱更多操作' })) expect(rendered.queryByRole('button', { name: '将本机信箱全部标为已读' })).toBeNull() rendered.getByText('信箱') }) @@ -116,6 +119,8 @@ describe('InboxScreen', () => { const onClearInbox = jest.fn(async () => 2) const rendered = await render() + expect(rendered.queryByRole('button', { name: '清空本机信箱' })).toBeNull() + await fireEvent.press(rendered.getByRole('button', { name: '信箱更多操作' })) await fireEvent.press(rendered.getByRole('button', { name: '清空本机信箱' })) rendered.getByRole('header', { name: '确认清空本机信箱?' }) rendered.getByText(/不会删除 command 防重放记录、活动审计、设置或凭证/) @@ -124,13 +129,88 @@ describe('InboxScreen', () => { await waitFor(() => rendered.getByText('已清空 2 条本机信箱消息。')) }) - test('空搜索结果与本地/离线边界表达准确', async () => { + test('空搜索结果与本地内容边界表达准确', async () => { const rendered = await render() rendered.getByText('没有匹配的本机信箱消息。') - rendered.getByText(/离线队列与 push 尚未实现/) + rendered.getByText('“不存在”的搜索结果') }) + + test('未读筛选受快照控制,提交成功并收到新 props 后才更新范围', async () => { + const props = screenProps() + const rendered = await render() + await fireEvent.press(rendered.getByRole('radio', { name: '只看未读消息' })) + await waitFor(() => expect(props.onViewOptionsChange).toHaveBeenCalledWith({ + ...defaultView, unreadOnly: true, + })) + expect(rendered.getByRole('radio', { name: '查看全部消息' }).props.accessibilityState.selected).toBe(true) + rendered.getByRole('button', { name: /已读,产品更新/ }) + const unreadView: InboxViewOptions = { ...defaultView, unreadOnly: true } + await rendered.rerender() + expect(rendered.getByRole('radio', { name: '只看未读消息' }).props.accessibilityState.selected).toBe(true) + expect(rendered.queryByRole('button', { name: /已读,产品更新/ })).toBeNull() + await rendered.rerender() + rendered.getByText('未读消息都处理完了。') + await fireEvent.press(rendered.getByRole('radio', { name: '查看全部消息' })) + await waitFor(() => expect(props.onViewOptionsChange).toHaveBeenLastCalledWith(defaultView)) + await rendered.rerender() + rendered.getByRole('button', { name: /已读,产品更新/ }) + }) + + test('未读过滤失败保留原选中与消息,pending 时阻止重复提交', async () => { + let rejectChange!: (reason: Error) => void + const onViewOptionsChange = jest.fn(() => new Promise((_resolve, reject) => { rejectChange = reject })) + const rendered = await render() + await fireEvent.press(rendered.getByRole('radio', { name: '只看未读消息' })) + expect(rendered.getByRole('radio', { name: '只看未读消息' }).props.accessibilityState) + .toMatchObject({ busy: true, disabled: true, selected: false }) + await fireEvent.press(rendered.getByRole('radio', { name: '只看未读消息' })) + expect(onViewOptionsChange).toHaveBeenCalledTimes(1) + rejectChange(new Error('storage failed')) + await waitFor(() => rendered.getByText('筛选失败;仍显示原来的消息范围。')) + expect(rendered.getByRole('radio', { name: '查看全部消息' }).props.accessibilityState) + .toMatchObject({ busy: false, disabled: false, selected: true }) + rendered.getByRole('button', { name: /已读,产品更新/ }) + }) + + test('未读搜索无结果不误报整箱为空,搜索与排序保留过滤条件', async () => { + const onViewOptionsChange = jest.fn(async () => undefined) + const rendered = await render() + rendered.getByText('没有匹配的未读消息。') + rendered.getByText('“产品”的未读结果') + expect(rendered.queryByText('最近还没有 Agent 来信。')).toBeNull() + await fireEvent.changeText(rendered.getByLabelText('搜索本机信箱'), 'Daily') + await fireEvent(rendered.getByLabelText('搜索本机信箱'), 'submitEditing') + await waitFor(() => expect(onViewOptionsChange).toHaveBeenLastCalledWith({ + searchQuery: 'Daily', sort: 'received_desc', unreadOnly: true, + })) + await fireEvent.press(rendered.getByRole('button', { name: '选择信箱排序' })) + await fireEvent.press(rendered.getByRole('radio', { name: '信箱排序:最早' })) + await waitFor(() => expect(onViewOptionsChange).toHaveBeenLastCalledWith({ + searchQuery: 'Daily', sort: 'received_asc', unreadOnly: true, + })) + }) + + test('更多面板的清空可以取消且失败时保留消息和重试入口', async () => { + const onClearInbox = jest.fn(async () => { throw new Error('storage unavailable') }) + const rendered = await render() + await fireEvent.press(rendered.getByRole('button', { name: '信箱更多操作' })) + await fireEvent.press(rendered.getByRole('button', { name: '清空本机信箱' })) + await fireEvent.press(rendered.getByRole('button', { name: '取消清空本机信箱' })) + expect(onClearInbox).not.toHaveBeenCalled() + await fireEvent.press(rendered.getByRole('button', { name: '清空本机信箱' })) + await fireEvent.press(rendered.getByRole('button', { name: '确认清空本机信箱' })) + await waitFor(() => rendered.getByText('清空失败;本机信箱消息未被确认删除。')) + rendered.getByRole('button', { name: '确认清空本机信箱' }) + await fireEvent.press(rendered.getByRole('button', { name: '取消清空本机信箱' })) + await fireEvent.press(rendered.getByRole('button', { name: '关闭信箱操作' })) + rendered.getByRole('button', { name: /未读,高,今日订阅摘要/ }) + }) + }) diff --git a/src/ui/screens/__tests__/SettingsScreen.test.tsx b/src/ui/screens/__tests__/SettingsScreen.test.tsx index 7747745..4cca87d 100644 --- a/src/ui/screens/__tests__/SettingsScreen.test.tsx +++ b/src/ui/screens/__tests__/SettingsScreen.test.tsx @@ -3,22 +3,6 @@ import { fireEvent, render } from '@testing-library/react-native' import { SettingsScreen } from '../SettingsScreen' import type { ApplicationSnapshot } from '@/runtime/applicationRuntime' -import type { ComponentProps } from 'react' - -type TestProps = Omit< - ComponentProps, - 'onClearGatewayConfiguration' | 'onSaveGatewayConfiguration' -> - -function renderSettings(props: TestProps) { - return render( - undefined)} - onSaveGatewayConfiguration={jest.fn(async () => undefined)} - {...props} - />, - ) -} const snapshot: ApplicationSnapshot = { appState: 'active', @@ -47,144 +31,70 @@ const snapshot: ApplicationSnapshot = { transportState: 'unconfigured', } -const baseHandlers = { - onEmergencyDisable: jest.fn(), - onEnable: jest.fn(), - onOpenCapabilities: jest.fn(), - onOpenCameraSettings: jest.fn(), - onOpenMedia: jest.fn(), - onOpenNotificationSettings: jest.fn(), - onOpenStatus: jest.fn(), - onRequestNotificationPermission: jest.fn(), - onRequestCameraPermission: jest.fn(), - onSetBackgroundRuntime: jest.fn(), - onSetControlMode: jest.fn(), +function handlers() { + return { + onEmergencyDisable: jest.fn(), + onEnable: jest.fn(), + onOpenActivity: jest.fn(), + onOpenCapabilities: jest.fn(), + onOpenConnection: jest.fn(), + onOpenControls: jest.fn(), + onOpenMedia: jest.fn(), + onOpenStatus: jest.fn(), + } } -describe('SettingsScreen', () => { - test('切换控制模式为直接调用并给出高特权提示', async () => { - const onSetControlMode = jest.fn() - const rendered = await renderSettings({ ...baseHandlers, onSetControlMode, snapshot }) - await fireEvent.press(rendered.getByRole('button', { name: '允许直接调用(含高危)' })) - expect(onSetControlMode).toHaveBeenCalledWith('direct_call') - }) - - test('设备信息卡片提供状态、能力与媒体的二级导航入口', async () => { - const onOpenStatus = jest.fn() - const onOpenCapabilities = jest.fn() - const onOpenMedia = jest.fn() - const rendered = await renderSettings({ - ...baseHandlers, - onOpenCapabilities, - onOpenMedia, - onOpenStatus, - snapshot, - }) - await fireEvent.press(rendered.getByRole('button', { name: '设备状态' })) - await fireEvent.press(rendered.getByRole('button', { name: '设备能力' })) - await fireEvent.press(rendered.getByRole('button', { name: '媒体会话' })) - expect(onOpenStatus).toHaveBeenCalledTimes(1) - expect(onOpenCapabilities).toHaveBeenCalledTimes(1) - expect(onOpenMedia).toHaveBeenCalledTimes(1) - }) - - test('direct_call 模式展示高特权工具警告', async () => { - const rendered = await renderSettings({ - ...baseHandlers, - snapshot: { ...snapshot, controlMode: 'direct_call' }, - }) - rendered.getByText(/高特权工具(shell、剪贴板、任意 URL\/Intent)/) - rendered.getByText(/前台相机也会在可见预览就绪后自动拍摄并上传/) - }) - - test('后台运行开关切换并传出新值', async () => { - const onSetBackgroundRuntime = jest.fn() - const rendered = await renderSettings({ ...baseHandlers, onSetBackgroundRuntime, snapshot }) - await fireEvent.press(rendered.getByRole('switch', { name: '允许后台运行' })) - expect(onSetBackgroundRuntime).toHaveBeenCalledWith(true) - }) - - test('后台开关反映当前 checked 状态', async () => { - const rendered = await renderSettings({ - ...baseHandlers, - snapshot: { ...snapshot, backgroundRuntimeEnabled: true }, - }) - const toggle = rendered.getByRole('switch', { name: '允许后台运行' }) - expect(toggle.props.accessibilityState).toMatchObject({ checked: true }) +describe('设备总览', () => { + test('独立入口导航到连接、安全、能力、运行详情、媒体和活动,不直接展示配置表单', async () => { + const actions = handlers() + const screen = await render() + await fireEvent.press(screen.getByRole('button', { name: '连接配置' })) + await fireEvent.press(screen.getByRole('button', { name: '授权与安全' })) + await fireEvent.press(screen.getByRole('button', { name: '设备能力' })) + await fireEvent.press(screen.getByRole('button', { name: '运行详情' })) + await fireEvent.press(screen.getByRole('button', { name: '媒体会话' })) + await fireEvent.press(screen.getByRole('button', { name: '活动记录' })) + expect(actions.onOpenActivity).toHaveBeenCalledTimes(1) + expect(actions.onOpenConnection).toHaveBeenCalledTimes(1) + expect(actions.onOpenControls).toHaveBeenCalledTimes(1) + expect(actions.onOpenCapabilities).toHaveBeenCalledTimes(1) + expect(actions.onOpenStatus).toHaveBeenCalledTimes(1) + expect(actions.onOpenMedia).toHaveBeenCalledTimes(1) + expect(screen.queryByLabelText('Tool Bridge API key')).toBeNull() + expect(screen.queryByRole('switch', { name: '允许后台运行' })).toBeNull() }) - test('紧急停用触发回调', async () => { - const onEmergencyDisable = jest.fn() - const rendered = await renderSettings({ ...baseHandlers, onEmergencyDisable, snapshot }) - await fireEvent.press(rendered.getByRole('button', { name: '紧急停用远程能力' })) - expect(onEmergencyDisable).toHaveBeenCalledTimes(1) + test('优先显示已连接设备 ID,没有身份时不编造设备名称', async () => { + const screen = await render() + screen.getByText('设备身份准备中') + await screen.rerender() + screen.getByText('local_device') + await screen.rerender() + screen.getByText('gateway_device') + expect(screen.queryByText('local_device')).toBeNull() }) - test('Disabled 状态只提供恢复入口,隐藏其他设置', async () => { - const onEnable = jest.fn() - const rendered = await renderSettings({ - ...baseHandlers, - onEnable, - snapshot: { ...snapshot, controlMode: 'disabled', reachability: 'disabled' }, - }) - expect(rendered.queryByRole('switch', { name: '允许后台运行' })).toBeNull() - await fireEvent.press(rendered.getByRole('button', { name: '恢复为每次确认' })) - expect(onEnable).toHaveBeenCalledTimes(1) + test('只有 SDK ready 显示已连接,网关 origin 本身不代表连接成功', async () => { + const screen = await render() + screen.getByText('网关尚未就绪') + expect(screen.queryByText('已连接网关')).toBeNull() + await screen.rerender() + screen.getByText('已连接网关') }) - test('系统仍允许请求的通知权限只能由本地 UI 触发', async () => { - const onRequestNotificationPermission = jest.fn() - const rendered = await renderSettings({ - ...baseHandlers, - onRequestNotificationPermission, - snapshot: { - ...snapshot, - capabilities: [{ - availability: { reason: 'notification_permission_requestable', status: 'unavailable' }, - descriptor: { - confirmation: 'when_locked', - description: '创建本地通知', - effect: 'write', - limits: { maxResultBytes: 2_048, rate: { maxGlobal: 10, maxPerCaller: 5, windowSeconds: 60 } }, - path: 'phone/productivity', - queuePolicy: 'reject_offline', - risk: 'medium', - tool: 'notify', - }, - }], - }, - }) - await fireEvent.press(rendered.getByRole('button', { name: '启用本地通知' })) - expect(onRequestNotificationPermission).toHaveBeenCalledTimes(1) + test('总览可立即紧急停用,并在停用后恢复为每次确认', async () => { + const actions = handlers() + const screen = await render() + await fireEvent.press(screen.getByRole('button', { name: '紧急停用远程能力' })) + expect(actions.onEmergencyDisable).toHaveBeenCalledTimes(1) + await screen.rerender() + expect(screen.queryByRole('button', { name: '紧急停用远程能力' })).toBeNull() + await fireEvent.press(screen.getByRole('button', { name: '恢复为每次确认' })) + expect(actions.onEnable).toHaveBeenCalledTimes(1) }) - test('相机权限可请求时只能由本地 UI 启用', async () => { - const onRequestCameraPermission = jest.fn() - const rendered = await renderSettings({ - ...baseHandlers, - onRequestCameraPermission, - snapshot: { - ...snapshot, - capabilities: [{ - availability: { - permission: 'camera', - reason: 'camera_permission_required', - status: 'permission_required', - }, - descriptor: { - confirmation: 'always', - description: '拍摄照片', - effect: 'write', - limits: { maxResultBytes: 4_096, rate: { maxGlobal: 4, maxPerCaller: 2, windowSeconds: 60 } }, - path: 'phone/camera', - queuePolicy: 'reject_offline', - risk: 'high', - tool: 'capture_photo', - }, - }], - }, - }) - await fireEvent.press(rendered.getByRole('button', { name: '启用前台相机' })) - expect(onRequestCameraPermission).toHaveBeenCalledTimes(1) + test('直接调用模式在总览保留高风险提示', async () => { + const screen = await render() + screen.getByText(/直接调用已开启,包括高风险命令/) }) }) diff --git a/src/ui/theme.ts b/src/ui/theme.ts index 1de776f..ccff053 100644 --- a/src/ui/theme.ts +++ b/src/ui/theme.ts @@ -1,18 +1,59 @@ -export const colors = { - background: '#08111f', - border: '#26364b', - danger: '#ff6b6b', - muted: '#91a4bd', - outline: '#5a6f8b', - panel: '#111f31', - // 卡片内嵌块与输入框的浅一级底色;文字对比仍需满足 themeContrast gate。 - panelElevated: '#18293f', - primary: '#66d9c8', - text: '#f5f8fc', - warning: '#ffd166', +import { useMemo } from 'react' +import { useColorScheme } from 'react-native' + +// 系统外观是唯一主题来源;切换时不重挂载页面或运行时。 +export const lightColors = { + background: '#f4f5f7', + panel: '#ffffff', + panelElevated: '#eef1f5', + border: '#dde2e9', + outline: '#7b8798', + text: '#192333', + muted: '#5e6b7d', + primary: '#315ecb', + primarySoft: '#eaf0ff', + onPrimary: '#ffffff', + success: '#217553', + successSoft: '#e8f4ee', + danger: '#bd3545', + dangerSoft: '#fff0f1', + onDanger: '#ffffff', + warning: '#8b5b0c', + warningSoft: '#fff4da', } as const -// 统一间距与圆角刻度;组件不再各自硬编码魔法数。 +export type ThemeColors = { readonly [Key in keyof typeof lightColors]: string } + +export const darkColors: ThemeColors = { + background: '#111418', + panel: '#1b2027', + panelElevated: '#252c36', + border: '#333d4a', + outline: '#748296', + text: '#edf1f7', + muted: '#a4afbf', + primary: '#9ab6ff', + primarySoft: '#253551', + onPrimary: '#15213c', + success: '#80cfac', + successSoft: '#203a30', + danger: '#ff9da6', + dangerSoft: '#412930', + onDanger: '#37151d', + warning: '#ebc47f', + warningSoft: '#3b3324', +} + +export function useTheme() { + const isDark = useColorScheme() === 'dark' + return { colors: isDark ? darkColors : lightColors, isDark } +} + +export function useThemedStyles(factory: (colors: ThemeColors) => T): T { + const { colors } = useTheme() + return useMemo(() => factory(colors), [colors, factory]) +} + export const spacing = { lg: 16, md: 12, @@ -23,7 +64,7 @@ export const spacing = { } as const export const radius = { - lg: 18, - md: 14, - sm: 10, + lg: 16, + md: 12, + sm: 8, } as const