diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f4553c831..350e2ec6f 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` 必须在 H5 facade 发起请求前通过 `normalizeHostBridgeExportTextPayload()` 预校验文件名、文本内容、可选 MIME 和 5 MiB 上限;可选 `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` 是 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 并完成上限校验,无法拿到可信大小时直接拒绝导入。H5 facade 消费 `file.importText` / `file.importDocument` / `file.importImage` / `file.captureImage` / `file.importAudio` / `file.imageDropped` 返回结果时必须分别通过共享导入结果 normalizer 再校验文件名、MIME、内容、base64、字节数和图片拖拽坐标,避免旧壳或异常壳返回超界数据被业务层消费。`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` 必须在 H5 facade 发起请求前通过 `normalizeHostBridgeExportTextPayload()` 预校验文件名、文本内容、可选 MIME 和 5 MiB 上限;可选 `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 c9ef4ce3f..0498c0f7f 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -71,14 +71,14 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗 - `openHostExternalUrl()`:原生 App 宿主的受控外链入口。H5 中需要离开主站的外链在 `native_app` 下先通过 `app.openExternalUrl` 请求宿主系统浏览器打开;只允许 `http:`、`https:`、`mailto:`、`tel:`,相对路径会先归一化到当前站点绝对 URL,再通过共享契约 `normalizeHostBridgeExternalUrlPayload()` 清洗为 `{ url }` 载荷。Expo 移动壳消费该共享 payload normalizer,Tauri 桌面壳在 Rust 侧用 URL parser 镜像同一协议清单。宿主不可用或拒绝时回退浏览器外链行为,普通浏览器和小程序保持原有 `` 语义。H5 支付链接和微信 OAuth 登录授权 URL 也走该入口:原生壳未声明真实 `payment.request` / `auth.requestLogin` 前,微信 H5 支付 URL 和后端返回的微信登录授权 URL 优先交给宿主系统浏览器,宿主未处理时才回退当前 WebView 跳转;不得把 H5 支付或网页登录伪装成已完成的原生支付 / 原生登录。 - `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录和内置独立 H5 体验入口等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;Tauri 桌面壳同样只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内跳转。平台首页的儿童动作热身 Demo 入口在 `native_app` 且宿主声明 `navigation.openNativePage` 时必须优先走该 facade 跳转 `/child-motion-demo`,普通浏览器、小程序和未声明能力的裁剪壳才回退浏览器跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。 - `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。H5 facade 发起请求前先通过共享契约 `normalizeHostBridgeExportTextPayload()` 预校验文件名、文本内容、可选 MIME 和 5 MiB 上限;Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板;Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,可选 MIME 只能来自共享契约 `HOST_BRIDGE_TEXT_MIME_TYPES`,未传时默认为 `text/plain`,非文本 MIME 必须拒绝,不能借文本导出通道伪装成图片、音频或二进制文件;Expo 与 Tauri 壳仍必须二次校验真实文本字节数和 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,也不绕过后端文档解析、大小校验或错误口径。 +- `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 收到结果后继续通过共享契约 `normalizeHostBridgeImportTextResult()` 复核文件名、MIME、文本内容和字节数,非法或超界结果归为 `false`;用户取消时由 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`,仍拿不到可信大小时直接拒绝导入;H5 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportDocumentResult()` 复核文件名、MIME、base64 和字节数。创作 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;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` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 +- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png`、`image/jpeg`、`image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄能力不使用麦克风,移动壳包级麦克风权限只服务同源 H5 实时声音玩法。H5 facade 收到导入、拍摄或拖拽结果后继续通过共享契约 `normalizeHostBridgeImportImageResult()` 复核文件名、MIME、base64、字节数和可选坐标。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` 上传和场景音频字段写回链路。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 +- `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 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportAudioResult()` 复核文件名、MIME、base64 和字节数。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;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 5218f6fdd..4269b4d2e 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -44,6 +44,10 @@ import { normalizeHostBridgeExternalUrl, normalizeHostBridgeExternalUrlPayload, normalizeHostBridgeHapticsImpactStyle, + normalizeHostBridgeImportAudioResult, + normalizeHostBridgeImportDocumentResult, + normalizeHostBridgeImportImageResult, + normalizeHostBridgeImportTextResult, normalizeHostBridgeLifecycleState, normalizeHostBridgeLocalNotification, normalizeHostBridgeQrCodeValue, @@ -418,6 +422,91 @@ describe('HostBridge shared contract helpers', () => { ).toBeNull(); }); + test('归一化宿主文件导入结果', () => { + expect( + normalizeHostBridgeImportTextResult({ + action: 'selected', + fileName: ' 剧情.md ', + content: '暖灯猫街', + mimeType: 'text/markdown', + bytes: 12, + }), + ).toEqual({ + action: 'selected', + fileName: '剧情.md', + content: '暖灯猫街', + mimeType: 'text/markdown', + bytes: 12, + }); + expect( + normalizeHostBridgeImportDocumentResult({ + action: 'selected', + fileName: ' 世界设定.docx ', + base64Data: ' UEsDBGRvY3g= ', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + bytes: 8, + }), + ).toEqual({ + action: 'selected', + fileName: '世界设定.docx', + base64Data: 'UEsDBGRvY3g=', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + bytes: 8, + }); + expect( + normalizeHostBridgeImportImageResult({ + action: 'dropped', + fileName: ' 参考图.png ', + base64Data: ' aW1hZ2U= ', + mimeType: 'image/png', + bytes: 5, + position: { x: 12, y: 24 }, + }), + ).toEqual({ + action: 'dropped', + fileName: '参考图.png', + base64Data: 'aW1hZ2U=', + mimeType: 'image/png', + bytes: 5, + position: { x: 12, y: 24 }, + }); + expect( + normalizeHostBridgeImportAudioResult({ + action: 'selected', + fileName: ' 敲击音效.webm ', + base64Data: ' YXVkaW8= ', + mimeType: 'audio/webm', + bytes: 5, + }), + ).toEqual({ + action: 'selected', + fileName: '敲击音效.webm', + base64Data: 'YXVkaW8=', + mimeType: 'audio/webm', + bytes: 5, + }); + expect( + normalizeHostBridgeImportTextResult({ + action: 'selected', + fileName: 'bad.bin', + content: 'content', + mimeType: 'application/octet-stream', + bytes: 7, + }), + ).toBeNull(); + expect( + normalizeHostBridgeImportAudioResult({ + action: 'selected', + fileName: 'huge.webm', + base64Data: 'YXVkaW8=', + mimeType: 'audio/webm', + bytes: 20 * 1024 * 1024 + 1, + }), + ).toBeNull(); + }); + test('归一化宿主本地通知内容', () => { expect( normalizeHostBridgeLocalNotification({ diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index e4743fc9f..34d9248fb 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -708,6 +708,204 @@ export function normalizeHostBridgeExportTextPayload( }; } +function normalizeHostBridgeImportedBytes(rawBytes: unknown, maxBytes: number) { + if ( + typeof rawBytes !== 'number' || + !Number.isInteger(rawBytes) || + rawBytes <= 0 || + rawBytes > maxBytes + ) { + return null; + } + + return rawBytes; +} + +function normalizeHostBridgeImportFileName(rawFileName: unknown) { + if (typeof rawFileName !== 'string') { + return null; + } + + const fileName = rawFileName.trim(); + if (!fileName || hasHostBridgeControlCharacter(fileName)) { + return null; + } + + return fileName; +} + +export function normalizeHostBridgeImportTextResult( + payload: unknown, +): FileImportTextResult | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + if (candidate.action !== 'selected') { + return null; + } + + const fileName = normalizeHostBridgeImportFileName(candidate.fileName); + const bytes = normalizeHostBridgeImportedBytes( + candidate.bytes, + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES, + ); + const mimeType = candidate.mimeType as HostBridgeTextMimeType; + if ( + !fileName || + bytes === null || + !HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType) || + typeof candidate.content !== 'string' || + estimateHostBridgeUtf8Bytes(candidate.content) > + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES + ) { + return null; + } + + return { + action: 'selected', + fileName, + content: candidate.content, + mimeType, + bytes, + }; +} + +export function normalizeHostBridgeImportDocumentResult( + payload: unknown, +): FileImportDocumentResult | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + if (candidate.action !== 'selected') { + return null; + } + + const fileName = normalizeHostBridgeImportFileName(candidate.fileName); + const bytes = normalizeHostBridgeImportedBytes( + candidate.bytes, + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, + ); + const mimeType = candidate.mimeType as HostBridgeDocumentMimeType; + const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); + if ( + !fileName || + bytes === null || + !HOST_BRIDGE_DOCUMENT_MIME_TYPES.includes(mimeType) || + !base64Data || + estimateHostBridgeBase64Bytes(base64Data) > + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES + ) { + return null; + } + + return { + action: 'selected', + fileName, + base64Data, + mimeType, + bytes, + }; +} + +export function normalizeHostBridgeImportImageResult( + payload: unknown, +): FileImportImageResult | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + if ( + candidate.action !== 'selected' && + candidate.action !== 'dropped' && + candidate.action !== 'captured' + ) { + return null; + } + + const fileName = normalizeHostBridgeImportFileName(candidate.fileName); + const bytes = normalizeHostBridgeImportedBytes( + candidate.bytes, + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES, + ); + const mimeType = candidate.mimeType as HostBridgeImageMimeType; + const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); + if ( + !fileName || + bytes === null || + !HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType) || + !base64Data || + estimateHostBridgeBase64Bytes(base64Data) > + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES + ) { + return null; + } + + const position = + candidate.position && + typeof candidate.position.x === 'number' && + Number.isFinite(candidate.position.x) && + typeof candidate.position.y === 'number' && + Number.isFinite(candidate.position.y) + ? { + x: candidate.position.x, + y: candidate.position.y, + } + : undefined; + + return { + action: candidate.action, + fileName, + base64Data, + mimeType, + bytes, + ...(position ? { position } : {}), + }; +} + +export function normalizeHostBridgeImportAudioResult( + payload: unknown, +): FileImportAudioResult | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const candidate = payload as Partial; + if (candidate.action !== 'selected') { + return null; + } + + const fileName = normalizeHostBridgeImportFileName(candidate.fileName); + const bytes = normalizeHostBridgeImportedBytes( + candidate.bytes, + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES, + ); + const mimeType = candidate.mimeType as HostBridgeAudioMimeType; + const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); + if ( + !fileName || + bytes === null || + !HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType) || + !base64Data || + estimateHostBridgeBase64Bytes(base64Data) > + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES + ) { + return null; + } + + return { + action: 'selected', + fileName, + base64Data, + mimeType, + bytes, + }; +} + function normalizeHostBridgeBase64Data(rawData: unknown) { if (typeof rawData !== 'string') { return null; diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 17b270aa8..8a8b3c357 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -972,10 +972,6 @@ function assertH5HostBridgePayloadBoundaries() { ); for (const sharedBoundary of [ - 'HOST_BRIDGE_TEXT_MIME_TYPES', - 'HOST_BRIDGE_DOCUMENT_MIME_TYPES', - 'HOST_BRIDGE_IMAGE_MIME_TYPES', - 'HOST_BRIDGE_AUDIO_MIME_TYPES', 'HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS', 'HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS', 'HOST_BRIDGE_SCANNER_TIMEOUT_MS', @@ -1132,6 +1128,54 @@ function assertH5HostBridgePayloadBoundaries() { 'H5 HostBridge facade must normalize app.setTitle payloads with the shared title boundary', ); } + for (const importBoundary of [ + { + functionName: 'importHostTextFile', + normalizer: 'normalizeHostBridgeImportTextResult', + }, + { + functionName: 'importHostDocumentFile', + normalizer: 'normalizeHostBridgeImportDocumentResult', + }, + { + functionName: 'importHostImageFile', + normalizer: 'normalizeHostBridgeImportImageResult', + }, + { + functionName: 'captureHostImageFile', + normalizer: 'normalizeHostBridgeImportImageResult', + }, + { + functionName: 'importHostAudioFile', + normalizer: 'normalizeHostBridgeImportAudioResult', + }, + { + functionName: 'subscribeHostImageDrop', + normalizer: 'normalizeHostBridgeImportImageResult', + }, + ]) { + const functionSource = extractFunctionSource( + h5HostBridgeSource, + importBoundary.functionName, + ); + if (!functionSource.includes(`${importBoundary.normalizer}(`)) { + throw new Error( + `${importBoundary.functionName} must normalize imported file results with shared ${importBoundary.normalizer}`, + ); + } + } + for (const localMimeSet of [ + 'HOST_BRIDGE_TEXT_MIME_TYPE_SET', + 'HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET', + 'HOST_BRIDGE_IMAGE_MIME_TYPE_SET', + 'HOST_BRIDGE_AUDIO_MIME_TYPE_SET', + ]) { + if (h5HostBridgeSource.includes(localMimeSet)) { + throw new Error( + `H5 HostBridge facade must use shared import normalizers instead of local ${localMimeSet}`, + ); + } + } } function assertH5NativeAppTransportTimeoutBoundaries() { diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index 194062984..8e3bc4caf 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -2052,6 +2052,39 @@ describe('hostBridge', () => { await expect(importHostDocumentFile()).resolves.toBe(false); }); + test('原生 App 宿主导入结果超出共享边界时回退为 false', async () => { + const invoke = vi.fn( + async (_command: string, args?: Record) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: true, + result: { + action: 'selected', + fileName: 'too-large.webm', + base64Data: 'YXVkaW8=', + mimeType: 'audio/webm', + bytes: 20 * 1024 * 1024 + 1, + }, + }; + }, + ); + window.history.replaceState( + null, + '', + nativeAppPath(['file.importAudio']), + ); + window.__TAURI__ = { + core: { + invoke: asTauriInvoke(invoke), + }, + }; + + await expect(importHostAudioFile()).resolves.toBe(false); + }); + test('原生 App 宿主取消导入图片时回退为 false', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index 95ea96748..71644886e 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -48,6 +48,10 @@ import { normalizeHostBridgeExportTextPayload, normalizeHostBridgeExternalUrlPayload, normalizeHostBridgeHapticsImpactStyle, + normalizeHostBridgeImportAudioResult, + normalizeHostBridgeImportDocumentResult, + normalizeHostBridgeImportImageResult, + normalizeHostBridgeImportTextResult, normalizeHostBridgeLifecycleState, normalizeHostBridgeLocalNotification, normalizeHostBridgeQrCodeValue, @@ -907,64 +911,19 @@ export async function exportHostAudioFile( } } -const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set(HOST_BRIDGE_IMAGE_MIME_TYPES); - -function normalizeHostImageImportResult( - payload: FileImportImageResult | null | undefined, -): FileImportImageResult | false { - if ( - !payload || - typeof payload !== 'object' || - payload.action !== 'selected' && - payload.action !== 'dropped' && - payload.action !== 'captured' || - !HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(payload.mimeType) || - typeof payload.fileName !== 'string' || - !payload.fileName.trim() || - typeof payload.base64Data !== 'string' || - !payload.base64Data.trim() || - typeof payload.bytes !== 'number' || - !Number.isInteger(payload.bytes) || - payload.bytes <= 0 - ) { - return false; - } - - const position = - payload.position && - typeof payload.position.x === 'number' && - Number.isFinite(payload.position.x) && - typeof payload.position.y === 'number' && - Number.isFinite(payload.position.y) - ? { - x: payload.position.x, - y: payload.position.y, - } - : undefined; - - return { - action: payload.action, - fileName: payload.fileName.trim(), - base64Data: payload.base64Data, - mimeType: payload.mimeType, - bytes: payload.bytes, - ...(position ? { position } : {}), - }; -} - export async function importHostImageFile() { if (!canUseNativeHostCapability('file.importImage')) { return false; } try { - return normalizeHostImageImportResult( + return normalizeHostBridgeImportImageResult( await requestNativeAppHostBridge( 'file.importImage', undefined, { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ), - ); + ) ?? false; } catch (error) { if (isUnsupportedHostBridgeError(error)) { return false; @@ -979,13 +938,13 @@ export async function captureHostImageFile() { } try { - return normalizeHostImageImportResult( + return normalizeHostBridgeImportImageResult( await requestNativeAppHostBridge( 'file.captureImage', undefined, { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ), - ); + ) ?? false; } catch (error) { if (isUnsupportedHostBridgeError(error)) { return false; @@ -1018,49 +977,19 @@ export async function scanHostQrCode() { } } -const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set(HOST_BRIDGE_TEXT_MIME_TYPES); -const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = new Set(HOST_BRIDGE_DOCUMENT_MIME_TYPES); - -function normalizeHostTextImportResult( - payload: FileImportTextResult | null | undefined, -): FileImportTextResult | false { - if ( - !payload || - typeof payload !== 'object' || - payload.action !== 'selected' || - !HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(payload.mimeType) || - typeof payload.fileName !== 'string' || - !payload.fileName.trim() || - typeof payload.content !== 'string' || - typeof payload.bytes !== 'number' || - !Number.isInteger(payload.bytes) || - payload.bytes <= 0 - ) { - return false; - } - - return { - action: 'selected', - fileName: payload.fileName.trim(), - content: payload.content, - mimeType: payload.mimeType, - bytes: payload.bytes, - }; -} - export async function importHostTextFile() { if (!canUseNativeHostCapability('file.importText')) { return false; } try { - return normalizeHostTextImportResult( + return normalizeHostBridgeImportTextResult( await requestNativeAppHostBridge( 'file.importText', undefined, { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ), - ); + ) ?? false; } catch (error) { if (isUnsupportedHostBridgeError(error)) { return false; @@ -1069,47 +998,19 @@ export async function importHostTextFile() { } } -function normalizeHostDocumentImportResult( - payload: FileImportDocumentResult | null | undefined, -): FileImportDocumentResult | false { - if ( - !payload || - typeof payload !== 'object' || - payload.action !== 'selected' || - !HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET.has(payload.mimeType) || - typeof payload.fileName !== 'string' || - !payload.fileName.trim() || - typeof payload.base64Data !== 'string' || - !payload.base64Data.trim() || - typeof payload.bytes !== 'number' || - !Number.isInteger(payload.bytes) || - payload.bytes <= 0 - ) { - return false; - } - - return { - action: 'selected', - fileName: payload.fileName.trim(), - base64Data: payload.base64Data, - mimeType: payload.mimeType, - bytes: payload.bytes, - }; -} - export async function importHostDocumentFile() { if (!canUseNativeHostCapability('file.importDocument')) { return false; } try { - return normalizeHostDocumentImportResult( + return normalizeHostBridgeImportDocumentResult( await requestNativeAppHostBridge( 'file.importDocument', undefined, { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ), - ); + ) ?? false; } catch (error) { if (isUnsupportedHostBridgeError(error)) { return false; @@ -1118,49 +1019,19 @@ export async function importHostDocumentFile() { } } -const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set(HOST_BRIDGE_AUDIO_MIME_TYPES); - -function normalizeHostAudioImportResult( - payload: FileImportAudioResult | null | undefined, -): FileImportAudioResult | false { - if ( - !payload || - typeof payload !== 'object' || - payload.action !== 'selected' || - !HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(payload.mimeType) || - typeof payload.fileName !== 'string' || - !payload.fileName.trim() || - typeof payload.base64Data !== 'string' || - !payload.base64Data.trim() || - typeof payload.bytes !== 'number' || - !Number.isInteger(payload.bytes) || - payload.bytes <= 0 - ) { - return false; - } - - return { - action: 'selected', - fileName: payload.fileName.trim(), - base64Data: payload.base64Data, - mimeType: payload.mimeType, - bytes: payload.bytes, - }; -} - export async function importHostAudioFile() { if (!canUseNativeHostCapability('file.importAudio')) { return false; } try { - return normalizeHostAudioImportResult( + return normalizeHostBridgeImportAudioResult( await requestNativeAppHostBridge( 'file.importAudio', undefined, { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ), - ); + ) ?? false; } catch (error) { if (isUnsupportedHostBridgeError(error)) { return false; @@ -1365,7 +1236,7 @@ export function subscribeHostImageDrop( return subscribeNativeAppHostBridgeEvent( 'file.imageDropped', (payload) => { - const normalizedPayload = normalizeHostImageImportResult(payload); + const normalizedPayload = normalizeHostBridgeImportImageResult(payload); if (normalizedPayload) { listener(normalizedPayload); }