diff --git a/apps/desktop-shell/src-tauri/src/shell/navigation.rs b/apps/desktop-shell/src-tauri/src/shell/navigation.rs index 74438204c..6f08de6d0 100644 --- a/apps/desktop-shell/src-tauri/src/shell/navigation.rs +++ b/apps/desktop-shell/src-tauri/src/shell/navigation.rs @@ -7,31 +7,19 @@ use tauri_plugin_opener::OpenerExt; const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; -fn external_url_protocol(raw_url: &str) -> Option<&str> { - raw_url.split_once(':').map(|(protocol, _)| protocol) -} - pub(crate) fn normalize_external_url(raw_url: &str) -> Option { let url = raw_url.trim(); if url.is_empty() || url.chars().any(char::is_control) { return None; } - let protocol = external_url_protocol(url)?; - if protocol.is_empty() - || !protocol.chars().all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.') - }) - { - return None; - } - - let protocol_with_colon = format!("{}:", protocol.to_ascii_lowercase()); + let parsed_url = Url::parse(url).ok()?; + let protocol_with_colon = format!("{}:", parsed_url.scheme().to_ascii_lowercase()); if !EXTERNAL_URL_PROTOCOLS.contains(&protocol_with_colon.as_str()) { return None; } - Some(url.to_string()) + Some(parsed_url.to_string()) } pub(crate) fn desktop_navigation_can_go_back_payload(can_go_back: bool) -> Value { @@ -205,8 +193,13 @@ mod tests { normalize_external_url("mailto:hi@example.com"), Some("mailto:hi@example.com".to_string()) ); + assert_eq!( + normalize_external_url("tel:+12345678"), + Some("tel:+12345678".to_string()) + ); assert_eq!(normalize_external_url("javascript:alert(1)"), None); assert_eq!(normalize_external_url("file:///etc/passwd"), None); + assert_eq!(normalize_external_url("http://exa mple.com/path"), None); assert_eq!(normalize_external_url("https://example.com/\nnext"), None); assert_eq!(normalize_external_url("/relative/path"), None); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 17940f77e..ab44e1ec7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -22,6 +22,7 @@ - 决策:移动端原生壳采用 `Expo + React Native`,桌面端壳采用 `Tauri`。两者都只作为 `native_app` 宿主壳和 HostBridge adapter,不重写现有 React H5 主站,不把固定内置玩法迁到 React Native / Rust UI,也不让 AI 生成 H5 游戏直接访问完整 HostBridge。Expo 壳通过 `react-native-webview` 承接 H5 与 native 通信,Tauri 壳通过受控 command 和 capabilities 承接桌面能力;新增能力必须先进入 HostBridge 契约和测试。 - 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,`navigation.openNativePage` 只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的原生页面,且通过 `host.events` 注入 `navigation.canGoBack` 返回栈状态事件,`share.setTarget` / `share.open` 解析统一分享目标并调用 React Native 系统分享面板,发布分享弹窗在 native_app 中通过 `share.open` 提供“系统分享”动作,失败时保留复制链接回退路径;`file.exportText` 写入 Expo 缓存文本文件后交给系统分享 / 保存面板,成功只返回文件名和字节数,`haptics.impact` 通过 Expo Haptics 承接 H5 运行时点击反馈;`app.openExternalUrl` 在 Expo 与 Tauri 两端都只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议;H5 复制服务在 native_app 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板,失败后再回退浏览器复制路径;H5 运行时反馈在 native_app 中优先通过 `haptics.impact` 请求真实移动端触觉,宿主不可用或 unsupported 时回退浏览器 `navigator.vibrate`;H5 主站按当前平台阶段同步 `document.title` 并通过 `app.setTitle` 请求宿主窗口标题,Tauri 壳通过主窗口 API 同步非空窗口标题,Expo 移动壳不声明该能力时静默忽略;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,将 `navigation.openNativePage` 实现为 `https://app.genarrative.world` 同源 H5 route 的主窗口受控跳转,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板;桌面 `file.exportText` 通过 Tauri dialog 插件打开系统保存对话框并由 Rust 写入文本文件,但不把 dialog / fs 插件 command 直接暴露给 H5,成功只返回文件名和字节数,用户取消返回 `cancelled`;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。 - 2026-06-18 外链接入:H5 新增 `openHostExternalUrl()` facade,`native_app` 下会把外链归一化为允许协议的绝对 URL 后请求 `app.openExternalUrl`;ICP备案号和 RPG 资产调试原图入口已优先走宿主系统浏览器,普通浏览器和小程序保留原 `` 行为,宿主不可用或拒绝时回退浏览器外链。 +- 2026-06-18 外链协议白名单门禁:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS` 是 `app.openExternalUrl` 唯一协议来源,当前只允许 `http:`、`https:`、`mailto:`、`tel:`;Expo 直接复用共享归一化逻辑,Tauri Rust 侧必须用 URL parser 镜像同一清单,根级 `npm run check:native-shells` 会拒绝共享契约与桌面壳协议清单漂移。 - 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:`、`https:`、`mailto:`、`tel:` 交给 `Linking.openURL`,`javascript:`、`file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。 - 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。 - 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 57720c739..e0aa542d0 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -434,6 +434,8 @@ GameBridge 禁止: 2026-06-18 追加:移动壳 HostBridge 消息入口增加来源校验。`onMessage` 不只依赖导航拦截和 `originWhitelist`,还会读取 `event.nativeEvent.url`,只有同源主站页面才能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域 URL、协议降级或危险协议页面发来的消息全部丢弃,不返回 HostBridge 错误细节。该校验与 `navigation.openNativePage` 共用同源规则,防止历史中间页或异常页面在带完整 HostBridge 的 WebView 中发起宿主能力请求。 +2026-06-18 追加:`app.openExternalUrl` 的协议白名单以共享 HostBridge 契约 `HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS` 为唯一来源,当前只允许 `http:`、`https:`、`mailto:`、`tel:`。Expo 壳直接复用共享归一化逻辑,Tauri 壳 Rust 侧用 URL parser 镜像同一清单;`npm run check:native-shells` 会反查共享契约与桌面壳协议清单,防止某一端单独放宽外链协议。 + 2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts`、`files.ts`、`share.ts` 和 facade `bridge.ts`,与桌面端 `host_bridge/protocol.rs`、`files.rs`、`share.rs`、`mod.rs` 对齐;移动壳根 `App.tsx` 也保持薄入口,只装配 `src/shell/ShellApp.tsx`,WebView 容器、深链、网络、生命周期和安全策略全部留在 `src/shell/`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。 ### Phase 4:宿主能力扩展 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 4db466e66..8c250339a 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -96,6 +96,8 @@ const capabilityListMarkers = { mobile: '移动壳当前通用真实能力完整清单为', mobileIosExtra: '移动壳 iOS 额外真实能力为', }; +const sharedHostBridgeContractPath = + 'packages/shared/src/contracts/hostBridge.ts'; const productionShellExtensions = new Set([ '.json', '.mjs', @@ -283,6 +285,17 @@ function extractRustCapabilities(source) { return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); } +function extractRustStringArray(source, constName) { + const match = source.match( + new RegExp(`const ${constName}[^=]*= \\[([\\s\\S]*?)\\];`), + ); + if (!match) { + throw new Error(`unable to read Rust ${constName}`); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); +} + function extractDocumentCapabilityList(source, marker) { const markerIndex = source.indexOf(marker); if (markerIndex === -1) { @@ -337,6 +350,23 @@ function assertNativeShellCapabilityPlan() { ); } +function assertExternalUrlProtocolParity() { + const sharedContractSource = fs.readFileSync( + sharedHostBridgeContractPath, + 'utf8', + ); + const desktopNavigationSource = fs.readFileSync( + 'apps/desktop-shell/src-tauri/src/shell/navigation.rs', + 'utf8', + ); + + assertSameList( + extractRustStringArray(desktopNavigationSource, 'EXTERNAL_URL_PROTOCOLS'), + extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS'), + 'desktop shell external URL protocol list', + ); +} + function assertHostBridgeLayerLayout() { const wechatBridgeFiles = fs .readdirSync('miniprogram/host-bridge', { withFileTypes: true }) @@ -475,6 +505,9 @@ assertHostBridgeLayerLayout(); console.log('[check:native-shells] native-shell-capability-plan'); assertNativeShellCapabilityPlan(); +console.log('[check:native-shells] external-url-protocol-parity'); +assertExternalUrlProtocolParity(); + console.log('[check:native-shells] production-shell-dev-scaffold-scan'); assertNoProductionShellDevScaffoldTerms();