From 545ad1703c7d93026011e51751a0de13309167bd Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 02:25:41 +0800 Subject: [PATCH 01/12] refactor(ui): unify system themes and clarify device controls Refresh every screen with shared light/dark semantic colors, clearer message and device layouts, and readable confirmation controls. Preserve runtime policy and platform permissions; keep pending interactions when system appearance changes. --- app/(tabs)/_layout.tsx | 17 +- app/_layout.tsx | 5 +- eslint.config.mjs | 3 + src/ui/__tests__/systemTheme.test.tsx | 49 ++++++ src/ui/__tests__/themeContrast.test.ts | 20 ++- src/ui/components/AccessibleAction.tsx | 29 ++-- src/ui/components/CameraCaptureModal.tsx | 17 +- src/ui/components/EmptyState.tsx | 44 +++++ .../components/GatewayConfigurationCard.tsx | 150 +++++++++++------- src/ui/components/Icon.tsx | 7 +- .../components/PendingConfirmationModal.tsx | 71 +++++++-- src/ui/components/Pill.tsx | 21 ++- src/ui/components/SafeMarkdown.tsx | 48 +++--- src/ui/components/Screen.tsx | 32 ++-- src/ui/components/SectionHeading.tsx | 19 +++ src/ui/components/SettingToggle.tsx | 10 +- src/ui/components/StatusCard.tsx | 16 +- src/ui/screens/ActivityScreen.tsx | 74 ++++----- src/ui/screens/CapabilitiesScreen.tsx | 46 +++--- src/ui/screens/HomeScreen.tsx | 23 ++- src/ui/screens/InboxMessageScreen.tsx | 19 ++- src/ui/screens/InboxScreen.tsx | 70 ++++---- src/ui/screens/MediaScreen.tsx | 50 +++--- src/ui/screens/SettingsScreen.tsx | 54 +++++-- src/ui/screens/__tests__/InboxScreen.test.tsx | 4 +- src/ui/theme.ts | 73 +++++++-- 26 files changed, 644 insertions(+), 327 deletions(-) create mode 100644 src/ui/__tests__/systemTheme.test.tsx create mode 100644 src/ui/components/EmptyState.tsx create mode 100644 src/ui/components/SectionHeading.tsx diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 91220a4..efb13cb 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 ( 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 ( <> - + ({ + __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/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..7e570d8 100644 --- a/src/ui/components/GatewayConfigurationCard.tsx +++ b/src/ui/components/GatewayConfigurationCard.tsx @@ -3,8 +3,9 @@ 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' @@ -25,6 +26,8 @@ export function GatewayConfigurationCard({ onClear, onSave, }: GatewayConfigurationCardProps) { + const styles = useThemedStyles(createStyles) + const { colors } = useTheme() const [apiKey, setApiKey] = useState('') const [confirmingClear, setConfirmingClear] = useState(false) const [deviceIdInput, setDeviceIdInput] = useState('') @@ -80,59 +83,74 @@ export function GatewayConfigurationCard({ return ( + + + + + + 连接你的工具网络 + 使用网关地址与密钥连接此设备 + + - 暂时使用手工 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。 + + StyleSheet.create({ + 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 +244,7 @@ const styles = StyleSheet.create({ lineHeight: 21, }, confirmation: { - backgroundColor: colors.panelElevated, + backgroundColor: colors.dangerSoft, borderColor: colors.danger, borderRadius: radius.md, borderWidth: 1, @@ -230,7 +272,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..475f2b8 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' @@ -47,14 +47,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..96b6ca2 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.md }, 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: 16, lineHeight: 27 }, 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: 16, lineHeight: 27, 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: 16, lineHeight: 27 }, 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..2179bbe 100644 --- a/src/ui/components/Screen.tsx +++ b/src/ui/components/Screen.tsx @@ -5,7 +5,7 @@ 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 { useTheme, useThemedStyles, type ThemeColors, radius, spacing } from '@/ui/theme' import type { PropsWithChildren } from 'react' @@ -27,6 +27,8 @@ export function Screen({ onBack, title, }: ScreenProps) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) const headingRef = useRef(null) useEffect(() => { @@ -37,6 +39,8 @@ export function Screen({ {onBack === undefined ? null : ( @@ -71,7 +75,7 @@ export function Screen({ ) } -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ backButton: { alignItems: 'center', alignSelf: 'flex-start', @@ -91,6 +95,9 @@ const styles = StyleSheet.create({ fontWeight: '700', }, content: { + alignSelf: 'center', + width: '100%', + maxWidth: 720, gap: spacing.lg, paddingBottom: spacing.xxl, paddingHorizontal: spacing.xl, @@ -98,31 +105,28 @@ const styles = StyleSheet.create({ }, description: { color: colors.muted, - fontSize: 15, - lineHeight: 22, + fontSize: 14, + lineHeight: 21, }, eyebrow: { color: colors.primary, fontSize: 11, - fontWeight: '800', + fontWeight: '700', letterSpacing: 1.5, }, eyebrowBadge: { alignSelf: 'flex-start', - backgroundColor: colors.panelElevated, - borderColor: colors.border, - borderRadius: 999, - borderWidth: 1, - paddingHorizontal: spacing.md, - paddingVertical: 5, + paddingVertical: spacing.xs, }, headerBlock: { - gap: spacing.sm, + gap: spacing.xs, + paddingTop: spacing.sm, + paddingBottom: spacing.sm, }, heading: { color: colors.text, - fontSize: 30, - fontWeight: '800', + fontSize: 32, + fontWeight: '700', letterSpacing: -0.5, }, safeArea: { 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/screens/ActivityScreen.tsx b/src/ui/screens/ActivityScreen.tsx index 38ee193..fb7a58f 100644 --- a/src/ui/screens/ActivityScreen.tsx +++ b/src/ui/screens/ActivityScreen.tsx @@ -6,10 +6,11 @@ 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' @@ -25,6 +26,7 @@ export function ActivityScreen({ onClearAuditHistory, records, }: ActivityScreenProps) { + const styles = useThemedStyles(createStyles) const [confirmingClear, setConfirmingClear] = useState(false) const [feedback, setFeedback] = useState(null) const [isClearing, setIsClearing] = useState(false) @@ -63,33 +65,33 @@ 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 +147,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 +176,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..287a80b 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 @@ -30,11 +33,23 @@ export function CapabilitiesScreen({ onBack={onBack} title="能力" > + + + {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/HomeScreen.tsx b/src/ui/screens/HomeScreen.tsx index 5a4fe87..a14663a 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,20 @@ 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 : ( @@ -161,7 +172,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..0809f15 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]) @@ -89,7 +91,7 @@ export function InboxMessageScreen({ message.urgency === 'critical' ? styles.urgencyCritical : styles.urgencyHigh, ]} > - {URGENCY_LABEL[message.urgency]} + {URGENCY_LABEL[message.urgency]} ) : null} {caller} @@ -107,12 +109,15 @@ export function InboxMessageScreen({ - + + + ) } -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + readingCard: { backgroundColor: colors.panel, padding: spacing.xl, borderRadius: radius.lg, borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth }, caller: { color: colors.text, flexShrink: 1, @@ -156,10 +161,10 @@ const styles = StyleSheet.create({ fontSize: 14, }, urgencyCritical: { - backgroundColor: colors.danger, + backgroundColor: colors.dangerSoft, }, urgencyHigh: { - backgroundColor: colors.warning, + backgroundColor: colors.warningSoft, }, urgencyTag: { borderRadius: radius.sm, @@ -167,7 +172,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..778cf7d 100644 --- a/src/ui/screens/InboxScreen.tsx +++ b/src/ui/screens/InboxScreen.tsx @@ -6,14 +6,16 @@ import { useDiscreteAccessibilityAnnouncement, } from '@/ui/accessibility' import { AccessibleAction } from '@/ui/components/AccessibleAction' +import { EmptyState } from '@/ui/components/EmptyState' import { Icon } from '@/ui/components/Icon' import { Screen } from '@/ui/components/Screen' +import { SectionHeading } from '@/ui/components/SectionHeading' import { URGENCY_LABEL, 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, @@ -45,6 +47,8 @@ function MessageListItem({ now, onOpen, }: Readonly<{ message: InboxMessage; now?: Date | undefined; onOpen(): void }>) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) const caller = message.callerDisplayName ?? message.callerSubjectId const unread = message.readAt === null const relative = formatRelativeTime(message.receivedAt, now) @@ -65,6 +69,9 @@ function MessageListItem({ onPress={onOpen} style={({ pressed }) => [styles.item, pressed ? styles.itemPressed : null]} > + + {caller.slice(0, 1).toLocaleUpperCase()} + {message.title} - {relative} {showUrgency ? ( @@ -91,6 +97,7 @@ function MessageListItem({ ) : null} {caller} + {relative} {summary === '' ? null : ( {summary} @@ -112,6 +119,8 @@ export function InboxScreen({ unreadCount, viewOptions, }: InboxScreenProps) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) const [confirmingClear, setConfirmingClear] = useState(false) const [feedback, setFeedback] = useState(null) const [isClearing, setIsClearing] = useState(false) @@ -200,8 +209,8 @@ export function InboxScreen({ return ( @@ -224,7 +233,7 @@ export function InboxScreen({ { setSearchText('') void applySearch('') @@ -271,13 +280,9 @@ export function InboxScreen({ )} + 0 ? `${unreadCount} 条未读` : '全部已读'} /> {messages.length === 0 ? ( - - - - {searching ? '没有匹配的本机信箱消息。' : '最近还没有 Agent 来信。'} - - + ) : ( {messages.map(message => ( @@ -346,7 +351,7 @@ export function InboxScreen({ ) } -const styles = StyleSheet.create({ +const createStyles = (colors: ThemeColors) => StyleSheet.create({ actionRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.md }, confirmation: { backgroundColor: colors.panel, @@ -358,17 +363,6 @@ const styles = StyleSheet.create({ }, 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, @@ -382,16 +376,18 @@ const styles = StyleSheet.create({ paddingVertical: spacing.md, }, flexButton: { flexBasis: 140, flexGrow: 1 }, + avatar: { alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: 14, backgroundColor: colors.primarySoft }, + avatarText: { fontSize: 16, fontWeight: '700', color: colors.primary }, + clearSearch: { minWidth: 48, minHeight: 48, alignItems: 'center', justifyContent: 'center' }, item: { alignItems: 'center', backgroundColor: colors.panel, borderColor: colors.border, - borderRadius: radius.lg, - borderWidth: 1, - columnGap: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + columnGap: spacing.md, flexDirection: 'row', paddingHorizontal: spacing.lg, - paddingVertical: spacing.md, + paddingVertical: spacing.xl, }, itemBody: { flexGrow: 1, @@ -400,6 +396,7 @@ const styles = StyleSheet.create({ }, itemCaller: { color: colors.muted, + flexGrow: 1, flexShrink: 1, fontSize: 13, }, @@ -423,6 +420,7 @@ const styles = StyleSheet.create({ }, itemTime: { color: colors.muted, + flexShrink: 0, fontSize: 12, }, itemTitle: { @@ -435,14 +433,12 @@ const styles = StyleSheet.create({ itemTitleUnread: { fontWeight: '800', }, - list: { - gap: spacing.sm, - }, + list: { backgroundColor: colors.panel, borderRadius: radius.lg, overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth, borderColor: colors.border }, markAllButton: { alignItems: 'center', borderRadius: radius.sm, justifyContent: 'center', - minHeight: 36, + minHeight: 48, paddingHorizontal: spacing.md, }, markAllText: { @@ -474,7 +470,7 @@ const styles = StyleSheet.create({ borderRadius: 999, borderWidth: 1, justifyContent: 'center', - minHeight: 36, + minHeight: 48, paddingHorizontal: spacing.md, }, sortChipSelected: { @@ -487,7 +483,7 @@ const styles = StyleSheet.create({ fontWeight: '700', }, sortChipTextSelected: { - color: colors.background, + color: colors.onPrimary, }, sortRow: { alignItems: 'center', @@ -509,12 +505,12 @@ const styles = StyleSheet.create({ backgroundColor: colors.primary, }, urgencyCritical: { - backgroundColor: colors.danger, - color: colors.background, + backgroundColor: colors.dangerSoft, + color: colors.danger, }, urgencyHigh: { - backgroundColor: colors.warning, - color: colors.background, + backgroundColor: colors.warningSoft, + color: colors.warning, }, urgencyTag: { borderRadius: radius.sm, diff --git a/src/ui/screens/MediaScreen.tsx b/src/ui/screens/MediaScreen.tsx index a247272..79d9a15 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,23 @@ 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 +107,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 +137,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..b1a2d44 100644 --- a/src/ui/screens/SettingsScreen.tsx +++ b/src/ui/screens/SettingsScreen.tsx @@ -5,9 +5,10 @@ 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 { SectionHeading } from '@/ui/components/SectionHeading' import { SettingToggle } from '@/ui/components/SettingToggle' import { StatusCard } 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 { ManualGatewayConfigurationInput } from '@/identity/manualGatewayCredential' @@ -29,7 +30,7 @@ const CONTROL_MODE_OPTIONS: readonly Readonly<{ mode: 'trusted_session', }, { - hint: '所有命令(含高风险)直接执行,不再询问;仅紧急停用可中断', + hint: '跳过逐次确认;系统权限、平台限制与紧急停用仍然有效', label: '允许直接调用(含高危)', mode: 'direct_call', }, @@ -70,6 +71,8 @@ export function SettingsScreen({ onSetControlMode, snapshot, }: SettingsScreenProps) { + 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' @@ -100,14 +103,21 @@ export function SettingsScreen({ return ( + + + + {snapshot.transportState === 'ready' ? '已连接网关' : '网关尚未就绪'} + {snapshot.transportState === 'ready' ? '连接可用,命令仍由本机裁决。' : '检查下方连接设置,准备接收 Agent 请求。'} + + + - 查看运行时状态、已探测的设备能力与媒体会话。这些页面只读,不改变裁决配置。 + 查看设备状态与能力,管理正在进行的提示、计时器和媒体。 ) : ( <> + 选择 Agent 命令在本机的裁决强度。 @@ -150,14 +161,21 @@ export function SettingsScreen({ {CONTROL_MODE_OPTIONS.map(option => { const active = snapshot.controlMode === option.mode return ( - { onSetControlMode(option.mode) }} - variant={active ? 'primary' : 'secondary'} - {...(active ? { icon: 'positive' as const } : {})} - /> + style={({ pressed }) => [styles.modeOption, active ? styles.modeOptionActive : null, pressed ? styles.navRowPressed : null]} + > + + {option.label} + {option.hint} + + + ) })} {snapshot.controlMode === 'direct_call' ? ( @@ -267,6 +285,8 @@ function NavRow({ label, onPress, }: Readonly<{ hint: string; icon: IconName; label: string; onPress(): void }>) { + const { colors } = useTheme() + const styles = useThemedStyles(createStyles) return ( StyleSheet.create({ + connectionOverview: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, backgroundColor: colors.panel, padding: spacing.xl, borderRadius: radius.lg, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.border }, + connectionSymbol: { width: 52, height: 52, borderRadius: 18, backgroundColor: colors.primarySoft, alignItems: 'center', justifyContent: 'center' }, + connectionCopy: { flex: 1, gap: spacing.xs }, + connectionTitle: { color: colors.text, fontSize: 18, fontWeight: '700' }, + 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, @@ -322,7 +352,7 @@ const styles = StyleSheet.create({ }, warningNote: { alignItems: 'flex-start', - backgroundColor: colors.panelElevated, + backgroundColor: colors.warningSoft, borderRadius: radius.sm, columnGap: spacing.sm, flexDirection: 'row', diff --git a/src/ui/screens/__tests__/InboxScreen.test.tsx b/src/ui/screens/__tests__/InboxScreen.test.tsx index c4e53c3..20261fd 100644 --- a/src/ui/screens/__tests__/InboxScreen.test.tsx +++ b/src/ui/screens/__tests__/InboxScreen.test.tsx @@ -124,13 +124,13 @@ describe('InboxScreen', () => { await waitFor(() => rendered.getByText('已清空 2 条本机信箱消息。')) }) - test('空搜索结果与本地/离线边界表达准确', async () => { + test('空搜索结果与本地内容边界表达准确', async () => { const rendered = await render() rendered.getByText('没有匹配的本机信箱消息。') - rendered.getByText(/离线队列与 push 尚未实现/) + rendered.getByText(/来自 Agent 的消息,集中留在本机/) }) }) 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 From 810c681d5160819fafafd7daf73c8d1bdfacccec Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 02:26:00 +0800 Subject: [PATCH 02/12] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=B8=BB=E9=A2=98=E7=BA=A6=E6=9D=9F=E4=B8=8E=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E9=A1=B5=E9=9D=A2=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llmdoc/capabilities/architecture.mdx | 2 +- llmdoc/capabilities/local-device-inbox.mdx | 2 +- llmdoc/delivery/definition-of-done.mdx | 3 +- .../manual-gateway-configuration.mdx | 2 +- llmdoc/integration/sdk-device-transport.mdx | 2 +- llmdoc/runtime/accessibility-semantics.mdx | 28 ++++++++++++++----- llmdoc/runtime/architecture.mdx | 2 +- 7 files changed, 28 insertions(+), 13 deletions(-) diff --git a/llmdoc/capabilities/architecture.mdx b/llmdoc/capabilities/architecture.mdx index 080edbd..9324c1f 100644 --- a/llmdoc/capabilities/architecture.mdx +++ b/llmdoc/capabilities/architecture.mdx @@ -164,7 +164,7 @@ adapter。registry 在展示与执行前读取真实 probe,并在返回 SDK - 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/local-device-inbox.mdx b/llmdoc/capabilities/local-device-inbox.mdx index 488c575..8502def 100644 --- a/llmdoc/capabilities/local-device-inbox.mdx +++ b/llmdoc/capabilities/local-device-inbox.mdx @@ -149,7 +149,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 持久信任、不授权其他图片或自动预取,失败后重试也需要再次点按。 diff --git a/llmdoc/delivery/definition-of-done.mdx b/llmdoc/delivery/definition-of-done.mdx index be2d0e3..25b3fda 100644 --- a/llmdoc/delivery/definition-of-done.mdx +++ b/llmdoc/delivery/definition-of-done.mdx @@ -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/integration/manual-gateway-configuration.mdx b/llmdoc/integration/manual-gateway-configuration.mdx index 718a465..9394741 100644 --- a/llmdoc/integration/manual-gateway-configuration.mdx +++ b/llmdoc/integration/manual-gateway-configuration.mdx @@ -23,7 +23,7 @@ code: ## 当前用途 -正式 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。 diff --git a/llmdoc/integration/sdk-device-transport.mdx b/llmdoc/integration/sdk-device-transport.mdx index da3173f..bb9bd10 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/runtime/accessibility-semantics.mdx b/llmdoc/runtime/accessibility-semantics.mdx index f83ce3e..5d18b90 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,36 @@ 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/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 - src/ui/**/__tests__/** - scripts/verify-android-emulator.mjs --- -# Accessibility semantics 自动化基线 +# 系统主题与 Accessibility semantics 基线 + +## 系统外观 + +- 已决定:系统外观是唯一主题来源,不保存独立主题偏好。共享语义色覆盖浅色和深色,页面、导航、 + 状态栏及全局弹窗同步跟随;切换主题只更新样式,不重挂载页面或运行时,不丢弃未提交表单与待确认状态。 +- 主操作和危险操作分别使用配对的前景/背景语义色;危险操作不能复用浅色面板文字颜色推断可读性。 + 颜色回归必须检查组件实际采用的文字组合,而不是未用于渲染的候选配色。 ## 共享语义 - `Screen` 暴露唯一页面 header,并只在页面获得 focus 时请求辅助技术焦点;普通 snapshot/rerender 不 - 反复抢焦点。`StatusCard` title 是 header,`StatusRow` 将 label/value 合成单一可访问名称并隐藏重复的 + 反复抢焦点。`StatusCard` 与 `SectionHeading` title 是 header,`StatusRow` 将 label/value 合成单一可访问名称并隐藏重复的 视觉子文本,同时允许系统字号和换行。 - `AccessibleAction` 统一 button role、上下文唯一 label、必要 hint、busy/disabled state,以及至少 - 48dp 的 minWidth/minHeight。四个 tab 显式使用“状态/能力/媒体/活动标签页”唯一 label,并投影 selected。 + 48dp 的 minWidth/minHeight。主导航保留信箱、活动、设置三个 tab,显式使用“信箱/活动/设置标签页” + 唯一 label,并投影 selected;状态、能力、媒体从设置进入二级页面。 - Activity destructive confirmation 打开时聚焦确认标题,取消/完成后返回清除触发按钮;组件测试覆盖 focus 往返和普通更新不抢焦点。 - 远程 command 的 `PendingConfirmationModal` 挂在 root layout,不受当前 tab 与首页滚动位置影响;打开时 @@ -49,13 +61,14 @@ 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 至少 @@ -71,8 +84,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/architecture.mdx b/llmdoc/runtime/architecture.mdx index 0d606b3..9c02be4 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 投影活动命令并请求 From dfbac1488e76d355355bc25c575bb0715b30703c Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 02:26:00 +0800 Subject: [PATCH 03/12] chore(llmdoc): refresh fingerprints --- llmdoc/meta.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/llmdoc/meta.json b/llmdoc/meta.json index 910db84..5c3dc21 100644 --- a/llmdoc/meta.json +++ b/llmdoc/meta.json @@ -6,10 +6,10 @@ }, "documents": { "architecture.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "capabilities/architecture.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "capabilities/bounded-linking-handoffs.mdx": { "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" @@ -18,16 +18,16 @@ "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" }, "capabilities/foreground-camera-capture.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "capabilities/local-device-inbox.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "capabilities/local-notifications.mdx": { "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" }, "capabilities/local-timers.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "delivery/eas-project-binding.mdx": { "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" @@ -42,10 +42,10 @@ "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" }, "integration/manual-gateway-configuration.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "integration/sdk-device-transport.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "integration/trusted-grants.mdx": { "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" @@ -54,31 +54,31 @@ "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" }, "runtime/accessibility-semantics.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "runtime/activity-history.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "runtime/architecture.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "runtime/command-retention.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "runtime/safety-boundaries.mdx": { "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" }, "product/requirements-and-roadmap.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "delivery/engineering-baseline.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "delivery/definition-of-done.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" }, "delivery/verification-evidence.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" } }, "convergence": { From 1b13ff39a93a62237a18bc1e55664f10d2e9e772 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 04:11:31 +0800 Subject: [PATCH 04/12] refactor(ui): reorganize inbox reading and device management Keep the inbox as the landing page, move local actions into sheets, and split connection and authorization into dedicated routes. Query unread messages before limiting results so older unread items remain discoverable. Preserve remote confirmation, permission probes, and credential storage boundaries. --- README.md | 17 +- app/(tabs)/settings.tsx | 22 +- app/connection.tsx | 18 + app/controls.tsx | 32 ++ scripts/verify-android-emulator.mjs | 287 +++++----- src/inbox/types.ts | 3 +- src/runtime/applicationRuntime.ts | 1 + src/storage/__tests__/inboxRepository.test.ts | 43 +- src/storage/inboxRepository.ts | 6 +- src/ui/__tests__/navigation.test.ts | 2 +- src/ui/components/ActionSheet.tsx | 78 +++ .../components/GatewayConfigurationCard.tsx | 16 +- src/ui/components/Icon.tsx | 10 + src/ui/components/SafeMarkdown.tsx | 8 +- src/ui/components/Screen.tsx | 168 +++--- .../components/__tests__/ActionSheet.test.tsx | 30 ++ src/ui/navigation.ts | 8 +- src/ui/screens/CapabilitiesScreen.tsx | 1 + src/ui/screens/ConnectionSettingsScreen.tsx | 44 ++ src/ui/screens/ControlSettingsScreen.tsx | 269 ++++++++++ src/ui/screens/HomeScreen.tsx | 7 +- src/ui/screens/InboxMessageScreen.tsx | 62 ++- src/ui/screens/InboxScreen.tsx | 488 ++++++------------ src/ui/screens/MediaScreen.tsx | 1 + src/ui/screens/SettingsScreen.tsx | 384 +++----------- .../ConnectionSettingsScreen.test.tsx | 52 ++ .../__tests__/ControlSettingsScreen.test.tsx | 189 +++++++ src/ui/screens/__tests__/HomeScreen.test.tsx | 4 +- src/ui/screens/__tests__/InboxScreen.test.tsx | 86 ++- .../screens/__tests__/SettingsScreen.test.tsx | 197 ++----- 30 files changed, 1470 insertions(+), 1063 deletions(-) create mode 100644 app/connection.tsx create mode 100644 app/controls.tsx create mode 100644 src/ui/components/ActionSheet.tsx create mode 100644 src/ui/components/__tests__/ActionSheet.test.tsx create mode 100644 src/ui/screens/ConnectionSettingsScreen.tsx create mode 100644 src/ui/screens/ControlSettingsScreen.tsx create mode 100644 src/ui/screens/__tests__/ConnectionSettingsScreen.test.tsx create mode 100644 src/ui/screens/__tests__/ControlSettingsScreen.test.tsx diff --git a/README.md b/README.md index 4159ef3..1b61fcd 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,7 +173,7 @@ 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` @@ -182,8 +183,8 @@ Android development debug APK 构建完成、API 36 emulator 已启动且另一 pnpm verify:android:emulator ``` -该脚本会卸载 emulator 中的 dev application id 后重新安装 APK,并验证安装后权限、首页状态、动态 -能力、local-only 通知/timer 边界、紧急停用重启持久化、六个标签页的唯一语义,以及关键页面在 200% +该脚本会卸载 emulator 中的 dev application id 后重新安装 APK,并验证安装后权限、信箱首页与设备分层导航、动态 +能力、local-only 通知/timer 边界、紧急停用重启持久化、三个标签页的唯一语义,以及关键页面在 200% 系统字号下的名称、选中状态和操作最小尺寸;不会操作 preview/production 包,也不替代 TalkBack、VoiceOver 或真机验收。 diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index af41de5..3ab74a8 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -5,33 +5,17 @@ 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 ( { void setControlMode('disabled') }} onEnable={() => { void setControlMode('ask_every_time') }} 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/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/scripts/verify-android-emulator.mjs b/scripts/verify-android-emulator.mjs index a3ff775..df69581 100644 --- a/scripts/verify-android-emulator.mjs +++ b/scripts/verify-android-emulator.mjs @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { access } from 'node:fs/promises' +import { access, readFile } from 'node:fs/promises' import { promisify } from 'node:util' const execFileAsync = promisify(execFile) @@ -7,6 +7,8 @@ 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' +let displaySize +let density const forbiddenPermissions = [ 'android.permission.ACCESS_BACKGROUND_LOCATION', 'android.permission.RECEIVE_BOOT_COMPLETED', @@ -100,15 +102,64 @@ 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 => ( +function describedNode(source, description) { + return nodeWithAttribute(source, 'content-desc', value => ( value === description || value.endsWith(`, ${description}`) )) +} + +async function swipeContent(direction) { + const [width, height] = displaySize + const x = Math.round(width * 0.5) + // 留出固定标题栏与底部 tab;不要在导航区域触发滑动。 + const start = Math.round(height * (direction === 'down' ? 0.72 : 0.35)) + const end = Math.round(height * (direction === 'down' ? 0.35 : 0.72)) + await adb('shell', 'input', 'swipe', String(x), String(start), String(x), String(end), '300') + await delay(250) +} + +async function findUi(predicate, label) { + let source = await dumpUi() + if (predicate(source)) return source + // tab 保留滚动位置;先向下查找,再向上查找,不能假定当前位于顶部。 + for (const direction of ['down', 'up']) { + for (let attempt = 0; attempt < 60; attempt += 1) { + await swipeContent(direction) + const next = await dumpUi() + if (predicate(next)) return next + if (next === source) break + source = next + } + } + throw new Error(`滚动后仍找不到 UI: ${label}\n${source.slice(0, 2_000)}`) +} + +async function findDescription(description, minimumHeightDp = 0) { + const source = await findUi(current => { + const node = describedNode(current, description) + return node !== null && nodeHeightDp(node, density) >= minimumHeightDp + }, description) + return describedNode(source, description) +} + +async function tapByDescription(description) { + const node = await findDescription(description, 48) if (node === null) throw new Error(`找不到 accessibility 节点: ${description}`) await tapNode(node) } +async function openDevice() { + await tapByDescription('设备标签页') + const source = await waitForUi(current => describedNode(current, '设备标签页')?.includes('selected="true"') === true, '设备总览') + requireSelectedTab(source, '设备标签页') +} + +async function returnToDevice() { + await tapByDescription('设备') + const source = await waitForUi(current => describedNode(current, '设备标签页')?.includes('selected="true"') === true, '返回设备总览') + requireSelectedTab(source, '设备标签页') +} + async function launchApp() { const deepLink = `toolbridgemobile-dev://expo-development-client/?url=${encodeURIComponent(devServerUrl)}` await adb( @@ -125,7 +176,7 @@ async function launchApp() { let source = await waitForUi(current => ( hasText(current, 'Continue') - || hasText(current, '设备裁决优先') + || describedNode(current, '信箱标签页') !== null ), 'development client 或 App 首页') if (hasText(source, 'Continue')) { const continueNode = nodeWithAttribute(source, 'text', value => value === 'Continue') @@ -138,7 +189,7 @@ async function launchApp() { if (closeNode === null) throw new Error('development client Close 节点消失') await tapNode(closeNode) } - return waitForUi(current => hasText(current, '设备裁决优先'), 'Tool Bridge Mobile 首页') + return waitForUi(current => describedNode(current, '信箱标签页') !== null, 'Tool Bridge Mobile 信箱首页') } async function ensureMetro() { @@ -167,8 +218,24 @@ await execFileAsync('adb', ['uninstall', appId]).catch(() => undefined) await adb('install', apkPath) await adb('reverse', `tcp:${devServerPort}`, `tcp:${devServerPort}`) +const sizeOutput = await adb('shell', 'wm', 'size') +const sizeMatch = /Override size: (\d+)x(\d+)/u.exec(sizeOutput) + ?? /Physical size: (\d+)x(\d+)/u.exec(sizeOutput) +if (sizeMatch === null) throw new Error(`无法读取 emulator size: ${sizeOutput}`) +displaySize = [Number(sizeMatch[1]), Number(sizeMatch[2])] +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}`) +density = Number(densityMatch[1]) + +// 与发布 gate 使用同一版本事实入口;不把某次历史 APK 版本固定在 smoke 中。 +const appConfig = await readFile(new URL('../app.config.ts', import.meta.url), 'utf8') +const appVersion = /export const APP_VERSION = '([^']+)'/u.exec(appConfig)?.[1] +const versionCode = /export const ANDROID_VERSION_CODE = (\d+)/u.exec(appConfig)?.[1] +if (appVersion === undefined || versionCode === undefined) throw new Error('无法从 app.config.ts 读取发布版本') const packageInfo = await adb('shell', 'dumpsys', 'package', appId) -for (const expected of ['versionCode=2 minSdk=24 targetSdk=36', 'versionName=0.0.2']) { +for (const expected of [`versionCode=${versionCode} minSdk=24 targetSdk=36`, `versionName=${appVersion}`]) { if (!packageInfo.includes(expected)) throw new Error(`安装包信息缺少: ${expected}`) } for (const forbidden of forbiddenPermissions) { @@ -187,173 +254,115 @@ for (const expected of [ } 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}`) - } +for (const tabLabel of ['信箱标签页', '活动标签页', '设备标签页']) { + if (describedNode(source, tabLabel) === null) throw new Error(`首页缺少唯一 tab accessibility label: ${tabLabel}`) } -requireSelectedTab(source, '状态标签页') - -await tapByDescription('信箱标签页') -source = await waitForUi(current => hasText(current, '最近还没有 Agent 来信。'), '信箱页') requireSelectedTab(source, '信箱标签页') +await findUi(current => hasText(current, '最近还没有 Agent 来信。'), 'fresh install 信箱空态') -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 [ +await openDevice() +await tapByDescription('连接配置') +await waitForUi(current => hasText(current, '连接配置'), '连接配置页面') +await findDescription('Tool Bridge API key', 48) +await returnToDevice() +await tapByDescription('授权与安全') +await waitForUi(current => hasText(current, '授权与安全'), '授权与安全页面') +await findDescription('每次确认(当前)', 48) +await findDescription('启用本地通知', 48) +await returnToDevice() +await tapByDescription('运行详情') +await waitForUi(current => hasText(current, '设备状态'), '运行详情页面') +await findUi(current => describedNode(current, '控制模式:每次确认') !== null, '默认控制模式') +await findUi(current => describedNode(current, '连接:unconfigured') !== null, '未配置网关的连接状态') +await returnToDevice() + +await tapByDescription('设备能力') +await waitForUi(current => hasText(current, '能力'), '能力页面') +for (const capability of [ + 'phone/apps.can_open_url', + 'phone/location.current', + 'phone/location.open_map', + 'phone/productivity.notify', '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() + await findUi(current => hasText(current, capability), `能力 ${capability}`) + if (capability === 'phone/location.current') { + await findUi(current => current.includes('permission_required: foreground_location_permission_required'), '未授权位置能力的 permission_required') } - if (!hasText(source, timerCapability)) { - throw new Error(`能力页未显示 ${timerCapability}`) + if (capability === 'phone/productivity.notify') { + await findUi(current => current.includes('unavailable: notification_permission_requestable'), 'fresh install 通知仅本地可请求') } } +await returnToDevice() -await tapByDescription('状态标签页') -await waitForUi(current => hasText(current, '设备裁决优先'), '返回状态页') await tapByDescription('紧急停用远程能力') -await waitForUi(current => ( - hasText(current, 'disabled') && hasText(current, '恢复为每次确认') -), '紧急停用状态') +await findUi(current => hasText(current, '新命令当前均被拒绝。恢复后,仍需在本机逐次确认。'), '紧急停用状态') +await findDescription('恢复为每次确认', 48) await adb('shell', 'am', 'force-stop', appId) source = await launchApp() -if (!hasText(source, 'disabled') || !hasText(source, '恢复为每次确认')) { - throw new Error('紧急停用状态未在进程重启后恢复') -} +requireSelectedTab(source, '信箱标签页') +await openDevice() +await findUi(current => hasText(current, '远程能力已停用'), '重启后保留 disabled 模式') await tapByDescription('恢复为每次确认') -await waitForUi(current => hasText(current, 'ask_every_time'), '恢复控制模式') +await tapByDescription('授权与安全') +await findDescription('每次确认(当前)', 48) +await returnToDevice() await tapByDescription('活动标签页') -source = await waitForUi(current => ( - hasText(current, '暂无远程调用记录。') - && current.includes('最近 100 条调用元数据') - && current.includes('最多保留 5,000 条') -), '本地活动历史范围') +source = await waitForUi(current => describedNode(current, '活动标签页')?.includes('selected="true"') === true, '活动页面') +requireSelectedTab(source, '活动标签页') +await findUi(current => hasText(current, '暂无远程调用记录。'), '本地活动空态') +await findUi(current => current.includes('显示最近 100 条,本机最多保留 5,000 条;不展示参数、正文或结果载荷。'), '本地活动历史范围') await tapByDescription('清除本机活动历史') -source = await waitForUi(current => ( - hasText(current, '确认清除当前活动历史?') - && current.includes('不会清除防重放记录、计时器、设置、installation identity 或凭证') -), '活动历史 destructive confirmation') +await findUi(current => hasText(current, '确认清除当前活动历史?'), '活动历史确认标题') +await findUi(current => current.includes('不会清除防重放记录、计时器、设置、installation identity 或凭证'), '活动清除保留对象') await tapByDescription('取消清除活动历史') -await waitForUi(current => ( - nodeWithAttribute(current, 'content-desc', value => value === '清除本机活动历史') !== null -), '取消清除活动历史') await tapByDescription('清除本机活动历史') -await waitForUi(current => hasText(current, '确认清除当前活动历史?'), '再次确认清除活动历史') +await findUi(current => hasText(current, '确认清除当前活动历史?'), '再次确认清除活动历史') await tapByDescription('确认清除活动历史') -await waitForUi(current => ( - hasText(current, '已清除 0 条本机活动历史;后续调用会继续记录。') -), '清除空活动历史的真实结果') +await findUi(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, '媒体标签页') + requireSelectedTab(source, '信箱标签页') + await openDevice() + await tapByDescription('媒体会话') + await findUi(current => hasText(current, '暂无 App 自有媒体会话。'), '200% 字号媒体空态') + await returnToDevice() await tapByDescription('活动标签页') - source = await waitForUi(current => ( - nodeWithAttribute(current, 'content-desc', value => value === '清除本机活动历史') !== null - ), '200% 字号活动页') + source = await waitForUi(current => describedNode(current, '活动标签页')?.includes('selected="true"') === true, '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') + await tapByDescription('清除本机活动历史') + // 分别滚动到每个 action 并核对 48dp,不要求放大字号后仍处于同一屏。 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 findDescription(actionLabel, 48) } await tapByDescription('取消清除活动历史') + await tapByDescription('清除本机活动历史') + await tapByDescription('确认清除活动历史') + await findUi(current => hasText(current, '已清除 0 条本机活动历史;后续调用会继续记录。'), '200% 字号确认清除结果') - 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, '状态标签页') + await openDevice() + await tapByDescription('设备能力') + await findUi(current => hasText(current, 'phone/apps.can_open_url'), '200% 字号能力列表可达') + await returnToDevice() + await tapByDescription('运行详情') + await findUi(current => describedNode(current, '控制模式:每次确认') !== null, '200% 字号运行状态可读') + await returnToDevice() + await tapByDescription('连接配置') + await findDescription('Tool Bridge API key', 48) + await returnToDevice() + await tapByDescription('授权与安全') + await findDescription('每次确认(当前)', 48) + await findDescription('允许后台运行', 48) + await returnToDevice() } finally { await adb('shell', 'settings', 'put', 'system', 'font_scale', originalFontScale) await adb('shell', 'am', 'force-stop', appId) 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..aac069e 100644 --- a/src/ui/__tests__/navigation.test.ts +++ b/src/ui/__tests__/navigation.test.ts @@ -3,7 +3,7 @@ import { TAB_OPTIONS, TAB_ORDER } from '../navigation' describe('tab accessibility labels', () => { test('三个主 tab 使用稳定、唯一且带上下文的可访问名称', () => { const tabs = TAB_ORDER.map(name => TAB_OPTIONS[name]) - expect(tabs.map(tab => tab.title)).toEqual(['信箱', '活动', '设置']) + expect(tabs.map(tab => tab.title)).toEqual(['信箱', '活动', '设备']) expect(new Set(tabs.map(tab => tab.tabBarAccessibilityLabel)).size).toBe(3) expect(tabs.every(tab => tab.tabBarAccessibilityLabel.endsWith('标签页'))).toBe(true) }) 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/GatewayConfigurationCard.tsx b/src/ui/components/GatewayConfigurationCard.tsx index 7e570d8..5bd3284 100644 --- a/src/ui/components/GatewayConfigurationCard.tsx +++ b/src/ui/components/GatewayConfigurationCard.tsx @@ -10,6 +10,7 @@ import { radius, spacing, useTheme, useThemedStyles, type ThemeColors } from '@/ import type { ManualGatewayConfigurationInput } from '@/identity/manualGatewayCredential' type GatewayConfigurationCardProps = Readonly<{ + appearance?: 'card' | 'page' currentOrigin: string | null defaultDeviceId: string | null onClear(): Promise @@ -21,6 +22,7 @@ function safeFeedback(error: unknown, fallback: string): string { } export function GatewayConfigurationCard({ + appearance = 'card', currentOrigin, defaultDeviceId, onClear, @@ -81,9 +83,9 @@ export function GatewayConfigurationCard({ } } - return ( - - + const content = ( + <> + {appearance === 'card' ? @@ -91,7 +93,7 @@ export function GatewayConfigurationCard({ 连接你的工具网络 使用网关地址与密钥连接此设备 - + : null} API key 只保存在系统安全存储中,保存后不会回显。 @@ -204,11 +206,15 @@ export function GatewayConfigurationCard({ )} {feedback === null ? null : {feedback}} - + ) + return appearance === 'page' + ? {content} + : {content} } const createStyles = (colors: ThemeColors) => StyleSheet.create({ + pageForm: { gap: spacing.xl }, connectionHeader: { alignItems: 'center', flexDirection: 'row', diff --git a/src/ui/components/Icon.tsx b/src/ui/components/Icon.tsx index 475f2b8..38f1fa4 100644 --- a/src/ui/components/Icon.tsx +++ b/src/ui/components/Icon.tsx @@ -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', diff --git a/src/ui/components/SafeMarkdown.tsx b/src/ui/components/SafeMarkdown.tsx index 96b6ca2..719b3b2 100644 --- a/src/ui/components/SafeMarkdown.tsx +++ b/src/ui/components/SafeMarkdown.tsx @@ -395,7 +395,7 @@ function safeDisplayHost(source: string): string | null { } const createStyles = (colors: ThemeColors) => StyleSheet.create({ - block: { marginBottom: spacing.md }, + block: { marginBottom: spacing.lg }, blockContent: { flex: 1, gap: spacing.sm }, blockquote: { backgroundColor: colors.panelElevated, @@ -432,14 +432,14 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({ padding: spacing.md, }, inlineCode: { backgroundColor: colors.panelElevated, fontFamily: 'monospace', fontSize: 14 }, - inlineText: { color: colors.text, fontSize: 16, lineHeight: 27 }, + 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: 16, lineHeight: 27, minWidth: 28 }, + 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: 16, lineHeight: 27 }, + paragraph: { color: colors.text, fontSize: 17, lineHeight: 29 }, root: { gap: spacing.xs }, rule: { backgroundColor: colors.border, height: 1, marginVertical: spacing.lg }, strike: { textDecorationLine: 'line-through' }, diff --git a/src/ui/components/Screen.tsx b/src/ui/components/Screen.tsx index 2179bbe..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 { useTheme, useThemedStyles, type ThemeColors, 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,8 +29,13 @@ 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) @@ -35,102 +45,102 @@ export function Screen({ 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 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: { alignSelf: 'center', - width: '100%', + gap: spacing.xl, maxWidth: 720, - gap: spacing.lg, paddingBottom: spacing.xxl, paddingHorizontal: spacing.xl, - paddingTop: spacing.lg, - }, - description: { - color: colors.muted, - fontSize: 14, - lineHeight: 21, - }, - eyebrow: { - color: colors.primary, - fontSize: 11, - fontWeight: '700', - letterSpacing: 1.5, - }, - eyebrowBadge: { - alignSelf: 'flex-start', - paddingVertical: spacing.xs, - }, - headerBlock: { - gap: spacing.xs, - paddingTop: spacing.sm, - paddingBottom: spacing.sm, - }, - heading: { - color: colors.text, - fontSize: 32, - fontWeight: '700', - letterSpacing: -0.5, + paddingTop: spacing.xl, + width: '100%', }, - safeArea: { - backgroundColor: colors.background, - flex: 1, + 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: 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/__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..b12cb19 100644 --- a/src/ui/navigation.ts +++ b/src/ui/navigation.ts @@ -1,11 +1,11 @@ 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 @@ -15,7 +15,7 @@ export const TAB_ORDER = ['index', 'activity', 'settings'] as const 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/CapabilitiesScreen.tsx b/src/ui/screens/CapabilitiesScreen.tsx index 287a80b..fd9182c 100644 --- a/src/ui/screens/CapabilitiesScreen.tsx +++ b/src/ui/screens/CapabilitiesScreen.tsx @@ -28,6 +28,7 @@ export function CapabilitiesScreen({ return ( + 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 a14663a..e5ad81c 100644 --- a/src/ui/screens/HomeScreen.tsx +++ b/src/ui/screens/HomeScreen.tsx @@ -77,6 +77,7 @@ export function HomeScreen({ return ( {snapshot.transportState === 'ready' ? '设备已连接' : '设备尚未就绪'} - {snapshot.transportState === 'ready' ? '远程命令仍受本机策略与系统权限约束。' : '前往设置查看网关配置与连接状态。'} + {snapshot.transportState === 'ready' ? '远程命令仍受本机策略与系统权限约束。' : '前往设备页查看网关配置与连接状态。'} @@ -162,9 +163,9 @@ export function HomeScreen({ ))} diff --git a/src/ui/screens/InboxMessageScreen.tsx b/src/ui/screens/InboxMessageScreen.tsx index 0809f15..4450fea 100644 --- a/src/ui/screens/InboxMessageScreen.tsx +++ b/src/ui/screens/InboxMessageScreen.tsx @@ -81,35 +81,42 @@ 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} - )} - + @@ -117,7 +124,10 @@ export function InboxMessageScreen({ } const createStyles = (colors: ThemeColors) => StyleSheet.create({ - readingCard: { backgroundColor: colors.panel, padding: spacing.xl, borderRadius: radius.lg, borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth }, + 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, @@ -144,7 +154,9 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({ paddingVertical: spacing.xxl, }, metaBlock: { - gap: spacing.sm, + alignItems: 'flex-start', + flexDirection: 'row', + gap: spacing.md, }, metaLine: { alignItems: 'center', @@ -158,7 +170,7 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({ }, time: { color: colors.muted, - fontSize: 14, + fontSize: 12, }, urgencyCritical: { backgroundColor: colors.dangerSoft, diff --git a/src/ui/screens/InboxScreen.tsx b/src/ui/screens/InboxScreen.tsx index 778cf7d..88855b1 100644 --- a/src/ui/screens/InboxScreen.tsx +++ b/src/ui/screens/InboxScreen.tsx @@ -6,10 +6,10 @@ 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 { SectionHeading } from '@/ui/components/SectionHeading' import { URGENCY_LABEL, formatRelativeTime, @@ -24,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' }, @@ -47,7 +48,6 @@ function MessageListItem({ now, onOpen, }: Readonly<{ message: InboxMessage; now?: Date | undefined; onOpen(): void }>) { - const { colors } = useTheme() const styles = useThemedStyles(createStyles) const caller = message.callerDisplayName ?? message.callerSubjectId const unread = message.readAt === null @@ -57,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]} > - - {caller.slice(0, 1).toLocaleUpperCase()} - - - - - - {message.title} - - + - {showUrgency ? ( - - {URGENCY_LABEL[message.urgency]} - - ) : null} - {caller} - {relative} + + {caller} + {relative} - {summary === '' ? null : ( - {summary} - )} + {message.title} + {summary === '' ? null : {summary}} + {showUrgency ? {URGENCY_LABEL[message.urgency]}优先级 : null} - ) } @@ -121,6 +94,11 @@ export function InboxScreen({ }: 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) @@ -140,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}`, @@ -169,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 { @@ -182,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 { @@ -190,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) @@ -206,318 +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 ? '标记中…' : '全部已读'} - - - )} - - - 0 ? `${unreadCount} 条未读` : '全部已读'} /> - {messages.length === 0 ? ( - - ) : ( - - {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 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' }, - 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 }, - avatar: { alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: 14, backgroundColor: colors.primarySoft }, - avatarText: { fontSize: 16, fontWeight: '700', color: colors.primary }, - clearSearch: { minWidth: 48, minHeight: 48, alignItems: 'center', justifyContent: 'center' }, - item: { - alignItems: 'center', - backgroundColor: colors.panel, - borderColor: colors.border, - borderBottomWidth: StyleSheet.hairlineWidth, - columnGap: spacing.md, - flexDirection: 'row', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.xl, - }, - itemBody: { - flexGrow: 1, - flexShrink: 1, - gap: spacing.xs, - }, - itemCaller: { - color: colors.muted, - flexGrow: 1, - 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, - flexShrink: 0, - fontSize: 12, - }, - itemTitle: { - color: colors.text, - flexGrow: 1, - flexShrink: 1, - fontSize: 16, - fontWeight: '600', - }, - itemTitleUnread: { - fontWeight: '800', - }, - list: { backgroundColor: colors.panel, borderRadius: radius.lg, overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth, borderColor: colors.border }, - markAllButton: { - alignItems: 'center', - borderRadius: radius.sm, - justifyContent: 'center', - minHeight: 48, - 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: 48, - paddingHorizontal: spacing.md, - }, - sortChipSelected: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - sortChipText: { - color: colors.muted, - fontSize: 13, - fontWeight: '700', - }, - sortChipTextSelected: { - color: colors.onPrimary, - }, - 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.dangerSoft, - color: colors.danger, - }, - urgencyHigh: { - backgroundColor: colors.warningSoft, - color: colors.warning, - }, - 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 79d9a15..2242a74 100644 --- a/src/ui/screens/MediaScreen.tsx +++ b/src/ui/screens/MediaScreen.tsx @@ -36,6 +36,7 @@ export function MediaScreen({ return ( -}>[] = [ - { - 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 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, onOpenCapabilities, - onOpenCameraSettings, + onOpenConnection, + onOpenControls, onOpenMedia, - onOpenNotificationSettings, onOpenStatus, - onRequestNotificationPermission, - onRequestCameraPermission, - onSaveGatewayConfiguration, - onSetBackgroundRuntime, - onSetControlMode, snapshot, }: SettingsScreenProps) { 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' + 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 ( - - - - - {snapshot.transportState === 'ready' ? '已连接网关' : '网关尚未就绪'} - {snapshot.transportState === 'ready' ? '连接可用,命令仍由本机裁决。' : '检查下方连接设置,准备接收 Agent 请求。'} + + + + 当前设备 + {deviceId ?? '设备身份准备中'} + + + {connected ? '已连接网关' : '网关尚未就绪'} + {deviceId === null ? '初始化完成后显示本机设备 ID。' : '这是 Agent 连接与识别这台设备时使用的 ID。'} - - - - 查看设备状态与能力,管理正在进行的提示、计时器和媒体。 - - - - - - - {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} + {snapshot.controlMode === 'direct_call' ? ( + + + 直接调用已开启,包括高风险命令。系统权限与设备限制仍然有效;可随时紧急停用。 + + ) : null} - {notificationSettingsRequired ? ( - - - 系统通知权限或 Tool Bridge 本地通知 channel 已关闭;远程命令无权改变该设置。 - - - - ) : 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 createStyles = (colors: ThemeColors) => StyleSheet.create({ - connectionOverview: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, backgroundColor: colors.panel, padding: spacing.xl, borderRadius: radius.lg, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.border }, - connectionSymbol: { width: 52, height: 52, borderRadius: 18, backgroundColor: colors.primarySoft, alignItems: 'center', justifyContent: 'center' }, - connectionCopy: { flex: 1, gap: spacing.xs }, - connectionTitle: { color: colors.text, fontSize: 18, fontWeight: '700' }, - 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, - }, - 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.warningSoft, - borderRadius: radius.sm, - columnGap: spacing.sm, - flexDirection: 'row', - padding: spacing.md, - }, - warningText: { - color: colors.warning, - flexShrink: 1, - fontSize: 13, - lineHeight: 19, - }, + 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__/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 20261fd..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 防重放记录、活动审计、设置或凭证/) @@ -131,6 +136,81 @@ describe('InboxScreen', () => { viewOptions: { searchQuery: '不存在', sort: 'received_desc' }, })} />) rendered.getByText('没有匹配的本机信箱消息。') - rendered.getByText(/来自 Agent 的消息,集中留在本机/) + 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..21c4cc8 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,67 @@ 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(), + 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: '媒体会话' })) + 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(/直接调用已开启,包括高风险命令/) }) }) From 874aa3bb580c8946006c6c0bd28e3d04e69ca041 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 04:12:09 +0800 Subject: [PATCH 05/12] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E5=88=86=E5=B1=82=E5=AF=BC=E8=88=AA=E4=B8=8E=E4=BF=A1?= =?UTF-8?q?=E7=AE=B1=E6=9C=AA=E8=AF=BB=E8=BF=87=E6=BB=A4=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llmdoc/capabilities/architecture.mdx | 3 ++- llmdoc/capabilities/local-device-inbox.mdx | 15 ++++++++++----- llmdoc/delivery/definition-of-done.mdx | 2 +- .../integration/manual-gateway-configuration.mdx | 8 +++++--- llmdoc/integration/sdk-device-transport.mdx | 2 +- llmdoc/integration/trusted-grants.mdx | 4 ++-- llmdoc/integration/upstream-and-platform-gaps.mdx | 2 +- llmdoc/runtime/accessibility-semantics.mdx | 14 +++++++++++--- llmdoc/runtime/architecture.mdx | 13 +++++++------ llmdoc/runtime/safety-boundaries.mdx | 11 ++++++++--- 10 files changed, 48 insertions(+), 26 deletions(-) diff --git a/llmdoc/capabilities/architecture.mdx b/llmdoc/capabilities/architecture.mdx index 9324c1f..970faf6 100644 --- a/llmdoc/capabilities/architecture.mdx +++ b/llmdoc/capabilities/architecture.mdx @@ -158,7 +158,8 @@ 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/ diff --git a/llmdoc/capabilities/local-device-inbox.mdx b/llmdoc/capabilities/local-device-inbox.mdx index 8502def..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 与图片加载 @@ -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/delivery/definition-of-done.mdx b/llmdoc/delivery/definition-of-done.mdx index 25b3fda..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、幂等和脱敏通过; diff --git a/llmdoc/integration/manual-gateway-configuration.mdx b/llmdoc/integration/manual-gateway-configuration.mdx index 9394741..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 bb9bd10..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/runtime/accessibility-semantics.mdx b/llmdoc/runtime/accessibility-semantics.mdx index 5d18b90..5d744bc 100644 --- a/llmdoc/runtime/accessibility-semantics.mdx +++ b/llmdoc/runtime/accessibility-semantics.mdx @@ -14,6 +14,7 @@ code: - 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 @@ -21,6 +22,8 @@ code: - 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 --- @@ -36,12 +39,17 @@ code: ## 共享语义 -- `Screen` 暴露唯一页面 header,并只在页面获得 focus 时请求辅助技术焦点;普通 snapshot/rerender 不 +- `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 名当作用户可见名称。 +- `ActionSheet` 仅承载用户主动打开的本地排序和批量操作;打开时聚焦标题,关闭后由组件或调用页恢复 + 触发控件焦点,忙碌时可禁止关闭。它不接收远程命令裁决,不能替代全局 `PendingConfirmationModal`。 - Activity destructive confirmation 打开时聚焦确认标题,取消/完成后返回清除触发按钮;组件测试覆盖 focus 往返和普通更新不抢焦点。 - 远程 command 的 `PendingConfirmationModal` 挂在 root layout,不受当前 tab 与首页滚动位置影响;打开时 diff --git a/llmdoc/runtime/architecture.mdx b/llmdoc/runtime/architecture.mdx index 9c02be4..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 必须转义,排序不得拼接用户字符串。全局未读、全部已读和清空作用域不得被当前搜索、 + 未读过滤或显示上限截断;本地批量操作应明示这一范围。 ## 事实真源 From 83f955a53fccad567dcc7ae61e0f87ec6ef5f0b5 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 04:12:09 +0800 Subject: [PATCH 06/12] chore(llmdoc): refresh fingerprints --- llmdoc/meta.json | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/llmdoc/meta.json b/llmdoc/meta.json index 5c3dc21..bc85fb9 100644 --- a/llmdoc/meta.json +++ b/llmdoc/meta.json @@ -6,79 +6,79 @@ }, "documents": { "architecture.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/architecture.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/bounded-linking-handoffs.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/bounded-media-source.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/foreground-camera-capture.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/local-device-inbox.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/local-notifications.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/local-timers.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "delivery/eas-project-binding.mdx": { "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" }, "delivery/evidence-language.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "delivery/github-preview-release.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "delivery/verification-and-claims.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "integration/manual-gateway-configuration.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "integration/sdk-device-transport.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "integration/trusted-grants.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "integration/upstream-and-platform-gaps.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "runtime/accessibility-semantics.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "runtime/activity-history.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "runtime/architecture.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "runtime/command-retention.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "runtime/safety-boundaries.mdx": { - "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "product/requirements-and-roadmap.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "delivery/engineering-baseline.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "delivery/definition-of-done.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "delivery/verification-evidence.mdx": { - "validatedRevision": "810c681d5160819fafafd7daf73c8d1bdfacccec" + "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" } }, "convergence": { From 1ef3053979cc78c1d21ebd6461d60beeb246e036 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 04:29:47 +0800 Subject: [PATCH 07/12] refactor(ui): move activity history into device management Keep only inbox and device tabs for frequent access. Open audit history from device management as a detail route with a back action, and preserve audit clearing behavior. --- README.md | 6 +++--- app/(tabs)/_layout.tsx | 7 ------- app/(tabs)/settings.tsx | 1 + app/{(tabs) => }/activity.tsx | 3 ++- scripts/verify-android-emulator.mjs | 15 +++++++-------- src/ui/__tests__/navigation.test.ts | 6 +++--- src/ui/navigation.ts | 8 +++----- src/ui/screens/ActivityScreen.tsx | 4 ++++ src/ui/screens/SettingsScreen.tsx | 3 +++ src/ui/screens/__tests__/ActivityScreen.test.tsx | 5 ++++- src/ui/screens/__tests__/SettingsScreen.test.tsx | 5 ++++- 11 files changed, 34 insertions(+), 29 deletions(-) rename app/{(tabs) => }/activity.tsx (81%) diff --git a/README.md b/README.md index 1b61fcd..64a2d27 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,8 @@ 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 不应写入 `.env`、`EXPO_PUBLIC_*`、源码或 URL;保存时 App 会先停止旧连接,再把 key 写入系统 SecureStore。 @@ -184,7 +184,7 @@ pnpm verify:android:emulator ``` 该脚本会卸载 emulator 中的 dev application id 后重新安装 APK,并验证安装后权限、信箱首页与设备分层导航、动态 -能力、local-only 通知/timer 边界、紧急停用重启持久化、三个标签页的唯一语义,以及关键页面在 200% +能力、local-only 通知/timer 边界、紧急停用重启持久化、两个标签页的唯一语义,以及关键页面在 200% 系统字号下的名称、选中状态和操作最小尺寸;不会操作 preview/production 包,也不替代 TalkBack、VoiceOver 或真机验收。 diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index efb13cb..d2c225c 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -48,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') }} onOpenConnection={() => { router.navigate('/connection') }} onOpenControls={() => { router.navigate('/controls') }} diff --git a/app/(tabs)/activity.tsx b/app/activity.tsx similarity index 81% rename from app/(tabs)/activity.tsx rename to app/activity.tsx index 1bdf750..2d2263f 100644 --- a/app/(tabs)/activity.tsx +++ b/app/activity.tsx @@ -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' @@ -9,6 +9,7 @@ export default function ActivityRoute() { return ( { router.back() }} onClearAuditHistory={clearAuditHistory} records={snapshot.auditRecords} /> diff --git a/scripts/verify-android-emulator.mjs b/scripts/verify-android-emulator.mjs index df69581..a37166c 100644 --- a/scripts/verify-android-emulator.mjs +++ b/scripts/verify-android-emulator.mjs @@ -254,7 +254,7 @@ for (const expected of [ } let source = await launchApp() -for (const tabLabel of ['信箱标签页', '活动标签页', '设备标签页']) { +for (const tabLabel of ['信箱标签页', '设备标签页']) { if (describedNode(source, tabLabel) === null) throw new Error(`首页缺少唯一 tab accessibility label: ${tabLabel}`) } requireSelectedTab(source, '信箱标签页') @@ -310,9 +310,8 @@ await tapByDescription('授权与安全') await findDescription('每次确认(当前)', 48) await returnToDevice() -await tapByDescription('活动标签页') -source = await waitForUi(current => describedNode(current, '活动标签页')?.includes('selected="true"') === true, '活动页面') -requireSelectedTab(source, '活动标签页') +await tapByDescription('活动记录') +await waitForUi(current => hasText(current, '活动'), '设备内的活动页面') await findUi(current => hasText(current, '暂无远程调用记录。'), '本地活动空态') await findUi(current => current.includes('显示最近 100 条,本机最多保留 5,000 条;不展示参数、正文或结果载荷。'), '本地活动历史范围') await tapByDescription('清除本机活动历史') @@ -323,6 +322,7 @@ await tapByDescription('清除本机活动历史') await findUi(current => hasText(current, '确认清除当前活动历史?'), '再次确认清除活动历史') await tapByDescription('确认清除活动历史') await findUi(current => hasText(current, '已清除 0 条本机活动历史;后续调用会继续记录。'), '清除空活动历史的真实结果') +await returnToDevice() const fontScaleSource = (await adb('shell', 'settings', 'get', 'system', 'font_scale')).trim() const originalFontScale = /^\d+(?:\.\d+)?$/u.test(fontScaleSource) ? fontScaleSource : '1.0' @@ -336,9 +336,8 @@ try { await findUi(current => hasText(current, '暂无 App 自有媒体会话。'), '200% 字号媒体空态') await returnToDevice() - await tapByDescription('活动标签页') - source = await waitForUi(current => describedNode(current, '活动标签页')?.includes('selected="true"') === true, '200% 字号活动页面') - requireSelectedTab(source, '活动标签页') + await tapByDescription('活动记录') + await waitForUi(current => hasText(current, '活动'), '200% 字号活动页面') await tapByDescription('清除本机活动历史') // 分别滚动到每个 action 并核对 48dp,不要求放大字号后仍处于同一屏。 for (const actionLabel of ['取消清除活动历史', '确认清除活动历史']) { @@ -349,7 +348,7 @@ try { await tapByDescription('确认清除活动历史') await findUi(current => hasText(current, '已清除 0 条本机活动历史;后续调用会继续记录。'), '200% 字号确认清除结果') - await openDevice() + await returnToDevice() await tapByDescription('设备能力') await findUi(current => hasText(current, 'phone/apps.can_open_url'), '200% 字号能力列表可达') await returnToDevice() diff --git a/src/ui/__tests__/navigation.test.ts b/src/ui/__tests__/navigation.test.ts index aac069e..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/navigation.ts b/src/ui/navigation.ts index b12cb19..1da6171 100644 --- a/src/ui/navigation.ts +++ b/src/ui/navigation.ts @@ -1,19 +1,17 @@ import type { IconName } from '@/ui/components/Icon' -// 主导航只保留三个高频入口:信箱(落地首页)、活动、设备。 -// 状态、能力、媒体降级为设备页内的二级页面,不再占用 tab bar。 +// 主导航只保留两个高频入口:信箱(落地首页)、设备。 +// 活动、状态、能力、媒体作为设备页内的二级页面,不再占用 tab bar。 export const TAB_OPTIONS = { - activity: { tabBarAccessibilityLabel: '活动标签页', title: '活动' }, index: { 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: 'deviceActive', inactive: 'device' }, } as const satisfies Record< diff --git a/src/ui/screens/ActivityScreen.tsx b/src/ui/screens/ActivityScreen.tsx index fb7a58f..0753a74 100644 --- a/src/ui/screens/ActivityScreen.tsx +++ b/src/ui/screens/ActivityScreen.tsx @@ -17,12 +17,14 @@ 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) { @@ -65,6 +67,8 @@ export function ActivityScreen({ return ( + 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__/SettingsScreen.test.tsx b/src/ui/screens/__tests__/SettingsScreen.test.tsx index 21c4cc8..4cca87d 100644 --- a/src/ui/screens/__tests__/SettingsScreen.test.tsx +++ b/src/ui/screens/__tests__/SettingsScreen.test.tsx @@ -35,6 +35,7 @@ function handlers() { return { onEmergencyDisable: jest.fn(), onEnable: jest.fn(), + onOpenActivity: jest.fn(), onOpenCapabilities: jest.fn(), onOpenConnection: jest.fn(), onOpenControls: jest.fn(), @@ -44,7 +45,7 @@ function handlers() { } describe('设备总览', () => { - test('独立入口导航到连接、安全、能力、运行详情和媒体,不直接展示配置表单', async () => { + test('独立入口导航到连接、安全、能力、运行详情、媒体和活动,不直接展示配置表单', async () => { const actions = handlers() const screen = await render() await fireEvent.press(screen.getByRole('button', { name: '连接配置' })) @@ -52,6 +53,8 @@ describe('设备总览', () => { 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) From 62acdc614d74e6a8d1590b958c4e38c47974009d Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 04:30:07 +0800 Subject: [PATCH 08/12] =?UTF-8?q?docs:=20=E5=B0=86=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=AE=9A=E4=B9=89=E4=B8=BA=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=BA=8C=E7=BA=A7=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llmdoc/runtime/accessibility-semantics.mdx | 9 +++++---- llmdoc/runtime/activity-history.mdx | 11 +++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/llmdoc/runtime/accessibility-semantics.mdx b/llmdoc/runtime/accessibility-semantics.mdx index 5d744bc..5a5bbc0 100644 --- a/llmdoc/runtime/accessibility-semantics.mdx +++ b/llmdoc/runtime/accessibility-semantics.mdx @@ -45,9 +45,10 @@ code: - 常规页面的主标题与 toolbar 固定在内容滚动区之外;消息阅读页把主标题放入可滚动正文,避免长标题 挤压阅读空间,返回操作仍留在顶部。两种布局都保留同一主标题语义与 focus 规则。 - `AccessibleAction` 统一 button role、上下文唯一 label、必要 hint、busy/disabled state,以及至少 - 48dp 的 minWidth/minHeight。主导航保留信箱、活动、设备三个 tab,显式使用“信箱/活动/设备标签页” + 48dp 的 minWidth/minHeight。主导航只保留信箱、设备两个 tab,显式使用“信箱/设备标签页” 唯一 label,并投影 selected;信箱仍为落地首页。设备 tab 保留内部 `settings` route,作为连接、授权安全、 - 状态、能力与媒体的总览入口,不把内部 route 名当作用户可见名称。 + 活动记录、状态、能力与媒体的总览入口,不把内部 route 名当作用户可见名称。活动是低频管理页面, + 从设备总览进入并可返回设备,不再占用底部 tab。 - `ActionSheet` 仅承载用户主动打开的本地排序和批量操作;打开时聚焦标题,关闭后由组件或调用页恢复 触发控件焦点,忙碌时可禁止关闭。它不接收远程命令裁决,不能替代全局 `PendingConfirmationModal`。 - Activity destructive confirmation 打开时聚焦确认标题,取消/完成后返回清除触发按钮;组件测试覆盖 @@ -78,8 +79,8 @@ code: 覆盖双主题文本和交互边界 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 至少 +- Android emulator semantic smoke 应验证两个 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 顺序、平台 diff --git a/llmdoc/runtime/activity-history.mdx b/llmdoc/runtime/activity-history.mdx index f91236b..f64ddc7 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,6 +15,7 @@ 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 --- @@ -23,6 +24,8 @@ code: ## 展示与保留 +- 已决定:活动记录是低频设备管理页面,通过设备总览进入 `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,15 +57,15 @@ 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、真机或服务端审计证据。 +- Android emulator fresh-install smoke 应从设备总览进入 Activity,覆盖 100/5,000 范围文案、打开确认后取消、 + 再次确认、真实“已清除 0 条”结果和返回设备。它没有生成有记录的 SQLite clear,也不是 iOS、真机或服务端审计证据。 ## 事实真源 - 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` From f5032d756f6c274acc2710ebe2ddc81cf6a6e7f2 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 04:30:07 +0800 Subject: [PATCH 09/12] chore(llmdoc): refresh fingerprints --- llmdoc/meta.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/llmdoc/meta.json b/llmdoc/meta.json index bc85fb9..76936d8 100644 --- a/llmdoc/meta.json +++ b/llmdoc/meta.json @@ -6,7 +6,7 @@ }, "documents": { "architecture.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" }, "capabilities/architecture.mdx": { "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" @@ -33,13 +33,13 @@ "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" }, "delivery/evidence-language.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" }, "delivery/github-preview-release.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" }, "delivery/verification-and-claims.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" }, "integration/manual-gateway-configuration.mdx": { "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" @@ -54,10 +54,10 @@ "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "runtime/accessibility-semantics.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" }, "runtime/activity-history.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" }, "runtime/architecture.mdx": { "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" @@ -69,16 +69,16 @@ "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "product/requirements-and-roadmap.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" }, "delivery/engineering-baseline.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" }, "delivery/definition-of-done.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" }, "delivery/verification-evidence.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" } }, "convergence": { From f6586cd352a1231a6c5cfe210e57350014b3d67c Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 04:48:24 +0800 Subject: [PATCH 10/12] chore: remove obsolete Android UI smoke script Remove the legacy UiAutomator script and its pnpm entry point; retain normal verification and device acceptance requirements. --- README.md | 12 - package.json | 1 - scripts/verify-android-emulator.mjs | 376 ---------------------------- 3 files changed, 389 deletions(-) delete mode 100644 scripts/verify-android-emulator.mjs diff --git a/README.md b/README.md index 64a2d27..246ee13 100644 --- a/README.md +++ b/README.md @@ -176,18 +176,6 @@ EAS `preview` profile 固定 Node 22.23.1、`APP_VARIANT=preview`、preview envi `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/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 a37166c..0000000 --- a/scripts/verify-android-emulator.mjs +++ /dev/null @@ -1,376 +0,0 @@ -import { execFile } from 'node:child_process' -import { access, readFile } 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' -let displaySize -let density -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 -} - -function describedNode(source, description) { - return nodeWithAttribute(source, 'content-desc', value => ( - value === description || value.endsWith(`, ${description}`) - )) -} - -async function swipeContent(direction) { - const [width, height] = displaySize - const x = Math.round(width * 0.5) - // 留出固定标题栏与底部 tab;不要在导航区域触发滑动。 - const start = Math.round(height * (direction === 'down' ? 0.72 : 0.35)) - const end = Math.round(height * (direction === 'down' ? 0.35 : 0.72)) - await adb('shell', 'input', 'swipe', String(x), String(start), String(x), String(end), '300') - await delay(250) -} - -async function findUi(predicate, label) { - let source = await dumpUi() - if (predicate(source)) return source - // tab 保留滚动位置;先向下查找,再向上查找,不能假定当前位于顶部。 - for (const direction of ['down', 'up']) { - for (let attempt = 0; attempt < 60; attempt += 1) { - await swipeContent(direction) - const next = await dumpUi() - if (predicate(next)) return next - if (next === source) break - source = next - } - } - throw new Error(`滚动后仍找不到 UI: ${label}\n${source.slice(0, 2_000)}`) -} - -async function findDescription(description, minimumHeightDp = 0) { - const source = await findUi(current => { - const node = describedNode(current, description) - return node !== null && nodeHeightDp(node, density) >= minimumHeightDp - }, description) - return describedNode(source, description) -} - -async function tapByDescription(description) { - const node = await findDescription(description, 48) - if (node === null) throw new Error(`找不到 accessibility 节点: ${description}`) - await tapNode(node) -} - -async function openDevice() { - await tapByDescription('设备标签页') - const source = await waitForUi(current => describedNode(current, '设备标签页')?.includes('selected="true"') === true, '设备总览') - requireSelectedTab(source, '设备标签页') -} - -async function returnToDevice() { - await tapByDescription('设备') - const source = await waitForUi(current => describedNode(current, '设备标签页')?.includes('selected="true"') === true, '返回设备总览') - requireSelectedTab(source, '设备标签页') -} - -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') - || describedNode(current, '信箱标签页') !== null - ), '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 => describedNode(current, '信箱标签页') !== null, '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 sizeOutput = await adb('shell', 'wm', 'size') -const sizeMatch = /Override size: (\d+)x(\d+)/u.exec(sizeOutput) - ?? /Physical size: (\d+)x(\d+)/u.exec(sizeOutput) -if (sizeMatch === null) throw new Error(`无法读取 emulator size: ${sizeOutput}`) -displaySize = [Number(sizeMatch[1]), Number(sizeMatch[2])] -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}`) -density = Number(densityMatch[1]) - -// 与发布 gate 使用同一版本事实入口;不把某次历史 APK 版本固定在 smoke 中。 -const appConfig = await readFile(new URL('../app.config.ts', import.meta.url), 'utf8') -const appVersion = /export const APP_VERSION = '([^']+)'/u.exec(appConfig)?.[1] -const versionCode = /export const ANDROID_VERSION_CODE = (\d+)/u.exec(appConfig)?.[1] -if (appVersion === undefined || versionCode === undefined) throw new Error('无法从 app.config.ts 读取发布版本') -const packageInfo = await adb('shell', 'dumpsys', 'package', appId) -for (const expected of [`versionCode=${versionCode} minSdk=24 targetSdk=36`, `versionName=${appVersion}`]) { - 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 tabLabel of ['信箱标签页', '设备标签页']) { - if (describedNode(source, tabLabel) === null) throw new Error(`首页缺少唯一 tab accessibility label: ${tabLabel}`) -} -requireSelectedTab(source, '信箱标签页') -await findUi(current => hasText(current, '最近还没有 Agent 来信。'), 'fresh install 信箱空态') - -await openDevice() -await tapByDescription('连接配置') -await waitForUi(current => hasText(current, '连接配置'), '连接配置页面') -await findDescription('Tool Bridge API key', 48) -await returnToDevice() -await tapByDescription('授权与安全') -await waitForUi(current => hasText(current, '授权与安全'), '授权与安全页面') -await findDescription('每次确认(当前)', 48) -await findDescription('启用本地通知', 48) -await returnToDevice() -await tapByDescription('运行详情') -await waitForUi(current => hasText(current, '设备状态'), '运行详情页面') -await findUi(current => describedNode(current, '控制模式:每次确认') !== null, '默认控制模式') -await findUi(current => describedNode(current, '连接:unconfigured') !== null, '未配置网关的连接状态') -await returnToDevice() - -await tapByDescription('设备能力') -await waitForUi(current => hasText(current, '能力'), '能力页面') -for (const capability of [ - 'phone/apps.can_open_url', - 'phone/location.current', - 'phone/location.open_map', - 'phone/productivity.notify', - 'phone/productivity.timer_start', - 'phone/productivity.timer_cancel', - 'phone/productivity.timer_status', -]) { - await findUi(current => hasText(current, capability), `能力 ${capability}`) - if (capability === 'phone/location.current') { - await findUi(current => current.includes('permission_required: foreground_location_permission_required'), '未授权位置能力的 permission_required') - } - if (capability === 'phone/productivity.notify') { - await findUi(current => current.includes('unavailable: notification_permission_requestable'), 'fresh install 通知仅本地可请求') - } -} -await returnToDevice() - -await tapByDescription('紧急停用远程能力') -await findUi(current => hasText(current, '新命令当前均被拒绝。恢复后,仍需在本机逐次确认。'), '紧急停用状态') -await findDescription('恢复为每次确认', 48) -await adb('shell', 'am', 'force-stop', appId) -source = await launchApp() -requireSelectedTab(source, '信箱标签页') -await openDevice() -await findUi(current => hasText(current, '远程能力已停用'), '重启后保留 disabled 模式') -await tapByDescription('恢复为每次确认') -await tapByDescription('授权与安全') -await findDescription('每次确认(当前)', 48) -await returnToDevice() - -await tapByDescription('活动记录') -await waitForUi(current => hasText(current, '活动'), '设备内的活动页面') -await findUi(current => hasText(current, '暂无远程调用记录。'), '本地活动空态') -await findUi(current => current.includes('显示最近 100 条,本机最多保留 5,000 条;不展示参数、正文或结果载荷。'), '本地活动历史范围') -await tapByDescription('清除本机活动历史') -await findUi(current => hasText(current, '确认清除当前活动历史?'), '活动历史确认标题') -await findUi(current => current.includes('不会清除防重放记录、计时器、设置、installation identity 或凭证'), '活动清除保留对象') -await tapByDescription('取消清除活动历史') -await tapByDescription('清除本机活动历史') -await findUi(current => hasText(current, '确认清除当前活动历史?'), '再次确认清除活动历史') -await tapByDescription('确认清除活动历史') -await findUi(current => hasText(current, '已清除 0 条本机活动历史;后续调用会继续记录。'), '清除空活动历史的真实结果') -await returnToDevice() - -const fontScaleSource = (await adb('shell', 'settings', 'get', 'system', 'font_scale')).trim() -const originalFontScale = /^\d+(?:\.\d+)?$/u.test(fontScaleSource) ? fontScaleSource : '1.0' -await adb('shell', 'settings', 'put', 'system', 'font_scale', '2.0') -try { - await adb('shell', 'am', 'force-stop', appId) - source = await launchApp() - requireSelectedTab(source, '信箱标签页') - await openDevice() - await tapByDescription('媒体会话') - await findUi(current => hasText(current, '暂无 App 自有媒体会话。'), '200% 字号媒体空态') - await returnToDevice() - - await tapByDescription('活动记录') - await waitForUi(current => hasText(current, '活动'), '200% 字号活动页面') - await tapByDescription('清除本机活动历史') - // 分别滚动到每个 action 并核对 48dp,不要求放大字号后仍处于同一屏。 - for (const actionLabel of ['取消清除活动历史', '确认清除活动历史']) { - await findDescription(actionLabel, 48) - } - await tapByDescription('取消清除活动历史') - await tapByDescription('清除本机活动历史') - await tapByDescription('确认清除活动历史') - await findUi(current => hasText(current, '已清除 0 条本机活动历史;后续调用会继续记录。'), '200% 字号确认清除结果') - - await returnToDevice() - await tapByDescription('设备能力') - await findUi(current => hasText(current, 'phone/apps.can_open_url'), '200% 字号能力列表可达') - await returnToDevice() - await tapByDescription('运行详情') - await findUi(current => describedNode(current, '控制模式:每次确认') !== null, '200% 字号运行状态可读') - await returnToDevice() - await tapByDescription('连接配置') - await findDescription('Tool Bridge API key', 48) - await returnToDevice() - await tapByDescription('授权与安全') - await findDescription('每次确认(当前)', 48) - await findDescription('允许后台运行', 48) - await returnToDevice() -} 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% 字号语义交互。') From 17ccdab3a596c80ae5edd8e19266bff8e6299dc6 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 04:48:42 +0800 Subject: [PATCH 11/12] =?UTF-8?q?docs:=20=E7=A7=BB=E9=99=A4=E9=80=80?= =?UTF-8?q?=E5=BD=B9=E6=A8=A1=E6=8B=9F=E5=99=A8=E8=84=9A=E6=9C=AC=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=B9=B6=E4=BF=9D=E7=95=99=E9=AA=8C=E6=94=B6=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llmdoc/capabilities/bounded-linking-handoffs.mdx | 4 ++-- llmdoc/capabilities/local-notifications.mdx | 4 ++-- llmdoc/capabilities/local-timers.mdx | 2 +- llmdoc/delivery/engineering-baseline.mdx | 2 +- llmdoc/delivery/verification-and-claims.mdx | 5 ----- llmdoc/delivery/verification-evidence.mdx | 6 +----- llmdoc/runtime/accessibility-semantics.mdx | 3 +-- llmdoc/runtime/activity-history.mdx | 7 +++---- 8 files changed, 11 insertions(+), 22 deletions(-) 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-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/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/runtime/accessibility-semantics.mdx b/llmdoc/runtime/accessibility-semantics.mdx index 5a5bbc0..44cf75c 100644 --- a/llmdoc/runtime/accessibility-semantics.mdx +++ b/llmdoc/runtime/accessibility-semantics.mdx @@ -25,7 +25,6 @@ code: - app/(tabs)/settings.tsx - src/ui/screens/SettingsScreen.tsx - src/ui/**/__tests__/** - - scripts/verify-android-emulator.mjs --- # 系统主题与 Accessibility semantics 基线 @@ -79,7 +78,7 @@ code: 覆盖双主题文本和交互边界 contrast gate,系统主题组件测试覆盖切换后保留未提交表单及危险操作实际配色。 `SafeMarkdown` 组件测试另覆盖跨样式单链接、相邻链接和通用失败 `alert`;这不证明 TalkBack/VoiceOver 实际朗读或真机系统 handoff。 -- Android emulator semantic smoke 应验证两个 tab 的唯一 accessibility label/selected state,保存原字号后设为 +- 页面交互验收应检查两个 tab 的唯一 accessibility label/selected state,保存原字号后设为 200%,force-stop/relaunch,经设备总览进入活动记录并操作 clear/cancel/confirm、返回设备,复核关键 action bounds 至少 48dp,最后恢复原字号。 - UIAutomator 只观察语义树、selected state、bounds 和点击,不能替代 Android TalkBack、VoiceOver、 diff --git a/llmdoc/runtime/activity-history.mdx b/llmdoc/runtime/activity-history.mdx index f64ddc7..719d206 100644 --- a/llmdoc/runtime/activity-history.mdx +++ b/llmdoc/runtime/activity-history.mdx @@ -17,7 +17,6 @@ code: - src/ui/screens/ActivityScreen.tsx - app/activity.tsx - test/contract/audit-history.contract.test.ts - - scripts/verify-android-emulator.mjs --- # 本地 Activity 与仅审计历史清除 @@ -57,8 +56,8 @@ 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,也不构成另一平台或服务端审计证据。 ## 事实真源 @@ -69,4 +68,4 @@ code: - 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` From bc22e4478a162484ddd28692cedd4cabeaaf37a7 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 04:48:42 +0800 Subject: [PATCH 12/12] chore(llmdoc): refresh fingerprints --- llmdoc/meta.json | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/llmdoc/meta.json b/llmdoc/meta.json index 76936d8..f2e75a3 100644 --- a/llmdoc/meta.json +++ b/llmdoc/meta.json @@ -6,58 +6,58 @@ }, "documents": { "architecture.mdx": { - "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "capabilities/architecture.mdx": { "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/bounded-linking-handoffs.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "capabilities/bounded-media-source.mdx": { "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/foreground-camera-capture.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "capabilities/local-device-inbox.mdx": { "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "capabilities/local-notifications.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "capabilities/local-timers.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/eas-project-binding.mdx": { "validatedRevision": "763539a8fe0dd504be7baa67669e5ec8c5db5587" }, "delivery/evidence-language.mdx": { - "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/github-preview-release.mdx": { - "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/verification-and-claims.mdx": { - "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "integration/manual-gateway-configuration.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "integration/sdk-device-transport.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "integration/trusted-grants.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "integration/upstream-and-platform-gaps.mdx": { - "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "runtime/accessibility-semantics.mdx": { - "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "runtime/activity-history.mdx": { - "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "runtime/architecture.mdx": { "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" @@ -69,16 +69,16 @@ "validatedRevision": "874aa3bb580c8946006c6c0bd28e3d04e69ca041" }, "product/requirements-and-roadmap.mdx": { - "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/engineering-baseline.mdx": { - "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/definition-of-done.mdx": { - "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" }, "delivery/verification-evidence.mdx": { - "validatedRevision": "62acdc614d74e6a8d1590b958c4e38c47974009d" + "validatedRevision": "17ccdab3a596c80ae5edd8e19266bff8e6299dc6" } }, "convergence": {