接入桌面壳受控站内导航

Tauri 桌面壳支持 navigation.openNativePage 的同源 H5 路由跳转

补充桌面壳导航白名单校验和运行时能力检查

更新宿主壳方案和项目共享决策记录
This commit is contained in:
2026-06-17 23:19:59 +08:00
parent 1a26806804
commit 87cdb8bfba
6 changed files with 90 additions and 8 deletions

View File

@@ -34,7 +34,7 @@ const requiredUrlParts = [
'hostShell=tauri_desktop',
'hostPlatform=unknown',
'bridgeVersion=1',
'hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText,file.exportText',
'hostCapabilities=host.getRuntime,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,clipboard.writeText,file.exportText',
];
for (const part of requiredUrlParts) {
@@ -55,6 +55,7 @@ const requiredMainSnippets = [
'tauri_plugin_clipboard_manager::init()',
'"share.open"',
'"share.setTarget"',
'"navigation.openNativePage"',
'"app.setTitle"',
'"clipboard.writeText"',
'"file.exportText"',

View File

@@ -4,6 +4,7 @@ use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::Manager;
use tauri::Url;
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
@@ -77,6 +78,7 @@ fn capabilities() -> Vec<&'static str> {
"host.getRuntime",
"share.open",
"share.setTarget",
"navigation.openNativePage",
"app.openExternalUrl",
"app.setTitle",
"clipboard.writeText",
@@ -166,6 +168,21 @@ fn normalize_external_url(raw_url: &str) -> Option<String> {
Some(url.to_string())
}
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)
}
fn normalize_window_title(raw_title: &str) -> Option<String> {
let title = raw_title.trim();
if title.is_empty() || title.chars().any(char::is_control) {
@@ -179,7 +196,11 @@ fn normalize_export_file_name(raw_file_name: &str) -> String {
let mut file_name = String::new();
let mut last_was_space = false;
for character in raw_file_name.trim().chars().take(EXPORT_FILE_NAME_MAX_LENGTH) {
for character in raw_file_name
.trim()
.chars()
.take(EXPORT_FILE_NAME_MAX_LENGTH)
{
if character.is_control()
|| matches!(
character,
@@ -377,6 +398,29 @@ async fn host_bridge_request(
Err(error) => failed(request.id, "host_error", error.to_string()),
}
}
"navigation.openNativePage" => {
let url = match required_string_payload(&request, "url")
.ok()
.and_then(normalize_native_page_url)
{
Some(url) => url,
None => {
return failed(
request.id,
"invalid_request",
"url must use an allowed same-origin H5 route",
)
}
};
match app.get_webview_window("main") {
Some(window) => match window.navigate(url) {
Ok(()) => ok(request.id, json!(true)),
Err(error) => failed(request.id, "host_error", error.to_string()),
},
None => failed(request.id, "host_error", "main window not found"),
}
}
"clipboard.writeText" => {
let text = match required_string_payload(&request, "text") {
Ok(text) => text,
@@ -530,6 +574,10 @@ mod tests {
.as_array()
.unwrap()
.contains(&json!("share.setTarget")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("navigation.openNativePage")));
assert!(result["capabilities"]
.as_array()
.unwrap()
@@ -590,6 +638,39 @@ mod tests {
assert_eq!(normalize_external_url("/relative/path"), None);
}
#[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
);
}
#[test]
fn window_title_normalization_requires_visible_text() {
assert_eq!(

View File

@@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "npm --prefix ../.. run dev:web",
"beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck",
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText,file.exportText",
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,clipboard.writeText,file.exportText",
"frontendDist": "../../../dist"
},
"app": {
@@ -14,7 +14,7 @@
{
"create": false,
"label": "main",
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText,file.exportText",
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,clipboard.writeText,file.exportText",
"title": "Genarrative",
"width": 1280,
"height": 820,