From 14f4ae4a6554100b1de77208fe10a7eb7592c768 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 14:01:29 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=8E=9F=E7=94=9F=E6=89=AB?= =?UTF-8?q?=E7=A0=81=E8=B6=85=E6=97=B6=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将二维码扫码超时提升到共享 HostBridge 契约 让 H5 facade 和测试复用共享扫码超时常量 增加原生壳门禁拦截扫码超时字面量回退 补充宿主壳方案文档和共享决策记录 --- apps/desktop-shell/scripts/check-config.mjs | 6 ++++++ docs/project-memory/shared-memory/decision-log.md | 7 +++++++ ...前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 +- docs/【前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- packages/shared/src/contracts/hostBridge.test.ts | 2 ++ packages/shared/src/contracts/hostBridge.ts | 1 + scripts/check-native-shells.mjs | 6 ++++++ src/services/host-bridge/hostBridge.test.ts | 3 ++- src/services/host-bridge/hostBridge.ts | 3 ++- 9 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 6c0f0164b..1c8b9a808 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1795,12 +1795,18 @@ if (!h5HostBridgeSource.includes('HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS,')) { if (!h5HostBridgeSource.includes('HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,')) { throw new Error('H5 HostBridge facade must import shared user interaction timeout'); } +if (!h5HostBridgeSource.includes('HOST_BRIDGE_SCANNER_TIMEOUT_MS,')) { + throw new Error('H5 HostBridge facade must import shared scanner timeout'); +} if (h5HostBridgeSource.includes('HOST_RUNTIME_REFRESH_TIMEOUT_MS')) { throw new Error('H5 HostBridge facade must not redeclare runtime refresh timeout'); } if (h5HostBridgeSource.includes('timeoutMs: 30000')) { throw new Error('H5 HostBridge facade must not redeclare user interaction timeout'); } +if (h5HostBridgeSource.includes('timeoutMs: 60000')) { + throw new Error('H5 HostBridge facade must not redeclare scanner timeout'); +} for (const snippet of [ 'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS', 'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e512b5d99..5777c0fe1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2754,3 +2754,10 @@ - 决策:`scripts/check-native-shells.mjs` 新增微信 capability flow contract。每个微信 capability 必须对应真实 `miniprogram/host-bridge/*`、`miniprogram/shell/*`、`miniprogram/pages/*` 文件,源码中必须保留关键页面工厂或 `wx.login` / `wx.requestPayment` / `wx.requestVirtualPayment` / `wx.saveImageToPhotosAlbum` 等真实宿主调用,并且对应测试必须在 `check:native-shells` 的微信壳测试清单内。 - 影响范围:`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档、共享开发流程记忆。 - 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 原生宿主二维码扫码超时单一来源 + +- 背景:二维码扫码属于真实相机交互,等待时间应长于普通宿主请求;此前 H5 `scanHostQrCode()` 直接手写 `timeoutMs: 60000`,会让扫码等待边界和共享 HostBridge 契约漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_SCANNER_TIMEOUT_MS`,作为 H5 facade 发起 `scanner.scanQrCode` 请求的唯一超时来源;`src/services/host-bridge/hostBridge.ts` 必须导入共享常量,不得继续手写 `timeoutMs: 60000`。根级原生壳门禁和桌面壳配置检查会拒绝回退到本地字面量。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档和共享开发流程记忆。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/services/host-bridge/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index fbfbb6fcb..1f993931e 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -160,7 +160,7 @@ type HostBridgeEvent = { | `haptics.impact` | 轻量触感反馈 | 支持 | 不声明 | | `notification.showLocal` | 发送即时本地系统通知 | 支持 Expo Notifications | 支持 Rust 侧 Tauri notification | -每个 method 都必须有明确 payload schema、超时、错误码和能力开关;H5 看到不支持时回退到现有浏览器路径。 +每个 method 都必须有明确 payload schema、超时、错误码和能力开关;H5 看到不支持时回退到现有浏览器路径。H5 发起原生壳请求的超时边界统一从 `packages/shared/src/contracts/hostBridge.ts` 读取:普通请求使用 `HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS`,传入值最多裁剪到 `HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS`;宿主 runtime 回读使用 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS`;文件导入 / 导出、图片选择 / 拍摄和音频导入 / 导出使用 `HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS`;二维码扫码使用 `HOST_BRIDGE_SCANNER_TIMEOUT_MS`。H5 facade 不得在调用点手写 `30000` 或 `60000` 等毫秒字面量,根级 `check:native-shells` 与桌面壳配置检查会拦截回退。 ## Expo React Native 壳 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 53a3f97dc..9089c3c7c 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -73,7 +73,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗 - `importHostDocumentFile()`:原生 App 宿主的受控文档导入入口。Expo 移动壳通过 Expo DocumentPicker,Tauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json`、`application/vnd.openxmlformats-officedocument.wordprocessingml.document` 或对应 `.txt` / `.md` / `.markdown` / `.csv` / `.json` / `.docx` 扩展名,单次不超过 5 MiB。成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI、本机绝对路径或通用文件系统能力;宿主必须在读取 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文档导入,把返回 base64 转换成现有浏览器 `File` 后继续调用 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才回退到文本导入,普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。该能力不在前端解析 DOCX,也不绕过后端文档解析、大小校验或错误口径。 - `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。 - `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png`、`image/jpeg`、`image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄能力不使用麦克风,移动壳包级麦克风权限只服务同源 H5 实时声音玩法。H5 的通用图片输入面板 `CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;创作 Agent 工作台参考图上传、轻输入 composer 参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传、汪汪声浪结果页三图槽位上传、抓大鹅结果页发布封面 / 封面参考图上传、RPG 角色资产工作室角色参考图上传、RPG 作品封面 / 封面参考图上传、RPG 场景图片参考图上传和视觉小说结果页图片素材上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 工作台继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,轻输入 composer 继续复用 `readPuzzleReferenceImageAsDataUrl` 的图片类型、大小、压缩和 data URL 预览链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路,抓大鹅结果页继续复用现有封面 data URL 读取、AI 重绘开关、参考图集合和封面生成 payload 链路,RPG 角色资产工作室继续复用现有 `readFileAsDataUrl` 参考图集合和角色形象生成 payload 链路,RPG 作品封面上传继续复用现有 10 MiB 校验、图片尺寸读取、16:9 裁剪和 `uploadCustomWorldCoverImage` 保存链路,RPG 作品封面参考图继续复用现有 `readImageFileAsDataUrl` 读取、预览和 `generateCustomWorldCoverImage` payload 链路,RPG 场景图片参考图继续复用现有 `readImageFileAsDataUrl` 读取、预览和 `rpgCreationAssetClient.generateSceneImage` payload 链路,视觉小说结果页继续把图片转换成浏览器 `File` 后交给 `uploadVisualNovelAsset` 上传和当前封面 / 角色 / 场景素材写回链路;反馈页和轻输入 composer 在移动壳声明 `file.captureImage` 时额外展示拍摄入口,并把拍摄结果复用同一图片校验与提交链路。在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 -- `scanHostQrCode()`:原生 App 宿主的受控二维码扫描入口。Expo 移动壳声明 `scanner.scanQrCode`,通过 `expo-camera` 的真实相机权限和 `CameraView` 扫描 QR code,成功只返回清洗后的二维码文本与 `qr_code` 格式,单次值最多保留 4096 字符且拒绝空值和控制字符;用户关闭或系统取消返回 `cancelled`,H5 不会继续连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中用于明确返回 `unsupported_method`,不声明 capability、不伪造桌面扫码。个人中心扫码入口在 `native_app` 且宿主声明该能力时优先调用原生扫码;宿主不支持、旧壳缺能力或扫码结果非法时继续打开现有浏览器摄像头扫码弹层,普通浏览器和小程序保持原有路径。 +- `scanHostQrCode()`:原生 App 宿主的受控二维码扫描入口。Expo 移动壳声明 `scanner.scanQrCode`,通过 `expo-camera` 的真实相机权限和 `CameraView` 扫描 QR code,成功只返回清洗后的二维码文本与 `qr_code` 格式,单次值最多保留 4096 字符且拒绝空值和控制字符;H5 facade 的扫码等待上限固定读取共享契约 `HOST_BRIDGE_SCANNER_TIMEOUT_MS`,不得在业务调用点手写毫秒数;用户关闭或系统取消返回 `cancelled`,H5 不会继续连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中用于明确返回 `unsupported_method`,不声明 capability、不伪造桌面扫码。个人中心扫码入口在 `native_app` 且宿主声明该能力时优先调用原生扫码;宿主不支持、旧壳缺能力或扫码结果非法时继续打开现有浏览器摄像头扫码弹层,普通浏览器和小程序保持原有路径。 HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`;事件名必须同时进入 capability 白名单。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。H5 事件订阅入口必须同时要求 `host.events` 和对应事件 capability,不能仅凭 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 或 `file.imageDropped` 单项能力就绑定事件监听;旧壳或裁剪壳缺任一能力时订阅应返回空取消函数。`npm run check:native-shells` 会反查共享事件清单、H5 订阅 facade 和 `canUseNativeHostEventCapability(...)`,防止后续事件订阅绕过双能力门控。 - `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取音频内容或生成 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。H5 的通用音频输入面板 `CreativeAudioInputPanel` 在 `native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;视觉小说结果页音乐和环境音素材上传同样优先调用宿主音频导入,再把返回副本转换成浏览器 `File` 后继续交给 `uploadVisualNovelAsset` 上传和场景音频字段写回链路。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 9469e69e7..c54ad4c0e 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -23,6 +23,7 @@ import { HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS, HOST_BRIDGE_RESPONSE_CACHE_MAX, HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, + HOST_BRIDGE_SCANNER_TIMEOUT_MS, HOST_BRIDGE_TEXT_MIME_TYPES, HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, isHostBridgeMethod, @@ -56,6 +57,7 @@ describe('HostBridge shared contract helpers', () => { expect(HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS).toBe(60000); expect(HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS).toBe(3000); expect(HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS).toBe(30000); + expect(HOST_BRIDGE_SCANNER_TIMEOUT_MS).toBe(60000); }); test('固定宿主侧响应回放缓存边界', () => { diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index dbf89ab72..102885032 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -220,6 +220,7 @@ export const HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS = 8000; export const HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS = 60000; export const HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS = 3000; export const HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS = 30000; +export const HOST_BRIDGE_SCANNER_TIMEOUT_MS = 60000; export const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128; export const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH = 120; diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 51ed08453..9e1e20626 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -978,6 +978,7 @@ function assertH5HostBridgePayloadBoundaries() { 'HOST_BRIDGE_AUDIO_MIME_TYPES', 'HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS', 'HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS', + 'HOST_BRIDGE_SCANNER_TIMEOUT_MS', ]) { if (!h5HostBridgeSource.includes(`${sharedBoundary},`)) { throw new Error( @@ -1022,6 +1023,11 @@ function assertH5HostBridgePayloadBoundaries() { 'H5 HostBridge facade must use shared HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS', ); } + if (h5HostBridgeSource.includes('timeoutMs: 60000')) { + throw new Error( + 'H5 HostBridge facade must use shared HOST_BRIDGE_SCANNER_TIMEOUT_MS', + ); + } } function assertH5NativeAppTransportTimeoutBoundaries() { diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index 3dd552b1c..c0a8b9eb6 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import type { HostBridgeCapability } from '../../../packages/shared/src/contracts/hostBridge'; import { HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, + HOST_BRIDGE_SCANNER_TIMEOUT_MS, HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, } from '../../../packages/shared/src/contracts/hostBridge'; import { @@ -1209,7 +1210,7 @@ describe('hostBridge', () => { expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'scanner.scanQrCode', - timeoutMs: 60000, + timeoutMs: HOST_BRIDGE_SCANNER_TIMEOUT_MS, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index a15fd2f49..59e459e3f 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -31,6 +31,7 @@ import { HOST_BRIDGE_NATIVE_APP_QUERY, HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY, + HOST_BRIDGE_SCANNER_TIMEOUT_MS, HOST_BRIDGE_TEXT_MIME_TYPES, HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY, @@ -931,7 +932,7 @@ export async function scanHostQrCode() { await requestNativeAppHostBridge( 'scanner.scanQrCode', undefined, - { timeoutMs: 60000 }, + { timeoutMs: HOST_BRIDGE_SCANNER_TIMEOUT_MS }, ); return normalizeHostBridgeQrCodeValue(result?.value) ?? false; } catch (error) {