收口宿主壳外链打开协议
共享 HostBridge 契约新增外链 URL 协议白名单校验 Expo 移动壳打开外链前拒绝危险协议 Tauri 桌面壳打开外链前拒绝危险协议 补充共享契约、移动壳和桌面壳外链校验测试 更新宿主壳方案和团队共享决策记录
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。
|
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri 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`。
|
||||||
|
|||||||
@@ -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 3:Tauri 桌面壳 MVP
|
### Phase 3:Tauri 桌面壳 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 直接加载本地主站 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:宿主能力扩展
|
### Phase 4:宿主能力扩展
|
||||||
|
|
||||||
|
|||||||
27
packages/shared/src/contracts/hostBridge.test.ts
Normal file
27
packages/shared/src/contracts/hostBridge.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user