4731263073
桌面壳页面加载完成后回放生命周期状态 桌面壳页面加载完成后重装网络状态监听 桌面壳配置检查锁定页面状态重放路径 宿主壳方案和共享决策日志记录桌面状态重放约束
2803 lines
93 KiB
Rust
2803 lines
93 KiB
Rust
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::net::{TcpStream, ToSocketAddrs};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Condvar, Mutex};
|
|
use std::time::Duration;
|
|
use tauri::menu::{Menu, MenuItem};
|
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
|
use tauri::webview::{DownloadEvent, NewWindowResponse, PageLoadEvent};
|
|
use tauri::DragDropEvent;
|
|
use tauri::Manager;
|
|
use tauri::Theme;
|
|
use tauri::Url;
|
|
use tauri::WebviewUrl;
|
|
use tauri::WebviewWindow;
|
|
use tauri::WindowEvent;
|
|
use tauri_plugin_clipboard_manager::ClipboardExt;
|
|
use tauri_plugin_dialog::DialogExt;
|
|
use tauri_plugin_notification::NotificationExt;
|
|
use tauri_plugin_opener::OpenerExt;
|
|
|
|
const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge";
|
|
const HOST_BRIDGE_VERSION: u8 = 1;
|
|
const HOST_BRIDGE_METHODS: [&str; 23] = [
|
|
"host.getRuntime",
|
|
"appearance.getColorScheme",
|
|
"auth.requestLogin",
|
|
"payment.request",
|
|
"share.setTarget",
|
|
"share.open",
|
|
"navigation.openNativePage",
|
|
"app.reloadWebView",
|
|
"app.openExternalUrl",
|
|
"app.setTitle",
|
|
"app.setBadgeCount",
|
|
"network.status",
|
|
"clipboard.writeText",
|
|
"clipboard.readText",
|
|
"file.exportText",
|
|
"file.importText",
|
|
"file.exportImage",
|
|
"file.importImage",
|
|
"file.captureImage",
|
|
"file.importAudio",
|
|
"file.exportAudio",
|
|
"haptics.impact",
|
|
"notification.showLocal",
|
|
];
|
|
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_AUDIO_MAX_BYTES: usize = 20 * 1024 * 1024;
|
|
const IMPORT_TEXT_MAX_BYTES: u64 = 5 * 1024 * 1024;
|
|
const IMPORT_IMAGE_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
|
const IMPORT_AUDIO_MAX_BYTES: u64 = 20 * 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;
|
|
const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80;
|
|
const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240;
|
|
const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000;
|
|
const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: usize = 120;
|
|
const HOST_BRIDGE_RESPONSE_CACHE_MAX: usize = 128;
|
|
const DESKTOP_TRAY_ID: &str = "genarrative-desktop-tray";
|
|
const TRAY_MENU_SHOW: &str = "show-main-window";
|
|
const TRAY_MENU_RELOAD: &str = "reload-main-window";
|
|
const TRAY_MENU_QUIT: &str = "quit-desktop-shell";
|
|
|
|
#[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(Clone, Debug, Serialize)]
|
|
struct HostBridgeError {
|
|
code: &'static str,
|
|
message: String,
|
|
}
|
|
|
|
#[derive(Clone, 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>>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct HostBridgeReplayState {
|
|
cache: Mutex<HostBridgeReplayCache>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct HostBridgeReplayCache {
|
|
order: Vec<String>,
|
|
slots: HashMap<String, Arc<HostBridgeReplaySlot>>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct HostBridgeReplaySlot {
|
|
response: Mutex<Option<HostBridgeResponse>>,
|
|
ready: Condvar,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum HostBridgeReplayReservation {
|
|
Execute(Arc<HostBridgeReplaySlot>),
|
|
Wait(Arc<HostBridgeReplaySlot>),
|
|
}
|
|
|
|
impl HostBridgeReplayState {
|
|
fn reserve(&self, request_id: &str) -> HostBridgeReplayReservation {
|
|
let mut cache = self.cache.lock().expect("host bridge replay cache lock");
|
|
if let Some(slot) = cache.slots.get(request_id) {
|
|
return HostBridgeReplayReservation::Wait(slot.clone());
|
|
}
|
|
|
|
let slot = Arc::new(HostBridgeReplaySlot::default());
|
|
cache.order.push(request_id.to_string());
|
|
cache.slots.insert(request_id.to_string(), slot.clone());
|
|
while cache.order.len() > HOST_BRIDGE_RESPONSE_CACHE_MAX {
|
|
if let Some(oldest_request_id) = cache.order.first().cloned() {
|
|
cache.order.remove(0);
|
|
cache.slots.remove(&oldest_request_id);
|
|
}
|
|
}
|
|
|
|
HostBridgeReplayReservation::Execute(slot)
|
|
}
|
|
|
|
fn complete(
|
|
&self,
|
|
slot: Arc<HostBridgeReplaySlot>,
|
|
response: HostBridgeResponse,
|
|
) -> HostBridgeResponse {
|
|
let mut stored_response = slot.response.lock().expect("host bridge replay slot lock");
|
|
*stored_response = Some(response.clone());
|
|
slot.ready.notify_all();
|
|
response
|
|
}
|
|
|
|
fn wait_for_response(slot: Arc<HostBridgeReplaySlot>) -> HostBridgeResponse {
|
|
let mut stored_response = slot.response.lock().expect("host bridge replay slot lock");
|
|
while stored_response.is_none() {
|
|
stored_response = slot
|
|
.ready
|
|
.wait(stored_response)
|
|
.expect("host bridge replay slot wait");
|
|
}
|
|
|
|
stored_response.clone().expect("host bridge replay response")
|
|
}
|
|
}
|
|
|
|
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 desktop_entry_url_with_platform(raw_url: &str) -> String {
|
|
let platform = desktop_platform();
|
|
if let Ok(mut url) = Url::parse(raw_url) {
|
|
let query_pairs = url
|
|
.query_pairs()
|
|
.filter(|(key, _)| key != "hostPlatform")
|
|
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
|
.collect::<Vec<_>>();
|
|
url.query_pairs_mut()
|
|
.clear()
|
|
.extend_pairs(
|
|
query_pairs
|
|
.iter()
|
|
.map(|(key, value)| (key.as_str(), value.as_str())),
|
|
)
|
|
.append_pair("hostPlatform", platform);
|
|
return url.to_string();
|
|
}
|
|
|
|
let (without_hash, hash) = raw_url
|
|
.split_once('#')
|
|
.map(|(path, hash)| (path, Some(hash)))
|
|
.unwrap_or((raw_url, None));
|
|
let mut parts = without_hash.splitn(2, '?');
|
|
let path = parts.next().unwrap_or_default();
|
|
let query = parts.next();
|
|
let mut pairs = query
|
|
.map(|query| {
|
|
query
|
|
.split('&')
|
|
.filter(|pair| {
|
|
!pair.is_empty()
|
|
&& !pair
|
|
.split_once('=')
|
|
.map(|(key, _)| key == "hostPlatform")
|
|
.unwrap_or(false)
|
|
})
|
|
.map(str::to_owned)
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
pairs.push(format!("hostPlatform={platform}"));
|
|
let normalized_url = format!("{path}?{}", pairs.join("&"));
|
|
if let Some(hash) = hash {
|
|
format!("{normalized_url}#{hash}")
|
|
} else {
|
|
normalized_url
|
|
}
|
|
}
|
|
|
|
fn desktop_window_config_with_runtime_platform(
|
|
mut config: tauri::utils::config::WindowConfig,
|
|
) -> tauri::utils::config::WindowConfig {
|
|
config.url = match config.url {
|
|
WebviewUrl::External(url) => WebviewUrl::External(
|
|
Url::parse(&desktop_entry_url_with_platform(url.as_str())).unwrap_or(url),
|
|
),
|
|
WebviewUrl::CustomProtocol(url) => WebviewUrl::CustomProtocol(
|
|
Url::parse(&desktop_entry_url_with_platform(url.as_str())).unwrap_or(url),
|
|
),
|
|
WebviewUrl::App(path) => WebviewUrl::App(PathBuf::from(desktop_entry_url_with_platform(
|
|
path.to_string_lossy().as_ref(),
|
|
))),
|
|
other => other,
|
|
};
|
|
config
|
|
}
|
|
|
|
fn capabilities() -> Vec<&'static str> {
|
|
vec![
|
|
"host.getRuntime",
|
|
"appearance.getColorScheme",
|
|
"app.lifecycle",
|
|
"share.open",
|
|
"share.setTarget",
|
|
"navigation.openNativePage",
|
|
"app.reloadWebView",
|
|
"app.openExternalUrl",
|
|
"app.setTitle",
|
|
"app.setBadgeCount",
|
|
"network.status",
|
|
"network.statusChanged",
|
|
"clipboard.writeText",
|
|
"clipboard.readText",
|
|
"file.exportText",
|
|
"file.importText",
|
|
"file.exportImage",
|
|
"file.importImage",
|
|
"file.importAudio",
|
|
"file.exportAudio",
|
|
"file.imageDropped",
|
|
"notification.showLocal",
|
|
]
|
|
}
|
|
|
|
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 has_control_character(value: &str) -> bool {
|
|
value.chars().any(|character| {
|
|
let code_point = character as u32;
|
|
code_point <= 31 || code_point == 127
|
|
})
|
|
}
|
|
|
|
fn normalize_request_id(raw_id: &str) -> Option<String> {
|
|
let id = raw_id.trim();
|
|
if id.is_empty()
|
|
|| id.chars().count() > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH
|
|
|| has_control_character(id)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
Some(id.to_string())
|
|
}
|
|
|
|
fn is_host_bridge_method(method: &str) -> bool {
|
|
HOST_BRIDGE_METHODS.contains(&method)
|
|
}
|
|
|
|
fn validate_request(request: &HostBridgeRequest) -> Option<HostBridgeResponse> {
|
|
let Some(request_id) = normalize_request_id(&request.id) else {
|
|
return Some(failed(
|
|
"invalid".to_string(),
|
|
"invalid_request",
|
|
"invalid host bridge request id",
|
|
));
|
|
};
|
|
|
|
if request.bridge != HOST_BRIDGE_PROTOCOL || request.version != HOST_BRIDGE_VERSION {
|
|
return Some(failed(
|
|
request_id,
|
|
"invalid_request",
|
|
"invalid host bridge envelope",
|
|
));
|
|
}
|
|
|
|
if !is_host_bridge_method(&request.method) {
|
|
return Some(failed(
|
|
request_id,
|
|
"invalid_request",
|
|
"invalid host bridge method",
|
|
));
|
|
}
|
|
|
|
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 is_desktop_packaged_asset_url(url: &Url) -> bool {
|
|
if url.scheme() == "tauri" {
|
|
return true;
|
|
}
|
|
|
|
url.scheme() == "https"
|
|
&& url
|
|
.host_str()
|
|
.map(|host| host.ends_with(".localhost"))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn should_allow_desktop_webview_navigation(url: &Url) -> bool {
|
|
if is_desktop_packaged_asset_url(url) {
|
|
return true;
|
|
}
|
|
|
|
if url.scheme() == "https" {
|
|
let base_url = Url::parse(WEB_APP_ORIGIN).ok();
|
|
return base_url
|
|
.map(|base_url| url.origin() == base_url.origin())
|
|
.unwrap_or(false);
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn desktop_external_navigation_url(url: &Url) -> Option<String> {
|
|
if should_allow_desktop_webview_navigation(url) {
|
|
return None;
|
|
}
|
|
|
|
normalize_external_url(url.as_str())
|
|
}
|
|
|
|
fn open_desktop_external_navigation(app: &tauri::AppHandle, url: &Url) {
|
|
let Some(external_url) = desktop_external_navigation_url(url) else {
|
|
return;
|
|
};
|
|
let _ = app.opener().open_url(external_url, None::<&str>);
|
|
}
|
|
|
|
fn should_allow_desktop_webview_download(event: &DownloadEvent<'_>) -> bool {
|
|
match event {
|
|
DownloadEvent::Requested { .. } => false,
|
|
DownloadEvent::Finished { .. } => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
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_clipboard_text(text: String) -> String {
|
|
text.chars().take(CLIPBOARD_TEXT_MAX_LENGTH).collect()
|
|
}
|
|
|
|
fn normalize_plain_text(
|
|
value: Option<&str>,
|
|
max_length: usize,
|
|
required: bool,
|
|
) -> Option<Option<String>> {
|
|
let Some(value) = value else {
|
|
return if required { None } else { Some(None) };
|
|
};
|
|
if value.chars().any(char::is_control) {
|
|
return None;
|
|
}
|
|
|
|
let text = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if text.is_empty() {
|
|
return if required { None } else { Some(None) };
|
|
}
|
|
|
|
Some(Some(text.chars().take(max_length).collect()))
|
|
}
|
|
|
|
fn local_notification_payload(
|
|
request: &HostBridgeRequest,
|
|
) -> Result<(String, Option<String>), HostBridgeResponse> {
|
|
let payload = request
|
|
.payload
|
|
.as_ref()
|
|
.ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required"))?;
|
|
let title = match normalize_plain_text(
|
|
payload.get("title").and_then(Value::as_str),
|
|
LOCAL_NOTIFICATION_TITLE_MAX_LENGTH,
|
|
true,
|
|
) {
|
|
Some(Some(title)) => title,
|
|
_ => {
|
|
return Err(failed(
|
|
request.id.clone(),
|
|
"invalid_request",
|
|
"title is required",
|
|
))
|
|
}
|
|
};
|
|
let body = match normalize_plain_text(
|
|
payload.get("body").and_then(Value::as_str),
|
|
LOCAL_NOTIFICATION_BODY_MAX_LENGTH,
|
|
false,
|
|
) {
|
|
Some(body) => body,
|
|
None => {
|
|
return Err(failed(
|
|
request.id.clone(),
|
|
"invalid_request",
|
|
"body is invalid",
|
|
))
|
|
}
|
|
};
|
|
|
|
Ok((title, body))
|
|
}
|
|
|
|
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 import_text_mime_type(path: &Path) -> Option<&'static str> {
|
|
match path
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.map(|extension| extension.to_ascii_lowercase())
|
|
.as_deref()
|
|
{
|
|
Some("txt") => Some("text/plain"),
|
|
Some("md") | Some("markdown") => Some("text/markdown"),
|
|
Some("csv") => Some("text/csv"),
|
|
Some("json") => Some("application/json"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn import_text_file_payload(path: PathBuf) -> Result<Value, String> {
|
|
if !path.is_file() {
|
|
return Err("text file is required".to_string());
|
|
}
|
|
|
|
let mime_type =
|
|
import_text_mime_type(&path).ok_or_else(|| "text MIME must be allowed".to_string())?;
|
|
let metadata = fs::metadata(&path).map_err(|error| error.to_string())?;
|
|
let byte_count = metadata.len();
|
|
if byte_count == 0 || byte_count > IMPORT_TEXT_MAX_BYTES {
|
|
return Err("text exceeds import size limit".to_string());
|
|
}
|
|
|
|
let content = fs::read_to_string(&path).map_err(|error| error.to_string())?;
|
|
let byte_count = content.len() as u64;
|
|
if byte_count == 0 || byte_count > IMPORT_TEXT_MAX_BYTES {
|
|
return Err("text exceeds import size limit".to_string());
|
|
}
|
|
let file_name = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.map(normalize_export_file_name)
|
|
.unwrap_or_else(|| "genarrative-import.txt".to_string());
|
|
|
|
Ok(json!({
|
|
"action": "selected",
|
|
"fileName": file_name,
|
|
"content": content,
|
|
"mimeType": mime_type,
|
|
"bytes": byte_count,
|
|
}))
|
|
}
|
|
|
|
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 import_image_mime_type(path: &Path) -> Option<&'static str> {
|
|
match path
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.map(|extension| extension.to_ascii_lowercase())
|
|
.as_deref()
|
|
{
|
|
Some("png") => Some("image/png"),
|
|
Some("jpg") | Some("jpeg") => Some("image/jpeg"),
|
|
Some("webp") => Some("image/webp"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn import_audio_mime_type(path: &Path) -> Option<&'static str> {
|
|
match path
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.map(|extension| extension.to_ascii_lowercase())
|
|
.as_deref()
|
|
{
|
|
Some("mp3") => Some("audio/mpeg"),
|
|
Some("m4a") | Some("mp4") => Some("audio/mp4"),
|
|
Some("wav") => Some("audio/wav"),
|
|
Some("ogg") => Some("audio/ogg"),
|
|
Some("webm") => Some("audio/webm"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn export_audio_extension(mime_type: &str) -> Option<&'static str> {
|
|
match mime_type {
|
|
"audio/mpeg" => Some("mp3"),
|
|
"audio/mp4" => Some("m4a"),
|
|
"audio/wav" => Some("wav"),
|
|
"audio/ogg" => Some("ogg"),
|
|
"audio/webm" => Some("webm"),
|
|
_ => 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 normalize_export_audio_file_name(raw_file_name: &str, mime_type: &str) -> String {
|
|
let mut file_name = normalize_export_file_name(raw_file_name);
|
|
let extension = export_audio_extension(mime_type).unwrap_or("webm");
|
|
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 export_audio_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_audio_extension(mime_type).is_none() {
|
|
return Err(failed(
|
|
request.id.clone(),
|
|
"invalid_request",
|
|
"mimeType must be an allowed audio 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.is_empty() || bytes.len() > EXPORT_AUDIO_MAX_BYTES {
|
|
return Err(failed(
|
|
request.id.clone(),
|
|
"invalid_request",
|
|
"audio exceeds file export size limit",
|
|
));
|
|
}
|
|
|
|
let file_name = payload
|
|
.get("fileName")
|
|
.and_then(Value::as_str)
|
|
.map(|file_name| normalize_export_audio_file_name(file_name, mime_type))
|
|
.unwrap_or_else(|| normalize_export_audio_file_name("genarrative-audio", 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 import_image_file_payload(
|
|
path: PathBuf,
|
|
action: &'static str,
|
|
position: Option<(i32, i32)>,
|
|
) -> Result<Value, String> {
|
|
if !path.is_file() {
|
|
return Err("image file is required".to_string());
|
|
}
|
|
|
|
let mime_type =
|
|
import_image_mime_type(&path).ok_or_else(|| "image MIME must be allowed".to_string())?;
|
|
let metadata = fs::metadata(&path).map_err(|error| error.to_string())?;
|
|
let byte_count = metadata.len();
|
|
if byte_count == 0 || byte_count > IMPORT_IMAGE_MAX_BYTES {
|
|
return Err("image exceeds import size limit".to_string());
|
|
}
|
|
|
|
let bytes = fs::read(&path).map_err(|error| error.to_string())?;
|
|
let byte_count = bytes.len() as u64;
|
|
if byte_count == 0 || byte_count > IMPORT_IMAGE_MAX_BYTES {
|
|
return Err("image exceeds import size limit".to_string());
|
|
}
|
|
let file_name = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.map(normalize_export_file_name)
|
|
.unwrap_or_else(|| "genarrative-import.png".to_string());
|
|
let mut payload = json!({
|
|
"action": action,
|
|
"fileName": file_name,
|
|
"base64Data": BASE64_STANDARD.encode(bytes),
|
|
"mimeType": mime_type,
|
|
"bytes": byte_count,
|
|
});
|
|
|
|
if let Some((x, y)) = position {
|
|
payload["position"] = json!({
|
|
"x": x,
|
|
"y": y,
|
|
});
|
|
}
|
|
|
|
Ok(payload)
|
|
}
|
|
|
|
fn import_audio_file_payload(path: PathBuf) -> Result<Value, String> {
|
|
if !path.is_file() {
|
|
return Err("audio file is required".to_string());
|
|
}
|
|
|
|
let mime_type =
|
|
import_audio_mime_type(&path).ok_or_else(|| "audio MIME must be allowed".to_string())?;
|
|
let metadata = fs::metadata(&path).map_err(|error| error.to_string())?;
|
|
let byte_count = metadata.len();
|
|
if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES {
|
|
return Err("audio exceeds import size limit".to_string());
|
|
}
|
|
|
|
let bytes = fs::read(&path).map_err(|error| error.to_string())?;
|
|
let byte_count = bytes.len() as u64;
|
|
if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES {
|
|
return Err("audio exceeds import size limit".to_string());
|
|
}
|
|
let file_name = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.map(normalize_export_file_name)
|
|
.unwrap_or_else(|| "genarrative-import-audio.webm".to_string());
|
|
|
|
Ok(json!({
|
|
"action": "selected",
|
|
"fileName": file_name,
|
|
"base64Data": BASE64_STANDARD.encode(bytes),
|
|
"mimeType": mime_type,
|
|
"bytes": 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: {}, origin: window.location.origin, source: window }})); 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 emit_desktop_image_drop_event(
|
|
window: &WebviewWindow,
|
|
paths: &[PathBuf],
|
|
position: (i32, i32),
|
|
) -> tauri::Result<()> {
|
|
let Some(path) = paths
|
|
.iter()
|
|
.find(|path| path.is_file() && import_image_mime_type(path).is_some())
|
|
.cloned()
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let Ok(payload) = import_image_file_payload(path, "dropped", Some(position)) else {
|
|
return Ok(());
|
|
};
|
|
let script =
|
|
host_bridge_event_script("file.imageDropped", payload).map_err(tauri::Error::Json)?;
|
|
|
|
window.eval(script)
|
|
}
|
|
|
|
fn register_desktop_file_drop_events(window: &WebviewWindow) {
|
|
let drop_window = window.clone();
|
|
window.on_window_event(move |event| {
|
|
if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, position }) = event {
|
|
let drop_position = (position.x.round() as i32, position.y.round() as i32);
|
|
let _ = emit_desktop_image_drop_event(&drop_window, paths, drop_position);
|
|
}
|
|
});
|
|
}
|
|
|
|
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 should_replay_desktop_webview_state_on_page_load(event: PageLoadEvent) -> bool {
|
|
event == PageLoadEvent::Finished
|
|
}
|
|
|
|
fn replay_desktop_webview_state(window: &WebviewWindow) {
|
|
let focused = window.is_focused().unwrap_or(true);
|
|
let (state, native_state) = if focused {
|
|
("active", "focused")
|
|
} else {
|
|
("inactive", "blurred")
|
|
};
|
|
|
|
let _ = emit_desktop_lifecycle_event(window, state, focused, native_state);
|
|
let _ = register_desktop_network_events(window);
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum DesktopTrayAction {
|
|
ShowMainWindow,
|
|
ReloadMainWindow,
|
|
QuitApp,
|
|
Ignore,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum DesktopWindowCloseAction {
|
|
HideToTray,
|
|
CloseWindow,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum DesktopSingleInstanceAction {
|
|
ShowMainWindow,
|
|
}
|
|
|
|
fn resolve_desktop_tray_menu_action(menu_id: &str) -> DesktopTrayAction {
|
|
match menu_id {
|
|
TRAY_MENU_SHOW => DesktopTrayAction::ShowMainWindow,
|
|
TRAY_MENU_RELOAD => DesktopTrayAction::ReloadMainWindow,
|
|
TRAY_MENU_QUIT => DesktopTrayAction::QuitApp,
|
|
_ => DesktopTrayAction::Ignore,
|
|
}
|
|
}
|
|
|
|
fn resolve_desktop_tray_icon_action(
|
|
button: MouseButton,
|
|
button_state: MouseButtonState,
|
|
) -> DesktopTrayAction {
|
|
if button == MouseButton::Left && button_state == MouseButtonState::Up {
|
|
DesktopTrayAction::ShowMainWindow
|
|
} else {
|
|
DesktopTrayAction::Ignore
|
|
}
|
|
}
|
|
|
|
fn resolve_desktop_window_close_action(tray_registered: bool) -> DesktopWindowCloseAction {
|
|
if tray_registered {
|
|
DesktopWindowCloseAction::HideToTray
|
|
} else {
|
|
DesktopWindowCloseAction::CloseWindow
|
|
}
|
|
}
|
|
|
|
fn resolve_desktop_single_instance_action() -> DesktopSingleInstanceAction {
|
|
DesktopSingleInstanceAction::ShowMainWindow
|
|
}
|
|
|
|
fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
window.show()?;
|
|
window.unminimize()?;
|
|
window.set_focus()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn reload_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
window.reload()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_desktop_tray_action(app: &tauri::AppHandle, action: DesktopTrayAction) {
|
|
match action {
|
|
DesktopTrayAction::ShowMainWindow => {
|
|
let _ = show_main_window(app);
|
|
}
|
|
DesktopTrayAction::ReloadMainWindow => {
|
|
let _ = reload_main_window(app);
|
|
}
|
|
DesktopTrayAction::QuitApp => app.exit(0),
|
|
DesktopTrayAction::Ignore => {}
|
|
}
|
|
}
|
|
|
|
fn register_desktop_tray(app: &tauri::App) -> tauri::Result<()> {
|
|
let show_item = MenuItem::with_id(app, TRAY_MENU_SHOW, "显示主窗口", true, None::<&str>)?;
|
|
let reload_item = MenuItem::with_id(app, TRAY_MENU_RELOAD, "刷新", true, None::<&str>)?;
|
|
let quit_item = MenuItem::with_id(app, TRAY_MENU_QUIT, "退出", true, None::<&str>)?;
|
|
let tray_menu = Menu::with_items(app, &[&show_item, &reload_item, &quit_item])?;
|
|
let mut tray_builder = TrayIconBuilder::with_id(DESKTOP_TRAY_ID)
|
|
.menu(&tray_menu)
|
|
.tooltip("Genarrative")
|
|
.show_menu_on_left_click(false)
|
|
.on_menu_event(|app, event| {
|
|
let menu_id = event.id().0.as_str();
|
|
handle_desktop_tray_action(app, resolve_desktop_tray_menu_action(menu_id));
|
|
})
|
|
.on_tray_icon_event(|tray, event| {
|
|
if let TrayIconEvent::Click {
|
|
button,
|
|
button_state,
|
|
..
|
|
} = event
|
|
{
|
|
handle_desktop_tray_action(
|
|
tray.app_handle(),
|
|
resolve_desktop_tray_icon_action(button, button_state),
|
|
);
|
|
}
|
|
});
|
|
|
|
if let Some(icon) = app.default_window_icon().cloned() {
|
|
tray_builder = tray_builder.icon(icon);
|
|
}
|
|
|
|
tray_builder.build(app)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn register_desktop_window_close_events(window: &WebviewWindow, tray_registered: bool) {
|
|
let close_window = window.clone();
|
|
window.on_window_event(move |event| {
|
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
|
if resolve_desktop_window_close_action(tray_registered)
|
|
== DesktopWindowCloseAction::HideToTray
|
|
{
|
|
api.prevent_close();
|
|
let _ = close_window.hide();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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),
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn execute_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"),
|
|
}
|
|
}
|
|
"app.reloadWebView" => match app.get_webview_window("main") {
|
|
Some(window) => match window.reload() {
|
|
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()),
|
|
}
|
|
}
|
|
"clipboard.readText" => match app.clipboard().read_text() {
|
|
Ok(text) => ok(
|
|
request.id,
|
|
json!({
|
|
"text": normalize_clipboard_text(text),
|
|
}),
|
|
),
|
|
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.importText" => {
|
|
let file_path = app
|
|
.dialog()
|
|
.file()
|
|
.add_filter("Text", &["txt", "md", "markdown", "csv", "json"])
|
|
.blocking_pick_file();
|
|
let Some(file_path) = file_path else {
|
|
return failed(request.id, "cancelled", "file import cancelled");
|
|
};
|
|
let path = match file_path.into_path() {
|
|
Ok(path) => path,
|
|
Err(error) => return failed(request.id, "host_error", error.to_string()),
|
|
};
|
|
let import_result =
|
|
tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await;
|
|
match import_result {
|
|
Ok(Ok(payload)) => ok(request.id, payload),
|
|
Ok(Err(error)) => failed(request.id, "invalid_request", error),
|
|
Err(error) => failed(request.id, "host_error", error.to_string()),
|
|
}
|
|
}
|
|
"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,
|
|
}),
|
|
)
|
|
}
|
|
"file.importImage" => {
|
|
let file_path = app
|
|
.dialog()
|
|
.file()
|
|
.add_filter("Image", &["png", "jpg", "jpeg", "webp"])
|
|
.blocking_pick_file();
|
|
let Some(file_path) = file_path else {
|
|
return failed(request.id, "cancelled", "file import cancelled");
|
|
};
|
|
let path = match file_path.into_path() {
|
|
Ok(path) => path,
|
|
Err(error) => return failed(request.id, "host_error", error.to_string()),
|
|
};
|
|
let import_result = tauri::async_runtime::spawn_blocking(move || {
|
|
import_image_file_payload(path, "selected", None)
|
|
})
|
|
.await;
|
|
match import_result {
|
|
Ok(Ok(payload)) => ok(request.id, payload),
|
|
Ok(Err(error)) => failed(request.id, "invalid_request", error),
|
|
Err(error) => failed(request.id, "host_error", error.to_string()),
|
|
}
|
|
}
|
|
"file.importAudio" => {
|
|
let file_path = app
|
|
.dialog()
|
|
.file()
|
|
.add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"])
|
|
.blocking_pick_file();
|
|
let Some(file_path) = file_path else {
|
|
return failed(request.id, "cancelled", "file import cancelled");
|
|
};
|
|
let path = match file_path.into_path() {
|
|
Ok(path) => path,
|
|
Err(error) => return failed(request.id, "host_error", error.to_string()),
|
|
};
|
|
let import_result =
|
|
tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await;
|
|
match import_result {
|
|
Ok(Ok(payload)) => ok(request.id, payload),
|
|
Ok(Err(error)) => failed(request.id, "invalid_request", error),
|
|
Err(error) => failed(request.id, "host_error", error.to_string()),
|
|
}
|
|
}
|
|
"file.exportAudio" => {
|
|
let (file_name, bytes) = match export_audio_payload(&request) {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
let file_path = app
|
|
.dialog()
|
|
.file()
|
|
.add_filter("Audio", &["mp3", "m4a", "wav", "ogg", "webm"])
|
|
.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()),
|
|
}
|
|
}
|
|
"notification.showLocal" => {
|
|
let (title, body) = match local_notification_payload(&request) {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
let mut notification = app.notification().builder().title(title);
|
|
if let Some(body) = body {
|
|
notification = notification.body(body);
|
|
}
|
|
|
|
match notification.show() {
|
|
Ok(()) => ok(request.id, json!(true)),
|
|
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),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn host_bridge_request(
|
|
app: tauri::AppHandle,
|
|
replay_state: tauri::State<'_, HostBridgeReplayState>,
|
|
mut request: HostBridgeRequest,
|
|
) -> Result<HostBridgeResponse, String> {
|
|
if let Some(response) = validate_request(&request) {
|
|
return Ok(response);
|
|
}
|
|
request.id = normalize_request_id(&request.id).unwrap_or(request.id);
|
|
|
|
let response = match replay_state.reserve(&request.id) {
|
|
HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot),
|
|
HostBridgeReplayReservation::Execute(slot) => {
|
|
let response = execute_host_bridge_request(app, request).await;
|
|
replay_state.complete(slot, response)
|
|
}
|
|
};
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
fn main() {
|
|
tauri::Builder::default()
|
|
.manage(DesktopShareState::default())
|
|
.manage(HostBridgeReplayState::default())
|
|
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
|
match resolve_desktop_single_instance_action() {
|
|
DesktopSingleInstanceAction::ShowMainWindow => {
|
|
let _ = show_main_window(app);
|
|
}
|
|
}
|
|
}))
|
|
.plugin(tauri_plugin_clipboard_manager::init())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_notification::init())
|
|
.plugin(tauri_plugin_opener::init())
|
|
.setup(|app| {
|
|
let tray_registered = match register_desktop_tray(app) {
|
|
Ok(()) => true,
|
|
Err(error) => {
|
|
eprintln!("desktop tray registration failed: {error}");
|
|
false
|
|
}
|
|
};
|
|
let window_config = app.config().app.windows.get(0).cloned();
|
|
if let Some(config) = window_config {
|
|
let config = desktop_window_config_with_runtime_platform(config);
|
|
let app_handle = app.handle().clone();
|
|
let new_window_app_handle = app.handle().clone();
|
|
let window = tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?
|
|
.on_navigation(move |url| {
|
|
if should_allow_desktop_webview_navigation(url) {
|
|
true
|
|
} else {
|
|
open_desktop_external_navigation(&app_handle, url);
|
|
false
|
|
}
|
|
})
|
|
.on_new_window(move |url, _features| {
|
|
open_desktop_external_navigation(&new_window_app_handle, &url);
|
|
NewWindowResponse::Deny
|
|
})
|
|
.on_page_load(|window, payload| {
|
|
if should_replay_desktop_webview_state_on_page_load(payload.event()) {
|
|
replay_desktop_webview_state(&window);
|
|
}
|
|
})
|
|
.on_download(|_webview, event| should_allow_desktop_webview_download(&event))
|
|
.build()?;
|
|
register_desktop_window_close_events(&window, tray_registered);
|
|
register_desktop_lifecycle_events(&window);
|
|
let _ = emit_desktop_lifecycle_event(&window, "active", true, "created");
|
|
let _ = register_desktop_network_events(&window);
|
|
register_desktop_file_drop_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.reloadWebView")));
|
|
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!("clipboard.readText")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.exportText")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.importText")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.exportImage")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.importImage")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.importAudio")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.exportAudio")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.imageDropped")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("notification.showLocal")));
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_entry_url_replaces_static_platform_marker() {
|
|
let platform = desktop_platform();
|
|
|
|
let dev_url = desktop_entry_url_with_platform(
|
|
"http://127.0.0.1:3000/?clientRuntime=native_app&hostPlatform=unknown&bridgeVersion=1",
|
|
);
|
|
let dev_url = Url::parse(&dev_url).expect("dev url");
|
|
|
|
assert_eq!(
|
|
dev_url.query_pairs().find(|(key, _)| key == "hostPlatform"),
|
|
Some(("hostPlatform".into(), platform.into()))
|
|
);
|
|
assert_eq!(
|
|
dev_url
|
|
.query_pairs()
|
|
.filter(|(key, _)| key == "hostPlatform")
|
|
.count(),
|
|
1
|
|
);
|
|
|
|
let packaged_url = desktop_entry_url_with_platform(
|
|
"index.html?clientRuntime=native_app&hostPlatform=unknown&bridgeVersion=1#works",
|
|
);
|
|
|
|
assert!(packaged_url.contains(&format!("hostPlatform={platform}")));
|
|
assert!(!packaged_url.contains("hostPlatform=unknown"));
|
|
assert!(packaged_url.ends_with("#works"));
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_method_is_explicit() {
|
|
for method in ["auth.requestLogin", "payment.request"] {
|
|
let response = resolve_host_bridge_request(request(method));
|
|
|
|
assert!(!response.ok);
|
|
let error = response.error.expect("error");
|
|
assert_eq!(error.code, "unsupported_method");
|
|
assert!(error.message.contains(method));
|
|
}
|
|
}
|
|
|
|
#[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_request_id_and_unknown_method_are_rejected() {
|
|
for id in ["", "request\n1"] {
|
|
let mut invalid = request("share.open");
|
|
invalid.id = id.to_string();
|
|
|
|
let response = resolve_host_bridge_request(invalid);
|
|
|
|
assert!(!response.ok);
|
|
assert_eq!(response.id, "invalid");
|
|
assert_eq!(response.error.expect("error").code, "invalid_request");
|
|
}
|
|
|
|
let mut oversized = request("share.open");
|
|
oversized.id = "a".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH + 1);
|
|
let response = resolve_host_bridge_request(oversized);
|
|
assert!(!response.ok);
|
|
assert_eq!(response.id, "invalid");
|
|
assert_eq!(response.error.expect("error").code, "invalid_request");
|
|
|
|
let mut multibyte_boundary = request("host.getRuntime");
|
|
multibyte_boundary.id = "作".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH);
|
|
let response = resolve_host_bridge_request(multibyte_boundary);
|
|
assert!(response.ok);
|
|
|
|
let response = resolve_host_bridge_request(request("host.runArbitraryCommand"));
|
|
assert!(!response.ok);
|
|
let error = response.error.expect("error");
|
|
assert_eq!(error.code, "invalid_request");
|
|
assert_eq!(error.message, "invalid host bridge method");
|
|
}
|
|
|
|
#[test]
|
|
fn host_bridge_replay_state_reuses_first_response_for_duplicate_id() {
|
|
let replay_state = HostBridgeReplayState::default();
|
|
let mut side_effect_count = 0;
|
|
|
|
let first_reservation = replay_state.reserve("request-1");
|
|
let first_response = match first_reservation {
|
|
HostBridgeReplayReservation::Execute(slot) => {
|
|
side_effect_count += 1;
|
|
replay_state.complete(slot, ok("request-1".to_string(), json!(true)))
|
|
}
|
|
HostBridgeReplayReservation::Wait(_) => panic!("first request must execute"),
|
|
};
|
|
let second_response = match replay_state.reserve("request-1") {
|
|
HostBridgeReplayReservation::Execute(_) => panic!("duplicate request must not execute"),
|
|
HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot),
|
|
};
|
|
|
|
assert_eq!(side_effect_count, 1);
|
|
assert_eq!(second_response.ok, first_response.ok);
|
|
assert_eq!(second_response.result, first_response.result);
|
|
}
|
|
|
|
#[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 clipboard_text_is_truncated_to_contract_limit() {
|
|
assert_eq!(
|
|
normalize_clipboard_text("作品号 PZ-1".to_string()),
|
|
"作品号 PZ-1"
|
|
);
|
|
assert_eq!(
|
|
normalize_clipboard_text("a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(),
|
|
CLIPBOARD_TEXT_MAX_LENGTH
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn local_notification_payload_is_normalized() {
|
|
let mut request = request("notification.showLocal");
|
|
request.payload = Some(json!({
|
|
"title": " 生成完成 ",
|
|
"body": " 作品已准备好 可以试玩 "
|
|
}));
|
|
|
|
let (title, body) = local_notification_payload(&request).expect("payload");
|
|
|
|
assert_eq!(title, "生成完成");
|
|
assert_eq!(body.as_deref(), Some("作品已准备好 可以试玩"));
|
|
}
|
|
|
|
#[test]
|
|
fn local_notification_payload_rejects_empty_and_control_text() {
|
|
let mut empty = request("notification.showLocal");
|
|
empty.payload = Some(json!({
|
|
"title": " "
|
|
}));
|
|
|
|
let response = local_notification_payload(&empty).expect_err("empty title");
|
|
|
|
assert_eq!(response.error.expect("error").code, "invalid_request");
|
|
|
|
let mut control = request("notification.showLocal");
|
|
control.payload = Some(json!({
|
|
"title": "生成\n完成"
|
|
}));
|
|
|
|
let response = local_notification_payload(&control).expect_err("control title");
|
|
|
|
assert_eq!(response.error.expect("error").code, "invalid_request");
|
|
}
|
|
|
|
#[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("origin: window.location.origin"));
|
|
assert!(script.contains("source: window"));
|
|
assert!(script.contains("GenarrativeHostBridge"));
|
|
assert!(script.contains("app.lifecycle"));
|
|
assert!(script.contains("\\\"state\\\":\\\"active\\\""));
|
|
assert!(script.contains("\\\"focused\\\":true"));
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_page_load_replays_state_only_after_finished_load() {
|
|
assert!(should_replay_desktop_webview_state_on_page_load(
|
|
PageLoadEvent::Finished
|
|
));
|
|
assert!(!should_replay_desktop_webview_state_on_page_load(
|
|
PageLoadEvent::Started
|
|
));
|
|
}
|
|
|
|
#[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 desktop_tray_menu_ids_map_to_real_window_actions() {
|
|
assert_eq!(
|
|
resolve_desktop_tray_menu_action(TRAY_MENU_SHOW),
|
|
DesktopTrayAction::ShowMainWindow
|
|
);
|
|
assert_eq!(
|
|
resolve_desktop_tray_menu_action(TRAY_MENU_RELOAD),
|
|
DesktopTrayAction::ReloadMainWindow
|
|
);
|
|
assert_eq!(
|
|
resolve_desktop_tray_menu_action(TRAY_MENU_QUIT),
|
|
DesktopTrayAction::QuitApp
|
|
);
|
|
assert_eq!(
|
|
resolve_desktop_tray_menu_action("unknown"),
|
|
DesktopTrayAction::Ignore
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_tray_left_click_restores_main_window_only_on_release() {
|
|
assert_eq!(
|
|
resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Up),
|
|
DesktopTrayAction::ShowMainWindow
|
|
);
|
|
assert_eq!(
|
|
resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Down),
|
|
DesktopTrayAction::Ignore
|
|
);
|
|
assert_eq!(
|
|
resolve_desktop_tray_icon_action(MouseButton::Right, MouseButtonState::Up),
|
|
DesktopTrayAction::Ignore
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_close_hides_to_tray_only_when_tray_is_registered() {
|
|
assert_eq!(
|
|
resolve_desktop_window_close_action(true),
|
|
DesktopWindowCloseAction::HideToTray
|
|
);
|
|
assert_eq!(
|
|
resolve_desktop_window_close_action(false),
|
|
DesktopWindowCloseAction::CloseWindow
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_single_instance_only_restores_existing_window() {
|
|
assert_eq!(
|
|
resolve_desktop_single_instance_action(),
|
|
DesktopSingleInstanceAction::ShowMainWindow
|
|
);
|
|
}
|
|
|
|
#[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 desktop_webview_navigation_stays_on_packaged_or_same_origin_pages() {
|
|
let packaged_url = Url::parse("tauri://localhost/index.html").expect("packaged url");
|
|
assert!(should_allow_desktop_webview_navigation(&packaged_url));
|
|
assert_eq!(desktop_external_navigation_url(&packaged_url), None);
|
|
|
|
let windows_packaged_url =
|
|
Url::parse("https://tauri.localhost/index.html").expect("windows packaged url");
|
|
assert!(should_allow_desktop_webview_navigation(
|
|
&windows_packaged_url
|
|
));
|
|
assert_eq!(desktop_external_navigation_url(&windows_packaged_url), None);
|
|
|
|
let same_origin_url = Url::parse("https://app.genarrative.world/works/detail?work=PZ-1")
|
|
.expect("same-origin url");
|
|
assert!(should_allow_desktop_webview_navigation(&same_origin_url));
|
|
assert_eq!(desktop_external_navigation_url(&same_origin_url), None);
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_webview_navigation_sends_external_urls_to_system_only() {
|
|
let external_url = Url::parse("https://example.com/path").expect("external url");
|
|
assert!(!should_allow_desktop_webview_navigation(&external_url));
|
|
assert_eq!(
|
|
desktop_external_navigation_url(&external_url),
|
|
Some("https://example.com/path".to_string())
|
|
);
|
|
|
|
let mail_url = Url::parse("mailto:hi@example.com").expect("mail url");
|
|
assert!(!should_allow_desktop_webview_navigation(&mail_url));
|
|
assert_eq!(
|
|
desktop_external_navigation_url(&mail_url),
|
|
Some("mailto:hi@example.com".to_string())
|
|
);
|
|
|
|
let unsafe_url = Url::parse("javascript:alert(1)").expect("unsafe url");
|
|
assert!(!should_allow_desktop_webview_navigation(&unsafe_url));
|
|
assert_eq!(desktop_external_navigation_url(&unsafe_url), None);
|
|
|
|
let file_url = Url::parse("file:///etc/passwd").expect("file url");
|
|
assert!(!should_allow_desktop_webview_navigation(&file_url));
|
|
assert_eq!(desktop_external_navigation_url(&file_url), None);
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_webview_downloads_are_blocked_by_default() {
|
|
let mut destination = std::env::temp_dir().join("genarrative-webview-download.txt");
|
|
let url =
|
|
Url::parse("https://app.genarrative.world/download.txt").expect("download url");
|
|
let requested = DownloadEvent::Requested {
|
|
url: url.clone(),
|
|
destination: &mut destination,
|
|
};
|
|
|
|
assert!(!should_allow_desktop_webview_download(&requested));
|
|
|
|
let finished = DownloadEvent::Finished {
|
|
url,
|
|
path: None,
|
|
success: false,
|
|
};
|
|
|
|
assert!(should_allow_desktop_webview_download(&finished));
|
|
}
|
|
|
|
#[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 import_text_file_payload_reads_allowed_text_without_exposing_path() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"genarrative-host-bridge-import-text-{}.md",
|
|
std::process::id()
|
|
));
|
|
fs::write(&path, "暖灯猫街").expect("write import text");
|
|
|
|
let payload = import_text_file_payload(path.clone()).expect("payload");
|
|
|
|
assert_eq!(payload["action"], "selected");
|
|
assert_eq!(
|
|
payload["fileName"],
|
|
path.file_name().unwrap().to_str().unwrap()
|
|
);
|
|
assert_eq!(payload["content"], "暖灯猫街");
|
|
assert_eq!(payload["mimeType"], "text/markdown");
|
|
assert_eq!(payload["bytes"], "暖灯猫街".len() as u64);
|
|
assert!(!payload
|
|
.to_string()
|
|
.contains(path.to_string_lossy().as_ref()));
|
|
|
|
fs::remove_file(path).expect("remove import text");
|
|
}
|
|
|
|
#[test]
|
|
fn import_text_file_payload_rejects_invalid_or_oversized_text() {
|
|
let image_path = std::env::temp_dir().join(format!(
|
|
"genarrative-host-bridge-import-text-{}.png",
|
|
std::process::id()
|
|
));
|
|
fs::write(&image_path, "text").expect("write image-like text");
|
|
assert_eq!(
|
|
import_text_file_payload(image_path.clone()).unwrap_err(),
|
|
"text MIME must be allowed"
|
|
);
|
|
fs::remove_file(image_path).expect("remove image-like text");
|
|
|
|
let large_path = std::env::temp_dir().join(format!(
|
|
"genarrative-host-bridge-import-text-large-{}.txt",
|
|
std::process::id()
|
|
));
|
|
fs::write(&large_path, "a".repeat(IMPORT_TEXT_MAX_BYTES as usize + 1))
|
|
.expect("write large text");
|
|
assert_eq!(
|
|
import_text_file_payload(large_path.clone()).unwrap_err(),
|
|
"text exceeds import size limit"
|
|
);
|
|
fs::remove_file(large_path).expect("remove large text");
|
|
}
|
|
|
|
#[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 import_image_file_payload_reads_allowed_image_without_exposing_path() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"genarrative-host-bridge-import-{}.png",
|
|
std::process::id()
|
|
));
|
|
fs::write(&path, b"image").expect("write import image");
|
|
|
|
let payload =
|
|
import_image_file_payload(path.clone(), "selected", Some((12, 24))).expect("payload");
|
|
|
|
assert_eq!(payload["action"], "selected");
|
|
assert_eq!(
|
|
payload["fileName"],
|
|
path.file_name().unwrap().to_str().unwrap()
|
|
);
|
|
assert_eq!(payload["base64Data"], "aW1hZ2U=");
|
|
assert_eq!(payload["mimeType"], "image/png");
|
|
assert_eq!(payload["bytes"], 5);
|
|
assert_eq!(payload["position"], json!({ "x": 12, "y": 24 }));
|
|
assert!(!payload
|
|
.to_string()
|
|
.contains(path.to_string_lossy().as_ref()));
|
|
|
|
fs::remove_file(path).expect("remove import image");
|
|
}
|
|
|
|
#[test]
|
|
fn import_image_file_payload_rejects_invalid_or_oversized_images() {
|
|
let text_path = std::env::temp_dir().join(format!(
|
|
"genarrative-host-bridge-import-{}.txt",
|
|
std::process::id()
|
|
));
|
|
fs::write(&text_path, b"text").expect("write text file");
|
|
assert_eq!(
|
|
import_image_file_payload(text_path.clone(), "selected", None).unwrap_err(),
|
|
"image MIME must be allowed"
|
|
);
|
|
fs::remove_file(text_path).expect("remove text file");
|
|
|
|
let large_path = std::env::temp_dir().join(format!(
|
|
"genarrative-host-bridge-import-large-{}.webp",
|
|
std::process::id()
|
|
));
|
|
fs::write(
|
|
&large_path,
|
|
vec![1u8; (IMPORT_IMAGE_MAX_BYTES + 1) as usize],
|
|
)
|
|
.expect("write large image");
|
|
assert_eq!(
|
|
import_image_file_payload(large_path.clone(), "selected", None).unwrap_err(),
|
|
"image exceeds import size limit"
|
|
);
|
|
fs::remove_file(large_path).expect("remove large image");
|
|
}
|
|
|
|
#[test]
|
|
fn import_audio_file_payload_reads_allowed_audio_without_exposing_path() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"genarrative-host-bridge-import-audio-{}.webm",
|
|
std::process::id()
|
|
));
|
|
fs::write(&path, b"audio").expect("write import audio");
|
|
|
|
let payload = import_audio_file_payload(path.clone()).expect("payload");
|
|
|
|
assert_eq!(payload["action"], "selected");
|
|
assert_eq!(
|
|
payload["fileName"],
|
|
path.file_name().unwrap().to_str().unwrap()
|
|
);
|
|
assert_eq!(payload["base64Data"], "YXVkaW8=");
|
|
assert_eq!(payload["mimeType"], "audio/webm");
|
|
assert_eq!(payload["bytes"], 5);
|
|
assert!(!payload
|
|
.to_string()
|
|
.contains(path.to_string_lossy().as_ref()));
|
|
|
|
fs::remove_file(path).expect("remove import audio");
|
|
}
|
|
|
|
#[test]
|
|
fn import_audio_file_payload_rejects_invalid_or_oversized_audio() {
|
|
let text_path = std::env::temp_dir().join(format!(
|
|
"genarrative-host-bridge-import-audio-{}.txt",
|
|
std::process::id()
|
|
));
|
|
fs::write(&text_path, b"audio").expect("write text file");
|
|
assert_eq!(
|
|
import_audio_file_payload(text_path.clone()).unwrap_err(),
|
|
"audio MIME must be allowed"
|
|
);
|
|
fs::remove_file(text_path).expect("remove text file");
|
|
|
|
let large_path = std::env::temp_dir().join(format!(
|
|
"genarrative-host-bridge-import-audio-large-{}.mp3",
|
|
std::process::id()
|
|
));
|
|
fs::write(
|
|
&large_path,
|
|
vec![1u8; (IMPORT_AUDIO_MAX_BYTES + 1) as usize],
|
|
)
|
|
.expect("write large audio");
|
|
assert_eq!(
|
|
import_audio_file_payload(large_path.clone()).unwrap_err(),
|
|
"audio exceeds import size limit"
|
|
);
|
|
fs::remove_file(large_path).expect("remove large audio");
|
|
}
|
|
|
|
#[test]
|
|
fn export_audio_payload_decodes_allowed_audio_base64() {
|
|
let mut valid = request("file.exportAudio");
|
|
valid.payload = Some(json!({
|
|
"fileName": "敲击:音效?.wav",
|
|
"base64Data": "YXVkaW8=",
|
|
"mimeType": "audio/wav"
|
|
}));
|
|
|
|
let (file_name, bytes) = export_audio_payload(&valid).expect("audio payload");
|
|
|
|
assert_eq!(file_name, "敲击-音效-.wav");
|
|
assert_eq!(bytes, b"audio");
|
|
|
|
let mut missing_extension = request("file.exportAudio");
|
|
missing_extension.payload = Some(json!({
|
|
"fileName": "敲击音效",
|
|
"base64Data": "YXVkaW8=",
|
|
"mimeType": "audio/webm"
|
|
}));
|
|
|
|
let (file_name, _bytes) = export_audio_payload(&missing_extension).expect("audio payload");
|
|
|
|
assert_eq!(file_name, "敲击音效.webm");
|
|
}
|
|
|
|
#[test]
|
|
fn export_audio_payload_rejects_invalid_or_oversized_audio() {
|
|
let mut invalid_mime = request("file.exportAudio");
|
|
invalid_mime.payload = Some(json!({
|
|
"fileName": "hit.txt",
|
|
"base64Data": "YXVkaW8=",
|
|
"mimeType": "text/plain"
|
|
}));
|
|
let response = export_audio_payload(&invalid_mime).expect_err("invalid mime");
|
|
assert!(!response.ok);
|
|
assert_eq!(response.error.expect("error").code, "invalid_request");
|
|
|
|
let mut empty = request("file.exportAudio");
|
|
empty.payload = Some(json!({
|
|
"fileName": "hit.wav",
|
|
"base64Data": "",
|
|
"mimeType": "audio/wav"
|
|
}));
|
|
let response = export_audio_payload(&empty).expect_err("empty audio");
|
|
assert!(!response.ok);
|
|
assert_eq!(response.error.expect("error").code, "invalid_request");
|
|
|
|
let mut oversized = request("file.exportAudio");
|
|
oversized.payload = Some(json!({
|
|
"fileName": "hit.webm",
|
|
"base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_AUDIO_MAX_BYTES + 1]),
|
|
"mimeType": "audio/webm"
|
|
}));
|
|
let response = export_audio_payload(&oversized).expect_err("oversized audio");
|
|
assert!(!response.ok);
|
|
let error = response.error.expect("error");
|
|
assert_eq!(error.code, "invalid_request");
|
|
assert_eq!(error.message, "audio exceeds file export size limit");
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|