diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index ca26e134..98113658 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt; const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge"; const HOST_BRIDGE_VERSION: u8 = 1; const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; +const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -129,6 +130,33 @@ fn required_string_payload<'a>( }) } +fn external_url_protocol(raw_url: &str) -> Option<&str> { + raw_url.split_once(':').map(|(protocol, _)| protocol) +} + +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()); + if !EXTERNAL_URL_PROTOCOLS.contains(&protocol_with_colon.as_str()) { + return None; + } + + Some(url.to_string()) +} + fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> { value .get(field) @@ -238,9 +266,18 @@ fn host_bridge_request( match request.method.as_str() { "app.openExternalUrl" => { - let url = match required_string_payload(&request, "url") { - Ok(url) => url, - Err(response) => return response, + let url = match required_string_payload(&request, "url") + .ok() + .and_then(normalize_external_url) + { + Some(url) => url, + None => { + return failed( + request.id, + "invalid_request", + "url must use an allowed external protocol", + ) + } }; match app.opener().open_url(url, None::<&str>) { @@ -380,6 +417,22 @@ mod tests { assert_eq!(error.message, "text is required"); } + #[test] + fn external_url_normalization_allows_only_safe_protocols() { + assert_eq!( + normalize_external_url(" https://example.com/path "), + Some("https://example.com/path".to_string()) + ); + assert_eq!( + normalize_external_url("mailto:hi@example.com"), + Some("mailto:hi@example.com".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("https://example.com/\nnext"), None); + assert_eq!(normalize_external_url("/relative/path"), None); + } + #[test] fn share_text_uses_direct_share_payload() { let state = DesktopShareState::default(); diff --git a/apps/mobile-shell/src/mobileHostBridge.test.ts b/apps/mobile-shell/src/mobileHostBridge.test.ts index 34888ea7..47a889ad 100644 --- a/apps/mobile-shell/src/mobileHostBridge.test.ts +++ b/apps/mobile-shell/src/mobileHostBridge.test.ts @@ -1,3 +1,4 @@ +import * as Linking from 'expo-linking'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { @@ -83,6 +84,7 @@ function expectFailed(response: HostBridgeResponse) { } afterEach(() => { + vi.mocked(Linking.openURL).mockReset(); configureMobileHostBridgeNavigation(null); }); @@ -148,4 +150,28 @@ describe('handleMobileHostBridgeMessage', () => { expect(failedResponse.error.code).toBe('unsupported_method'); }); + + test('app.openExternalUrl 只打开允许的外链协议', async () => { + const response = await send( + request('app.openExternalUrl', { + url: ' https://example.com/path ', + }), + ); + + expectOk(response); + expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/path'); + }); + + test('app.openExternalUrl 拒绝危险协议', async () => { + const response = await send( + request('app.openExternalUrl', { + url: 'javascript:alert(1)', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('invalid_request'); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); }); diff --git a/apps/mobile-shell/src/mobileHostBridge.ts b/apps/mobile-shell/src/mobileHostBridge.ts index 57f296b4..d8c7bd08 100644 --- a/apps/mobile-shell/src/mobileHostBridge.ts +++ b/apps/mobile-shell/src/mobileHostBridge.ts @@ -14,6 +14,7 @@ import { type HostBridgeRequest, type HostBridgeResponse, type NavigateNativePagePayload, + normalizeHostBridgeExternalUrl, type OpenExternalUrlPayload, type ShareOpenPayload, } from '../../../packages/shared/src/contracts/hostBridge'; @@ -106,9 +107,11 @@ function failure( } async function openExternalUrl(payload: unknown) { - const url = (payload as OpenExternalUrlPayload | undefined)?.url; + const url = normalizeHostBridgeExternalUrl( + (payload as OpenExternalUrlPayload | undefined)?.url, + ); if (!url) { - throw invalidRequest('url is required'); + throw invalidRequest('url must use an allowed external protocol'); } await Linking.openURL(url); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a5363ff6..8c14691f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,7 +20,7 @@ - 背景:后续需要移动端 App 和桌面端 App,但现有主站、固定玩法 runtime、小程序壳和未来 AI H5 sandbox 已经以 H5 为主线;如果移动端重写 React Native UI、桌面端重写 Rust/Tauri UI,会形成玩法、登录、支付、分享和运行态的多套实现。 - 决策:移动端原生壳采用 `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,不伪造尚未存在的原生页面;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。 +- 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,不伪造尚未存在的原生页面;`app.openExternalUrl` 在 Expo 与 Tauri 两端都只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 - 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 60eaa2da..4dc13318 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -116,7 +116,7 @@ type HostBridgeEvent = { | `share.setTarget` | 同步当前作品分享目标 | 支持 | 支持 | | `share.open` | 打开分享动作 | 支持系统分享面板 | 复制分享文本到剪贴板 | | `navigation.openNativePage` | 打开受控宿主页 | 支持同源 H5 route | 支持设置 / 关于等 | -| `app.openExternalUrl` | 用系统浏览器打开外链 | 支持 | 支持 | +| `app.openExternalUrl` | 用系统浏览器打开外链 | 支持白名单协议 | 支持白名单协议 | | `clipboard.writeText` | 写剪贴板 | 可选 | 可选 | | `haptics.impact` | 轻量触感反馈 | 可选 | 不支持 | @@ -238,7 +238,7 @@ GameBridge 禁止: - iOS / Android 深链打开作品详情、创作页和邀请码。 - 登录和支付先 fallback 到 H5;只把能力边界跑通。 -当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`app.openExternalUrl`、`clipboard.writeText`、`haptics.impact` 和 Android 返回键回退;其中 `navigation.openNativePage` 在 Expo 壳内只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的登录、支付或其它原生页面。登录和支付尚未接入渠道 SDK 时明确返回 unsupported,让 H5 fallback 承接。 +当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`app.openExternalUrl`、`clipboard.writeText`、`haptics.impact` 和 Android 返回键回退;其中 `navigation.openNativePage` 在 Expo 壳内只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的登录、支付或其它原生页面,`app.openExternalUrl` 只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议。登录和支付尚未接入渠道 SDK 时明确返回 unsupported,让 H5 fallback 承接。 ### Phase 3:Tauri 桌面壳 MVP @@ -249,7 +249,7 @@ GameBridge 禁止: - 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。 - 验证 macOS / Windows / Linux 至少一条本地 smoke。 -当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板;不把 opener 或 clipboard 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`share.setTarget`、`share.open`、`app.openExternalUrl` 和 `clipboard.writeText`;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。 +当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行且只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板;不把 opener 或 clipboard 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`share.setTarget`、`share.open`、`app.openExternalUrl` 和 `clipboard.writeText`;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。 ### Phase 4:宿主能力扩展 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts new file mode 100644 index 00000000..65b1d6bf --- /dev/null +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'vitest'; + +import { normalizeHostBridgeExternalUrl } from './hostBridge'; + +describe('HostBridge shared contract helpers', () => { + test('只允许明确的外链协议交给宿主打开', () => { + expect(normalizeHostBridgeExternalUrl(' https://example.com/a ')).toBe( + 'https://example.com/a', + ); + expect(normalizeHostBridgeExternalUrl('mailto:hi@example.com')).toBe( + 'mailto:hi@example.com', + ); + expect(normalizeHostBridgeExternalUrl('tel:+12345678')).toBe( + 'tel:+12345678', + ); + }); + + test('拒绝空值、控制字符和危险协议', () => { + expect(normalizeHostBridgeExternalUrl('')).toBeNull(); + expect(normalizeHostBridgeExternalUrl('javascript:alert(1)')).toBeNull(); + expect(normalizeHostBridgeExternalUrl('file:///etc/passwd')).toBeNull(); + expect( + normalizeHostBridgeExternalUrl('https://example.com/\nnext'), + ).toBeNull(); + expect(normalizeHostBridgeExternalUrl('/relative/path')).toBeNull(); + }); +}); diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 6b4cd576..6fd84ce6 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -87,6 +87,49 @@ export type OpenExternalUrlPayload = { url: string; }; +export const HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS = [ + 'http:', + 'https:', + 'mailto:', + 'tel:', +] as const; + +export type HostBridgeExternalUrlProtocol = + (typeof HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS)[number]; + +function hasHostBridgeControlCharacter(value: string) { + return [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }); +} + +export function normalizeHostBridgeExternalUrl(rawUrl: unknown) { + if (typeof rawUrl !== 'string') { + return null; + } + + const urlText = rawUrl.trim(); + if (!urlText || hasHostBridgeControlCharacter(urlText)) { + return null; + } + + try { + const url = new URL(urlText); + if ( + !HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS.includes( + url.protocol as HostBridgeExternalUrlProtocol, + ) + ) { + return null; + } + + return url.toString(); + } catch { + return null; + } +} + export type ClipboardWriteTextPayload = { text: string; };