diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4de42109f..5478a4835 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2571,7 +2571,7 @@ ## 2026-06-19 HostBridge 载荷边界单一来源 - 背景:文件导入导出、剪贴板、角标、本地通知和 request id 都已经在 Expo 与 Tauri 两套壳里有运行时校验;如果 MIME 清单、字节上限或文本长度只靠人工同步,新增文件类型或调整上限时会出现 H5 契约、移动壳和桌面壳互相漂移。 -- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、文档导入 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、窗口标题长度、外链 URL payload、分享 payload、剪贴板文本长度、触觉反馈 style 和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;移动壳 `file.importText` / `file.importDocument` / `file.importAudio` 必须在读取文本内容或 base64 前,通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,无法拿到可信大小时直接拒绝导入。`share.open` 必须通过共享 `normalizeHostBridgeShareOpenPayload()` 把 `url`、`href`、`path`、`targetPath` 和 `work` 归一到公开 H5 同源 URL,H5 facade 和 Expo 移动壳都执行该边界,Tauri 壳用 Rust URL parser 镜像同一规则。`app.openExternalUrl` 必须先通过共享 `normalizeHostBridgeExternalUrlPayload()` 清洗为 `{ url }`,H5 facade 和 Expo 移动壳都执行该边界,Tauri 壳用 Rust URL parser 镜像同一协议清单。`app.setTitle` 必须拒绝空值和控制字符,并按共享 80 字符上限截断;H5 facade 和 Tauri 壳都执行该边界。`clipboard.writeText` / `clipboard.readText` 两个方向都必须执行同一个 100000 字符上限;H5 facade 发起 `clipboard.writeText` 前先按共享上限归一化 payload,Expo 与 Tauri 壳仍必须再次执行同一边界,不允许只信 H5 facade 的预校验。H5 facade 发起 `haptics.impact` 前也必须按共享 style 清单归一化,未知 style 不发往宿主,Expo 壳仍二次拒绝未知值。`file.exportText` 的可选 `mimeType` 只能来自 `HOST_BRIDGE_TEXT_MIME_TYPES`,缺省为 `text/plain`,Expo 与 Tauri 都必须拒绝图片、音频或二进制 MIME,避免 H5 通过文本导出通道伪装落盘;两端 config check 必须反查该边界。Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、文档导入 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、窗口标题长度、外链 URL payload、分享 payload、剪贴板文本长度、触觉反馈 style 和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;移动壳 `file.importText` / `file.importDocument` / `file.importAudio` 必须在读取文本内容或 base64 前,通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,无法拿到可信大小时直接拒绝导入。`share.open` 必须通过共享 `normalizeHostBridgeShareOpenPayload()` 把 `url`、`href`、`path`、`targetPath` 和 `work` 归一到公开 H5 同源 URL,H5 facade 和 Expo 移动壳都执行该边界,Tauri 壳用 Rust URL parser 镜像同一规则。`app.openExternalUrl` 必须先通过共享 `normalizeHostBridgeExternalUrlPayload()` 清洗为 `{ url }`,H5 facade 和 Expo 移动壳都执行该边界,Tauri 壳用 Rust URL parser 镜像同一协议清单。`app.setTitle` 必须拒绝空值和控制字符,并按共享 80 字符上限截断;H5 facade 和 Tauri 壳都执行该边界。`clipboard.writeText` / `clipboard.readText` 两个方向都必须执行同一个 100000 字符上限;H5 facade 发起 `clipboard.writeText` 前先按共享上限归一化 payload,Expo 与 Tauri 壳仍必须再次执行同一边界,不允许只信 H5 facade 的预校验。H5 facade 发起 `haptics.impact` 前也必须按共享 style 清单归一化,未知 style 不发往宿主,Expo 壳仍二次拒绝未知值。`file.exportText` 的可选 `mimeType` 只能来自 `HOST_BRIDGE_TEXT_MIME_TYPES`,缺省为 `text/plain`,Expo 与 Tauri 都必须拒绝图片、音频或二进制 MIME,避免 H5 通过文本导出通道伪装落盘;`file.exportImage` / `file.exportAudio` 必须在 H5 facade 发起请求前分别通过 `normalizeHostBridgeExportImagePayload()` / `normalizeHostBridgeExportAudioPayload()` 预校验文件名、MIME、base64 和共享导出上限,Expo 与 Tauri 壳仍必须按真实字节和 MIME 二次校验,不允许只信 H5 预检。两端 config check 必须反查该边界。Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。 - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/host_bridge/`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run test -- packages/shared/src/contracts/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index d9da9869d..1796a072e 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -73,13 +73,13 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗 - `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板;Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,单次文本不超过 5 MiB,可选 MIME 只能来自共享契约 `HOST_BRIDGE_TEXT_MIME_TYPES`,未传时默认为 `text/plain`,非文本 MIME 必须拒绝,不能借文本导出通道伪装成图片、音频或二进制文件;成功只返回文件名和字节数,不把本机绝对路径暴露给 H5;系统分享不可用或用户取消时返回明确错误,由 H5 fallback 承接。创作 Agent 工作台在 `native_app` 且声明该能力时提供会话 Markdown 导出入口,导出内容只来自当前 H5 已持有的会话标题、摘要、进度、锚点、消息、流式回复和输入草稿,并在 H5 侧先按同一 5 MiB 上限做 UTF-8 byte 校验;普通浏览器、小程序和未声明能力的裁剪壳不展示该入口。 - `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取文本内容前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。 - `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`,宿主未声明时保留浏览器下载路径。 +- `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;H5 facade 发起请求前先通过共享契约 `normalizeHostBridgeExportImagePayload()` 预校验文件名、MIME、base64 和 5 MiB 上限,Expo 与 Tauri 壳仍必须二次校验真实字节与 MIME。Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 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 字符且拒绝空值和控制字符;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` 上传和场景音频字段写回链路。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 -- `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIME;Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。单次音频不超过 20 MiB,成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob`、`fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。 +- `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIME;H5 facade 发起请求前先通过共享契约 `normalizeHostBridgeExportAudioPayload()` 预校验文件名、MIME、base64 和 20 MiB 上限,Expo 与 Tauri 壳仍必须二次校验真实字节与 MIME。Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob`、`fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。 ## 迁移顺序 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 7e59a1436..4dd2afd14 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -37,7 +37,9 @@ import { normalizeHostBridgeClipboardText, normalizeHostBridgeColorScheme, normalizeHostBridgeConnectionType, + normalizeHostBridgeExportAudioPayload, normalizeHostBridgeExportFileName, + normalizeHostBridgeExportImagePayload, normalizeHostBridgeExternalUrl, normalizeHostBridgeExternalUrlPayload, normalizeHostBridgeHapticsImpactStyle, @@ -349,6 +351,45 @@ describe('HostBridge shared contract helpers', () => { expect(HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES).toBe(5 * 1024 * 1024); }); + test('归一化宿主图片和音频导出载荷', () => { + expect( + normalizeHostBridgeExportImagePayload({ + fileName: ' ../分享:卡?.png ', + base64Data: ' c2hhcmU= ', + mimeType: 'image/png', + }), + ).toEqual({ + fileName: '分享-卡-.png', + base64Data: 'c2hhcmU=', + mimeType: 'image/png', + }); + expect( + normalizeHostBridgeExportAudioPayload({ + fileName: '敲击音效.wav', + base64Data: ' YXVkaW8= ', + mimeType: 'audio/wav', + }), + ).toEqual({ + fileName: '敲击音效.wav', + base64Data: 'YXVkaW8=', + mimeType: 'audio/wav', + }); + expect( + normalizeHostBridgeExportImagePayload({ + fileName: 'bad.gif', + base64Data: 'c2hhcmU=', + mimeType: 'image/gif', + }), + ).toBeNull(); + expect( + normalizeHostBridgeExportAudioPayload({ + fileName: 'empty.wav', + base64Data: ' ', + mimeType: 'audio/wav', + }), + ).toBeNull(); + }); + test('归一化宿主本地通知内容', () => { expect( normalizeHostBridgeLocalNotification({ diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index f87f710b8..15ea2de26 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -573,6 +573,10 @@ export const HOST_BRIDGE_IMAGE_MIME_TYPES = [ 'image/webp', ] as const satisfies readonly HostBridgeImageMimeType[]; +const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set( + HOST_BRIDGE_IMAGE_MIME_TYPES, +); + export type FileImportImageResult = { action: 'selected' | 'dropped' | 'captured'; fileName: string; @@ -625,6 +629,10 @@ export const HOST_BRIDGE_AUDIO_MIME_TYPES = [ 'audio/webm', ] as const satisfies readonly HostBridgeAudioMimeType[]; +const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set( + HOST_BRIDGE_AUDIO_MIME_TYPES, +); + export type FileImportAudioResult = { action: 'selected'; fileName: string; @@ -653,6 +661,80 @@ export const HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; export const HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; export const HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; +function normalizeHostBridgeBase64Data(rawData: unknown) { + if (typeof rawData !== 'string') { + return null; + } + + const base64Data = rawData.trim(); + if (!base64Data || hasHostBridgeControlCharacter(base64Data)) { + return null; + } + + return base64Data; +} + +function estimateHostBridgeBase64Bytes(base64Data: string) { + const normalizedLength = base64Data.replace(/\s+/g, '').length; + return Math.ceil((normalizedLength * 3) / 4); +} + +export function normalizeHostBridgeExportImagePayload( + payload: unknown, +): FileExportImagePayload | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + const mimeType = candidate.mimeType as HostBridgeImageMimeType; + if (!HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType)) { + return null; + } + + const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); + if ( + !base64Data || + estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES + ) { + return null; + } + + return { + fileName: normalizeHostBridgeExportFileName(candidate.fileName), + base64Data, + mimeType, + }; +} + +export function normalizeHostBridgeExportAudioPayload( + payload: unknown, +): FileExportAudioPayload | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + const mimeType = candidate.mimeType as HostBridgeAudioMimeType; + if (!HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType)) { + return null; + } + + const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); + if ( + !base64Data || + estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES + ) { + return null; + } + + return { + fileName: normalizeHostBridgeExportFileName(candidate.fileName), + base64Data, + mimeType, + }; +} + export const HOST_BRIDGE_HAPTICS_IMPACT_STYLES = [ 'light', 'medium', diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 042b32305..a3509c058 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -1060,6 +1060,30 @@ function assertH5HostBridgePayloadBoundaries() { 'H5 HostBridge facade must normalize share.open payloads with the shared share boundary', ); } + if ( + !h5HostBridgeSource.includes( + 'const normalizedPayload = normalizeHostBridgeExportImagePayload(params);', + ) || + !h5HostBridgeSource.includes( + "'file.exportImage',\n normalizedPayload,", + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize file.exportImage payloads with the shared image export boundary', + ); + } + if ( + !h5HostBridgeSource.includes( + 'const normalizedPayload = normalizeHostBridgeExportAudioPayload(params);', + ) || + !h5HostBridgeSource.includes( + "'file.exportAudio',\n normalizedPayload,", + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize file.exportAudio payloads with the shared audio export boundary', + ); + } if ( !h5HostBridgeSource.includes( 'const clipboardText = normalizeHostBridgeClipboardText(text);', diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index dab831ea7..de62bc3f6 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -1053,8 +1053,8 @@ describe('hostBridge', () => { ).resolves.toBe(false); await expect( exportHostImageFile({ - fileName: '分享卡.png', - base64Data: 'c2hhcmUtY2FyZA==', + fileName: ' ../分享:卡?.png ', + base64Data: ' c2hhcmUtY2FyZA== ', mimeType: 'image/png', }), ).resolves.toBe(true); @@ -1220,7 +1220,7 @@ describe('hostBridge', () => { request: expect.objectContaining({ method: 'file.exportImage', payload: { - fileName: '分享卡.png', + fileName: '分享-卡-.png', base64Data: 'c2hhcmUtY2FyZA==', mimeType: 'image/png', }, @@ -1536,6 +1536,16 @@ describe('hostBridge', () => { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); + + invoke.mockClear(); + await expect( + exportHostImageFile({ + fileName: 'bad.gif', + base64Data: 'c2hhcmUtY2FyZA==', + mimeType: 'image/gif' as 'image/png', + }), + ).resolves.toBe(false); + expect(invoke).not.toHaveBeenCalled(); }); test('原生 App 宿主不支持文本导出时回退 H5', async () => { @@ -1897,6 +1907,16 @@ describe('hostBridge', () => { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); + + invoke.mockClear(); + await expect( + exportHostAudioFile({ + fileName: 'empty.wav', + base64Data: ' ', + mimeType: 'audio/wav', + }), + ).resolves.toBe(false); + expect(invoke).not.toHaveBeenCalled(); }); test('原生 App 宿主取消导入音频时回退为 false', async () => { diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index a366dc776..a84389d80 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -43,6 +43,8 @@ import { normalizeHostBridgeClipboardText, normalizeHostBridgeColorScheme, normalizeHostBridgeConnectionType, + normalizeHostBridgeExportAudioPayload, + normalizeHostBridgeExportImagePayload, normalizeHostBridgeExternalUrlPayload, normalizeHostBridgeHapticsImpactStyle, normalizeHostBridgeLifecycleState, @@ -854,10 +856,15 @@ export async function exportHostImageFile( return false; } + const normalizedPayload = normalizeHostBridgeExportImagePayload(params); + if (!normalizedPayload) { + return false; + } + try { return await requestNativeAppHostBridge( 'file.exportImage', - params, + normalizedPayload, { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ); } catch (error) { @@ -875,10 +882,15 @@ export async function exportHostAudioFile( return false; } + const normalizedPayload = normalizeHostBridgeExportAudioPayload(params); + if (!normalizedPayload) { + return false; + } + try { return await requestNativeAppHostBridge( 'file.exportAudio', - params, + normalizedPayload, { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ); } catch (error) {