Files
Genarrative/apps/desktop-shell/src-tauri/src/host_bridge/share.rs
T
kdletters 8bd8363ffa 修正原生壳公开主站配置
将原生壳公开主站统一为 www.genarrative.world

调整 Tauri release 直接加载线上主站并移除 frontendDist 打包

补齐创作入口配置开发代理与回归测试

同步原生壳方案文档和共享记忆
2026-06-22 15:07:02 +08:00

387 lines
12 KiB
Rust

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<Option<Value>>,
}
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<String> {
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::<Vec<_>>();
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<String, HostBridgeResponse> {
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::<DesktopShareState>();
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::<DesktopShareState>();
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"));
}
}