From ce27a0c35dce52db6ef9d378e9f30347ee0826e2 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 18:10:19 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=86=E5=88=86=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E8=81=8C=E8=B4=A3=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将桌面壳 WebView 职责拆分为运行态、URL、导航、网络、生命周期、拖拽和事件模块 更新三端桥接层结构门禁清单 同步宿主壳文档和共享记忆中的桌面壳模块口径 --- apps/desktop-shell/scripts/check-config.mjs | 7 + .../src-tauri/src/shell/events.rs | 48 ++ .../src-tauri/src/shell/file_drop.rs | 35 ++ .../src-tauri/src/shell/lifecycle.rs | 69 ++ apps/desktop-shell/src-tauri/src/shell/mod.rs | 7 + .../src-tauri/src/shell/navigation.rs | 216 +++++++ .../src-tauri/src/shell/network.rs | 113 ++++ .../src-tauri/src/shell/runtime.rs | 32 + apps/desktop-shell/src-tauri/src/shell/url.rs | 108 ++++ .../src-tauri/src/shell/webview.rs | 592 +----------------- .../shared-memory/decision-log.md | 4 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- scripts/check-native-shells.mjs | 7 + 14 files changed, 661 insertions(+), 583 deletions(-) create mode 100644 apps/desktop-shell/src-tauri/src/shell/events.rs create mode 100644 apps/desktop-shell/src-tauri/src/shell/file_drop.rs create mode 100644 apps/desktop-shell/src-tauri/src/shell/lifecycle.rs create mode 100644 apps/desktop-shell/src-tauri/src/shell/navigation.rs create mode 100644 apps/desktop-shell/src-tauri/src/shell/network.rs create mode 100644 apps/desktop-shell/src-tauri/src/shell/runtime.rs create mode 100644 apps/desktop-shell/src-tauri/src/shell/url.rs diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index a8ca74b19..f0e822a3f 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1047,8 +1047,15 @@ const requiredRustHostModules = [ 'host_bridge/share.rs', 'main.rs', 'shell/deep_link.rs', + 'shell/events.rs', + 'shell/file_drop.rs', + 'shell/lifecycle.rs', 'shell/mod.rs', + 'shell/navigation.rs', + 'shell/network.rs', + 'shell/runtime.rs', 'shell/tray.rs', + 'shell/url.rs', 'shell/webview.rs', ]; const requiredRustHostSnippets = [ diff --git a/apps/desktop-shell/src-tauri/src/shell/events.rs b/apps/desktop-shell/src-tauri/src/shell/events.rs new file mode 100644 index 000000000..336642e26 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/events.rs @@ -0,0 +1,48 @@ +use crate::host_bridge::protocol::{HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION}; +use serde_json::{json, Value}; + +pub(crate) fn host_bridge_event_script( + event: &str, + payload: Value, +) -> Result { + let message = json!({ + "bridge": HOST_BRIDGE_PROTOCOL, + "version": HOST_BRIDGE_VERSION, + "event": event, + "payload": payload, + }); + let data = serde_json::to_string(&message)?; + let data_literal = serde_json::to_string(&data)?; + + Ok(format!( + "window.dispatchEvent(new MessageEvent('message', {{ data: {}, origin: window.location.origin, source: window }})); true;", + data_literal + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn host_bridge_event_script_dispatches_lifecycle_message() { + let script = host_bridge_event_script( + "app.lifecycle", + json!({ + "state": "active", + "focused": true, + "nativeState": "focused", + }), + ) + .expect("event script"); + + assert!(script.contains("MessageEvent('message'")); + assert!(script.contains("origin: window.location.origin")); + assert!(script.contains("source: window")); + assert!(script.contains("GenarrativeHostBridge")); + assert!(script.contains("app.lifecycle")); + assert!(script.contains("\\\"state\\\":\\\"active\\\"")); + assert!(script.contains("\\\"focused\\\":true")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/file_drop.rs b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs new file mode 100644 index 000000000..33c97a9a4 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs @@ -0,0 +1,35 @@ +use crate::host_bridge::files::{import_image_file_payload, import_image_mime_type}; +use crate::shell::events::host_bridge_event_script; +use std::path::PathBuf; +use tauri::{DragDropEvent, WebviewWindow, WindowEvent}; + +fn emit_desktop_image_drop_event( + window: &WebviewWindow, + paths: &[PathBuf], + position: (i32, i32), +) -> tauri::Result<()> { + let Some(path) = paths + .iter() + .find(|path| path.is_file() && import_image_mime_type(path).is_some()) + .cloned() + else { + return Ok(()); + }; + let Ok(payload) = import_image_file_payload(path, "dropped", Some(position)) else { + return Ok(()); + }; + let script = + host_bridge_event_script("file.imageDropped", payload).map_err(tauri::Error::Json)?; + + window.eval(script) +} + +pub(crate) fn register_desktop_file_drop_events(window: &WebviewWindow) { + let drop_window = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, position }) = event { + let drop_position = (position.x.round() as i32, position.y.round() as i32); + let _ = emit_desktop_image_drop_event(&drop_window, paths, drop_position); + } + }); +} diff --git a/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs new file mode 100644 index 000000000..082203b93 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs @@ -0,0 +1,69 @@ +use crate::shell::events::host_bridge_event_script; +use crate::shell::network::register_desktop_network_events; +use serde_json::json; +use tauri::webview::PageLoadEvent; +use tauri::{WebviewWindow, WindowEvent}; + +pub(crate) fn emit_desktop_lifecycle_event( + window: &WebviewWindow, + state: &'static str, + focused: bool, + native_state: &'static str, +) -> tauri::Result<()> { + let script = host_bridge_event_script( + "app.lifecycle", + json!({ + "state": state, + "focused": focused, + "nativeState": native_state, + }), + ) + .map_err(tauri::Error::Json)?; + + window.eval(script) +} + +pub(crate) fn register_desktop_lifecycle_events(window: &WebviewWindow) { + let lifecycle_window = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::Focused(focused) = event { + let (state, native_state) = if *focused { + ("active", "focused") + } else { + ("inactive", "blurred") + }; + let _ = emit_desktop_lifecycle_event(&lifecycle_window, state, *focused, native_state); + } + }); +} + +pub(crate) fn should_replay_desktop_webview_state_on_page_load(event: PageLoadEvent) -> bool { + event == PageLoadEvent::Finished +} + +pub(crate) fn replay_desktop_webview_state(window: &WebviewWindow) { + let focused = window.is_focused().unwrap_or(true); + let (state, native_state) = if focused { + ("active", "focused") + } else { + ("inactive", "blurred") + }; + + let _ = emit_desktop_lifecycle_event(window, state, focused, native_state); + let _ = register_desktop_network_events(window); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_page_load_replays_state_only_after_finished_load() { + assert!(should_replay_desktop_webview_state_on_page_load( + PageLoadEvent::Finished + )); + assert!(!should_replay_desktop_webview_state_on_page_load( + PageLoadEvent::Started + )); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/mod.rs b/apps/desktop-shell/src-tauri/src/shell/mod.rs index bbd8f9a44..4020b2cd2 100644 --- a/apps/desktop-shell/src-tauri/src/shell/mod.rs +++ b/apps/desktop-shell/src-tauri/src/shell/mod.rs @@ -1,3 +1,10 @@ pub(crate) mod deep_link; +mod events; +mod file_drop; +mod lifecycle; +mod navigation; +mod network; +mod runtime; pub(crate) mod tray; +mod url; pub(crate) mod webview; diff --git a/apps/desktop-shell/src-tauri/src/shell/navigation.rs b/apps/desktop-shell/src-tauri/src/shell/navigation.rs new file mode 100644 index 000000000..2b532ed8e --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/navigation.rs @@ -0,0 +1,216 @@ +use crate::shell::url::WEB_APP_ORIGIN; +use tauri::webview::DownloadEvent; +use tauri::Url; +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 { + 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 is_desktop_packaged_asset_url(url: &Url) -> bool { + if url.scheme() == "tauri" { + return true; + } + + url.scheme() == "https" + && url + .host_str() + .map(|host| host.ends_with(".localhost")) + .unwrap_or(false) +} + +pub(crate) fn should_allow_desktop_webview_navigation(url: &Url) -> bool { + if is_desktop_packaged_asset_url(url) { + return true; + } + + if url.scheme() == "https" { + let base_url = Url::parse(WEB_APP_ORIGIN).ok(); + return base_url + .map(|base_url| url.origin() == base_url.origin()) + .unwrap_or(false); + } + + false +} + +pub(crate) fn desktop_external_navigation_url(url: &Url) -> Option { + if should_allow_desktop_webview_navigation(url) { + return None; + } + + normalize_external_url(url.as_str()) +} + +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>); +} + +pub(crate) fn should_allow_desktop_webview_download(event: &DownloadEvent<'_>) -> bool { + match event { + DownloadEvent::Requested { .. } => false, + DownloadEvent::Finished { .. } => true, + _ => false, + } +} + +pub(crate) fn normalize_native_page_url(raw_url: &str) -> Option { + let url = raw_url.trim(); + if url.is_empty() || url.chars().any(char::is_control) { + return None; + } + + let base_url = Url::parse(WEB_APP_ORIGIN).ok()?; + let normalized_url = base_url.join(url).ok()?; + if normalized_url.scheme() != "https" || normalized_url.origin() != base_url.origin() { + return None; + } + + Some(normalized_url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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 desktop_webview_navigation_stays_on_packaged_or_same_origin_pages() { + let packaged_url = Url::parse("tauri://localhost/index.html").expect("packaged url"); + assert!(should_allow_desktop_webview_navigation(&packaged_url)); + assert_eq!(desktop_external_navigation_url(&packaged_url), None); + + let windows_packaged_url = + Url::parse("https://tauri.localhost/index.html").expect("windows packaged url"); + assert!(should_allow_desktop_webview_navigation( + &windows_packaged_url + )); + assert_eq!(desktop_external_navigation_url(&windows_packaged_url), None); + + let same_origin_url = Url::parse("https://app.genarrative.world/works/detail?work=PZ-1") + .expect("same-origin url"); + assert!(should_allow_desktop_webview_navigation(&same_origin_url)); + assert_eq!(desktop_external_navigation_url(&same_origin_url), None); + } + + #[test] + fn desktop_webview_navigation_sends_external_urls_to_system_only() { + let external_url = Url::parse("https://example.com/path").expect("external url"); + assert!(!should_allow_desktop_webview_navigation(&external_url)); + assert_eq!( + desktop_external_navigation_url(&external_url), + Some("https://example.com/path".to_string()) + ); + + let mail_url = Url::parse("mailto:hi@example.com").expect("mail url"); + assert!(!should_allow_desktop_webview_navigation(&mail_url)); + assert_eq!( + desktop_external_navigation_url(&mail_url), + Some("mailto:hi@example.com".to_string()) + ); + + let unsafe_url = Url::parse("javascript:alert(1)").expect("unsafe url"); + assert!(!should_allow_desktop_webview_navigation(&unsafe_url)); + assert_eq!(desktop_external_navigation_url(&unsafe_url), None); + + let file_url = Url::parse("file:///etc/passwd").expect("file url"); + assert!(!should_allow_desktop_webview_navigation(&file_url)); + assert_eq!(desktop_external_navigation_url(&file_url), None); + } + + #[test] + fn desktop_webview_downloads_are_blocked_by_default() { + let mut destination = std::env::temp_dir().join("genarrative-webview-download.txt"); + let url = Url::parse("https://app.genarrative.world/download.txt").expect("download url"); + let requested = DownloadEvent::Requested { + url: url.clone(), + destination: &mut destination, + }; + + assert!(!should_allow_desktop_webview_download(&requested)); + + let finished = DownloadEvent::Finished { + url, + path: None, + success: false, + }; + + assert!(should_allow_desktop_webview_download(&finished)); + } + + #[test] + fn native_page_url_normalization_allows_same_origin_routes() { + assert_eq!( + normalize_native_page_url("/works/detail?work=PZ-1") + .expect("same-origin route") + .as_str(), + "https://app.genarrative.world/works/detail?work=PZ-1" + ); + assert_eq!( + normalize_native_page_url("works/detail?work=PZ-1") + .expect("relative route") + .as_str(), + "https://app.genarrative.world/works/detail?work=PZ-1" + ); + assert_eq!( + normalize_native_page_url("https://app.genarrative.world/works/detail?work=PZ-1") + .expect("absolute same-origin route") + .as_str(), + "https://app.genarrative.world/works/detail?work=PZ-1" + ); + } + + #[test] + fn native_page_url_normalization_rejects_unsafe_routes() { + assert_eq!(normalize_native_page_url("https://example.com/works"), None); + assert_eq!(normalize_native_page_url("//example.com/works"), None); + assert_eq!(normalize_native_page_url("javascript:alert(1)"), None); + assert_eq!( + normalize_native_page_url("https://app.genarrative.world/\nnext"), + None + ); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/network.rs b/apps/desktop-shell/src-tauri/src/shell/network.rs new file mode 100644 index 000000000..fe89fdd6a --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/network.rs @@ -0,0 +1,113 @@ +use crate::shell::events::host_bridge_event_script; +use serde_json::{json, Value}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; +use tauri::WebviewWindow; + +const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200; + +pub(crate) fn desktop_network_status_payload(is_online: bool) -> Value { + json!({ + "isConnected": is_online, + "isInternetReachable": is_online, + "connectionType": if is_online { "unknown" } else { "none" }, + "nativeType": if is_online { "online" } else { "offline" }, + }) +} + +pub(crate) fn resolve_desktop_network_status() -> Value { + let timeout = Duration::from_millis(DESKTOP_NETWORK_CHECK_TIMEOUT_MS); + let is_reachable = ("app.genarrative.world", 443) + .to_socket_addrs() + .map(|addresses| { + addresses.into_iter().any(|address| { + TcpStream::connect_timeout(&address, timeout) + .map(|stream| { + drop(stream); + true + }) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + desktop_network_status_payload(is_reachable) +} + +pub(crate) fn register_desktop_network_events(window: &WebviewWindow) -> tauri::Result<()> { + let online_script = host_bridge_event_script( + "network.statusChanged", + desktop_network_status_payload(true), + ) + .map_err(tauri::Error::Json)?; + let offline_script = host_bridge_event_script( + "network.statusChanged", + desktop_network_status_payload(false), + ) + .map_err(tauri::Error::Json)?; + let current_status_script = host_bridge_event_script( + "network.statusChanged", + json!({ + "isConnected": "__GENARRATIVE_DESKTOP_ONLINE__", + "isInternetReachable": "__GENARRATIVE_DESKTOP_ONLINE__", + "connectionType": "__GENARRATIVE_DESKTOP_CONNECTION_TYPE__", + "nativeType": "__GENARRATIVE_DESKTOP_NATIVE_TYPE__", + }), + ) + .map_err(tauri::Error::Json)? + .replace("\"__GENARRATIVE_DESKTOP_ONLINE__\"", "navigator.onLine") + .replace( + "\"__GENARRATIVE_DESKTOP_CONNECTION_TYPE__\"", + "(navigator.onLine ? 'unknown' : 'none')", + ) + .replace( + "\"__GENARRATIVE_DESKTOP_NATIVE_TYPE__\"", + "(navigator.onLine ? 'online' : 'offline')", + ); + + let script = format!( + "(() => {{ + if (window.__GENARRATIVE_DESKTOP_NETWORK_LISTENER_INSTALLED__) {{ + return true; + }} + window.__GENARRATIVE_DESKTOP_NETWORK_LISTENER_INSTALLED__ = true; + const emitOnline = () => {{ {} }}; + const emitOffline = () => {{ {} }}; + window.addEventListener('online', emitOnline); + window.addEventListener('offline', emitOffline); + {} + return true; + }})();", + online_script, offline_script, current_status_script + ); + + window.eval(script) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn desktop_network_status_payload_reports_reachability() { + assert_eq!( + desktop_network_status_payload(true), + json!({ + "isConnected": true, + "isInternetReachable": true, + "connectionType": "unknown", + "nativeType": "online", + }) + ); + assert_eq!( + desktop_network_status_payload(false), + json!({ + "isConnected": false, + "isInternetReachable": false, + "connectionType": "none", + "nativeType": "offline", + }) + ); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/runtime.rs b/apps/desktop-shell/src-tauri/src/shell/runtime.rs new file mode 100644 index 000000000..70522b8ab --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/runtime.rs @@ -0,0 +1,32 @@ +use tauri::Theme; + +pub(crate) fn desktop_platform() -> &'static str { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "linux") { + "linux" + } else { + "unknown" + } +} + +pub(crate) fn color_scheme_from_theme(theme: Theme) -> &'static str { + match theme { + Theme::Light => "light", + Theme::Dark => "dark", + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_scheme_maps_window_theme() { + assert_eq!(color_scheme_from_theme(Theme::Light), "light"); + assert_eq!(color_scheme_from_theme(Theme::Dark), "dark"); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/url.rs b/apps/desktop-shell/src-tauri/src/shell/url.rs new file mode 100644 index 000000000..7e08b5975 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/url.rs @@ -0,0 +1,108 @@ +use crate::shell::runtime::desktop_platform; +use std::path::PathBuf; +use tauri::{Url, WebviewUrl}; + +pub(crate) const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; + +pub(crate) fn desktop_entry_url_with_platform(raw_url: &str) -> String { + let platform = desktop_platform(); + if let Ok(mut url) = Url::parse(raw_url) { + let query_pairs = url + .query_pairs() + .filter(|(key, _)| key != "hostPlatform") + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + url.query_pairs_mut() + .clear() + .extend_pairs( + query_pairs + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())), + ) + .append_pair("hostPlatform", platform); + return url.to_string(); + } + + let (without_hash, hash) = raw_url + .split_once('#') + .map(|(path, hash)| (path, Some(hash))) + .unwrap_or((raw_url, None)); + let mut parts = without_hash.splitn(2, '?'); + let path = parts.next().unwrap_or_default(); + let query = parts.next(); + let mut pairs = query + .map(|query| { + query + .split('&') + .filter(|pair| { + !pair.is_empty() + && !pair + .split_once('=') + .map(|(key, _)| key == "hostPlatform") + .unwrap_or(false) + }) + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + pairs.push(format!("hostPlatform={platform}")); + let normalized_url = format!("{path}?{}", pairs.join("&")); + if let Some(hash) = hash { + format!("{normalized_url}#{hash}") + } else { + normalized_url + } +} + +pub(crate) fn desktop_window_config_with_runtime_platform( + mut config: tauri::utils::config::WindowConfig, +) -> tauri::utils::config::WindowConfig { + config.url = match config.url { + WebviewUrl::External(url) => WebviewUrl::External( + Url::parse(&desktop_entry_url_with_platform(url.as_str())).unwrap_or(url), + ), + WebviewUrl::CustomProtocol(url) => WebviewUrl::CustomProtocol( + Url::parse(&desktop_entry_url_with_platform(url.as_str())).unwrap_or(url), + ), + WebviewUrl::App(path) => WebviewUrl::App(PathBuf::from(desktop_entry_url_with_platform( + path.to_string_lossy().as_ref(), + ))), + other => other, + }; + config +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_entry_url_replaces_static_platform_marker() { + let platform = desktop_platform(); + + let dev_url = desktop_entry_url_with_platform( + "http://127.0.0.1:3000/?clientRuntime=native_app&hostPlatform=unknown&bridgeVersion=1", + ); + let dev_url = Url::parse(&dev_url).expect("dev url"); + + assert_eq!( + dev_url.query_pairs().find(|(key, _)| key == "hostPlatform"), + Some(("hostPlatform".into(), platform.into())) + ); + assert_eq!( + dev_url + .query_pairs() + .filter(|(key, _)| key == "hostPlatform") + .count(), + 1 + ); + + let packaged_url = desktop_entry_url_with_platform( + "index.html?clientRuntime=native_app&hostPlatform=unknown&bridgeVersion=1#works", + ); + + assert!(packaged_url.contains(&format!("hostPlatform={platform}"))); + assert!(!packaged_url.contains("hostPlatform=unknown")); + assert!(packaged_url.ends_with("#works")); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/webview.rs b/apps/desktop-shell/src-tauri/src/shell/webview.rs index da66781ea..ceacc786c 100644 --- a/apps/desktop-shell/src-tauri/src/shell/webview.rs +++ b/apps/desktop-shell/src-tauri/src/shell/webview.rs @@ -1,578 +1,14 @@ -use crate::host_bridge::files::{import_image_file_payload, import_image_mime_type}; -use crate::host_bridge::protocol::{HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION}; -use serde_json::{json, Value}; -use std::net::{TcpStream, ToSocketAddrs}; -use std::path::PathBuf; -use std::time::Duration; -use tauri::webview::{DownloadEvent, PageLoadEvent}; -use tauri::{DragDropEvent, Theme, Url, WebviewUrl, WebviewWindow, WindowEvent}; -use tauri_plugin_opener::OpenerExt; - -pub(crate) const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; -const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; -const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200; - -pub(crate) fn desktop_platform() -> &'static str { - if cfg!(target_os = "macos") { - "macos" - } else if cfg!(target_os = "windows") { - "windows" - } else if cfg!(target_os = "linux") { - "linux" - } else { - "unknown" - } -} - -pub(crate) fn desktop_entry_url_with_platform(raw_url: &str) -> String { - let platform = desktop_platform(); - if let Ok(mut url) = Url::parse(raw_url) { - let query_pairs = url - .query_pairs() - .filter(|(key, _)| key != "hostPlatform") - .map(|(key, value)| (key.into_owned(), value.into_owned())) - .collect::>(); - url.query_pairs_mut() - .clear() - .extend_pairs( - query_pairs - .iter() - .map(|(key, value)| (key.as_str(), value.as_str())), - ) - .append_pair("hostPlatform", platform); - return url.to_string(); - } - - let (without_hash, hash) = raw_url - .split_once('#') - .map(|(path, hash)| (path, Some(hash))) - .unwrap_or((raw_url, None)); - let mut parts = without_hash.splitn(2, '?'); - let path = parts.next().unwrap_or_default(); - let query = parts.next(); - let mut pairs = query - .map(|query| { - query - .split('&') - .filter(|pair| { - !pair.is_empty() - && !pair - .split_once('=') - .map(|(key, _)| key == "hostPlatform") - .unwrap_or(false) - }) - .map(str::to_owned) - .collect::>() - }) - .unwrap_or_default(); - pairs.push(format!("hostPlatform={platform}")); - let normalized_url = format!("{path}?{}", pairs.join("&")); - if let Some(hash) = hash { - format!("{normalized_url}#{hash}") - } else { - normalized_url - } -} - -pub(crate) fn desktop_window_config_with_runtime_platform( - mut config: tauri::utils::config::WindowConfig, -) -> tauri::utils::config::WindowConfig { - config.url = match config.url { - WebviewUrl::External(url) => WebviewUrl::External( - Url::parse(&desktop_entry_url_with_platform(url.as_str())).unwrap_or(url), - ), - WebviewUrl::CustomProtocol(url) => WebviewUrl::CustomProtocol( - Url::parse(&desktop_entry_url_with_platform(url.as_str())).unwrap_or(url), - ), - WebviewUrl::App(path) => WebviewUrl::App(PathBuf::from(desktop_entry_url_with_platform( - path.to_string_lossy().as_ref(), - ))), - other => other, - }; - config -} - -pub(crate) fn color_scheme_from_theme(theme: Theme) -> &'static str { - match theme { - Theme::Light => "light", - Theme::Dark => "dark", - _ => "unknown", - } -} - -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 { - 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 is_desktop_packaged_asset_url(url: &Url) -> bool { - if url.scheme() == "tauri" { - return true; - } - - url.scheme() == "https" - && url - .host_str() - .map(|host| host.ends_with(".localhost")) - .unwrap_or(false) -} - -pub(crate) fn should_allow_desktop_webview_navigation(url: &Url) -> bool { - if is_desktop_packaged_asset_url(url) { - return true; - } - - if url.scheme() == "https" { - let base_url = Url::parse(WEB_APP_ORIGIN).ok(); - return base_url - .map(|base_url| url.origin() == base_url.origin()) - .unwrap_or(false); - } - - false -} - -pub(crate) fn desktop_external_navigation_url(url: &Url) -> Option { - if should_allow_desktop_webview_navigation(url) { - return None; - } - - normalize_external_url(url.as_str()) -} - -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>); -} - -pub(crate) fn should_allow_desktop_webview_download(event: &DownloadEvent<'_>) -> bool { - match event { - DownloadEvent::Requested { .. } => false, - DownloadEvent::Finished { .. } => true, - _ => false, - } -} - -pub(crate) fn normalize_native_page_url(raw_url: &str) -> Option { - let url = raw_url.trim(); - if url.is_empty() || url.chars().any(char::is_control) { - return None; - } - - let base_url = Url::parse(WEB_APP_ORIGIN).ok()?; - let normalized_url = base_url.join(url).ok()?; - if normalized_url.scheme() != "https" || normalized_url.origin() != base_url.origin() { - return None; - } - - Some(normalized_url) -} - -pub(crate) fn host_bridge_event_script( - event: &str, - payload: Value, -) -> Result { - let message = json!({ - "bridge": HOST_BRIDGE_PROTOCOL, - "version": HOST_BRIDGE_VERSION, - "event": event, - "payload": payload, - }); - let data = serde_json::to_string(&message)?; - let data_literal = serde_json::to_string(&data)?; - - Ok(format!( - "window.dispatchEvent(new MessageEvent('message', {{ data: {}, origin: window.location.origin, source: window }})); true;", - data_literal - )) -} - -pub(crate) fn emit_desktop_lifecycle_event( - window: &WebviewWindow, - state: &'static str, - focused: bool, - native_state: &'static str, -) -> tauri::Result<()> { - let script = host_bridge_event_script( - "app.lifecycle", - json!({ - "state": state, - "focused": focused, - "nativeState": native_state, - }), - ) - .map_err(tauri::Error::Json)?; - - window.eval(script) -} - -pub(crate) fn desktop_network_status_payload(is_online: bool) -> Value { - json!({ - "isConnected": is_online, - "isInternetReachable": is_online, - "connectionType": if is_online { "unknown" } else { "none" }, - "nativeType": if is_online { "online" } else { "offline" }, - }) -} - -pub(crate) fn resolve_desktop_network_status() -> Value { - let timeout = Duration::from_millis(DESKTOP_NETWORK_CHECK_TIMEOUT_MS); - let is_reachable = ("app.genarrative.world", 443) - .to_socket_addrs() - .map(|addresses| { - addresses.into_iter().any(|address| { - TcpStream::connect_timeout(&address, timeout) - .map(|stream| { - drop(stream); - true - }) - .unwrap_or(false) - }) - }) - .unwrap_or(false); - - desktop_network_status_payload(is_reachable) -} - -pub(crate) fn register_desktop_lifecycle_events(window: &WebviewWindow) { - let lifecycle_window = window.clone(); - window.on_window_event(move |event| { - if let WindowEvent::Focused(focused) = event { - let (state, native_state) = if *focused { - ("active", "focused") - } else { - ("inactive", "blurred") - }; - let _ = emit_desktop_lifecycle_event(&lifecycle_window, state, *focused, native_state); - } - }); -} - -fn emit_desktop_image_drop_event( - window: &WebviewWindow, - paths: &[PathBuf], - position: (i32, i32), -) -> tauri::Result<()> { - let Some(path) = paths - .iter() - .find(|path| path.is_file() && import_image_mime_type(path).is_some()) - .cloned() - else { - return Ok(()); - }; - let Ok(payload) = import_image_file_payload(path, "dropped", Some(position)) else { - return Ok(()); - }; - let script = - host_bridge_event_script("file.imageDropped", payload).map_err(tauri::Error::Json)?; - - window.eval(script) -} - -pub(crate) fn register_desktop_file_drop_events(window: &WebviewWindow) { - let drop_window = window.clone(); - window.on_window_event(move |event| { - if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, position }) = event { - let drop_position = (position.x.round() as i32, position.y.round() as i32); - let _ = emit_desktop_image_drop_event(&drop_window, paths, drop_position); - } - }); -} - -pub(crate) fn register_desktop_network_events(window: &WebviewWindow) -> tauri::Result<()> { - let online_script = host_bridge_event_script( - "network.statusChanged", - desktop_network_status_payload(true), - ) - .map_err(tauri::Error::Json)?; - let offline_script = host_bridge_event_script( - "network.statusChanged", - desktop_network_status_payload(false), - ) - .map_err(tauri::Error::Json)?; - let current_status_script = host_bridge_event_script( - "network.statusChanged", - json!({ - "isConnected": "__GENARRATIVE_DESKTOP_ONLINE__", - "isInternetReachable": "__GENARRATIVE_DESKTOP_ONLINE__", - "connectionType": "__GENARRATIVE_DESKTOP_CONNECTION_TYPE__", - "nativeType": "__GENARRATIVE_DESKTOP_NATIVE_TYPE__", - }), - ) - .map_err(tauri::Error::Json)? - .replace("\"__GENARRATIVE_DESKTOP_ONLINE__\"", "navigator.onLine") - .replace( - "\"__GENARRATIVE_DESKTOP_CONNECTION_TYPE__\"", - "(navigator.onLine ? 'unknown' : 'none')", - ) - .replace( - "\"__GENARRATIVE_DESKTOP_NATIVE_TYPE__\"", - "(navigator.onLine ? 'online' : 'offline')", - ); - - let script = format!( - "(() => {{ - if (window.__GENARRATIVE_DESKTOP_NETWORK_LISTENER_INSTALLED__) {{ - return true; - }} - window.__GENARRATIVE_DESKTOP_NETWORK_LISTENER_INSTALLED__ = true; - const emitOnline = () => {{ {} }}; - const emitOffline = () => {{ {} }}; - window.addEventListener('online', emitOnline); - window.addEventListener('offline', emitOffline); - {} - return true; - }})();", - online_script, offline_script, current_status_script - ); - - window.eval(script) -} - -pub(crate) fn should_replay_desktop_webview_state_on_page_load(event: PageLoadEvent) -> bool { - event == PageLoadEvent::Finished -} - -pub(crate) fn replay_desktop_webview_state(window: &WebviewWindow) { - let focused = window.is_focused().unwrap_or(true); - let (state, native_state) = if focused { - ("active", "focused") - } else { - ("inactive", "blurred") - }; - - let _ = emit_desktop_lifecycle_event(window, state, focused, native_state); - let _ = register_desktop_network_events(window); -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn desktop_entry_url_replaces_static_platform_marker() { - let platform = desktop_platform(); - - let dev_url = desktop_entry_url_with_platform( - "http://127.0.0.1:3000/?clientRuntime=native_app&hostPlatform=unknown&bridgeVersion=1", - ); - let dev_url = Url::parse(&dev_url).expect("dev url"); - - assert_eq!( - dev_url.query_pairs().find(|(key, _)| key == "hostPlatform"), - Some(("hostPlatform".into(), platform.into())) - ); - assert_eq!( - dev_url - .query_pairs() - .filter(|(key, _)| key == "hostPlatform") - .count(), - 1 - ); - - let packaged_url = desktop_entry_url_with_platform( - "index.html?clientRuntime=native_app&hostPlatform=unknown&bridgeVersion=1#works", - ); - - assert!(packaged_url.contains(&format!("hostPlatform={platform}"))); - assert!(!packaged_url.contains("hostPlatform=unknown")); - assert!(packaged_url.ends_with("#works")); - } - - #[test] - fn color_scheme_maps_window_theme() { - assert_eq!(color_scheme_from_theme(Theme::Light), "light"); - assert_eq!(color_scheme_from_theme(Theme::Dark), "dark"); - } - - #[test] - fn host_bridge_event_script_dispatches_lifecycle_message() { - let script = host_bridge_event_script( - "app.lifecycle", - json!({ - "state": "active", - "focused": true, - "nativeState": "focused", - }), - ) - .expect("event script"); - - assert!(script.contains("MessageEvent('message'")); - assert!(script.contains("origin: window.location.origin")); - assert!(script.contains("source: window")); - assert!(script.contains("GenarrativeHostBridge")); - assert!(script.contains("app.lifecycle")); - assert!(script.contains("\\\"state\\\":\\\"active\\\"")); - assert!(script.contains("\\\"focused\\\":true")); - } - - #[test] - fn desktop_page_load_replays_state_only_after_finished_load() { - assert!(should_replay_desktop_webview_state_on_page_load( - PageLoadEvent::Finished - )); - assert!(!should_replay_desktop_webview_state_on_page_load( - PageLoadEvent::Started - )); - } - - #[test] - fn desktop_network_status_payload_reports_reachability() { - assert_eq!( - desktop_network_status_payload(true), - json!({ - "isConnected": true, - "isInternetReachable": true, - "connectionType": "unknown", - "nativeType": "online", - }) - ); - assert_eq!( - desktop_network_status_payload(false), - json!({ - "isConnected": false, - "isInternetReachable": false, - "connectionType": "none", - "nativeType": "offline", - }) - ); - } - - #[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 desktop_webview_navigation_stays_on_packaged_or_same_origin_pages() { - let packaged_url = Url::parse("tauri://localhost/index.html").expect("packaged url"); - assert!(should_allow_desktop_webview_navigation(&packaged_url)); - assert_eq!(desktop_external_navigation_url(&packaged_url), None); - - let windows_packaged_url = - Url::parse("https://tauri.localhost/index.html").expect("windows packaged url"); - assert!(should_allow_desktop_webview_navigation( - &windows_packaged_url - )); - assert_eq!(desktop_external_navigation_url(&windows_packaged_url), None); - - let same_origin_url = Url::parse("https://app.genarrative.world/works/detail?work=PZ-1") - .expect("same-origin url"); - assert!(should_allow_desktop_webview_navigation(&same_origin_url)); - assert_eq!(desktop_external_navigation_url(&same_origin_url), None); - } - - #[test] - fn desktop_webview_navigation_sends_external_urls_to_system_only() { - let external_url = Url::parse("https://example.com/path").expect("external url"); - assert!(!should_allow_desktop_webview_navigation(&external_url)); - assert_eq!( - desktop_external_navigation_url(&external_url), - Some("https://example.com/path".to_string()) - ); - - let mail_url = Url::parse("mailto:hi@example.com").expect("mail url"); - assert!(!should_allow_desktop_webview_navigation(&mail_url)); - assert_eq!( - desktop_external_navigation_url(&mail_url), - Some("mailto:hi@example.com".to_string()) - ); - - let unsafe_url = Url::parse("javascript:alert(1)").expect("unsafe url"); - assert!(!should_allow_desktop_webview_navigation(&unsafe_url)); - assert_eq!(desktop_external_navigation_url(&unsafe_url), None); - - let file_url = Url::parse("file:///etc/passwd").expect("file url"); - assert!(!should_allow_desktop_webview_navigation(&file_url)); - assert_eq!(desktop_external_navigation_url(&file_url), None); - } - - #[test] - fn desktop_webview_downloads_are_blocked_by_default() { - let mut destination = std::env::temp_dir().join("genarrative-webview-download.txt"); - let url = Url::parse("https://app.genarrative.world/download.txt").expect("download url"); - let requested = DownloadEvent::Requested { - url: url.clone(), - destination: &mut destination, - }; - - assert!(!should_allow_desktop_webview_download(&requested)); - - let finished = DownloadEvent::Finished { - url, - path: None, - success: false, - }; - - assert!(should_allow_desktop_webview_download(&finished)); - } - - #[test] - fn native_page_url_normalization_allows_same_origin_routes() { - assert_eq!( - normalize_native_page_url("/works/detail?work=PZ-1") - .expect("same-origin route") - .as_str(), - "https://app.genarrative.world/works/detail?work=PZ-1" - ); - assert_eq!( - normalize_native_page_url("works/detail?work=PZ-1") - .expect("relative route") - .as_str(), - "https://app.genarrative.world/works/detail?work=PZ-1" - ); - assert_eq!( - normalize_native_page_url("https://app.genarrative.world/works/detail?work=PZ-1") - .expect("absolute same-origin route") - .as_str(), - "https://app.genarrative.world/works/detail?work=PZ-1" - ); - } - - #[test] - fn native_page_url_normalization_rejects_unsafe_routes() { - assert_eq!(normalize_native_page_url("https://example.com/works"), None); - assert_eq!(normalize_native_page_url("//example.com/works"), None); - assert_eq!(normalize_native_page_url("javascript:alert(1)"), None); - assert_eq!( - normalize_native_page_url("https://app.genarrative.world/\nnext"), - None - ); - } -} +pub(crate) use crate::shell::file_drop::register_desktop_file_drop_events; +pub(crate) use crate::shell::lifecycle::{ + emit_desktop_lifecycle_event, register_desktop_lifecycle_events, replay_desktop_webview_state, + should_replay_desktop_webview_state_on_page_load, +}; +pub(crate) use crate::shell::navigation::{ + normalize_external_url, normalize_native_page_url, open_desktop_external_navigation, + should_allow_desktop_webview_download, should_allow_desktop_webview_navigation, +}; +pub(crate) use crate::shell::network::{ + register_desktop_network_events, resolve_desktop_network_status, +}; +pub(crate) use crate::shell::runtime::{color_scheme_from_theme, desktop_platform}; +pub(crate) use crate::shell::url::{desktop_window_config_with_runtime_platform, WEB_APP_ORIGIN}; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f82264b51..350a23e83 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -86,7 +86,7 @@ - 2026-06-18 移动壳 WebView 安全开关:Expo 移动壳 WebView 必须显式禁用 JS 自动开窗、多窗口、文件访问、file URL 跨源访问、HTTPS 混合内容、第三方 Cookie、共享 Cookie 和 WebView 远程调试;同源主站页面才能留在带 HostBridge 的 WebView 内,外链只通过受控协议离开容器交给系统。配置检查和移动壳导航测试会拒绝这些边界被放宽。 - 2026-06-18 移动壳 WebView 默认下载边界:Expo WebView 内网页自动下载和 `` 直接落盘默认关闭;壳层注入脚本阻断 download 链接,iOS `onFileDownload` 只丢弃不落盘,Android 包配置通过 `blockedPermissions` 移除外部存储读写、管理外部存储和请求安装包权限。移动端文本、图片、音频保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等 HostBridge 受控导出能力进入系统分享 / 保存面板。 - 2026-06-18 移动壳 HostBridge 消息来源校验:Expo 移动壳 `onMessage` 必须根据 `event.nativeEvent.url` 校验消息来源,只有同源主站页面能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域、协议降级和危险协议页面消息直接丢弃,不返回宿主能力错误细节。该规则与 WebView 导航留壳规则共用同源判断,配置检查和移动壳导航测试会拒绝移除。 -- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/` 下同名职责文件承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。Expo `protocol.ts`、`files.ts`、`share.ts` 和 facade `bridge.ts` 分别对齐 Tauri `host_bridge/protocol.rs`、`files.rs`、`share.rs`、`mod.rs`,根 `App.tsx` 只装配 `src/shell/ShellApp.tsx`,不直接进口 HostBridge。Tauri `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。 +- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/` 下同名职责文件承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。Expo `protocol.ts`、`files.ts`、`share.ts` 和 facade `bridge.ts` 分别对齐 Tauri `host_bridge/protocol.rs`、`files.rs`、`share.rs`、`mod.rs`,根 `App.tsx` 只装配 `src/shell/ShellApp.tsx`,不直接进口 HostBridge。Tauri `shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。 - 影响范围:`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`。 @@ -2475,6 +2475,6 @@ ## 2026-06-18 三端宿主桥接层文件结构对齐 - 背景:微信小程序壳、Expo 移动壳和 Tauri 桌面壳都在承接宿主能力;如果微信页面继续散落 `index.shared.js`,桌面端继续把桥接分发堆在 `main.rs`,后续新增登录、支付、文件、通知或 sandbox 转发能力时会很难跨端对照 owner。 -- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts`、`files.ts`、`share.ts` 和 facade `bridge.ts`,分别负责协议 / 能力清单 / request 校验 / replay 基础、文件能力、分享能力和 method 分发,根 `App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,`apps/mobile-shell/src/shell/*.ts(x)` 负责 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 deep link、tray、webview 分文件承接容器行为,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单,并拒绝移动根入口直接引用 HostBridge。 +- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts`、`files.ts`、`share.ts` 和 facade `bridge.ts`,分别负责协议 / 能力清单 / request 校验 / replay 基础、文件能力、分享能力和 method 分发,根 `App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,`apps/mobile-shell/src/shell/*.ts(x)` 负责 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `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` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单,并拒绝移动根入口直接引用 HostBridge。 - 影响范围:`miniprogram/host-bridge/`、`miniprogram/pages/*/index.js`、`apps/mobile-shell/src/`、`apps/desktop-shell/src-tauri/src/`、`scripts/check-native-shells.mjs`、宿主壳方案文档。 - 验证方式:`npm run test -- miniprogram/host-bridge/webView.test.js miniprogram/host-bridge/payment.test.js miniprogram/host-bridge/shareGrid.test.js miniprogram/host-bridge/subscribeMessage.test.js miniprogram/pages/web-view/index.style.test.js`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 53c70fd60..8d5990121 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -64,7 +64,7 @@ src/ 已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。 -三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,协议归一、支付 / 订阅 / 分享结果编解码统一放在 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。 +三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,协议归一、支付 / 订阅 / 分享结果编解码统一放在 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。 ## HostBridge 消息协议 @@ -415,7 +415,7 @@ GameBridge 禁止: 2026-06-18 追加:移动壳 HostBridge 消息入口增加来源校验。`onMessage` 不只依赖导航拦截和 `originWhitelist`,还会读取 `event.nativeEvent.url`,只有同源主站页面才能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域 URL、协议降级或危险协议页面发来的消息全部丢弃,不返回 HostBridge 错误细节。该校验与 `navigation.openNativePage` 共用同源规则,防止历史中间页或异常页面在带完整 HostBridge 的 WebView 中发起宿主能力请求。 -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`,其中 `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `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` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。 ### Phase 4:宿主能力扩展 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index f8a1f1dba..04844096c 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -37,7 +37,7 @@ AI H5 sandbox -> parent HostBridge adapter ``` -桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的协议与可测试桥接函数统一放在 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,`miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。 +桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的协议与可测试桥接函数统一放在 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,`miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。 ## 首批能力 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index ffcae36bb..0b62b7d76 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -64,8 +64,15 @@ const expectedDesktopHostBridgeRustFiles = [ ]; const expectedDesktopShellRustFiles = [ 'deep_link.rs', + 'events.rs', + 'file_drop.rs', + 'lifecycle.rs', 'mod.rs', + 'navigation.rs', + 'network.rs', + 'runtime.rs', 'tray.rs', + 'url.rs', 'webview.rs', ]; const productionShellExtensions = new Set([