接入桌面壳深链
新增Tauri桌面深链模块并归一到同源H5入口 启用deep-link插件与single-instance深链转发 更新原生壳门禁和架构文档
This commit is contained in:
@@ -493,6 +493,11 @@ for (const [sectionName, dependencyName, expectedLine] of [
|
||||
'tauri-plugin-dialog',
|
||||
'tauri-plugin-dialog = "2.7.1"',
|
||||
],
|
||||
[
|
||||
'dependencies',
|
||||
'tauri-plugin-deep-link',
|
||||
'tauri-plugin-deep-link = "2.4.9"',
|
||||
],
|
||||
[
|
||||
'dependencies',
|
||||
'tauri-plugin-notification',
|
||||
@@ -506,7 +511,7 @@ for (const [sectionName, dependencyName, expectedLine] of [
|
||||
[
|
||||
'dependencies',
|
||||
'tauri-plugin-single-instance',
|
||||
'tauri-plugin-single-instance = "2.4.2"',
|
||||
'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }',
|
||||
],
|
||||
]) {
|
||||
assertCargoDependencyLine(sectionName, dependencyName, expectedLine);
|
||||
@@ -519,6 +524,7 @@ for (const [packageName, expectedVersion] of [
|
||||
['serde_json', '1.0.150'],
|
||||
['tauri', '2.11.2'],
|
||||
['tauri-plugin-clipboard-manager', '2.3.2'],
|
||||
['tauri-plugin-deep-link', '2.4.9'],
|
||||
['tauri-plugin-dialog', '2.7.1'],
|
||||
['tauri-plugin-notification', '2.3.3'],
|
||||
['tauri-plugin-opener', '2.5.4'],
|
||||
@@ -534,6 +540,7 @@ for (const [dependencyName, expectedVersion] of [
|
||||
['serde_json', '1.0.150'],
|
||||
['tauri', '2.11.2'],
|
||||
['tauri-plugin-clipboard-manager', '2.3.2'],
|
||||
['tauri-plugin-deep-link', '2.4.9'],
|
||||
['tauri-plugin-dialog', '2.7.1'],
|
||||
['tauri-plugin-notification', '2.3.3'],
|
||||
['tauri-plugin-opener', '2.5.4'],
|
||||
@@ -1039,12 +1046,22 @@ const requiredRustHostModules = [
|
||||
'host_bridge/protocol.rs',
|
||||
'host_bridge/share.rs',
|
||||
'main.rs',
|
||||
'shell/deep_link.rs',
|
||||
'shell/mod.rs',
|
||||
'shell/tray.rs',
|
||||
'shell/webview.rs',
|
||||
];
|
||||
const requiredRustHostSnippets = [
|
||||
'tauri_plugin_single_instance::init',
|
||||
'tauri_plugin_deep_link::init()',
|
||||
'register_desktop_deep_link_events(app)',
|
||||
'register_desktop_deep_link_schemes(app)',
|
||||
'DeepLinkExt',
|
||||
'app.deep_link().on_open_url',
|
||||
'app.deep_link().get_current()',
|
||||
'app.deep_link().register_all()',
|
||||
'normalize_desktop_deep_link_url',
|
||||
'DESKTOP_DEEP_LINK_HOSTS',
|
||||
'resolve_desktop_single_instance_action',
|
||||
'tauri_plugin_clipboard_manager::init()',
|
||||
'TrayIconBuilder::with_id',
|
||||
@@ -1203,8 +1220,25 @@ if (cargoManifest.includes('"devtools"')) {
|
||||
throw new Error('desktop shell must not enable Tauri devtools feature');
|
||||
}
|
||||
|
||||
if (!cargoManifest.includes('tauri-plugin-single-instance = "2.4.2"')) {
|
||||
throw new Error('desktop shell must depend on tauri-plugin-single-instance');
|
||||
if (
|
||||
!cargoManifest.includes(
|
||||
'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }',
|
||||
)
|
||||
) {
|
||||
throw new Error('desktop shell must depend on tauri-plugin-single-instance with deep-link feature');
|
||||
}
|
||||
|
||||
if (!cargoManifest.includes('tauri-plugin-deep-link = "2.4.9"')) {
|
||||
throw new Error('desktop shell must depend on tauri-plugin-deep-link');
|
||||
}
|
||||
|
||||
if (
|
||||
!config.plugins ||
|
||||
!config.plugins['deep-link'] ||
|
||||
JSON.stringify(config.plugins['deep-link']?.desktop?.schemes ?? []) !==
|
||||
JSON.stringify(['genarrative'])
|
||||
) {
|
||||
throw new Error('desktop shell deep-link plugin must register only the genarrative scheme');
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
Generated
+98
@@ -520,6 +520,26 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -810,6 +830,15 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@@ -1283,6 +1312,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
@@ -1510,6 +1540,12 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -2478,6 +2514,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -3019,6 +3065,16 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -3717,6 +3773,27 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
@@ -3809,6 +3886,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -4034,6 +4112,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -4956,6 +5043,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
|
||||
@@ -13,7 +13,8 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2.11.2", features = ["tray-icon"] }
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
tauri-plugin-single-instance = "2.4.2"
|
||||
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
|
||||
|
||||
@@ -2,6 +2,7 @@ mod host_bridge;
|
||||
mod shell;
|
||||
|
||||
use host_bridge::{host_bridge_request, DesktopShareState, HostBridgeReplayState};
|
||||
use shell::deep_link::{register_desktop_deep_link_events, register_desktop_deep_link_schemes};
|
||||
use shell::tray::{
|
||||
register_desktop_tray, register_desktop_window_close_events,
|
||||
resolve_desktop_single_instance_action, show_main_window, DesktopSingleInstanceAction,
|
||||
@@ -26,6 +27,7 @@ fn main() {
|
||||
}
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
@@ -68,6 +70,8 @@ fn main() {
|
||||
let _ = emit_desktop_lifecycle_event(&window, "active", true, "created");
|
||||
let _ = register_desktop_network_events(&window);
|
||||
register_desktop_file_drop_events(&window);
|
||||
register_desktop_deep_link_events(app)?;
|
||||
register_desktop_deep_link_schemes(app);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
use crate::host_bridge::protocol::{capabilities, HOST_BRIDGE_VERSION};
|
||||
use crate::shell::tray::show_main_window;
|
||||
use crate::shell::webview::{desktop_platform, WEB_APP_ORIGIN};
|
||||
use tauri::{Manager, Url, WebviewWindow};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
const DESKTOP_DEEP_LINK_HOSTS: [&str; 2] = ["open", "app"];
|
||||
const HOST_CONTEXT_QUERY_KEYS: [&str; 7] = [
|
||||
"clientRuntime",
|
||||
"clientType",
|
||||
"hostShell",
|
||||
"hostPlatform",
|
||||
"hostVersion",
|
||||
"bridgeVersion",
|
||||
"hostCapabilities",
|
||||
];
|
||||
|
||||
fn extract_path_from_custom_scheme(url: &Url) -> String {
|
||||
let host = url.host_str().unwrap_or_default();
|
||||
if DESKTOP_DEEP_LINK_HOSTS.contains(&host) {
|
||||
return format!(
|
||||
"{}{}{}",
|
||||
url.path(),
|
||||
url.query()
|
||||
.map(|query| format!("?{query}"))
|
||||
.unwrap_or_default(),
|
||||
url.fragment()
|
||||
.map(|fragment| format!("#{fragment}"))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
format!(
|
||||
"{}{}{}{}",
|
||||
if host.is_empty() { "" } else { "/" },
|
||||
host,
|
||||
url.path(),
|
||||
url.query()
|
||||
.map(|query| format!("?{query}"))
|
||||
.unwrap_or_default(),
|
||||
) + &url
|
||||
.fragment()
|
||||
.map(|fragment| format!("#{fragment}"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_desktop_deep_link_url(raw_url: &Url) -> Option<Url> {
|
||||
let base_url = Url::parse(WEB_APP_ORIGIN).ok()?;
|
||||
let target_path = match raw_url.scheme() {
|
||||
"genarrative" => extract_path_from_custom_scheme(raw_url),
|
||||
"https" if raw_url.origin() == base_url.origin() => {
|
||||
format!(
|
||||
"{}{}{}",
|
||||
raw_url.path(),
|
||||
raw_url
|
||||
.query()
|
||||
.map(|query| format!("?{query}"))
|
||||
.unwrap_or_default(),
|
||||
raw_url
|
||||
.fragment()
|
||||
.map(|fragment| format!("#{fragment}"))
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut target_url = base_url.join(&target_path).ok()?;
|
||||
if target_url.origin() != base_url.origin() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let retained_query_pairs = target_url
|
||||
.query_pairs()
|
||||
.filter(|(key, _)| !HOST_CONTEXT_QUERY_KEYS.contains(&key.as_ref()))
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
target_url
|
||||
.query_pairs_mut()
|
||||
.clear()
|
||||
.extend_pairs(
|
||||
retained_query_pairs
|
||||
.iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str())),
|
||||
)
|
||||
.append_pair("clientRuntime", "native_app")
|
||||
.append_pair("clientType", "native_app")
|
||||
.append_pair("hostShell", "tauri_desktop")
|
||||
.append_pair("hostPlatform", desktop_platform())
|
||||
.append_pair("hostVersion", env!("CARGO_PKG_VERSION"))
|
||||
.append_pair("bridgeVersion", &HOST_BRIDGE_VERSION.to_string())
|
||||
.append_pair("hostCapabilities", &capabilities().join(","));
|
||||
if target_url.origin() != base_url.origin() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(target_url)
|
||||
}
|
||||
|
||||
fn open_desktop_deep_link_url(window: &WebviewWindow, url: &Url) {
|
||||
let Some(target_url) = normalize_desktop_deep_link_url(url) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = window.navigate(target_url);
|
||||
let _ = show_main_window(window.app_handle());
|
||||
}
|
||||
|
||||
pub(crate) fn register_desktop_deep_link_events(app: &tauri::App) -> tauri::Result<()> {
|
||||
let app_handle = app.handle().clone();
|
||||
app.deep_link().on_open_url(move |event| {
|
||||
let Some(window) = app_handle.get_webview_window("main") else {
|
||||
return;
|
||||
};
|
||||
|
||||
for url in event.urls() {
|
||||
open_desktop_deep_link_url(&window, &url);
|
||||
}
|
||||
});
|
||||
|
||||
let Some(window) = app.get_webview_window("main") else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Ok(Some(urls)) = app.deep_link().get_current() {
|
||||
for url in urls {
|
||||
open_desktop_deep_link_url(&window, &url);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn register_desktop_deep_link_schemes(app: &tauri::App) {
|
||||
let _ = app.deep_link().register_all();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn normalized(raw_url: &str) -> Url {
|
||||
normalize_desktop_deep_link_url(&Url::parse(raw_url).expect("deep link url"))
|
||||
.expect("normalized deep link url")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_scheme_open_host_maps_to_same_origin_h5_route_with_host_context() {
|
||||
let url = normalized("genarrative://open/works/detail?work=PZ-1#play");
|
||||
|
||||
assert_eq!(url.origin().ascii_serialization(), WEB_APP_ORIGIN);
|
||||
assert_eq!(url.path(), "/works/detail");
|
||||
assert_eq!(
|
||||
url.query_pairs().find(|(key, _)| key == "work").unwrap().1,
|
||||
"PZ-1"
|
||||
);
|
||||
assert_eq!(
|
||||
url.query_pairs()
|
||||
.find(|(key, _)| key == "clientRuntime")
|
||||
.unwrap()
|
||||
.1,
|
||||
"native_app"
|
||||
);
|
||||
assert_eq!(
|
||||
url.query_pairs()
|
||||
.find(|(key, _)| key == "hostShell")
|
||||
.unwrap()
|
||||
.1,
|
||||
"tauri_desktop"
|
||||
);
|
||||
assert_eq!(url.fragment(), Some("play"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_scheme_without_reserved_host_keeps_host_as_first_path_segment() {
|
||||
let url = normalized("genarrative://works/detail?work=PZ-1");
|
||||
|
||||
assert_eq!(url.path(), "/works/detail");
|
||||
assert_eq!(
|
||||
url.query_pairs().find(|(key, _)| key == "work").unwrap().1,
|
||||
"PZ-1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_origin_https_link_keeps_path_and_restores_desktop_host_context() {
|
||||
let url =
|
||||
normalized("https://app.genarrative.world/creation/puzzle?clientRuntime=browser#draft");
|
||||
|
||||
assert_eq!(url.path(), "/creation/puzzle");
|
||||
assert_eq!(
|
||||
url.query_pairs()
|
||||
.filter(|(key, _)| key == "clientRuntime")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
url.query_pairs()
|
||||
.find(|(key, _)| key == "clientType")
|
||||
.unwrap()
|
||||
.1,
|
||||
"native_app"
|
||||
);
|
||||
assert_eq!(url.fragment(), Some("draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_and_unsafe_links_are_rejected() {
|
||||
for raw_url in [
|
||||
"https://example.com/works/detail?work=PZ-1",
|
||||
"http://app.genarrative.world/works/detail?work=PZ-1",
|
||||
"mailto:hi@example.com",
|
||||
"javascript:alert(1)",
|
||||
] {
|
||||
let url = Url::parse(raw_url).expect("url");
|
||||
assert_eq!(normalize_desktop_deep_link_url(&url), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_deep_link_config_check_requires_custom_scheme() {
|
||||
let configured = serde_json::json!({
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["genarrative"]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let missing = serde_json::json!({
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["example"]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
configured
|
||||
.get("plugins")
|
||||
.and_then(|plugins| plugins.get("deep-link"))
|
||||
.and_then(|deep_link| deep_link.get("desktop"))
|
||||
.and_then(|desktop| desktop.get("schemes"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|schemes| schemes.first())
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("genarrative")
|
||||
);
|
||||
assert_ne!(
|
||||
missing
|
||||
.get("plugins")
|
||||
.and_then(|plugins| plugins.get("deep-link"))
|
||||
.and_then(|deep_link| deep_link.get("desktop"))
|
||||
.and_then(|desktop| desktop.get("schemes"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|schemes| schemes.first())
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("genarrative")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub(crate) mod deep_link;
|
||||
pub(crate) mod tray;
|
||||
pub(crate) mod webview;
|
||||
|
||||
@@ -39,5 +39,12 @@
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["genarrative"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
- 2026-06-18 剪贴板读取能力:新增 `clipboard.readText` HostBridge capability,H5 只能读取纯文本结果,契约限制返回文本最多 100000 字符;Expo 壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 壳通过 Rust 侧 `tauri-plugin-clipboard-manager` 读取文本且不开放插件 JS guest API。该能力不读取图片、HTML、文件列表或剪贴板监听事件,宿主未声明或读取失败时由 H5 视作失败并保留原流程。
|
||||
- 2026-06-18 文本文件导入能力:新增 `file.importText` HostBridge capability,H5 统一通过 `importHostTextFile()` 读取宿主返回的纯文本内容;Expo 壳通过 `expo-document-picker` 打开系统文档选择器,Tauri 壳通过系统文件选择框读取真实文本文件。两端只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备 URI / 本机绝对路径,也不开放通用文件系统。
|
||||
- 2026-06-18 Tauri 系统托盘:桌面壳启用真实 OS 托盘并复用品牌图标,托盘菜单只执行显示主窗口、刷新主窗口和退出应用,左键点击托盘图标恢复并聚焦主窗口;该能力归桌面壳自身,不进入 HostBridge capability,不向 H5 暴露托盘、菜单、shell 或任意窗口控制 API。托盘注册成功时主窗口关闭按钮只隐藏到托盘,必须通过托盘“退出”结束应用;托盘注册失败不得阻断主窗口启动,也不得拦截关闭,避免窗口消失后无法恢复。`check:native-shells` 和 Tauri cargo test 覆盖托盘配置、菜单动作映射和关闭策略。
|
||||
- 2026-06-18 Tauri 单实例:桌面壳启用 `tauri-plugin-single-instance` 并要求该插件最先注册;重复启动 App 时第二实例退出,只唤醒、取消最小化并聚焦已有主窗口,不把第二实例 argv / cwd / 深链内容作为事件透传给 H5。桌面深链后续如需接入,必须先定义受控 URL 归一和宿主边界,不能借单实例回调直接开放任意启动参数。
|
||||
- 2026-06-18 Tauri 单实例:桌面壳启用 `tauri-plugin-single-instance` 并要求该插件最先注册;重复启动 App 时第二实例退出,只唤醒、取消最小化并聚焦已有主窗口,不把第二实例 argv / cwd 作为事件透传给 H5。Windows / Linux 的二次实例深链只通过 single-instance 的 `deep-link` feature 交给 Tauri deep-link 插件,再由 `shell/deep_link.rs` 做受控 URL 归一。
|
||||
- 2026-06-18 Tauri 桌面深链:桌面壳启用 `tauri-plugin-deep-link`,但不安装 JS guest 包、不把 deep-link command 加入主窗口 capability,也不新增 HostBridge capability。Tauri 配置只注册 `genarrative` scheme;Rust 层只接受 `genarrative://open/...`、`genarrative://app/...`、`genarrative://<path>` 和 `https://app.genarrative.world/...`,统一跳转到同源 H5 并补写 `native_app`、`tauri_desktop`、当前平台、版本和真实 capability 清单;外域、明文协议和危险协议不进入主 WebView。
|
||||
- 2026-06-18 桌面壳安装包身份:Tauri 桌面壳的产品名固定为 `Genarrative`,应用 identifier 固定为 `world.genarrative.desktop`,Tauri 配置、`apps/desktop-shell/package.json` 与 Cargo package 版本统一为 `0.1.0`;Release 主窗口只加载打包的 `index.html` 和根 `dist` H5 资产,dev URL 只指向本机 Vite 调试入口。桌面壳 CSP 保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:`,也不得在没有真实端点、签名密钥和发布流程前配置 updater;检查脚本会拒绝包身份、版本、CSP 或 updater 约束漂移。
|
||||
- 2026-06-18 桌面壳观测与渠道 SDK 边界:Tauri 桌面壳默认不接入崩溃上报、analytics、遥测日志、自动更新或渠道分发 SDK;Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等 Node / Cargo 依赖、`package-lock.json` / `Cargo.lock` 解析包和 Rust 初始化片段都会被配置检查拒绝。后续只有在真实端点、采集字段、用户授权、隐私披露、签名和发布流程确定后,才能按单项能力更新方案并接入。
|
||||
- 2026-06-18 桌面壳 HostBridge 版本边界:Tauri release / dev 入口 URL 的 `hostVersion` 必须与 `tauri.conf.json`、`apps/desktop-shell/package.json` 和 Cargo package 版本一致;`host.getRuntime` 回包继续使用 `env!("CARGO_PKG_VERSION")`,配置检查会拒绝入口 query 版本、Tauri 配置版本或 Rust runtime 版本来源分叉。
|
||||
@@ -85,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/wechatHostBridge*.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。
|
||||
- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/wechatHostBridge*.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。Tauri `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 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`。
|
||||
@@ -2474,6 +2475,6 @@
|
||||
## 2026-06-18 三端宿主桥接层文件结构对齐
|
||||
|
||||
- 背景:微信小程序壳、Expo 移动壳和 Tauri 桌面壳都在承接宿主能力;如果微信页面继续散落 `index.shared.js`,桌面端继续把桥接分发堆在 `main.rs`,后续新增登录、支付、文件、通知或 sandbox 转发能力时会很难跨端对照 owner。
|
||||
- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 负责协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 负责 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单。
|
||||
- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 负责协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 负责 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` 锁定三端桥接层目录清单。
|
||||
- 影响范围:`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/wechatHostBridgeWebView.test.js miniprogram/host-bridge/wechatHostBridgePayment.test.js miniprogram/host-bridge/wechatHostBridgeShareGrid.test.js miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.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`。
|
||||
|
||||
@@ -188,6 +188,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。
|
||||
- 主 WebView 显式关闭 DevTools,Cargo 不启用 Tauri `devtools` feature;本地调试通过普通浏览器和 Vite 完成,不把可分发桌面壳变成调试容器。
|
||||
- 崩溃上报、前端 analytics、桌面遥测日志、自动更新和渠道分发 SDK 都必须等真实端点、采集字段、用户同意、隐私策略、签名和发布流程确定后逐项接入;当前桌面壳不安装 Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等相关依赖。
|
||||
- 桌面壳和根 H5 包不安装 `@tauri-apps/api` 或 `@tauri-apps/plugin-*` JS guest 包;生产 H5 只通过 Tauri 注入的 `window.__TAURI__.core.invoke('host_bridge_request', request)` 进入 HostBridge。opener、clipboard、dialog、notification 等能力只保留 Rust Cargo 插件,由 Rust 内部分发并受 capability 白名单约束。
|
||||
- 桌面深链只作为宿主启动 / 唤醒入口处理,不进入 HostBridge capability,也不把 deep-link 插件 command 授权给 H5。Tauri 只注册 `genarrative` scheme,并接受同源 `https://app.genarrative.world` URL;壳层会把目标路径归一为带 `native_app`、`tauri_desktop` 和真实 capability 清单的同源 H5 URL,外域、明文协议和危险协议直接丢弃。
|
||||
|
||||
桌面 release 和 dev 模式:
|
||||
|
||||
@@ -348,7 +349,9 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:桌面壳启用 Tauri 真实系统托盘,并复用品牌图标。托盘菜单只提供宿主壳级动作:显示主窗口、刷新主窗口和退出应用;左键点击托盘图标恢复并聚焦主窗口。该能力不进入 HostBridge capability 清单,不向 H5 暴露托盘 API、菜单 API、shell API 或任意窗口控制;如果当前桌面环境无法注册托盘,壳会继续启动主窗口。托盘注册成功时,用户点击主窗口关闭按钮会隐藏到托盘,必须通过托盘“退出”动作结束应用;托盘注册失败时不拦截关闭,避免窗口消失后没有恢复入口。
|
||||
|
||||
2026-06-18 追加:桌面壳启用 Tauri 单实例。用户重复启动桌面 App 时,新实例会退出并唤醒已有主窗口;该回调只执行显示、取消最小化和聚焦主窗口,不把第二实例的命令行参数、工作目录或深链内容作为事件透传给 H5。桌面深链若后续需要接入,必须单独定义受控来源、路径归一和 HostBridge / GameBridge 边界。
|
||||
2026-06-18 追加:桌面壳启用 Tauri 单实例。用户重复启动桌面 App 时,新实例会退出并唤醒已有主窗口;该回调只执行显示、取消最小化和聚焦主窗口,不把第二实例的命令行参数或工作目录作为事件透传给 H5。Windows / Linux 上由单实例插件的 `deep-link` feature 把二次实例 URL 交给 deep-link 插件,仍由 `shell/deep_link.rs` 做受控归一。
|
||||
|
||||
2026-06-18 追加:桌面壳接入 Tauri deep-link 插件,但不开放插件 JS guest API,也不把 deep-link 命令加入 capability。桌面配置只注册 `genarrative` scheme;Rust 层只接受 `genarrative://open/...`、`genarrative://app/...`、`genarrative://<path>` 和 `https://app.genarrative.world/...`,统一跳到同源 H5 路径并补写 `clientRuntime=native_app`、`hostShell=tauri_desktop`、当前平台、版本与真实 `hostCapabilities`。外域、`http:`、`mailto:`、`javascript:`、`file:` 等来源不进入主 WebView。
|
||||
|
||||
2026-06-18 追加:桌面壳安装包身份固定为 `world.genarrative.desktop`,产品名为 `Genarrative`,Tauri、Node package 与 Cargo package 版本统一为 `0.1.0`。Release 主窗口只能从打包进二进制的 `index.html` 进入根 `dist` H5 资产,dev URL 只能指向本机 Vite 调试入口;CSP 必须保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:` 这类扩大桌面攻击面的来源。当前不配置自动更新器,直到存在真实更新端点、签名密钥和发布流程再接入;`apps/desktop-shell/scripts/check-config.mjs` 会校验这些包身份、版本、CSP 和 updater 禁用约束。
|
||||
|
||||
@@ -412,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/wechatHostBridge*.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 和 `apps/mobile-shell/src/shell/mobileShell*.ts`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面或桌面入口。
|
||||
2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 和 `apps/mobile-shell/src/shell/mobileShell*.ts`;桌面壳 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` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面或桌面入口。
|
||||
|
||||
### Phase 4:宿主能力扩展
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ const expectedDesktopHostBridgeRustFiles = [
|
||||
'share.rs',
|
||||
];
|
||||
const expectedDesktopShellRustFiles = [
|
||||
'deep_link.rs',
|
||||
'mod.rs',
|
||||
'tray.rs',
|
||||
'webview.rs',
|
||||
|
||||
Reference in New Issue
Block a user