收口宿主壳外链打开协议

共享 HostBridge 契约新增外链 URL 协议白名单校验

Expo 移动壳打开外链前拒绝危险协议

Tauri 桌面壳打开外链前拒绝危险协议

补充共享契约、移动壳和桌面壳外链校验测试

更新宿主壳方案和团队共享决策记录
This commit is contained in:
2026-06-17 22:31:24 +08:00
parent 8b14c6ebe5
commit 61d910400e
7 changed files with 161 additions and 9 deletions

View File

@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge"; const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge";
const HOST_BRIDGE_VERSION: u8 = 1; const HOST_BRIDGE_VERSION: u8 = 1;
const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; const WEB_APP_ORIGIN: &str = "https://app.genarrative.world";
const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"];
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[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<String> {
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> { fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
value value
.get(field) .get(field)
@@ -238,9 +266,18 @@ fn host_bridge_request(
match request.method.as_str() { match request.method.as_str() {
"app.openExternalUrl" => { "app.openExternalUrl" => {
let url = match required_string_payload(&request, "url") { let url = match required_string_payload(&request, "url")
Ok(url) => url, .ok()
Err(response) => return response, .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>) { match app.opener().open_url(url, None::<&str>) {
@@ -380,6 +417,22 @@ mod tests {
assert_eq!(error.message, "text is required"); 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] #[test]
fn share_text_uses_direct_share_payload() { fn share_text_uses_direct_share_payload() {
let state = DesktopShareState::default(); let state = DesktopShareState::default();

View File

@@ -1,3 +1,4 @@
import * as Linking from 'expo-linking';
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import { import {
@@ -83,6 +84,7 @@ function expectFailed(response: HostBridgeResponse) {
} }
afterEach(() => { afterEach(() => {
vi.mocked(Linking.openURL).mockReset();
configureMobileHostBridgeNavigation(null); configureMobileHostBridgeNavigation(null);
}); });
@@ -148,4 +150,28 @@ describe('handleMobileHostBridgeMessage', () => {
expect(failedResponse.error.code).toBe('unsupported_method'); 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();
});
}); });

View File

@@ -14,6 +14,7 @@ import {
type HostBridgeRequest, type HostBridgeRequest,
type HostBridgeResponse, type HostBridgeResponse,
type NavigateNativePagePayload, type NavigateNativePagePayload,
normalizeHostBridgeExternalUrl,
type OpenExternalUrlPayload, type OpenExternalUrlPayload,
type ShareOpenPayload, type ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge'; } from '../../../packages/shared/src/contracts/hostBridge';
@@ -106,9 +107,11 @@ function failure(
} }
async function openExternalUrl(payload: unknown) { async function openExternalUrl(payload: unknown) {
const url = (payload as OpenExternalUrlPayload | undefined)?.url; const url = normalizeHostBridgeExternalUrl(
(payload as OpenExternalUrlPayload | undefined)?.url,
);
if (!url) { if (!url) {
throw invalidRequest('url is required'); throw invalidRequest('url must use an allowed external protocol');
} }
await Linking.openURL(url); await Linking.openURL(url);

View File

@@ -20,7 +20,7 @@
- 背景:后续需要移动端 App 和桌面端 App但现有主站、固定玩法 runtime、小程序壳和未来 AI H5 sandbox 已经以 H5 为主线;如果移动端重写 React Native UI、桌面端重写 Rust/Tauri UI会形成玩法、登录、支付、分享和运行态的多套实现。 - 背景:后续需要移动端 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 契约和测试。 - 决策:移动端原生壳采用 `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 边界。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
- 验证方式普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5固定玩法在各宿主中读取同一作品数据和运行态 snapshotAI sandbox 无法直接调用 HostBridgeTauri release 不允许任意远端页面调用桌面命令。 - 验证方式普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5固定玩法在各宿主中读取同一作品数据和运行态 snapshotAI sandbox 无法直接调用 HostBridgeTauri release 不允许任意远端页面调用桌面命令。
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md``docs/【前端架构】宿主壳能力统一协议-2026-06-17.md` - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md``docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`

View File

@@ -116,7 +116,7 @@ type HostBridgeEvent = {
| `share.setTarget` | 同步当前作品分享目标 | 支持 | 支持 | | `share.setTarget` | 同步当前作品分享目标 | 支持 | 支持 |
| `share.open` | 打开分享动作 | 支持系统分享面板 | 复制分享文本到剪贴板 | | `share.open` | 打开分享动作 | 支持系统分享面板 | 复制分享文本到剪贴板 |
| `navigation.openNativePage` | 打开受控宿主页 | 支持同源 H5 route | 支持设置 / 关于等 | | `navigation.openNativePage` | 打开受控宿主页 | 支持同源 H5 route | 支持设置 / 关于等 |
| `app.openExternalUrl` | 用系统浏览器打开外链 | 支持 | 支持 | | `app.openExternalUrl` | 用系统浏览器打开外链 | 支持白名单协议 | 支持白名单协议 |
| `clipboard.writeText` | 写剪贴板 | 可选 | 可选 | | `clipboard.writeText` | 写剪贴板 | 可选 | 可选 |
| `haptics.impact` | 轻量触感反馈 | 可选 | 不支持 | | `haptics.impact` | 轻量触感反馈 | 可选 | 不支持 |
@@ -238,7 +238,7 @@ GameBridge 禁止:
- iOS / Android 深链打开作品详情、创作页和邀请码。 - iOS / Android 深链打开作品详情、创作页和邀请码。
- 登录和支付先 fallback 到 H5只把能力边界跑通。 - 登录和支付先 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 3Tauri 桌面壳 MVP ### Phase 3Tauri 桌面壳 MVP
@@ -249,7 +249,7 @@ GameBridge 禁止:
- 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。 - 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。
- 验证 macOS / Windows / Linux 至少一条本地 smoke。 - 验证 macOS / Windows / Linux 至少一条本地 smoke。
当前状态:已新增 `apps/desktop-shell/`Tauri dev 直接加载本地主站 Viterelease 打包根 `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 直接加载本地主站 Viterelease 打包根 `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宿主能力扩展 ### Phase 4宿主能力扩展

View File

@@ -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();
});
});

View File

@@ -87,6 +87,49 @@ export type OpenExternalUrlPayload = {
url: string; 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 = { export type ClipboardWriteTextPayload = {
text: string; text: string;
}; };