Files
Genarrative/apps/desktop-shell/src-tauri/src/main.rs
T
kdletters 586e46fa63 接入原生壳网络状态能力
新增 network.status 与 network.statusChanged HostBridge 能力

Expo 壳通过 expo-network 查询并订阅真实网络状态

Tauri 壳通过主站可达性查询和 WebView online/offline 事件同步网络状态

更新壳能力检查、测试和架构文档
2026-06-18 02:35:48 +08:00

1329 lines
42 KiB
Rust

use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
use std::net::{TcpStream, ToSocketAddrs};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Duration;
use tauri::Manager;
use tauri::Theme;
use tauri::Url;
use tauri::WebviewWindow;
use tauri::WindowEvent;
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::DialogExt;
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:"];
const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024;
const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024;
const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt";
const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120;
const BADGE_COUNT_MAX: i64 = 99999;
const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200;
#[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",
"appearance.getColorScheme",
"app.lifecycle",
"share.open",
"share.setTarget",
"navigation.openNativePage",
"app.openExternalUrl",
"app.setTitle",
"app.setBadgeCount",
"network.status",
"network.statusChanged",
"clipboard.writeText",
"file.exportText",
"file.exportImage",
]
}
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 color_scheme_from_theme(theme: Theme) -> &'static str {
match theme {
Theme::Light => "light",
Theme::Dark => "dark",
_ => "unknown",
}
}
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_native_page_url(raw_url: &str) -> Option<Url> {
let url = raw_url.trim();
if url.is_empty() || url.chars().any(char::is_control) {
return None;
}
let base_url = Url::parse(WEB_APP_ORIGIN).ok()?;
let normalized_url = base_url.join(url).ok()?;
if normalized_url.scheme() != "https" || normalized_url.origin() != base_url.origin() {
return None;
}
Some(normalized_url)
}
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 badge_count_payload(request: &HostBridgeRequest) -> Result<Option<i64>, HostBridgeResponse> {
let count = request
.payload
.as_ref()
.and_then(|value| value.get("count"))
.and_then(Value::as_i64)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"count must be an integer between 0 and 99999",
)
})?;
if !(0..=BADGE_COUNT_MAX).contains(&count) {
return Err(failed(
request.id.clone(),
"invalid_request",
"count must be an integer between 0 and 99999",
));
}
Ok(if count == 0 { None } else { Some(count) })
}
fn normalize_export_file_name(raw_file_name: &str) -> String {
let mut file_name = String::new();
let mut last_was_space = false;
for character in raw_file_name
.trim()
.chars()
.take(EXPORT_FILE_NAME_MAX_LENGTH)
{
if character.is_control()
|| matches!(
character,
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
)
{
file_name.push('-');
last_was_space = false;
continue;
}
if character.is_whitespace() {
if !last_was_space {
file_name.push(' ');
last_was_space = true;
}
continue;
}
file_name.push(character);
last_was_space = false;
}
let file_name = file_name
.trim()
.trim_start_matches(|character| matches!(character, '.' | '-') || character.is_whitespace())
.trim();
if file_name.is_empty() {
EXPORT_FILE_NAME_FALLBACK.to_string()
} else {
file_name.to_string()
}
}
fn export_text_payload(
request: &HostBridgeRequest,
) -> Result<(String, String), HostBridgeResponse> {
let payload = request.payload.as_ref().ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"fileName and content are required",
)
})?;
let file_name = payload
.get("fileName")
.and_then(Value::as_str)
.map(normalize_export_file_name)
.unwrap_or_else(|| EXPORT_FILE_NAME_FALLBACK.to_string());
let content = payload
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| failed(request.id.clone(), "invalid_request", "content is required"))?;
if content.len() > EXPORT_TEXT_MAX_BYTES {
return Err(failed(
request.id.clone(),
"invalid_request",
"content exceeds file export size limit",
));
}
Ok((file_name, content.to_string()))
}
fn write_export_text_file(path: PathBuf, content: String) -> Result<usize, String> {
fs::write(path, content.as_bytes()).map_err(|error| error.to_string())?;
Ok(content.len())
}
fn export_image_extension(mime_type: &str) -> Option<&'static str> {
match mime_type {
"image/png" => Some("png"),
"image/jpeg" => Some("jpg"),
"image/webp" => Some("webp"),
_ => None,
}
}
fn normalize_export_image_file_name(raw_file_name: &str, mime_type: &str) -> String {
let mut file_name = normalize_export_file_name(raw_file_name);
let extension = export_image_extension(mime_type).unwrap_or("png");
if !file_name
.to_ascii_lowercase()
.ends_with(&format!(".{}", extension))
{
file_name.push('.');
file_name.push_str(extension);
}
file_name
}
fn export_image_payload(
request: &HostBridgeRequest,
) -> Result<(String, Vec<u8>), HostBridgeResponse> {
let payload = request.payload.as_ref().ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"fileName, mimeType and base64Data are required",
)
})?;
let mime_type = payload
.get("mimeType")
.and_then(Value::as_str)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"mimeType is required",
)
})?;
if export_image_extension(mime_type).is_none() {
return Err(failed(
request.id.clone(),
"invalid_request",
"mimeType must be an allowed image type",
));
}
let base64_data = payload
.get("base64Data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"base64Data is required",
)
})?;
let bytes = BASE64_STANDARD.decode(base64_data).map_err(|_| {
failed(
request.id.clone(),
"invalid_request",
"base64Data is invalid",
)
})?;
if bytes.len() > EXPORT_IMAGE_MAX_BYTES {
return Err(failed(
request.id.clone(),
"invalid_request",
"image exceeds file export size limit",
));
}
let file_name = payload
.get("fileName")
.and_then(Value::as_str)
.map(|file_name| normalize_export_image_file_name(file_name, mime_type))
.unwrap_or_else(|| normalize_export_image_file_name("genarrative-share-card", mime_type));
Ok((file_name, bytes))
}
fn write_export_bytes_file(path: PathBuf, bytes: Vec<u8>) -> Result<usize, String> {
let byte_count = bytes.len();
fs::write(path, bytes).map_err(|error| error.to_string())?;
Ok(byte_count)
}
fn host_bridge_event_script(event: &str, payload: Value) -> Result<String, serde_json::Error> {
let message = json!({
"bridge": HOST_BRIDGE_PROTOCOL,
"version": HOST_BRIDGE_VERSION,
"event": event,
"payload": payload,
});
let data = serde_json::to_string(&message)?;
let data_literal = serde_json::to_string(&data)?;
Ok(format!(
"window.dispatchEvent(new MessageEvent('message', {{ data: {} }})); true;",
data_literal
))
}
fn emit_desktop_lifecycle_event(
window: &WebviewWindow,
state: &'static str,
focused: bool,
native_state: &'static str,
) -> tauri::Result<()> {
let script = host_bridge_event_script(
"app.lifecycle",
json!({
"state": state,
"focused": focused,
"nativeState": native_state,
}),
)
.map_err(tauri::Error::Json)?;
window.eval(script)
}
fn desktop_network_status_payload(is_online: bool) -> Value {
json!({
"isConnected": is_online,
"isInternetReachable": is_online,
"connectionType": if is_online { "unknown" } else { "none" },
"nativeType": if is_online { "online" } else { "offline" },
})
}
fn resolve_desktop_network_status() -> Value {
let timeout = Duration::from_millis(DESKTOP_NETWORK_CHECK_TIMEOUT_MS);
let is_reachable = ("app.genarrative.world", 443)
.to_socket_addrs()
.map(|addresses| {
addresses.into_iter().any(|address| {
TcpStream::connect_timeout(&address, timeout)
.map(|stream| {
drop(stream);
true
})
.unwrap_or(false)
})
})
.unwrap_or(false);
desktop_network_status_payload(is_reachable)
}
fn register_desktop_lifecycle_events(window: &WebviewWindow) {
let lifecycle_window = window.clone();
window.on_window_event(move |event| {
if let WindowEvent::Focused(focused) = event {
let (state, native_state) = if *focused {
("active", "focused")
} else {
("inactive", "blurred")
};
let _ = emit_desktop_lifecycle_event(&lifecycle_window, state, *focused, native_state);
}
});
}
fn register_desktop_network_events(window: &WebviewWindow) -> tauri::Result<()> {
let online_script = host_bridge_event_script(
"network.statusChanged",
desktop_network_status_payload(true),
)
.map_err(tauri::Error::Json)?;
let offline_script = host_bridge_event_script(
"network.statusChanged",
desktop_network_status_payload(false),
)
.map_err(tauri::Error::Json)?;
let current_status_script = host_bridge_event_script(
"network.statusChanged",
json!({
"isConnected": "__GENARRATIVE_DESKTOP_ONLINE__",
"isInternetReachable": "__GENARRATIVE_DESKTOP_ONLINE__",
"connectionType": "__GENARRATIVE_DESKTOP_CONNECTION_TYPE__",
"nativeType": "__GENARRATIVE_DESKTOP_NATIVE_TYPE__",
}),
)
.map_err(tauri::Error::Json)?
.replace("\"__GENARRATIVE_DESKTOP_ONLINE__\"", "navigator.onLine")
.replace(
"\"__GENARRATIVE_DESKTOP_CONNECTION_TYPE__\"",
"(navigator.onLine ? 'unknown' : 'none')",
)
.replace(
"\"__GENARRATIVE_DESKTOP_NATIVE_TYPE__\"",
"(navigator.onLine ? 'online' : 'offline')",
);
let script = format!(
"(() => {{
if (window.__GENARRATIVE_DESKTOP_NETWORK_LISTENER_INSTALLED__) {{
return true;
}}
window.__GENARRATIVE_DESKTOP_NETWORK_LISTENER_INSTALLED__ = true;
const emitOnline = () => {{ {} }};
const emitOffline = () => {{ {} }};
window.addEventListener('online', emitOnline);
window.addEventListener('offline', emitOffline);
{}
return true;
}})();",
online_script, offline_script, current_status_script
);
window.eval(script)
}
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]
async fn host_bridge_request(
app: tauri::AppHandle,
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()),
}
}
"appearance.getColorScheme" => match app.get_webview_window("main") {
Some(window) => match window.theme() {
Ok(theme) => ok(
request.id,
json!({
"colorScheme": color_scheme_from_theme(theme)
}),
),
Err(error) => failed(request.id, "host_error", error.to_string()),
},
None => failed(request.id, "host_error", "main window not found"),
},
"navigation.openNativePage" => {
let url = match required_string_payload(&request, "url")
.ok()
.and_then(normalize_native_page_url)
{
Some(url) => url,
None => {
return failed(
request.id,
"invalid_request",
"url must use an allowed same-origin H5 route",
)
}
};
match app.get_webview_window("main") {
Some(window) => match window.navigate(url) {
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"),
}
}
"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()),
}
}
"file.exportText" => {
let (file_name, content) = match export_text_payload(&request) {
Ok(payload) => payload,
Err(response) => return response,
};
let file_path = app
.dialog()
.file()
.add_filter("Text", &["txt", "json", "md", "csv"])
.set_file_name(file_name.clone())
.blocking_save_file();
let Some(file_path) = file_path else {
return failed(request.id, "cancelled", "file export cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
let export_result =
tauri::async_runtime::spawn_blocking(move || write_export_text_file(path, content))
.await;
let bytes = match export_result {
Ok(Ok(bytes)) => bytes,
Ok(Err(error)) => return failed(request.id, "host_error", error),
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
ok(
request.id,
json!({
"action": "saved",
"fileName": file_name,
"bytes": bytes,
}),
)
}
"file.exportImage" => {
let (file_name, bytes) = match export_image_payload(&request) {
Ok(payload) => payload,
Err(response) => return response,
};
let file_path = app
.dialog()
.file()
.add_filter("Image", &["png", "jpg", "jpeg", "webp"])
.set_file_name(file_name.clone())
.blocking_save_file();
let Some(file_path) = file_path else {
return failed(request.id, "cancelled", "file export cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
let export_result =
tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes))
.await;
let byte_count = match export_result {
Ok(Ok(byte_count)) => byte_count,
Ok(Err(error)) => return failed(request.id, "host_error", error),
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
ok(
request.id,
json!({
"action": "saved",
"fileName": file_name,
"bytes": byte_count,
}),
)
}
"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"),
}
}
"app.setBadgeCount" => {
let count = match badge_count_payload(&request) {
Ok(count) => count,
Err(response) => return response,
};
match app.get_webview_window("main") {
Some(window) => match window.set_badge_count(count) {
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"),
}
}
"network.status" => {
let network_status =
tauri::async_runtime::spawn_blocking(resolve_desktop_network_status).await;
match network_status {
Ok(status) => ok(request.id, status),
Err(error) => failed(request.id, "host_error", error.to_string()),
}
}
"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");
};
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, json!(true))
}
Err(_) => failed(request.id, "host_error", "share target lock poisoned"),
};
response
}
"share.open" => {
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 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_dialog::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let window_config = app.config().app.windows.get(0).cloned();
if let Some(config) = window_config {
let window =
tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?.build()?;
register_desktop_lifecycle_events(&window);
let _ = emit_desktop_lifecycle_event(&window, "active", true, "created");
let _ = register_desktop_network_events(&window);
}
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!("appearance.getColorScheme")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("app.lifecycle")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("network.status")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("network.statusChanged")));
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!("navigation.openNativePage")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("app.setTitle")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("app.setBadgeCount")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("file.exportText")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("file.exportImage")));
}
#[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 color_scheme_maps_window_theme() {
assert_eq!(color_scheme_from_theme(Theme::Light), "light");
assert_eq!(color_scheme_from_theme(Theme::Dark), "dark");
}
#[test]
fn host_bridge_event_script_dispatches_lifecycle_message() {
let script = host_bridge_event_script(
"app.lifecycle",
json!({
"state": "active",
"focused": true,
"nativeState": "focused",
}),
)
.expect("event script");
assert!(script.contains("MessageEvent('message'"));
assert!(script.contains("GenarrativeHostBridge"));
assert!(script.contains("app.lifecycle"));
assert!(script.contains("\\\"state\\\":\\\"active\\\""));
assert!(script.contains("\\\"focused\\\":true"));
}
#[test]
fn desktop_network_status_payload_reports_reachability() {
assert_eq!(
desktop_network_status_payload(true),
json!({
"isConnected": true,
"isInternetReachable": true,
"connectionType": "unknown",
"nativeType": "online",
})
);
assert_eq!(
desktop_network_status_payload(false),
json!({
"isConnected": false,
"isInternetReachable": false,
"connectionType": "none",
"nativeType": "offline",
})
);
}
#[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 native_page_url_normalization_allows_same_origin_routes() {
assert_eq!(
normalize_native_page_url("/works/detail?work=PZ-1")
.expect("same-origin route")
.as_str(),
"https://app.genarrative.world/works/detail?work=PZ-1"
);
assert_eq!(
normalize_native_page_url("works/detail?work=PZ-1")
.expect("relative route")
.as_str(),
"https://app.genarrative.world/works/detail?work=PZ-1"
);
assert_eq!(
normalize_native_page_url("https://app.genarrative.world/works/detail?work=PZ-1")
.expect("absolute same-origin route")
.as_str(),
"https://app.genarrative.world/works/detail?work=PZ-1"
);
}
#[test]
fn native_page_url_normalization_rejects_unsafe_routes() {
assert_eq!(normalize_native_page_url("https://example.com/works"), None);
assert_eq!(normalize_native_page_url("//example.com/works"), None);
assert_eq!(normalize_native_page_url("javascript:alert(1)"), None);
assert_eq!(
normalize_native_page_url("https://app.genarrative.world/\nnext"),
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 badge_count_payload_accepts_clear_and_positive_counts() {
let mut clear = request("app.setBadgeCount");
clear.payload = Some(json!({ "count": 0 }));
assert_eq!(badge_count_payload(&clear).expect("clear badge"), None);
let mut count = request("app.setBadgeCount");
count.payload = Some(json!({ "count": 12 }));
assert_eq!(badge_count_payload(&count).expect("badge count"), Some(12));
}
#[test]
fn badge_count_payload_rejects_invalid_counts() {
for count in [json!(-1), json!(1.5), json!(100000), json!("1")] {
let mut invalid = request("app.setBadgeCount");
invalid.payload = Some(json!({ "count": count }));
let response = badge_count_payload(&invalid).expect_err("invalid count");
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(
error.message,
"count must be an integer between 0 and 99999"
);
}
}
#[test]
fn export_file_name_normalization_rejects_path_like_characters() {
assert_eq!(
normalize_export_file_name(" 作品:记录?.txt "),
"作品-记录-.txt"
);
assert_eq!(normalize_export_file_name("../secret.txt"), "secret.txt");
assert_eq!(normalize_export_file_name(""), EXPORT_FILE_NAME_FALLBACK);
let long_file_name = "甲".repeat(140);
assert_eq!(
normalize_export_file_name(&long_file_name).chars().count(),
EXPORT_FILE_NAME_MAX_LENGTH
);
}
#[test]
fn export_text_payload_requires_text_content() {
let mut invalid = request("file.exportText");
invalid.payload = Some(json!({
"fileName": "作品记录.txt",
"content": 123
}));
let response = export_text_payload(&invalid).expect_err("invalid content");
assert!(!response.ok);
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "content is required");
}
#[test]
fn export_text_payload_rejects_oversized_content() {
let mut invalid = request("file.exportText");
invalid.payload = Some(json!({
"fileName": "作品记录.txt",
"content": "a".repeat(EXPORT_TEXT_MAX_BYTES + 1)
}));
let response = export_text_payload(&invalid).expect_err("oversized content");
assert!(!response.ok);
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "content exceeds file export size limit");
}
#[test]
fn write_export_text_file_persists_utf8_content() {
let path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-export-{}.txt",
std::process::id()
));
let bytes = write_export_text_file(path.clone(), "暖灯猫街".to_string())
.expect("write export file");
assert_eq!(bytes, "暖灯猫街".len());
assert_eq!(
fs::read_to_string(&path).expect("read export file"),
"暖灯猫街"
);
fs::remove_file(path).expect("remove export file");
}
#[test]
fn export_image_payload_decodes_allowed_image_base64() {
let mut valid = request("file.exportImage");
valid.payload = Some(json!({
"fileName": "分享:卡?.png",
"base64Data": "c2hhcmUtY2FyZA==",
"mimeType": "image/png"
}));
let (file_name, bytes) = export_image_payload(&valid).expect("image payload");
assert_eq!(file_name, "分享-卡-.png");
assert_eq!(bytes, b"share-card");
}
#[test]
fn export_image_payload_rejects_invalid_mime_and_base64() {
let mut invalid_mime = request("file.exportImage");
invalid_mime.payload = Some(json!({
"fileName": "分享卡.txt",
"base64Data": "c2hhcmUtY2FyZA==",
"mimeType": "text/plain"
}));
let response = export_image_payload(&invalid_mime).expect_err("invalid mime");
assert_eq!(response.error.expect("error").code, "invalid_request");
let mut invalid_base64 = request("file.exportImage");
invalid_base64.payload = Some(json!({
"fileName": "分享卡.png",
"base64Data": "not base64!",
"mimeType": "image/png"
}));
let response = export_image_payload(&invalid_base64).expect_err("invalid base64");
assert_eq!(
response.error.expect("error").message,
"base64Data is invalid"
);
}
#[test]
fn export_image_payload_rejects_oversized_image() {
let mut invalid = request("file.exportImage");
invalid.payload = Some(json!({
"fileName": "分享卡.png",
"base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_IMAGE_MAX_BYTES + 1]),
"mimeType": "image/png"
}));
let response = export_image_payload(&invalid).expect_err("oversized image");
assert_eq!(
response.error.expect("error").message,
"image exceeds file export size limit"
);
}
#[test]
fn write_export_bytes_file_persists_binary_content() {
let path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-share-card-{}.png",
std::process::id()
));
let bytes = write_export_bytes_file(path.clone(), vec![0x89, b'P', b'N', b'G'])
.expect("write image file");
assert_eq!(bytes, 4);
assert_eq!(
fs::read(&path).expect("read image file"),
vec![0x89, b'P', b'N', b'G']
);
fs::remove_file(path).expect("remove image file");
}
#[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");
}
}