use crate::host_bridge::clipboard::{ clipboard_write_unavailable_response, write_desktop_clipboard_text, }; use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse}; use crate::shell::webview::WEB_APP_ORIGIN; use serde_json::json; use serde_json::Value; use std::sync::Mutex; use tauri::Manager; use tauri::Url; #[derive(Debug, Default)] pub(crate) struct DesktopShareState { pub(crate) target: Mutex>, } enum DesktopSharePayload { Empty, Invalid, Valid(String), } 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 log_desktop_share_failure(label: &str) -> bool { eprintln!("desktop share failed for {label}"); false } fn share_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { failed(request.id.clone(), "host_error", "share unavailable") } fn share_target_payload(value: &Value) -> &Value { value.get("target").unwrap_or(value) } fn work_detail_url(work: &str) -> String { let mut url = Url::parse(WEB_APP_ORIGIN).expect("desktop web origin"); url.set_path("/works/detail"); url.set_query(None); url.query_pairs_mut().append_pair("work", work); url.to_string() } fn normalize_public_share_url(raw_url: &str) -> Option { if raw_url.starts_with("//") { return None; } let base_url = Url::parse(WEB_APP_ORIGIN).ok()?; let url = base_url.join(raw_url).ok()?; if url.origin() != base_url.origin() { return None; } Some(url.to_string()) } fn share_text_from_value(value: &Value) -> DesktopSharePayload { 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 raw_direct_url = payload_string(payload, "url").or_else(|| payload_string(payload, "href")); let direct_url = match raw_direct_url { Some(url) => match normalize_public_share_url(url) { Some(url) => Some(url), None => return DesktopSharePayload::Invalid, }, None => None, }; let work_url = payload_string(payload, "work").map(work_detail_url); let raw_path = payload_string(payload, "path").or_else(|| payload_string(payload, "targetPath")); let path_url = match raw_path { Some(path) => match normalize_public_share_url(path) { Some(url) => Some(url), None => return DesktopSharePayload::Invalid, }, None => None, }; let resolved_url = direct_url.or(work_url).or(path_url); let parts = [title, message, resolved_url.as_deref()] .into_iter() .flatten() .collect::>(); if parts.is_empty() { DesktopSharePayload::Empty } else { DesktopSharePayload::Valid(parts.join("\n")) } } pub(crate) fn share_text_from_request( request: &HostBridgeRequest, share_state: &DesktopShareState, ) -> Result { if let Some(payload) = request.payload.as_ref() { match share_text_from_value(payload) { DesktopSharePayload::Valid(text) => return Ok(text), DesktopSharePayload::Invalid => { return Err(failed( request.id.clone(), "invalid_request", "share target is invalid", )) } DesktopSharePayload::Empty => {} } } let stored_target = share_state .target .lock() .map_err(|_| { log_desktop_share_failure("target.lock"); share_unavailable_response(request) })? .clone(); match stored_target.as_ref().map(share_text_from_value) { Some(DesktopSharePayload::Valid(text)) => Ok(text), Some(DesktopSharePayload::Invalid) => Err(failed( request.id.clone(), "invalid_request", "share target is invalid", )), Some(DesktopSharePayload::Empty) | None => Err(failed( request.id.clone(), "invalid_request", "share target is required", )), } } pub(crate) fn set_desktop_host_bridge_share_target( app: &tauri::AppHandle, request: &HostBridgeRequest, ) -> HostBridgeResponse { let target = request .payload .as_ref() .and_then(|payload| payload.get("target")); let Some(target) = target else { return failed(request.id.clone(), "invalid_request", "target is required"); }; match share_text_from_value(target) { DesktopSharePayload::Valid(_) => {} DesktopSharePayload::Invalid => { return failed( request.id.clone(), "invalid_request", "share target is invalid", ) } DesktopSharePayload::Empty => { return failed( request.id.clone(), "invalid_request", "share 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.clone(), json!(true)) } Err(_) => { log_desktop_share_failure("target.store"); share_unavailable_response(request) } }; response } pub(crate) fn open_desktop_host_bridge_share( app: &tauri::AppHandle, request: &HostBridgeRequest, ) -> HostBridgeResponse { let share_state = app.state::(); let share_text = match share_text_from_request(request, &share_state) { Ok(text) => text, Err(response) => return response, }; match write_desktop_clipboard_text(app, &share_text) { Ok(()) => ok( request.id.clone(), json!({ "action": "copied_to_clipboard" }), ), Err(_) => clipboard_write_unavailable_response(request), } } #[cfg(test)] mod tests { use super::*; use crate::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://www.genarrative.world/works/detail?work=PZ-1" })); let text = share_text_from_request(&open, &state).expect("share text"); assert_eq!( text, "测试作品\n来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-1" ); } #[test] fn share_text_normalizes_public_urls_only() { let state = DesktopShareState::default(); let mut direct_path = request("share.open"); direct_path.payload = Some(json!({ "title": "测试作品", "path": "/works/detail?work=PZ-1" })); let path_text = share_text_from_request(&direct_path, &state).expect("path share text"); assert_eq!( path_text, "测试作品\nhttps://www.genarrative.world/works/detail?work=PZ-1" ); let mut work = request("share.open"); work.payload = Some(json!({ "title": "测试作品", "work": "PZ 1/二" })); let work_text = share_text_from_request(&work, &state).expect("work share text"); assert_eq!( work_text, "测试作品\nhttps://www.genarrative.world/works/detail?work=PZ+1%2F%E4%BA%8C" ); } #[test] fn share_text_rejects_external_or_unsafe_urls_without_using_cached_target() { let state = DesktopShareState::default(); *state.target.lock().expect("share target lock") = Some(json!({ "payload": { "title": "缓存作品", "work": "PZ-1" } })); for field in ["url", "href", "path", "targetPath"] { let mut open = request("share.open"); open.payload = Some(json!({ "title": "危险作品", field: if field == "path" { "//evil.example/works/detail?work=PZ-1" } else { "https://evil.example/works/detail?work=PZ-1" } })); let response = share_text_from_request(&open, &state).expect_err("invalid share url"); assert!(!response.ok); let error = response.error.expect("error"); assert_eq!(error.code, "invalid_request"); assert_eq!(error.message, "share target is invalid"); } let mut javascript_url = request("share.open"); javascript_url.payload = Some(json!({ "title": "危险作品", "url": "javascript:alert(1)" })); assert!( !share_text_from_request(&javascript_url, &state) .expect_err("unsafe share url") .ok ); } #[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://www.genarrative.world/works/detail?work=PZ-1" ); } #[test] fn share_target_payload_must_be_valid_before_cache() { assert!(matches!( share_text_from_value(&json!({})), DesktopSharePayload::Empty )); assert!(matches!( share_text_from_value(&json!({ "title": "危险作品", "url": "https://example.com/works/detail?work=PZ-1" })), DesktopSharePayload::Invalid )); assert!(matches!( share_text_from_value(&json!({ "title": "测试作品", "work": "PZ-1" })), DesktopSharePayload::Valid(_) )); } #[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"); } #[test] fn share_unavailable_response_is_stable() { let response = share_unavailable_response(&request("share.open")); assert!(!response.ok); let error = response.error.expect("share error"); assert_eq!(error.code, "host_error"); assert_eq!(error.message, "share unavailable"); } #[test] fn share_failures_are_logged_without_exposing_native_detail() { assert!(!log_desktop_share_failure("target.lock")); assert!(!log_desktop_share_failure("target.store")); } #[test] fn share_failures_log_stable_label_only() { assert!(!log_desktop_share_failure("target.lock")); } }