细分桌面壳容器职责模块

将桌面壳 WebView 职责拆分为运行态、URL、导航、网络、生命周期、拖拽和事件模块

更新三端桥接层结构门禁清单

同步宿主壳文档和共享记忆中的桌面壳模块口径
This commit is contained in:
2026-06-18 18:10:19 +08:00
parent 0210a445cf
commit ce27a0c35d
14 changed files with 661 additions and 583 deletions
@@ -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 无法直接调用 HostBridgeTauri 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 policyTauri 桌面壳拆成 `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 policyTauri 桌面壳拆成 `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 policyTauri 桌面壳使用 `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 policyTauri 桌面壳使用 `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 policyTauri 桌面壳使用 `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 policyTauri 桌面壳使用 `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` 会检查这些目录清单。
## 首批能力
+7
View File
@@ -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([