合并原生壳桥接统一分支

合并 Expo 移动壳与 Tauri 桌面壳工程
保留创作主页、项目入口和现有生产发布脚本
同步 HostBridge 文档、依赖锁文件和原生壳门禁
This commit is contained in:
2026-06-22 22:28:31 +08:00
263 changed files with 72457 additions and 2263 deletions
+5
View File
@@ -28,6 +28,11 @@ temp-build-goal-check/
/public/generated-custom-world-scenes
temp*build*/
/server-rs/target/
/apps/desktop-shell/src-tauri/target/
/apps/desktop-shell/src-tauri/gen/
/apps/desktop-shell/src-tauri/permissions/autogenerated/
/apps/mobile-shell/.expo/
/apps/mobile-shell/.expo-export-smoke/
/server-rs/.spacetimedb/
/server-rs/.data/
/public/generated-animations
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@genarrative/desktop-shell",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "WEB_PORT=3000 tauri dev",
"build": "tauri build",
"stage-release-binary": "node scripts/stage-release-binary.mjs",
"typecheck": "node scripts/check-config.mjs"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.2",
"typescript": "~5.8.2"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(new URL('../../../', import.meta.url).pathname);
const releaseDir = path.join(
repoRoot,
'apps',
'desktop-shell',
'src-tauri',
'target',
'release',
);
const executableName =
process.platform === 'win32'
? 'genarrative-desktop-shell.exe'
: 'genarrative-desktop-shell';
const sourcePath = path.join(releaseDir, executableName);
const stagedDir = path.join(repoRoot, 'build', 'native', 'desktop');
const stagedPath = path.join(stagedDir, executableName);
function assertExecutable(filePath, label) {
if (!fs.existsSync(filePath)) {
throw new Error(`desktop release binary is missing: ${filePath}`);
}
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size < 1024 * 1024) {
throw new Error(`${label} must be a real non-empty executable file`);
}
const handle = fs.openSync(filePath, 'r');
try {
const header = Buffer.alloc(8);
fs.readSync(handle, header, 0, header.length, 0);
if (process.platform === 'linux') {
const isElf =
header[0] === 0x7f &&
header[1] === 0x45 &&
header[2] === 0x4c &&
header[3] === 0x46;
if (!isElf || (stat.mode & 0o111) === 0) {
throw new Error(`${label} must be an executable ELF file`);
}
return;
}
if (process.platform === 'darwin') {
const machMagic = header.readUInt32BE(0);
const isMachO =
machMagic === 0xcafebabe ||
machMagic === 0xcafed00d ||
machMagic === 0xfeedface ||
machMagic === 0xfeedfacf;
if (!isMachO || (stat.mode & 0o111) === 0) {
throw new Error(`${label} must be an executable Mach-O file`);
}
return;
}
if (process.platform === 'win32') {
if (header[0] !== 0x4d || header[1] !== 0x5a) {
throw new Error(`${label} must be a PE executable`);
}
}
} finally {
fs.closeSync(handle);
}
}
assertExecutable(sourcePath, 'desktop release binary');
fs.mkdirSync(stagedDir, {recursive: true});
fs.copyFileSync(sourcePath, stagedPath);
const sourceMode = fs.statSync(sourcePath).mode;
fs.chmodSync(stagedPath, sourceMode & 0o777);
assertExecutable(stagedPath, 'staged desktop release binary');
console.log(`[desktop-shell:stage-release-binary] ${stagedPath}`);
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "genarrative-desktop-shell"
version = "0.1.0"
edition = "2021"
publish = false
[build-dependencies]
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
base64 = "0.22"
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 = { version = "2.4.2", features = ["deep-link"] }
tauri-plugin-window-state = "2.4.1"
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>允许 Genarrative 使用摄像头运行需要实时动作输入的同源 H5 体验。</string>
<key>NSMicrophoneUsageDescription</key>
<string>允许 Genarrative 使用麦克风运行需要实时声音输入的同源 H5 玩法。</string>
</dict>
</plist>
+5
View File
@@ -0,0 +1,5 @@
fn main() {
let app_manifest = tauri_build::AppManifest::new().commands(&["host_bridge_request"]);
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(app_manifest))
.expect("failed to run Tauri build script");
}
@@ -0,0 +1,9 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "主窗口只开放 Genarrative 桌面宿主壳需要的受控命令。",
"windows": ["main"],
"permissions": [
"allow-host-bridge-request"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+241
View File
@@ -0,0 +1,241 @@
use crate::host_bridge::{DesktopShareState, HostBridgeReplayState};
use crate::shell::deep_link::{
register_desktop_deep_link_events, register_desktop_deep_link_schemes,
};
use crate::shell::tray::{
register_desktop_tray, register_desktop_window_close_events,
resolve_desktop_single_instance_action, show_main_window, DesktopSingleInstanceAction,
};
use crate::shell::webview::{
desktop_external_navigation_url, desktop_window_config_with_runtime_platform,
emit_current_desktop_lifecycle_event, handle_desktop_webview_download,
log_desktop_host_event_result, open_desktop_external_navigation,
open_normalized_desktop_external_url, register_desktop_file_drop_events,
register_desktop_lifecycle_events, register_desktop_navigation_events,
replay_desktop_webview_state, should_allow_desktop_webview_navigation,
should_replay_desktop_webview_state_on_page_load,
};
use crate::shell::window_state::desktop_window_state_plugin;
use tauri::webview::NewWindowResponse;
const DESKTOP_MAIN_WINDOW_LABEL: &str = "main";
fn desktop_main_window_config_from_windows(
windows: &[tauri::utils::config::WindowConfig],
) -> tauri::Result<tauri::utils::config::WindowConfig> {
windows
.iter()
.find(|window| window.label == DESKTOP_MAIN_WINDOW_LABEL)
.cloned()
.map(desktop_window_config_with_runtime_platform)
.ok_or(tauri::Error::WindowNotFound)
}
#[cfg(dev)]
fn apply_desktop_dev_url_to_main_window_config(
mut config: tauri::utils::config::WindowConfig,
dev_url: &tauri::Url,
) -> tauri::utils::config::WindowConfig {
config.url = tauri::WebviewUrl::External(dev_url.clone());
desktop_window_config_with_runtime_platform(config)
}
fn desktop_main_window_config(
app: &tauri::App,
) -> tauri::Result<tauri::utils::config::WindowConfig> {
let config = desktop_main_window_config_from_windows(&app.config().app.windows)?;
#[cfg(dev)]
if let Some(dev_url) = app.config().build.dev_url.as_ref() {
return Ok(apply_desktop_dev_url_to_main_window_config(config, dev_url));
}
Ok(config)
}
fn desktop_new_window_external_url(url: &tauri::Url) -> Option<String> {
desktop_external_navigation_url(url)
}
fn desktop_new_window_response<R: tauri::Runtime>() -> NewWindowResponse<R> {
NewWindowResponse::Deny
}
pub(crate) fn run() {
tauri::Builder::default()
.manage(DesktopShareState::default())
.manage(HostBridgeReplayState::default())
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
match resolve_desktop_single_instance_action() {
DesktopSingleInstanceAction::ShowMainWindow => {
log_desktop_host_event_result("single_instance.show", show_main_window(app));
}
}
}))
.plugin(desktop_window_state_plugin())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let tray_registered = match register_desktop_tray(app) {
Ok(()) => true,
Err(_error) => {
eprintln!("desktop tray registration failed");
false
}
};
let config = desktop_main_window_config(app)?;
let app_handle = app.handle().clone();
let new_window_app_handle = app.handle().clone();
let window = tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?
.on_navigation(move |url| {
if should_allow_desktop_webview_navigation(url) {
true
} else {
open_desktop_external_navigation(&app_handle, url);
false
}
})
.on_new_window(move |url, _features| {
if let Some(external_url) = desktop_new_window_external_url(&url) {
log_desktop_host_event_result(
"webview.new_window.external",
open_normalized_desktop_external_url(
&new_window_app_handle,
external_url,
),
);
}
desktop_new_window_response::<tauri::Wry>()
})
.on_page_load(|window, payload| {
if should_replay_desktop_webview_state_on_page_load(payload.event()) {
replay_desktop_webview_state(&window);
}
})
.on_download(|_webview, event| handle_desktop_webview_download(&event))
.build()?;
register_desktop_window_close_events(&window, tray_registered);
register_desktop_lifecycle_events(&window);
log_desktop_host_event_result(
"app.lifecycle",
emit_current_desktop_lifecycle_event(&window),
);
register_desktop_navigation_events(&window)?;
register_desktop_file_drop_events(&window);
register_desktop_deep_link_events(app)?;
register_desktop_deep_link_schemes(app);
Ok(())
})
.invoke_handler(tauri::generate_handler![
crate::host_bridge::host_bridge_request
])
.run(tauri::generate_context!())
.expect("failed to run Genarrative desktop shell");
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn desktop_main_window_config_uses_labeled_main_window() {
let mut secondary_window = tauri::utils::config::WindowConfig::default();
secondary_window.label = "secondary".to_string();
secondary_window.url = tauri::WebviewUrl::App(PathBuf::from("secondary.html"));
let mut main_window = tauri::utils::config::WindowConfig::default();
main_window.label = "main".to_string();
main_window.url = tauri::WebviewUrl::App(PathBuf::from("index.html"));
let config = desktop_main_window_config_from_windows(&[secondary_window, main_window])
.expect("main window config");
assert_eq!(config.label, "main");
match config.url {
tauri::WebviewUrl::App(path) => {
let path = path.to_string_lossy();
assert!(path.starts_with("index.html?"));
assert!(path.contains("clientRuntime=native_app"));
assert!(path.contains("clientType=native_app"));
assert!(path.contains("hostShell=tauri_desktop"));
assert!(path.contains("hostPlatform="));
assert!(path.contains(&format!("hostVersion={}", env!("CARGO_PKG_VERSION"))));
assert!(path.contains("bridgeVersion="));
assert!(path.contains("hostCapabilities="));
}
other => panic!("unexpected main window url {other:?}"),
}
}
#[cfg(dev)]
#[test]
fn desktop_main_window_config_uses_dev_url_in_dev_builds() {
let mut main_window = tauri::utils::config::WindowConfig::default();
main_window.label = "main".to_string();
main_window.url = tauri::WebviewUrl::App(PathBuf::from("index.html"));
let mut config = tauri::utils::config::Config::default();
config.app.windows = vec![main_window];
config.build.dev_url =
Some(tauri::Url::parse("http://127.0.0.1:3000/").expect("desktop dev url"));
let mut resolved = desktop_main_window_config_from_windows(&config.app.windows)
.expect("main window config");
if let Some(dev_url) = config.build.dev_url.as_ref() {
resolved = apply_desktop_dev_url_to_main_window_config(resolved, dev_url);
}
match resolved.url {
tauri::WebviewUrl::External(url) => {
assert_eq!(url.origin().ascii_serialization(), "http://127.0.0.1:3000");
assert_eq!(url.path(), "/");
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "hostShell")
.map(|(_, value)| value.into_owned()),
Some("tauri_desktop".to_string())
);
}
other => panic!("unexpected dev main window url {other:?}"),
}
}
#[test]
fn desktop_main_window_config_rejects_missing_main_window() {
let mut secondary_window = tauri::utils::config::WindowConfig::default();
secondary_window.label = "secondary".to_string();
let error = desktop_main_window_config_from_windows(&[secondary_window])
.expect_err("missing main window should fail startup");
assert!(matches!(error, tauri::Error::WindowNotFound));
}
#[test]
fn desktop_new_window_policy_denies_embedded_windows() {
assert!(matches!(
desktop_new_window_response::<tauri::Wry>(),
NewWindowResponse::Deny
));
}
#[test]
fn desktop_new_window_policy_opens_only_safe_external_urls() {
let external_url = tauri::Url::parse("https://example.com/share")
.expect("external desktop new window url");
assert_eq!(
desktop_new_window_external_url(&external_url),
Some("https://example.com/share".to_string())
);
let same_origin_url = tauri::Url::parse("https://www.genarrative.world/works/detail")
.expect("same-origin desktop new window url");
assert_eq!(desktop_new_window_external_url(&same_origin_url), None);
let unsafe_url =
tauri::Url::parse("javascript:alert(1)").expect("unsafe desktop new window url");
assert_eq!(desktop_new_window_external_url(&unsafe_url), None);
}
}
@@ -0,0 +1,81 @@
use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse};
use crate::shell::webview::color_scheme_from_theme;
use serde_json::json;
use tauri::Manager;
pub(crate) fn desktop_appearance_color_scheme(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
match app.get_webview_window("main") {
Some(window) => match window.theme() {
Ok(theme) => ok_color_scheme(request, color_scheme_from_theme(theme)),
Err(_error) => {
log_desktop_appearance_failure("theme");
appearance_unavailable_response(request)
}
},
None => {
log_desktop_appearance_failure("window");
appearance_unavailable_response(request)
}
}
}
fn ok_color_scheme(request: &HostBridgeRequest, color_scheme: &'static str) -> HostBridgeResponse {
ok(
request.id.clone(),
json!({
"colorScheme": color_scheme
}),
)
}
fn appearance_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(request.id.clone(), "host_error", "appearance unavailable")
}
fn log_desktop_appearance_failure(label: &str) -> bool {
eprintln!("desktop appearance failed for {label}");
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_bridge::protocol::request;
#[test]
fn ok_color_scheme_reports_contract_shape() {
let response = ok_color_scheme(&request("appearance.getColorScheme"), "dark");
assert!(response.ok);
assert_eq!(
response.result.expect("appearance result"),
json!({
"colorScheme": "dark"
})
);
}
#[test]
fn appearance_unavailable_response_is_stable() {
let response = appearance_unavailable_response(&request("appearance.getColorScheme"));
assert!(!response.ok);
let error = response.error.expect("appearance error");
assert_eq!(error.code, "host_error");
assert_eq!(error.message, "appearance unavailable");
}
#[test]
fn appearance_failures_are_logged_without_exposing_native_detail() {
assert!(!log_desktop_appearance_failure("theme"));
assert!(!log_desktop_appearance_failure("window"));
}
#[test]
fn appearance_failures_log_stable_label_only() {
assert!(!log_desktop_appearance_failure("theme"));
}
}
@@ -0,0 +1,117 @@
use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse};
use serde_json::json;
use serde_json::Value;
use tauri::Manager;
const BADGE_COUNT_MAX: i64 = 99999;
fn badge_count_payload(request: &HostBridgeRequest) -> Result<Option<i64>, HostBridgeResponse> {
let count = request
.payload
.as_ref()
.and_then(|value| value.get("count"))
.and_then(Value::as_i64)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"count must be an integer between 0 and 99999",
)
})?;
if !(0..=BADGE_COUNT_MAX).contains(&count) {
return Err(failed(
request.id.clone(),
"invalid_request",
"count must be an integer between 0 and 99999",
));
}
Ok(if count == 0 { None } else { Some(count) })
}
pub(crate) fn set_desktop_app_badge_count(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let count = match badge_count_payload(request) {
Ok(count) => count,
Err(response) => return response,
};
let Some(window) = app.get_webview_window("main") else {
log_desktop_badge_failure("window");
return badge_unavailable_response(request);
};
match window.set_badge_count(count) {
Ok(()) => ok(request.id.clone(), json!(true)),
Err(_error) => {
log_desktop_badge_failure("set");
badge_unavailable_response(request)
}
}
}
fn badge_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(request.id.clone(), "host_error", "badge unavailable")
}
fn log_desktop_badge_failure(label: &str) -> bool {
eprintln!("desktop app badge failed for {label}");
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_bridge::protocol::request;
use serde_json::json;
#[test]
fn badge_count_payload_accepts_clear_and_positive_counts() {
let mut clear = request("app.setBadgeCount");
clear.payload = Some(json!({ "count": 0 }));
assert_eq!(badge_count_payload(&clear).expect("clear badge"), None);
let mut count = request("app.setBadgeCount");
count.payload = Some(json!({ "count": 12 }));
assert_eq!(badge_count_payload(&count).expect("badge count"), Some(12));
}
#[test]
fn badge_count_payload_rejects_invalid_counts() {
for count in [json!(-1), json!(1.5), json!(100000), json!("1")] {
let mut invalid = request("app.setBadgeCount");
invalid.payload = Some(json!({ "count": count }));
let response = badge_count_payload(&invalid).expect_err("invalid count");
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(
error.message,
"count must be an integer between 0 and 99999"
);
}
}
#[test]
fn badge_unavailable_response_is_stable() {
let response = badge_unavailable_response(&request("app.setBadgeCount"));
assert!(!response.ok);
let error = response.error.expect("badge error");
assert_eq!(error.code, "host_error");
assert_eq!(error.message, "badge unavailable");
}
#[test]
fn badge_failures_are_logged_without_exposing_native_detail() {
assert!(!log_desktop_badge_failure("set"));
assert!(!log_desktop_badge_failure("window"));
}
#[test]
fn badge_failures_log_stable_label_only() {
assert!(!log_desktop_badge_failure("set"));
}
}
@@ -0,0 +1,116 @@
pub(crate) fn capabilities() -> Vec<&'static str> {
vec![
"host.getRuntime",
"appearance.getColorScheme",
"host.events",
"app.lifecycle",
"share.open",
"share.setTarget",
"navigation.openNativePage",
"navigation.canGoBack",
"app.reloadWebView",
"app.openExternalUrl",
"app.setTitle",
"app.setBadgeCount",
"network.status",
"clipboard.writeText",
"clipboard.readText",
"file.exportText",
"file.importText",
"file.importDocument",
"file.exportImage",
"file.importImage",
"file.importAudio",
"file.exportAudio",
"file.imageDropped",
"notification.showLocal",
]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
use std::collections::HashSet;
#[test]
fn runtime_capability_list_stays_ordered() {
assert_eq!(
capabilities(),
vec![
"host.getRuntime",
"appearance.getColorScheme",
"host.events",
"app.lifecycle",
"share.open",
"share.setTarget",
"navigation.openNativePage",
"navigation.canGoBack",
"app.reloadWebView",
"app.openExternalUrl",
"app.setTitle",
"app.setBadgeCount",
"network.status",
"clipboard.writeText",
"clipboard.readText",
"file.exportText",
"file.importText",
"file.importDocument",
"file.exportImage",
"file.importImage",
"file.importAudio",
"file.exportAudio",
"file.imageDropped",
"notification.showLocal",
]
);
assert!(Value::from(capabilities()).as_array().is_some());
}
#[test]
fn runtime_capability_list_keeps_desktop_boundaries() {
let desktop_capabilities = capabilities();
let unique_capabilities = desktop_capabilities.iter().copied().collect::<HashSet<_>>();
assert_eq!(unique_capabilities.len(), desktop_capabilities.len());
for capability in [
"host.getRuntime",
"appearance.getColorScheme",
"host.events",
"app.lifecycle",
"share.open",
"share.setTarget",
"navigation.openNativePage",
"navigation.canGoBack",
"app.reloadWebView",
"app.openExternalUrl",
"app.setTitle",
"app.setBadgeCount",
"network.status",
"clipboard.writeText",
"clipboard.readText",
"file.exportText",
"file.importText",
"file.importDocument",
"file.exportImage",
"file.importImage",
"file.importAudio",
"file.exportAudio",
"file.imageDropped",
"notification.showLocal",
] {
assert!(desktop_capabilities.contains(&capability));
}
for capability in [
"auth.requestLogin",
"payment.request",
"file.captureImage",
"scanner.scanQrCode",
"haptics.impact",
] {
assert!(!desktop_capabilities.contains(&capability));
}
}
}
@@ -0,0 +1,165 @@
use tauri_plugin_clipboard_manager::ClipboardExt;
use crate::host_bridge::protocol::{
failed, ok, required_string_payload, HostBridgeRequest, HostBridgeResponse,
};
use serde_json::json;
pub(crate) const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000;
pub(crate) fn normalize_clipboard_text(text: &str) -> String {
text.chars().take(CLIPBOARD_TEXT_MAX_LENGTH).collect()
}
pub(crate) fn write_desktop_clipboard_text(
app: &tauri::AppHandle,
text: &str,
) -> Result<(), String> {
app.clipboard()
.write_text(normalize_clipboard_text(text))
.map_err(|error| error.to_string())
}
pub(crate) fn read_desktop_clipboard_text(app: &tauri::AppHandle) -> Result<String, String> {
app.clipboard()
.read_text()
.map(|text| normalize_clipboard_text(&text))
.map_err(|error| error.to_string())
}
fn log_desktop_clipboard_failure(label: &str) -> bool {
eprintln!("desktop clipboard failed for {label}");
false
}
pub(crate) fn clipboard_write_unavailable_response(
request: &HostBridgeRequest,
) -> HostBridgeResponse {
failed(
request.id.clone(),
"host_error",
"clipboard write unavailable",
)
}
pub(crate) fn clipboard_read_unavailable_response(
request: &HostBridgeRequest,
) -> HostBridgeResponse {
failed(
request.id.clone(),
"host_error",
"clipboard read unavailable",
)
}
pub(crate) fn write_desktop_host_bridge_clipboard_text(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let text = match required_string_payload(request, "text") {
Ok(text) => text,
Err(response) => return response,
};
match write_desktop_clipboard_text(app, text) {
Ok(()) => ok(request.id.clone(), json!(true)),
Err(_error) => {
log_desktop_clipboard_failure("write.text");
clipboard_write_unavailable_response(request)
}
}
}
pub(crate) fn read_desktop_host_bridge_clipboard_text(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
match read_desktop_clipboard_text(app) {
Ok(text) => ok(
request.id.clone(),
json!({
"text": text,
}),
),
Err(_error) => {
log_desktop_clipboard_failure("read.text");
clipboard_read_unavailable_response(request)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clipboard_text_is_truncated_to_contract_limit() {
assert_eq!(normalize_clipboard_text("作品号 PZ-1"), "作品号 PZ-1");
assert_eq!(
normalize_clipboard_text(&"a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(),
CLIPBOARD_TEXT_MAX_LENGTH
);
let unicode_text = "".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10);
let truncated = normalize_clipboard_text(&unicode_text);
assert_eq!(truncated.chars().count(), CLIPBOARD_TEXT_MAX_LENGTH);
assert!(truncated.ends_with('猫'));
}
#[test]
fn clipboard_read_response_reports_contract_shape() {
let response = ok(
"clipboard-read".to_string(),
json!({
"text": normalize_clipboard_text("邀请码 GEN-1")
}),
);
assert!(response.ok);
assert_eq!(
response.result.expect("clipboard result"),
json!({
"text": "邀请码 GEN-1"
})
);
}
#[test]
fn clipboard_unavailable_responses_are_stable() {
let write_response = clipboard_write_unavailable_response(&HostBridgeRequest {
bridge: "GenarrativeHostBridge".to_string(),
version: 1,
id: "clipboard-write".to_string(),
method: "clipboard.writeText".to_string(),
payload: None,
});
let read_response = clipboard_read_unavailable_response(&HostBridgeRequest {
bridge: "GenarrativeHostBridge".to_string(),
version: 1,
id: "clipboard-read".to_string(),
method: "clipboard.readText".to_string(),
payload: None,
});
assert!(!write_response.ok);
let write_error = write_response.error.expect("clipboard write error");
assert_eq!(write_error.code, "host_error");
assert_eq!(write_error.message, "clipboard write unavailable");
assert!(!read_response.ok);
let read_error = read_response.error.expect("clipboard read error");
assert_eq!(read_error.code, "host_error");
assert_eq!(read_error.message, "clipboard read unavailable");
}
#[test]
fn clipboard_failures_are_logged_without_exposing_native_detail() {
assert!(!log_desktop_clipboard_failure("write.text"));
assert!(!log_desktop_clipboard_failure("read.text"));
}
#[test]
fn clipboard_failures_log_stable_label_only() {
assert!(!log_desktop_clipboard_failure("write.text"));
}
}
@@ -0,0 +1,110 @@
use crate::host_bridge::appearance::desktop_appearance_color_scheme;
use crate::host_bridge::badge::set_desktop_app_badge_count;
use crate::host_bridge::clipboard::{
read_desktop_host_bridge_clipboard_text, write_desktop_host_bridge_clipboard_text,
};
use crate::host_bridge::files::{
export_desktop_host_bridge_audio_file, export_desktop_host_bridge_image_file,
export_desktop_host_bridge_text_file, import_desktop_host_bridge_audio_file,
import_desktop_host_bridge_document_file, import_desktop_host_bridge_image_file,
import_desktop_host_bridge_text_file,
};
use crate::host_bridge::navigation::{
open_desktop_host_bridge_external_url, open_desktop_host_bridge_native_page,
reload_desktop_host_bridge_webview,
};
use crate::host_bridge::network::resolve_desktop_host_bridge_network_status;
use crate::host_bridge::notifications::show_desktop_local_notification;
use crate::host_bridge::protocol::{
failed, validate_request, HostBridgeRequest, HostBridgeResponse,
};
use crate::host_bridge::runtime::desktop_host_bridge_runtime_response;
use crate::host_bridge::share::{
open_desktop_host_bridge_share, set_desktop_host_bridge_share_target,
};
use crate::host_bridge::title::set_desktop_host_bridge_window_title;
pub(crate) fn resolve_host_bridge_request(request: HostBridgeRequest) -> HostBridgeResponse {
if let Some(response) = validate_request(&request) {
return response;
}
match request.method.as_str() {
"host.getRuntime" => desktop_host_bridge_runtime_response(&request),
_ => failed(
request.id,
"unsupported_method",
format!("{} unsupported in desktop shell", request.method),
),
}
}
pub(super) async fn execute_host_bridge_request(
app: tauri::AppHandle,
request: HostBridgeRequest,
) -> HostBridgeResponse {
if let Some(response) = validate_request(&request) {
return response;
}
match request.method.as_str() {
"app.openExternalUrl" => open_desktop_host_bridge_external_url(&app, &request),
"appearance.getColorScheme" => desktop_appearance_color_scheme(&app, &request),
"navigation.openNativePage" => open_desktop_host_bridge_native_page(&app, &request),
"app.reloadWebView" => reload_desktop_host_bridge_webview(&app, &request),
"clipboard.writeText" => write_desktop_host_bridge_clipboard_text(&app, &request),
"clipboard.readText" => read_desktop_host_bridge_clipboard_text(&app, &request),
"file.exportText" => export_desktop_host_bridge_text_file(&app, &request).await,
"file.importText" => import_desktop_host_bridge_text_file(&app, &request).await,
"file.importDocument" => import_desktop_host_bridge_document_file(&app, &request).await,
"file.exportImage" => export_desktop_host_bridge_image_file(&app, &request).await,
"file.importImage" => import_desktop_host_bridge_image_file(&app, &request).await,
"file.importAudio" => import_desktop_host_bridge_audio_file(&app, &request).await,
"file.exportAudio" => export_desktop_host_bridge_audio_file(&app, &request).await,
"app.setTitle" => set_desktop_host_bridge_window_title(&app, &request),
"app.setBadgeCount" => set_desktop_app_badge_count(&app, &request),
"network.status" => resolve_desktop_host_bridge_network_status(&request).await,
"notification.showLocal" => show_desktop_local_notification(&app, &request),
"share.setTarget" => set_desktop_host_bridge_share_target(&app, &request),
"share.open" => open_desktop_host_bridge_share(&app, &request),
_ => resolve_host_bridge_request(request),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_bridge::capabilities::capabilities;
use crate::host_bridge::protocol::HOST_BRIDGE_METHODS;
use crate::host_bridge::protocol::request;
#[test]
fn unsupported_method_is_explicit() {
let desktop_capabilities = capabilities();
let unsupported_methods = HOST_BRIDGE_METHODS
.iter()
.copied()
.filter(|method| !desktop_capabilities.contains(method))
.collect::<Vec<_>>();
assert_eq!(
unsupported_methods,
vec![
"auth.requestLogin",
"payment.request",
"file.captureImage",
"scanner.scanQrCode",
"haptics.impact",
],
);
for method in unsupported_methods {
let response = resolve_host_bridge_request(request(method));
assert!(!response.ok);
let error = response.error.expect("error");
assert_eq!(error.code, "unsupported_method");
assert!(error.message.contains(method));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,405 @@
use crate::host_bridge::file_payloads::{
export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload,
import_document_file_payload, import_image_file_payload, import_text_file_payload,
write_export_bytes_file, write_export_text_file, ImportFilePayloadError,
};
use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse};
use serde_json::json;
use tauri_plugin_dialog::DialogExt;
fn file_export_cancelled_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(request.id.clone(), "cancelled", "file export cancelled")
}
fn file_import_cancelled_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(request.id.clone(), "cancelled", "file import cancelled")
}
fn file_export_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(request.id.clone(), "host_error", "file export unavailable")
}
fn file_import_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(request.id.clone(), "host_error", "file import unavailable")
}
fn log_desktop_file_export_failure(label: &str) -> bool {
eprintln!("desktop file export failed for {label}");
false
}
fn log_desktop_file_import_failure(label: &str) -> bool {
eprintln!("desktop file import failed for {label}");
false
}
fn file_import_payload_error_response(
request: &HostBridgeRequest,
label: &str,
error: ImportFilePayloadError,
) -> HostBridgeResponse {
match error.invalid_message() {
Some(message) => failed(request.id.clone(), "invalid_request", message),
None => {
log_desktop_file_import_failure(label);
file_import_unavailable_response(request)
}
}
}
pub(crate) async fn export_desktop_host_bridge_text_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let (file_name, content) = match export_text_payload(request) {
Ok(payload) => payload,
Err(response) => return response,
};
let file_path = app
.dialog()
.file()
.add_filter("Text", &["txt", "json", "md", "csv"])
.set_file_name(file_name.clone())
.blocking_save_file();
let Some(file_path) = file_path else {
return file_export_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_export_failure("path.convert");
return file_export_unavailable_response(request);
}
};
let export_result =
tauri::async_runtime::spawn_blocking(move || write_export_text_file(path, content)).await;
let bytes = match export_result {
Ok(Ok(bytes)) => bytes,
Ok(Err(_error)) => {
log_desktop_file_export_failure("write.text");
return file_export_unavailable_response(request);
}
Err(_error) => {
log_desktop_file_export_failure("write.text.join");
return file_export_unavailable_response(request);
}
};
ok(
request.id.clone(),
json!({
"action": "saved",
"fileName": file_name,
"bytes": bytes,
}),
)
}
pub(crate) async fn import_desktop_host_bridge_text_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let file_path = app
.dialog()
.file()
.add_filter("Text", &["txt", "md", "markdown", "csv", "json"])
.blocking_pick_file();
let Some(file_path) = file_path else {
return file_import_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_import_failure("path.convert");
return file_import_unavailable_response(request);
}
};
let import_result =
tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await;
match import_result {
Ok(Ok(payload)) => ok(request.id.clone(), payload),
Ok(Err(error)) => file_import_payload_error_response(request, "read.text", error),
Err(_error) => {
log_desktop_file_import_failure("read.text.join");
file_import_unavailable_response(request)
}
}
}
pub(crate) async fn import_desktop_host_bridge_document_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let file_path = app
.dialog()
.file()
.add_filter(
"Document",
&["txt", "md", "markdown", "csv", "json", "docx"],
)
.blocking_pick_file();
let Some(file_path) = file_path else {
return file_import_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_import_failure("path.convert");
return file_import_unavailable_response(request);
}
};
let import_result =
tauri::async_runtime::spawn_blocking(move || import_document_file_payload(path)).await;
match import_result {
Ok(Ok(payload)) => ok(request.id.clone(), payload),
Ok(Err(error)) => file_import_payload_error_response(request, "read.document", error),
Err(_error) => {
log_desktop_file_import_failure("read.document.join");
file_import_unavailable_response(request)
}
}
}
pub(crate) async fn export_desktop_host_bridge_image_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let (file_name, bytes) = match export_image_payload(request) {
Ok(payload) => payload,
Err(response) => return response,
};
let file_path = app
.dialog()
.file()
.add_filter("Image", &["png", "jpg", "jpeg", "webp"])
.set_file_name(file_name.clone())
.blocking_save_file();
let Some(file_path) = file_path else {
return file_export_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_export_failure("path.convert");
return file_export_unavailable_response(request);
}
};
let export_result =
tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)).await;
let byte_count = match export_result {
Ok(Ok(byte_count)) => byte_count,
Ok(Err(_error)) => {
log_desktop_file_export_failure("write.image");
return file_export_unavailable_response(request);
}
Err(_error) => {
log_desktop_file_export_failure("write.image.join");
return file_export_unavailable_response(request);
}
};
ok(
request.id.clone(),
json!({
"action": "saved",
"fileName": file_name,
"bytes": byte_count,
}),
)
}
pub(crate) async fn import_desktop_host_bridge_image_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let file_path = app
.dialog()
.file()
.add_filter("Image", &["png", "jpg", "jpeg", "webp"])
.blocking_pick_file();
let Some(file_path) = file_path else {
return file_import_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_import_failure("path.convert");
return file_import_unavailable_response(request);
}
};
let import_result = tauri::async_runtime::spawn_blocking(move || {
import_image_file_payload(path, "selected", None)
})
.await;
match import_result {
Ok(Ok(payload)) => ok(request.id.clone(), payload),
Ok(Err(error)) => file_import_payload_error_response(request, "read.image", error),
Err(_error) => {
log_desktop_file_import_failure("read.image.join");
file_import_unavailable_response(request)
}
}
}
pub(crate) async fn import_desktop_host_bridge_audio_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let file_path = app
.dialog()
.file()
.add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"])
.blocking_pick_file();
let Some(file_path) = file_path else {
return file_import_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_import_failure("path.convert");
return file_import_unavailable_response(request);
}
};
let import_result =
tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await;
match import_result {
Ok(Ok(payload)) => ok(request.id.clone(), payload),
Ok(Err(error)) => file_import_payload_error_response(request, "read.audio", error),
Err(_error) => {
log_desktop_file_import_failure("read.audio.join");
file_import_unavailable_response(request)
}
}
}
pub(crate) async fn export_desktop_host_bridge_audio_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let (file_name, bytes) = match export_audio_payload(request) {
Ok(payload) => payload,
Err(response) => return response,
};
let file_path = app
.dialog()
.file()
.add_filter("Audio", &["mp3", "m4a", "wav", "ogg", "webm"])
.set_file_name(file_name.clone())
.blocking_save_file();
let Some(file_path) = file_path else {
return file_export_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_export_failure("path.convert");
return file_export_unavailable_response(request);
}
};
let export_result =
tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)).await;
let byte_count = match export_result {
Ok(Ok(byte_count)) => byte_count,
Ok(Err(_error)) => {
log_desktop_file_export_failure("write.audio");
return file_export_unavailable_response(request);
}
Err(_error) => {
log_desktop_file_export_failure("write.audio.join");
return file_export_unavailable_response(request);
}
};
ok(
request.id.clone(),
json!({
"action": "saved",
"fileName": file_name,
"bytes": byte_count,
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_bridge::protocol::request;
#[test]
fn desktop_file_cancelled_responses_are_stable() {
let request = request("file.importText");
let export_error = file_export_cancelled_response(&request)
.error
.expect("export error");
assert_eq!(export_error.code, "cancelled");
assert_eq!(export_error.message, "file export cancelled");
let import_error = file_import_cancelled_response(&request)
.error
.expect("import error");
assert_eq!(import_error.code, "cancelled");
assert_eq!(import_error.message, "file import cancelled");
}
#[test]
fn desktop_file_unavailable_responses_are_stable() {
let request = request("file.importText");
let export_error = file_export_unavailable_response(&request)
.error
.expect("export error");
assert_eq!(export_error.code, "host_error");
assert_eq!(export_error.message, "file export unavailable");
let import_error = file_import_unavailable_response(&request)
.error
.expect("import error");
assert_eq!(import_error.code, "host_error");
assert_eq!(import_error.message, "file import unavailable");
}
#[test]
fn desktop_file_failures_are_logged_without_exposing_native_detail() {
assert!(!log_desktop_file_export_failure("path.convert"));
assert!(!log_desktop_file_export_failure("write.text"));
assert!(!log_desktop_file_import_failure("path.convert"));
assert!(!log_desktop_file_import_failure("read.text.join"));
}
#[test]
fn desktop_file_failures_log_stable_label_only() {
assert!(!log_desktop_file_export_failure("write.image.join"));
assert!(!log_desktop_file_import_failure("read.audio.join"));
}
#[test]
fn desktop_file_import_validation_errors_keep_stable_invalid_request() {
let request = request("file.importText");
let error = file_import_payload_error_response(
&request,
"read.text",
ImportFilePayloadError::InvalidRequest("text MIME must be allowed"),
)
.error
.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "text MIME must be allowed");
}
#[test]
fn desktop_file_import_native_read_errors_use_stable_host_error() {
let request = request("file.importText");
let error = file_import_payload_error_response(
&request,
"read.text",
ImportFilePayloadError::NativeRead,
)
.error
.expect("error");
assert_eq!(error.code, "host_error");
assert_eq!(error.message, "file import unavailable");
}
}
@@ -0,0 +1,85 @@
mod appearance;
pub(crate) mod capabilities;
mod badge;
mod clipboard;
mod dispatch;
pub(crate) mod file_payloads;
pub(crate) mod files;
mod navigation;
mod network;
mod notifications;
pub(crate) mod protocol;
mod runtime;
mod share;
mod title;
pub(crate) use protocol::HostBridgeReplayState;
pub(crate) use share::DesktopShareState;
use crate::host_bridge::dispatch::execute_host_bridge_request;
use crate::host_bridge::protocol::{
normalize_request_id, validate_request, HostBridgeReplayReservation, HostBridgeRequest,
HostBridgeResponse,
};
fn prepare_host_bridge_request(
request: &mut HostBridgeRequest,
) -> Option<HostBridgeResponse> {
if let Some(response) = validate_request(request) {
return Some(response);
}
request.id = normalize_request_id(&request.id).unwrap_or_else(|| request.id.clone());
None
}
#[tauri::command]
pub(crate) async fn host_bridge_request(
app: tauri::AppHandle,
replay_state: tauri::State<'_, HostBridgeReplayState>,
mut request: HostBridgeRequest,
) -> Result<HostBridgeResponse, String> {
if let Some(response) = prepare_host_bridge_request(&mut request) {
return Ok(response);
}
let response = match replay_state.reserve(&request.id) {
Err(response) => response,
Ok(HostBridgeReplayReservation::Wait(slot)) => {
HostBridgeReplayState::wait_for_response(slot)
}
Ok(HostBridgeReplayReservation::Execute(slot)) => {
let response = execute_host_bridge_request(app, request).await;
replay_state.complete(slot, response)
}
};
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_bridge::protocol::request;
#[test]
fn host_bridge_request_rejects_invalid_requests_before_replay() {
let replay_state = HostBridgeReplayState::default();
let mut invalid = request("share.open");
invalid.id = " request-1 ".to_string();
invalid.bridge = "OtherBridge".to_string();
let response =
prepare_host_bridge_request(&mut invalid).expect("invalid envelope");
assert!(!response.ok);
assert_eq!(response.id, "request-1");
assert_eq!(response.error.expect("error").code, "invalid_request");
match replay_state.reserve("request-1").expect("replay reservation") {
HostBridgeReplayReservation::Execute(_) => {}
HostBridgeReplayReservation::Wait(_) => {
panic!("invalid request must not reserve replay slot")
}
}
}
}
@@ -0,0 +1,265 @@
use crate::host_bridge::protocol::{
failed, ok, required_string_payload, HostBridgeRequest, HostBridgeResponse,
};
use crate::shell::webview::{
normalize_external_url, normalize_native_page_url, open_normalized_desktop_external_url,
};
use serde_json::json;
use tauri::Manager;
fn desktop_external_url_from_request(
request: &HostBridgeRequest,
) -> Result<String, HostBridgeResponse> {
required_string_payload(request, "url")
.ok()
.and_then(normalize_external_url)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"url must use an allowed external protocol",
)
})
}
fn desktop_native_page_url_from_request(
request: &HostBridgeRequest,
) -> Result<tauri::Url, HostBridgeResponse> {
required_string_payload(request, "url")
.ok()
.and_then(normalize_native_page_url)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"url must use an allowed same-origin H5 route",
)
})
}
pub(crate) fn open_desktop_host_bridge_external_url(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let url = match desktop_external_url_from_request(request) {
Ok(url) => url,
Err(response) => return response,
};
match open_normalized_desktop_external_url(app, url) {
Ok(()) => ok(request.id.clone(), json!(true)),
Err(_error) => {
log_desktop_navigation_failure("external.open");
external_url_unavailable_response(request)
}
}
}
fn external_url_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(
request.id.clone(),
"host_error",
"external URL cannot be opened",
)
}
pub(crate) fn open_desktop_host_bridge_native_page(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let url = match desktop_native_page_url_from_request(request) {
Ok(url) => url,
Err(response) => return response,
};
match app.get_webview_window("main") {
Some(window) => match window.navigate(url) {
Ok(()) => ok(request.id.clone(), json!(true)),
Err(_error) => {
log_desktop_navigation_failure("native.navigate");
native_page_unavailable_response(request)
}
},
None => {
log_desktop_navigation_failure("native.window");
native_page_unavailable_response(request)
}
}
}
fn native_page_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(request.id.clone(), "host_error", "native page unavailable")
}
fn log_desktop_navigation_failure(label: &str) -> bool {
eprintln!("desktop navigation failed for {label}");
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_bridge::protocol::request;
use serde_json::json;
fn request_with_url(method: &str, url: &str) -> HostBridgeRequest {
let mut request = request(method);
request.payload = Some(json!({ "url": url }));
request
}
#[test]
fn desktop_external_url_request_accepts_only_safe_system_protocols() {
let request = request_with_url("app.openExternalUrl", " https://example.com/share ");
assert_eq!(
desktop_external_url_from_request(&request).expect("external url"),
"https://example.com/share"
);
for url in [
"",
"/works/detail?work=PZ-1",
"javascript:alert(1)",
"file:///etc/passwd",
"https://example.com/\nnext",
] {
let response = desktop_external_url_from_request(&request_with_url(
"app.openExternalUrl",
url,
))
.expect_err("invalid external url");
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(
error.message,
"url must use an allowed external protocol"
);
}
}
#[test]
fn external_url_unavailable_response_is_stable() {
let response = external_url_unavailable_response(&request("app.openExternalUrl"));
assert!(!response.ok);
let error = response.error.expect("external url error");
assert_eq!(error.code, "host_error");
assert_eq!(error.message, "external URL cannot be opened");
}
#[test]
fn desktop_native_page_request_accepts_only_same_origin_h5_routes() {
let route = desktop_native_page_url_from_request(&request_with_url(
"navigation.openNativePage",
"/works/detail?work=PZ-1",
))
.expect("native page url");
assert_eq!(
route.origin().ascii_serialization(),
"https://www.genarrative.world"
);
assert_eq!(route.path(), "/works/detail");
assert!(route
.query_pairs()
.any(|(key, value)| key == "hostShell" && value == "tauri_desktop"));
for url in [
"https://example.com/works",
"//example.com/works",
"javascript:alert(1)",
"https://www.genarrative.world/\nnext",
] {
let response = desktop_native_page_url_from_request(&request_with_url(
"navigation.openNativePage",
url,
))
.expect_err("invalid native page url");
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(
error.message,
"url must use an allowed same-origin H5 route"
);
}
}
#[test]
fn native_page_unavailable_response_is_stable() {
let response = native_page_unavailable_response(&request("navigation.openNativePage"));
assert!(!response.ok);
let error = response.error.expect("native page error");
assert_eq!(error.code, "host_error");
assert_eq!(error.message, "native page unavailable");
}
#[test]
fn navigation_failures_are_logged_without_exposing_native_detail() {
assert!(!log_desktop_navigation_failure("external.open"));
assert!(!log_desktop_navigation_failure("native.navigate"));
assert!(!log_desktop_navigation_failure("native.window"));
}
#[test]
fn navigation_failures_log_stable_label_only() {
assert!(!log_desktop_navigation_failure("external.open"));
}
}
pub(crate) fn reload_desktop_host_bridge_webview(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
match app.get_webview_window("main") {
Some(window) => match window.reload() {
Ok(()) => ok(request.id.clone(), json!(true)),
Err(_error) => {
log_desktop_navigation_failure("webview.reload");
webview_reload_unavailable_response(request)
}
},
None => {
log_desktop_navigation_failure("webview.window");
webview_reload_unavailable_response(request)
}
}
}
fn webview_reload_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(
request.id.clone(),
"host_error",
"webview reload unavailable",
)
}
#[cfg(test)]
mod reload_tests {
use super::*;
use crate::host_bridge::protocol::request;
#[test]
fn webview_reload_unavailable_response_is_stable() {
let response = webview_reload_unavailable_response(&request("app.reloadWebView"));
assert!(!response.ok);
let error = response.error.expect("webview reload error");
assert_eq!(error.code, "host_error");
assert_eq!(error.message, "webview reload unavailable");
}
#[test]
fn webview_reload_failures_are_logged_without_exposing_native_detail() {
assert!(!log_desktop_navigation_failure("webview.reload"));
assert!(!log_desktop_navigation_failure("webview.window"));
}
#[test]
fn webview_reload_failures_log_stable_label_only() {
assert!(!log_desktop_navigation_failure("webview.reload"));
}
}

Some files were not shown because too many files have changed in this diff Show More