Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 7 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/媒体
Expand Down Expand Up @@ -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 字符)。设备声明挂载到
Expand Down Expand Up @@ -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) 留下双端真机证据。

Expand Down
24 changes: 11 additions & 13 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -15,24 +15,29 @@ function tabIcon(inactive: IconName, active: IconName) {
}

export default function TabsLayout() {
const { colors } = useTheme()
const insets = useSafeAreaInsets()
return (
<Tabs
screenOptions={{
headerShown: false,
sceneStyle: { backgroundColor: colors.background },
tabBarActiveBackgroundColor: colors.primarySoft,
tabBarItemStyle: { borderRadius: 12, marginHorizontal: 8 },
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.muted,
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '700',
fontSize: 12,
fontWeight: '600',
},
tabBarStyle: {
backgroundColor: colors.panel,
borderTopColor: colors.border,
borderTopWidth: 1,
elevation: 0,
height: Platform.OS === 'ios' ? 88 : 64,
paddingTop: 6,
height: 64 + insets.bottom,
paddingBottom: Math.max(insets.bottom, 8),
paddingTop: 8,
},
}}
>
Expand All @@ -43,13 +48,6 @@ export default function TabsLayout() {
tabBarIcon: tabIcon(TAB_ICONS.index.inactive, TAB_ICONS.index.active),
}}
/>
<Tabs.Screen
name="activity"
options={{
...TAB_OPTIONS.activity,
tabBarIcon: tabIcon(TAB_ICONS.activity.inactive, TAB_ICONS.activity.active),
}}
/>
<Tabs.Screen
name="settings"
options={{
Expand Down
23 changes: 4 additions & 19 deletions app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,18 @@ import { SettingsScreen } from '@/ui/screens/SettingsScreen'

export default function SettingsRoute() {
const focused = useIsFocused()
const {
clearGatewayConfiguration,
openCameraSettings,
openNotificationSettings,
requestCameraPermission,
requestNotificationPermission,
saveGatewayConfiguration,
setBackgroundRuntimeEnabled,
setControlMode,
snapshot,
} = useRuntime()
const { setControlMode, snapshot } = useRuntime()
return (
<SettingsScreen
focused={focused}
onClearGatewayConfiguration={clearGatewayConfiguration}
onEmergencyDisable={() => { 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}
/>
)
Expand Down
5 changes: 3 additions & 2 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -15,6 +15,7 @@ export default function RootLayout() {
}

function RootContent() {
const { colors, isDark } = useTheme()
const {
approveConfirmation,
failCameraCapture,
Expand All @@ -24,7 +25,7 @@ function RootContent() {
} = useRuntime()
return (
<>
<StatusBar style="light" />
<StatusBar style={isDark ? 'light' : 'dark'} />
<Stack screenOptions={{ contentStyle: { backgroundColor: colors.background }, headerShown: false }} />
<PendingConfirmationModal
confirmations={snapshot.pendingConfirmations}
Expand Down
3 changes: 2 additions & 1 deletion app/(tabs)/activity.tsx → app/activity.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useIsFocused } from 'expo-router'
import { router, useIsFocused } from 'expo-router'

import { useRuntime } from '@/runtime/RuntimeProvider'
import { ActivityScreen } from '@/ui/screens/ActivityScreen'
Expand All @@ -9,6 +9,7 @@ export default function ActivityRoute() {
return (
<ActivityScreen
focused={focused}
onBack={() => { router.back() }}
onClearAuditHistory={clearAuditHistory}
records={snapshot.auditRecords}
/>
Expand Down
18 changes: 18 additions & 0 deletions app/connection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ConnectionSettingsScreen
focused={focused}
onBack={() => { router.back() }}
onClear={clearGatewayConfiguration}
onSave={saveGatewayConfiguration}
snapshot={snapshot}
/>
)
}
32 changes: 32 additions & 0 deletions app/controls.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ControlSettingsScreen
focused={focused}
onBack={() => { 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}
/>
)
}
3 changes: 3 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
5 changes: 3 additions & 2 deletions llmdoc/capabilities/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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、
Expand Down
4 changes: 2 additions & 2 deletions llmdoc/capabilities/bounded-linking-handoffs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ Android merged manifest 为地图 handler discovery 增加 `<queries>` 中的 `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 是不同层,仍受上游阻塞。
Expand Down
17 changes: 11 additions & 6 deletions llmdoc/capabilities/local-device-inbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 与图片加载

Expand All @@ -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 持久信任、不授权其他图片或自动预取,失败后重试也需要再次点按。
Expand Down Expand Up @@ -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。

Expand Down
4 changes: 2 additions & 2 deletions llmdoc/capabilities/local-notifications.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 行为已验证。

Expand Down
2 changes: 1 addition & 1 deletion llmdoc/capabilities/local-timers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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、
呈现或准时性。

## 事实真源
Expand Down
Loading