diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 091bde42a..14481ec49 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -36,6 +36,7 @@ const nativeAppHostBridgePath = new URL( const nativeAppHostBridgeSource = fs.readFileSync(nativeAppHostBridgePath, 'utf8'); const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url); const main = fs.readFileSync(mainPath, 'utf8'); +const rustSourceDir = new URL('../src-tauri/src/', import.meta.url); const productionSourceRoots = [ new URL('../package.json', import.meta.url), new URL('../src-tauri/Cargo.toml', import.meta.url), @@ -261,7 +262,7 @@ function assertNoBlockedDesktopSdkSnippets() { const sources = [ ['tauri.conf.json', JSON.stringify(config)], ['build.rs', buildScript], - ['main.rs', main], + ['src-tauri/src', rustHostSource], ]; for (const [sourceName, source] of sources) { @@ -392,6 +393,14 @@ function collectProductionSourceFiles(entry) { return [entry]; } +const productionSourceFiles = productionSourceRoots.flatMap((root) => + collectProductionSourceFiles(root), +); +const rustHostSourceFiles = collectProductionSourceFiles(rustSourceDir); +const rustHostSource = rustHostSourceFiles + .map((file) => fs.readFileSync(file, 'utf8')) + .join('\n'); + function assertNoDevScaffoldTerms(files) { for (const file of files) { const source = fs.readFileSync(file, 'utf8'); @@ -415,9 +424,7 @@ function assertNoDevScaffoldTerms(files) { } } -assertNoDevScaffoldTerms( - productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)), -); +assertNoDevScaffoldTerms(productionSourceFiles); assertNoBlockedNpmDependencies(); assertNoTauriGuestNpmDependencies(packageConfig, 'desktop shell package'); assertNoTauriGuestNpmDependencies(rootPackageConfig, 'root H5 package'); @@ -721,6 +728,10 @@ function extractTauriInvokeCommands(source) { .filter(Boolean); } +function collectRustSourceBasenames(files) { + return files.map((file) => file.pathname.split('/').pop()).sort(); +} + function extractNativeAppTauriInvokeCommands(source) { return [...source.matchAll(/\btauriInvoke[\s\S]*?\(\s*([^,\n]+?)\s*,/g)] .map((match) => match[1].trim()) @@ -785,9 +796,9 @@ const sharedMethods = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_METHODS', ); -const desktopMethods = extractRustStringArrayConst(main, 'HOST_BRIDGE_METHODS'); -const desktopCapabilities = extractDesktopCapabilities(main); -const desktopHandledMethods = extractDesktopHandledMethods(main); +const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS'); +const desktopCapabilities = extractDesktopCapabilities(rustHostSource); +const desktopHandledMethods = extractDesktopHandledMethods(rustHostSource); const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request']; assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist'); const unknownHandledDesktopMethods = desktopHandledMethods.filter( @@ -861,7 +872,7 @@ if (extractCargoPackageString(cargoManifest, 'version') !== config.version) { throw new Error('desktop shell Cargo package version must match tauri.conf.json version'); } -if (!main.includes('host_version: env!("CARGO_PKG_VERSION")')) { +if (!rustHostSource.includes('host_version: env!("CARGO_PKG_VERSION")')) { throw new Error('desktop shell runtime response must use the Cargo package version'); } @@ -1012,7 +1023,16 @@ const sharedTauriCommand = extractTsStringConst( 'HOST_BRIDGE_TAURI_COMMAND', ); const allowedTauriCommands = [sharedTauriCommand]; -const requiredMainSnippets = [ +const requiredRustHostModules = [ + 'desktop_host_bridge.rs', + 'desktop_host_bridge_files.rs', + 'desktop_host_bridge_protocol.rs', + 'desktop_host_bridge_share.rs', + 'desktop_shell_tray.rs', + 'desktop_shell_webview.rs', + 'main.rs', +]; +const requiredRustHostSnippets = [ 'tauri_plugin_single_instance::init', 'resolve_desktop_single_instance_action', 'tauri_plugin_clipboard_manager::init()', @@ -1117,6 +1137,11 @@ assertSameList( ['host_bridge_request'], 'shared Tauri HostBridge command', ); +for (const moduleName of requiredRustHostModules) { + if (!collectRustSourceBasenames(rustHostSourceFiles).includes(moduleName)) { + throw new Error(`desktop shell Rust bridge module missing ${moduleName}`); + } +} assertSameList( extractNativeAppTauriInvokeCommands(nativeAppHostBridgeSource), ['HOST_BRIDGE_TAURI_COMMAND'], @@ -1189,8 +1214,8 @@ if (main.includes('single-instance",') || main.includes('"single-instance"')) { throw new Error('desktop shell must not emit secondary-instance argv to H5'); } -for (const snippet of requiredMainSnippets) { - if (!main.includes(snippet)) { +for (const snippet of requiredRustHostSnippets) { + if (!rustHostSource.includes(snippet)) { throw new Error(`desktop shell Rust host bridge missing ${snippet}`); } } diff --git a/apps/desktop-shell/src-tauri/src/desktop_host_bridge.rs b/apps/desktop-shell/src-tauri/src/desktop_host_bridge.rs new file mode 100644 index 000000000..062f61afd --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/desktop_host_bridge.rs @@ -0,0 +1,723 @@ +use crate::desktop_host_bridge_files::{ + export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload, + import_image_file_payload, import_text_file_payload, write_export_bytes_file, + write_export_text_file, +}; +use crate::desktop_host_bridge_protocol::{ + capabilities, failed, normalize_request_id, ok, required_string_payload, validate_request, + HostBridgeReplayReservation, HostBridgeReplayState, HostBridgeRequest, HostBridgeResponse, + HostBridgeRuntime, HOST_BRIDGE_VERSION, +}; +use crate::desktop_host_bridge_share::{share_text_from_request, DesktopShareState}; +use crate::desktop_shell_webview::{ + color_scheme_from_theme, desktop_platform, normalize_external_url, normalize_native_page_url, + resolve_desktop_network_status, +}; +use serde_json::{json, Value}; +use tauri::Manager; +use tauri_plugin_clipboard_manager::ClipboardExt; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_notification::NotificationExt; +use tauri_plugin_opener::OpenerExt; + +const BADGE_COUNT_MAX: i64 = 99999; +const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80; +const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240; +const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000; + +fn normalize_window_title(raw_title: &str) -> Option { + let title = raw_title.trim(); + if title.is_empty() || title.chars().any(char::is_control) { + return None; + } + + Some(title.chars().take(80).collect()) +} + +fn badge_count_payload(request: &HostBridgeRequest) -> Result, 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) }) +} + +fn normalize_clipboard_text(text: String) -> String { + text.chars().take(CLIPBOARD_TEXT_MAX_LENGTH).collect() +} + +fn normalize_plain_text( + value: Option<&str>, + max_length: usize, + required: bool, +) -> Option> { + let Some(value) = value else { + return if required { None } else { Some(None) }; + }; + if value.chars().any(char::is_control) { + return None; + } + + let text = value.split_whitespace().collect::>().join(" "); + if text.is_empty() { + return if required { None } else { Some(None) }; + } + + Some(Some(text.chars().take(max_length).collect())) +} + +fn local_notification_payload( + request: &HostBridgeRequest, +) -> Result<(String, Option), HostBridgeResponse> { + let payload = request + .payload + .as_ref() + .ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required"))?; + let title = match normalize_plain_text( + payload.get("title").and_then(Value::as_str), + LOCAL_NOTIFICATION_TITLE_MAX_LENGTH, + true, + ) { + Some(Some(title)) => title, + _ => { + return Err(failed( + request.id.clone(), + "invalid_request", + "title is required", + )) + } + }; + let body = match normalize_plain_text( + payload.get("body").and_then(Value::as_str), + LOCAL_NOTIFICATION_BODY_MAX_LENGTH, + false, + ) { + Some(body) => body, + None => { + return Err(failed( + request.id.clone(), + "invalid_request", + "body is invalid", + )) + } + }; + + Ok((title, body)) +} + +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" => ok( + request.id, + json!(HostBridgeRuntime { + shell: "tauri_desktop", + platform: desktop_platform(), + host_version: env!("CARGO_PKG_VERSION"), + bridge_version: HOST_BRIDGE_VERSION, + capabilities: capabilities(), + }), + ), + _ => failed( + request.id, + "unsupported_method", + format!("{} unsupported in desktop shell", request.method), + ), + } +} + +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" => { + let url = match required_string_payload(&request, "url") + .ok() + .and_then(normalize_external_url) + { + Some(url) => url, + None => { + return failed( + request.id, + "invalid_request", + "url must use an allowed external protocol", + ) + } + }; + + match app.opener().open_url(url, None::<&str>) { + Ok(()) => ok(request.id, json!(true)), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "appearance.getColorScheme" => match app.get_webview_window("main") { + Some(window) => match window.theme() { + Ok(theme) => ok( + request.id, + json!({ + "colorScheme": color_scheme_from_theme(theme) + }), + ), + Err(error) => failed(request.id, "host_error", error.to_string()), + }, + None => failed(request.id, "host_error", "main window not found"), + }, + "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"), + } + } + "app.reloadWebView" => match app.get_webview_window("main") { + Some(window) => match window.reload() { + 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, + Err(response) => return response, + }; + + match app.clipboard().write_text(text) { + Ok(()) => ok(request.id, json!(true)), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "clipboard.readText" => match app.clipboard().read_text() { + Ok(text) => ok( + request.id, + json!({ + "text": normalize_clipboard_text(text), + }), + ), + Err(error) => failed(request.id, "host_error", error.to_string()), + }, + "file.exportText" => { + 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 failed(request.id, "cancelled", "file export cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + 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)) => return failed(request.id, "host_error", error), + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + + ok( + request.id, + json!({ + "action": "saved", + "fileName": file_name, + "bytes": bytes, + }), + ) + } + "file.importText" => { + 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 failed(request.id, "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let import_result = + tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await; + match import_result { + Ok(Ok(payload)) => ok(request.id, payload), + Ok(Err(error)) => failed(request.id, "invalid_request", error), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "file.exportImage" => { + 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 failed(request.id, "cancelled", "file export cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + 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)) => return failed(request.id, "host_error", error), + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + ok( + request.id, + json!({ + "action": "saved", + "fileName": file_name, + "bytes": byte_count, + }), + ) + } + "file.importImage" => { + let file_path = app + .dialog() + .file() + .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return failed(request.id, "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + 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, payload), + Ok(Err(error)) => failed(request.id, "invalid_request", error), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "file.importAudio" => { + 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 failed(request.id, "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let import_result = + tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await; + match import_result { + Ok(Ok(payload)) => ok(request.id, payload), + Ok(Err(error)) => failed(request.id, "invalid_request", error), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "file.exportAudio" => { + 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 failed(request.id, "cancelled", "file export cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + 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)) => return failed(request.id, "host_error", error), + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + ok( + request.id, + json!({ + "action": "saved", + "fileName": file_name, + "bytes": byte_count, + }), + ) + } + "app.setTitle" => { + let title = match required_string_payload(&request, "title") + .ok() + .and_then(normalize_window_title) + { + Some(title) => title, + None => return failed(request.id, "invalid_request", "title is required"), + }; + + match app.get_webview_window("main") { + Some(window) => match window.set_title(&title) { + 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"), + } + } + "app.setBadgeCount" => { + let count = match badge_count_payload(&request) { + Ok(count) => count, + Err(response) => return response, + }; + + match app.get_webview_window("main") { + Some(window) => match window.set_badge_count(count) { + 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"), + } + } + "network.status" => { + let network_status = + tauri::async_runtime::spawn_blocking(resolve_desktop_network_status).await; + match network_status { + Ok(status) => ok(request.id, status), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "notification.showLocal" => { + let (title, body) = match local_notification_payload(&request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let mut notification = app.notification().builder().title(title); + if let Some(body) = body { + notification = notification.body(body); + } + + match notification.show() { + Ok(()) => ok(request.id, json!(true)), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "share.setTarget" => { + let target = request + .payload + .as_ref() + .and_then(|payload| payload.get("target")); + let Some(target) = target else { + return failed(request.id, "invalid_request", "target is required"); + }; + let share_state = app.state::(); + + let response = match share_state.target.lock() { + Ok(mut current_target) => { + *current_target = Some(target.clone()); + ok(request.id, json!(true)) + } + Err(_) => failed(request.id, "host_error", "share target lock poisoned"), + }; + response + } + "share.open" => { + let share_state = app.state::(); + let share_text = match share_text_from_request(&request, &share_state) { + Ok(text) => text, + Err(response) => return response, + }; + + match app.clipboard().write_text(share_text) { + Ok(()) => ok( + request.id, + json!({ + "action": "copied_to_clipboard" + }), + ), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + _ => resolve_host_bridge_request(request), + } +} + +#[tauri::command] +pub(crate) async fn host_bridge_request( + app: tauri::AppHandle, + replay_state: tauri::State<'_, HostBridgeReplayState>, + mut request: HostBridgeRequest, +) -> Result { + if let Some(response) = validate_request(&request) { + return Ok(response); + } + request.id = normalize_request_id(&request.id).unwrap_or(request.id); + + let response = match replay_state.reserve(&request.id) { + HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot), + 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::desktop_host_bridge_protocol::request; + + #[test] + fn runtime_response_reports_tauri_shell() { + let response = resolve_host_bridge_request(request("host.getRuntime")); + + assert!(response.ok); + let result = response.result.expect("runtime result"); + assert_eq!(result["shell"], "tauri_desktop"); + assert_eq!(result["bridgeVersion"], HOST_BRIDGE_VERSION); + assert_eq!(result["capabilities"], json!(capabilities())); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("appearance.getColorScheme"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.lifecycle"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("network.status"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("network.statusChanged"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("share.open"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("share.setTarget"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("navigation.openNativePage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.reloadWebView"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.setTitle"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.setBadgeCount"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("clipboard.readText"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.exportText"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importText"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.exportImage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importImage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importAudio"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.exportAudio"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.imageDropped"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("notification.showLocal"))); + } + + #[test] + fn unsupported_method_is_explicit() { + for method in ["auth.requestLogin", "payment.request"] { + 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)); + } + } + + #[test] + fn clipboard_text_is_truncated_to_contract_limit() { + assert_eq!( + normalize_clipboard_text("作品号 PZ-1".to_string()), + "作品号 PZ-1" + ); + assert_eq!( + normalize_clipboard_text("a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(), + CLIPBOARD_TEXT_MAX_LENGTH + ); + } + + #[test] + fn local_notification_payload_is_normalized() { + let mut request = request("notification.showLocal"); + request.payload = Some(json!({ + "title": " 生成完成 ", + "body": " 作品已准备好 可以试玩 " + })); + + let (title, body) = local_notification_payload(&request).expect("payload"); + + assert_eq!(title, "生成完成"); + assert_eq!(body.as_deref(), Some("作品已准备好 可以试玩")); + } + + #[test] + fn local_notification_payload_rejects_empty_and_control_text() { + let mut empty = request("notification.showLocal"); + empty.payload = Some(json!({ + "title": " " + })); + + let response = local_notification_payload(&empty).expect_err("empty title"); + + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut control = request("notification.showLocal"); + control.payload = Some(json!({ + "title": "生成\n完成" + })); + + let response = local_notification_payload(&control).expect_err("control title"); + + assert_eq!(response.error.expect("error").code, "invalid_request"); + } + + #[test] + fn window_title_normalization_requires_visible_text() { + assert_eq!( + normalize_window_title(" Genarrative "), + Some("Genarrative".to_string()) + ); + assert_eq!(normalize_window_title(""), None); + assert_eq!(normalize_window_title("Genarrative\nDev"), None); + + let long_title = "甲".repeat(120); + assert_eq!( + normalize_window_title(&long_title) + .expect("truncated title") + .chars() + .count(), + 80 + ); + } + + #[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" + ); + } + } +} diff --git a/apps/desktop-shell/src-tauri/src/desktop_host_bridge_files.rs b/apps/desktop-shell/src-tauri/src/desktop_host_bridge_files.rs new file mode 100644 index 000000000..05e4a7ff1 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/desktop_host_bridge_files.rs @@ -0,0 +1,797 @@ +use crate::desktop_host_bridge_protocol::{failed, HostBridgeRequest, HostBridgeResponse}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub(crate) const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024; +const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024; +const EXPORT_AUDIO_MAX_BYTES: usize = 20 * 1024 * 1024; +pub(crate) const IMPORT_TEXT_MAX_BYTES: u64 = 5 * 1024 * 1024; +const IMPORT_IMAGE_MAX_BYTES: u64 = 10 * 1024 * 1024; +const IMPORT_AUDIO_MAX_BYTES: u64 = 20 * 1024 * 1024; +const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt"; +const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120; + +pub(crate) 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) + { + if character.is_control() + || matches!( + character, + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' + ) + { + file_name.push('-'); + last_was_space = false; + continue; + } + + if character.is_whitespace() { + if !last_was_space { + file_name.push(' '); + last_was_space = true; + } + continue; + } + + file_name.push(character); + last_was_space = false; + } + + let file_name = file_name + .trim() + .trim_start_matches(|character| matches!(character, '.' | '-') || character.is_whitespace()) + .trim(); + if file_name.is_empty() { + EXPORT_FILE_NAME_FALLBACK.to_string() + } else { + file_name.to_string() + } +} + +pub(crate) fn export_text_payload( + request: &HostBridgeRequest, +) -> Result<(String, String), HostBridgeResponse> { + let payload = request.payload.as_ref().ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "fileName and content are required", + ) + })?; + let file_name = payload + .get("fileName") + .and_then(Value::as_str) + .map(normalize_export_file_name) + .unwrap_or_else(|| EXPORT_FILE_NAME_FALLBACK.to_string()); + let content = payload + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| failed(request.id.clone(), "invalid_request", "content is required"))?; + + if content.len() > EXPORT_TEXT_MAX_BYTES { + return Err(failed( + request.id.clone(), + "invalid_request", + "content exceeds file export size limit", + )); + } + + Ok((file_name, content.to_string())) +} + +pub(crate) fn write_export_text_file(path: PathBuf, content: String) -> Result { + fs::write(path, content.as_bytes()).map_err(|error| error.to_string())?; + Ok(content.len()) +} + +fn import_text_mime_type(path: &Path) -> Option<&'static str> { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("txt") => Some("text/plain"), + Some("md") | Some("markdown") => Some("text/markdown"), + Some("csv") => Some("text/csv"), + Some("json") => Some("application/json"), + _ => None, + } +} + +pub(crate) fn import_text_file_payload(path: PathBuf) -> Result { + if !path.is_file() { + return Err("text file is required".to_string()); + } + + let mime_type = + import_text_mime_type(&path).ok_or_else(|| "text MIME must be allowed".to_string())?; + let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; + let byte_count = metadata.len(); + if byte_count == 0 || byte_count > IMPORT_TEXT_MAX_BYTES { + return Err("text exceeds import size limit".to_string()); + } + + let content = fs::read_to_string(&path).map_err(|error| error.to_string())?; + let byte_count = content.len() as u64; + if byte_count == 0 || byte_count > IMPORT_TEXT_MAX_BYTES { + return Err("text exceeds import size limit".to_string()); + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map(normalize_export_file_name) + .unwrap_or_else(|| "genarrative-import.txt".to_string()); + + Ok(json!({ + "action": "selected", + "fileName": file_name, + "content": content, + "mimeType": mime_type, + "bytes": byte_count, + })) +} + +fn export_image_extension(mime_type: &str) -> Option<&'static str> { + match mime_type { + "image/png" => Some("png"), + "image/jpeg" => Some("jpg"), + "image/webp" => Some("webp"), + _ => None, + } +} + +pub(crate) fn import_image_mime_type(path: &Path) -> Option<&'static str> { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("png") => Some("image/png"), + Some("jpg") | Some("jpeg") => Some("image/jpeg"), + Some("webp") => Some("image/webp"), + _ => None, + } +} + +fn import_audio_mime_type(path: &Path) -> Option<&'static str> { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("mp3") => Some("audio/mpeg"), + Some("m4a") | Some("mp4") => Some("audio/mp4"), + Some("wav") => Some("audio/wav"), + Some("ogg") => Some("audio/ogg"), + Some("webm") => Some("audio/webm"), + _ => None, + } +} + +fn export_audio_extension(mime_type: &str) -> Option<&'static str> { + match mime_type { + "audio/mpeg" => Some("mp3"), + "audio/mp4" => Some("m4a"), + "audio/wav" => Some("wav"), + "audio/ogg" => Some("ogg"), + "audio/webm" => Some("webm"), + _ => None, + } +} + +fn normalize_export_image_file_name(raw_file_name: &str, mime_type: &str) -> String { + let mut file_name = normalize_export_file_name(raw_file_name); + let extension = export_image_extension(mime_type).unwrap_or("png"); + if !file_name + .to_ascii_lowercase() + .ends_with(&format!(".{}", extension)) + { + file_name.push('.'); + file_name.push_str(extension); + } + file_name +} + +fn normalize_export_audio_file_name(raw_file_name: &str, mime_type: &str) -> String { + let mut file_name = normalize_export_file_name(raw_file_name); + let extension = export_audio_extension(mime_type).unwrap_or("webm"); + if !file_name + .to_ascii_lowercase() + .ends_with(&format!(".{}", extension)) + { + file_name.push('.'); + file_name.push_str(extension); + } + file_name +} + +pub(crate) fn export_image_payload( + request: &HostBridgeRequest, +) -> Result<(String, Vec), HostBridgeResponse> { + let payload = request.payload.as_ref().ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "fileName, mimeType and base64Data are required", + ) + })?; + let mime_type = payload + .get("mimeType") + .and_then(Value::as_str) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "mimeType is required", + ) + })?; + if export_image_extension(mime_type).is_none() { + return Err(failed( + request.id.clone(), + "invalid_request", + "mimeType must be an allowed image type", + )); + } + + let base64_data = payload + .get("base64Data") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "base64Data is required", + ) + })?; + let bytes = BASE64_STANDARD.decode(base64_data).map_err(|_| { + failed( + request.id.clone(), + "invalid_request", + "base64Data is invalid", + ) + })?; + if bytes.len() > EXPORT_IMAGE_MAX_BYTES { + return Err(failed( + request.id.clone(), + "invalid_request", + "image exceeds file export size limit", + )); + } + + let file_name = payload + .get("fileName") + .and_then(Value::as_str) + .map(|file_name| normalize_export_image_file_name(file_name, mime_type)) + .unwrap_or_else(|| normalize_export_image_file_name("genarrative-share-card", mime_type)); + + Ok((file_name, bytes)) +} + +pub(crate) fn export_audio_payload( + request: &HostBridgeRequest, +) -> Result<(String, Vec), HostBridgeResponse> { + let payload = request.payload.as_ref().ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "fileName, mimeType and base64Data are required", + ) + })?; + let mime_type = payload + .get("mimeType") + .and_then(Value::as_str) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "mimeType is required", + ) + })?; + if export_audio_extension(mime_type).is_none() { + return Err(failed( + request.id.clone(), + "invalid_request", + "mimeType must be an allowed audio type", + )); + } + + let base64_data = payload + .get("base64Data") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "base64Data is required", + ) + })?; + let bytes = BASE64_STANDARD.decode(base64_data).map_err(|_| { + failed( + request.id.clone(), + "invalid_request", + "base64Data is invalid", + ) + })?; + if bytes.is_empty() || bytes.len() > EXPORT_AUDIO_MAX_BYTES { + return Err(failed( + request.id.clone(), + "invalid_request", + "audio exceeds file export size limit", + )); + } + + let file_name = payload + .get("fileName") + .and_then(Value::as_str) + .map(|file_name| normalize_export_audio_file_name(file_name, mime_type)) + .unwrap_or_else(|| normalize_export_audio_file_name("genarrative-audio", mime_type)); + + Ok((file_name, bytes)) +} + +pub(crate) fn write_export_bytes_file(path: PathBuf, bytes: Vec) -> Result { + let byte_count = bytes.len(); + fs::write(path, bytes).map_err(|error| error.to_string())?; + Ok(byte_count) +} + +pub(crate) fn import_image_file_payload( + path: PathBuf, + action: &'static str, + position: Option<(i32, i32)>, +) -> Result { + if !path.is_file() { + return Err("image file is required".to_string()); + } + + let mime_type = + import_image_mime_type(&path).ok_or_else(|| "image MIME must be allowed".to_string())?; + let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; + let byte_count = metadata.len(); + if byte_count == 0 || byte_count > IMPORT_IMAGE_MAX_BYTES { + return Err("image exceeds import size limit".to_string()); + } + + let bytes = fs::read(&path).map_err(|error| error.to_string())?; + let byte_count = bytes.len() as u64; + if byte_count == 0 || byte_count > IMPORT_IMAGE_MAX_BYTES { + return Err("image exceeds import size limit".to_string()); + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map(normalize_export_file_name) + .unwrap_or_else(|| "genarrative-import.png".to_string()); + let mut payload = json!({ + "action": action, + "fileName": file_name, + "base64Data": BASE64_STANDARD.encode(bytes), + "mimeType": mime_type, + "bytes": byte_count, + }); + + if let Some((x, y)) = position { + payload["position"] = json!({ + "x": x, + "y": y, + }); + } + + Ok(payload) +} + +pub(crate) fn import_audio_file_payload(path: PathBuf) -> Result { + if !path.is_file() { + return Err("audio file is required".to_string()); + } + + let mime_type = + import_audio_mime_type(&path).ok_or_else(|| "audio MIME must be allowed".to_string())?; + let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; + let byte_count = metadata.len(); + if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES { + return Err("audio exceeds import size limit".to_string()); + } + + let bytes = fs::read(&path).map_err(|error| error.to_string())?; + let byte_count = bytes.len() as u64; + if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES { + return Err("audio exceeds import size limit".to_string()); + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map(normalize_export_file_name) + .unwrap_or_else(|| "genarrative-import-audio.webm".to_string()); + + Ok(json!({ + "action": "selected", + "fileName": file_name, + "base64Data": BASE64_STANDARD.encode(bytes), + "mimeType": mime_type, + "bytes": byte_count, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::desktop_host_bridge_protocol::request; + + #[test] + fn export_file_name_normalization_rejects_path_like_characters() { + assert_eq!( + normalize_export_file_name(" 作品:记录?.txt "), + "作品-记录-.txt" + ); + assert_eq!(normalize_export_file_name("../secret.txt"), "secret.txt"); + assert_eq!(normalize_export_file_name(""), EXPORT_FILE_NAME_FALLBACK); + + let long_file_name = "甲".repeat(140); + assert_eq!( + normalize_export_file_name(&long_file_name).chars().count(), + EXPORT_FILE_NAME_MAX_LENGTH + ); + } + + #[test] + fn export_text_payload_requires_text_content() { + let mut invalid = request("file.exportText"); + invalid.payload = Some(json!({ + "fileName": "作品记录.txt", + "content": 123 + })); + + let response = export_text_payload(&invalid).expect_err("invalid content"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "content is required"); + } + + #[test] + fn export_text_payload_rejects_oversized_content() { + let mut invalid = request("file.exportText"); + invalid.payload = Some(json!({ + "fileName": "作品记录.txt", + "content": "a".repeat(EXPORT_TEXT_MAX_BYTES + 1) + })); + + let response = export_text_payload(&invalid).expect_err("oversized content"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "content exceeds file export size limit"); + } + + #[test] + fn write_export_text_file_persists_utf8_content() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-export-{}.txt", + std::process::id() + )); + + let bytes = write_export_text_file(path.clone(), "暖灯猫街".to_string()) + .expect("write export file"); + + assert_eq!(bytes, "暖灯猫街".len()); + assert_eq!( + fs::read_to_string(&path).expect("read export file"), + "暖灯猫街" + ); + fs::remove_file(path).expect("remove export file"); + } + + #[test] + fn import_text_file_payload_reads_allowed_text_without_exposing_path() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-text-{}.md", + std::process::id() + )); + fs::write(&path, "暖灯猫街").expect("write import text"); + + let payload = import_text_file_payload(path.clone()).expect("payload"); + + assert_eq!(payload["action"], "selected"); + assert_eq!( + payload["fileName"], + path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["content"], "暖灯猫街"); + assert_eq!(payload["mimeType"], "text/markdown"); + assert_eq!(payload["bytes"], "暖灯猫街".len() as u64); + assert!(!payload + .to_string() + .contains(path.to_string_lossy().as_ref())); + + fs::remove_file(path).expect("remove import text"); + } + + #[test] + fn import_text_file_payload_rejects_invalid_or_oversized_text() { + let image_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-text-{}.png", + std::process::id() + )); + fs::write(&image_path, "text").expect("write image-like text"); + assert_eq!( + import_text_file_payload(image_path.clone()).unwrap_err(), + "text MIME must be allowed" + ); + fs::remove_file(image_path).expect("remove image-like text"); + + let large_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-text-large-{}.txt", + std::process::id() + )); + fs::write(&large_path, "a".repeat(IMPORT_TEXT_MAX_BYTES as usize + 1)) + .expect("write large text"); + assert_eq!( + import_text_file_payload(large_path.clone()).unwrap_err(), + "text exceeds import size limit" + ); + fs::remove_file(large_path).expect("remove large text"); + } + + #[test] + fn export_image_payload_decodes_allowed_image_base64() { + let mut valid = request("file.exportImage"); + valid.payload = Some(json!({ + "fileName": "分享:卡?.png", + "base64Data": "c2hhcmUtY2FyZA==", + "mimeType": "image/png" + })); + + let (file_name, bytes) = export_image_payload(&valid).expect("image payload"); + + assert_eq!(file_name, "分享-卡-.png"); + assert_eq!(bytes, b"share-card"); + } + + #[test] + fn import_image_file_payload_reads_allowed_image_without_exposing_path() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-{}.png", + std::process::id() + )); + fs::write(&path, b"image").expect("write import image"); + + let payload = + import_image_file_payload(path.clone(), "selected", Some((12, 24))).expect("payload"); + + assert_eq!(payload["action"], "selected"); + assert_eq!( + payload["fileName"], + path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["base64Data"], "aW1hZ2U="); + assert_eq!(payload["mimeType"], "image/png"); + assert_eq!(payload["bytes"], 5); + assert_eq!(payload["position"], json!({ "x": 12, "y": 24 })); + assert!(!payload + .to_string() + .contains(path.to_string_lossy().as_ref())); + + fs::remove_file(path).expect("remove import image"); + } + + #[test] + fn import_image_file_payload_rejects_invalid_or_oversized_images() { + let text_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-{}.txt", + std::process::id() + )); + fs::write(&text_path, b"text").expect("write text file"); + assert_eq!( + import_image_file_payload(text_path.clone(), "selected", None).unwrap_err(), + "image MIME must be allowed" + ); + fs::remove_file(text_path).expect("remove text file"); + + let large_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-large-{}.webp", + std::process::id() + )); + fs::write( + &large_path, + vec![1u8; (IMPORT_IMAGE_MAX_BYTES + 1) as usize], + ) + .expect("write large image"); + assert_eq!( + import_image_file_payload(large_path.clone(), "selected", None).unwrap_err(), + "image exceeds import size limit" + ); + fs::remove_file(large_path).expect("remove large image"); + } + + #[test] + fn import_audio_file_payload_reads_allowed_audio_without_exposing_path() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-{}.webm", + std::process::id() + )); + fs::write(&path, b"audio").expect("write import audio"); + + let payload = import_audio_file_payload(path.clone()).expect("payload"); + + assert_eq!(payload["action"], "selected"); + assert_eq!( + payload["fileName"], + path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["base64Data"], "YXVkaW8="); + assert_eq!(payload["mimeType"], "audio/webm"); + assert_eq!(payload["bytes"], 5); + assert!(!payload + .to_string() + .contains(path.to_string_lossy().as_ref())); + + fs::remove_file(path).expect("remove import audio"); + } + + #[test] + fn import_audio_file_payload_rejects_invalid_or_oversized_audio() { + let text_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-{}.txt", + std::process::id() + )); + fs::write(&text_path, b"audio").expect("write text file"); + assert_eq!( + import_audio_file_payload(text_path.clone()).unwrap_err(), + "audio MIME must be allowed" + ); + fs::remove_file(text_path).expect("remove text file"); + + let large_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-large-{}.mp3", + std::process::id() + )); + fs::write( + &large_path, + vec![1u8; (IMPORT_AUDIO_MAX_BYTES + 1) as usize], + ) + .expect("write large audio"); + assert_eq!( + import_audio_file_payload(large_path.clone()).unwrap_err(), + "audio exceeds import size limit" + ); + fs::remove_file(large_path).expect("remove large audio"); + } + + #[test] + fn export_audio_payload_decodes_allowed_audio_base64() { + let mut valid = request("file.exportAudio"); + valid.payload = Some(json!({ + "fileName": "敲击:音效?.wav", + "base64Data": "YXVkaW8=", + "mimeType": "audio/wav" + })); + + let (file_name, bytes) = export_audio_payload(&valid).expect("audio payload"); + + assert_eq!(file_name, "敲击-音效-.wav"); + assert_eq!(bytes, b"audio"); + + let mut missing_extension = request("file.exportAudio"); + missing_extension.payload = Some(json!({ + "fileName": "敲击音效", + "base64Data": "YXVkaW8=", + "mimeType": "audio/webm" + })); + + let (file_name, _bytes) = export_audio_payload(&missing_extension).expect("audio payload"); + + assert_eq!(file_name, "敲击音效.webm"); + } + + #[test] + fn export_audio_payload_rejects_invalid_or_oversized_audio() { + let mut invalid_mime = request("file.exportAudio"); + invalid_mime.payload = Some(json!({ + "fileName": "hit.txt", + "base64Data": "YXVkaW8=", + "mimeType": "text/plain" + })); + let response = export_audio_payload(&invalid_mime).expect_err("invalid mime"); + assert!(!response.ok); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut empty = request("file.exportAudio"); + empty.payload = Some(json!({ + "fileName": "hit.wav", + "base64Data": "", + "mimeType": "audio/wav" + })); + let response = export_audio_payload(&empty).expect_err("empty audio"); + assert!(!response.ok); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut oversized = request("file.exportAudio"); + oversized.payload = Some(json!({ + "fileName": "hit.webm", + "base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_AUDIO_MAX_BYTES + 1]), + "mimeType": "audio/webm" + })); + let response = export_audio_payload(&oversized).expect_err("oversized audio"); + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "audio exceeds file export size limit"); + } + + #[test] + fn export_image_payload_rejects_invalid_mime_and_base64() { + let mut invalid_mime = request("file.exportImage"); + invalid_mime.payload = Some(json!({ + "fileName": "分享卡.txt", + "base64Data": "c2hhcmUtY2FyZA==", + "mimeType": "text/plain" + })); + let response = export_image_payload(&invalid_mime).expect_err("invalid mime"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut invalid_base64 = request("file.exportImage"); + invalid_base64.payload = Some(json!({ + "fileName": "分享卡.png", + "base64Data": "not base64!", + "mimeType": "image/png" + })); + let response = export_image_payload(&invalid_base64).expect_err("invalid base64"); + assert_eq!( + response.error.expect("error").message, + "base64Data is invalid" + ); + } + + #[test] + fn export_image_payload_rejects_oversized_image() { + let mut invalid = request("file.exportImage"); + invalid.payload = Some(json!({ + "fileName": "分享卡.png", + "base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_IMAGE_MAX_BYTES + 1]), + "mimeType": "image/png" + })); + + let response = export_image_payload(&invalid).expect_err("oversized image"); + + assert_eq!( + response.error.expect("error").message, + "image exceeds file export size limit" + ); + } + + #[test] + fn write_export_bytes_file_persists_binary_content() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-share-card-{}.png", + std::process::id() + )); + + let bytes = write_export_bytes_file(path.clone(), vec![0x89, b'P', b'N', b'G']) + .expect("write image file"); + + assert_eq!(bytes, 4); + assert_eq!( + fs::read(&path).expect("read image file"), + vec![0x89, b'P', b'N', b'G'] + ); + fs::remove_file(path).expect("remove image file"); + } +} diff --git a/apps/desktop-shell/src-tauri/src/desktop_host_bridge_protocol.rs b/apps/desktop-shell/src-tauri/src/desktop_host_bridge_protocol.rs new file mode 100644 index 000000000..f946cbafb --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/desktop_host_bridge_protocol.rs @@ -0,0 +1,397 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::{Arc, Condvar, Mutex}; + +pub(crate) const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge"; +pub(crate) const HOST_BRIDGE_VERSION: u8 = 1; +pub(crate) const HOST_BRIDGE_METHODS: [&str; 23] = [ + "host.getRuntime", + "appearance.getColorScheme", + "auth.requestLogin", + "payment.request", + "share.setTarget", + "share.open", + "navigation.openNativePage", + "app.reloadWebView", + "app.openExternalUrl", + "app.setTitle", + "app.setBadgeCount", + "network.status", + "clipboard.writeText", + "clipboard.readText", + "file.exportText", + "file.importText", + "file.exportImage", + "file.importImage", + "file.captureImage", + "file.importAudio", + "file.exportAudio", + "haptics.impact", + "notification.showLocal", +]; +pub(crate) const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: usize = 120; +const HOST_BRIDGE_RESPONSE_CACHE_MAX: usize = 128; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HostBridgeRequest { + pub(crate) bridge: String, + pub(crate) version: u8, + pub(crate) id: String, + pub(crate) method: String, + pub(crate) payload: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HostBridgeRuntime { + pub(crate) shell: &'static str, + pub(crate) platform: &'static str, + pub(crate) host_version: &'static str, + pub(crate) bridge_version: u8, + pub(crate) capabilities: Vec<&'static str>, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct HostBridgeError { + pub(crate) code: &'static str, + pub(crate) message: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HostBridgeResponse { + pub(crate) bridge: &'static str, + pub(crate) version: u8, + pub(crate) id: String, + pub(crate) ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option, +} + +#[derive(Debug, Default)] +pub(crate) struct HostBridgeReplayState { + cache: Mutex, +} + +#[derive(Debug, Default)] +struct HostBridgeReplayCache { + order: Vec, + slots: HashMap>, +} + +#[derive(Debug, Default)] +pub(crate) struct HostBridgeReplaySlot { + response: Mutex>, + ready: Condvar, +} + +#[derive(Debug)] +pub(crate) enum HostBridgeReplayReservation { + Execute(Arc), + Wait(Arc), +} + +impl HostBridgeReplayState { + pub(crate) fn reserve(&self, request_id: &str) -> HostBridgeReplayReservation { + let mut cache = self.cache.lock().expect("host bridge replay cache lock"); + if let Some(slot) = cache.slots.get(request_id) { + return HostBridgeReplayReservation::Wait(slot.clone()); + } + + let slot = Arc::new(HostBridgeReplaySlot::default()); + cache.order.push(request_id.to_string()); + cache.slots.insert(request_id.to_string(), slot.clone()); + while cache.order.len() > HOST_BRIDGE_RESPONSE_CACHE_MAX { + if let Some(oldest_request_id) = cache.order.first().cloned() { + cache.order.remove(0); + cache.slots.remove(&oldest_request_id); + } + } + + HostBridgeReplayReservation::Execute(slot) + } + + pub(crate) fn complete( + &self, + slot: Arc, + response: HostBridgeResponse, + ) -> HostBridgeResponse { + let mut stored_response = slot.response.lock().expect("host bridge replay slot lock"); + *stored_response = Some(response.clone()); + slot.ready.notify_all(); + response + } + + pub(crate) fn wait_for_response(slot: Arc) -> HostBridgeResponse { + let mut stored_response = slot.response.lock().expect("host bridge replay slot lock"); + while stored_response.is_none() { + stored_response = slot + .ready + .wait(stored_response) + .expect("host bridge replay slot wait"); + } + + stored_response + .clone() + .expect("host bridge replay response") + } +} + +pub(crate) fn capabilities() -> Vec<&'static str> { + vec![ + "host.getRuntime", + "appearance.getColorScheme", + "app.lifecycle", + "share.open", + "share.setTarget", + "navigation.openNativePage", + "app.reloadWebView", + "app.openExternalUrl", + "app.setTitle", + "app.setBadgeCount", + "network.status", + "network.statusChanged", + "clipboard.writeText", + "clipboard.readText", + "file.exportText", + "file.importText", + "file.exportImage", + "file.importImage", + "file.importAudio", + "file.exportAudio", + "file.imageDropped", + "notification.showLocal", + ] +} + +pub(crate) fn ok(id: String, result: Value) -> HostBridgeResponse { + HostBridgeResponse { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id, + ok: true, + result: Some(result), + error: None, + } +} + +pub(crate) fn failed( + id: String, + code: &'static str, + message: impl Into, +) -> HostBridgeResponse { + HostBridgeResponse { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id, + ok: false, + result: None, + error: Some(HostBridgeError { + code, + message: message.into(), + }), + } +} + +pub(crate) fn has_control_character(value: &str) -> bool { + value.chars().any(|character| { + let code_point = character as u32; + code_point <= 31 || code_point == 127 + }) +} + +pub(crate) fn normalize_request_id(raw_id: &str) -> Option { + let id = raw_id.trim(); + if id.is_empty() + || id.chars().count() > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH + || has_control_character(id) + { + return None; + } + + Some(id.to_string()) +} + +pub(crate) fn is_host_bridge_method(method: &str) -> bool { + HOST_BRIDGE_METHODS.contains(&method) +} + +pub(crate) fn validate_request(request: &HostBridgeRequest) -> Option { + let Some(request_id) = normalize_request_id(&request.id) else { + return Some(failed( + "invalid".to_string(), + "invalid_request", + "invalid host bridge request id", + )); + }; + + if request.bridge != HOST_BRIDGE_PROTOCOL || request.version != HOST_BRIDGE_VERSION { + return Some(failed( + request_id, + "invalid_request", + "invalid host bridge envelope", + )); + } + + if !is_host_bridge_method(&request.method) { + return Some(failed( + request_id, + "invalid_request", + "invalid host bridge method", + )); + } + + None +} + +pub(crate) fn required_string_payload<'a>( + request: &'a HostBridgeRequest, + field: &'static str, +) -> Result<&'a str, HostBridgeResponse> { + request + .payload + .as_ref() + .and_then(|value| value.get(field)) + .and_then(Value::as_str) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + format!("{} is required", field), + ) + }) +} + +#[cfg(test)] +pub(crate) fn request(method: &str) -> HostBridgeRequest { + HostBridgeRequest { + bridge: HOST_BRIDGE_PROTOCOL.to_string(), + version: HOST_BRIDGE_VERSION, + id: "request-1".to_string(), + method: method.to_string(), + payload: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + #[test] + fn invalid_envelope_is_rejected() { + let mut invalid = request("host.getRuntime"); + invalid.bridge = "OtherBridge".to_string(); + + let response = validate_request(&invalid).expect("invalid envelope"); + + assert!(!response.ok); + assert_eq!(response.error.expect("error").code, "invalid_request"); + } + + #[test] + fn invalid_request_id_and_unknown_method_are_rejected() { + for id in ["", "request\n1"] { + let mut invalid = request("share.open"); + invalid.id = id.to_string(); + + let response = validate_request(&invalid).expect("invalid id"); + + assert!(!response.ok); + assert_eq!(response.id, "invalid"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + } + + let mut oversized = request("share.open"); + oversized.id = "a".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH + 1); + let response = validate_request(&oversized).expect("oversized id"); + assert!(!response.ok); + assert_eq!(response.id, "invalid"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut multibyte_boundary = request("host.getRuntime"); + multibyte_boundary.id = "作".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH); + assert!(validate_request(&multibyte_boundary).is_none()); + + let response = + validate_request(&request("host.runArbitraryCommand")).expect("unknown method"); + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "invalid host bridge method"); + } + + #[test] + fn host_bridge_replay_state_reuses_first_response_for_duplicate_id() { + let replay_state = HostBridgeReplayState::default(); + let mut side_effect_count = 0; + + let first_reservation = replay_state.reserve("request-1"); + let first_response = match first_reservation { + HostBridgeReplayReservation::Execute(slot) => { + side_effect_count += 1; + replay_state.complete(slot, ok("request-1".to_string(), json!(true))) + } + HostBridgeReplayReservation::Wait(_) => panic!("first request must execute"), + }; + let second_response = match replay_state.reserve("request-1") { + HostBridgeReplayReservation::Execute(_) => panic!("duplicate request must not execute"), + HostBridgeReplayReservation::Wait(slot) => { + HostBridgeReplayState::wait_for_response(slot) + } + }; + + assert_eq!(side_effect_count, 1); + assert_eq!(second_response.ok, first_response.ok); + assert_eq!(second_response.result, first_response.result); + } + + #[test] + fn invalid_string_payload_is_rejected() { + let mut invalid = request("clipboard.writeText"); + invalid.payload = Some(json!({ "text": 123 })); + + let response = required_string_payload(&invalid, "text").expect_err("invalid payload"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "text is required"); + } + + #[test] + fn runtime_capability_list_stays_ordered() { + assert_eq!( + capabilities(), + vec![ + "host.getRuntime", + "appearance.getColorScheme", + "app.lifecycle", + "share.open", + "share.setTarget", + "navigation.openNativePage", + "app.reloadWebView", + "app.openExternalUrl", + "app.setTitle", + "app.setBadgeCount", + "network.status", + "network.statusChanged", + "clipboard.writeText", + "clipboard.readText", + "file.exportText", + "file.importText", + "file.exportImage", + "file.importImage", + "file.importAudio", + "file.exportAudio", + "file.imageDropped", + "notification.showLocal", + ] + ); + assert!(Value::from(capabilities()).as_array().is_some()); + } +} diff --git a/apps/desktop-shell/src-tauri/src/desktop_host_bridge_share.rs b/apps/desktop-shell/src-tauri/src/desktop_host_bridge_share.rs new file mode 100644 index 000000000..1c148ef28 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/desktop_host_bridge_share.rs @@ -0,0 +1,147 @@ +use crate::desktop_host_bridge_protocol::{failed, HostBridgeRequest, HostBridgeResponse}; +use crate::desktop_shell_webview::WEB_APP_ORIGIN; +use serde_json::Value; +use std::sync::Mutex; + +#[derive(Debug, Default)] +pub(crate) struct DesktopShareState { + pub(crate) target: Mutex>, +} + +fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> { + value + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) +} + +fn share_target_payload(value: &Value) -> &Value { + value.get("target").unwrap_or(value) +} + +fn work_detail_url(work: &str) -> String { + format!("{}/works/detail?work={}", WEB_APP_ORIGIN, work) +} + +fn share_text_from_value(value: &Value) -> Option { + let target = share_target_payload(value); + let payload = target.get("payload").unwrap_or(target); + let title = payload_string(payload, "title"); + let message = payload_string(payload, "message"); + let url = payload_string(payload, "url").or_else(|| payload_string(payload, "href")); + let work_url = payload_string(payload, "work").map(work_detail_url); + let path_url = payload_string(payload, "path") + .or_else(|| payload_string(payload, "targetPath")) + .map(|path| format!("{}{}", WEB_APP_ORIGIN, path)); + let resolved_url = url.map(str::to_owned).or(work_url).or(path_url); + let parts = [title, message, resolved_url.as_deref()] + .into_iter() + .flatten() + .collect::>(); + + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } +} + +pub(crate) fn share_text_from_request( + request: &HostBridgeRequest, + share_state: &DesktopShareState, +) -> Result { + if let Some(payload) = request.payload.as_ref() { + if let Some(text) = share_text_from_value(payload) { + return Ok(text); + } + } + + let stored_target = share_state + .target + .lock() + .map_err(|_| { + failed( + request.id.clone(), + "host_error", + "share target lock poisoned", + ) + })? + .clone(); + + stored_target + .as_ref() + .and_then(share_text_from_value) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "share target is required", + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::desktop_host_bridge_protocol::request; + use serde_json::json; + + #[test] + fn share_text_uses_direct_share_payload() { + let state = DesktopShareState::default(); + let mut open = request("share.open"); + open.payload = Some(json!({ + "title": "测试作品", + "message": "来玩这个作品", + "url": "https://app.genarrative.world/works/detail?work=PZ-1" + })); + + let text = share_text_from_request(&open, &state).expect("share text"); + + assert_eq!( + text, + "测试作品\n来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-1" + ); + } + + #[test] + fn share_text_uses_stored_work_target() { + let state = DesktopShareState::default(); + let mut set_target = request("share.setTarget"); + set_target.payload = Some(json!({ + "target": { + "type": "genarrative:share-target", + "payload": { + "work": "PZ-1", + "title": "测试作品" + } + } + })); + let target = set_target + .payload + .as_ref() + .and_then(|payload| payload.get("target")) + .expect("target"); + *state.target.lock().expect("share target lock") = Some(target.clone()); + + let text = share_text_from_request(&request("share.open"), &state).expect("share text"); + + assert_eq!( + text, + "测试作品\nhttps://app.genarrative.world/works/detail?work=PZ-1" + ); + } + + #[test] + fn share_text_requires_payload_or_stored_target() { + let state = DesktopShareState::default(); + let response = + share_text_from_request(&request("share.open"), &state).expect_err("missing target"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "share target is required"); + } +} diff --git a/apps/desktop-shell/src-tauri/src/desktop_shell_tray.rs b/apps/desktop-shell/src-tauri/src/desktop_shell_tray.rs new file mode 100644 index 000000000..7eb7aadd7 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/desktop_shell_tray.rs @@ -0,0 +1,200 @@ +use tauri::menu::{Menu, MenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{Manager, WebviewWindow, WindowEvent}; + +const DESKTOP_TRAY_ID: &str = "genarrative-desktop-tray"; +pub(crate) const TRAY_MENU_SHOW: &str = "show-main-window"; +pub(crate) const TRAY_MENU_RELOAD: &str = "reload-main-window"; +pub(crate) const TRAY_MENU_QUIT: &str = "quit-desktop-shell"; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum DesktopTrayAction { + ShowMainWindow, + ReloadMainWindow, + QuitApp, + Ignore, +} + +#[derive(Debug, PartialEq, Eq)] +enum DesktopWindowCloseAction { + HideToTray, + CloseWindow, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum DesktopSingleInstanceAction { + ShowMainWindow, +} + +pub(crate) fn resolve_desktop_tray_menu_action(menu_id: &str) -> DesktopTrayAction { + match menu_id { + TRAY_MENU_SHOW => DesktopTrayAction::ShowMainWindow, + TRAY_MENU_RELOAD => DesktopTrayAction::ReloadMainWindow, + TRAY_MENU_QUIT => DesktopTrayAction::QuitApp, + _ => DesktopTrayAction::Ignore, + } +} + +pub(crate) fn resolve_desktop_tray_icon_action( + button: MouseButton, + button_state: MouseButtonState, +) -> DesktopTrayAction { + if button == MouseButton::Left && button_state == MouseButtonState::Up { + DesktopTrayAction::ShowMainWindow + } else { + DesktopTrayAction::Ignore + } +} + +fn resolve_desktop_window_close_action(tray_registered: bool) -> DesktopWindowCloseAction { + if tray_registered { + DesktopWindowCloseAction::HideToTray + } else { + DesktopWindowCloseAction::CloseWindow + } +} + +pub(crate) fn resolve_desktop_single_instance_action() -> DesktopSingleInstanceAction { + DesktopSingleInstanceAction::ShowMainWindow +} + +pub(crate) fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window("main") { + window.show()?; + window.unminimize()?; + window.set_focus()?; + } + + Ok(()) +} + +fn reload_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window("main") { + window.reload()?; + } + + Ok(()) +} + +fn handle_desktop_tray_action(app: &tauri::AppHandle, action: DesktopTrayAction) { + match action { + DesktopTrayAction::ShowMainWindow => { + let _ = show_main_window(app); + } + DesktopTrayAction::ReloadMainWindow => { + let _ = reload_main_window(app); + } + DesktopTrayAction::QuitApp => app.exit(0), + DesktopTrayAction::Ignore => {} + } +} + +pub(crate) fn register_desktop_tray(app: &tauri::App) -> tauri::Result<()> { + let show_item = MenuItem::with_id(app, TRAY_MENU_SHOW, "显示主窗口", true, None::<&str>)?; + let reload_item = MenuItem::with_id(app, TRAY_MENU_RELOAD, "刷新", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, TRAY_MENU_QUIT, "退出", true, None::<&str>)?; + let tray_menu = Menu::with_items(app, &[&show_item, &reload_item, &quit_item])?; + let mut tray_builder = TrayIconBuilder::with_id(DESKTOP_TRAY_ID) + .menu(&tray_menu) + .tooltip("Genarrative") + .show_menu_on_left_click(false) + .on_menu_event(|app, event| { + let menu_id = event.id().0.as_str(); + handle_desktop_tray_action(app, resolve_desktop_tray_menu_action(menu_id)); + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button, + button_state, + .. + } = event + { + handle_desktop_tray_action( + tray.app_handle(), + resolve_desktop_tray_icon_action(button, button_state), + ); + } + }); + + if let Some(icon) = app.default_window_icon().cloned() { + tray_builder = tray_builder.icon(icon); + } + + tray_builder.build(app)?; + Ok(()) +} + +pub(crate) fn register_desktop_window_close_events(window: &WebviewWindow, tray_registered: bool) { + let close_window = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::CloseRequested { api, .. } = event { + if resolve_desktop_window_close_action(tray_registered) + == DesktopWindowCloseAction::HideToTray + { + api.prevent_close(); + let _ = close_window.hide(); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_tray_menu_ids_map_to_real_window_actions() { + assert_eq!( + resolve_desktop_tray_menu_action(TRAY_MENU_SHOW), + DesktopTrayAction::ShowMainWindow + ); + assert_eq!( + resolve_desktop_tray_menu_action(TRAY_MENU_RELOAD), + DesktopTrayAction::ReloadMainWindow + ); + assert_eq!( + resolve_desktop_tray_menu_action(TRAY_MENU_QUIT), + DesktopTrayAction::QuitApp + ); + assert_eq!( + resolve_desktop_tray_menu_action("unknown"), + DesktopTrayAction::Ignore + ); + } + + #[test] + fn desktop_tray_left_click_restores_main_window_only_on_release() { + assert_eq!( + resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Up), + DesktopTrayAction::ShowMainWindow + ); + assert_eq!( + resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Down), + DesktopTrayAction::Ignore + ); + assert_eq!( + resolve_desktop_tray_icon_action(MouseButton::Right, MouseButtonState::Up), + DesktopTrayAction::Ignore + ); + } + + #[test] + fn desktop_close_hides_to_tray_only_when_tray_is_registered() { + assert_eq!( + resolve_desktop_window_close_action(true), + DesktopWindowCloseAction::HideToTray + ); + assert_eq!( + resolve_desktop_window_close_action(false), + DesktopWindowCloseAction::CloseWindow + ); + } + + #[test] + fn desktop_single_instance_only_restores_existing_window() { + assert_eq!( + resolve_desktop_single_instance_action(), + DesktopSingleInstanceAction::ShowMainWindow + ); + } +} diff --git a/apps/desktop-shell/src-tauri/src/desktop_shell_webview.rs b/apps/desktop-shell/src-tauri/src/desktop_shell_webview.rs new file mode 100644 index 000000000..30f3aab23 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/desktop_shell_webview.rs @@ -0,0 +1,578 @@ +use crate::desktop_host_bridge_files::{import_image_file_payload, import_image_mime_type}; +use crate::desktop_host_bridge_protocol::{HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION}; +use serde_json::{json, Value}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::PathBuf; +use std::time::Duration; +use tauri::webview::{DownloadEvent, PageLoadEvent}; +use tauri::{DragDropEvent, Theme, Url, WebviewUrl, WebviewWindow, WindowEvent}; +use tauri_plugin_opener::OpenerExt; + +pub(crate) const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; +const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; +const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200; + +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 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::>(); + 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::>() + }) + .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 +} + +pub(crate) fn color_scheme_from_theme(theme: Theme) -> &'static str { + match theme { + Theme::Light => "light", + Theme::Dark => "dark", + _ => "unknown", + } +} + +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 { + 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 { + 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 { + 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) +} + +pub(crate) fn host_bridge_event_script( + event: &str, + payload: Value, +) -> Result { + 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 + )) +} + +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 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_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); + } + }); +} + +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); + } + }); +} + +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) +} + +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::*; + use serde_json::json; + + #[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")); + } + + #[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"); + } + + #[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")); + } + + #[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 + )); + } + + #[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", + }) + ); + } + + #[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 + ); + } +} diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 88bca8883..5758ad868 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -1,1779 +1,25 @@ -use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::HashMap; -use std::fs; -use std::net::{TcpStream, ToSocketAddrs}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Condvar, Mutex}; -use std::time::Duration; -use tauri::menu::{Menu, MenuItem}; -use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; -use tauri::webview::{DownloadEvent, NewWindowResponse, PageLoadEvent}; -use tauri::DragDropEvent; -use tauri::Manager; -use tauri::Theme; -use tauri::Url; -use tauri::WebviewUrl; -use tauri::WebviewWindow; -use tauri::WindowEvent; -use tauri_plugin_clipboard_manager::ClipboardExt; -use tauri_plugin_dialog::DialogExt; -use tauri_plugin_notification::NotificationExt; -use tauri_plugin_opener::OpenerExt; - -const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge"; -const HOST_BRIDGE_VERSION: u8 = 1; -const HOST_BRIDGE_METHODS: [&str; 23] = [ - "host.getRuntime", - "appearance.getColorScheme", - "auth.requestLogin", - "payment.request", - "share.setTarget", - "share.open", - "navigation.openNativePage", - "app.reloadWebView", - "app.openExternalUrl", - "app.setTitle", - "app.setBadgeCount", - "network.status", - "clipboard.writeText", - "clipboard.readText", - "file.exportText", - "file.importText", - "file.exportImage", - "file.importImage", - "file.captureImage", - "file.importAudio", - "file.exportAudio", - "haptics.impact", - "notification.showLocal", -]; -const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; -const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; -const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024; -const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024; -const EXPORT_AUDIO_MAX_BYTES: usize = 20 * 1024 * 1024; -const IMPORT_TEXT_MAX_BYTES: u64 = 5 * 1024 * 1024; -const IMPORT_IMAGE_MAX_BYTES: u64 = 10 * 1024 * 1024; -const IMPORT_AUDIO_MAX_BYTES: u64 = 20 * 1024 * 1024; -const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt"; -const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120; -const BADGE_COUNT_MAX: i64 = 99999; -const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200; -const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80; -const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240; -const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000; -const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: usize = 120; -const HOST_BRIDGE_RESPONSE_CACHE_MAX: usize = 128; -const DESKTOP_TRAY_ID: &str = "genarrative-desktop-tray"; -const TRAY_MENU_SHOW: &str = "show-main-window"; -const TRAY_MENU_RELOAD: &str = "reload-main-window"; -const TRAY_MENU_QUIT: &str = "quit-desktop-shell"; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HostBridgeRequest { - bridge: String, - version: u8, - id: String, - method: String, - payload: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct HostBridgeRuntime { - shell: &'static str, - platform: &'static str, - host_version: &'static str, - bridge_version: u8, - capabilities: Vec<&'static str>, -} - -#[derive(Clone, Debug, Serialize)] -struct HostBridgeError { - code: &'static str, - message: String, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct HostBridgeResponse { - bridge: &'static str, - version: u8, - id: String, - ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Debug, Default)] -struct DesktopShareState { - target: Mutex>, -} - -#[derive(Debug, Default)] -struct HostBridgeReplayState { - cache: Mutex, -} - -#[derive(Debug, Default)] -struct HostBridgeReplayCache { - order: Vec, - slots: HashMap>, -} - -#[derive(Debug, Default)] -struct HostBridgeReplaySlot { - response: Mutex>, - ready: Condvar, -} - -#[derive(Debug)] -enum HostBridgeReplayReservation { - Execute(Arc), - Wait(Arc), -} - -impl HostBridgeReplayState { - fn reserve(&self, request_id: &str) -> HostBridgeReplayReservation { - let mut cache = self.cache.lock().expect("host bridge replay cache lock"); - if let Some(slot) = cache.slots.get(request_id) { - return HostBridgeReplayReservation::Wait(slot.clone()); - } - - let slot = Arc::new(HostBridgeReplaySlot::default()); - cache.order.push(request_id.to_string()); - cache.slots.insert(request_id.to_string(), slot.clone()); - while cache.order.len() > HOST_BRIDGE_RESPONSE_CACHE_MAX { - if let Some(oldest_request_id) = cache.order.first().cloned() { - cache.order.remove(0); - cache.slots.remove(&oldest_request_id); - } - } - - HostBridgeReplayReservation::Execute(slot) - } - - fn complete( - &self, - slot: Arc, - response: HostBridgeResponse, - ) -> HostBridgeResponse { - let mut stored_response = slot.response.lock().expect("host bridge replay slot lock"); - *stored_response = Some(response.clone()); - slot.ready.notify_all(); - response - } - - fn wait_for_response(slot: Arc) -> HostBridgeResponse { - let mut stored_response = slot.response.lock().expect("host bridge replay slot lock"); - while stored_response.is_none() { - stored_response = slot - .ready - .wait(stored_response) - .expect("host bridge replay slot wait"); - } - - stored_response.clone().expect("host bridge replay response") - } -} - -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" - } -} - -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::>(); - 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::>() - }) - .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 - } -} - -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 -} - -fn capabilities() -> Vec<&'static str> { - vec![ - "host.getRuntime", - "appearance.getColorScheme", - "app.lifecycle", - "share.open", - "share.setTarget", - "navigation.openNativePage", - "app.reloadWebView", - "app.openExternalUrl", - "app.setTitle", - "app.setBadgeCount", - "network.status", - "network.statusChanged", - "clipboard.writeText", - "clipboard.readText", - "file.exportText", - "file.importText", - "file.exportImage", - "file.importImage", - "file.importAudio", - "file.exportAudio", - "file.imageDropped", - "notification.showLocal", - ] -} - -fn ok(id: String, result: Value) -> HostBridgeResponse { - HostBridgeResponse { - bridge: HOST_BRIDGE_PROTOCOL, - version: HOST_BRIDGE_VERSION, - id, - ok: true, - result: Some(result), - error: None, - } -} - -fn failed(id: String, code: &'static str, message: impl Into) -> HostBridgeResponse { - HostBridgeResponse { - bridge: HOST_BRIDGE_PROTOCOL, - version: HOST_BRIDGE_VERSION, - id, - ok: false, - result: None, - error: Some(HostBridgeError { - code, - message: message.into(), - }), - } -} - -fn color_scheme_from_theme(theme: Theme) -> &'static str { - match theme { - Theme::Light => "light", - Theme::Dark => "dark", - _ => "unknown", - } -} - -fn has_control_character(value: &str) -> bool { - value.chars().any(|character| { - let code_point = character as u32; - code_point <= 31 || code_point == 127 - }) -} - -fn normalize_request_id(raw_id: &str) -> Option { - let id = raw_id.trim(); - if id.is_empty() - || id.chars().count() > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH - || has_control_character(id) - { - return None; - } - - Some(id.to_string()) -} - -fn is_host_bridge_method(method: &str) -> bool { - HOST_BRIDGE_METHODS.contains(&method) -} - -fn validate_request(request: &HostBridgeRequest) -> Option { - let Some(request_id) = normalize_request_id(&request.id) else { - return Some(failed( - "invalid".to_string(), - "invalid_request", - "invalid host bridge request id", - )); - }; - - if request.bridge != HOST_BRIDGE_PROTOCOL || request.version != HOST_BRIDGE_VERSION { - return Some(failed( - request_id, - "invalid_request", - "invalid host bridge envelope", - )); - } - - if !is_host_bridge_method(&request.method) { - return Some(failed( - request_id, - "invalid_request", - "invalid host bridge method", - )); - } - - None -} - -fn required_string_payload<'a>( - request: &'a HostBridgeRequest, - field: &'static str, -) -> Result<&'a str, HostBridgeResponse> { - request - .payload - .as_ref() - .and_then(|value| value.get(field)) - .and_then(Value::as_str) - .ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - format!("{} is required", field), - ) - }) -} - -fn external_url_protocol(raw_url: &str) -> Option<&str> { - raw_url.split_once(':').map(|(protocol, _)| protocol) -} - -fn normalize_external_url(raw_url: &str) -> Option { - 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) -} - -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 -} - -fn desktop_external_navigation_url(url: &Url) -> Option { - if should_allow_desktop_webview_navigation(url) { - return None; - } - - normalize_external_url(url.as_str()) -} - -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>); -} - -fn should_allow_desktop_webview_download(event: &DownloadEvent<'_>) -> bool { - match event { - DownloadEvent::Requested { .. } => false, - DownloadEvent::Finished { .. } => true, - _ => false, - } -} - -fn normalize_native_page_url(raw_url: &str) -> Option { - 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 { - let title = raw_title.trim(); - if title.is_empty() || title.chars().any(char::is_control) { - return None; - } - - Some(title.chars().take(80).collect()) -} - -fn badge_count_payload(request: &HostBridgeRequest) -> Result, 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) }) -} - -fn normalize_clipboard_text(text: String) -> String { - text.chars().take(CLIPBOARD_TEXT_MAX_LENGTH).collect() -} - -fn normalize_plain_text( - value: Option<&str>, - max_length: usize, - required: bool, -) -> Option> { - let Some(value) = value else { - return if required { None } else { Some(None) }; - }; - if value.chars().any(char::is_control) { - return None; - } - - let text = value.split_whitespace().collect::>().join(" "); - if text.is_empty() { - return if required { None } else { Some(None) }; - } - - Some(Some(text.chars().take(max_length).collect())) -} - -fn local_notification_payload( - request: &HostBridgeRequest, -) -> Result<(String, Option), HostBridgeResponse> { - let payload = request - .payload - .as_ref() - .ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required"))?; - let title = match normalize_plain_text( - payload.get("title").and_then(Value::as_str), - LOCAL_NOTIFICATION_TITLE_MAX_LENGTH, - true, - ) { - Some(Some(title)) => title, - _ => { - return Err(failed( - request.id.clone(), - "invalid_request", - "title is required", - )) - } - }; - let body = match normalize_plain_text( - payload.get("body").and_then(Value::as_str), - LOCAL_NOTIFICATION_BODY_MAX_LENGTH, - false, - ) { - Some(body) => body, - None => { - return Err(failed( - request.id.clone(), - "invalid_request", - "body is invalid", - )) - } - }; - - Ok((title, body)) -} - -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) - { - if character.is_control() - || matches!( - character, - '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' - ) - { - file_name.push('-'); - last_was_space = false; - continue; - } - - if character.is_whitespace() { - if !last_was_space { - file_name.push(' '); - last_was_space = true; - } - continue; - } - - file_name.push(character); - last_was_space = false; - } - - let file_name = file_name - .trim() - .trim_start_matches(|character| matches!(character, '.' | '-') || character.is_whitespace()) - .trim(); - if file_name.is_empty() { - EXPORT_FILE_NAME_FALLBACK.to_string() - } else { - file_name.to_string() - } -} - -fn export_text_payload( - request: &HostBridgeRequest, -) -> Result<(String, String), HostBridgeResponse> { - let payload = request.payload.as_ref().ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - "fileName and content are required", - ) - })?; - let file_name = payload - .get("fileName") - .and_then(Value::as_str) - .map(normalize_export_file_name) - .unwrap_or_else(|| EXPORT_FILE_NAME_FALLBACK.to_string()); - let content = payload - .get("content") - .and_then(Value::as_str) - .ok_or_else(|| failed(request.id.clone(), "invalid_request", "content is required"))?; - - if content.len() > EXPORT_TEXT_MAX_BYTES { - return Err(failed( - request.id.clone(), - "invalid_request", - "content exceeds file export size limit", - )); - } - - Ok((file_name, content.to_string())) -} - -fn write_export_text_file(path: PathBuf, content: String) -> Result { - fs::write(path, content.as_bytes()).map_err(|error| error.to_string())?; - Ok(content.len()) -} - -fn import_text_mime_type(path: &Path) -> Option<&'static str> { - match path - .extension() - .and_then(|extension| extension.to_str()) - .map(|extension| extension.to_ascii_lowercase()) - .as_deref() - { - Some("txt") => Some("text/plain"), - Some("md") | Some("markdown") => Some("text/markdown"), - Some("csv") => Some("text/csv"), - Some("json") => Some("application/json"), - _ => None, - } -} - -fn import_text_file_payload(path: PathBuf) -> Result { - if !path.is_file() { - return Err("text file is required".to_string()); - } - - let mime_type = - import_text_mime_type(&path).ok_or_else(|| "text MIME must be allowed".to_string())?; - let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; - let byte_count = metadata.len(); - if byte_count == 0 || byte_count > IMPORT_TEXT_MAX_BYTES { - return Err("text exceeds import size limit".to_string()); - } - - let content = fs::read_to_string(&path).map_err(|error| error.to_string())?; - let byte_count = content.len() as u64; - if byte_count == 0 || byte_count > IMPORT_TEXT_MAX_BYTES { - return Err("text exceeds import size limit".to_string()); - } - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .map(normalize_export_file_name) - .unwrap_or_else(|| "genarrative-import.txt".to_string()); - - Ok(json!({ - "action": "selected", - "fileName": file_name, - "content": content, - "mimeType": mime_type, - "bytes": byte_count, - })) -} - -fn export_image_extension(mime_type: &str) -> Option<&'static str> { - match mime_type { - "image/png" => Some("png"), - "image/jpeg" => Some("jpg"), - "image/webp" => Some("webp"), - _ => None, - } -} - -fn import_image_mime_type(path: &Path) -> Option<&'static str> { - match path - .extension() - .and_then(|extension| extension.to_str()) - .map(|extension| extension.to_ascii_lowercase()) - .as_deref() - { - Some("png") => Some("image/png"), - Some("jpg") | Some("jpeg") => Some("image/jpeg"), - Some("webp") => Some("image/webp"), - _ => None, - } -} - -fn import_audio_mime_type(path: &Path) -> Option<&'static str> { - match path - .extension() - .and_then(|extension| extension.to_str()) - .map(|extension| extension.to_ascii_lowercase()) - .as_deref() - { - Some("mp3") => Some("audio/mpeg"), - Some("m4a") | Some("mp4") => Some("audio/mp4"), - Some("wav") => Some("audio/wav"), - Some("ogg") => Some("audio/ogg"), - Some("webm") => Some("audio/webm"), - _ => None, - } -} - -fn export_audio_extension(mime_type: &str) -> Option<&'static str> { - match mime_type { - "audio/mpeg" => Some("mp3"), - "audio/mp4" => Some("m4a"), - "audio/wav" => Some("wav"), - "audio/ogg" => Some("ogg"), - "audio/webm" => Some("webm"), - _ => None, - } -} - -fn normalize_export_image_file_name(raw_file_name: &str, mime_type: &str) -> String { - let mut file_name = normalize_export_file_name(raw_file_name); - let extension = export_image_extension(mime_type).unwrap_or("png"); - if !file_name - .to_ascii_lowercase() - .ends_with(&format!(".{}", extension)) - { - file_name.push('.'); - file_name.push_str(extension); - } - file_name -} - -fn normalize_export_audio_file_name(raw_file_name: &str, mime_type: &str) -> String { - let mut file_name = normalize_export_file_name(raw_file_name); - let extension = export_audio_extension(mime_type).unwrap_or("webm"); - if !file_name - .to_ascii_lowercase() - .ends_with(&format!(".{}", extension)) - { - file_name.push('.'); - file_name.push_str(extension); - } - file_name -} - -fn export_image_payload( - request: &HostBridgeRequest, -) -> Result<(String, Vec), HostBridgeResponse> { - let payload = request.payload.as_ref().ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - "fileName, mimeType and base64Data are required", - ) - })?; - let mime_type = payload - .get("mimeType") - .and_then(Value::as_str) - .ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - "mimeType is required", - ) - })?; - if export_image_extension(mime_type).is_none() { - return Err(failed( - request.id.clone(), - "invalid_request", - "mimeType must be an allowed image type", - )); - } - - let base64_data = payload - .get("base64Data") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - "base64Data is required", - ) - })?; - let bytes = BASE64_STANDARD.decode(base64_data).map_err(|_| { - failed( - request.id.clone(), - "invalid_request", - "base64Data is invalid", - ) - })?; - if bytes.len() > EXPORT_IMAGE_MAX_BYTES { - return Err(failed( - request.id.clone(), - "invalid_request", - "image exceeds file export size limit", - )); - } - - let file_name = payload - .get("fileName") - .and_then(Value::as_str) - .map(|file_name| normalize_export_image_file_name(file_name, mime_type)) - .unwrap_or_else(|| normalize_export_image_file_name("genarrative-share-card", mime_type)); - - Ok((file_name, bytes)) -} - -fn export_audio_payload( - request: &HostBridgeRequest, -) -> Result<(String, Vec), HostBridgeResponse> { - let payload = request.payload.as_ref().ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - "fileName, mimeType and base64Data are required", - ) - })?; - let mime_type = payload - .get("mimeType") - .and_then(Value::as_str) - .ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - "mimeType is required", - ) - })?; - if export_audio_extension(mime_type).is_none() { - return Err(failed( - request.id.clone(), - "invalid_request", - "mimeType must be an allowed audio type", - )); - } - - let base64_data = payload - .get("base64Data") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - "base64Data is required", - ) - })?; - let bytes = BASE64_STANDARD.decode(base64_data).map_err(|_| { - failed( - request.id.clone(), - "invalid_request", - "base64Data is invalid", - ) - })?; - if bytes.is_empty() || bytes.len() > EXPORT_AUDIO_MAX_BYTES { - return Err(failed( - request.id.clone(), - "invalid_request", - "audio exceeds file export size limit", - )); - } - - let file_name = payload - .get("fileName") - .and_then(Value::as_str) - .map(|file_name| normalize_export_audio_file_name(file_name, mime_type)) - .unwrap_or_else(|| normalize_export_audio_file_name("genarrative-audio", mime_type)); - - Ok((file_name, bytes)) -} - -fn write_export_bytes_file(path: PathBuf, bytes: Vec) -> Result { - let byte_count = bytes.len(); - fs::write(path, bytes).map_err(|error| error.to_string())?; - Ok(byte_count) -} - -fn import_image_file_payload( - path: PathBuf, - action: &'static str, - position: Option<(i32, i32)>, -) -> Result { - if !path.is_file() { - return Err("image file is required".to_string()); - } - - let mime_type = - import_image_mime_type(&path).ok_or_else(|| "image MIME must be allowed".to_string())?; - let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; - let byte_count = metadata.len(); - if byte_count == 0 || byte_count > IMPORT_IMAGE_MAX_BYTES { - return Err("image exceeds import size limit".to_string()); - } - - let bytes = fs::read(&path).map_err(|error| error.to_string())?; - let byte_count = bytes.len() as u64; - if byte_count == 0 || byte_count > IMPORT_IMAGE_MAX_BYTES { - return Err("image exceeds import size limit".to_string()); - } - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .map(normalize_export_file_name) - .unwrap_or_else(|| "genarrative-import.png".to_string()); - let mut payload = json!({ - "action": action, - "fileName": file_name, - "base64Data": BASE64_STANDARD.encode(bytes), - "mimeType": mime_type, - "bytes": byte_count, - }); - - if let Some((x, y)) = position { - payload["position"] = json!({ - "x": x, - "y": y, - }); - } - - Ok(payload) -} - -fn import_audio_file_payload(path: PathBuf) -> Result { - if !path.is_file() { - return Err("audio file is required".to_string()); - } - - let mime_type = - import_audio_mime_type(&path).ok_or_else(|| "audio MIME must be allowed".to_string())?; - let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; - let byte_count = metadata.len(); - if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES { - return Err("audio exceeds import size limit".to_string()); - } - - let bytes = fs::read(&path).map_err(|error| error.to_string())?; - let byte_count = bytes.len() as u64; - if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES { - return Err("audio exceeds import size limit".to_string()); - } - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .map(normalize_export_file_name) - .unwrap_or_else(|| "genarrative-import-audio.webm".to_string()); - - Ok(json!({ - "action": "selected", - "fileName": file_name, - "base64Data": BASE64_STANDARD.encode(bytes), - "mimeType": mime_type, - "bytes": byte_count, - })) -} - -fn host_bridge_event_script(event: &str, payload: Value) -> Result { - 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 - )) -} - -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) -} - -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" }, - }) -} - -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) -} - -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); - } - }); -} - -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) -} - -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); - } - }); -} - -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) -} - -fn should_replay_desktop_webview_state_on_page_load(event: PageLoadEvent) -> bool { - event == PageLoadEvent::Finished -} - -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); -} - -#[derive(Debug, PartialEq, Eq)] -enum DesktopTrayAction { - ShowMainWindow, - ReloadMainWindow, - QuitApp, - Ignore, -} - -#[derive(Debug, PartialEq, Eq)] -enum DesktopWindowCloseAction { - HideToTray, - CloseWindow, -} - -#[derive(Debug, PartialEq, Eq)] -enum DesktopSingleInstanceAction { - ShowMainWindow, -} - -fn resolve_desktop_tray_menu_action(menu_id: &str) -> DesktopTrayAction { - match menu_id { - TRAY_MENU_SHOW => DesktopTrayAction::ShowMainWindow, - TRAY_MENU_RELOAD => DesktopTrayAction::ReloadMainWindow, - TRAY_MENU_QUIT => DesktopTrayAction::QuitApp, - _ => DesktopTrayAction::Ignore, - } -} - -fn resolve_desktop_tray_icon_action( - button: MouseButton, - button_state: MouseButtonState, -) -> DesktopTrayAction { - if button == MouseButton::Left && button_state == MouseButtonState::Up { - DesktopTrayAction::ShowMainWindow - } else { - DesktopTrayAction::Ignore - } -} - -fn resolve_desktop_window_close_action(tray_registered: bool) -> DesktopWindowCloseAction { - if tray_registered { - DesktopWindowCloseAction::HideToTray - } else { - DesktopWindowCloseAction::CloseWindow - } -} - -fn resolve_desktop_single_instance_action() -> DesktopSingleInstanceAction { - DesktopSingleInstanceAction::ShowMainWindow -} - -fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { - if let Some(window) = app.get_webview_window("main") { - window.show()?; - window.unminimize()?; - window.set_focus()?; - } - - Ok(()) -} - -fn reload_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { - if let Some(window) = app.get_webview_window("main") { - window.reload()?; - } - - Ok(()) -} - -fn handle_desktop_tray_action(app: &tauri::AppHandle, action: DesktopTrayAction) { - match action { - DesktopTrayAction::ShowMainWindow => { - let _ = show_main_window(app); - } - DesktopTrayAction::ReloadMainWindow => { - let _ = reload_main_window(app); - } - DesktopTrayAction::QuitApp => app.exit(0), - DesktopTrayAction::Ignore => {} - } -} - -fn register_desktop_tray(app: &tauri::App) -> tauri::Result<()> { - let show_item = MenuItem::with_id(app, TRAY_MENU_SHOW, "显示主窗口", true, None::<&str>)?; - let reload_item = MenuItem::with_id(app, TRAY_MENU_RELOAD, "刷新", true, None::<&str>)?; - let quit_item = MenuItem::with_id(app, TRAY_MENU_QUIT, "退出", true, None::<&str>)?; - let tray_menu = Menu::with_items(app, &[&show_item, &reload_item, &quit_item])?; - let mut tray_builder = TrayIconBuilder::with_id(DESKTOP_TRAY_ID) - .menu(&tray_menu) - .tooltip("Genarrative") - .show_menu_on_left_click(false) - .on_menu_event(|app, event| { - let menu_id = event.id().0.as_str(); - handle_desktop_tray_action(app, resolve_desktop_tray_menu_action(menu_id)); - }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button, - button_state, - .. - } = event - { - handle_desktop_tray_action( - tray.app_handle(), - resolve_desktop_tray_icon_action(button, button_state), - ); - } - }); - - if let Some(icon) = app.default_window_icon().cloned() { - tray_builder = tray_builder.icon(icon); - } - - tray_builder.build(app)?; - Ok(()) -} - -fn register_desktop_window_close_events(window: &WebviewWindow, tray_registered: bool) { - let close_window = window.clone(); - window.on_window_event(move |event| { - if let WindowEvent::CloseRequested { api, .. } = event { - if resolve_desktop_window_close_action(tray_registered) - == DesktopWindowCloseAction::HideToTray - { - api.prevent_close(); - let _ = close_window.hide(); - } - } - }); -} - -fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> { - value - .get(field) - .and_then(Value::as_str) - .map(str::trim) - .filter(|text| !text.is_empty()) -} - -fn share_target_payload(value: &Value) -> &Value { - value.get("target").unwrap_or(value) -} - -fn work_detail_url(work: &str) -> String { - format!("{}/works/detail?work={}", WEB_APP_ORIGIN, work) -} - -fn share_text_from_value(value: &Value) -> Option { - let target = share_target_payload(value); - let payload = target.get("payload").unwrap_or(target); - let title = payload_string(payload, "title"); - let message = payload_string(payload, "message"); - let url = payload_string(payload, "url").or_else(|| payload_string(payload, "href")); - let work_url = payload_string(payload, "work").map(work_detail_url); - let path_url = payload_string(payload, "path") - .or_else(|| payload_string(payload, "targetPath")) - .map(|path| format!("{}{}", WEB_APP_ORIGIN, path)); - let resolved_url = url.map(str::to_owned).or(work_url).or(path_url); - let parts = [title, message, resolved_url.as_deref()] - .into_iter() - .flatten() - .collect::>(); - - if parts.is_empty() { - None - } else { - Some(parts.join("\n")) - } -} - -fn share_text_from_request( - request: &HostBridgeRequest, - share_state: &DesktopShareState, -) -> Result { - if let Some(payload) = request.payload.as_ref() { - if let Some(text) = share_text_from_value(payload) { - return Ok(text); - } - } - - let stored_target = share_state - .target - .lock() - .map_err(|_| { - failed( - request.id.clone(), - "host_error", - "share target lock poisoned", - ) - })? - .clone(); - - stored_target - .as_ref() - .and_then(share_text_from_value) - .ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - "share target is required", - ) - }) -} - -fn resolve_host_bridge_request(request: HostBridgeRequest) -> HostBridgeResponse { - if let Some(response) = validate_request(&request) { - return response; - } - - match request.method.as_str() { - "host.getRuntime" => ok( - request.id, - json!(HostBridgeRuntime { - shell: "tauri_desktop", - platform: desktop_platform(), - host_version: env!("CARGO_PKG_VERSION"), - bridge_version: HOST_BRIDGE_VERSION, - capabilities: capabilities(), - }), - ), - _ => failed( - request.id, - "unsupported_method", - format!("{} unsupported in desktop shell", request.method), - ), - } -} - -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" => { - let url = match required_string_payload(&request, "url") - .ok() - .and_then(normalize_external_url) - { - Some(url) => url, - None => { - return failed( - request.id, - "invalid_request", - "url must use an allowed external protocol", - ) - } - }; - - match app.opener().open_url(url, None::<&str>) { - Ok(()) => ok(request.id, json!(true)), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "appearance.getColorScheme" => match app.get_webview_window("main") { - Some(window) => match window.theme() { - Ok(theme) => ok( - request.id, - json!({ - "colorScheme": color_scheme_from_theme(theme) - }), - ), - Err(error) => failed(request.id, "host_error", error.to_string()), - }, - None => failed(request.id, "host_error", "main window not found"), - }, - "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"), - } - } - "app.reloadWebView" => match app.get_webview_window("main") { - Some(window) => match window.reload() { - 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, - Err(response) => return response, - }; - - match app.clipboard().write_text(text) { - Ok(()) => ok(request.id, json!(true)), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "clipboard.readText" => match app.clipboard().read_text() { - Ok(text) => ok( - request.id, - json!({ - "text": normalize_clipboard_text(text), - }), - ), - Err(error) => failed(request.id, "host_error", error.to_string()), - }, - "file.exportText" => { - 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 failed(request.id, "cancelled", "file export cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - 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)) => return failed(request.id, "host_error", error), - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - - ok( - request.id, - json!({ - "action": "saved", - "fileName": file_name, - "bytes": bytes, - }), - ) - } - "file.importText" => { - 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 failed(request.id, "cancelled", "file import cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let import_result = - tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await; - match import_result { - Ok(Ok(payload)) => ok(request.id, payload), - Ok(Err(error)) => failed(request.id, "invalid_request", error), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "file.exportImage" => { - 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 failed(request.id, "cancelled", "file export cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - 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)) => return failed(request.id, "host_error", error), - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - ok( - request.id, - json!({ - "action": "saved", - "fileName": file_name, - "bytes": byte_count, - }), - ) - } - "file.importImage" => { - let file_path = app - .dialog() - .file() - .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) - .blocking_pick_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file import cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - 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, payload), - Ok(Err(error)) => failed(request.id, "invalid_request", error), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "file.importAudio" => { - 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 failed(request.id, "cancelled", "file import cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let import_result = - tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await; - match import_result { - Ok(Ok(payload)) => ok(request.id, payload), - Ok(Err(error)) => failed(request.id, "invalid_request", error), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "file.exportAudio" => { - 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 failed(request.id, "cancelled", "file export cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - 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)) => return failed(request.id, "host_error", error), - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - ok( - request.id, - json!({ - "action": "saved", - "fileName": file_name, - "bytes": byte_count, - }), - ) - } - "app.setTitle" => { - let title = match required_string_payload(&request, "title") - .ok() - .and_then(normalize_window_title) - { - Some(title) => title, - None => return failed(request.id, "invalid_request", "title is required"), - }; - - match app.get_webview_window("main") { - Some(window) => match window.set_title(&title) { - 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"), - } - } - "app.setBadgeCount" => { - let count = match badge_count_payload(&request) { - Ok(count) => count, - Err(response) => return response, - }; - - match app.get_webview_window("main") { - Some(window) => match window.set_badge_count(count) { - 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"), - } - } - "network.status" => { - let network_status = - tauri::async_runtime::spawn_blocking(resolve_desktop_network_status).await; - match network_status { - Ok(status) => ok(request.id, status), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "notification.showLocal" => { - let (title, body) = match local_notification_payload(&request) { - Ok(payload) => payload, - Err(response) => return response, - }; - let mut notification = app.notification().builder().title(title); - if let Some(body) = body { - notification = notification.body(body); - } - - match notification.show() { - Ok(()) => ok(request.id, json!(true)), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "share.setTarget" => { - let target = request - .payload - .as_ref() - .and_then(|payload| payload.get("target")); - let Some(target) = target else { - return failed(request.id, "invalid_request", "target is required"); - }; - let share_state = app.state::(); - - let response = match share_state.target.lock() { - Ok(mut current_target) => { - *current_target = Some(target.clone()); - ok(request.id, json!(true)) - } - Err(_) => failed(request.id, "host_error", "share target lock poisoned"), - }; - response - } - "share.open" => { - let share_state = app.state::(); - let share_text = match share_text_from_request(&request, &share_state) { - Ok(text) => text, - Err(response) => return response, - }; - - match app.clipboard().write_text(share_text) { - Ok(()) => ok( - request.id, - json!({ - "action": "copied_to_clipboard" - }), - ), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - _ => resolve_host_bridge_request(request), - } -} - -#[tauri::command] -async fn host_bridge_request( - app: tauri::AppHandle, - replay_state: tauri::State<'_, HostBridgeReplayState>, - mut request: HostBridgeRequest, -) -> Result { - if let Some(response) = validate_request(&request) { - return Ok(response); - } - request.id = normalize_request_id(&request.id).unwrap_or(request.id); - - let response = match replay_state.reserve(&request.id) { - HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot), - HostBridgeReplayReservation::Execute(slot) => { - let response = execute_host_bridge_request(app, request).await; - replay_state.complete(slot, response) - } - }; - - Ok(response) -} +mod desktop_host_bridge; +mod desktop_host_bridge_files; +mod desktop_host_bridge_protocol; +mod desktop_host_bridge_share; +mod desktop_shell_tray; +mod desktop_shell_webview; + +use desktop_host_bridge::host_bridge_request; +use desktop_host_bridge_protocol::HostBridgeReplayState; +use desktop_host_bridge_share::DesktopShareState; +use desktop_shell_tray::{ + register_desktop_tray, register_desktop_window_close_events, + resolve_desktop_single_instance_action, show_main_window, DesktopSingleInstanceAction, +}; +use desktop_shell_webview::{ + desktop_window_config_with_runtime_platform, emit_desktop_lifecycle_event, + open_desktop_external_navigation, register_desktop_file_drop_events, + register_desktop_lifecycle_events, register_desktop_network_events, + replay_desktop_webview_state, should_allow_desktop_webview_download, + should_allow_desktop_webview_navigation, should_replay_desktop_webview_state_on_page_load, +}; +use tauri::webview::NewWindowResponse; fn main() { tauri::Builder::default() @@ -1835,968 +81,3 @@ fn main() { .run(tauri::generate_context!()) .expect("failed to run Genarrative desktop shell"); } - -#[cfg(test)] -mod tests { - use super::*; - - fn request(method: &str) -> HostBridgeRequest { - HostBridgeRequest { - bridge: HOST_BRIDGE_PROTOCOL.to_string(), - version: HOST_BRIDGE_VERSION, - id: "request-1".to_string(), - method: method.to_string(), - payload: None, - } - } - - #[test] - fn runtime_response_reports_tauri_shell() { - let response = resolve_host_bridge_request(request("host.getRuntime")); - - assert!(response.ok); - let result = response.result.expect("runtime result"); - assert_eq!(result["shell"], "tauri_desktop"); - assert_eq!(result["bridgeVersion"], HOST_BRIDGE_VERSION); - assert_eq!(result["capabilities"], json!(capabilities())); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("appearance.getColorScheme"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("app.lifecycle"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("network.status"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("network.statusChanged"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("share.open"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("share.setTarget"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("navigation.openNativePage"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("app.reloadWebView"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("app.setTitle"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("app.setBadgeCount"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("clipboard.readText"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.exportText"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.importText"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.exportImage"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.importImage"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.importAudio"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.exportAudio"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.imageDropped"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("notification.showLocal"))); - } - - #[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")); - } - - #[test] - fn unsupported_method_is_explicit() { - for method in ["auth.requestLogin", "payment.request"] { - 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)); - } - } - - #[test] - fn invalid_envelope_is_rejected() { - let mut invalid = request("host.getRuntime"); - invalid.bridge = "OtherBridge".to_string(); - - let response = resolve_host_bridge_request(invalid); - - assert!(!response.ok); - assert_eq!(response.error.expect("error").code, "invalid_request"); - } - - #[test] - fn invalid_request_id_and_unknown_method_are_rejected() { - for id in ["", "request\n1"] { - let mut invalid = request("share.open"); - invalid.id = id.to_string(); - - let response = resolve_host_bridge_request(invalid); - - assert!(!response.ok); - assert_eq!(response.id, "invalid"); - assert_eq!(response.error.expect("error").code, "invalid_request"); - } - - let mut oversized = request("share.open"); - oversized.id = "a".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH + 1); - let response = resolve_host_bridge_request(oversized); - assert!(!response.ok); - assert_eq!(response.id, "invalid"); - assert_eq!(response.error.expect("error").code, "invalid_request"); - - let mut multibyte_boundary = request("host.getRuntime"); - multibyte_boundary.id = "作".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH); - let response = resolve_host_bridge_request(multibyte_boundary); - assert!(response.ok); - - let response = resolve_host_bridge_request(request("host.runArbitraryCommand")); - assert!(!response.ok); - let error = response.error.expect("error"); - assert_eq!(error.code, "invalid_request"); - assert_eq!(error.message, "invalid host bridge method"); - } - - #[test] - fn host_bridge_replay_state_reuses_first_response_for_duplicate_id() { - let replay_state = HostBridgeReplayState::default(); - let mut side_effect_count = 0; - - let first_reservation = replay_state.reserve("request-1"); - let first_response = match first_reservation { - HostBridgeReplayReservation::Execute(slot) => { - side_effect_count += 1; - replay_state.complete(slot, ok("request-1".to_string(), json!(true))) - } - HostBridgeReplayReservation::Wait(_) => panic!("first request must execute"), - }; - let second_response = match replay_state.reserve("request-1") { - HostBridgeReplayReservation::Execute(_) => panic!("duplicate request must not execute"), - HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot), - }; - - assert_eq!(side_effect_count, 1); - assert_eq!(second_response.ok, first_response.ok); - assert_eq!(second_response.result, first_response.result); - } - - #[test] - fn invalid_string_payload_is_rejected() { - let mut invalid = request("clipboard.writeText"); - invalid.payload = Some(json!({ "text": 123 })); - - let response = required_string_payload(&invalid, "text").expect_err("invalid payload"); - - assert!(!response.ok); - let error = response.error.expect("error"); - assert_eq!(error.code, "invalid_request"); - assert_eq!(error.message, "text is required"); - } - - #[test] - fn clipboard_text_is_truncated_to_contract_limit() { - assert_eq!( - normalize_clipboard_text("作品号 PZ-1".to_string()), - "作品号 PZ-1" - ); - assert_eq!( - normalize_clipboard_text("a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(), - CLIPBOARD_TEXT_MAX_LENGTH - ); - } - - #[test] - fn local_notification_payload_is_normalized() { - let mut request = request("notification.showLocal"); - request.payload = Some(json!({ - "title": " 生成完成 ", - "body": " 作品已准备好 可以试玩 " - })); - - let (title, body) = local_notification_payload(&request).expect("payload"); - - assert_eq!(title, "生成完成"); - assert_eq!(body.as_deref(), Some("作品已准备好 可以试玩")); - } - - #[test] - fn local_notification_payload_rejects_empty_and_control_text() { - let mut empty = request("notification.showLocal"); - empty.payload = Some(json!({ - "title": " " - })); - - let response = local_notification_payload(&empty).expect_err("empty title"); - - assert_eq!(response.error.expect("error").code, "invalid_request"); - - let mut control = request("notification.showLocal"); - control.payload = Some(json!({ - "title": "生成\n完成" - })); - - let response = local_notification_payload(&control).expect_err("control title"); - - assert_eq!(response.error.expect("error").code, "invalid_request"); - } - - #[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"); - } - - #[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")); - } - - #[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 - )); - } - - #[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", - }) - ); - } - - #[test] - fn desktop_tray_menu_ids_map_to_real_window_actions() { - assert_eq!( - resolve_desktop_tray_menu_action(TRAY_MENU_SHOW), - DesktopTrayAction::ShowMainWindow - ); - assert_eq!( - resolve_desktop_tray_menu_action(TRAY_MENU_RELOAD), - DesktopTrayAction::ReloadMainWindow - ); - assert_eq!( - resolve_desktop_tray_menu_action(TRAY_MENU_QUIT), - DesktopTrayAction::QuitApp - ); - assert_eq!( - resolve_desktop_tray_menu_action("unknown"), - DesktopTrayAction::Ignore - ); - } - - #[test] - fn desktop_tray_left_click_restores_main_window_only_on_release() { - assert_eq!( - resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Up), - DesktopTrayAction::ShowMainWindow - ); - assert_eq!( - resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Down), - DesktopTrayAction::Ignore - ); - assert_eq!( - resolve_desktop_tray_icon_action(MouseButton::Right, MouseButtonState::Up), - DesktopTrayAction::Ignore - ); - } - - #[test] - fn desktop_close_hides_to_tray_only_when_tray_is_registered() { - assert_eq!( - resolve_desktop_window_close_action(true), - DesktopWindowCloseAction::HideToTray - ); - assert_eq!( - resolve_desktop_window_close_action(false), - DesktopWindowCloseAction::CloseWindow - ); - } - - #[test] - fn desktop_single_instance_only_restores_existing_window() { - assert_eq!( - resolve_desktop_single_instance_action(), - DesktopSingleInstanceAction::ShowMainWindow - ); - } - - #[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 - ); - } - - #[test] - fn window_title_normalization_requires_visible_text() { - assert_eq!( - normalize_window_title(" Genarrative "), - Some("Genarrative".to_string()) - ); - assert_eq!(normalize_window_title(""), None); - assert_eq!(normalize_window_title("Genarrative\nDev"), None); - - let long_title = "甲".repeat(120); - assert_eq!( - normalize_window_title(&long_title) - .expect("truncated title") - .chars() - .count(), - 80 - ); - } - - #[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 export_file_name_normalization_rejects_path_like_characters() { - assert_eq!( - normalize_export_file_name(" 作品:记录?.txt "), - "作品-记录-.txt" - ); - assert_eq!(normalize_export_file_name("../secret.txt"), "secret.txt"); - assert_eq!(normalize_export_file_name(""), EXPORT_FILE_NAME_FALLBACK); - - let long_file_name = "甲".repeat(140); - assert_eq!( - normalize_export_file_name(&long_file_name).chars().count(), - EXPORT_FILE_NAME_MAX_LENGTH - ); - } - - #[test] - fn export_text_payload_requires_text_content() { - let mut invalid = request("file.exportText"); - invalid.payload = Some(json!({ - "fileName": "作品记录.txt", - "content": 123 - })); - - let response = export_text_payload(&invalid).expect_err("invalid content"); - - assert!(!response.ok); - let error = response.error.expect("error"); - assert_eq!(error.code, "invalid_request"); - assert_eq!(error.message, "content is required"); - } - - #[test] - fn export_text_payload_rejects_oversized_content() { - let mut invalid = request("file.exportText"); - invalid.payload = Some(json!({ - "fileName": "作品记录.txt", - "content": "a".repeat(EXPORT_TEXT_MAX_BYTES + 1) - })); - - let response = export_text_payload(&invalid).expect_err("oversized content"); - - assert!(!response.ok); - let error = response.error.expect("error"); - assert_eq!(error.code, "invalid_request"); - assert_eq!(error.message, "content exceeds file export size limit"); - } - - #[test] - fn write_export_text_file_persists_utf8_content() { - let path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-export-{}.txt", - std::process::id() - )); - - let bytes = write_export_text_file(path.clone(), "暖灯猫街".to_string()) - .expect("write export file"); - - assert_eq!(bytes, "暖灯猫街".len()); - assert_eq!( - fs::read_to_string(&path).expect("read export file"), - "暖灯猫街" - ); - fs::remove_file(path).expect("remove export file"); - } - - #[test] - fn import_text_file_payload_reads_allowed_text_without_exposing_path() { - let path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-text-{}.md", - std::process::id() - )); - fs::write(&path, "暖灯猫街").expect("write import text"); - - let payload = import_text_file_payload(path.clone()).expect("payload"); - - assert_eq!(payload["action"], "selected"); - assert_eq!( - payload["fileName"], - path.file_name().unwrap().to_str().unwrap() - ); - assert_eq!(payload["content"], "暖灯猫街"); - assert_eq!(payload["mimeType"], "text/markdown"); - assert_eq!(payload["bytes"], "暖灯猫街".len() as u64); - assert!(!payload - .to_string() - .contains(path.to_string_lossy().as_ref())); - - fs::remove_file(path).expect("remove import text"); - } - - #[test] - fn import_text_file_payload_rejects_invalid_or_oversized_text() { - let image_path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-text-{}.png", - std::process::id() - )); - fs::write(&image_path, "text").expect("write image-like text"); - assert_eq!( - import_text_file_payload(image_path.clone()).unwrap_err(), - "text MIME must be allowed" - ); - fs::remove_file(image_path).expect("remove image-like text"); - - let large_path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-text-large-{}.txt", - std::process::id() - )); - fs::write(&large_path, "a".repeat(IMPORT_TEXT_MAX_BYTES as usize + 1)) - .expect("write large text"); - assert_eq!( - import_text_file_payload(large_path.clone()).unwrap_err(), - "text exceeds import size limit" - ); - fs::remove_file(large_path).expect("remove large text"); - } - - #[test] - fn export_image_payload_decodes_allowed_image_base64() { - let mut valid = request("file.exportImage"); - valid.payload = Some(json!({ - "fileName": "分享:卡?.png", - "base64Data": "c2hhcmUtY2FyZA==", - "mimeType": "image/png" - })); - - let (file_name, bytes) = export_image_payload(&valid).expect("image payload"); - - assert_eq!(file_name, "分享-卡-.png"); - assert_eq!(bytes, b"share-card"); - } - - #[test] - fn import_image_file_payload_reads_allowed_image_without_exposing_path() { - let path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-{}.png", - std::process::id() - )); - fs::write(&path, b"image").expect("write import image"); - - let payload = - import_image_file_payload(path.clone(), "selected", Some((12, 24))).expect("payload"); - - assert_eq!(payload["action"], "selected"); - assert_eq!( - payload["fileName"], - path.file_name().unwrap().to_str().unwrap() - ); - assert_eq!(payload["base64Data"], "aW1hZ2U="); - assert_eq!(payload["mimeType"], "image/png"); - assert_eq!(payload["bytes"], 5); - assert_eq!(payload["position"], json!({ "x": 12, "y": 24 })); - assert!(!payload - .to_string() - .contains(path.to_string_lossy().as_ref())); - - fs::remove_file(path).expect("remove import image"); - } - - #[test] - fn import_image_file_payload_rejects_invalid_or_oversized_images() { - let text_path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-{}.txt", - std::process::id() - )); - fs::write(&text_path, b"text").expect("write text file"); - assert_eq!( - import_image_file_payload(text_path.clone(), "selected", None).unwrap_err(), - "image MIME must be allowed" - ); - fs::remove_file(text_path).expect("remove text file"); - - let large_path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-large-{}.webp", - std::process::id() - )); - fs::write( - &large_path, - vec![1u8; (IMPORT_IMAGE_MAX_BYTES + 1) as usize], - ) - .expect("write large image"); - assert_eq!( - import_image_file_payload(large_path.clone(), "selected", None).unwrap_err(), - "image exceeds import size limit" - ); - fs::remove_file(large_path).expect("remove large image"); - } - - #[test] - fn import_audio_file_payload_reads_allowed_audio_without_exposing_path() { - let path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-audio-{}.webm", - std::process::id() - )); - fs::write(&path, b"audio").expect("write import audio"); - - let payload = import_audio_file_payload(path.clone()).expect("payload"); - - assert_eq!(payload["action"], "selected"); - assert_eq!( - payload["fileName"], - path.file_name().unwrap().to_str().unwrap() - ); - assert_eq!(payload["base64Data"], "YXVkaW8="); - assert_eq!(payload["mimeType"], "audio/webm"); - assert_eq!(payload["bytes"], 5); - assert!(!payload - .to_string() - .contains(path.to_string_lossy().as_ref())); - - fs::remove_file(path).expect("remove import audio"); - } - - #[test] - fn import_audio_file_payload_rejects_invalid_or_oversized_audio() { - let text_path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-audio-{}.txt", - std::process::id() - )); - fs::write(&text_path, b"audio").expect("write text file"); - assert_eq!( - import_audio_file_payload(text_path.clone()).unwrap_err(), - "audio MIME must be allowed" - ); - fs::remove_file(text_path).expect("remove text file"); - - let large_path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-audio-large-{}.mp3", - std::process::id() - )); - fs::write( - &large_path, - vec![1u8; (IMPORT_AUDIO_MAX_BYTES + 1) as usize], - ) - .expect("write large audio"); - assert_eq!( - import_audio_file_payload(large_path.clone()).unwrap_err(), - "audio exceeds import size limit" - ); - fs::remove_file(large_path).expect("remove large audio"); - } - - #[test] - fn export_audio_payload_decodes_allowed_audio_base64() { - let mut valid = request("file.exportAudio"); - valid.payload = Some(json!({ - "fileName": "敲击:音效?.wav", - "base64Data": "YXVkaW8=", - "mimeType": "audio/wav" - })); - - let (file_name, bytes) = export_audio_payload(&valid).expect("audio payload"); - - assert_eq!(file_name, "敲击-音效-.wav"); - assert_eq!(bytes, b"audio"); - - let mut missing_extension = request("file.exportAudio"); - missing_extension.payload = Some(json!({ - "fileName": "敲击音效", - "base64Data": "YXVkaW8=", - "mimeType": "audio/webm" - })); - - let (file_name, _bytes) = export_audio_payload(&missing_extension).expect("audio payload"); - - assert_eq!(file_name, "敲击音效.webm"); - } - - #[test] - fn export_audio_payload_rejects_invalid_or_oversized_audio() { - let mut invalid_mime = request("file.exportAudio"); - invalid_mime.payload = Some(json!({ - "fileName": "hit.txt", - "base64Data": "YXVkaW8=", - "mimeType": "text/plain" - })); - let response = export_audio_payload(&invalid_mime).expect_err("invalid mime"); - assert!(!response.ok); - assert_eq!(response.error.expect("error").code, "invalid_request"); - - let mut empty = request("file.exportAudio"); - empty.payload = Some(json!({ - "fileName": "hit.wav", - "base64Data": "", - "mimeType": "audio/wav" - })); - let response = export_audio_payload(&empty).expect_err("empty audio"); - assert!(!response.ok); - assert_eq!(response.error.expect("error").code, "invalid_request"); - - let mut oversized = request("file.exportAudio"); - oversized.payload = Some(json!({ - "fileName": "hit.webm", - "base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_AUDIO_MAX_BYTES + 1]), - "mimeType": "audio/webm" - })); - let response = export_audio_payload(&oversized).expect_err("oversized audio"); - assert!(!response.ok); - let error = response.error.expect("error"); - assert_eq!(error.code, "invalid_request"); - assert_eq!(error.message, "audio exceeds file export size limit"); - } - - #[test] - fn export_image_payload_rejects_invalid_mime_and_base64() { - let mut invalid_mime = request("file.exportImage"); - invalid_mime.payload = Some(json!({ - "fileName": "分享卡.txt", - "base64Data": "c2hhcmUtY2FyZA==", - "mimeType": "text/plain" - })); - let response = export_image_payload(&invalid_mime).expect_err("invalid mime"); - assert_eq!(response.error.expect("error").code, "invalid_request"); - - let mut invalid_base64 = request("file.exportImage"); - invalid_base64.payload = Some(json!({ - "fileName": "分享卡.png", - "base64Data": "not base64!", - "mimeType": "image/png" - })); - let response = export_image_payload(&invalid_base64).expect_err("invalid base64"); - assert_eq!( - response.error.expect("error").message, - "base64Data is invalid" - ); - } - - #[test] - fn export_image_payload_rejects_oversized_image() { - let mut invalid = request("file.exportImage"); - invalid.payload = Some(json!({ - "fileName": "分享卡.png", - "base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_IMAGE_MAX_BYTES + 1]), - "mimeType": "image/png" - })); - - let response = export_image_payload(&invalid).expect_err("oversized image"); - - assert_eq!( - response.error.expect("error").message, - "image exceeds file export size limit" - ); - } - - #[test] - fn write_export_bytes_file_persists_binary_content() { - let path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-share-card-{}.png", - std::process::id() - )); - - let bytes = write_export_bytes_file(path.clone(), vec![0x89, b'P', b'N', b'G']) - .expect("write image file"); - - assert_eq!(bytes, 4); - assert_eq!( - fs::read(&path).expect("read image file"), - vec![0x89, b'P', b'N', b'G'] - ); - fs::remove_file(path).expect("remove image file"); - } - - #[test] - fn share_text_uses_direct_share_payload() { - let state = DesktopShareState::default(); - let mut open = request("share.open"); - open.payload = Some(json!({ - "title": "测试作品", - "message": "来玩这个作品", - "url": "https://app.genarrative.world/works/detail?work=PZ-1" - })); - - let text = share_text_from_request(&open, &state).expect("share text"); - - assert_eq!( - text, - "测试作品\n来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-1" - ); - } - - #[test] - fn share_text_uses_stored_work_target() { - let state = DesktopShareState::default(); - let mut set_target = request("share.setTarget"); - set_target.payload = Some(json!({ - "target": { - "type": "genarrative:share-target", - "payload": { - "work": "PZ-1", - "title": "测试作品" - } - } - })); - let target = set_target - .payload - .as_ref() - .and_then(|payload| payload.get("target")) - .expect("target"); - *state.target.lock().expect("share target lock") = Some(target.clone()); - - let text = share_text_from_request(&request("share.open"), &state).expect("share text"); - - assert_eq!( - text, - "测试作品\nhttps://app.genarrative.world/works/detail?work=PZ-1" - ); - } - - #[test] - fn share_text_requires_payload_or_stored_target() { - let state = DesktopShareState::default(); - let response = - share_text_from_request(&request("share.open"), &state).expect_err("missing target"); - - assert!(!response.ok); - let error = response.error.expect("error"); - assert_eq!(error.code, "invalid_request"); - assert_eq!(error.message, "share target is required"); - } -} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index dc0fbd862..36c8e1619 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2469,3 +2469,10 @@ - 决策:Expo 和 Tauri 注入给 H5 的 HostBridge response / event 统一带 `origin: window.location.origin` 和 `source: window`;`nativeAppHostBridge` listener 只接受无外部 source 或当前窗口 source 的消息,并拒绝非当前页面 origin。AI sandbox 后续继续使用独立 GameBridge allowlist,不允许直接结算 HostBridge 请求。 - 影响范围:`apps/mobile-shell/App.tsx`、`apps/desktop-shell/src-tauri/src/main.rs`、`src/services/host-bridge/nativeAppHostBridge.ts`、两端壳配置检查和 HostBridge 方案文档。 - 验证方式:`npm run check:native-shells`、`npm run test -- src/services/host-bridge/nativeAppHostBridge.test.ts src/services/host-bridge/hostBridge.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 三端宿主桥接层文件结构对齐 + +- 背景:微信小程序壳、Expo 移动壳和 Tauri 桌面壳都在承接宿主能力;如果微信页面继续散落 `index.shared.js`,桌面端继续把桥接分发堆在 `main.rs`,后续新增登录、支付、文件、通知或 sandbox 转发能力时会很难跨端对照 owner。 +- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留生命周期和装配;Expo 移动壳保持 `mobileHostBridge.ts` 负责协议分发,`mobileShell*.ts` 负责 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `desktop_host_bridge*.rs` 与 `desktop_shell*.rs`,`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`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 7b1047d22..571a3ed19 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -49,6 +49,8 @@ AI 生成 H5 游戏 iframe apps/ mobile-shell/ # Expo + React Native App 壳 desktop-shell/ # Tauri 桌面 App 壳 +miniprogram/ + host-bridge/ # 微信小程序宿主桥接逻辑,页面只做装配 packages/ shared/ src/contracts/ @@ -61,6 +63,8 @@ src/ 已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。 +三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,可测试桥接逻辑统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`;Expo 移动壳使用 `apps/mobile-shell/src/mobileHostBridge.ts` 承接协议分发,`mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/desktop_host_bridge*.rs` 承接协议、分发、文件和分享,`desktop_shell*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。 + ## HostBridge 消息协议 H5 进入原生 App 壳时由壳层附加稳定 query: @@ -407,6 +411,8 @@ 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 和装配;移动壳继续保持 `mobileHostBridge.ts` + `mobileShell*.ts`;桌面壳 Rust 源码拆成 `desktop_host_bridge.rs`、`desktop_host_bridge_protocol.rs`、`desktop_host_bridge_files.rs`、`desktop_host_bridge_share.rs`、`desktop_shell_webview.rs`、`desktop_shell_tray.rs` 和薄 `main.rs`。根级 `npm run check:native-shells` 会锁定三端桥接层文件清单,避免后续把能力逻辑重新散落到页面或桌面入口。 + ### Phase 4:宿主能力扩展 - 移动端接入系统分享、推送、原生登录和渠道支付。 @@ -429,7 +435,7 @@ GameBridge 禁止: - AI sandbox 无法调用 HostBridge,也无法读取 H5 登录态。 - Tauri release 包不允许任意远端页面调用桌面命令。 - Expo WebView 外链离开主站后不保留完整 HostBridge。 -- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测和两端生产壳临时替身词扫描。 +- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测和两端生产壳临时替身词扫描。 ## 参考资料 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index f1d12078c..93946b492 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -30,13 +30,15 @@ H5 业务层 -> HostBridge 能力接口 -> browserHostBridge -> wechatMiniProgramHostBridge - -> nativeAppHostBridge(预留) + -> nativeAppHostBridge AI H5 sandbox -> GameBridge 受限协议 -> parent HostBridge adapter ``` +桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的可测试桥接逻辑统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留页面装配;Expo 移动壳使用 `mobileHostBridge.ts` 和 `mobileShell*.ts`;Tauri 桌面壳使用 `desktop_host_bridge*.rs` 和 `desktop_shell*.rs`,`main.rs` 不再承载 HostBridge 分发细节。`npm run check:native-shells` 会检查这些文件清单。 + ## 首批能力 - `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`,并解析 `hostCapabilities` 能力声明;进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主 runtime 并缓存能力清单,未知能力会被丢弃。H5 业务只根据已声明或已回读的能力展示入口、发起宿主请求或走 fallback。 @@ -70,7 +72,7 @@ AI H5 sandbox 2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。 3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。 4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉;主 App 启动会触发一次 `host.getRuntime` 回读并订阅能力变化,避免裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时长期隐藏真实可用能力。 -5. 每次新增或调整 native capability 后,必须运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test 和桌面 release `--no-bundle` 构建烟测;排查单端问题时再单独运行 `npm run mobile-shell:typecheck`、`npm run mobile-shell:test`、`npm run mobile-shell:config`、`npm run mobile-shell:export`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test` 或 `npm run desktop-shell:build -- --no-bundle`。 +5. 每次新增或调整 native capability 后,必须运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test 和桌面 release `--no-bundle` 构建烟测;排查单端问题时再单独运行 `npm run mobile-shell:typecheck`、`npm run mobile-shell:test`、`npm run mobile-shell:config`、`npm run mobile-shell:export`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test` 或 `npm run desktop-shell:build -- --no-bundle`。 ## 验收 @@ -79,7 +81,7 @@ AI H5 sandbox - 小程序支付仍跳转 `/pages/wechat-pay/index` 并保留支付结果 hash 回灌确认。 - 小程序订阅授权仍跳转 `/pages/subscribe-message/index`,且返回不阻断生成主链路。 - 普通浏览器分享、H5 支付和 Native 二维码支付不受影响。 -- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、两端壳实现、Expo managed config、移动端 production bundle 和桌面 release 构建入口没有漂移。 +- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle 和桌面 release 构建入口没有漂移。 ## 后续 diff --git a/miniprogram/pages/wechat-pay/index.shared.js b/miniprogram/host-bridge/wechatHostBridgePayment.js similarity index 100% rename from miniprogram/pages/wechat-pay/index.shared.js rename to miniprogram/host-bridge/wechatHostBridgePayment.js diff --git a/miniprogram/pages/wechat-pay/index.test.js b/miniprogram/host-bridge/wechatHostBridgePayment.test.js similarity index 98% rename from miniprogram/pages/wechat-pay/index.test.js rename to miniprogram/host-bridge/wechatHostBridgePayment.test.js index 1407c89ab..d636b5efe 100644 --- a/miniprogram/pages/wechat-pay/index.test.js +++ b/miniprogram/host-bridge/wechatHostBridgePayment.test.js @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import wechatPayBridge from './index.shared.js'; +import wechatPayBridge from './wechatHostBridgePayment.js'; const { appendPayResult, diff --git a/miniprogram/pages/share-grid/index.shared.js b/miniprogram/host-bridge/wechatHostBridgeShareGrid.js similarity index 100% rename from miniprogram/pages/share-grid/index.shared.js rename to miniprogram/host-bridge/wechatHostBridgeShareGrid.js diff --git a/miniprogram/pages/share-grid/index.test.js b/miniprogram/host-bridge/wechatHostBridgeShareGrid.test.js similarity index 96% rename from miniprogram/pages/share-grid/index.test.js rename to miniprogram/host-bridge/wechatHostBridgeShareGrid.test.js index 832f7890c..5002fef2f 100644 --- a/miniprogram/pages/share-grid/index.test.js +++ b/miniprogram/host-bridge/wechatHostBridgeShareGrid.test.js @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import shareGridBridge from './index.shared.js'; +import shareGridBridge from './wechatHostBridgeShareGrid.js'; const { buildShareGridTileFileName, diff --git a/miniprogram/pages/subscribe-message/index.shared.js b/miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.js similarity index 100% rename from miniprogram/pages/subscribe-message/index.shared.js rename to miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.js diff --git a/miniprogram/pages/subscribe-message/index.test.js b/miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.test.js similarity index 97% rename from miniprogram/pages/subscribe-message/index.test.js rename to miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.test.js index 0922f9332..b69a1253b 100644 --- a/miniprogram/pages/subscribe-message/index.test.js +++ b/miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.test.js @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import subscribeMessageBridge from './index.shared.js'; +import subscribeMessageBridge from './wechatHostBridgeSubscribeMessage.js'; const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU'; diff --git a/miniprogram/pages/web-view/index.shared.js b/miniprogram/host-bridge/wechatHostBridgeWebView.js similarity index 100% rename from miniprogram/pages/web-view/index.shared.js rename to miniprogram/host-bridge/wechatHostBridgeWebView.js diff --git a/miniprogram/pages/web-view/index.test.js b/miniprogram/host-bridge/wechatHostBridgeWebView.test.js similarity index 98% rename from miniprogram/pages/web-view/index.test.js rename to miniprogram/host-bridge/wechatHostBridgeWebView.test.js index a04adbc5b..6790fd020 100644 --- a/miniprogram/pages/web-view/index.test.js +++ b/miniprogram/host-bridge/wechatHostBridgeWebView.test.js @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import webViewBridge from './index.shared.js'; +import webViewBridge from './wechatHostBridgeWebView.js'; const { appendLaunchTargetToEntryUrl, diff --git a/miniprogram/pages/share-grid/index.js b/miniprogram/pages/share-grid/index.js index 2cae173ce..61922c831 100644 --- a/miniprogram/pages/share-grid/index.js +++ b/miniprogram/pages/share-grid/index.js @@ -5,7 +5,7 @@ const { buildShareGridTileFileName, buildShareGridTilePlan, normalizeShareGridQuery, -} = require('./index.shared'); +} = require('../../host-bridge/wechatHostBridgeShareGrid'); function downloadImage(imageUrl) { return new Promise((resolve, reject) => { diff --git a/miniprogram/pages/subscribe-message/index.js b/miniprogram/pages/subscribe-message/index.js index 52ce7ea24..a5349d410 100644 --- a/miniprogram/pages/subscribe-message/index.js +++ b/miniprogram/pages/subscribe-message/index.js @@ -1,7 +1,9 @@ /* global Page */ const { GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID } = require('../../config'); -const { createSubscribeMessagePage } = require('./index.shared'); +const { + createSubscribeMessagePage, +} = require('../../host-bridge/wechatHostBridgeSubscribeMessage'); Page( createSubscribeMessagePage(null, { diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index b417a4957..68becaf67 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -16,7 +16,7 @@ const { buildWebViewShareTimelineQuery, resolveShareTargetFromWebViewMessage, resolveWebViewUrlFromRuntimeConfig, -} = require('./index.shared'); +} = require('../../host-bridge/wechatHostBridgeWebView'); const MINI_PROGRAM_CLIENT_TYPE = 'mini_program'; const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; diff --git a/miniprogram/pages/wechat-pay/index.js b/miniprogram/pages/wechat-pay/index.js index ad188c923..7521bf0b9 100644 --- a/miniprogram/pages/wechat-pay/index.js +++ b/miniprogram/pages/wechat-pay/index.js @@ -1,3 +1,3 @@ -const { createWechatPayPage } = require('./index.shared'); +const { createWechatPayPage } = require('../../host-bridge/wechatHostBridgePayment'); Page(createWechatPayPage()); diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index c2fe747bc..43176ddbf 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -7,6 +7,36 @@ import path from 'node:path'; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const productionShellRoots = ['apps/mobile-shell', 'apps/desktop-shell']; +const expectedWechatHostBridgeFiles = [ + 'wechatHostBridgePayment.js', + 'wechatHostBridgePayment.test.js', + 'wechatHostBridgeShareGrid.js', + 'wechatHostBridgeShareGrid.test.js', + 'wechatHostBridgeSubscribeMessage.js', + 'wechatHostBridgeSubscribeMessage.test.js', + 'wechatHostBridgeWebView.js', + 'wechatHostBridgeWebView.test.js', +]; +const expectedMobileShellFiles = [ + 'mobileHostBridge.ts', + 'mobileShellDeepLink.ts', + 'mobileShellLifecycle.ts', + 'mobileShellNavigation.ts', + 'mobileShellNetwork.ts', + 'mobileShellRuntime.ts', + 'mobileShellSafeArea.ts', + 'mobileShellUrl.ts', + 'mobileShellWebViewPolicy.ts', +]; +const expectedDesktopShellRustFiles = [ + 'desktop_host_bridge.rs', + 'desktop_host_bridge_files.rs', + 'desktop_host_bridge_protocol.rs', + 'desktop_host_bridge_share.rs', + 'desktop_shell_tray.rs', + 'desktop_shell_webview.rs', + 'main.rs', +]; const productionShellExtensions = new Set([ '.json', '.mjs', @@ -145,6 +175,65 @@ function assertNoProductionShellDevScaffoldTerms() { } } +function assertSameList(actual, expected, label) { + if ( + actual.length !== expected.length || + actual.some((value, index) => value !== expected[index]) + ) { + throw new Error( + `${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`, + ); + } +} + +function assertHostBridgeLayerLayout() { + const wechatBridgeFiles = fs + .readdirSync('miniprogram/host-bridge', { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(); + assertSameList( + wechatBridgeFiles, + expectedWechatHostBridgeFiles, + 'wechat host bridge files', + ); + + for (const pagePath of [ + 'miniprogram/pages/web-view/index.js', + 'miniprogram/pages/wechat-pay/index.js', + 'miniprogram/pages/share-grid/index.js', + 'miniprogram/pages/subscribe-message/index.js', + ]) { + const source = fs.readFileSync(pagePath, 'utf8'); + if (source.includes("require('./index.shared')")) { + throw new Error(`${pagePath} must import from miniprogram/host-bridge`); + } + } + + const mobileShellFiles = fs + .readdirSync('apps/mobile-shell/src', { withFileTypes: true }) + .filter((entry) => entry.isFile() && !entry.name.endsWith('.test.ts')) + .map((entry) => entry.name) + .filter((name) => name.startsWith('mobileHostBridge') || name.startsWith('mobileShell')) + .sort(); + assertSameList( + mobileShellFiles, + expectedMobileShellFiles, + 'mobile shell bridge files', + ); + + const desktopShellRustFiles = fs + .readdirSync('apps/desktop-shell/src-tauri/src', { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.rs')) + .map((entry) => entry.name) + .sort(); + assertSameList( + desktopShellRustFiles, + expectedDesktopShellRustFiles, + 'desktop shell Rust bridge files', + ); +} + for (const step of steps) { console.log(`[check:native-shells] ${step.label}`); const result = spawnSync(step.command, step.args, { @@ -171,6 +260,9 @@ for (const step of steps) { } } +console.log('[check:native-shells] host-bridge-layer-layout'); +assertHostBridgeLayerLayout(); + console.log('[check:native-shells] production-shell-dev-scaffold-scan'); assertNoProductionShellDevScaffoldTerms();