合并原生壳桥接统一分支
合并 Expo 移动壳与 Tauri 桌面壳工程 保留创作主页、项目入口和现有生产发布脚本 同步 HostBridge 文档、依赖锁文件和原生壳门禁
This commit is contained in:
@@ -28,6 +28,11 @@ temp-build-goal-check/
|
||||
/public/generated-custom-world-scenes
|
||||
temp*build*/
|
||||
/server-rs/target/
|
||||
/apps/desktop-shell/src-tauri/target/
|
||||
/apps/desktop-shell/src-tauri/gen/
|
||||
/apps/desktop-shell/src-tauri/permissions/autogenerated/
|
||||
/apps/mobile-shell/.expo/
|
||||
/apps/mobile-shell/.expo-export-smoke/
|
||||
/server-rs/.spacetimedb/
|
||||
/server-rs/.data/
|
||||
/public/generated-animations
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@genarrative/desktop-shell",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "WEB_PORT=3000 tauri dev",
|
||||
"build": "tauri build",
|
||||
"stage-release-binary": "node scripts/stage-release-binary.mjs",
|
||||
"typecheck": "node scripts/check-config.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"typescript": "~5.8.2"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(new URL('../../../', import.meta.url).pathname);
|
||||
const releaseDir = path.join(
|
||||
repoRoot,
|
||||
'apps',
|
||||
'desktop-shell',
|
||||
'src-tauri',
|
||||
'target',
|
||||
'release',
|
||||
);
|
||||
const executableName =
|
||||
process.platform === 'win32'
|
||||
? 'genarrative-desktop-shell.exe'
|
||||
: 'genarrative-desktop-shell';
|
||||
const sourcePath = path.join(releaseDir, executableName);
|
||||
const stagedDir = path.join(repoRoot, 'build', 'native', 'desktop');
|
||||
const stagedPath = path.join(stagedDir, executableName);
|
||||
|
||||
function assertExecutable(filePath, label) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`desktop release binary is missing: ${filePath}`);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile() || stat.size < 1024 * 1024) {
|
||||
throw new Error(`${label} must be a real non-empty executable file`);
|
||||
}
|
||||
|
||||
const handle = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const header = Buffer.alloc(8);
|
||||
fs.readSync(handle, header, 0, header.length, 0);
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
const isElf =
|
||||
header[0] === 0x7f &&
|
||||
header[1] === 0x45 &&
|
||||
header[2] === 0x4c &&
|
||||
header[3] === 0x46;
|
||||
if (!isElf || (stat.mode & 0o111) === 0) {
|
||||
throw new Error(`${label} must be an executable ELF file`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const machMagic = header.readUInt32BE(0);
|
||||
const isMachO =
|
||||
machMagic === 0xcafebabe ||
|
||||
machMagic === 0xcafed00d ||
|
||||
machMagic === 0xfeedface ||
|
||||
machMagic === 0xfeedfacf;
|
||||
if (!isMachO || (stat.mode & 0o111) === 0) {
|
||||
throw new Error(`${label} must be an executable Mach-O file`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (header[0] !== 0x4d || header[1] !== 0x5a) {
|
||||
throw new Error(`${label} must be a PE executable`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(handle);
|
||||
}
|
||||
}
|
||||
|
||||
assertExecutable(sourcePath, 'desktop release binary');
|
||||
fs.mkdirSync(stagedDir, {recursive: true});
|
||||
fs.copyFileSync(sourcePath, stagedPath);
|
||||
const sourceMode = fs.statSync(sourcePath).mode;
|
||||
fs.chmodSync(stagedPath, sourceMode & 0o777);
|
||||
assertExecutable(stagedPath, 'staged desktop release binary');
|
||||
|
||||
console.log(`[desktop-shell:stage-release-binary] ${stagedPath}`);
|
||||
Generated
+5800
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "genarrative-desktop-shell"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2.11.2", features = ["tray-icon"] }
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>允许 Genarrative 使用摄像头运行需要实时动作输入的同源 H5 体验。</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>允许 Genarrative 使用麦克风运行需要实时声音输入的同源 H5 玩法。</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
let app_manifest = tauri_build::AppManifest::new().commands(&["host_bridge_request"]);
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(app_manifest))
|
||||
.expect("failed to run Tauri build script");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "主窗口只开放 Genarrative 桌面宿主壳需要的受控命令。",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"allow-host-bridge-request"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 827 B |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,241 @@
|
||||
use crate::host_bridge::{DesktopShareState, HostBridgeReplayState};
|
||||
use crate::shell::deep_link::{
|
||||
register_desktop_deep_link_events, register_desktop_deep_link_schemes,
|
||||
};
|
||||
use crate::shell::tray::{
|
||||
register_desktop_tray, register_desktop_window_close_events,
|
||||
resolve_desktop_single_instance_action, show_main_window, DesktopSingleInstanceAction,
|
||||
};
|
||||
use crate::shell::webview::{
|
||||
desktop_external_navigation_url, desktop_window_config_with_runtime_platform,
|
||||
emit_current_desktop_lifecycle_event, handle_desktop_webview_download,
|
||||
log_desktop_host_event_result, open_desktop_external_navigation,
|
||||
open_normalized_desktop_external_url, register_desktop_file_drop_events,
|
||||
register_desktop_lifecycle_events, register_desktop_navigation_events,
|
||||
replay_desktop_webview_state, should_allow_desktop_webview_navigation,
|
||||
should_replay_desktop_webview_state_on_page_load,
|
||||
};
|
||||
use crate::shell::window_state::desktop_window_state_plugin;
|
||||
use tauri::webview::NewWindowResponse;
|
||||
|
||||
const DESKTOP_MAIN_WINDOW_LABEL: &str = "main";
|
||||
|
||||
fn desktop_main_window_config_from_windows(
|
||||
windows: &[tauri::utils::config::WindowConfig],
|
||||
) -> tauri::Result<tauri::utils::config::WindowConfig> {
|
||||
windows
|
||||
.iter()
|
||||
.find(|window| window.label == DESKTOP_MAIN_WINDOW_LABEL)
|
||||
.cloned()
|
||||
.map(desktop_window_config_with_runtime_platform)
|
||||
.ok_or(tauri::Error::WindowNotFound)
|
||||
}
|
||||
|
||||
#[cfg(dev)]
|
||||
fn apply_desktop_dev_url_to_main_window_config(
|
||||
mut config: tauri::utils::config::WindowConfig,
|
||||
dev_url: &tauri::Url,
|
||||
) -> tauri::utils::config::WindowConfig {
|
||||
config.url = tauri::WebviewUrl::External(dev_url.clone());
|
||||
desktop_window_config_with_runtime_platform(config)
|
||||
}
|
||||
|
||||
fn desktop_main_window_config(
|
||||
app: &tauri::App,
|
||||
) -> tauri::Result<tauri::utils::config::WindowConfig> {
|
||||
let config = desktop_main_window_config_from_windows(&app.config().app.windows)?;
|
||||
#[cfg(dev)]
|
||||
if let Some(dev_url) = app.config().build.dev_url.as_ref() {
|
||||
return Ok(apply_desktop_dev_url_to_main_window_config(config, dev_url));
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn desktop_new_window_external_url(url: &tauri::Url) -> Option<String> {
|
||||
desktop_external_navigation_url(url)
|
||||
}
|
||||
|
||||
fn desktop_new_window_response<R: tauri::Runtime>() -> NewWindowResponse<R> {
|
||||
NewWindowResponse::Deny
|
||||
}
|
||||
|
||||
pub(crate) fn run() {
|
||||
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 => {
|
||||
log_desktop_host_event_result("single_instance.show", show_main_window(app));
|
||||
}
|
||||
}
|
||||
}))
|
||||
.plugin(desktop_window_state_plugin())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.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");
|
||||
false
|
||||
}
|
||||
};
|
||||
let config = desktop_main_window_config(app)?;
|
||||
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| {
|
||||
if let Some(external_url) = desktop_new_window_external_url(&url) {
|
||||
log_desktop_host_event_result(
|
||||
"webview.new_window.external",
|
||||
open_normalized_desktop_external_url(
|
||||
&new_window_app_handle,
|
||||
external_url,
|
||||
),
|
||||
);
|
||||
}
|
||||
desktop_new_window_response::<tauri::Wry>()
|
||||
})
|
||||
.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| handle_desktop_webview_download(&event))
|
||||
.build()?;
|
||||
register_desktop_window_close_events(&window, tray_registered);
|
||||
register_desktop_lifecycle_events(&window);
|
||||
log_desktop_host_event_result(
|
||||
"app.lifecycle",
|
||||
emit_current_desktop_lifecycle_event(&window),
|
||||
);
|
||||
register_desktop_navigation_events(&window)?;
|
||||
register_desktop_file_drop_events(&window);
|
||||
register_desktop_deep_link_events(app)?;
|
||||
register_desktop_deep_link_schemes(app);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
crate::host_bridge::host_bridge_request
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to run Genarrative desktop shell");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn desktop_main_window_config_uses_labeled_main_window() {
|
||||
let mut secondary_window = tauri::utils::config::WindowConfig::default();
|
||||
secondary_window.label = "secondary".to_string();
|
||||
secondary_window.url = tauri::WebviewUrl::App(PathBuf::from("secondary.html"));
|
||||
|
||||
let mut main_window = tauri::utils::config::WindowConfig::default();
|
||||
main_window.label = "main".to_string();
|
||||
main_window.url = tauri::WebviewUrl::App(PathBuf::from("index.html"));
|
||||
|
||||
let config = desktop_main_window_config_from_windows(&[secondary_window, main_window])
|
||||
.expect("main window config");
|
||||
|
||||
assert_eq!(config.label, "main");
|
||||
match config.url {
|
||||
tauri::WebviewUrl::App(path) => {
|
||||
let path = path.to_string_lossy();
|
||||
assert!(path.starts_with("index.html?"));
|
||||
assert!(path.contains("clientRuntime=native_app"));
|
||||
assert!(path.contains("clientType=native_app"));
|
||||
assert!(path.contains("hostShell=tauri_desktop"));
|
||||
assert!(path.contains("hostPlatform="));
|
||||
assert!(path.contains(&format!("hostVersion={}", env!("CARGO_PKG_VERSION"))));
|
||||
assert!(path.contains("bridgeVersion="));
|
||||
assert!(path.contains("hostCapabilities="));
|
||||
}
|
||||
other => panic!("unexpected main window url {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(dev)]
|
||||
#[test]
|
||||
fn desktop_main_window_config_uses_dev_url_in_dev_builds() {
|
||||
let mut main_window = tauri::utils::config::WindowConfig::default();
|
||||
main_window.label = "main".to_string();
|
||||
main_window.url = tauri::WebviewUrl::App(PathBuf::from("index.html"));
|
||||
|
||||
let mut config = tauri::utils::config::Config::default();
|
||||
config.app.windows = vec![main_window];
|
||||
config.build.dev_url =
|
||||
Some(tauri::Url::parse("http://127.0.0.1:3000/").expect("desktop dev url"));
|
||||
|
||||
let mut resolved = desktop_main_window_config_from_windows(&config.app.windows)
|
||||
.expect("main window config");
|
||||
if let Some(dev_url) = config.build.dev_url.as_ref() {
|
||||
resolved = apply_desktop_dev_url_to_main_window_config(resolved, dev_url);
|
||||
}
|
||||
|
||||
match resolved.url {
|
||||
tauri::WebviewUrl::External(url) => {
|
||||
assert_eq!(url.origin().ascii_serialization(), "http://127.0.0.1:3000");
|
||||
assert_eq!(url.path(), "/");
|
||||
assert_eq!(
|
||||
url.query_pairs()
|
||||
.find(|(key, _)| key == "hostShell")
|
||||
.map(|(_, value)| value.into_owned()),
|
||||
Some("tauri_desktop".to_string())
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected dev main window url {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_main_window_config_rejects_missing_main_window() {
|
||||
let mut secondary_window = tauri::utils::config::WindowConfig::default();
|
||||
secondary_window.label = "secondary".to_string();
|
||||
|
||||
let error = desktop_main_window_config_from_windows(&[secondary_window])
|
||||
.expect_err("missing main window should fail startup");
|
||||
|
||||
assert!(matches!(error, tauri::Error::WindowNotFound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_new_window_policy_denies_embedded_windows() {
|
||||
assert!(matches!(
|
||||
desktop_new_window_response::<tauri::Wry>(),
|
||||
NewWindowResponse::Deny
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_new_window_policy_opens_only_safe_external_urls() {
|
||||
let external_url = tauri::Url::parse("https://example.com/share")
|
||||
.expect("external desktop new window url");
|
||||
assert_eq!(
|
||||
desktop_new_window_external_url(&external_url),
|
||||
Some("https://example.com/share".to_string())
|
||||
);
|
||||
|
||||
let same_origin_url = tauri::Url::parse("https://www.genarrative.world/works/detail")
|
||||
.expect("same-origin desktop new window url");
|
||||
assert_eq!(desktop_new_window_external_url(&same_origin_url), None);
|
||||
|
||||
let unsafe_url =
|
||||
tauri::Url::parse("javascript:alert(1)").expect("unsafe desktop new window url");
|
||||
assert_eq!(desktop_new_window_external_url(&unsafe_url), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse};
|
||||
use crate::shell::webview::color_scheme_from_theme;
|
||||
use serde_json::json;
|
||||
use tauri::Manager;
|
||||
|
||||
pub(crate) fn desktop_appearance_color_scheme(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
match app.get_webview_window("main") {
|
||||
Some(window) => match window.theme() {
|
||||
Ok(theme) => ok_color_scheme(request, color_scheme_from_theme(theme)),
|
||||
Err(_error) => {
|
||||
log_desktop_appearance_failure("theme");
|
||||
appearance_unavailable_response(request)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log_desktop_appearance_failure("window");
|
||||
appearance_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_color_scheme(request: &HostBridgeRequest, color_scheme: &'static str) -> HostBridgeResponse {
|
||||
ok(
|
||||
request.id.clone(),
|
||||
json!({
|
||||
"colorScheme": color_scheme
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn appearance_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
|
||||
failed(request.id.clone(), "host_error", "appearance unavailable")
|
||||
}
|
||||
|
||||
fn log_desktop_appearance_failure(label: &str) -> bool {
|
||||
eprintln!("desktop appearance failed for {label}");
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host_bridge::protocol::request;
|
||||
|
||||
#[test]
|
||||
fn ok_color_scheme_reports_contract_shape() {
|
||||
let response = ok_color_scheme(&request("appearance.getColorScheme"), "dark");
|
||||
|
||||
assert!(response.ok);
|
||||
assert_eq!(
|
||||
response.result.expect("appearance result"),
|
||||
json!({
|
||||
"colorScheme": "dark"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_unavailable_response_is_stable() {
|
||||
let response = appearance_unavailable_response(&request("appearance.getColorScheme"));
|
||||
|
||||
assert!(!response.ok);
|
||||
let error = response.error.expect("appearance error");
|
||||
assert_eq!(error.code, "host_error");
|
||||
assert_eq!(error.message, "appearance unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_failures_are_logged_without_exposing_native_detail() {
|
||||
assert!(!log_desktop_appearance_failure("theme"));
|
||||
assert!(!log_desktop_appearance_failure("window"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_failures_log_stable_label_only() {
|
||||
assert!(!log_desktop_appearance_failure("theme"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse};
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
use tauri::Manager;
|
||||
|
||||
const BADGE_COUNT_MAX: i64 = 99999;
|
||||
|
||||
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) })
|
||||
}
|
||||
|
||||
pub(crate) fn set_desktop_app_badge_count(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
let count = match badge_count_payload(request) {
|
||||
Ok(count) => count,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let Some(window) = app.get_webview_window("main") else {
|
||||
log_desktop_badge_failure("window");
|
||||
return badge_unavailable_response(request);
|
||||
};
|
||||
|
||||
match window.set_badge_count(count) {
|
||||
Ok(()) => ok(request.id.clone(), json!(true)),
|
||||
Err(_error) => {
|
||||
log_desktop_badge_failure("set");
|
||||
badge_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn badge_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
|
||||
failed(request.id.clone(), "host_error", "badge unavailable")
|
||||
}
|
||||
|
||||
fn log_desktop_badge_failure(label: &str) -> bool {
|
||||
eprintln!("desktop app badge failed for {label}");
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host_bridge::protocol::request;
|
||||
use serde_json::json;
|
||||
|
||||
#[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 badge_unavailable_response_is_stable() {
|
||||
let response = badge_unavailable_response(&request("app.setBadgeCount"));
|
||||
|
||||
assert!(!response.ok);
|
||||
let error = response.error.expect("badge error");
|
||||
assert_eq!(error.code, "host_error");
|
||||
assert_eq!(error.message, "badge unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn badge_failures_are_logged_without_exposing_native_detail() {
|
||||
assert!(!log_desktop_badge_failure("set"));
|
||||
assert!(!log_desktop_badge_failure("window"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn badge_failures_log_stable_label_only() {
|
||||
assert!(!log_desktop_badge_failure("set"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
pub(crate) fn capabilities() -> Vec<&'static str> {
|
||||
vec![
|
||||
"host.getRuntime",
|
||||
"appearance.getColorScheme",
|
||||
"host.events",
|
||||
"app.lifecycle",
|
||||
"share.open",
|
||||
"share.setTarget",
|
||||
"navigation.openNativePage",
|
||||
"navigation.canGoBack",
|
||||
"app.reloadWebView",
|
||||
"app.openExternalUrl",
|
||||
"app.setTitle",
|
||||
"app.setBadgeCount",
|
||||
"network.status",
|
||||
"clipboard.writeText",
|
||||
"clipboard.readText",
|
||||
"file.exportText",
|
||||
"file.importText",
|
||||
"file.importDocument",
|
||||
"file.exportImage",
|
||||
"file.importImage",
|
||||
"file.importAudio",
|
||||
"file.exportAudio",
|
||||
"file.imageDropped",
|
||||
"notification.showLocal",
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn runtime_capability_list_stays_ordered() {
|
||||
assert_eq!(
|
||||
capabilities(),
|
||||
vec![
|
||||
"host.getRuntime",
|
||||
"appearance.getColorScheme",
|
||||
"host.events",
|
||||
"app.lifecycle",
|
||||
"share.open",
|
||||
"share.setTarget",
|
||||
"navigation.openNativePage",
|
||||
"navigation.canGoBack",
|
||||
"app.reloadWebView",
|
||||
"app.openExternalUrl",
|
||||
"app.setTitle",
|
||||
"app.setBadgeCount",
|
||||
"network.status",
|
||||
"clipboard.writeText",
|
||||
"clipboard.readText",
|
||||
"file.exportText",
|
||||
"file.importText",
|
||||
"file.importDocument",
|
||||
"file.exportImage",
|
||||
"file.importImage",
|
||||
"file.importAudio",
|
||||
"file.exportAudio",
|
||||
"file.imageDropped",
|
||||
"notification.showLocal",
|
||||
]
|
||||
);
|
||||
assert!(Value::from(capabilities()).as_array().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_capability_list_keeps_desktop_boundaries() {
|
||||
let desktop_capabilities = capabilities();
|
||||
let unique_capabilities = desktop_capabilities.iter().copied().collect::<HashSet<_>>();
|
||||
|
||||
assert_eq!(unique_capabilities.len(), desktop_capabilities.len());
|
||||
|
||||
for capability in [
|
||||
"host.getRuntime",
|
||||
"appearance.getColorScheme",
|
||||
"host.events",
|
||||
"app.lifecycle",
|
||||
"share.open",
|
||||
"share.setTarget",
|
||||
"navigation.openNativePage",
|
||||
"navigation.canGoBack",
|
||||
"app.reloadWebView",
|
||||
"app.openExternalUrl",
|
||||
"app.setTitle",
|
||||
"app.setBadgeCount",
|
||||
"network.status",
|
||||
"clipboard.writeText",
|
||||
"clipboard.readText",
|
||||
"file.exportText",
|
||||
"file.importText",
|
||||
"file.importDocument",
|
||||
"file.exportImage",
|
||||
"file.importImage",
|
||||
"file.importAudio",
|
||||
"file.exportAudio",
|
||||
"file.imageDropped",
|
||||
"notification.showLocal",
|
||||
] {
|
||||
assert!(desktop_capabilities.contains(&capability));
|
||||
}
|
||||
|
||||
for capability in [
|
||||
"auth.requestLogin",
|
||||
"payment.request",
|
||||
"file.captureImage",
|
||||
"scanner.scanQrCode",
|
||||
"haptics.impact",
|
||||
] {
|
||||
assert!(!desktop_capabilities.contains(&capability));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
|
||||
use crate::host_bridge::protocol::{
|
||||
failed, ok, required_string_payload, HostBridgeRequest, HostBridgeResponse,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub(crate) const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000;
|
||||
|
||||
pub(crate) fn normalize_clipboard_text(text: &str) -> String {
|
||||
text.chars().take(CLIPBOARD_TEXT_MAX_LENGTH).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn write_desktop_clipboard_text(
|
||||
app: &tauri::AppHandle,
|
||||
text: &str,
|
||||
) -> Result<(), String> {
|
||||
app.clipboard()
|
||||
.write_text(normalize_clipboard_text(text))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn read_desktop_clipboard_text(app: &tauri::AppHandle) -> Result<String, String> {
|
||||
app.clipboard()
|
||||
.read_text()
|
||||
.map(|text| normalize_clipboard_text(&text))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn log_desktop_clipboard_failure(label: &str) -> bool {
|
||||
eprintln!("desktop clipboard failed for {label}");
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn clipboard_write_unavailable_response(
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
failed(
|
||||
request.id.clone(),
|
||||
"host_error",
|
||||
"clipboard write unavailable",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn clipboard_read_unavailable_response(
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
failed(
|
||||
request.id.clone(),
|
||||
"host_error",
|
||||
"clipboard read unavailable",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn write_desktop_host_bridge_clipboard_text(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
let text = match required_string_payload(request, "text") {
|
||||
Ok(text) => text,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
match write_desktop_clipboard_text(app, text) {
|
||||
Ok(()) => ok(request.id.clone(), json!(true)),
|
||||
Err(_error) => {
|
||||
log_desktop_clipboard_failure("write.text");
|
||||
clipboard_write_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_desktop_host_bridge_clipboard_text(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
match read_desktop_clipboard_text(app) {
|
||||
Ok(text) => ok(
|
||||
request.id.clone(),
|
||||
json!({
|
||||
"text": text,
|
||||
}),
|
||||
),
|
||||
Err(_error) => {
|
||||
log_desktop_clipboard_failure("read.text");
|
||||
clipboard_read_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clipboard_text_is_truncated_to_contract_limit() {
|
||||
assert_eq!(normalize_clipboard_text("作品号 PZ-1"), "作品号 PZ-1");
|
||||
assert_eq!(
|
||||
normalize_clipboard_text(&"a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(),
|
||||
CLIPBOARD_TEXT_MAX_LENGTH
|
||||
);
|
||||
|
||||
let unicode_text = "猫".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10);
|
||||
let truncated = normalize_clipboard_text(&unicode_text);
|
||||
assert_eq!(truncated.chars().count(), CLIPBOARD_TEXT_MAX_LENGTH);
|
||||
assert!(truncated.ends_with('猫'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_read_response_reports_contract_shape() {
|
||||
let response = ok(
|
||||
"clipboard-read".to_string(),
|
||||
json!({
|
||||
"text": normalize_clipboard_text("邀请码 GEN-1")
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(response.ok);
|
||||
assert_eq!(
|
||||
response.result.expect("clipboard result"),
|
||||
json!({
|
||||
"text": "邀请码 GEN-1"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_unavailable_responses_are_stable() {
|
||||
let write_response = clipboard_write_unavailable_response(&HostBridgeRequest {
|
||||
bridge: "GenarrativeHostBridge".to_string(),
|
||||
version: 1,
|
||||
id: "clipboard-write".to_string(),
|
||||
method: "clipboard.writeText".to_string(),
|
||||
payload: None,
|
||||
});
|
||||
let read_response = clipboard_read_unavailable_response(&HostBridgeRequest {
|
||||
bridge: "GenarrativeHostBridge".to_string(),
|
||||
version: 1,
|
||||
id: "clipboard-read".to_string(),
|
||||
method: "clipboard.readText".to_string(),
|
||||
payload: None,
|
||||
});
|
||||
|
||||
assert!(!write_response.ok);
|
||||
let write_error = write_response.error.expect("clipboard write error");
|
||||
assert_eq!(write_error.code, "host_error");
|
||||
assert_eq!(write_error.message, "clipboard write unavailable");
|
||||
|
||||
assert!(!read_response.ok);
|
||||
let read_error = read_response.error.expect("clipboard read error");
|
||||
assert_eq!(read_error.code, "host_error");
|
||||
assert_eq!(read_error.message, "clipboard read unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_failures_are_logged_without_exposing_native_detail() {
|
||||
assert!(!log_desktop_clipboard_failure("write.text"));
|
||||
assert!(!log_desktop_clipboard_failure("read.text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_failures_log_stable_label_only() {
|
||||
assert!(!log_desktop_clipboard_failure("write.text"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use crate::host_bridge::appearance::desktop_appearance_color_scheme;
|
||||
use crate::host_bridge::badge::set_desktop_app_badge_count;
|
||||
use crate::host_bridge::clipboard::{
|
||||
read_desktop_host_bridge_clipboard_text, write_desktop_host_bridge_clipboard_text,
|
||||
};
|
||||
use crate::host_bridge::files::{
|
||||
export_desktop_host_bridge_audio_file, export_desktop_host_bridge_image_file,
|
||||
export_desktop_host_bridge_text_file, import_desktop_host_bridge_audio_file,
|
||||
import_desktop_host_bridge_document_file, import_desktop_host_bridge_image_file,
|
||||
import_desktop_host_bridge_text_file,
|
||||
};
|
||||
use crate::host_bridge::navigation::{
|
||||
open_desktop_host_bridge_external_url, open_desktop_host_bridge_native_page,
|
||||
reload_desktop_host_bridge_webview,
|
||||
};
|
||||
use crate::host_bridge::network::resolve_desktop_host_bridge_network_status;
|
||||
use crate::host_bridge::notifications::show_desktop_local_notification;
|
||||
use crate::host_bridge::protocol::{
|
||||
failed, validate_request, HostBridgeRequest, HostBridgeResponse,
|
||||
};
|
||||
use crate::host_bridge::runtime::desktop_host_bridge_runtime_response;
|
||||
use crate::host_bridge::share::{
|
||||
open_desktop_host_bridge_share, set_desktop_host_bridge_share_target,
|
||||
};
|
||||
use crate::host_bridge::title::set_desktop_host_bridge_window_title;
|
||||
|
||||
pub(crate) fn resolve_host_bridge_request(request: HostBridgeRequest) -> HostBridgeResponse {
|
||||
if let Some(response) = validate_request(&request) {
|
||||
return response;
|
||||
}
|
||||
|
||||
match request.method.as_str() {
|
||||
"host.getRuntime" => desktop_host_bridge_runtime_response(&request),
|
||||
_ => failed(
|
||||
request.id,
|
||||
"unsupported_method",
|
||||
format!("{} unsupported in desktop shell", request.method),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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" => open_desktop_host_bridge_external_url(&app, &request),
|
||||
"appearance.getColorScheme" => desktop_appearance_color_scheme(&app, &request),
|
||||
"navigation.openNativePage" => open_desktop_host_bridge_native_page(&app, &request),
|
||||
"app.reloadWebView" => reload_desktop_host_bridge_webview(&app, &request),
|
||||
"clipboard.writeText" => write_desktop_host_bridge_clipboard_text(&app, &request),
|
||||
"clipboard.readText" => read_desktop_host_bridge_clipboard_text(&app, &request),
|
||||
"file.exportText" => export_desktop_host_bridge_text_file(&app, &request).await,
|
||||
"file.importText" => import_desktop_host_bridge_text_file(&app, &request).await,
|
||||
"file.importDocument" => import_desktop_host_bridge_document_file(&app, &request).await,
|
||||
"file.exportImage" => export_desktop_host_bridge_image_file(&app, &request).await,
|
||||
"file.importImage" => import_desktop_host_bridge_image_file(&app, &request).await,
|
||||
"file.importAudio" => import_desktop_host_bridge_audio_file(&app, &request).await,
|
||||
"file.exportAudio" => export_desktop_host_bridge_audio_file(&app, &request).await,
|
||||
"app.setTitle" => set_desktop_host_bridge_window_title(&app, &request),
|
||||
"app.setBadgeCount" => set_desktop_app_badge_count(&app, &request),
|
||||
"network.status" => resolve_desktop_host_bridge_network_status(&request).await,
|
||||
"notification.showLocal" => show_desktop_local_notification(&app, &request),
|
||||
"share.setTarget" => set_desktop_host_bridge_share_target(&app, &request),
|
||||
"share.open" => open_desktop_host_bridge_share(&app, &request),
|
||||
_ => resolve_host_bridge_request(request),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host_bridge::capabilities::capabilities;
|
||||
use crate::host_bridge::protocol::HOST_BRIDGE_METHODS;
|
||||
use crate::host_bridge::protocol::request;
|
||||
|
||||
#[test]
|
||||
fn unsupported_method_is_explicit() {
|
||||
let desktop_capabilities = capabilities();
|
||||
let unsupported_methods = HOST_BRIDGE_METHODS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|method| !desktop_capabilities.contains(method))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
unsupported_methods,
|
||||
vec![
|
||||
"auth.requestLogin",
|
||||
"payment.request",
|
||||
"file.captureImage",
|
||||
"scanner.scanQrCode",
|
||||
"haptics.impact",
|
||||
],
|
||||
);
|
||||
|
||||
for method in unsupported_methods {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,405 @@
|
||||
use crate::host_bridge::file_payloads::{
|
||||
export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload,
|
||||
import_document_file_payload, import_image_file_payload, import_text_file_payload,
|
||||
write_export_bytes_file, write_export_text_file, ImportFilePayloadError,
|
||||
};
|
||||
use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse};
|
||||
use serde_json::json;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
fn file_export_cancelled_response(request: &HostBridgeRequest) -> HostBridgeResponse {
|
||||
failed(request.id.clone(), "cancelled", "file export cancelled")
|
||||
}
|
||||
|
||||
fn file_import_cancelled_response(request: &HostBridgeRequest) -> HostBridgeResponse {
|
||||
failed(request.id.clone(), "cancelled", "file import cancelled")
|
||||
}
|
||||
|
||||
fn file_export_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
|
||||
failed(request.id.clone(), "host_error", "file export unavailable")
|
||||
}
|
||||
|
||||
fn file_import_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
|
||||
failed(request.id.clone(), "host_error", "file import unavailable")
|
||||
}
|
||||
|
||||
fn log_desktop_file_export_failure(label: &str) -> bool {
|
||||
eprintln!("desktop file export failed for {label}");
|
||||
false
|
||||
}
|
||||
|
||||
fn log_desktop_file_import_failure(label: &str) -> bool {
|
||||
eprintln!("desktop file import failed for {label}");
|
||||
false
|
||||
}
|
||||
|
||||
fn file_import_payload_error_response(
|
||||
request: &HostBridgeRequest,
|
||||
label: &str,
|
||||
error: ImportFilePayloadError,
|
||||
) -> HostBridgeResponse {
|
||||
match error.invalid_message() {
|
||||
Some(message) => failed(request.id.clone(), "invalid_request", message),
|
||||
None => {
|
||||
log_desktop_file_import_failure(label);
|
||||
file_import_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn export_desktop_host_bridge_text_file(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
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 file_export_cancelled_response(request);
|
||||
};
|
||||
let path = match file_path.into_path() {
|
||||
Ok(path) => path,
|
||||
Err(_error) => {
|
||||
log_desktop_file_export_failure("path.convert");
|
||||
return file_export_unavailable_response(request);
|
||||
}
|
||||
};
|
||||
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)) => {
|
||||
log_desktop_file_export_failure("write.text");
|
||||
return file_export_unavailable_response(request);
|
||||
}
|
||||
Err(_error) => {
|
||||
log_desktop_file_export_failure("write.text.join");
|
||||
return file_export_unavailable_response(request);
|
||||
}
|
||||
};
|
||||
|
||||
ok(
|
||||
request.id.clone(),
|
||||
json!({
|
||||
"action": "saved",
|
||||
"fileName": file_name,
|
||||
"bytes": bytes,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn import_desktop_host_bridge_text_file(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
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 file_import_cancelled_response(request);
|
||||
};
|
||||
let path = match file_path.into_path() {
|
||||
Ok(path) => path,
|
||||
Err(_error) => {
|
||||
log_desktop_file_import_failure("path.convert");
|
||||
return file_import_unavailable_response(request);
|
||||
}
|
||||
};
|
||||
let import_result =
|
||||
tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await;
|
||||
match import_result {
|
||||
Ok(Ok(payload)) => ok(request.id.clone(), payload),
|
||||
Ok(Err(error)) => file_import_payload_error_response(request, "read.text", error),
|
||||
Err(_error) => {
|
||||
log_desktop_file_import_failure("read.text.join");
|
||||
file_import_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn import_desktop_host_bridge_document_file(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
let file_path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter(
|
||||
"Document",
|
||||
&["txt", "md", "markdown", "csv", "json", "docx"],
|
||||
)
|
||||
.blocking_pick_file();
|
||||
let Some(file_path) = file_path else {
|
||||
return file_import_cancelled_response(request);
|
||||
};
|
||||
let path = match file_path.into_path() {
|
||||
Ok(path) => path,
|
||||
Err(_error) => {
|
||||
log_desktop_file_import_failure("path.convert");
|
||||
return file_import_unavailable_response(request);
|
||||
}
|
||||
};
|
||||
let import_result =
|
||||
tauri::async_runtime::spawn_blocking(move || import_document_file_payload(path)).await;
|
||||
match import_result {
|
||||
Ok(Ok(payload)) => ok(request.id.clone(), payload),
|
||||
Ok(Err(error)) => file_import_payload_error_response(request, "read.document", error),
|
||||
Err(_error) => {
|
||||
log_desktop_file_import_failure("read.document.join");
|
||||
file_import_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn export_desktop_host_bridge_image_file(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
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 file_export_cancelled_response(request);
|
||||
};
|
||||
let path = match file_path.into_path() {
|
||||
Ok(path) => path,
|
||||
Err(_error) => {
|
||||
log_desktop_file_export_failure("path.convert");
|
||||
return file_export_unavailable_response(request);
|
||||
}
|
||||
};
|
||||
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)) => {
|
||||
log_desktop_file_export_failure("write.image");
|
||||
return file_export_unavailable_response(request);
|
||||
}
|
||||
Err(_error) => {
|
||||
log_desktop_file_export_failure("write.image.join");
|
||||
return file_export_unavailable_response(request);
|
||||
}
|
||||
};
|
||||
ok(
|
||||
request.id.clone(),
|
||||
json!({
|
||||
"action": "saved",
|
||||
"fileName": file_name,
|
||||
"bytes": byte_count,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn import_desktop_host_bridge_image_file(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
let file_path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("Image", &["png", "jpg", "jpeg", "webp"])
|
||||
.blocking_pick_file();
|
||||
let Some(file_path) = file_path else {
|
||||
return file_import_cancelled_response(request);
|
||||
};
|
||||
let path = match file_path.into_path() {
|
||||
Ok(path) => path,
|
||||
Err(_error) => {
|
||||
log_desktop_file_import_failure("path.convert");
|
||||
return file_import_unavailable_response(request);
|
||||
}
|
||||
};
|
||||
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.clone(), payload),
|
||||
Ok(Err(error)) => file_import_payload_error_response(request, "read.image", error),
|
||||
Err(_error) => {
|
||||
log_desktop_file_import_failure("read.image.join");
|
||||
file_import_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn import_desktop_host_bridge_audio_file(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
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 file_import_cancelled_response(request);
|
||||
};
|
||||
let path = match file_path.into_path() {
|
||||
Ok(path) => path,
|
||||
Err(_error) => {
|
||||
log_desktop_file_import_failure("path.convert");
|
||||
return file_import_unavailable_response(request);
|
||||
}
|
||||
};
|
||||
let import_result =
|
||||
tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await;
|
||||
match import_result {
|
||||
Ok(Ok(payload)) => ok(request.id.clone(), payload),
|
||||
Ok(Err(error)) => file_import_payload_error_response(request, "read.audio", error),
|
||||
Err(_error) => {
|
||||
log_desktop_file_import_failure("read.audio.join");
|
||||
file_import_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn export_desktop_host_bridge_audio_file(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
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 file_export_cancelled_response(request);
|
||||
};
|
||||
let path = match file_path.into_path() {
|
||||
Ok(path) => path,
|
||||
Err(_error) => {
|
||||
log_desktop_file_export_failure("path.convert");
|
||||
return file_export_unavailable_response(request);
|
||||
}
|
||||
};
|
||||
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)) => {
|
||||
log_desktop_file_export_failure("write.audio");
|
||||
return file_export_unavailable_response(request);
|
||||
}
|
||||
Err(_error) => {
|
||||
log_desktop_file_export_failure("write.audio.join");
|
||||
return file_export_unavailable_response(request);
|
||||
}
|
||||
};
|
||||
ok(
|
||||
request.id.clone(),
|
||||
json!({
|
||||
"action": "saved",
|
||||
"fileName": file_name,
|
||||
"bytes": byte_count,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host_bridge::protocol::request;
|
||||
|
||||
#[test]
|
||||
fn desktop_file_cancelled_responses_are_stable() {
|
||||
let request = request("file.importText");
|
||||
|
||||
let export_error = file_export_cancelled_response(&request)
|
||||
.error
|
||||
.expect("export error");
|
||||
assert_eq!(export_error.code, "cancelled");
|
||||
assert_eq!(export_error.message, "file export cancelled");
|
||||
|
||||
let import_error = file_import_cancelled_response(&request)
|
||||
.error
|
||||
.expect("import error");
|
||||
assert_eq!(import_error.code, "cancelled");
|
||||
assert_eq!(import_error.message, "file import cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_file_unavailable_responses_are_stable() {
|
||||
let request = request("file.importText");
|
||||
|
||||
let export_error = file_export_unavailable_response(&request)
|
||||
.error
|
||||
.expect("export error");
|
||||
assert_eq!(export_error.code, "host_error");
|
||||
assert_eq!(export_error.message, "file export unavailable");
|
||||
|
||||
let import_error = file_import_unavailable_response(&request)
|
||||
.error
|
||||
.expect("import error");
|
||||
assert_eq!(import_error.code, "host_error");
|
||||
assert_eq!(import_error.message, "file import unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_file_failures_are_logged_without_exposing_native_detail() {
|
||||
assert!(!log_desktop_file_export_failure("path.convert"));
|
||||
assert!(!log_desktop_file_export_failure("write.text"));
|
||||
assert!(!log_desktop_file_import_failure("path.convert"));
|
||||
assert!(!log_desktop_file_import_failure("read.text.join"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_file_failures_log_stable_label_only() {
|
||||
assert!(!log_desktop_file_export_failure("write.image.join"));
|
||||
assert!(!log_desktop_file_import_failure("read.audio.join"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_file_import_validation_errors_keep_stable_invalid_request() {
|
||||
let request = request("file.importText");
|
||||
|
||||
let error = file_import_payload_error_response(
|
||||
&request,
|
||||
"read.text",
|
||||
ImportFilePayloadError::InvalidRequest("text MIME must be allowed"),
|
||||
)
|
||||
.error
|
||||
.expect("error");
|
||||
|
||||
assert_eq!(error.code, "invalid_request");
|
||||
assert_eq!(error.message, "text MIME must be allowed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_file_import_native_read_errors_use_stable_host_error() {
|
||||
let request = request("file.importText");
|
||||
|
||||
let error = file_import_payload_error_response(
|
||||
&request,
|
||||
"read.text",
|
||||
ImportFilePayloadError::NativeRead,
|
||||
)
|
||||
.error
|
||||
.expect("error");
|
||||
|
||||
assert_eq!(error.code, "host_error");
|
||||
assert_eq!(error.message, "file import unavailable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
mod appearance;
|
||||
pub(crate) mod capabilities;
|
||||
mod badge;
|
||||
mod clipboard;
|
||||
mod dispatch;
|
||||
pub(crate) mod file_payloads;
|
||||
pub(crate) mod files;
|
||||
mod navigation;
|
||||
mod network;
|
||||
mod notifications;
|
||||
pub(crate) mod protocol;
|
||||
mod runtime;
|
||||
mod share;
|
||||
mod title;
|
||||
|
||||
pub(crate) use protocol::HostBridgeReplayState;
|
||||
pub(crate) use share::DesktopShareState;
|
||||
|
||||
use crate::host_bridge::dispatch::execute_host_bridge_request;
|
||||
use crate::host_bridge::protocol::{
|
||||
normalize_request_id, validate_request, HostBridgeReplayReservation, HostBridgeRequest,
|
||||
HostBridgeResponse,
|
||||
};
|
||||
|
||||
fn prepare_host_bridge_request(
|
||||
request: &mut HostBridgeRequest,
|
||||
) -> Option<HostBridgeResponse> {
|
||||
if let Some(response) = validate_request(request) {
|
||||
return Some(response);
|
||||
}
|
||||
request.id = normalize_request_id(&request.id).unwrap_or_else(|| request.id.clone());
|
||||
None
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn host_bridge_request(
|
||||
app: tauri::AppHandle,
|
||||
replay_state: tauri::State<'_, HostBridgeReplayState>,
|
||||
mut request: HostBridgeRequest,
|
||||
) -> Result<HostBridgeResponse, String> {
|
||||
if let Some(response) = prepare_host_bridge_request(&mut request) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let response = match replay_state.reserve(&request.id) {
|
||||
Err(response) => response,
|
||||
Ok(HostBridgeReplayReservation::Wait(slot)) => {
|
||||
HostBridgeReplayState::wait_for_response(slot)
|
||||
}
|
||||
Ok(HostBridgeReplayReservation::Execute(slot)) => {
|
||||
let response = execute_host_bridge_request(app, request).await;
|
||||
replay_state.complete(slot, response)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host_bridge::protocol::request;
|
||||
|
||||
#[test]
|
||||
fn host_bridge_request_rejects_invalid_requests_before_replay() {
|
||||
let replay_state = HostBridgeReplayState::default();
|
||||
let mut invalid = request("share.open");
|
||||
invalid.id = " request-1 ".to_string();
|
||||
invalid.bridge = "OtherBridge".to_string();
|
||||
|
||||
let response =
|
||||
prepare_host_bridge_request(&mut invalid).expect("invalid envelope");
|
||||
|
||||
assert!(!response.ok);
|
||||
assert_eq!(response.id, "request-1");
|
||||
assert_eq!(response.error.expect("error").code, "invalid_request");
|
||||
|
||||
match replay_state.reserve("request-1").expect("replay reservation") {
|
||||
HostBridgeReplayReservation::Execute(_) => {}
|
||||
HostBridgeReplayReservation::Wait(_) => {
|
||||
panic!("invalid request must not reserve replay slot")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use crate::host_bridge::protocol::{
|
||||
failed, ok, required_string_payload, HostBridgeRequest, HostBridgeResponse,
|
||||
};
|
||||
use crate::shell::webview::{
|
||||
normalize_external_url, normalize_native_page_url, open_normalized_desktop_external_url,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tauri::Manager;
|
||||
|
||||
fn desktop_external_url_from_request(
|
||||
request: &HostBridgeRequest,
|
||||
) -> Result<String, HostBridgeResponse> {
|
||||
required_string_payload(request, "url")
|
||||
.ok()
|
||||
.and_then(normalize_external_url)
|
||||
.ok_or_else(|| {
|
||||
failed(
|
||||
request.id.clone(),
|
||||
"invalid_request",
|
||||
"url must use an allowed external protocol",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn desktop_native_page_url_from_request(
|
||||
request: &HostBridgeRequest,
|
||||
) -> Result<tauri::Url, HostBridgeResponse> {
|
||||
required_string_payload(request, "url")
|
||||
.ok()
|
||||
.and_then(normalize_native_page_url)
|
||||
.ok_or_else(|| {
|
||||
failed(
|
||||
request.id.clone(),
|
||||
"invalid_request",
|
||||
"url must use an allowed same-origin H5 route",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn open_desktop_host_bridge_external_url(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
let url = match desktop_external_url_from_request(request) {
|
||||
Ok(url) => url,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
match open_normalized_desktop_external_url(app, url) {
|
||||
Ok(()) => ok(request.id.clone(), json!(true)),
|
||||
Err(_error) => {
|
||||
log_desktop_navigation_failure("external.open");
|
||||
external_url_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn external_url_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
|
||||
failed(
|
||||
request.id.clone(),
|
||||
"host_error",
|
||||
"external URL cannot be opened",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn open_desktop_host_bridge_native_page(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
let url = match desktop_native_page_url_from_request(request) {
|
||||
Ok(url) => url,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
match app.get_webview_window("main") {
|
||||
Some(window) => match window.navigate(url) {
|
||||
Ok(()) => ok(request.id.clone(), json!(true)),
|
||||
Err(_error) => {
|
||||
log_desktop_navigation_failure("native.navigate");
|
||||
native_page_unavailable_response(request)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log_desktop_navigation_failure("native.window");
|
||||
native_page_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn native_page_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
|
||||
failed(request.id.clone(), "host_error", "native page unavailable")
|
||||
}
|
||||
|
||||
fn log_desktop_navigation_failure(label: &str) -> bool {
|
||||
eprintln!("desktop navigation failed for {label}");
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host_bridge::protocol::request;
|
||||
use serde_json::json;
|
||||
|
||||
fn request_with_url(method: &str, url: &str) -> HostBridgeRequest {
|
||||
let mut request = request(method);
|
||||
request.payload = Some(json!({ "url": url }));
|
||||
request
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_external_url_request_accepts_only_safe_system_protocols() {
|
||||
let request = request_with_url("app.openExternalUrl", " https://example.com/share ");
|
||||
|
||||
assert_eq!(
|
||||
desktop_external_url_from_request(&request).expect("external url"),
|
||||
"https://example.com/share"
|
||||
);
|
||||
|
||||
for url in [
|
||||
"",
|
||||
"/works/detail?work=PZ-1",
|
||||
"javascript:alert(1)",
|
||||
"file:///etc/passwd",
|
||||
"https://example.com/\nnext",
|
||||
] {
|
||||
let response = desktop_external_url_from_request(&request_with_url(
|
||||
"app.openExternalUrl",
|
||||
url,
|
||||
))
|
||||
.expect_err("invalid external url");
|
||||
|
||||
let error = response.error.expect("error");
|
||||
assert_eq!(error.code, "invalid_request");
|
||||
assert_eq!(
|
||||
error.message,
|
||||
"url must use an allowed external protocol"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_url_unavailable_response_is_stable() {
|
||||
let response = external_url_unavailable_response(&request("app.openExternalUrl"));
|
||||
|
||||
assert!(!response.ok);
|
||||
let error = response.error.expect("external url error");
|
||||
assert_eq!(error.code, "host_error");
|
||||
assert_eq!(error.message, "external URL cannot be opened");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_native_page_request_accepts_only_same_origin_h5_routes() {
|
||||
let route = desktop_native_page_url_from_request(&request_with_url(
|
||||
"navigation.openNativePage",
|
||||
"/works/detail?work=PZ-1",
|
||||
))
|
||||
.expect("native page url");
|
||||
|
||||
assert_eq!(
|
||||
route.origin().ascii_serialization(),
|
||||
"https://www.genarrative.world"
|
||||
);
|
||||
assert_eq!(route.path(), "/works/detail");
|
||||
assert!(route
|
||||
.query_pairs()
|
||||
.any(|(key, value)| key == "hostShell" && value == "tauri_desktop"));
|
||||
|
||||
for url in [
|
||||
"https://example.com/works",
|
||||
"//example.com/works",
|
||||
"javascript:alert(1)",
|
||||
"https://www.genarrative.world/\nnext",
|
||||
] {
|
||||
let response = desktop_native_page_url_from_request(&request_with_url(
|
||||
"navigation.openNativePage",
|
||||
url,
|
||||
))
|
||||
.expect_err("invalid native page url");
|
||||
|
||||
let error = response.error.expect("error");
|
||||
assert_eq!(error.code, "invalid_request");
|
||||
assert_eq!(
|
||||
error.message,
|
||||
"url must use an allowed same-origin H5 route"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_page_unavailable_response_is_stable() {
|
||||
let response = native_page_unavailable_response(&request("navigation.openNativePage"));
|
||||
|
||||
assert!(!response.ok);
|
||||
let error = response.error.expect("native page error");
|
||||
assert_eq!(error.code, "host_error");
|
||||
assert_eq!(error.message, "native page unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_failures_are_logged_without_exposing_native_detail() {
|
||||
assert!(!log_desktop_navigation_failure("external.open"));
|
||||
assert!(!log_desktop_navigation_failure("native.navigate"));
|
||||
assert!(!log_desktop_navigation_failure("native.window"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_failures_log_stable_label_only() {
|
||||
assert!(!log_desktop_navigation_failure("external.open"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reload_desktop_host_bridge_webview(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
match app.get_webview_window("main") {
|
||||
Some(window) => match window.reload() {
|
||||
Ok(()) => ok(request.id.clone(), json!(true)),
|
||||
Err(_error) => {
|
||||
log_desktop_navigation_failure("webview.reload");
|
||||
webview_reload_unavailable_response(request)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log_desktop_navigation_failure("webview.window");
|
||||
webview_reload_unavailable_response(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn webview_reload_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
|
||||
failed(
|
||||
request.id.clone(),
|
||||
"host_error",
|
||||
"webview reload unavailable",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reload_tests {
|
||||
use super::*;
|
||||
use crate::host_bridge::protocol::request;
|
||||
|
||||
#[test]
|
||||
fn webview_reload_unavailable_response_is_stable() {
|
||||
let response = webview_reload_unavailable_response(&request("app.reloadWebView"));
|
||||
|
||||
assert!(!response.ok);
|
||||
let error = response.error.expect("webview reload error");
|
||||
assert_eq!(error.code, "host_error");
|
||||
assert_eq!(error.message, "webview reload unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webview_reload_failures_are_logged_without_exposing_native_detail() {
|
||||
assert!(!log_desktop_navigation_failure("webview.reload"));
|
||||
assert!(!log_desktop_navigation_failure("webview.window"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webview_reload_failures_log_stable_label_only() {
|
||||
assert!(!log_desktop_navigation_failure("webview.reload"));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user