a87f3dcc82
HostBridge 契约新增 app.setTitle 方法和标题 payload Tauri 桌面壳通过主窗口 API 同步非空窗口标题 桌面壳能力清单和配置守卫声明 app.setTitle 补充标题校验测试并更新宿主壳方案和团队共享决策记录
545 lines
16 KiB
Rust
545 lines
16 KiB
Rust
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<Value>,
|
|
}
|
|
|
|
#[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<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
error: Option<HostBridgeError>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct DesktopShareState {
|
|
target: Mutex<Option<Value>>,
|
|
}
|
|
|
|
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<String>) -> 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<HostBridgeResponse> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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::<Vec<_>>();
|
|
|
|
if parts.is_empty() {
|
|
None
|
|
} else {
|
|
Some(parts.join("\n"))
|
|
}
|
|
}
|
|
|
|
fn share_text_from_request(
|
|
request: &HostBridgeRequest,
|
|
share_state: &DesktopShareState,
|
|
) -> Result<String, HostBridgeResponse> {
|
|
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");
|
|
}
|
|
}
|