diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 2f7b06ca9..80d91ba1e 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -570,6 +570,10 @@ const sharedEvents = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_EVENTS', ); +const sharedBlockedDownloadProtocols = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS', +); const sharedMobileBaseCapabilities = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', @@ -1083,13 +1087,11 @@ for (const snippet of [ for (const snippet of [ 'MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS', + 'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS', + 'mobileWebViewBlockedDownloadProtocolMapScript', 'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', 'TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT', 'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT', - "'blob:'", - "'data:'", - "'file:'", - "'filesystem:'", 'shouldBlockMobileWebViewDownloadUrl', 'shouldBlockMobileWebViewNavigationRequest', "target.closest('a')", @@ -1109,6 +1111,15 @@ for (const snippet of [ } } +if ( + webViewPolicySource.includes("['blob:'") || + webViewPolicySource.includes("'filesystem:'") +) { + throw new Error( + 'mobile shell WebView blocked download protocols must come from shared contract', + ); +} + for (const snippet of [ 'parseMobileWebViewHistoryStateMessage', 'genarrative.mobile.historyState', diff --git a/apps/mobile-shell/src/shell/webViewPolicy.test.ts b/apps/mobile-shell/src/shell/webViewPolicy.test.ts index 31367097e..d21f9148a 100644 --- a/apps/mobile-shell/src/shell/webViewPolicy.test.ts +++ b/apps/mobile-shell/src/shell/webViewPolicy.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge'; import { BLOCK_WEBVIEW_DOWNLOAD_SCRIPT, MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT, @@ -34,6 +35,17 @@ describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => { expect(MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT.trim()).toMatch(/true;$/); }); + test('注入脚本复用共享下载协议阻断清单', () => { + for (const protocol of HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS) { + expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT).toContain( + `${JSON.stringify(protocol)}: true`, + ); + expect(shouldBlockMobileWebViewDownloadUrl(`${protocol}download-id`)).toBe( + true, + ); + } + }); + test('阻断嵌套元素触发的下载链接点击', () => { document.body.innerHTML = ` diff --git a/apps/mobile-shell/src/shell/webViewPolicy.ts b/apps/mobile-shell/src/shell/webViewPolicy.ts index fa4e9b991..a6fe58003 100644 --- a/apps/mobile-shell/src/shell/webViewPolicy.ts +++ b/apps/mobile-shell/src/shell/webViewPolicy.ts @@ -1,11 +1,15 @@ +import { HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge'; import { DEFAULT_MOBILE_SHELL_WEB_URL } from './url'; -const MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = new Set([ - 'blob:', - 'data:', - 'file:', - 'filesystem:', -]); +const MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = new Set( + HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS, +); + +function mobileWebViewBlockedDownloadProtocolMapScript() { + return HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS.map( + (protocol) => ` ${JSON.stringify(protocol)}: true`, + ).join(',\n'); +} export type MobileWebViewNavigationRequest = { url?: string | null; @@ -36,10 +40,7 @@ export function shouldBlockMobileWebViewNavigationRequest( export const BLOCK_WEBVIEW_DOWNLOAD_SCRIPT = ` (function() { var blockedDownloadProtocols = { - 'blob:': true, - 'data:': true, - 'file:': true, - 'filesystem:': true +${mobileWebViewBlockedDownloadProtocolMapScript()} }; function shouldBlockDownloadUrl(rawUrl) { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f7b3ca595..ecf631749 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2639,7 +2639,7 @@ ## 2026-06-18 移动壳 WebView 下载协议阻断 - 背景:移动壳已经通过 WebView 注入脚本阻断 `` 点击,并丢弃 iOS `onFileDownload` 事件;但 `blob:`、`data:`、`file:`、`filesystem:` 等下载协议导航仍可能在 `onShouldStartLoadWithRequest` 中进入普通同源 / 外链分流,脚本创建的下载链接也缺少行为级测试覆盖。 -- 决策:`apps/mobile-shell/src/shell/webViewPolicy.ts` 统一承接移动壳下载策略,注入脚本阻断下载链接点击、危险下载协议链接、`window.open` 下载 URL 和程序化 anchor click;`ShellApp` 在同源 / 外链分流前调用 `shouldBlockMobileWebViewNavigationRequest(...)`,命中 `blob:`、`data:`、`file:` 或 `filesystem:` 直接拒绝,不进入带完整 HostBridge 的 WebView,也不交给系统外部应用。移动端文件保存仍只能走受控 `file.exportText`、`file.exportImage`、`file.exportAudio` HostBridge method。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS` 是移动 WebView 禁止下载协议清单的唯一来源。`apps/mobile-shell/src/shell/webViewPolicy.ts` 统一承接移动壳下载策略,导航拦截和 WebView 注入脚本都必须复用该共享清单,阻断下载链接点击、危险下载协议链接、`window.open` 下载 URL 和程序化 anchor click;`ShellApp` 在同源 / 外链分流前调用 `shouldBlockMobileWebViewNavigationRequest(...)`,命中 `blob:`、`data:`、`file:` 或 `filesystem:` 直接拒绝,不进入带完整 HostBridge 的 WebView,也不交给系统外部应用。移动端文件保存仍只能走受控 `file.exportText`、`file.exportImage`、`file.exportAudio` HostBridge method。 - 影响范围:`apps/mobile-shell/src/shell/webViewPolicy.ts`、`apps/mobile-shell/src/shell/webViewPolicy.test.ts`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run mobile-shell:test -- src/shell/webViewPolicy.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run typecheck -- --pretty false`、`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 8e920a3e6..7b27300b0 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -271,7 +271,7 @@ GameBridge 禁止: - Tauri 主 WebView 禁止默认下载落盘;桌面文件保存只能通过受控 HostBridge 导出能力进入系统保存对话框。 - Tauri 主 WebView 禁止默认打开 DevTools;不得通过配置或 Cargo feature 为分发壳启用浏览器检查器。 - RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。 -- RN WebView 禁止网页自动下载、下载协议导航和 `` 直接落盘;移动端文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等受控 HostBridge method 进入系统分享 / 保存面板。 +- RN WebView 禁止网页自动下载、下载协议导航和 `` 直接落盘;禁止下载协议清单以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS` 为唯一来源,移动端文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等受控 HostBridge method 进入系统分享 / 保存面板。 - Expo 移动壳的通知能力只覆盖即时本地通知;Android 包配置必须阻断重启后通知恢复和精确定时权限,前端代码不得注册 Expo push token、远程推送监听或通知响应跳转流程。 - AI sandbox iframe 必须使用独立 CSP、`sandbox` 属性和单独 GameBridge allowlist。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 2e3f7cecb..d42eb547b 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -47,6 +47,8 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗 宿主上下文 query 的字段名和值以 `packages/shared/src/contracts/hostBridge.ts` 为源。`HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 覆盖 H5 runtime 识别可读取的 `clientRuntime`、`clientType`、`miniProgramEnv`、`hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion` 和 `hostCapabilities`;`HOST_BRIDGE_NATIVE_APP_QUERY_KEY`、`HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` 与 `HOST_BRIDGE_NATIVE_APP_QUERY` 固定 Expo / Tauri 原生壳入口 query;`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 固定微信 WebView 来源标记;`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 固定 H5 页面内导航需要跨路径保留的宿主字段,必须同时覆盖微信小程序来源字段和原生壳 `hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion`、`hostCapabilities` 完整运行态字段。Expo 移动壳直接引用共享常量,Tauri Rust 和微信小程序 CommonJS 运行时镜像由 `npm run check:native-shells` 反查;H5 `getHostRuntime()` 和路由保留列表不得重新手写这些字段。 +移动壳 WebView 下载协议阻断清单以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS` 为源。Expo 壳导航拦截和 WebView 注入脚本必须复用同一清单,命中后直接拒绝进入带完整 HostBridge 的 WebView;移动端文件保存只通过受控 `file.exportText`、`file.exportImage`、`file.exportAudio` 能力进入系统分享 / 保存面板。 + ## 首批能力 - `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`,并解析 `hostCapabilities` 能力声明;进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主 runtime 并缓存能力清单,未知能力会被丢弃。H5 业务只根据已声明或已回读的能力展示入口、发起宿主请求或走 fallback。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 634971674..5d8135d83 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -22,6 +22,7 @@ import { HOST_BRIDGE_EVENTS, HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS, + HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS, HOST_BRIDGE_RESPONSE_CACHE_MAX, HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, HOST_BRIDGE_SCANNER_TIMEOUT_MS, @@ -53,6 +54,15 @@ describe('HostBridge shared contract helpers', () => { expect(HOST_BRIDGE_TAURI_COMMAND).toBe('host_bridge_request'); }); + test('固定移动壳 WebView 下载协议阻断清单', () => { + expect(HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS).toEqual([ + 'blob:', + 'data:', + 'file:', + 'filesystem:', + ]); + }); + test('固定原生壳请求超时边界', () => { expect(HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS).toBe(8000); expect(HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS).toBe(60000); diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 5358ba900..a0b139b0b 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -411,6 +411,16 @@ export const HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS = [ export type HostBridgeExternalUrlProtocol = (typeof HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS)[number]; +export const HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = [ + 'blob:', + 'data:', + 'file:', + 'filesystem:', +] as const; + +export type HostBridgeMobileWebViewBlockedDownloadProtocol = + (typeof HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS)[number]; + function hasHostBridgeControlCharacter(value: string) { return [...value].some((character) => { const codePoint = character.codePointAt(0) ?? 0;