细分桌面壳容器职责模块
将桌面壳 WebView 职责拆分为运行态、URL、导航、网络、生命周期、拖拽和事件模块 更新三端桥接层结构门禁清单 同步宿主壳文档和共享记忆中的桌面壳模块口径
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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<String, serde_json::Error> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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 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<String> {
|
||||
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<Url> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 内网页自动下载和 `<a download>` 直接落盘默认关闭;壳层注入脚本阻断 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`。
|
||||
|
||||
@@ -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:宿主能力扩展
|
||||
|
||||
|
||||
@@ -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` 会检查这些目录清单。
|
||||
|
||||
## 首批能力
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user