From 106798f505f3d40e820c4ddc0097bbae6902277b Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 21:04:38 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E5=A3=B3=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=AF=BC=E5=87=BA=E6=89=A9=E5=B1=95=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按图片 MIME 归一移动壳导出文件名扩展 补充移动 HostBridge 图片导出测试和配置门禁 更新宿主壳文件导出文档 --- apps/mobile-shell/scripts/check-config.mjs | 1 + .../src/host-bridge/bridge.test.ts | 21 ++++++++++++++- apps/mobile-shell/src/host-bridge/files.ts | 26 ++++++++++++++++++- .../shared-memory/decision-log.md | 2 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 +- 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index b13b3aa55..84c159729 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -970,6 +970,7 @@ for (const snippet of [ 'ensureAudioBytesMatchMimeType', "'image bytes do not match MIME'", "'audio bytes do not match MIME'", + 'normalizeExportedImageFileName', 'normalizeHostBridgeExportFileName', 'normalizeHostBridgeClipboardText', 'normalizeHostBridgeHapticsImpactStyle', diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 7daba6703..14d7b8ed1 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -943,7 +943,7 @@ describe('handleMobileHostBridgeMessage', () => { test('file.exportImage 写入缓存图片并调起系统分享', async () => { const response = await send( request('file.exportImage', { - fileName: ' ../分享:卡?.png ', + fileName: ' ../分享:卡? ', base64Data: PNG_BASE64, mimeType: 'image/png', }), @@ -971,6 +971,25 @@ describe('handleMobileHostBridgeMessage', () => { dialogTitle: '分享-卡-.png', }, ); + + const jpegResponse = await send( + request('file.exportImage', { + fileName: '分享卡.png', + base64Data: JPEG_BASE64, + mimeType: 'image/jpeg', + }), + ); + + expect(expectOk(jpegResponse).result).toEqual({ + action: 'saved', + fileName: '分享卡.png.jpg', + bytes: 7, + }); + expect(writtenFiles.at(-1)).toEqual({ + uri: 'file:///cache/分享卡.png.jpg', + content: JPEG_BASE64, + options: { encoding: 'base64' }, + }); }); test('file.exportImage 拒绝非图片 MIME 与超限内容', async () => { diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index 2e526a185..2a9bd304b 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -208,6 +208,27 @@ function ensureAudioBytesMatchMimeType( } } +function imageFileExtension(mimeType: HostBridgeImageMimeType) { + if (mimeType === 'image/jpeg') { + return 'jpg'; + } + if (mimeType === 'image/webp') { + return 'webp'; + } + return 'png'; +} + +function normalizeExportedImageFileName( + rawFileName: unknown, + mimeType: HostBridgeImageMimeType, +) { + const fileName = normalizeHostBridgeExportFileName(rawFileName); + const extension = imageFileExtension(mimeType); + return fileName.toLowerCase().endsWith(`.${extension}`) + ? fileName + : `${fileName}.${extension}`; +} + export async function exportTextFile( payload: unknown, ): Promise { @@ -353,7 +374,10 @@ export async function exportImageFile( } satisfies HostBridgeError; } - const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName); + const fileName = normalizeExportedImageFileName( + exportPayload?.fileName, + mimeType as HostBridgeImageMimeType, + ); const file = new File(Paths.cache, fileName); file.write(base64Data, { encoding: 'base64' }); await Sharing.shareAsync(file.uri, { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 54480db61..733991244 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -70,7 +70,7 @@ - 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。 - 2026-06-18 桌面壳默认下载边界:Tauri 主 WebView 的下载事件默认拒绝网页自动下载和 `` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。 - 2026-06-18 桌面壳文件 bytes 校验:Tauri 图片 / 音频导入导出不得只信扩展名或 H5 声明 MIME;Rust 侧必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM bytes 头部,要求导入文件扩展名对应 MIME 与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。配置检查和 cargo test 覆盖该边界。 -- 2026-06-18 移动壳文件 bytes 校验:Expo 图片 / 音频导入导出不得只信系统 picker 返回 MIME、文件扩展名或 H5 声明 MIME;移动壳必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM base64 bytes 头部,要求导入 MIME 归一结果与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。配置检查和移动壳测试覆盖该边界。 +- 2026-06-18 移动壳文件 bytes 校验:Expo 图片 / 音频导入导出不得只信系统 picker 返回 MIME、文件扩展名或 H5 声明 MIME;移动壳必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM base64 bytes 头部,要求导入 MIME 归一结果与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。移动图片导出还必须按 MIME 给系统分享 / 保存面板补齐 `.png` / `.jpg` / `.webp` 文件名扩展,避免缓存文件名与真实图片类型漂移。配置检查和移动壳测试覆盖该边界。 - 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`,Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。 - 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。 - 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index cc091c1d3..c1e603461 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -279,7 +279,7 @@ GameBridge 禁止: 2026-06-18 追加:移动壳声明并实现 `file.importText`,通过 Expo DocumentPicker 打开系统文档选择器,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB;成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI,也不开放通用文件系统。H5 创作 Agent 工作台在原生壳声明该能力时优先打开宿主系统选择器,再把返回文本转换成现有浏览器 `File` 并继续调用 `/api/runtime/creation-agent/document-inputs/parse`,不在前端绕过后端文档解析、大小校验或 docx 处理。 -2026-06-18 追加:移动壳图片和音频导入 / 导出不再只信 Expo picker 返回的 MIME 或 H5 声明的 MIME。Expo 壳会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 base64 bytes 头部识别,导入时要求系统 picker 的 MIME 与 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。 +2026-06-18 追加:移动壳图片和音频导入 / 导出不再只信 Expo picker 返回的 MIME 或 H5 声明的 MIME。Expo 壳会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 base64 bytes 头部识别,导入时要求系统 picker 的 MIME 与 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。移动图片导出还会按 MIME 补齐 `.png` / `.jpg` / `.webp` 文件名扩展,避免系统分享 / 保存面板拿到无扩展或错扩展的缓存图片。 2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Expo 壳仍只通过 `expo-clipboard` 返回纯文本,H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理;普通浏览器、小程序和未声明该能力的裁剪壳不显示粘贴动作。