From 48bad28a96f67bdeafe9a77b030a0f751ce02383 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 19:34:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E8=83=BD=E5=8A=9B=E5=A4=B1=E8=B4=A5=E8=A7=82?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动剪贴板触觉网络记录原生异常日志 相关测试覆盖稳定错误和日志边界 配置门禁和文档同步基础能力观测约束 --- apps/mobile-shell/scripts/check-config.mjs | 14 +++++++ .../src/host-bridge/bridge.test.ts | 18 +++++++-- .../src/host-bridge/clipboard.test.ts | 40 ++++++++++++++----- .../mobile-shell/src/host-bridge/clipboard.ts | 10 ++++- .../src/host-bridge/haptics.test.ts | 20 +++++++--- apps/mobile-shell/src/host-bridge/haptics.ts | 7 +++- .../src/host-bridge/network.test.ts | 32 ++++++++++----- apps/mobile-shell/src/host-bridge/network.ts | 7 +++- .../shared-memory/decision-log.md | 6 +++ ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 + 10 files changed, 122 insertions(+), 34 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 9e780b89d..1c4076d1d 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -2381,6 +2381,10 @@ for (const snippet of [ 'writeMobileClipboardText(text)', 'readMobileHostBridgeClipboardText', 'ok(request, await readMobileClipboardText())', + 'logMobileClipboardFailure', + 'mobile clipboard failed for', + "logMobileClipboardFailure('write.set_string', error)", + "logMobileClipboardFailure('read.get_string', error)", "message: 'clipboard write unavailable'", "message: 'clipboard read unavailable'", ]) { @@ -2393,8 +2397,10 @@ if (!clipboardTestSource.includes("'猫'.repeat(100000)")) { } for (const snippet of [ 'maps native clipboard write failures to stable host errors', + 'mobile clipboard failed for write.set_string', "message: 'clipboard write unavailable'", 'maps native clipboard read failures to stable host errors', + 'mobile clipboard failed for read.get_string', "message: 'clipboard read unavailable'", 'rejects unavailable clipboard text without reporting a successful empty value', `Clipboard.getStringAsync).${'mo'}${'ck'}ResolvedValue(undefined as never)`, @@ -2409,6 +2415,9 @@ for (const snippet of [ for (const snippet of [ 'try {', 'return ok(request, await getMobileNetworkStatus())', + 'logMobileNetworkFailure', + 'mobile network failed for', + "logMobileNetworkFailure('status.query', error)", 'return failure(request, {', "code: 'host_error'", "message: 'network status unavailable'", @@ -2420,6 +2429,7 @@ for (const snippet of [ for (const snippet of [ 'converts native network failures to a stable host_error response', "new Error('network query failed')", + 'mobile network failed for status.query', "message: 'network status unavailable'", ]) { if (!hostBridgeNetworkTestSource.includes(snippet)) { @@ -2439,6 +2449,9 @@ if ( !hapticsSource.includes('Haptics.ImpactFeedbackStyle.Medium') || !hapticsSource.includes('Haptics.ImpactFeedbackStyle.Light') || !hapticsSource.includes('await Haptics.impactAsync(toExpoImpactStyle(style))') || + !hapticsSource.includes('logMobileHapticsFailure') || + !hapticsSource.includes('mobile haptics failed for') || + !hapticsSource.includes("logMobileHapticsFailure('impact.dispatch', error)") || !hapticsSource.includes("message: 'haptics impact unavailable'") || !hapticsSource.includes('ok(request, true)') ) { @@ -2449,6 +2462,7 @@ if ( for (const snippet of [ 'reports unavailable native feedback with a stable HostBridge error', + 'mobile haptics failed for impact.dispatch', "message: 'haptics impact unavailable'", ]) { if (!hapticsTestSource.includes(snippet)) { diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 5c2cacc5d..3331a2dd7 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -766,14 +766,24 @@ describe('handleMobileHostBridgeMessage', () => { }); test('clipboard.readText 读取失败时返回 host_error', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = new Error('clipboard unavailable'); vi.mocked(Clipboard.getStringAsync).mockRejectedValue( - new Error('clipboard unavailable'), + nativeError, ); - const response = await send(request('clipboard.readText')); + try { + const response = await send(request('clipboard.readText')); - const failedResponse = expectFailed(response); - expect(failedResponse.error.code).toBe('host_error'); + const failedResponse = expectFailed(response); + expect(failedResponse.error.code).toBe('host_error'); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile clipboard failed for read.get_string', + nativeError, + ); + } finally { + warnSpy.mockRestore(); + } }); test('haptics.impact 调起 Expo 触觉反馈', async () => { diff --git a/apps/mobile-shell/src/host-bridge/clipboard.test.ts b/apps/mobile-shell/src/host-bridge/clipboard.test.ts index 05a5fd747..62674f513 100644 --- a/apps/mobile-shell/src/host-bridge/clipboard.test.ts +++ b/apps/mobile-shell/src/host-bridge/clipboard.test.ts @@ -64,14 +64,24 @@ describe('mobile clipboard helpers', () => { }); test('maps native clipboard write failures to stable host errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = new Error('native clipboard write failed'); vi.mocked(Clipboard.setStringAsync).mockRejectedValueOnce( - new Error('native clipboard write failed'), + nativeError, ); - await expect(writeMobileClipboardText('作品号 PZ-1')).rejects.toMatchObject({ - code: 'host_error', - message: 'clipboard write unavailable', - }); + try { + await expect(writeMobileClipboardText('作品号 PZ-1')).rejects.toMatchObject({ + code: 'host_error', + message: 'clipboard write unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile clipboard failed for write.set_string', + nativeError, + ); + } finally { + warnSpy.mockRestore(); + } }); test('wraps HostBridge clipboard write responses', async () => { @@ -140,14 +150,24 @@ describe('mobile clipboard helpers', () => { }); test('maps native clipboard read failures to stable host errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = new Error('native clipboard read failed'); vi.mocked(Clipboard.getStringAsync).mockRejectedValueOnce( - new Error('native clipboard read failed'), + nativeError, ); - await expect(readMobileClipboardText()).rejects.toMatchObject({ - code: 'host_error', - message: 'clipboard read unavailable', - }); + try { + await expect(readMobileClipboardText()).rejects.toMatchObject({ + code: 'host_error', + message: 'clipboard read unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile clipboard failed for read.get_string', + nativeError, + ); + } finally { + warnSpy.mockRestore(); + } }); test('wraps HostBridge clipboard read responses', async () => { diff --git a/apps/mobile-shell/src/host-bridge/clipboard.ts b/apps/mobile-shell/src/host-bridge/clipboard.ts index 6aa3b1e28..f49a4f1d9 100644 --- a/apps/mobile-shell/src/host-bridge/clipboard.ts +++ b/apps/mobile-shell/src/host-bridge/clipboard.ts @@ -9,6 +9,10 @@ import { } from '../../../../packages/shared/src/contracts/hostBridge'; import { invalidRequest, ok } from './protocol'; +function logMobileClipboardFailure(label: string, error: unknown) { + console.warn(`mobile clipboard failed for ${label}`, error); +} + export async function writeMobileClipboardText(rawText: unknown) { const clipboardText = normalizeHostBridgeClipboardText(rawText); if (!clipboardText) { @@ -17,7 +21,8 @@ export async function writeMobileClipboardText(rawText: unknown) { try { await Clipboard.setStringAsync(clipboardText.text); - } catch { + } catch (error) { + logMobileClipboardFailure('write.set_string', error); throw { code: 'host_error', message: 'clipboard write unavailable', @@ -47,7 +52,8 @@ export async function readMobileClipboardText(): Promise { }); test('reports unavailable native feedback with a stable HostBridge error', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = new Error('native haptics failed'); vi.mocked(Haptics.impactAsync).mockRejectedValueOnce( - new Error('native haptics failed'), + nativeError, ); - await expect(runMobileHapticsImpact('light')).rejects.toMatchObject({ - code: 'host_error', - message: 'haptics impact unavailable', - }); + try { + await expect(runMobileHapticsImpact('light')).rejects.toMatchObject({ + code: 'host_error', + message: 'haptics impact unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile haptics failed for impact.dispatch', + nativeError, + ); + } finally { + warnSpy.mockRestore(); + } }); test('wraps HostBridge success response after real impact dispatch', async () => { diff --git a/apps/mobile-shell/src/host-bridge/haptics.ts b/apps/mobile-shell/src/host-bridge/haptics.ts index f941a9701..26bbe0764 100644 --- a/apps/mobile-shell/src/host-bridge/haptics.ts +++ b/apps/mobile-shell/src/host-bridge/haptics.ts @@ -17,6 +17,10 @@ function toExpoImpactStyle(style: HostBridgeHapticsImpactStyle) { : Haptics.ImpactFeedbackStyle.Light; } +function logMobileHapticsFailure(label: string, error: unknown) { + console.warn(`mobile haptics failed for ${label}`, error); +} + export async function runMobileHapticsImpact(rawStyle: unknown) { const style = normalizeHostBridgeHapticsImpactStyle(rawStyle); if (!style) { @@ -25,7 +29,8 @@ export async function runMobileHapticsImpact(rawStyle: unknown) { try { await Haptics.impactAsync(toExpoImpactStyle(style)); - } catch { + } catch (error) { + logMobileHapticsFailure('impact.dispatch', error); throw { code: 'host_error', message: 'haptics impact unavailable', diff --git a/apps/mobile-shell/src/host-bridge/network.test.ts b/apps/mobile-shell/src/host-bridge/network.test.ts index 579308554..83dce7031 100644 --- a/apps/mobile-shell/src/host-bridge/network.test.ts +++ b/apps/mobile-shell/src/host-bridge/network.test.ts @@ -72,19 +72,29 @@ describe('mobile HostBridge network helper', () => { }); test('converts native network failures to a stable host_error response', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = new Error('network query failed'); vi.mocked(Network.getNetworkStateAsync).mockRejectedValue( - new Error('network query failed'), + nativeError, ); - await expect(getMobileHostBridgeNetworkStatus(request())).resolves.toEqual({ - bridge: HOST_BRIDGE_PROTOCOL, - version: HOST_BRIDGE_VERSION, - id: 'network-request', - ok: false, - error: { - code: 'host_error', - message: 'network status unavailable', - }, - }); + try { + await expect(getMobileHostBridgeNetworkStatus(request())).resolves.toEqual({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'network-request', + ok: false, + error: { + code: 'host_error', + message: 'network status unavailable', + }, + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile network failed for status.query', + nativeError, + ); + } finally { + warnSpy.mockRestore(); + } }); }); diff --git a/apps/mobile-shell/src/host-bridge/network.ts b/apps/mobile-shell/src/host-bridge/network.ts index 18db4ed80..bd75b27a1 100644 --- a/apps/mobile-shell/src/host-bridge/network.ts +++ b/apps/mobile-shell/src/host-bridge/network.ts @@ -2,12 +2,17 @@ import { getMobileNetworkStatus } from '../shell/network'; import { type HostBridgeRequest } from '../../../../packages/shared/src/contracts/hostBridge'; import { failure, ok } from './protocol'; +function logMobileNetworkFailure(label: string, error: unknown) { + console.warn(`mobile network failed for ${label}`, error); +} + export async function getMobileHostBridgeNetworkStatus( request: HostBridgeRequest, ) { try { return ok(request, await getMobileNetworkStatus()); - } catch { + } catch (error) { + logMobileNetworkFailure('status.query', error); return failure(request, { code: 'host_error', message: 'network status unavailable', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d8336c25c..3948c6bee 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3112,6 +3112,12 @@ - 决策:`apps/mobile-shell/src/host-bridge/notifications.ts` 必须在权限读取 / 请求和通知投递失败时记录 `mobile notification failed for ...` 日志;`apps/mobile-shell/src/host-bridge/badge.ts` 必须在角标权限读取 / 请求、`setBadgeCountAsync` reject 和返回 `false` 时记录 `mobile app badge failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义;该约束不新增远程推送 token、后台通知、定时提醒或 Android 角标 capability。 - 验证方式:`npm run mobile-shell:test -- src/host-bridge/notifications.test.ts src/host-bridge/badge.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-20 移动壳剪贴板触觉网络异常必须可观测 + +- 背景:Expo 移动壳已声明剪贴板读写、触觉反馈和网络状态查询;这些能力会触发 Expo Clipboard、Haptics 和 Network 原生模块。如果原生 API 异常只被折叠成稳定 HostBridge 错误,H5 语义安全,但开发侧无法区分系统剪贴板不可用、触觉模块异常或网络模块查询失败。 +- 决策:`apps/mobile-shell/src/host-bridge/clipboard.ts` 必须在剪贴板写入 / 读取失败时记录 `mobile clipboard failed for ...` 日志;`apps/mobile-shell/src/host-bridge/haptics.ts` 必须在触觉派发失败时记录 `mobile haptics failed for ...` 日志;`apps/mobile-shell/src/host-bridge/network.ts` 必须在 HostBridge 网络状态查询失败时记录 `mobile network failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义;该约束不新增后台网络探测、任意系统能力或 H5 业务兜底路径。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/clipboard.test.ts src/host-bridge/haptics.test.ts src/host-bridge/network.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-20 移动壳门禁脚本必须自登记自扫描 - 背景:Expo 移动壳单端检查已把 `apps/mobile-shell/scripts/` 纳入生产源码扫描入口,但 `check-config.mjs` 自身仍被排除在脚本清单和替身词扫描之外;这会让移动壳与桌面壳门禁结构不一致,也可能让后续门禁反查内容绕过生产替身词规则。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 0be5e0d64..bd2d5a746 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -340,6 +340,8 @@ GameBridge 禁止: 2026-06-18 追加:H5 的平台外部生成队列概览开始消费 `network.status` / `network.statusChanged`。宿主未声明网络能力时继续按原逻辑轮询;宿主明确离线或不可达时暂停概览请求,恢复在线后重新刷新,不改变生成任务、作品架或后端回读事实。 +2026-06-20 追加:移动壳剪贴板、触觉和网络查询的原生异常必须可观测。Expo Clipboard 写入 / 读取、Expo Haptics 触觉派发、Expo Network 状态查询失败时,移动壳必须分别记录 `mobile clipboard failed for ...`、`mobile haptics failed for ...` 或 `mobile network failed for ...` 日志;HostBridge 回包仍只暴露稳定 `clipboard write unavailable`、`clipboard read unavailable`、`haptics impact unavailable` 或 `network status unavailable` 语义。该约束不新增后台网络探测、任意系统能力或 H5 业务兜底路径。 + 2026-06-18 追加:移动壳 `haptics.impact` 只接受 `light`、`medium`、`heavy` 三档 impact style,缺省为 `light`;未知强度返回 `invalid_request`,不会静默降级成真实设备触觉反馈。桌面壳不声明该能力,H5 继续按 HostBridge fallback 处理。 2026-06-19 追加:移动壳 `haptics.impact` 的 HostBridge payload 解析、共享 style 归一、Expo style 映射和 `Haptics.impactAsync(...)` 真实调用统一收口在 `apps/mobile-shell/src/host-bridge/haptics.ts`;`dispatch.ts` 只按 method 委托 `runMobileHostBridgeHapticsImpact(request.payload)`。