8bd8363ffa
将原生壳公开主站统一为 www.genarrative.world 调整 Tauri release 直接加载线上主站并移除 frontendDist 打包 补齐创作入口配置开发代理与回归测试 同步原生壳方案文档和共享记忆
387 lines
12 KiB
Rust
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"));
|
|
}
|
|
}
|