use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::sync::Mutex; use tauri::Manager; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_opener::OpenerExt; const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge"; const HOST_BRIDGE_VERSION: u8 = 1; const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; #[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(Debug, Serialize)] struct HostBridgeError { code: &'static str, message: String, } #[derive(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>, } 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 capabilities() -> Vec<&'static str> { vec![ "host.getRuntime", "share.open", "share.setTarget", "app.openExternalUrl", "app.setTitle", "clipboard.writeText", ] } 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 validate_request(request: &HostBridgeRequest) -> Option { if request.bridge != HOST_BRIDGE_PROTOCOL || request.version != HOST_BRIDGE_VERSION { return Some(failed( request.id.clone(), "invalid_request", "invalid host bridge envelope", )); } 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 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 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), ), } } #[tauri::command] fn host_bridge_request( app: tauri::AppHandle, share_state: tauri::State<'_, DesktopShareState>, 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()), } } "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()), } } "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"), } } "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"); }; 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"), } } "share.open" => { 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), } } fn main() { tauri::Builder::default() .manage(DesktopShareState::default()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_opener::init()) .setup(|app| { let window_config = app.config().app.windows.get(0).cloned(); if let Some(config) = window_config { tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?.build()?; } Ok(()) }) .invoke_handler(tauri::generate_handler![host_bridge_request]) .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!("share.open"))); assert!(result["capabilities"] .as_array() .unwrap() .contains(&json!("share.setTarget"))); assert!(result["capabilities"] .as_array() .unwrap() .contains(&json!("app.setTitle"))); } #[test] fn unsupported_method_is_explicit() { let response = resolve_host_bridge_request(request("payment.request")); assert!(!response.ok); let error = response.error.expect("error"); assert_eq!(error.code, "unsupported_method"); assert!(error.message.contains("payment.request")); } #[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_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 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 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 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"); } }