原生壳收紧外链协议门禁
让桌面壳外链归一改用 URL parser 校验允许协议 在统一原生壳检查中对齐共享契约与桌面壳外链协议清单 同步原生壳方案文档和团队决策记录中的外链协议口径
This commit is contained in:
@@ -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<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());
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 资产调试原图入口已优先走宿主系统浏览器,普通浏览器和小程序保留原 `<a>` 行为,宿主不可用或拒绝时回退浏览器外链。
|
||||
- 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 存在推断能力可用。
|
||||
|
||||
@@ -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:宿主能力扩展
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user