From 83be6c02d9e64a756cc04bd9ab86c03f2b6933af Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 17:12:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E6=A1=8C=E9=9D=A2=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=80=89=E6=8B=A9=E5=99=A8=E8=BF=87=E6=BB=A4=E9=97=A8?= =?UTF-8?q?=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 锁定 Tauri 文本、文档、图片和音频选择器扩展名边界 要求导入与导出文件对话框继续匹配 HostBridge 文件契约 同步桌面壳文件选择边界记录 --- apps/desktop-shell/scripts/check-config.mjs | 97 +++++++++++++++++++ .../shared-memory/decision-log.md | 1 + 2 files changed, 98 insertions(+) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index fcd1e31d5..0dfcc1aae 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -879,6 +879,60 @@ function extractDesktopHandledMethods(source) { ]; } +function extractDesktopHostBridgeMethodBody(source, method) { + const methodStart = source.indexOf(`"${method}" => {`); + if (methodStart < 0) { + throw new Error(`desktop shell HostBridge missing method ${method}`); + } + + const nextMethodStart = source.indexOf('\n "', methodStart + method.length); + return source.slice( + methodStart, + nextMethodStart > methodStart ? nextMethodStart : undefined, + ); +} + +function extractDesktopDialogFilter(source, method, filterLabel) { + const methodBody = extractDesktopHostBridgeMethodBody(source, method); + const match = methodBody.match( + new RegExp(`\\.add_filter\\("${filterLabel}",\\s*&\\[([^\\]]*)\\]\\)`), + ); + if (!match) { + throw new Error( + `desktop shell ${method} must use a ${filterLabel} system dialog filter`, + ); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); +} + +function assertDesktopDialogBoundary(method, filterLabel, expected, action) { + const methodBody = extractDesktopHostBridgeMethodBody( + desktopHostBridgeDispatchSource, + method, + ); + assertSameList( + extractDesktopDialogFilter( + desktopHostBridgeDispatchSource, + method, + filterLabel, + ), + expected, + `desktop shell ${method} ${filterLabel} dialog filter`, + ); + + const requiredDialogAction = + action === 'save' ? '.blocking_save_file()' : '.blocking_pick_file()'; + const blockedDialogAction = + action === 'save' ? '.blocking_pick_file()' : '.blocking_save_file()'; + if (!methodBody.includes(requiredDialogAction)) { + throw new Error(`desktop shell ${method} must use ${requiredDialogAction}`); + } + if (methodBody.includes(blockedDialogAction)) { + throw new Error(`desktop shell ${method} must not use ${blockedDialogAction}`); + } +} + function assertNoRawHostContextUrl(urlValue, label) { const rawUrl = String(urlValue ?? ''); const blockedQueryKeys = [ @@ -1965,6 +2019,49 @@ for (const staleTimeoutBoundary of [ } } +assertDesktopDialogBoundary( + 'file.exportText', + 'Text', + ['txt', 'json', 'md', 'csv'], + 'save', +); +assertDesktopDialogBoundary( + 'file.importText', + 'Text', + ['txt', 'md', 'markdown', 'csv', 'json'], + 'pick', +); +assertDesktopDialogBoundary( + 'file.importDocument', + 'Document', + ['txt', 'md', 'markdown', 'csv', 'json', 'docx'], + 'pick', +); +assertDesktopDialogBoundary( + 'file.exportImage', + 'Image', + ['png', 'jpg', 'jpeg', 'webp'], + 'save', +); +assertDesktopDialogBoundary( + 'file.importImage', + 'Image', + ['png', 'jpg', 'jpeg', 'webp'], + 'pick', +); +assertDesktopDialogBoundary( + 'file.importAudio', + 'Audio', + ['mp3', 'm4a', 'mp4', 'wav', 'ogg', 'webm'], + 'pick', +); +assertDesktopDialogBoundary( + 'file.exportAudio', + 'Audio', + ['mp3', 'm4a', 'wav', 'ogg', 'webm'], + 'save', +); + assertSameList( capability.windows ?? [], ['main'], diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1c6e4a7a7..7c84a2729 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2572,6 +2572,7 @@ - 背景:文件导入导出、剪贴板、角标、本地通知和 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 并完成上限校验,无法拿到可信大小时直接拒绝导入。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` 必须反查共享契约并拒绝漂移。 +- 追加:Tauri 桌面壳的系统文件对话框过滤器也是 HostBridge 文件边界的一部分:文本导出只允许 `txt/json/md/csv` 保存,文本导入只允许 `txt/md/markdown/csv/json` 选择,文档导入额外允许 `docx`,图片导入导出只允许 `png/jpg/jpeg/webp`,音频导入允许 `mp3/m4a/mp4/wav/ogg/webm`,音频导出只允许 `mp3/m4a/wav/ogg/webm`。`apps/desktop-shell/scripts/check-config.mjs` 必须按 method 精确检查 `.add_filter(...)` 与 `blocking_save_file` / `blocking_pick_file` 归属,避免把一次用户选择扩大成任意本地文件访问。 - 影响范围:`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`。