diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index abaf73229..f1dc05fbe 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -83,6 +83,14 @@ const desktopShellNetworkSource = fs.readFileSync( desktopShellNetworkPath, 'utf8', ); +const desktopShellNavigationPath = new URL( + '../src-tauri/src/shell/navigation.rs', + import.meta.url, +); +const desktopShellNavigationSource = fs.readFileSync( + desktopShellNavigationPath, + 'utf8', +); const productionSourceRoots = [ new URL('../package.json', import.meta.url), new URL('../src-tauri/Cargo.toml', import.meta.url), @@ -1589,6 +1597,26 @@ if (desktopHostBridgeDispatchSource.includes('json!(HostBridgeRuntime {')) { throw new Error('desktop shell host.getRuntime must be built by desktop_runtime()'); } +if ( + !desktopShellNavigationSource.includes('pub(crate) fn open_normalized_desktop_external_url') || + !desktopShellNavigationSource.includes( + 'open_normalized_desktop_external_url(app, external_url)', + ) +) { + throw new Error( + 'desktop shell WebView external navigation must use the shared external opener helper', + ); +} + +if ( + !desktopHostBridgeDispatchSource.includes('open_normalized_desktop_external_url(&app, url)') || + desktopHostBridgeDispatchSource.includes('app.opener().open_url(') +) { + throw new Error( + 'desktop shell app.openExternalUrl must use the shared external opener helper', + ); +} + if (config.build?.frontendDist !== '../../../dist') { throw new Error('desktop shell must package the root H5 dist'); } @@ -1930,6 +1958,7 @@ const requiredRustHostSnippets = [ 'desktop_main_window_config(app)?', 'should_allow_desktop_webview_navigation', 'desktop_external_navigation_url', + 'open_normalized_desktop_external_url', 'open_desktop_external_navigation', '.on_navigation(move |url|', '.on_new_window(move |url, _features|', diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs index 8b9e62f90..d3bf6fb18 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs @@ -12,14 +12,13 @@ use crate::host_bridge::protocol::{ use crate::host_bridge::share::{share_text_from_request, DesktopShareState}; use crate::shell::webview::{ color_scheme_from_theme, desktop_platform, normalize_external_url, normalize_native_page_url, - resolve_desktop_network_status, + open_normalized_desktop_external_url, resolve_desktop_network_status, }; use serde_json::{json, Value}; use tauri::Manager; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_dialog::DialogExt; use tauri_plugin_notification::{NotificationExt, PermissionState}; -use tauri_plugin_opener::OpenerExt; const BADGE_COUNT_MAX: i64 = 99999; const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80; @@ -192,7 +191,7 @@ pub(super) async fn execute_host_bridge_request( } }; - match app.opener().open_url(url, None::<&str>) { + match open_normalized_desktop_external_url(&app, url) { Ok(()) => ok(request.id, json!(true)), Err(error) => failed(request.id, "host_error", error.to_string()), } diff --git a/apps/desktop-shell/src-tauri/src/shell/navigation.rs b/apps/desktop-shell/src-tauri/src/shell/navigation.rs index 14e33934f..67db9a24a 100644 --- a/apps/desktop-shell/src-tauri/src/shell/navigation.rs +++ b/apps/desktop-shell/src-tauri/src/shell/navigation.rs @@ -149,11 +149,20 @@ pub(crate) fn desktop_external_navigation_url(url: &Url) -> Option { normalize_external_url(url.as_str()) } +pub(crate) fn open_normalized_desktop_external_url( + app: &tauri::AppHandle, + external_url: String, +) -> Result<(), String> { + app.opener() + .open_url(external_url, None::<&str>) + .map_err(|error| error.to_string()) +} + pub(crate) fn open_desktop_external_navigation(app: &tauri::AppHandle, url: &Url) { let Some(external_url) = desktop_external_navigation_url(url) else { return; }; - let _ = app.opener().open_url(external_url, None::<&str>); + let _ = open_normalized_desktop_external_url(app, external_url); } pub(crate) fn should_allow_desktop_webview_download(event: &DownloadEvent<'_>) -> bool { diff --git a/apps/desktop-shell/src-tauri/src/shell/webview.rs b/apps/desktop-shell/src-tauri/src/shell/webview.rs index 025f4b011..20711df8b 100644 --- a/apps/desktop-shell/src-tauri/src/shell/webview.rs +++ b/apps/desktop-shell/src-tauri/src/shell/webview.rs @@ -5,8 +5,8 @@ pub(crate) use crate::shell::lifecycle::{ }; pub(crate) use crate::shell::navigation::{ normalize_external_url, normalize_native_page_url, open_desktop_external_navigation, - register_desktop_navigation_events, should_allow_desktop_webview_download, - should_allow_desktop_webview_navigation, + open_normalized_desktop_external_url, register_desktop_navigation_events, + should_allow_desktop_webview_download, should_allow_desktop_webview_navigation, }; pub(crate) use crate::shell::network::{ register_desktop_network_events, resolve_desktop_network_status, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6a095c729..7e3ca7ec7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -29,6 +29,7 @@ - 2026-06-19 移动壳外链打开 helper 共用:Expo WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `openMobileShellExternalNavigation` 执行系统外链打开动作;HostBridge 分支仍先调用 `normalizeHostBridgeExternalUrlPayload` 保留 payload 错误语义,但不再单独维护 `Linking.canOpenURL` / `Linking.openURL` 顺序。移动壳配置检查会拒绝 `app.openExternalUrl` 绕开该 helper,避免两条离壳路径漂移。 - 2026-06-19 移动壳系统分享 URL 边界:Expo `share.open` 调用 React Native 系统分享面板前,只允许把 `url`、`href`、`path`、`targetPath` 和 `work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。分享实现复用移动壳入口 URL 的生产主站 origin,配置检查会拒绝重新声明同值 origin 或移除协议相对 URL 拦截。 - 2026-06-19 桌面壳系统分享 URL 边界:Tauri `share.open` 写入系统剪贴板前同样只允许把 `url`、`href`、`path`、`targetPath` 和 `work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。桌面壳配置检查会拒绝移除同源分享 URL 归一和协议相对 URL 拦截。 +- 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5,WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。 - 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 存在推断能力可用。该回读请求的短超时由共享契约 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS` 声明,H5 facade 不得本地重声明。 - 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck` 与 `npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES` 由 `miniprogram/host-bridge/protocol.test.js` 和根级 `npm run check:native-shells` 反查共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES`。新增能力必须先更新契约和真实壳实现,再通过这些检查。