From d16722fdf42a2fe9c0b7d763c28981610a133018 Mon Sep 17 00:00:00 2001 From: ZASENJC <1585463018@qq.com> Date: Thu, 15 Oct 2026 20:29:44 +0800 Subject: [PATCH] feat: add native Windows app and OCR, remove legacy Tauri frontend --- .gitignore | 8 - Cargo.lock | 69 +- Cargo.toml | 10 + {src-tauri => assets}/icons/128x128.png | Bin {src-tauri => assets}/icons/128x128@2x.png | Bin {src-tauri => assets}/icons/32x32.png | Bin .../icons/Square107x107Logo.png | Bin .../icons/Square142x142Logo.png | Bin .../icons/Square150x150Logo.png | Bin .../icons/Square284x284Logo.png | Bin .../icons/Square30x30Logo.png | Bin .../icons/Square310x310Logo.png | Bin .../icons/Square44x44Logo.png | Bin .../icons/Square71x71Logo.png | Bin .../icons/Square89x89Logo.png | Bin {src-tauri => assets}/icons/StoreLogo.png | Bin {src-tauri => assets}/icons/icon.icns | Bin {src-tauri => assets}/icons/icon.ico | Bin {src-tauri => assets}/icons/icon.png | Bin crates/ttree-ai/Cargo.toml | 3 - crates/ttree-ai/src/gguf.rs | 34 +- crates/ttree-ai/src/hy_mt_manager.rs | 87 +- crates/ttree-app/src/main.rs | 18 +- crates/ttree-app/src/windows_app.rs | 2650 ++++++++ crates/ttree-ocr/Cargo.toml | 3 + crates/ttree-ocr/src/lib.rs | 41 +- crates/ttree-ocr/src/postprocess.rs | 173 + crates/ttree-ocr/src/preprocess.rs | 395 ++ crates/ttree-ocr/src/windows_capture.rs | 661 ++ crates/ttree-ocr/src/windows_ocr.rs | 339 + crates/ttree-platform-macos/src/selection.rs | 248 +- index.html | 12 - package-lock.json | 2845 --------- package.json | 36 - scripts/package-pure-rust-macos.sh | 4 +- src-tauri/.gitignore | 7 - src-tauri/Cargo.lock | 5601 ----------------- src-tauri/Cargo.toml | 74 - src-tauri/Info.plist | 8 - src-tauri/build.rs | 3 - src-tauri/capabilities/default.json | 26 - src-tauri/src/commands.rs | 216 - src-tauri/src/config.rs | 1130 ---- src-tauri/src/history.rs | 458 -- src-tauri/src/keychain.rs | 154 - src-tauri/src/lib.rs | 107 - src-tauri/src/main.rs | 6 - src-tauri/src/ocr/image.rs | 111 - src-tauri/src/ocr/mod.rs | 21 - src-tauri/src/ocr/tests.rs | 75 - src-tauri/src/ocr/tests/diagnose-vision.swift | 71 - .../src/ocr/tests/generate-fixtures.swift | 45 - src-tauri/src/ocr/vision.rs | 263 - src-tauri/src/screenshot.rs | 121 - src-tauri/src/selection/macos.rs | 212 - src-tauri/src/selection/mod.rs | 42 - src-tauri/src/shortcut.rs | 682 -- src-tauri/src/translate/auto.rs | 290 - src-tauri/src/translate/bing.rs | 211 - src-tauri/src/translate/google.rs | 85 - src-tauri/src/translate/mod.rs | 325 - src-tauri/src/translate/openai.rs | 422 -- src-tauri/src/tray.rs | 48 - src-tauri/src/window.rs | 114 - src-tauri/tauri.conf.json | 61 - src/App.svelte | 82 - src/lib/api/tauri.test.ts | 37 - src/lib/api/tauri.ts | 223 - src/lib/components/EngineTabs.svelte | 92 - src/lib/components/LangSelector.svelte | 71 - src/lib/components/ResultView.svelte | 183 - src/lib/components/TranslatePanel.svelte | 341 - src/lib/components/panelSizing.test.ts | 12 - src/lib/components/panelSizing.ts | 20 - src/lib/shortcuts/shortcutRecorder.test.ts | 286 - src/lib/shortcuts/shortcutRecorder.ts | 183 - src/lib/stores/appearance.svelte.ts | 35 - src/lib/stores/chat.svelte.test.ts | 328 - src/lib/stores/chat.svelte.ts | 390 -- src/lib/stores/chatContext.test.ts | 56 - src/lib/stores/chatContext.ts | 16 - src/lib/stores/languages.test.ts | 18 - src/lib/stores/languages.ts | 45 - src/lib/stores/shortcuts.ts | 5 - src/lib/stores/translation.svelte.test.ts | 330 - src/lib/stores/translation.svelte.ts | 365 -- src/lib/stores/update.svelte.ts | 221 - src/lib/stores/updateCapabilities.test.ts | 48 - src/lib/views/ChatPanel.svelte | 932 --- src/lib/views/Settings.svelte | 1101 ---- src/lib/views/settingsLogic.test.ts | 100 - src/lib/views/settingsLogic.ts | 111 - src/main.ts | 9 - src/styles/global.css | 135 - src/styles/tokens.css | 166 - static/favicon.png | Bin 1571 -> 0 bytes static/tauri.svg | 6 - svelte.config.js | 5 - tsconfig.json | 21 - vite.config.js | 31 - 100 files changed, 4711 insertions(+), 19918 deletions(-) rename {src-tauri => assets}/icons/128x128.png (100%) rename {src-tauri => assets}/icons/128x128@2x.png (100%) rename {src-tauri => assets}/icons/32x32.png (100%) rename {src-tauri => assets}/icons/Square107x107Logo.png (100%) rename {src-tauri => assets}/icons/Square142x142Logo.png (100%) rename {src-tauri => assets}/icons/Square150x150Logo.png (100%) rename {src-tauri => assets}/icons/Square284x284Logo.png (100%) rename {src-tauri => assets}/icons/Square30x30Logo.png (100%) rename {src-tauri => assets}/icons/Square310x310Logo.png (100%) rename {src-tauri => assets}/icons/Square44x44Logo.png (100%) rename {src-tauri => assets}/icons/Square71x71Logo.png (100%) rename {src-tauri => assets}/icons/Square89x89Logo.png (100%) rename {src-tauri => assets}/icons/StoreLogo.png (100%) rename {src-tauri => assets}/icons/icon.icns (100%) rename {src-tauri => assets}/icons/icon.ico (100%) rename {src-tauri => assets}/icons/icon.png (100%) create mode 100644 crates/ttree-app/src/windows_app.rs create mode 100644 crates/ttree-ocr/src/postprocess.rs create mode 100644 crates/ttree-ocr/src/preprocess.rs create mode 100644 crates/ttree-ocr/src/windows_capture.rs create mode 100644 crates/ttree-ocr/src/windows_ocr.rs delete mode 100644 index.html delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 src-tauri/.gitignore delete mode 100644 src-tauri/Cargo.lock delete mode 100644 src-tauri/Cargo.toml delete mode 100644 src-tauri/Info.plist delete mode 100644 src-tauri/build.rs delete mode 100644 src-tauri/capabilities/default.json delete mode 100644 src-tauri/src/commands.rs delete mode 100644 src-tauri/src/config.rs delete mode 100644 src-tauri/src/history.rs delete mode 100644 src-tauri/src/keychain.rs delete mode 100644 src-tauri/src/lib.rs delete mode 100644 src-tauri/src/main.rs delete mode 100644 src-tauri/src/ocr/image.rs delete mode 100644 src-tauri/src/ocr/mod.rs delete mode 100644 src-tauri/src/ocr/tests.rs delete mode 100644 src-tauri/src/ocr/tests/diagnose-vision.swift delete mode 100644 src-tauri/src/ocr/tests/generate-fixtures.swift delete mode 100644 src-tauri/src/ocr/vision.rs delete mode 100644 src-tauri/src/screenshot.rs delete mode 100644 src-tauri/src/selection/macos.rs delete mode 100644 src-tauri/src/selection/mod.rs delete mode 100644 src-tauri/src/shortcut.rs delete mode 100644 src-tauri/src/translate/auto.rs delete mode 100644 src-tauri/src/translate/bing.rs delete mode 100644 src-tauri/src/translate/google.rs delete mode 100644 src-tauri/src/translate/mod.rs delete mode 100644 src-tauri/src/translate/openai.rs delete mode 100644 src-tauri/src/tray.rs delete mode 100644 src-tauri/src/window.rs delete mode 100644 src-tauri/tauri.conf.json delete mode 100644 src/App.svelte delete mode 100644 src/lib/api/tauri.test.ts delete mode 100644 src/lib/api/tauri.ts delete mode 100644 src/lib/components/EngineTabs.svelte delete mode 100644 src/lib/components/LangSelector.svelte delete mode 100644 src/lib/components/ResultView.svelte delete mode 100644 src/lib/components/TranslatePanel.svelte delete mode 100644 src/lib/components/panelSizing.test.ts delete mode 100644 src/lib/components/panelSizing.ts delete mode 100644 src/lib/shortcuts/shortcutRecorder.test.ts delete mode 100644 src/lib/shortcuts/shortcutRecorder.ts delete mode 100644 src/lib/stores/appearance.svelte.ts delete mode 100644 src/lib/stores/chat.svelte.test.ts delete mode 100644 src/lib/stores/chat.svelte.ts delete mode 100644 src/lib/stores/chatContext.test.ts delete mode 100644 src/lib/stores/chatContext.ts delete mode 100644 src/lib/stores/languages.test.ts delete mode 100644 src/lib/stores/languages.ts delete mode 100644 src/lib/stores/shortcuts.ts delete mode 100644 src/lib/stores/translation.svelte.test.ts delete mode 100644 src/lib/stores/translation.svelte.ts delete mode 100644 src/lib/stores/update.svelte.ts delete mode 100644 src/lib/stores/updateCapabilities.test.ts delete mode 100644 src/lib/views/ChatPanel.svelte delete mode 100644 src/lib/views/Settings.svelte delete mode 100644 src/lib/views/settingsLogic.test.ts delete mode 100644 src/lib/views/settingsLogic.ts delete mode 100644 src/main.ts delete mode 100644 src/styles/global.css delete mode 100644 src/styles/tokens.css delete mode 100644 static/favicon.png delete mode 100644 static/tauri.svg delete mode 100644 svelte.config.js delete mode 100644 tsconfig.json delete mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore index 6a672c1..9774dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,6 @@ .DS_Store .claude/scheduled_tasks.lock -node_modules -/build -/dist -/.svelte-kit -/package .env .env.* !.env.example -vite.config.js.timestamp-* -vite.config.ts.timestamp-* /target - diff --git a/Cargo.lock b/Cargo.lock index 400f93a..6b6799d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3160,6 +3160,7 @@ dependencies = [ "objc2-vision", "serde", "tempfile", + "windows", ] [[package]] @@ -3455,6 +3456,51 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -3468,8 +3514,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", ] [[package]] @@ -3481,6 +3536,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets", +] + [[package]] name = "windows-strings" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 1bfac65..60f8cad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,3 +114,13 @@ objc2-app-kit = { version = "0.3", features = [ "objc2-core-foundation", ] } objc2-quartz-core = { version = "0.3", features = ["CALayer"] } +windows = { version = "0.58", features = [ + "Foundation", + "Foundation_Collections", + "Globalization", + "Graphics_Imaging", + "Media_Ocr", + "Security_Cryptography", + "Storage_Streams", + "Win32_System_WinRT", +] } diff --git a/src-tauri/icons/128x128.png b/assets/icons/128x128.png similarity index 100% rename from src-tauri/icons/128x128.png rename to assets/icons/128x128.png diff --git a/src-tauri/icons/128x128@2x.png b/assets/icons/128x128@2x.png similarity index 100% rename from src-tauri/icons/128x128@2x.png rename to assets/icons/128x128@2x.png diff --git a/src-tauri/icons/32x32.png b/assets/icons/32x32.png similarity index 100% rename from src-tauri/icons/32x32.png rename to assets/icons/32x32.png diff --git a/src-tauri/icons/Square107x107Logo.png b/assets/icons/Square107x107Logo.png similarity index 100% rename from src-tauri/icons/Square107x107Logo.png rename to assets/icons/Square107x107Logo.png diff --git a/src-tauri/icons/Square142x142Logo.png b/assets/icons/Square142x142Logo.png similarity index 100% rename from src-tauri/icons/Square142x142Logo.png rename to assets/icons/Square142x142Logo.png diff --git a/src-tauri/icons/Square150x150Logo.png b/assets/icons/Square150x150Logo.png similarity index 100% rename from src-tauri/icons/Square150x150Logo.png rename to assets/icons/Square150x150Logo.png diff --git a/src-tauri/icons/Square284x284Logo.png b/assets/icons/Square284x284Logo.png similarity index 100% rename from src-tauri/icons/Square284x284Logo.png rename to assets/icons/Square284x284Logo.png diff --git a/src-tauri/icons/Square30x30Logo.png b/assets/icons/Square30x30Logo.png similarity index 100% rename from src-tauri/icons/Square30x30Logo.png rename to assets/icons/Square30x30Logo.png diff --git a/src-tauri/icons/Square310x310Logo.png b/assets/icons/Square310x310Logo.png similarity index 100% rename from src-tauri/icons/Square310x310Logo.png rename to assets/icons/Square310x310Logo.png diff --git a/src-tauri/icons/Square44x44Logo.png b/assets/icons/Square44x44Logo.png similarity index 100% rename from src-tauri/icons/Square44x44Logo.png rename to assets/icons/Square44x44Logo.png diff --git a/src-tauri/icons/Square71x71Logo.png b/assets/icons/Square71x71Logo.png similarity index 100% rename from src-tauri/icons/Square71x71Logo.png rename to assets/icons/Square71x71Logo.png diff --git a/src-tauri/icons/Square89x89Logo.png b/assets/icons/Square89x89Logo.png similarity index 100% rename from src-tauri/icons/Square89x89Logo.png rename to assets/icons/Square89x89Logo.png diff --git a/src-tauri/icons/StoreLogo.png b/assets/icons/StoreLogo.png similarity index 100% rename from src-tauri/icons/StoreLogo.png rename to assets/icons/StoreLogo.png diff --git a/src-tauri/icons/icon.icns b/assets/icons/icon.icns similarity index 100% rename from src-tauri/icons/icon.icns rename to assets/icons/icon.icns diff --git a/src-tauri/icons/icon.ico b/assets/icons/icon.ico similarity index 100% rename from src-tauri/icons/icon.ico rename to assets/icons/icon.ico diff --git a/src-tauri/icons/icon.png b/assets/icons/icon.png similarity index 100% rename from src-tauri/icons/icon.png rename to assets/icons/icon.png diff --git a/crates/ttree-ai/Cargo.toml b/crates/ttree-ai/Cargo.toml index 740f400..aab8a47 100644 --- a/crates/ttree-ai/Cargo.toml +++ b/crates/ttree-ai/Cargo.toml @@ -23,6 +23,3 @@ tempfile.workspace = true candle-core = { version = "0.11.0", features = ["metal", "accelerate"] } candle-transformers = { version = "0.11.0", features = ["metal", "accelerate"] } -[target.'cfg(not(target_os = "macos"))'.dependencies] -candle-core = { version = "0.11.0" } -candle-transformers = { version = "0.11.0" } diff --git a/crates/ttree-ai/src/gguf.rs b/crates/ttree-ai/src/gguf.rs index e3f2c42..7967a62 100644 --- a/crates/ttree-ai/src/gguf.rs +++ b/crates/ttree-ai/src/gguf.rs @@ -6,25 +6,36 @@ //! 3. 优先启用 Apple Silicon `Device::new_metal(0)` GPU 加速,并在内存中常驻缓存已加载的模型权重,二次翻译 0 加载延迟; //! 4. 每生成一个 Token 即刻回调 UI 并检查 `CancelToken`,支持随时毫秒级打断。 +#[cfg(target_os = "macos")] use std::collections::HashMap; +#[cfg(target_os = "macos")] use std::fs::File; +#[cfg(target_os = "macos")] use std::io::BufReader; +#[cfg(target_os = "macos")] use std::path::Path; +#[cfg(target_os = "macos")] use std::sync::{Mutex, OnceLock}; +#[cfg(target_os = "macos")] use candle_core::quantized::gguf_file::{self, Value as GgufValue}; +#[cfg(target_os = "macos")] use candle_core::{Device, Tensor}; +#[cfg(target_os = "macos")] use candle_transformers::models::{quantized_llama, quantized_qwen2, quantized_qwen3}; use ttree_core::{CancelToken, ChatMessage}; +#[cfg(target_os = "macos")] const MAX_NEW_TOKENS: usize = 512; +#[cfg(target_os = "macos")] enum QuantizedArch { Qwen2(quantized_qwen2::ModelWeights), Qwen3(quantized_qwen3::ModelWeights), Llama(quantized_llama::ModelWeights), } +#[cfg(target_os = "macos")] impl QuantizedArch { fn clear_kv_cache(&mut self) { match self { @@ -44,6 +55,7 @@ impl QuantizedArch { } /// 从 GGUF 内嵌的 `tokenizer.ggml.tokens` 词表构建的轻量分词/解码器。 +#[cfg(target_os = "macos")] pub struct GgufEmbeddedTokenizer { id_to_token: Vec, token_to_id: HashMap, @@ -51,6 +63,7 @@ pub struct GgufEmbeddedTokenizer { bos_token: Option, } +#[cfg(target_os = "macos")] impl GgufEmbeddedTokenizer { pub fn from_gguf_metadata(md: &HashMap) -> Result { let raw_tokens = md @@ -184,6 +197,7 @@ impl GgufEmbeddedTokenizer { } } +#[cfg(target_os = "macos")] struct CachedSession { model_path: String, device: Device, @@ -191,16 +205,18 @@ struct CachedSession { tokenizer: GgufEmbeddedTokenizer, } +#[cfg(target_os = "macos")] static GGUF_SESSION: OnceLock>> = OnceLock::new(); +#[cfg(target_os = "macos")] fn select_device() -> Device { - #[cfg(target_os = "macos")] if let Ok(metal) = Device::new_metal(0) { return metal; } Device::Cpu } +#[cfg(target_os = "macos")] fn load_session(model_path: &str) -> Result { let path = Path::new(model_path); if !path.is_file() { @@ -261,6 +277,7 @@ pub fn format_chatml_prompt(messages: &[ChatMessage]) -> String { prompt } +#[cfg(target_os = "macos")] pub async fn stream_gguf_inference( gguf_model_path: &str, _gguf_tokenizer_path: &str, @@ -355,7 +372,22 @@ where Ok(()) } +#[cfg(not(target_os = "macos"))] +pub async fn stream_gguf_inference( + _gguf_model_path: &str, + _gguf_tokenizer_path: &str, + _messages: &[ChatMessage], + _cancel: &CancelToken, + _on_delta: &mut F, +) -> Result<(), String> +where + F: FnMut(&str) + Send, +{ + Err("Candle Metal GGUF 直载仅限 macOS;Windows 请选择「内置 HY-MT1.5-1.8B」或「LocalServer / Ollama」后端。".to_string()) +} + /// 执行一步前向推理并采用 Argmax 贪心采样(翻译任务使用 temperature=0 最准确稳定)。 +#[cfg(target_os = "macos")] fn run_step( arch: &mut QuantizedArch, device: &Device, diff --git a/crates/ttree-ai/src/hy_mt_manager.rs b/crates/ttree-ai/src/hy_mt_manager.rs index 73b51da..be1d182 100644 --- a/crates/ttree-ai/src/hy_mt_manager.rs +++ b/crates/ttree-ai/src/hy_mt_manager.rs @@ -273,6 +273,10 @@ pub fn resolve_existing_llama_server() -> Option { if is_executable_file(&managed) { return Some(managed); } + let managed_nested = runtime_dir().join("build").join("bin").join(bin_name); + if is_executable_file(&managed_nested) { + return Some(managed_nested); + } // 2. 应用包内置同级目录(若打包时附带) if let Ok(exe) = std::env::current_exe() { @@ -417,6 +421,15 @@ fn detect_cpu_brand() -> String { } } } + #[cfg(target_os = "windows")] + { + if let Ok(id) = std::env::var("PROCESSOR_IDENTIFIER") { + let trimmed = id.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + } format!("{} CPU", std::env::consts::ARCH) } @@ -441,6 +454,31 @@ fn detect_total_ram_gb() -> f64 { } } } + #[cfg(target_os = "windows")] + { + #[repr(C)] + struct MemoryStatusEx { + length: u32, + memory_load: u32, + total_phys: u64, + avail_phys: u64, + total_page_file: u64, + avail_page_file: u64, + total_virtual: u64, + avail_virtual: u64, + avail_extended_virtual: u64, + } + extern "system" { + fn GlobalMemoryStatusEx(lpBuffer: *mut MemoryStatusEx) -> i32; + } + unsafe { + let mut status: MemoryStatusEx = std::mem::zeroed(); + status.length = std::mem::size_of::() as u32; + if GlobalMemoryStatusEx(&mut status) != 0 && status.total_phys > 0 { + return (status.total_phys as f64) / (1024.0 * 1024.0 * 1024.0); + } + } + } #[cfg(target_os = "linux")] { if let Ok(meminfo) = fs::read_to_string("/proc/meminfo") { @@ -477,6 +515,36 @@ fn detect_free_disk_gb(path: &Path) -> f64 { } } } + #[cfg(target_os = "windows")] + { + use std::os::windows::ffi::OsStrExt; + extern "system" { + fn GetDiskFreeSpaceExW( + lpDirectoryName: *const u16, + lpFreeBytesAvailableToCaller: *mut u64, + lpTotalNumberOfBytes: *mut u64, + lpTotalNumberOfFreeBytes: *mut u64, + ) -> i32; + } + let wide: Vec = check_dir + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut avail: u64 = 0; + unsafe { + if GetDiskFreeSpaceExW( + wide.as_ptr(), + &mut avail, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) != 0 + && avail > 0 + { + return (avail as f64) / (1024.0 * 1024.0 * 1024.0); + } + } + } 50.0 } @@ -882,10 +950,16 @@ pub async fn ensure_server_running( let root = local_llm_root_dir(); let _ = fs::create_dir_all(&root); let log_path = root.join("llama-server.log"); - let log_file = File::create(&log_path).unwrap_or_else(|_| File::open("/dev/null").unwrap()); - let log_err = log_file - .try_clone() - .unwrap_or_else(|_| File::open("/dev/null").unwrap()); + let (stdout_cfg, stderr_cfg) = match File::create(&log_path) { + Ok(f) => { + let err_stdio = f + .try_clone() + .map(Stdio::from) + .unwrap_or_else(|_| Stdio::null()); + (Stdio::from(f), err_stdio) + } + Err(_) => (Stdio::null(), Stdio::null()), + }; let mut cmd = Command::new(&server_bin); cmd.arg("-m") @@ -908,9 +982,10 @@ pub async fn ensure_server_running( .arg("1.05") .arg("--no-webui") .stdin(Stdio::null()) - .stdout(Stdio::from(log_file)) - .stderr(Stdio::from(log_err)); + .stdout(stdout_cfg) + .stderr(stderr_cfg); + #[cfg(unix)] if let Some(bin_dir) = server_bin.parent() { #[cfg(target_os = "macos")] cmd.env("DYLD_LIBRARY_PATH", bin_dir); diff --git a/crates/ttree-app/src/main.rs b/crates/ttree-app/src/main.rs index 4140b93..284077a 100644 --- a/crates/ttree-app/src/main.rs +++ b/crates/ttree-app/src/main.rs @@ -1,4 +1,4 @@ -//! `ttree` —— 100% 纯 Rust 原生 macOS AI 翻译与 OCR 桌面端入口。 +//! `ttree` —— 100% 纯 Rust 原生跨平台 (macOS AppKit / Windows Win32+DWM) AI 翻译与 OCR 桌面端入口。 #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] @@ -7,16 +7,24 @@ mod state; #[cfg(target_os = "macos")] mod ui_helpers; -#[cfg(not(target_os = "macos"))] -fn main() { - eprintln!("TTREE 纯 Rust 原生 AppKit 客户端仅支持 macOS 运行。"); -} +#[cfg(target_os = "windows")] +mod windows_app; #[cfg(target_os = "macos")] fn main() { macos_app::run(); } +#[cfg(target_os = "windows")] +fn main() { + windows_app::run(); +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +fn main() { + eprintln!("TTREE 纯 Rust 原生客户端目前仅支持 macOS 与 Windows 运行。"); +} + #[cfg(target_os = "macos")] pub(crate) fn dispatch_splitter_drag(splitter_id: u8, delta_down: f64) { macos_app::on_splitter_drag(splitter_id, delta_down); diff --git a/crates/ttree-app/src/windows_app.rs b/crates/ttree-app/src/windows_app.rs new file mode 100644 index 0000000..203f3e7 --- /dev/null +++ b/crates/ttree-app/src/windows_app.rs @@ -0,0 +1,2650 @@ +//! `ttree-app` Windows 100% 纯 Rust 原生 Win32 + DWM 桌面端实现。 +//! +//! 架构与 macOS AppKit 端完全对称: +//! - 复用 `ttree-core`、`ttree-storage`、`ttree-translate`、`ttree-ai`、`ttree-ocr`、`ttree-platform-macos`; +//! - 原生 Win32 控件树 + DWM 深色模式 & Mica/Acrylic 云母材质 + 分层窗口透明度 (`SetLayeredWindowAttributes`); +//! - 系统托盘 (`Shell_NotifyIconW`) + 25ms `SetTimer` 驱动 `global-hotkey` 与 Tokio `mpsc` 流式 UI 事件; +//! - 完整支持四段式翻译面板(含可拖拽高度分隔条)、AI 多轮流式对话、内置 `HY-MT1.5-1.8B` 一键下载托管、 +//! 以及增强版双通道多语言 Windows 截图 OCR。 + +#![cfg(target_os = "windows")] +#![allow(non_snake_case, clippy::too_many_arguments)] + +use std::cell::RefCell; +use std::ffi::c_void; +use std::ptr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use global_hotkey::hotkey::HotKey; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; +use tokio::runtime::Handle as TokioHandle; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; + +use ttree_ai::hy_mt_manager::{self, HyMtCompatibilityReport, HyMtDownloadProgress}; +use ttree_core::{ + normalize_ocr_newlines, recent_chat_context, resolve_language_pair, AiBackendKind, + AiTranslationConfig, AppearanceConfig, CancelSlot, ChatAiConfig, ChatMessage, Conversation, + HyMtQuant, PaneLayoutConfig, ShortcutConfig, ADAPTIVE_TARGET, PANEL_OPACITY_MAX, + PANEL_OPACITY_MIN, SOURCE_LANGUAGES, TARGET_LANGUAGES, TOP_BAR_HEIGHT, WINDOW_MAX_HEIGHT, + WINDOW_MAX_WIDTH, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, +}; +use ttree_ocr::OcrOutcome; +use ttree_platform_macos::autostart; +use ttree_platform_macos::hotkey::{parse_shortcut_config, ParsedShortcuts, ShortcutAction}; +use ttree_platform_macos::selection::capture_selected_text; +use ttree_storage::{HistoryStore, SettingsStore}; + +type HWND = *mut c_void; +type HMENU = *mut c_void; +type HINSTANCE = *mut c_void; +type HDC = *mut c_void; +type HFONT = *mut c_void; +type HBRUSH = *mut c_void; +type HICON = *mut c_void; +type HCURSOR = *mut c_void; +type HANDLE = *mut c_void; +type WPARAM = usize; +type LPARAM = isize; +type LRESULT = isize; +type BOOL = i32; + +const WS_OVERLAPPED: u32 = 0x00000000; +const WS_CAPTION: u32 = 0x00C00000; +const WS_SYSMENU: u32 = 0x00080000; +const WS_THICKFRAME: u32 = 0x00040000; +const WS_CLIPCHILDREN: u32 = 0x02000000; +const WS_CHILD: u32 = 0x40000000; +const WS_VSCROLL: u32 = 0x00200000; +const WS_BORDER: u32 = 0x00800000; + +const WS_EX_TOOLWINDOW: u32 = 0x00000080; +const WS_EX_LAYERED: u32 = 0x00080000; + +const ES_MULTILINE: u32 = 0x0004; +const ES_AUTOVSCROLL: u32 = 0x0040; +const ES_AUTOHSCROLL: u32 = 0x0080; +const ES_READONLY: u32 = 0x0800; +const ES_PASSWORD: u32 = 0x0020; +const ES_WANTRETURN: u32 = 0x1000; + +const BS_PUSHBUTTON: u32 = 0x0000; +const BS_AUTOCHECKBOX: u32 = 0x0003; + +const CBS_DROPDOWNLIST: u32 = 0x0003; +const CBS_HASSTRINGS: u32 = 0x0200; + +const WM_DESTROY: u32 = 0x0002; +const WM_SIZE: u32 = 0x0005; +const WM_ACTIVATE: u32 = 0x0006; +const WM_CLOSE: u32 = 0x0010; +const WM_GETMINMAXINFO: u32 = 0x0024; +const WM_SETFONT: u32 = 0x0030; +const WM_COMMAND: u32 = 0x0111; +const WM_TIMER: u32 = 0x0113; +const WM_CTLCOLOREDIT: u32 = 0x0133; +const WM_CTLCOLORLISTBOX: u32 = 0x0134; +const WM_CTLCOLORBTN: u32 = 0x0135; +const WM_CTLCOLORSTATIC: u32 = 0x0138; +const WM_MOUSEMOVE: u32 = 0x0200; +const WM_LBUTTONDOWN: u32 = 0x0201; +const WM_LBUTTONUP: u32 = 0x0202; +const WM_RBUTTONUP: u32 = 0x0205; +const WM_APP: u32 = 0x8000; +const WM_TRAYICON: u32 = WM_APP + 1; + +const WA_INACTIVE: usize = 0; +const EN_CHANGE: u16 = 0x0300; +const CBN_SELCHANGE: u16 = 1; +const BN_CLICKED: u16 = 0; + +const CB_ADDSTRING: u32 = 0x0143; +const CB_SETCURSEL: u32 = 0x014E; +const CB_GETCURSEL: u32 = 0x0147; +const CB_RESETCONTENT: u32 = 0x014B; + +const BM_GETCHECK: u32 = 0x00F0; +const BM_SETCHECK: u32 = 0x00F1; +const BST_CHECKED: usize = 1; +const BST_UNCHECKED: usize = 0; + +const EM_SETSEL: u32 = 0x00B1; +const EM_SCROLLCARET: u32 = 0x00B7; + +const SW_HIDE: i32 = 0; +const SW_SHOW: i32 = 5; +const SW_RESTORE: i32 = 9; + +const HWND_TOPMOST: HWND = -1isize as HWND; +const HWND_NOTOPMOST: HWND = -2isize as HWND; +const SWP_NOMOVE: u32 = 0x0002; +const SWP_NOSIZE: u32 = 0x0001; +const SWP_NOACTIVATE: u32 = 0x0010; + +const LWA_ALPHA: u32 = 0x00000002; +const TRANSPARENT: i32 = 1; +const OPAQUE: i32 = 2; + +const NIM_ADD: u32 = 0x00000000; +const NIM_DELETE: u32 = 0x00000002; +const NIF_MESSAGE: u32 = 0x00000001; +const NIF_ICON: u32 = 0x00000002; +const NIF_TIP: u32 = 0x00000004; + +const TPM_RIGHTBUTTON: u32 = 0x0002; +const TPM_BOTTOMALIGN: u32 = 0x0020; +const MF_STRING: u32 = 0x0000; +const MF_SEPARATOR: u32 = 0x0800; + +const IDI_APPLICATION: *const u16 = 32512 as *const u16; +const IDC_ARROW: *const u16 = 32512 as *const u16; +const IDC_SIZENS: *const u16 = 32645 as *const u16; + +const CF_UNICODETEXT: u32 = 13; +const GMEM_MOVEABLE: u32 = 0x0002; + +const DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2: isize = -4; +const DWMWA_USE_IMMERSIVE_DARK_MODE: u32 = 20; +const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 33; +const DWMWA_SYSTEMBACKDROP_TYPE: u32 = 38; +const DWMWCP_ROUND: u32 = 2; +const DWMSBT_MAINWINDOW_MICA: u32 = 2; + +const TIMER_ID_POLL: usize = 1; +const AUTO_TRANSLATE_DEBOUNCE: Duration = Duration::from_millis(650); +const RESIZE_SAVE_DEBOUNCE: Duration = Duration::from_millis(350); + +// 控件 ID 定义 +const ID_BTN_TAB_TRANS: usize = 101; +const ID_BTN_TAB_CHAT: usize = 102; +const ID_BTN_TAB_SETTINGS: usize = 103; +const ID_BTN_OCR: usize = 104; +const ID_BTN_PIN: usize = 105; + +const ID_EDIT_SOURCE: usize = 201; +const ID_CMB_SOURCE_LANG: usize = 202; +const ID_BTN_SWAP_LANG: usize = 203; +const ID_CMB_TARGET_LANG: usize = 204; +const ID_BTN_TRANS_NOW: usize = 205; +const ID_BTN_CLEAR_TRANS: usize = 206; +const ID_BTN_COPY_WEB: usize = 207; +const ID_EDIT_WEB_RESULT: usize = 208; +const ID_BTN_COPY_AI: usize = 209; +const ID_EDIT_AI_RESULT: usize = 210; + +const ID_CMB_CHAT_CONV: usize = 301; +const ID_BTN_CHAT_NEW: usize = 302; +const ID_BTN_CHAT_COPY: usize = 303; +const ID_BTN_CHAT_CLEAR: usize = 304; +const ID_EDIT_CHAT_HISTORY: usize = 305; +const ID_EDIT_CHAT_INPUT: usize = 306; +const ID_BTN_CHAT_SEND: usize = 307; +const ID_BTN_CHAT_STOP: usize = 308; + +const ID_CHK_AI_ENABLED: usize = 401; +const ID_CMB_AI_BACKEND: usize = 402; +const ID_CMB_HYMT_QUANT: usize = 403; +const ID_BTN_HYMT_CHECK: usize = 404; +const ID_BTN_HYMT_RUN: usize = 405; +const ID_BTN_HYMT_STOP: usize = 406; +const ID_EDIT_AI_URL: usize = 407; +const ID_EDIT_AI_KEY: usize = 408; +const ID_EDIT_AI_MODEL: usize = 409; +const ID_BTN_AI_DISCOVER: usize = 410; +const ID_EDIT_AI_PROMPT: usize = 411; +const ID_CMB_CHAT_BACKEND: usize = 412; +const ID_EDIT_CHAT_URL: usize = 413; +const ID_EDIT_CHAT_KEY: usize = 414; +const ID_EDIT_CHAT_MODEL: usize = 415; +const ID_BTN_CHAT_COPY_TRANS: usize = 416; +const ID_EDIT_HK_TOGGLE: usize = 417; +const ID_EDIT_HK_OCR: usize = 418; +const ID_EDIT_HK_CHAT: usize = 419; +const ID_EDIT_HK_SEL_TRANS: usize = 420; +const ID_EDIT_HK_SEL_CHAT: usize = 421; +const ID_BTN_HK_RESET: usize = 422; +const ID_CMB_OPACITY: usize = 423; +const ID_CHK_OCR_NEWLINE: usize = 424; +const ID_CHK_AUTOSTART: usize = 425; +const ID_BTN_SAVE_SETTINGS: usize = 426; + +const ID_TRAY_TRANSLATE: usize = 901; +const ID_TRAY_OCR: usize = 902; +const ID_TRAY_CHAT: usize = 903; +const ID_TRAY_SETTINGS: usize = 904; +const ID_TRAY_QUIT: usize = 905; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TabMode { + Translate, + Chat, + Settings, +} + +enum UiEvent { + WebTranslateDone { + seq: u64, + result: Result, + }, + AiTranslateDelta { + seq: u64, + delta: String, + }, + AiTranslateDone { + seq: u64, + result: Result<(), String>, + }, + ChatDelta { + seq: u64, + delta: String, + }, + ChatDone { + seq: u64, + result: Result<(), String>, + }, + OcrDone(Result), + ModelsDiscovered(Result, String>), + HyMtProgress(HyMtDownloadProgress), + HyMtReady(Result), + HyMtCompatReady(HyMtCompatibilityReport), +} + +#[repr(C)] +#[derive(Copy, Clone)] +struct POINT { + x: i32, + y: i32, +} + +#[repr(C)] +#[derive(Copy, Clone)] +struct RECT { + left: i32, + top: i32, + right: i32, + bottom: i32, +} + +#[repr(C)] +#[derive(Copy, Clone)] +struct MINMAXINFO { + ptReserved: POINT, + ptMaxSize: POINT, + ptMaxPosition: POINT, + ptMinTrackSize: POINT, + ptMaxTrackSize: POINT, +} + +#[repr(C)] +#[derive(Copy, Clone)] +struct MSG { + hwnd: HWND, + message: u32, + wParam: WPARAM, + lParam: LPARAM, + time: u32, + pt: POINT, +} + +#[repr(C)] +struct WNDCLASSEXW { + cbSize: u32, + style: u32, + lpfnWndProc: unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT, + cbClsExtra: i32, + cbWndExtra: i32, + hInstance: HINSTANCE, + hIcon: HICON, + hCursor: HCURSOR, + hbrBackground: HBRUSH, + lpszMenuName: *const u16, + lpszClassName: *const u16, + hIconSm: HICON, +} + +#[repr(C)] +struct NOTIFYICONDATAW { + cbSize: u32, + hWnd: HWND, + uID: u32, + uFlags: u32, + uCallbackMessage: u32, + hIcon: HICON, + szTip: [u16; 128], + dwState: u32, + dwStateMask: u32, + szInfo: [u16; 256], + uTimeoutOrVersion: u32, + szInfoTitle: [u16; 64], + dwInfoFlags: u32, + guidItem: [u8; 16], + hBalloonIcon: HICON, +} + +#[link(name = "dwmapi")] +extern "system" { + fn DwmSetWindowAttribute( + hwnd: HWND, + dwAttribute: u32, + pvAttribute: *const c_void, + cbAttribute: u32, + ) -> i32; +} + +#[link(name = "shell32")] +extern "system" { + fn Shell_NotifyIconW(dwMessage: u32, lpData: *const NOTIFYICONDATAW) -> BOOL; +} + +extern "system" { + fn SetProcessDpiAwarenessContext(value: isize) -> BOOL; + fn GetModuleHandleW(lpModuleName: *const u16) -> HINSTANCE; + fn RegisterClassExW(lpWndClass: *const WNDCLASSEXW) -> u16; + fn CreateWindowExW( + dwExStyle: u32, + lpClassName: *const u16, + lpWindowName: *const u16, + dwStyle: u32, + X: i32, + Y: i32, + nWidth: i32, + nHeight: i32, + hWndParent: HWND, + hMenu: HMENU, + hInstance: HINSTANCE, + lpParam: *mut c_void, + ) -> HWND; + fn DestroyWindow(hWnd: HWND) -> BOOL; + fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL; + fn IsWindowVisible(hWnd: HWND) -> BOOL; + fn SetForegroundWindow(hWnd: HWND) -> BOOL; + fn SetFocus(hWnd: HWND) -> HWND; + fn SetWindowPos( + hWnd: HWND, + hWndInsertAfter: HWND, + X: i32, + Y: i32, + cx: i32, + cy: i32, + uFlags: u32, + ) -> BOOL; + fn MoveWindow(hWnd: HWND, X: i32, Y: i32, nWidth: i32, nHeight: i32, bRepaint: BOOL) -> BOOL; + fn GetClientRect(hWnd: HWND, lpRect: *mut RECT) -> BOOL; + fn SetLayeredWindowAttributes(hWnd: HWND, crKey: u32, bAlpha: u8, dwFlags: u32) -> BOOL; + fn SetTimer(hWnd: HWND, nIDEvent: usize, uElapse: u32, lpTimerFunc: *const c_void) -> usize; + fn SendMessageW(hWnd: HWND, Msg: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT; + fn SetWindowTextW(hWnd: HWND, lpString: *const u16) -> BOOL; + fn GetWindowTextLengthW(hWnd: HWND) -> i32; + fn GetWindowTextW(hWnd: HWND, lpString: *mut u16, nMaxCount: i32) -> i32; + fn DefWindowProcW(hWnd: HWND, Msg: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT; + fn GetMessageW(lpMsg: *mut MSG, hWnd: HWND, wMsgFilterMin: u32, wMsgFilterMax: u32) -> BOOL; + fn TranslateMessage(lpMsg: *const MSG) -> BOOL; + fn DispatchMessageW(lpMsg: *const MSG) -> LRESULT; + fn PostQuitMessage(nExitCode: i32); + fn LoadIconW(hInstance: HINSTANCE, lpIconName: *const u16) -> HICON; + fn LoadCursorW(hInstance: HINSTANCE, lpCursorName: *const u16) -> HCURSOR; + fn SetCursor(hCursor: HCURSOR) -> HCURSOR; + fn SetCapture(hWnd: HWND) -> HWND; + fn ReleaseCapture() -> BOOL; + fn InvalidateRect(hWnd: HWND, lpRect: *const RECT, bErase: BOOL) -> BOOL; + fn GetCursorPos(lpPoint: *mut POINT) -> BOOL; + fn CreatePopupMenu() -> HMENU; + fn AppendMenuW(hMenu: HMENU, uFlags: u32, uIDNewItem: usize, lpNewItem: *const u16) -> BOOL; + fn TrackPopupMenu( + hMenu: HMENU, + uFlags: u32, + x: i32, + y: i32, + nReserved: i32, + hWnd: HWND, + prcRect: *const RECT, + ) -> BOOL; + fn DestroyMenu(hMenu: HMENU) -> BOOL; + + fn CreateSolidBrush(color: u32) -> HBRUSH; + fn CreateFontW( + cHeight: i32, + cWidth: i32, + cEscapement: i32, + cOrientation: i32, + cWeight: i32, + bItalic: u32, + bUnderline: u32, + bStrikeOut: u32, + iCharSet: u32, + iOutPrecision: u32, + iClipPrecision: u32, + iQuality: u32, + iPitchAndFamily: u32, + pszFaceName: *const u16, + ) -> HFONT; + fn SetTextColor(hdc: HDC, color: u32) -> u32; + fn SetBkColor(hdc: HDC, color: u32) -> u32; + fn SetBkMode(hdc: HDC, mode: i32) -> i32; + + fn OpenClipboard(hWndNewOwner: HWND) -> BOOL; + fn CloseClipboard() -> BOOL; + fn EmptyClipboard() -> BOOL; + fn SetClipboardData(uFormat: u32, hMem: HANDLE) -> HANDLE; + fn GlobalAlloc(uFlags: u32, dwBytes: usize) -> HANDLE; + fn GlobalLock(hMem: HANDLE) -> *mut c_void; + fn GlobalUnlock(hMem: HANDLE) -> BOOL; + fn GlobalFree(hMem: HANDLE) -> HANDLE; +} + +#[inline] +const fn rgb(r: u8, g: u8, b: u8) -> u32 { + (r as u32) | ((g as u32) << 8) | ((b as u32) << 16) +} + +fn wide_null(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +fn to_crlf(s: &str) -> String { + let normalized = s.replace("\r\n", "\n"); + normalized.replace('\n', "\r\n") +} + +fn from_crlf(s: &str) -> String { + s.replace("\r\n", "\n") +} + +unsafe fn set_hwnd_text(hwnd: HWND, text: &str) { + if !hwnd.is_null() { + let w = wide_null(&to_crlf(text)); + SetWindowTextW(hwnd, w.as_ptr()); + } +} + +unsafe fn get_hwnd_text(hwnd: HWND) -> String { + if hwnd.is_null() { + return String::new(); + } + let len = GetWindowTextLengthW(hwnd); + if len <= 0 { + return String::new(); + } + let mut buf = vec![0u16; (len as usize) + 2]; + let read = GetWindowTextW(hwnd, buf.as_mut_ptr(), len + 1); + if read <= 0 { + return String::new(); + } + from_crlf(&String::from_utf16_lossy(&buf[..read as usize])) +} + +unsafe fn copy_text_to_clipboard(hwnd: HWND, text: &str) { + if text.is_empty() { + return; + } + let utf16: Vec = text.encode_utf16().chain(std::iter::once(0)).collect(); + let byte_len = utf16.len() * 2; + if OpenClipboard(hwnd) != 0 { + EmptyClipboard(); + let hmem = GlobalAlloc(GMEM_MOVEABLE, byte_len); + if !hmem.is_null() { + let ptr = GlobalLock(hmem); + if !ptr.is_null() { + ptr::copy_nonoverlapping(utf16.as_ptr() as *const u8, ptr as *mut u8, byte_len); + GlobalUnlock(hmem); + SetClipboardData(CF_UNICODETEXT, hmem); + } else { + GlobalFree(hmem); + } + } + CloseClipboard(); + } +} + +struct Win32AppState { + rt: TokioHandle, + ui_tx: UnboundedSender, + ui_rx: UnboundedReceiver, + settings: Arc, + history: Arc, + hotkey_mgr: GlobalHotKeyManager, + registered_hotkeys: Vec, + parsed_shortcuts: ParsedShortcuts, + + hwnd: HWND, + font_regular: HFONT, + font_bold: HFONT, + brush_bg: HBRUSH, + brush_surface: HBRUSH, + + active_tab: TabMode, + pinned: bool, + pin_before_settings: bool, + ocr_in_flight: bool, + suppress_source_change: bool, + + source_lang_idx: usize, + target_lang_idx: usize, + pane_layout: PaneLayoutConfig, + dragging_splitter: Option<(u8, i32)>, + + translate_seq: u64, + web_cancel: CancelSlot, + ai_cancel: CancelSlot, + ai_stream_buf: String, + web_result_text: String, + + chat_seq: u64, + chat_cancel: CancelSlot, + chat_streaming: bool, + conversations: Vec, + active_conv_idx: usize, + current_messages: Vec, + streaming_reply: String, + + hymt_cancel: CancelSlot, + + pending_auto_translate: Option, + pending_resize_save: Option<(Instant, f64, f64)>, + + // Top bar controls + btn_tab_trans: HWND, + btn_tab_chat: HWND, + btn_tab_settings: HWND, + btn_ocr: HWND, + btn_pin: HWND, + + // Translate controls + edit_source: HWND, + cmb_source_lang: HWND, + btn_swap_lang: HWND, + cmb_target_lang: HWND, + btn_trans_now: HWND, + btn_clear_trans: HWND, + lbl_web_header: HWND, + btn_copy_web: HWND, + edit_web_result: HWND, + lbl_ai_header: HWND, + btn_copy_ai: HWND, + edit_ai_result: HWND, + + // Chat controls + cmb_chat_conv: HWND, + btn_chat_new: HWND, + btn_chat_copy: HWND, + btn_chat_clear: HWND, + edit_chat_history: HWND, + edit_chat_input: HWND, + btn_chat_send: HWND, + btn_chat_stop: HWND, + + // Settings controls + chk_ai_enabled: HWND, + lbl_ai_backend: HWND, + cmb_ai_backend: HWND, + lbl_hymt_quant: HWND, + cmb_hymt_quant: HWND, + btn_hymt_check: HWND, + btn_hymt_run: HWND, + btn_hymt_stop: HWND, + lbl_hymt_status: HWND, + lbl_ai_url: HWND, + edit_ai_url: HWND, + lbl_ai_key: HWND, + edit_ai_key: HWND, + lbl_ai_model: HWND, + edit_ai_model: HWND, + btn_ai_discover: HWND, + lbl_ai_prompt: HWND, + edit_ai_prompt: HWND, + + lbl_chat_cfg: HWND, + cmb_chat_backend: HWND, + edit_chat_url: HWND, + edit_chat_key: HWND, + edit_chat_model: HWND, + btn_chat_copy_trans: HWND, + + lbl_hk_header: HWND, + edit_hk_toggle: HWND, + edit_hk_ocr: HWND, + edit_hk_chat: HWND, + edit_hk_sel_trans: HWND, + edit_hk_sel_chat: HWND, + btn_hk_reset: HWND, + + lbl_opacity: HWND, + cmb_opacity: HWND, + chk_ocr_newline: HWND, + chk_autostart: HWND, + btn_save_settings: HWND, + lbl_settings_status: HWND, +} + +thread_local! { + static WIN_CTX: RefCell> = const { RefCell::new(None) }; +} + +fn with_state(f: F) { + WIN_CTX.with(|cell| { + if let Ok(mut borrow) = cell.try_borrow_mut() { + if let Some(st) = borrow.as_mut() { + f(st); + } + } + }); +} + +pub fn run() { + unsafe { + let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + } + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("初始化 Tokio 异步运行时失败"); + + let settings = Arc::new(SettingsStore::open_default()); + let history = Arc::new(HistoryStore::open_default()); + let hotkey_mgr = GlobalHotKeyManager::new().expect("初始化全局快捷键管理器失败"); + let (ui_tx, ui_rx) = mpsc::unbounded_channel(); + + let pane_layout = settings.pane_layout(); + let ai_enabled = settings.ai_translation_for_request().enabled; + let win_w = pane_layout.width as i32; + let win_h = pane_layout.translate_window_height(ai_enabled) as i32; + + unsafe { + let hinstance = GetModuleHandleW(ptr::null()); + let class_name = wide_null("TTreePureRustWin32MainClass"); + let bg_brush = CreateSolidBrush(rgb(24, 26, 32)); + let surface_brush = CreateSolidBrush(rgb(34, 37, 46)); + + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: 0, + lpfnWndProc: main_wnd_proc, + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: hinstance, + hIcon: LoadIconW(ptr::null_mut(), IDI_APPLICATION), + hCursor: LoadCursorW(ptr::null_mut(), IDC_ARROW), + hbrBackground: bg_brush, + lpszMenuName: ptr::null(), + lpszClassName: class_name.as_ptr(), + hIconSm: LoadIconW(ptr::null_mut(), IDI_APPLICATION), + }; + RegisterClassExW(&wc); + + let font_face = wide_null("Microsoft YaHei UI"); + let font_regular = CreateFontW( + -14, + 0, + 0, + 0, + 400, + 0, + 0, + 0, + 1, + 0, + 0, + 5, + 0, + font_face.as_ptr(), + ); + let font_bold = CreateFontW( + -14, + 0, + 0, + 0, + 600, + 0, + 0, + 0, + 1, + 0, + 0, + 5, + 0, + font_face.as_ptr(), + ); + + let title = wide_null("TTREE"); + let hwnd = CreateWindowExW( + WS_EX_LAYERED | WS_EX_TOOLWINDOW, + class_name.as_ptr(), + title.as_ptr(), + WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_CLIPCHILDREN, + 260, + 180, + win_w, + win_h, + ptr::null_mut(), + ptr::null_mut(), + hinstance, + ptr::null_mut(), + ); + if hwnd.is_null() { + panic!("创建 Windows 主窗口失败"); + } + + // 启用 Win11 原生深色标题栏、圆角与云母材质 + let dark_mode: u32 = 1; + let _ = DwmSetWindowAttribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + &dark_mode as *const u32 as *const c_void, + 4, + ); + let corner_round: u32 = DWMWCP_ROUND; + let _ = DwmSetWindowAttribute( + hwnd, + DWMWA_WINDOW_CORNER_PREFERENCE, + &corner_round as *const u32 as *const c_void, + 4, + ); + let mica_backdrop: u32 = DWMSBT_MAINWINDOW_MICA; + let _ = DwmSetWindowAttribute( + hwnd, + DWMWA_SYSTEMBACKDROP_TYPE, + &mica_backdrop as *const u32 as *const c_void, + 4, + ); + + let opacity = settings.appearance().panel_opacity; + let alpha = (opacity.clamp(PANEL_OPACITY_MIN, PANEL_OPACITY_MAX) * 255.0).round() as u8; + SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA); + + let convs = history.read_conversations().unwrap_or_default(); + let active_conv_idx = convs.len().saturating_sub(1); + let current_messages = convs + .get(active_conv_idx) + .map(|c| c.messages.clone()) + .unwrap_or_default(); + + let mut app_state = Win32AppState { + rt: rt.handle().clone(), + ui_tx, + ui_rx, + settings, + history, + hotkey_mgr, + registered_hotkeys: Vec::new(), + parsed_shortcuts: ParsedShortcuts::default(), + hwnd, + font_regular, + font_bold, + brush_bg: bg_brush, + brush_surface: surface_brush, + active_tab: TabMode::Translate, + pinned: false, + pin_before_settings: false, + ocr_in_flight: false, + suppress_source_change: false, + source_lang_idx: 0, + target_lang_idx: 0, + pane_layout, + dragging_splitter: None, + translate_seq: 0, + web_cancel: CancelSlot::new(), + ai_cancel: CancelSlot::new(), + ai_stream_buf: String::new(), + web_result_text: String::new(), + chat_seq: 0, + chat_cancel: CancelSlot::new(), + chat_streaming: false, + conversations: convs, + active_conv_idx, + current_messages, + streaming_reply: String::new(), + hymt_cancel: CancelSlot::new(), + pending_auto_translate: None, + pending_resize_save: None, + btn_tab_trans: ptr::null_mut(), + btn_tab_chat: ptr::null_mut(), + btn_tab_settings: ptr::null_mut(), + btn_ocr: ptr::null_mut(), + btn_pin: ptr::null_mut(), + edit_source: ptr::null_mut(), + cmb_source_lang: ptr::null_mut(), + btn_swap_lang: ptr::null_mut(), + cmb_target_lang: ptr::null_mut(), + btn_trans_now: ptr::null_mut(), + btn_clear_trans: ptr::null_mut(), + lbl_web_header: ptr::null_mut(), + btn_copy_web: ptr::null_mut(), + edit_web_result: ptr::null_mut(), + lbl_ai_header: ptr::null_mut(), + btn_copy_ai: ptr::null_mut(), + edit_ai_result: ptr::null_mut(), + cmb_chat_conv: ptr::null_mut(), + btn_chat_new: ptr::null_mut(), + btn_chat_copy: ptr::null_mut(), + btn_chat_clear: ptr::null_mut(), + edit_chat_history: ptr::null_mut(), + edit_chat_input: ptr::null_mut(), + btn_chat_send: ptr::null_mut(), + btn_chat_stop: ptr::null_mut(), + chk_ai_enabled: ptr::null_mut(), + lbl_ai_backend: ptr::null_mut(), + cmb_ai_backend: ptr::null_mut(), + lbl_hymt_quant: ptr::null_mut(), + cmb_hymt_quant: ptr::null_mut(), + btn_hymt_check: ptr::null_mut(), + btn_hymt_run: ptr::null_mut(), + btn_hymt_stop: ptr::null_mut(), + lbl_hymt_status: ptr::null_mut(), + lbl_ai_url: ptr::null_mut(), + edit_ai_url: ptr::null_mut(), + lbl_ai_key: ptr::null_mut(), + edit_ai_key: ptr::null_mut(), + lbl_ai_model: ptr::null_mut(), + edit_ai_model: ptr::null_mut(), + btn_ai_discover: ptr::null_mut(), + lbl_ai_prompt: ptr::null_mut(), + edit_ai_prompt: ptr::null_mut(), + lbl_chat_cfg: ptr::null_mut(), + cmb_chat_backend: ptr::null_mut(), + edit_chat_url: ptr::null_mut(), + edit_chat_key: ptr::null_mut(), + edit_chat_model: ptr::null_mut(), + btn_chat_copy_trans: ptr::null_mut(), + lbl_hk_header: ptr::null_mut(), + edit_hk_toggle: ptr::null_mut(), + edit_hk_ocr: ptr::null_mut(), + edit_hk_chat: ptr::null_mut(), + edit_hk_sel_trans: ptr::null_mut(), + edit_hk_sel_chat: ptr::null_mut(), + btn_hk_reset: ptr::null_mut(), + lbl_opacity: ptr::null_mut(), + cmb_opacity: ptr::null_mut(), + chk_ocr_newline: ptr::null_mut(), + chk_autostart: ptr::null_mut(), + btn_save_settings: ptr::null_mut(), + lbl_settings_status: ptr::null_mut(), + }; + + app_state.build_child_controls(hinstance); + let sc = app_state.settings.shortcuts(); + let _ = app_state.apply_shortcuts(&sc); + app_state.populate_settings_fields(); + app_state.refresh_chat_controls(); + app_state.relayout_children(); + + install_tray_icon(hwnd); + SetTimer(hwnd, TIMER_ID_POLL, 25, ptr::null()); + + if std::env::args().any(|a| a == "--show") { + app_state.show_and_focus(); + } + + WIN_CTX.with(|cell| { + *cell.borrow_mut() = Some(app_state); + }); + + let mut msg: MSG = std::mem::zeroed(); + while GetMessageW(&mut msg, ptr::null_mut(), 0, 0) > 0 { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + remove_tray_icon(hwnd); + hy_mt_manager::stop_server(); + } +} + +unsafe fn install_tray_icon(hwnd: HWND) { + let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); + nid.cbSize = std::mem::size_of::() as u32; + nid.hWnd = hwnd; + nid.uID = 1; + nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; + nid.uCallbackMessage = WM_TRAYICON; + nid.hIcon = LoadIconW(ptr::null_mut(), IDI_APPLICATION); + let tip = wide_null("TTREE AI 翻译与 OCR"); + let copy_len = tip.len().min(nid.szTip.len() - 1); + nid.szTip[..copy_len].copy_from_slice(&tip[..copy_len]); + Shell_NotifyIconW(NIM_ADD, &nid); +} + +unsafe fn remove_tray_icon(hwnd: HWND) { + let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); + nid.cbSize = std::mem::size_of::() as u32; + nid.hWnd = hwnd; + nid.uID = 1; + Shell_NotifyIconW(NIM_DELETE, &nid); +} + +unsafe fn show_tray_context_menu(hwnd: HWND) { + let menu = CreatePopupMenu(); + if menu.is_null() { + return; + } + let add = |id: usize, text: &str| { + let w = wide_null(text); + AppendMenuW(menu, MF_STRING, id, w.as_ptr()); + }; + add(ID_TRAY_TRANSLATE, "翻译窗口"); + add(ID_TRAY_OCR, "截图 OCR 翻译"); + add(ID_TRAY_CHAT, "AI 对话"); + AppendMenuW(menu, MF_SEPARATOR, 0, ptr::null()); + add(ID_TRAY_SETTINGS, "偏好设置…"); + AppendMenuW(menu, MF_SEPARATOR, 0, ptr::null()); + add(ID_TRAY_QUIT, "退出 TTREE"); + + let mut pt = POINT { x: 0, y: 0 }; + GetCursorPos(&mut pt); + SetForegroundWindow(hwnd); + TrackPopupMenu( + menu, + TPM_RIGHTBUTTON | TPM_BOTTOMALIGN, + pt.x, + pt.y, + 0, + hwnd, + ptr::null(), + ); + DestroyMenu(menu); +} + +impl Win32AppState { + unsafe fn create_ctrl( + &self, + hinstance: HINSTANCE, + class: &str, + title: &str, + style: u32, + id: usize, + bold: bool, + ) -> HWND { + let cls = wide_null(class); + let txt = wide_null(title); + let ctrl = CreateWindowExW( + 0, + cls.as_ptr(), + txt.as_ptr(), + WS_CHILD | style, + 0, + 0, + 10, + 10, + self.hwnd, + id as HMENU, + hinstance, + ptr::null_mut(), + ); + let font = if bold { + self.font_bold + } else { + self.font_regular + }; + SendMessageW(ctrl, WM_SETFONT, font as WPARAM, 1); + ctrl + } + + unsafe fn build_child_controls(&mut self, hinst: HINSTANCE) { + // 1. 顶部导航栏 + self.btn_tab_trans = + self.create_ctrl(hinst, "BUTTON", "● 翻译", BS_PUSHBUTTON, ID_BTN_TAB_TRANS, true); + self.btn_tab_chat = + self.create_ctrl(hinst, "BUTTON", "AI 对话", BS_PUSHBUTTON, ID_BTN_TAB_CHAT, false); + self.btn_ocr = + self.create_ctrl(hinst, "BUTTON", "截图 OCR", BS_PUSHBUTTON, ID_BTN_OCR, false); + self.btn_pin = + self.create_ctrl(hinst, "BUTTON", "置顶: 关", BS_PUSHBUTTON, ID_BTN_PIN, false); + self.btn_tab_settings = + self.create_ctrl(hinst, "BUTTON", "设置", BS_PUSHBUTTON, ID_BTN_TAB_SETTINGS, false); + + // 2. 翻译面板控件 + self.edit_source = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_WANTRETURN, + ID_EDIT_SOURCE, + false, + ); + self.cmb_source_lang = self.create_ctrl( + hinst, + "COMBOBOX", + "", + CBS_DROPDOWNLIST | CBS_HASSTRINGS | WS_VSCROLL, + ID_CMB_SOURCE_LANG, + false, + ); + for lang in SOURCE_LANGUAGES { + let w = wide_null(lang.label); + SendMessageW(self.cmb_source_lang, CB_ADDSTRING, 0, w.as_ptr() as LPARAM); + } + SendMessageW(self.cmb_source_lang, CB_SETCURSEL, 0, 0); + + self.btn_swap_lang = + self.create_ctrl(hinst, "BUTTON", "⇄", BS_PUSHBUTTON, ID_BTN_SWAP_LANG, true); + + self.cmb_target_lang = self.create_ctrl( + hinst, + "COMBOBOX", + "", + CBS_DROPDOWNLIST | CBS_HASSTRINGS | WS_VSCROLL, + ID_CMB_TARGET_LANG, + false, + ); + for lang in TARGET_LANGUAGES { + let w = wide_null(lang.label); + SendMessageW(self.cmb_target_lang, CB_ADDSTRING, 0, w.as_ptr() as LPARAM); + } + SendMessageW(self.cmb_target_lang, CB_SETCURSEL, 0, 0); + + self.btn_trans_now = + self.create_ctrl(hinst, "BUTTON", "翻译", BS_PUSHBUTTON, ID_BTN_TRANS_NOW, true); + self.btn_clear_trans = + self.create_ctrl(hinst, "BUTTON", "清空", BS_PUSHBUTTON, ID_BTN_CLEAR_TRANS, false); + + self.lbl_web_header = + self.create_ctrl(hinst, "STATIC", "Google / Bing 极速译文", 0, 0, true); + self.btn_copy_web = + self.create_ctrl(hinst, "BUTTON", "复制", BS_PUSHBUTTON, ID_BTN_COPY_WEB, false); + self.edit_web_result = self.create_ctrl( + hinst, + "EDIT", + "译文显示在这里", + WS_BORDER | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY, + ID_EDIT_WEB_RESULT, + false, + ); + + self.lbl_ai_header = self.create_ctrl(hinst, "STATIC", "AI 深度译文", 0, 0, true); + self.btn_copy_ai = + self.create_ctrl(hinst, "BUTTON", "复制", BS_PUSHBUTTON, ID_BTN_COPY_AI, false); + self.edit_ai_result = self.create_ctrl( + hinst, + "EDIT", + "AI 译文显示在这里", + WS_BORDER | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY, + ID_EDIT_AI_RESULT, + false, + ); + + // 3. AI 对话面板控件 + self.cmb_chat_conv = self.create_ctrl( + hinst, + "COMBOBOX", + "", + CBS_DROPDOWNLIST | CBS_HASSTRINGS | WS_VSCROLL, + ID_CMB_CHAT_CONV, + false, + ); + self.btn_chat_new = + self.create_ctrl(hinst, "BUTTON", "+ 新对话", BS_PUSHBUTTON, ID_BTN_CHAT_NEW, false); + self.btn_chat_copy = + self.create_ctrl(hinst, "BUTTON", "复制回复", BS_PUSHBUTTON, ID_BTN_CHAT_COPY, false); + self.btn_chat_clear = + self.create_ctrl(hinst, "BUTTON", "清空历史", BS_PUSHBUTTON, ID_BTN_CHAT_CLEAR, false); + self.edit_chat_history = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY, + ID_EDIT_CHAT_HISTORY, + false, + ); + self.edit_chat_input = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_WANTRETURN, + ID_EDIT_CHAT_INPUT, + false, + ); + self.btn_chat_send = + self.create_ctrl(hinst, "BUTTON", "发送", BS_PUSHBUTTON, ID_BTN_CHAT_SEND, true); + self.btn_chat_stop = + self.create_ctrl(hinst, "BUTTON", "停止", BS_PUSHBUTTON, ID_BTN_CHAT_STOP, false); + + // 4. 设置面板控件 + self.chk_ai_enabled = self.create_ctrl( + hinst, + "BUTTON", + "启用 AI 深度翻译分栏", + BS_AUTOCHECKBOX, + ID_CHK_AI_ENABLED, + true, + ); + self.lbl_ai_backend = self.create_ctrl(hinst, "STATIC", "AI 翻译后端:", 0, 0, false); + self.cmb_ai_backend = self.create_ctrl( + hinst, + "COMBOBOX", + "", + CBS_DROPDOWNLIST | CBS_HASSTRINGS | WS_VSCROLL, + ID_CMB_AI_BACKEND, + false, + ); + for b in AiBackendKind::ALL { + let w = wide_null(b.label()); + SendMessageW(self.cmb_ai_backend, CB_ADDSTRING, 0, w.as_ptr() as LPARAM); + } + + self.lbl_hymt_quant = + self.create_ctrl(hinst, "STATIC", "混元 HY-MT1.5 精度:", 0, 0, false); + self.cmb_hymt_quant = self.create_ctrl( + hinst, + "COMBOBOX", + "", + CBS_DROPDOWNLIST | CBS_HASSTRINGS | WS_VSCROLL, + ID_CMB_HYMT_QUANT, + false, + ); + for q in HyMtQuant::ALL { + let w = wide_null(q.label()); + SendMessageW(self.cmb_hymt_quant, CB_ADDSTRING, 0, w.as_ptr() as LPARAM); + } + self.btn_hymt_check = + self.create_ctrl(hinst, "BUTTON", "硬件检测", BS_PUSHBUTTON, ID_BTN_HYMT_CHECK, false); + self.btn_hymt_run = self.create_ctrl( + hinst, + "BUTTON", + "一键下载并运行", + BS_PUSHBUTTON, + ID_BTN_HYMT_RUN, + true, + ); + self.btn_hymt_stop = + self.create_ctrl(hinst, "BUTTON", "停止服务", BS_PUSHBUTTON, ID_BTN_HYMT_STOP, false); + self.lbl_hymt_status = + self.create_ctrl(hinst, "STATIC", "内置 HY-MT1.5-1.8B 就绪状态: 待检测", 0, 0, false); + + self.lbl_ai_url = self.create_ctrl(hinst, "STATIC", "翻译接口 URL:", 0, 0, false); + self.edit_ai_url = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL, + ID_EDIT_AI_URL, + false, + ); + self.lbl_ai_key = self.create_ctrl(hinst, "STATIC", "翻译 API Key:", 0, 0, false); + self.edit_ai_key = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL | ES_PASSWORD, + ID_EDIT_AI_KEY, + false, + ); + self.lbl_ai_model = self.create_ctrl(hinst, "STATIC", "翻译模型名称:", 0, 0, false); + self.edit_ai_model = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL, + ID_EDIT_AI_MODEL, + false, + ); + self.btn_ai_discover = self.create_ctrl( + hinst, + "BUTTON", + "探测本地", + BS_PUSHBUTTON, + ID_BTN_AI_DISCOVER, + false, + ); + self.lbl_ai_prompt = self.create_ctrl(hinst, "STATIC", "翻译提示词:", 0, 0, false); + self.edit_ai_prompt = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL, + ID_EDIT_AI_PROMPT, + false, + ); + + self.lbl_chat_cfg = + self.create_ctrl(hinst, "STATIC", "AI 对话后端 / URL / Key / 模型:", 0, 0, true); + self.cmb_chat_backend = self.create_ctrl( + hinst, + "COMBOBOX", + "", + CBS_DROPDOWNLIST | CBS_HASSTRINGS | WS_VSCROLL, + ID_CMB_CHAT_BACKEND, + false, + ); + for b in AiBackendKind::ALL { + let w = wide_null(b.label()); + SendMessageW(self.cmb_chat_backend, CB_ADDSTRING, 0, w.as_ptr() as LPARAM); + } + self.btn_chat_copy_trans = self.create_ctrl( + hinst, + "BUTTON", + "同翻译配置", + BS_PUSHBUTTON, + ID_BTN_CHAT_COPY_TRANS, + false, + ); + self.edit_chat_url = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL, + ID_EDIT_CHAT_URL, + false, + ); + self.edit_chat_key = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL | ES_PASSWORD, + ID_EDIT_CHAT_KEY, + false, + ); + self.edit_chat_model = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL, + ID_EDIT_CHAT_MODEL, + false, + ); + + self.lbl_hk_header = self.create_ctrl( + hinst, + "STATIC", + "全局热键 (呼出 / OCR / 对话 / 划词翻译 / 划词对话):", + 0, + 0, + true, + ); + self.edit_hk_toggle = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL, + ID_EDIT_HK_TOGGLE, + false, + ); + self.edit_hk_ocr = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL, + ID_EDIT_HK_OCR, + false, + ); + self.edit_hk_chat = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL, + ID_EDIT_HK_CHAT, + false, + ); + self.edit_hk_sel_trans = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL, + ID_EDIT_HK_SEL_TRANS, + false, + ); + self.edit_hk_sel_chat = self.create_ctrl( + hinst, + "EDIT", + "", + WS_BORDER | ES_AUTOHSCROLL, + ID_EDIT_HK_SEL_CHAT, + false, + ); + self.btn_hk_reset = self.create_ctrl( + hinst, + "BUTTON", + "默认快捷键", + BS_PUSHBUTTON, + ID_BTN_HK_RESET, + false, + ); + + self.lbl_opacity = self.create_ctrl(hinst, "STATIC", "窗口不透明度:", 0, 0, false); + self.cmb_opacity = self.create_ctrl( + hinst, + "COMBOBOX", + "", + CBS_DROPDOWNLIST | CBS_HASSTRINGS, + ID_CMB_OPACITY, + false, + ); + for pct in ["100%", "90%", "80%", "70%", "60%", "50%"] { + let w = wide_null(pct); + SendMessageW(self.cmb_opacity, CB_ADDSTRING, 0, w.as_ptr() as LPARAM); + } + + self.chk_ocr_newline = self.create_ctrl( + hinst, + "BUTTON", + "OCR 自动合并多行换行", + BS_AUTOCHECKBOX, + ID_CHK_OCR_NEWLINE, + false, + ); + self.chk_autostart = self.create_ctrl( + hinst, + "BUTTON", + "开机自动启动", + BS_AUTOCHECKBOX, + ID_CHK_AUTOSTART, + false, + ); + self.btn_save_settings = self.create_ctrl( + hinst, + "BUTTON", + "保存设置", + BS_PUSHBUTTON, + ID_BTN_SAVE_SETTINGS, + true, + ); + self.lbl_settings_status = self.create_ctrl(hinst, "STATIC", "", 0, 0, false); + } + + unsafe fn relayout_children(&self) { + let mut rc = RECT { + left: 0, + top: 0, + right: 480, + bottom: 520, + }; + GetClientRect(self.hwnd, &mut rc); + let cw = (rc.right - rc.left).max(360); + let ch = (rc.bottom - rc.top).max(300); + let pad = 12; + let inner_w = cw - pad * 2; + + // 顶部栏布局 + let top_y = 8; + let btn_h = 28; + MoveWindow(self.btn_tab_trans, pad, top_y, 76, btn_h, 1); + MoveWindow(self.btn_tab_chat, pad + 82, top_y, 76, btn_h, 1); + MoveWindow(self.btn_ocr, cw - pad - 232, top_y, 82, btn_h, 1); + MoveWindow(self.btn_pin, cw - pad - 144, top_y, 76, btn_h, 1); + MoveWindow(self.btn_tab_settings, cw - pad - 62, top_y, 62, btn_h, 1); + + let trans_ctrls = [ + self.edit_source, + self.cmb_source_lang, + self.btn_swap_lang, + self.cmb_target_lang, + self.btn_trans_now, + self.btn_clear_trans, + self.lbl_web_header, + self.btn_copy_web, + self.edit_web_result, + self.lbl_ai_header, + self.btn_copy_ai, + self.edit_ai_result, + ]; + let chat_ctrls = [ + self.cmb_chat_conv, + self.btn_chat_new, + self.btn_chat_copy, + self.btn_chat_clear, + self.edit_chat_history, + self.edit_chat_input, + self.btn_chat_send, + self.btn_chat_stop, + ]; + let settings_ctrls = [ + self.chk_ai_enabled, + self.lbl_ai_backend, + self.cmb_ai_backend, + self.lbl_hymt_quant, + self.cmb_hymt_quant, + self.btn_hymt_check, + self.btn_hymt_run, + self.btn_hymt_stop, + self.lbl_hymt_status, + self.lbl_ai_url, + self.edit_ai_url, + self.lbl_ai_key, + self.edit_ai_key, + self.lbl_ai_model, + self.edit_ai_model, + self.btn_ai_discover, + self.lbl_ai_prompt, + self.edit_ai_prompt, + self.lbl_chat_cfg, + self.cmb_chat_backend, + self.btn_chat_copy_trans, + self.edit_chat_url, + self.edit_chat_key, + self.edit_chat_model, + self.lbl_hk_header, + self.edit_hk_toggle, + self.edit_hk_ocr, + self.edit_hk_chat, + self.edit_hk_sel_trans, + self.edit_hk_sel_chat, + self.btn_hk_reset, + self.lbl_opacity, + self.cmb_opacity, + self.chk_ocr_newline, + self.chk_autostart, + self.btn_save_settings, + self.lbl_settings_status, + ]; + + for &c in &trans_ctrls { + ShowWindow( + c, + if self.active_tab == TabMode::Translate { + SW_SHOW + } else { + SW_HIDE + }, + ); + } + for &c in &chat_ctrls { + ShowWindow( + c, + if self.active_tab == TabMode::Chat { + SW_SHOW + } else { + SW_HIDE + }, + ); + } + for &c in &settings_ctrls { + ShowWindow( + c, + if self.active_tab == TabMode::Settings { + SW_SHOW + } else { + SW_HIDE + }, + ); + } + + let content_top = TOP_BAR_HEIGHT as i32; + match self.active_tab { + TabMode::Translate => { + let ai_enabled = self.settings.ai_translation_for_request().enabled; + let src_h = self.pane_layout.source_height as i32; + let lang_h = self.pane_layout.lang_height as i32; + let mut y = content_top; + + MoveWindow(self.edit_source, pad, y, inner_w, src_h, 1); + y += src_h + 6; + + let lang_y = y + (lang_h - 26).max(0) / 2; + let cmb_w = ((inner_w - 172) / 2).max(90); + MoveWindow(self.cmb_source_lang, pad, lang_y, cmb_w, 220, 1); + MoveWindow(self.btn_swap_lang, pad + cmb_w + 4, lang_y, 32, 26, 1); + MoveWindow( + self.cmb_target_lang, + pad + cmb_w + 40, + lang_y, + cmb_w, + 220, + 1, + ); + MoveWindow(self.btn_trans_now, cw - pad - 122, lang_y, 60, 26, 1); + MoveWindow(self.btn_clear_trans, cw - pad - 58, lang_y, 58, 26, 1); + y += lang_h + 6; + + let remaining_h = (ch - y - pad).max(120); + let web_total_h = if ai_enabled { + (self.pane_layout.web_height as i32).clamp(76, (remaining_h - 86).max(76)) + } else { + remaining_h + }; + + MoveWindow(self.lbl_web_header, pad, y, inner_w - 64, 22, 1); + MoveWindow(self.btn_copy_web, cw - pad - 58, y - 2, 58, 22, 1); + MoveWindow( + self.edit_web_result, + pad, + y + 24, + inner_w, + (web_total_h - 24).max(48), + 1, + ); + y += web_total_h + 8; + + if ai_enabled { + let ai_total_h = (ch - y - pad).max(76); + ShowWindow(self.lbl_ai_header, SW_SHOW); + ShowWindow(self.btn_copy_ai, SW_SHOW); + ShowWindow(self.edit_ai_result, SW_SHOW); + MoveWindow(self.lbl_ai_header, pad, y, inner_w - 64, 22, 1); + MoveWindow(self.btn_copy_ai, cw - pad - 58, y - 2, 58, 22, 1); + MoveWindow( + self.edit_ai_result, + pad, + y + 24, + inner_w, + (ai_total_h - 24).max(48), + 1, + ); + } else { + ShowWindow(self.lbl_ai_header, SW_HIDE); + ShowWindow(self.btn_copy_ai, SW_HIDE); + ShowWindow(self.edit_ai_result, SW_HIDE); + } + } + TabMode::Chat => { + let mut y = content_top; + let cmb_w = (inner_w - 222).max(110); + MoveWindow(self.cmb_chat_conv, pad, y, cmb_w, 220, 1); + MoveWindow(self.btn_chat_new, pad + cmb_w + 6, y, 68, 26, 1); + MoveWindow(self.btn_chat_copy, pad + cmb_w + 78, y, 68, 26, 1); + MoveWindow(self.btn_chat_clear, pad + cmb_w + 150, y, 72, 26, 1); + y += 32; + + let input_h = (self.pane_layout.chat_input_height as i32).clamp(68, 200); + let hist_h = (ch - y - input_h - 46).max(100); + MoveWindow(self.edit_chat_history, pad, y, inner_w, hist_h, 1); + y += hist_h + 6; + + MoveWindow(self.edit_chat_input, pad, y, inner_w, input_h, 1); + y += input_h + 6; + MoveWindow(self.btn_chat_send, cw - pad - 132, y, 64, 28, 1); + MoveWindow(self.btn_chat_stop, cw - pad - 64, y, 64, 28, 1); + } + TabMode::Settings => { + let mut y = content_top; + let row_h = 24; + let gap = 5; + let lbl_w = 132; + let field_x = pad + lbl_w; + let field_w = (inner_w - lbl_w).max(160); + + MoveWindow(self.chk_ai_enabled, pad, y, inner_w, row_h, 1); + y += row_h + gap; + + MoveWindow(self.lbl_ai_backend, pad, y + 3, lbl_w, row_h, 1); + MoveWindow(self.cmb_ai_backend, field_x, y, field_w, 200, 1); + y += row_h + gap; + + MoveWindow(self.lbl_hymt_quant, pad, y + 3, lbl_w, row_h, 1); + MoveWindow(self.cmb_hymt_quant, field_x, y, field_w, 220, 1); + y += row_h + gap; + + MoveWindow(self.btn_hymt_check, pad, y, 86, row_h, 1); + MoveWindow(self.btn_hymt_run, pad + 92, y, 132, row_h, 1); + MoveWindow(self.btn_hymt_stop, pad + 230, y, 86, row_h, 1); + y += row_h + 3; + MoveWindow(self.lbl_hymt_status, pad, y, inner_w, 20, 1); + y += 22; + + MoveWindow(self.lbl_ai_url, pad, y + 3, lbl_w, row_h, 1); + MoveWindow(self.edit_ai_url, field_x, y, field_w, row_h, 1); + y += row_h + gap; + + MoveWindow(self.lbl_ai_key, pad, y + 3, lbl_w, row_h, 1); + MoveWindow(self.edit_ai_key, field_x, y, field_w, row_h, 1); + y += row_h + gap; + + MoveWindow(self.lbl_ai_model, pad, y + 3, lbl_w, row_h, 1); + MoveWindow(self.edit_ai_model, field_x, y, field_w - 76, row_h, 1); + MoveWindow(self.btn_ai_discover, cw - pad - 72, y, 72, row_h, 1); + y += row_h + gap; + + MoveWindow(self.lbl_ai_prompt, pad, y + 3, lbl_w, row_h, 1); + MoveWindow(self.edit_ai_prompt, field_x, y, field_w, row_h, 1); + y += row_h + 8; + + MoveWindow(self.lbl_chat_cfg, pad, y + 3, inner_w - 90, row_h, 1); + MoveWindow(self.btn_chat_copy_trans, cw - pad - 86, y, 86, row_h, 1); + y += row_h + 4; + + let half_w = (inner_w - 6) / 2; + MoveWindow(self.cmb_chat_backend, pad, y, half_w, 200, 1); + MoveWindow(self.edit_chat_model, pad + half_w + 6, y, half_w, row_h, 1); + y += row_h + 4; + MoveWindow(self.edit_chat_url, pad, y, half_w, row_h, 1); + MoveWindow(self.edit_chat_key, pad + half_w + 6, y, half_w, row_h, 1); + y += row_h + 8; + + MoveWindow(self.lbl_hk_header, pad, y + 3, inner_w - 88, row_h, 1); + MoveWindow(self.btn_hk_reset, cw - pad - 84, y, 84, row_h, 1); + y += row_h + 4; + + let third_w = (inner_w - 8) / 3; + MoveWindow(self.edit_hk_toggle, pad, y, third_w, row_h, 1); + MoveWindow(self.edit_hk_ocr, pad + third_w + 4, y, third_w, row_h, 1); + MoveWindow( + self.edit_hk_chat, + pad + (third_w + 4) * 2, + y, + third_w, + row_h, + 1, + ); + y += row_h + 4; + MoveWindow(self.edit_hk_sel_trans, pad, y, half_w, row_h, 1); + MoveWindow(self.edit_hk_sel_chat, pad + half_w + 6, y, half_w, row_h, 1); + y += row_h + 8; + + MoveWindow(self.lbl_opacity, pad, y + 3, 98, row_h, 1); + MoveWindow(self.cmb_opacity, pad + 100, y, 76, 180, 1); + MoveWindow(self.chk_ocr_newline, pad + 186, y, 165, row_h, 1); + MoveWindow(self.chk_autostart, pad + 356, y, 115, row_h, 1); + y += row_h + 8; + + MoveWindow(self.btn_save_settings, pad, y, 96, 28, 1); + MoveWindow(self.lbl_settings_status, pad + 104, y + 4, inner_w - 104, 24, 1); + } + } + InvalidateRect(self.hwnd, ptr::null(), 1); + } + + unsafe fn switch_tab(&mut self, tab: TabMode) { + if self.active_tab == tab { + return; + } + if self.active_tab == TabMode::Settings && tab != TabMode::Settings { + self.set_pinned(self.pin_before_settings); + } else if tab == TabMode::Settings && self.active_tab != TabMode::Settings { + self.pin_before_settings = self.pinned; + self.set_pinned(true); + self.populate_settings_fields(); + } + self.active_tab = tab; + + set_hwnd_text( + self.btn_tab_trans, + if tab == TabMode::Translate { + "● 翻译" + } else { + "翻译" + }, + ); + set_hwnd_text( + self.btn_tab_chat, + if tab == TabMode::Chat { + "● AI 对话" + } else { + "AI 对话" + }, + ); + set_hwnd_text( + self.btn_tab_settings, + if tab == TabMode::Settings { + "● 设置" + } else { + "设置" + }, + ); + + self.relayout_children(); + match tab { + TabMode::Translate => { + SetFocus(self.edit_source); + } + TabMode::Chat => { + SetFocus(self.edit_chat_input); + } + TabMode::Settings => {} + } + } + + unsafe fn set_pinned(&mut self, pin: bool) { + self.pinned = pin; + let insert_after = if pin { HWND_TOPMOST } else { HWND_NOTOPMOST }; + SetWindowPos( + self.hwnd, + insert_after, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + set_hwnd_text(self.btn_pin, if pin { "置顶: 开" } else { "置顶: 关" }); + } + + unsafe fn show_and_focus(&mut self) { + ShowWindow(self.hwnd, SW_RESTORE); + ShowWindow(self.hwnd, SW_SHOW); + SetForegroundWindow(self.hwnd); + match self.active_tab { + TabMode::Translate => { + SetFocus(self.edit_source); + } + TabMode::Chat => { + SetFocus(self.edit_chat_input); + } + TabMode::Settings => {} + } + } + + unsafe fn hide_window(&mut self) { + self.web_cancel.cancel_current(); + self.ai_cancel.cancel_current(); + ShowWindow(self.hwnd, SW_HIDE); + } + + fn apply_shortcuts(&mut self, cfg: &ShortcutConfig) -> Result<(), String> { + let parsed = parse_shortcut_config(cfg).map_err(|e| e.to_string())?; + for hk in self.registered_hotkeys.drain(..) { + let _ = self.hotkey_mgr.unregister(hk); + } + for hk in parsed.unique_hotkeys() { + self.hotkey_mgr + .register(hk) + .map_err(|e| format!("注册全局快捷键失败: {e}"))?; + self.registered_hotkeys.push(hk); + } + self.parsed_shortcuts = parsed; + Ok(()) + } + + unsafe fn handle_hotkey_action(&mut self, action: ShortcutAction) { + match action { + ShortcutAction::Toggle => { + if IsWindowVisible(self.hwnd) != 0 && !self.pinned { + self.hide_window(); + } else { + self.switch_tab(TabMode::Translate); + self.show_and_focus(); + } + } + ShortcutAction::Ocr => { + self.trigger_ocr(); + } + ShortcutAction::AiDialog => { + self.switch_tab(TabMode::Chat); + self.show_and_focus(); + } + ShortcutAction::SelectionTranslate => { + if let Ok(Some(selected)) = capture_selected_text() { + self.switch_tab(TabMode::Translate); + self.show_and_focus(); + self.suppress_source_change = true; + set_hwnd_text(self.edit_source, &selected); + self.suppress_source_change = false; + self.start_translation(); + } else if IsWindowVisible(self.hwnd) != 0 && !self.pinned { + self.hide_window(); + } else { + self.switch_tab(TabMode::Translate); + self.show_and_focus(); + } + } + ShortcutAction::SelectionAiDialog => { + if let Ok(Some(selected)) = capture_selected_text() { + self.switch_tab(TabMode::Chat); + self.show_and_focus(); + set_hwnd_text(self.edit_chat_input, &selected); + self.send_chat_from_input(); + } else { + self.switch_tab(TabMode::Chat); + self.show_and_focus(); + } + } + } + } + + unsafe fn start_translation(&mut self) { + self.pending_auto_translate = None; + let text = get_hwnd_text(self.edit_source); + let trimmed = text.trim().to_string(); + if trimmed.is_empty() { + self.web_cancel.cancel_current(); + self.ai_cancel.cancel_current(); + set_hwnd_text(self.edit_web_result, "译文显示在这里"); + set_hwnd_text(self.edit_ai_result, "AI 译文显示在这里"); + return; + } + + self.translate_seq = self.translate_seq.wrapping_add(1); + let seq = self.translate_seq; + + let src_code = SOURCE_LANGUAGES + .get(self.source_lang_idx) + .map(|l| l.code) + .unwrap_or("auto"); + let tgt_code = TARGET_LANGUAGES + .get(self.target_lang_idx) + .map(|l| l.code) + .unwrap_or(ADAPTIVE_TARGET); + let (resolved_src, resolved_tgt) = resolve_language_pair(src_code, tgt_code, &trimmed); + + set_hwnd_text(self.edit_web_result, "翻译中…"); + let web_token = self.web_cancel.next(); + let tx_web = self.ui_tx.clone(); + let text_for_web = trimmed.clone(); + let src_for_web = resolved_src.to_string(); + let tgt_for_web = resolved_tgt.to_string(); + self.rt.spawn(async move { + let res = + ttree_translate::translate_web(&text_for_web, &src_for_web, &tgt_for_web, &web_token) + .await; + let _ = tx_web.send(UiEvent::WebTranslateDone { seq, result: res }); + }); + + let ai_cfg = self.settings.ai_translation_for_request(); + if ai_cfg.enabled { + self.ai_stream_buf.clear(); + set_hwnd_text(self.edit_ai_result, "AI 翻译中…"); + let ai_token = self.ai_cancel.next(); + let tx_ai = self.ui_tx.clone(); + let tx_ai_done = self.ui_tx.clone(); + let src_for_ai = resolved_src.to_string(); + let tgt_for_ai = resolved_tgt.to_string(); + self.rt.spawn(async move { + let res = ttree_ai::stream_translate( + &ai_cfg, + &trimmed, + &src_for_ai, + &tgt_for_ai, + &ai_token, + |delta| { + let _ = tx_ai.send(UiEvent::AiTranslateDelta { + seq, + delta: delta.to_string(), + }); + }, + ) + .await; + let _ = tx_ai_done.send(UiEvent::AiTranslateDone { seq, result: res }); + }); + } + } + + unsafe fn trigger_ocr(&mut self) { + if self.ocr_in_flight { + return; + } + self.ocr_in_flight = true; + ShowWindow(self.hwnd, SW_HIDE); + let tx = self.ui_tx.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(120)); + let res = ttree_ocr::capture_and_recognize(); + let _ = tx.send(UiEvent::OcrDone(res)); + }); + } + + unsafe fn refresh_chat_controls(&mut self) { + SendMessageW(self.cmb_chat_conv, CB_RESETCONTENT, 0, 0); + if self.conversations.is_empty() { + let w = wide_null("当前新对话"); + SendMessageW(self.cmb_chat_conv, CB_ADDSTRING, 0, w.as_ptr() as LPARAM); + SendMessageW(self.cmb_chat_conv, CB_SETCURSEL, 0, 0); + } else { + for (idx, conv) in self.conversations.iter().enumerate() { + let title = if conv.summary.trim().is_empty() { + format!("对话 #{}", idx + 1) + } else { + format!("#{}: {}", idx + 1, conv.summary) + }; + let w = wide_null(&title); + SendMessageW(self.cmb_chat_conv, CB_ADDSTRING, 0, w.as_ptr() as LPARAM); + } + let sel = self.active_conv_idx.min(self.conversations.len() - 1); + SendMessageW(self.cmb_chat_conv, CB_SETCURSEL, sel, 0); + } + self.render_chat_transcript(); + } + + unsafe fn render_chat_transcript(&self) { + let mut out = String::new(); + for msg in &self.current_messages { + let role_label = if msg.role == "user" { "【我】" } else { "【AI】" }; + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(role_label); + out.push('\n'); + out.push_str(&msg.content); + } + if self.chat_streaming { + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str("【AI】\n"); + if self.streaming_reply.is_empty() { + out.push_str("思考中…"); + } else { + out.push_str(&self.streaming_reply); + } + } + if out.is_empty() { + out.push_str("在下方输入问题,开始多轮 AI 对话…"); + } + set_hwnd_text(self.edit_chat_history, &out); + let len = GetWindowTextLengthW(self.edit_chat_history) as usize; + SendMessageW(self.edit_chat_history, EM_SETSEL, len, len as LPARAM); + SendMessageW(self.edit_chat_history, EM_SCROLLCARET, 0, 0); + } + + unsafe fn send_chat_from_input(&mut self) { + if self.chat_streaming { + return; + } + let text = get_hwnd_text(self.edit_chat_input).trim().to_string(); + if text.is_empty() { + return; + } + set_hwnd_text(self.edit_chat_input, ""); + + let user_msg = ChatMessage::user(text); + self.current_messages.push(user_msg); + self.streaming_reply.clear(); + self.chat_streaming = true; + self.render_chat_transcript(); + + self.chat_seq = self.chat_seq.wrapping_add(1); + let seq = self.chat_seq; + let token = self.chat_cancel.next(); + let cfg = self.settings.chat_ai_for_request(); + let context = recent_chat_context(&self.current_messages); + let tx = self.ui_tx.clone(); + let tx_done = self.ui_tx.clone(); + + self.rt.spawn(async move { + let res = ttree_ai::stream_chat(&cfg, &context, &token, |delta| { + let _ = tx.send(UiEvent::ChatDelta { + seq, + delta: delta.to_string(), + }); + }) + .await; + let _ = tx_done.send(UiEvent::ChatDone { seq, result: res }); + }); + } + + unsafe fn populate_settings_fields(&self) { + let ai_cfg = self.settings.ai_translation_for_request(); + let chat_cfg = self.settings.chat_ai_for_request(); + let hk_cfg = self.settings.shortcuts(); + let app_cfg = self.settings.appearance(); + + SendMessageW( + self.chk_ai_enabled, + BM_SETCHECK, + if ai_cfg.enabled { + BST_CHECKED + } else { + BST_UNCHECKED + }, + 0, + ); + let b_idx = AiBackendKind::ALL + .iter() + .position(|&b| b == ai_cfg.backend) + .unwrap_or(0); + SendMessageW(self.cmb_ai_backend, CB_SETCURSEL, b_idx, 0); + + let q_idx = HyMtQuant::ALL + .iter() + .position(|&q| q == ai_cfg.hy_mt_quant) + .unwrap_or(6); + SendMessageW(self.cmb_hymt_quant, CB_SETCURSEL, q_idx, 0); + + set_hwnd_text(self.edit_ai_url, &ai_cfg.base_url); + set_hwnd_text(self.edit_ai_key, &ai_cfg.api_key); + set_hwnd_text(self.edit_ai_model, &ai_cfg.model); + set_hwnd_text(self.edit_ai_prompt, &ai_cfg.translate_prompt); + + let cb_idx = AiBackendKind::ALL + .iter() + .position(|&b| b == chat_cfg.backend) + .unwrap_or(0); + SendMessageW(self.cmb_chat_backend, CB_SETCURSEL, cb_idx, 0); + set_hwnd_text(self.edit_chat_url, &chat_cfg.base_url); + set_hwnd_text(self.edit_chat_key, &chat_cfg.api_key); + set_hwnd_text(self.edit_chat_model, &chat_cfg.model); + + set_hwnd_text(self.edit_hk_toggle, &hk_cfg.toggle); + set_hwnd_text(self.edit_hk_ocr, &hk_cfg.ocr); + set_hwnd_text(self.edit_hk_chat, &hk_cfg.ai_dialog); + set_hwnd_text(self.edit_hk_sel_trans, &hk_cfg.selection_translate); + set_hwnd_text(self.edit_hk_sel_chat, &hk_cfg.selection_ai_dialog); + + let op_idx = if app_cfg.panel_opacity >= 0.95 { + 0 + } else if app_cfg.panel_opacity >= 0.85 { + 1 + } else if app_cfg.panel_opacity >= 0.75 { + 2 + } else if app_cfg.panel_opacity >= 0.65 { + 3 + } else if app_cfg.panel_opacity >= 0.55 { + 4 + } else { + 5 + }; + SendMessageW(self.cmb_opacity, CB_SETCURSEL, op_idx, 0); + + SendMessageW( + self.chk_ocr_newline, + BM_SETCHECK, + if app_cfg.ocr_strip_newlines { + BST_CHECKED + } else { + BST_UNCHECKED + }, + 0, + ); + SendMessageW( + self.chk_autostart, + BM_SETCHECK, + if autostart::is_enabled() { + BST_CHECKED + } else { + BST_UNCHECKED + }, + 0, + ); + } + + unsafe fn save_settings_from_ui(&mut self) { + let ai_enabled = SendMessageW(self.chk_ai_enabled, BM_GETCHECK, 0, 0) as usize == BST_CHECKED; + let b_idx = (SendMessageW(self.cmb_ai_backend, CB_GETCURSEL, 0, 0) as usize) + .min(AiBackendKind::ALL.len() - 1); + let q_idx = (SendMessageW(self.cmb_hymt_quant, CB_GETCURSEL, 0, 0) as usize) + .min(HyMtQuant::ALL.len() - 1); + + let ai_cfg = AiTranslationConfig { + enabled: ai_enabled, + backend: AiBackendKind::ALL[b_idx], + hy_mt_quant: HyMtQuant::ALL[q_idx], + base_url: get_hwnd_text(self.edit_ai_url), + api_key: get_hwnd_text(self.edit_ai_key), + has_api_key: !get_hwnd_text(self.edit_ai_key).trim().is_empty(), + clear_api_key: get_hwnd_text(self.edit_ai_key).trim().is_empty(), + model: get_hwnd_text(self.edit_ai_model), + gguf_model_path: String::new(), + gguf_tokenizer_path: String::new(), + translate_prompt: get_hwnd_text(self.edit_ai_prompt), + }; + if let Err(e) = self.settings.save_ai_translation(&ai_cfg) { + set_hwnd_text(self.lbl_settings_status, &format!("保存翻译设置失败: {e}")); + return; + } + + let cb_idx = (SendMessageW(self.cmb_chat_backend, CB_GETCURSEL, 0, 0) as usize) + .min(AiBackendKind::ALL.len() - 1); + let chat_cfg = ChatAiConfig { + backend: AiBackendKind::ALL[cb_idx], + hy_mt_quant: HyMtQuant::ALL[q_idx], + base_url: get_hwnd_text(self.edit_chat_url), + api_key: get_hwnd_text(self.edit_chat_key), + has_api_key: !get_hwnd_text(self.edit_chat_key).trim().is_empty(), + clear_api_key: get_hwnd_text(self.edit_chat_key).trim().is_empty(), + model: get_hwnd_text(self.edit_chat_model), + gguf_model_path: String::new(), + gguf_tokenizer_path: String::new(), + chat_prompt: self.settings.chat_ai_for_request().chat_prompt, + }; + if let Err(e) = self.settings.save_chat_ai(&chat_cfg) { + set_hwnd_text(self.lbl_settings_status, &format!("保存对话设置失败: {e}")); + return; + } + + let hk_cfg = ShortcutConfig { + toggle: get_hwnd_text(self.edit_hk_toggle), + ocr: get_hwnd_text(self.edit_hk_ocr), + ai_dialog: get_hwnd_text(self.edit_hk_chat), + selection_translate: get_hwnd_text(self.edit_hk_sel_trans), + selection_ai_dialog: get_hwnd_text(self.edit_hk_sel_chat), + }; + if let Err(e) = self.apply_shortcuts(&hk_cfg) { + set_hwnd_text(self.lbl_settings_status, &e); + return; + } + if let Err(e) = self.settings.save_shortcuts(&hk_cfg) { + set_hwnd_text(self.lbl_settings_status, &format!("保存快捷键失败: {e}")); + return; + } + + let op_idx = SendMessageW(self.cmb_opacity, CB_GETCURSEL, 0, 0) as usize; + let opacity = match op_idx { + 0 => 1.0, + 1 => 0.9, + 2 => 0.8, + 3 => 0.7, + 4 => 0.6, + _ => 0.5, + }; + let ocr_strip = + SendMessageW(self.chk_ocr_newline, BM_GETCHECK, 0, 0) as usize == BST_CHECKED; + let app_cfg = AppearanceConfig { + panel_opacity: opacity, + ocr_strip_newlines: ocr_strip, + }; + let _ = self.settings.save_appearance(app_cfg); + let alpha = (opacity * 255.0).round() as u8; + SetLayeredWindowAttributes(self.hwnd, 0, alpha, LWA_ALPHA); + + let want_autostart = + SendMessageW(self.chk_autostart, BM_GETCHECK, 0, 0) as usize == BST_CHECKED; + let _ = autostart::set_enabled(want_autostart); + + set_hwnd_text(self.lbl_settings_status, "✓ 设置已保存并立即生效"); + } + + unsafe fn handle_ui_event(&mut self, ev: UiEvent) { + match ev { + UiEvent::WebTranslateDone { seq, result } => { + if seq != self.translate_seq { + return; + } + match result { + Ok(text) => { + self.web_result_text = text.clone(); + set_hwnd_text( + self.edit_web_result, + if text.is_empty() { + "译文显示在这里" + } else { + &text + }, + ); + } + Err(err) => { + set_hwnd_text(self.edit_web_result, &format!("翻译失败: {err}")); + } + } + } + UiEvent::AiTranslateDelta { seq, delta } => { + if seq != self.translate_seq { + return; + } + self.ai_stream_buf.push_str(&delta); + set_hwnd_text(self.edit_ai_result, &self.ai_stream_buf); + } + UiEvent::AiTranslateDone { seq, result } => { + if seq != self.translate_seq { + return; + } + if let Err(err) = result { + if self.ai_stream_buf.is_empty() { + set_hwnd_text(self.edit_ai_result, &format!("AI 翻译失败: {err}")); + } + } + } + UiEvent::ChatDelta { seq, delta } => { + if seq != self.chat_seq { + return; + } + self.streaming_reply.push_str(&delta); + self.render_chat_transcript(); + } + UiEvent::ChatDone { seq, result } => { + if seq != self.chat_seq { + return; + } + self.chat_streaming = false; + match result { + Ok(()) => { + let reply = std::mem::take(&mut self.streaming_reply); + if !reply.trim().is_empty() { + if let Some(user_last) = self + .current_messages + .iter() + .rev() + .find(|m| m.role == "user") + .cloned() + { + let assistant_msg = ChatMessage::assistant(reply); + self.current_messages.push(assistant_msg.clone()); + let conv_id = self + .conversations + .get(self.active_conv_idx) + .map(|c| c.id); + let _ = self + .history + .append_round(conv_id, &[user_last, assistant_msg]); + self.conversations = + self.history.read_conversations().unwrap_or_default(); + self.active_conv_idx = self.conversations.len().saturating_sub(1); + } + } + } + Err(err) => { + self.current_messages + .push(ChatMessage::assistant(format!("请求失败: {err}"))); + } + } + self.refresh_chat_controls(); + } + UiEvent::OcrDone(res) => { + self.ocr_in_flight = false; + match res { + Ok(OcrOutcome::Cancelled) | Ok(OcrOutcome::Busy) => {} + Ok(OcrOutcome::EmptyText) => { + self.switch_tab(TabMode::Translate); + self.show_and_focus(); + set_hwnd_text( + self.edit_web_result, + "所选截图区域未检测到可识别的文字,请重新框选。", + ); + } + Ok(OcrOutcome::PermissionDenied(msg)) => { + self.switch_tab(TabMode::Translate); + self.show_and_focus(); + set_hwnd_text(self.edit_web_result, &msg); + } + Ok(OcrOutcome::Text(raw_text)) => { + let text = if self.settings.appearance().ocr_strip_newlines { + normalize_ocr_newlines(&raw_text) + } else { + raw_text.trim().to_string() + }; + self.switch_tab(TabMode::Translate); + self.show_and_focus(); + self.suppress_source_change = true; + set_hwnd_text(self.edit_source, &text); + self.suppress_source_change = false; + self.start_translation(); + } + Err(err) => { + self.switch_tab(TabMode::Translate); + self.show_and_focus(); + set_hwnd_text(self.edit_web_result, &format!("截图 OCR 失败: {err}")); + } + } + } + UiEvent::ModelsDiscovered(res) => match res { + Ok(models) if !models.is_empty() => { + set_hwnd_text(self.edit_ai_model, &models[0]); + set_hwnd_text( + self.lbl_settings_status, + &format!("已发现 {} 个本地模型: {}", models.len(), models.join(", ")), + ); + } + Ok(_) => { + set_hwnd_text(self.lbl_settings_status, "未探测到本地已加载模型"); + } + Err(e) => { + set_hwnd_text(self.lbl_settings_status, &format!("探测模型失败: {e}")); + } + }, + UiEvent::HyMtProgress(prog) => { + let mb_done = prog.downloaded_bytes as f64 / (1024.0 * 1024.0); + let mb_total = (prog.total_bytes.max(1)) as f64 / (1024.0 * 1024.0); + let pct = ((prog.downloaded_bytes as f64 / (prog.total_bytes.max(1) as f64)) + * 100.0) + .clamp(0.0, 100.0); + set_hwnd_text( + self.lbl_hymt_status, + &format!( + "{}: {:.1}% ({:.0}/{:.0} MB, {:.1} MB/s)", + prog.stage, pct, mb_done, mb_total, prog.speed_mbps + ), + ); + } + UiEvent::HyMtReady(res) => match res { + Ok(url) => { + set_hwnd_text( + self.lbl_hymt_status, + &format!("✓ HY-MT1.5-1.8B 本地推理服务运行中 ({url})"), + ); + } + Err(err) => { + set_hwnd_text(self.lbl_hymt_status, &format!("HY-MT1.5 启动失败: {err}")); + } + }, + UiEvent::HyMtCompatReady(rep) => { + set_hwnd_text( + self.lbl_hymt_status, + &format!( + "{} | 内存 {:.1}GB | 磁盘可用 {:.1}GB | 推荐精度: {}", + rep.gpu_backend, + rep.total_ram_gb, + rep.free_disk_gb, + rep.recommended_quant.code() + ), + ); + } + } + } + + unsafe fn poll_tick(&mut self) { + while let Ok(hk_ev) = GlobalHotKeyEvent::receiver().try_recv() { + if hk_ev.state == HotKeyState::Pressed { + if let Some(action) = self.parsed_shortcuts.action_for_id(hk_ev.id) { + self.handle_hotkey_action(action); + } + } + } + while let Ok(ui_ev) = self.ui_rx.try_recv() { + self.handle_ui_event(ui_ev); + } + + let now = Instant::now(); + if let Some(deadline) = self.pending_auto_translate { + if now >= deadline { + self.pending_auto_translate = None; + self.start_translation(); + } + } + if let Some((deadline, w, _h)) = self.pending_resize_save { + if now >= deadline { + self.pending_resize_save = None; + self.pane_layout.width = w.clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH); + self.settings.save_pane_layout(self.pane_layout); + } + } + } +} + +unsafe extern "system" fn main_wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_TIMER => { + if wparam == TIMER_ID_POLL { + with_state(|st| st.poll_tick()); + } + 0 + } + WM_ACTIVATE => { + let active_state = wparam & 0xFFFF; + if active_state == WA_INACTIVE { + with_state(|st| { + if !st.pinned && !st.ocr_in_flight && st.active_tab != TabMode::Settings { + st.hide_window(); + } + }); + } + 0 + } + WM_SIZE => { + let w = (lparam as u32 & 0xFFFF) as i16 as i32; + let h = ((lparam as u32 >> 16) & 0xFFFF) as i16 as i32; + if w > 0 && h > 0 { + with_state(|st| { + st.relayout_children(); + st.pending_resize_save = Some(( + Instant::now() + RESIZE_SAVE_DEBOUNCE, + w as f64, + h as f64, + )); + }); + } + 0 + } + WM_GETMINMAXINFO => { + let mmi = lparam as *mut MINMAXINFO; + if !mmi.is_null() { + (*mmi).ptMinTrackSize.x = WINDOW_MIN_WIDTH as i32; + (*mmi).ptMinTrackSize.y = WINDOW_MIN_HEIGHT as i32; + (*mmi).ptMaxTrackSize.x = WINDOW_MAX_WIDTH as i32; + (*mmi).ptMaxTrackSize.y = WINDOW_MAX_HEIGHT as i32; + } + 0 + } + WM_CTLCOLOREDIT | WM_CTLCOLORLISTBOX => { + let hdc = wparam as HDC; + SetTextColor(hdc, rgb(235, 238, 245)); + SetBkColor(hdc, rgb(34, 37, 46)); + SetBkMode(hdc, OPAQUE); + let mut brush = ptr::null_mut(); + with_state(|st| brush = st.brush_surface); + brush as LRESULT + } + WM_CTLCOLORSTATIC | WM_CTLCOLORBTN => { + let hdc = wparam as HDC; + SetTextColor(hdc, rgb(218, 224, 236)); + SetBkColor(hdc, rgb(24, 26, 32)); + SetBkMode(hdc, TRANSPARENT); + let mut brush = ptr::null_mut(); + with_state(|st| brush = st.brush_bg); + brush as LRESULT + } + WM_LBUTTONDOWN => { + let y = ((lparam as u32 >> 16) & 0xFFFF) as i16 as i32; + with_state(|st| { + if st.active_tab == TabMode::Translate { + let s1 = (TOP_BAR_HEIGHT + st.pane_layout.source_height) as i32; + let s2 = s1 + 6 + (st.pane_layout.lang_height as i32) + 6 + (st.pane_layout.web_height as i32); + if (y - s1).abs() <= 6 { + st.dragging_splitter = Some((1, y)); + SetCapture(hwnd); + SetCursor(LoadCursorW(ptr::null_mut(), IDC_SIZENS)); + } else if (y - s2).abs() <= 6 { + st.dragging_splitter = Some((2, y)); + SetCapture(hwnd); + SetCursor(LoadCursorW(ptr::null_mut(), IDC_SIZENS)); + } + } + }); + 0 + } + WM_MOUSEMOVE => { + let y = ((lparam as u32 >> 16) & 0xFFFF) as i16 as i32; + with_state(|st| { + if let Some((split_id, last_y)) = st.dragging_splitter { + let delta = (y - last_y) as f64; + if delta.abs() >= 1.0 { + if split_id == 1 { + st.pane_layout.source_height = + (st.pane_layout.source_height + delta).clamp(68.0, 240.0); + } else if split_id == 2 { + st.pane_layout.web_height = + (st.pane_layout.web_height + delta).clamp(76.0, 400.0); + } + st.dragging_splitter = Some((split_id, y)); + st.relayout_children(); + } + } + }); + 0 + } + WM_LBUTTONUP => { + with_state(|st| { + if st.dragging_splitter.take().is_some() { + ReleaseCapture(); + let _ = st.settings.save_pane_layout(st.pane_layout); + } + }); + 0 + } + WM_COMMAND => { + let id = wparam & 0xFFFF; + let code = ((wparam >> 16) & 0xFFFF) as u16; + with_state(|st| handle_wm_command(st, id, code)); + 0 + } + WM_TRAYICON => { + let mouse_msg = (lparam as u32) & 0xFFFF; + if mouse_msg == WM_LBUTTONUP { + with_state(|st| { + if IsWindowVisible(st.hwnd) != 0 { + st.hide_window(); + } else { + st.show_and_focus(); + } + }); + } else if mouse_msg == WM_RBUTTONUP { + show_tray_context_menu(hwnd); + } + 0 + } + WM_CLOSE => { + with_state(|st| st.hide_window()); + 0 + } + WM_DESTROY => { + PostQuitMessage(0); + 0 + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +unsafe fn handle_wm_command(st: &mut Win32AppState, id: usize, code: u16) { + match id { + ID_BTN_TAB_TRANS if code == BN_CLICKED => st.switch_tab(TabMode::Translate), + ID_BTN_TAB_CHAT if code == BN_CLICKED => st.switch_tab(TabMode::Chat), + ID_BTN_TAB_SETTINGS if code == BN_CLICKED => st.switch_tab(TabMode::Settings), + ID_BTN_OCR if code == BN_CLICKED => st.trigger_ocr(), + ID_BTN_PIN if code == BN_CLICKED => st.set_pinned(!st.pinned), + + ID_EDIT_SOURCE if code == EN_CHANGE => { + if !st.suppress_source_change { + st.pending_auto_translate = Some(Instant::now() + AUTO_TRANSLATE_DEBOUNCE); + } + } + ID_CMB_SOURCE_LANG if code == CBN_SELCHANGE => { + let idx = SendMessageW(st.cmb_source_lang, CB_GETCURSEL, 0, 0) as usize; + st.source_lang_idx = idx.min(SOURCE_LANGUAGES.len() - 1); + st.start_translation(); + } + ID_CMB_TARGET_LANG if code == CBN_SELCHANGE => { + let idx = SendMessageW(st.cmb_target_lang, CB_GETCURSEL, 0, 0) as usize; + st.target_lang_idx = idx.min(TARGET_LANGUAGES.len() - 1); + st.start_translation(); + } + ID_BTN_SWAP_LANG if code == BN_CLICKED => { + let src_code = SOURCE_LANGUAGES[st.source_lang_idx].code; + let tgt_code = TARGET_LANGUAGES[st.target_lang_idx].code; + if src_code != "auto" && tgt_code != ADAPTIVE_TARGET { + if let (Some(new_src), Some(new_tgt)) = ( + SOURCE_LANGUAGES.iter().position(|l| l.code == tgt_code), + TARGET_LANGUAGES.iter().position(|l| l.code == src_code), + ) { + st.source_lang_idx = new_src; + st.target_lang_idx = new_tgt; + SendMessageW(st.cmb_source_lang, CB_SETCURSEL, new_src, 0); + SendMessageW(st.cmb_target_lang, CB_SETCURSEL, new_tgt, 0); + st.start_translation(); + } + } + } + ID_BTN_TRANS_NOW if code == BN_CLICKED => st.start_translation(), + ID_BTN_CLEAR_TRANS if code == BN_CLICKED => { + st.suppress_source_change = true; + set_hwnd_text(st.edit_source, ""); + st.suppress_source_change = false; + st.web_cancel.cancel_current(); + st.ai_cancel.cancel_current(); + set_hwnd_text(st.edit_web_result, "译文显示在这里"); + set_hwnd_text(st.edit_ai_result, "AI 译文显示在这里"); + } + ID_BTN_COPY_WEB if code == BN_CLICKED => { + copy_text_to_clipboard(st.hwnd, &st.web_result_text); + } + ID_BTN_COPY_AI if code == BN_CLICKED => { + copy_text_to_clipboard(st.hwnd, &st.ai_stream_buf); + } + + ID_CMB_CHAT_CONV if code == CBN_SELCHANGE => { + let idx = SendMessageW(st.cmb_chat_conv, CB_GETCURSEL, 0, 0) as usize; + if idx < st.conversations.len() { + st.active_conv_idx = idx; + st.current_messages = st.conversations[idx].messages.clone(); + st.render_chat_transcript(); + } + } + ID_BTN_CHAT_NEW if code == BN_CLICKED => { + st.chat_cancel.cancel_current(); + st.chat_streaming = false; + st.current_messages.clear(); + let _ = st.history.start_new_conversation(); + st.conversations = st.history.read_conversations().unwrap_or_default(); + st.active_conv_idx = st.conversations.len().saturating_sub(1); + st.refresh_chat_controls(); + } + ID_BTN_CHAT_COPY if code == BN_CLICKED => { + if let Some(last_ai) = st + .current_messages + .iter() + .rev() + .find(|m| m.role == "assistant") + { + copy_text_to_clipboard(st.hwnd, &last_ai.content); + } + } + ID_BTN_CHAT_CLEAR if code == BN_CLICKED => { + st.chat_cancel.cancel_current(); + st.chat_streaming = false; + st.current_messages.clear(); + let _ = st.history.clear(); + st.conversations.clear(); + st.active_conv_idx = 0; + st.refresh_chat_controls(); + } + ID_BTN_CHAT_SEND if code == BN_CLICKED => st.send_chat_from_input(), + ID_BTN_CHAT_STOP if code == BN_CLICKED => { + st.chat_cancel.cancel_current(); + st.chat_streaming = false; + st.render_chat_transcript(); + } + + ID_BTN_HYMT_CHECK if code == BN_CLICKED => { + let tx = st.ui_tx.clone(); + st.rt.spawn_blocking(move || { + let rep = hy_mt_manager::check_compatibility(); + let _ = tx.send(UiEvent::HyMtCompatReady(rep)); + }); + } + ID_BTN_HYMT_RUN if code == BN_CLICKED => { + let q_idx = (SendMessageW(st.cmb_hymt_quant, CB_GETCURSEL, 0, 0) as usize) + .min(HyMtQuant::ALL.len() - 1); + let quant = HyMtQuant::ALL[q_idx]; + let token = st.hymt_cancel.next(); + let tx_prog = st.ui_tx.clone(); + let tx_done = st.ui_tx.clone(); + st.rt.spawn(async move { + let res = hy_mt_manager::download_and_start(quant, &token, |p| { + let _ = tx_prog.send(UiEvent::HyMtProgress(p)); + }) + .await; + let _ = tx_done.send(UiEvent::HyMtReady(res)); + }); + } + ID_BTN_HYMT_STOP if code == BN_CLICKED => { + st.hymt_cancel.cancel_current(); + hy_mt_manager::stop_server(); + set_hwnd_text(st.lbl_hymt_status, "已停止内置 HY-MT1.5 本地服务"); + } + ID_BTN_AI_DISCOVER if code == BN_CLICKED => { + let b_idx = (SendMessageW(st.cmb_ai_backend, CB_GETCURSEL, 0, 0) as usize) + .min(AiBackendKind::ALL.len() - 1); + let backend = AiBackendKind::ALL[b_idx]; + let url = get_hwnd_text(st.edit_ai_url); + let key = get_hwnd_text(st.edit_ai_key); + let tx = st.ui_tx.clone(); + st.rt.spawn(async move { + let opt_key = (!key.trim().is_empty()).then_some(key.as_str()); + let res = ttree_ai::discover_local_models(backend, &url, opt_key).await; + let _ = tx.send(UiEvent::ModelsDiscovered(res)); + }); + } + ID_BTN_CHAT_COPY_TRANS if code == BN_CLICKED => { + let b_idx = SendMessageW(st.cmb_ai_backend, CB_GETCURSEL, 0, 0) as usize; + SendMessageW(st.cmb_chat_backend, CB_SETCURSEL, b_idx, 0); + set_hwnd_text(st.edit_chat_url, &get_hwnd_text(st.edit_ai_url)); + set_hwnd_text(st.edit_chat_key, &get_hwnd_text(st.edit_ai_key)); + set_hwnd_text(st.edit_chat_model, &get_hwnd_text(st.edit_ai_model)); + } + ID_BTN_HK_RESET if code == BN_CLICKED => { + let def = ShortcutConfig::default(); + set_hwnd_text(st.edit_hk_toggle, &def.toggle); + set_hwnd_text(st.edit_hk_ocr, &def.ocr); + set_hwnd_text(st.edit_hk_chat, &def.ai_dialog); + set_hwnd_text(st.edit_hk_sel_trans, &def.selection_translate); + set_hwnd_text(st.edit_hk_sel_chat, &def.selection_ai_dialog); + } + ID_BTN_SAVE_SETTINGS if code == BN_CLICKED => st.save_settings_from_ui(), + + ID_TRAY_TRANSLATE => { + st.switch_tab(TabMode::Translate); + st.show_and_focus(); + } + ID_TRAY_OCR => st.trigger_ocr(), + ID_TRAY_CHAT => { + st.switch_tab(TabMode::Chat); + st.show_and_focus(); + } + ID_TRAY_SETTINGS => { + st.switch_tab(TabMode::Settings); + st.show_and_focus(); + } + ID_TRAY_QUIT => { + DestroyWindow(st.hwnd); + } + _ => {} + } +} diff --git a/crates/ttree-ocr/Cargo.toml b/crates/ttree-ocr/Cargo.toml index d1ab8b4..32c50f3 100644 --- a/crates/ttree-ocr/Cargo.toml +++ b/crates/ttree-ocr/Cargo.toml @@ -18,3 +18,6 @@ objc2-core-ml.workspace = true objc2-foundation.workspace = true objc2-image-io.workspace = true objc2-vision.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +windows.workspace = true diff --git a/crates/ttree-ocr/src/lib.rs b/crates/ttree-ocr/src/lib.rs index 7037146..255a29e 100644 --- a/crates/ttree-ocr/src/lib.rs +++ b/crates/ttree-ocr/src/lib.rs @@ -6,6 +6,8 @@ //! 3. `image.rs` 自动采样截图边缘背景色填充 Padding(替代旧版硬编码纯白底,彻底修复深色模式单行小图识别失败); //! 4. `vision.rs` 移除主路径上的 `PreferBackgroundProcessing(true)` 后台降级标志,确保菜单栏配件应用极速响应。 +pub mod postprocess; +pub mod preprocess; pub mod screenshot; #[cfg(target_os = "macos")] @@ -13,6 +15,11 @@ pub mod image; #[cfg(target_os = "macos")] pub mod vision; +#[cfg(target_os = "windows")] +pub mod windows_capture; +#[cfg(target_os = "windows")] +pub mod windows_ocr; + use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -46,11 +53,14 @@ pub enum OcrOutcome { EmptyText, /// 上一次截图框选仍在进行中。 Busy, - /// macOS 屏幕录制权限未授予或已失效。 + /// 屏幕录制权限未授予,或系统缺少 OCR 语言包。 PermissionDenied(String), } -/// 完整执行一次「macOS 原生交互式框选 (`screencapture -i -x -r`) + Apple Vision OCR」。 +/// 完整执行一次「原生交互式自由框选 + 原生离线 OCR 识别」。 +/// - macOS:调用 `/usr/sbin/screencapture -i -x -r` + Apple Vision OCR +/// - Windows:调用 Per-Monitor DPI v2 原生全屏框选 + 双通道增强多语言 `Windows.Media.Ocr` +#[cfg(target_os = "macos")] pub fn capture_and_recognize() -> Result { let Some(_guard) = BusyGuard::try_acquire() else { return Ok(OcrOutcome::Busy); @@ -65,12 +75,37 @@ pub fn capture_and_recognize() -> Result { } } +#[cfg(target_os = "windows")] +pub fn capture_and_recognize() -> Result { + let Some(_guard) = BusyGuard::try_acquire() else { + return Ok(OcrOutcome::Busy); + }; + + match windows_capture::capture_interactive_region()? { + windows_capture::WindowsCaptureResult::Captured(raw_img) => { + windows_ocr::recognize_raw_bgra(raw_img) + } + windows_capture::WindowsCaptureResult::Cancelled => Ok(OcrOutcome::Cancelled), + } +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +pub fn capture_and_recognize() -> Result { + Err("当前平台暂不支持截图 OCR".to_string()) +} + #[cfg(target_os = "macos")] pub fn recognize_file(path: &str) -> Result { vision::recognize_file(path) } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] +pub fn recognize_file(path: &str) -> Result { + windows_ocr::recognize_file(path) +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] pub fn recognize_file(_path: &str) -> Result { Err("当前平台暂不支持 OCR".to_string()) } + diff --git a/crates/ttree-ocr/src/postprocess.rs b/crates/ttree-ocr/src/postprocess.rs new file mode 100644 index 0000000..46d1830 --- /dev/null +++ b/crates/ttree-ocr/src/postprocess.rs @@ -0,0 +1,173 @@ +//! 跨平台 OCR 文本后处理与多语言引擎结果评分器。 +//! +//! 解决 `Windows.Media.Ocr` 的两大典型缺陷: +//! 1. **中文字符间被强行插入空格**:`Windows.Media.Ocr` 的 `OcrLine::Text()` 会将每个中文词/字用空格拼起来 +//! (如 `"截 图 翻 译 测 试"`),严重干扰下游机器翻译与大模型理解。本模块通过 `join_ocr_words` 与 +//! `clean_cjk_ocr_spaces` 智能消去 CJK 字符及全角标点之间的伪空格,同时完整保留英文单词间的合法空格; +//! 2. **错选纯英文引擎识别中文产生乱码符号**:提供 `score_ocr_text` 质量评分函数,供多语言引擎回退择优。 + +/// 判断字符是否属于 CJK 汉字、假名、谚文或全角标点符号。 +#[inline] +pub fn is_cjk_or_fullwidth(c: char) -> bool { + matches!( + c as u32, + 0x2E80..=0x2FFF // CJK 部首补充、康熙部首 + | 0x3000..=0x303F // CJK 符号和标点 + | 0x3040..=0x30FF // 平假名、片假名 + | 0x3100..=0x312F // 注音符号 + | 0x3130..=0x318F // 谚文兼容字母 + | 0x3200..=0x32FF // 带圈 CJK + | 0x3400..=0x4DBF // CJK 统一表意文字扩展 A + | 0x4E00..=0x9FFF // CJK 统一表意文字基本集 + | 0xAC00..=0xD7AF // 韩文音节 + | 0xF900..=0xFAFF // CJK 兼容表意文字 + | 0xFE30..=0xFE4F // CJK 兼容形式 + | 0xFF00..=0xFFEF // 全角 ASCII、全角标点 + | 0x20000..=0x2FA1F // CJK 扩展 B~F + ) +} + +/// 将一行内的 OCR 分词序列智能拼接: +/// - 相邻两个 Token 若任意一侧紧邻全角标点,或两侧均为 CJK 字符,则直接无缝拼接; +/// - 英文/数字单词之间保留单个空格。 +pub fn join_ocr_words>(words: &[S]) -> String { + let mut out = String::new(); + for word in words { + let piece = word.as_ref().trim(); + if piece.is_empty() { + continue; + } + if let (Some(prev_char), Some(next_char)) = (out.chars().last(), piece.chars().next()) { + if should_insert_space_between(prev_char, next_char) { + out.push(' '); + } + } + out.push_str(piece); + } + clean_cjk_ocr_spaces(&out) +} + +#[inline] +fn should_insert_space_between(left: char, right: char) -> bool { + if is_cjk_or_fullwidth(left) && is_cjk_or_fullwidth(right) { + return false; + } + // 全角标点与英文/数字之间也不加多余空格 + if is_fullwidth_punctuation(left) || is_fullwidth_punctuation(right) { + return false; + } + // 两侧均为 ASCII 字母/数字,或一侧为 ASCII 单词标点时保留空格 + left.is_ascii_graphic() && right.is_ascii_graphic() +} + +#[inline] +fn is_fullwidth_punctuation(c: char) -> bool { + matches!( + c, + ',' | '。' + | '、' + | ';' + | ':' + | '?' + | '!' + | '“' + | '”' + | '‘' + | '’' + | '(' + | ')' + | '【' + | '】' + | '《' + | '》' + | '—' + | '…' + | '·' + ) +} + +/// 清理单行字符串中 CJK 字符之间的冗余空格(兼容直接返回 `OcrLine::Text()` 的路径)。 +pub fn clean_cjk_ocr_spaces(line: &str) -> String { + let chars: Vec = line.trim().chars().collect(); + if chars.is_empty() { + return String::new(); + } + + let mut out = String::with_capacity(line.len()); + let mut i = 0; + while i < chars.len() { + let ch = chars[i]; + if ch == ' ' || ch == '\t' { + // 跳过连续空白,检查左右有效字符 + let prev = out.chars().last(); + let mut j = i + 1; + while j < chars.len() && (chars[j] == ' ' || chars[j] == '\t') { + j += 1; + } + let next = chars.get(j).copied(); + if let (Some(l), Some(r)) = (prev, next) { + if should_insert_space_between(l, r) { + out.push(' '); + } + } + i = j; + } else { + out.push(ch); + i += 1; + } + } + out +} + +/// 评估 OCR 识别结果的有效文本质量分(用于多语言引擎或多轮预处理候选择优)。 +pub fn score_ocr_text(text: &str) -> i64 { + let trimmed = text.trim(); + if trimmed.is_empty() { + return 0; + } + let mut score: i64 = 0; + for ch in trimmed.chars() { + if is_cjk_or_fullwidth(ch) { + score += 5; + } else if ch.is_ascii_alphanumeric() { + score += 3; + } else if ch.is_alphabetic() { + score += 3; + } else if matches!(ch, '.' | ',' | '-' | '_' | '/' | ':' | ';' | '?' | '!' | '\'' | '"') { + score += 1; + } else if ch.is_whitespace() { + // 不加分也不扣分 + } else { + // 孤立生僻控制符或乱码噪点轻微扣分 + score -= 1; + } + } + score +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_spaces_between_cjk_words_while_preserving_english_spaces() { + let words = vec!["这", "是", "Windows", "原", "生", "OCR", "Test", "engine", "!"]; + assert_eq!(join_ocr_words(&words), "这是Windows原生OCR Test engine!"); + } + + #[test] + fn cleans_inline_cjk_spaces() { + assert_eq!( + clean_cjk_ocr_spaces("请 安 装 Windows OCR 语 言 包 : 中文(简体)"), + "请安装Windows OCR语言包:中文(简体)" + ); + } + + #[test] + fn scores_meaningful_text_higher_than_ocr_noise() { + let good = score_ocr_text("你好世界 Hello World"); + let noise = score_ocr_text("|| ~ ^ `"); + assert!(good > 20); + assert!(good > noise); + } +} diff --git a/crates/ttree-ocr/src/preprocess.rs b/crates/ttree-ocr/src/preprocess.rs new file mode 100644 index 0000000..c1aa773 --- /dev/null +++ b/crates/ttree-ocr/src/preprocess.rs @@ -0,0 +1,395 @@ +//! 跨平台纯 Rust OCR 图像预处理器(专为解决 Windows.Media.Ocr 识别率痛点设计)。 +//! +//! 解决的核心问题: +//! 1. **小字/单行截图识别失败**:当框选高度 `< 56px` 或 `< 112px` 时,自动执行 3× / 2× 双线性插值放大(Bilinear Upscale), +//! 使字高落在 `Windows.Media.Ocr` 最佳识别区间(36~64px); +//! 2. **紧贴边缘吞首尾字**:采样原图外圈像素计算自适应背景色,四周无缝扩展 `32px` 边距并保证最小画布 `192×128`; +//! 3. **深色模式/低对比度灰字漏识**:提供 Pass-2 高对比度归一化(自动检测深色背景并反相为白底黑字 + 5%~95% 分位对比度拉伸 + S 曲线锐化)。 + +const BORDER_PADDING: u32 = 32; +const MIN_CANVAS_WIDTH: u32 = 192; +const MIN_CANVAS_HEIGHT: u32 = 128; +const MAX_OCR_PIXELS: u64 = 16_000_000; + +/// 内存中自上而下(Top-Down)排列的 32-bit BGRA 位图。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawBgraImage { + pub width: u32, + pub height: u32, + pub bgra: Vec, +} + +impl RawBgraImage { + pub fn new(width: u32, height: u32, bgra: Vec) -> Result { + if width == 0 || height == 0 { + return Err("截图尺寸为空,请重新框选。".to_string()); + } + let expected = (width as usize) + .checked_mul(height as usize) + .and_then(|px| px.checked_mul(4)) + .ok_or_else(|| "截图尺寸溢出".to_string())?; + if bgra.len() != expected { + return Err(format!( + "位图字节数不匹配:期望 {expected},实际 {}", + bgra.len() + )); + } + Ok(Self { + width, + height, + bgra, + }) + } + + /// 裁剪子区域 `[x..x+w, y..y+h]`。 + pub fn crop(&self, x: u32, y: u32, w: u32, h: u32) -> Result { + if w == 0 || h == 0 { + return Err("选区尺寸为空".to_string()); + } + let x2 = x.saturating_add(w).min(self.width); + let y2 = y.saturating_add(h).min(self.height); + if x >= x2 || y >= y2 { + return Err("选区超出屏幕范围".to_string()); + } + let actual_w = x2 - x; + let actual_h = y2 - y; + let mut out = vec![0u8; (actual_w as usize) * (actual_h as usize) * 4]; + let src_stride = (self.width as usize) * 4; + let dst_stride = (actual_w as usize) * 4; + + for row in 0..actual_h as usize { + let src_start = (y as usize + row) * src_stride + (x as usize) * 4; + let dst_start = row * dst_stride; + out[dst_start..dst_start + dst_stride] + .copy_from_slice(&self.bgra[src_start..src_start + dst_stride]); + } + + Ok(Self { + width: actual_w, + height: actual_h, + bgra: out, + }) + } +} + +/// 计算适合 OCR 的放大倍率(1× / 2× / 3×)。 +pub fn recommended_upscale_factor(width: u32, height: u32) -> u32 { + let base_pixels = (width as u64) * (height as u64); + if (height < 56 || width < 160) && base_pixels * 9 <= MAX_OCR_PIXELS { + 3 + } else if (height < 112 || width < 320) && base_pixels * 4 <= MAX_OCR_PIXELS { + 2 + } else { + 1 + } +} + +/// 消除 Windows ClearType LCD 亚像素渲染产生的红/蓝彩边伪影: +/// ClearType 在 100%/125% 缩放下对字体竖直笔画两侧使用红/蓝亚像素抗锯齿(导致 `|R - B|` 显著偏大且 R/G/B 单调递减/递增), +/// 这会让 `Windows.Media.Ocr` 二值化时笔画粘连或断裂。将其还原为以绿色通道为主导的平滑灰度边缘 `(R + 2G + B) / 4`。 +pub fn neutralize_cleartype_fringes(src: &RawBgraImage) -> RawBgraImage { + let mut out = src.bgra.clone(); + for px in out.chunks_exact_mut(4) { + let b = px[0] as i32; + let g = px[1] as i32; + let r = px[2] as i32; + let rb_diff = (r - b).abs(); + let is_monotonic = (r >= g && g >= b) || (b >= g && g >= r); + if is_monotonic && rb_diff >= 24 { + let luma = ((r + 2 * g + b) >> 2) as u8; + px[0] = luma; + px[1] = luma; + px[2] = luma; + } + } + RawBgraImage { + width: src.width, + height: src.height, + bgra: out, + } +} + +/// Pass-1 预处理:消除 ClearType 彩边 + 小图自适应双线性放大 + 外圈背景色无缝填充边距。 +pub fn prepare_primary_for_ocr(img: &RawBgraImage) -> Result { + let defringed = neutralize_cleartype_fringes(img); + let factor = recommended_upscale_factor(defringed.width, defringed.height); + let scaled = if factor > 1 { + upscale_bilinear(&defringed, factor) + } else { + defringed + }; + let (bg_b, bg_g, bg_r) = sample_border_bgr(&scaled); + pad_with_bg(&scaled, bg_b, bg_g, bg_r) +} + +/// Pass-2 高对比度增强预处理:针对深色模式(白字黑底)或低对比度灰字, +/// 自动反相为白底深色字并做分位数对比度拉伸 + S 曲线增强。 +pub fn prepare_high_contrast_for_ocr(img: &RawBgraImage) -> Result { + let defringed = neutralize_cleartype_fringes(img); + let factor = recommended_upscale_factor(defringed.width, defringed.height).max(2); + let factor = if (defringed.width as u64) + * (defringed.height as u64) + * (factor as u64) + * (factor as u64) + <= MAX_OCR_PIXELS + { + factor + } else { + 1 + }; + let scaled = if factor > 1 { + upscale_bilinear(&defringed, factor) + } else { + defringed + }; + + let (bg_b, bg_g, bg_r) = sample_border_bgr(&scaled); + let bg_luma = rgb_to_luma(bg_r, bg_g, bg_b); + let invert = bg_luma < 128; + + let pixel_count = (scaled.width as usize) * (scaled.height as usize); + let mut lumas = Vec::with_capacity(pixel_count); + let mut hist = [0usize; 256]; + + for chunk in scaled.bgra.chunks_exact(4) { + let mut y = rgb_to_luma(chunk[2], chunk[1], chunk[0]); + if invert { + y = 255 - y; + } + lumas.push(y); + hist[y as usize] += 1; + } + + let low = percentile_from_hist(&hist, pixel_count, 5); + let high = percentile_from_hist(&hist, pixel_count, 95).max(low.saturating_add(20)); + let span = (high - low).max(1) as f32; + + let mut enhanced = vec![255u8; scaled.bgra.len()]; + for (i, &y) in lumas.iter().enumerate() { + let norm = ((y as f32 - low as f32) / span).clamp(0.0, 1.0); + // S 曲线拉开笔画与底色对比度:暗部更黑,亮部纯白 + let curved = if norm > 0.72 { + 1.0 + } else if norm < 0.28 { + 0.0 + } else { + ((norm - 0.28) / 0.44).clamp(0.0, 1.0) + }; + let out_v = (curved * 255.0).round() as u8; + let idx = i * 4; + enhanced[idx] = out_v; + enhanced[idx + 1] = out_v; + enhanced[idx + 2] = out_v; + enhanced[idx + 3] = 255; + } + + let normalized_img = RawBgraImage { + width: scaled.width, + height: scaled.height, + bgra: enhanced, + }; + pad_with_bg(&normalized_img, 255, 255, 255) +} + +#[inline] +fn rgb_to_luma(r: u8, g: u8, b: u8) -> u8 { + ((77u32 * r as u32 + 150u32 * g as u32 + 29u32 * b as u32) >> 8) as u8 +} + +fn percentile_from_hist(hist: &[usize; 256], total: usize, pct: usize) -> u8 { + if total == 0 { + return 0; + } + let target = (total * pct) / 100; + let mut acc = 0usize; + for (val, &count) in hist.iter().enumerate() { + acc += count; + if acc >= target { + return val as u8; + } + } + 255 +} + +/// 双线性插值放大 BGRA 图像。 +pub fn upscale_bilinear(src: &RawBgraImage, factor: u32) -> RawBgraImage { + if factor <= 1 { + return src.clone(); + } + let dst_w = src.width * factor; + let dst_h = src.height * factor; + let mut dst = vec![0u8; (dst_w as usize) * (dst_h as usize) * 4]; + + let src_w = src.width as usize; + let src_h = src.height as usize; + let scale = 1.0 / (factor as f32); + + for dy in 0..dst_h as usize { + let sy_f = ((dy as f32 + 0.5) * scale - 0.5).max(0.0); + let y0 = (sy_f.floor() as usize).min(src_h - 1); + let y1 = (y0 + 1).min(src_h - 1); + let wy = sy_f - (y0 as f32); + + for dx in 0..dst_w as usize { + let sx_f = ((dx as f32 + 0.5) * scale - 0.5).max(0.0); + let x0 = (sx_f.floor() as usize).min(src_w - 1); + let x1 = (x0 + 1).min(src_w - 1); + let wx = sx_f - (x0 as f32); + + let i00 = (y0 * src_w + x0) * 4; + let i10 = (y0 * src_w + x1) * 4; + let i01 = (y1 * src_w + x0) * 4; + let i11 = (y1 * src_w + x1) * 4; + let dst_idx = (dy * (dst_w as usize) + dx) * 4; + + for c in 0..3 { + let v00 = src.bgra[i00 + c] as f32; + let v10 = src.bgra[i10 + c] as f32; + let v01 = src.bgra[i01 + c] as f32; + let v11 = src.bgra[i11 + c] as f32; + let top = v00 + (v10 - v00) * wx; + let bot = v01 + (v11 - v01) * wx; + let val = top + (bot - top) * wy; + dst[dst_idx + c] = val.round().clamp(0.0, 255.0) as u8; + } + dst[dst_idx + 3] = 255; + } + } + + RawBgraImage { + width: dst_w, + height: dst_h, + bgra: dst, + } +} + +/// 采样图像四周边缘像素的平均 `(B, G, R)`。 +pub fn sample_border_bgr(img: &RawBgraImage) -> (u8, u8, u8) { + let w = img.width as usize; + let h = img.height as usize; + if w == 0 || h == 0 { + return (255, 255, 255); + } + + let mut b_sum: u64 = 0; + let mut g_sum: u64 = 0; + let mut r_sum: u64 = 0; + let mut count: u64 = 0; + + let mut sample = |x: usize, y: usize| { + let idx = (y * w + x) * 4; + b_sum += img.bgra[idx] as u64; + g_sum += img.bgra[idx + 1] as u64; + r_sum += img.bgra[idx + 2] as u64; + count += 1; + }; + + for x in 0..w { + sample(x, 0); + if h > 1 { + sample(x, h - 1); + } + } + if h > 2 { + for y in 1..(h - 1) { + sample(0, y); + if w > 1 { + sample(w - 1, y); + } + } + } + + if count == 0 { + return (255, 255, 255); + } + ( + (b_sum / count) as u8, + (g_sum / count) as u8, + (r_sum / count) as u8, + ) +} + +fn pad_with_bg( + img: &RawBgraImage, + bg_b: u8, + bg_g: u8, + bg_r: u8, +) -> Result { + let canvas_w = img + .width + .checked_add(BORDER_PADDING * 2) + .map(|w| w.max(MIN_CANVAS_WIDTH)) + .ok_or_else(|| "画布宽度溢出".to_string())?; + let canvas_h = img + .height + .checked_add(BORDER_PADDING * 2) + .map(|h| h.max(MIN_CANVAS_HEIGHT)) + .ok_or_else(|| "画布高度溢出".to_string())?; + + if (canvas_w as u64) * (canvas_h as u64) > MAX_OCR_PIXELS { + return Err("截图区域过大,请缩小框选范围后重试。".to_string()); + } + + let total_px = (canvas_w as usize) * (canvas_h as usize); + let mut out = vec![0u8; total_px * 4]; + for chunk in out.chunks_exact_mut(4) { + chunk[0] = bg_b; + chunk[1] = bg_g; + chunk[2] = bg_r; + chunk[3] = 255; + } + + let offset_x = ((canvas_w - img.width) / 2) as usize; + let offset_y = ((canvas_h - img.height) / 2) as usize; + let src_stride = (img.width as usize) * 4; + let dst_stride = (canvas_w as usize) * 4; + + for row in 0..img.height as usize { + let src_start = row * src_stride; + let dst_start = (offset_y + row) * dst_stride + offset_x * 4; + out[dst_start..dst_start + src_stride] + .copy_from_slice(&img.bgra[src_start..src_start + src_stride]); + } + + Ok(RawBgraImage { + width: canvas_w, + height: canvas_h, + bgra: out, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn small_single_line_image_is_upscaled_and_padded() { + let raw = RawBgraImage::new(80, 24, vec![20u8; 80 * 24 * 4]).unwrap(); + assert_eq!(recommended_upscale_factor(80, 24), 3); + let prepared = prepare_primary_for_ocr(&raw).unwrap(); + assert_eq!(prepared.width, 80 * 3 + BORDER_PADDING * 2); + assert!(prepared.height >= MIN_CANVAS_HEIGHT); + } + + #[test] + fn dark_mode_image_inverts_to_white_background_in_high_contrast_pass() { + let mut bgra = vec![24u8; 60 * 30 * 4]; + // 在深灰背景 (24,24,24) 中间画几颗浅灰文字像素 (210,210,210) + for y in 10..20 { + for x in 20..40 { + let idx = (y * 60 + x) * 4; + bgra[idx] = 210; + bgra[idx + 1] = 210; + bgra[idx + 2] = 210; + bgra[idx + 3] = 255; + } + } + let raw = RawBgraImage::new(60, 30, bgra).unwrap(); + let hc = prepare_high_contrast_for_ocr(&raw).unwrap(); + // 外圈填充应为纯白背景 (255, 255, 255) + assert_eq!(&hc.bgra[0..3], &[255, 255, 255]); + // 中心文字像素应被反相并拉伸为深黑色 (0, 0, 0) + let center_idx = ((hc.height as usize / 2) * hc.width as usize + (hc.width as usize / 2)) * 4; + assert_eq!(&hc.bgra[center_idx..center_idx + 3], &[0, 0, 0]); + } +} diff --git a/crates/ttree-ocr/src/windows_capture.rs b/crates/ttree-ocr/src/windows_capture.rs new file mode 100644 index 0000000..d513ee4 --- /dev/null +++ b/crates/ttree-ocr/src/windows_capture.rs @@ -0,0 +1,661 @@ +//! Windows 纯 Rust 原生全屏自由框选截图(零 Webview 依赖、Per-Monitor DPI v2 1:1 物理像素对齐)。 +//! +//! 解决旧版 Windows 截图的三大痛点: +//! 1. **彻底消除 DPI 缩放偏移**:进入截图前将当前线程切换至 `DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2`, +//! 无论系统缩放为 100% / 125% / 150% / 200% 或多显示器异构缩放,GDI 截屏与鼠标框选坐标严格 1:1 对齐; +//! 2. **支持多显示器虚拟桌面**:使用 `SM_XVIRTUALSCREEN..SM_CXVIRTUALSCREEN` 覆盖全部显示器; +//! 3. **毫秒级顺滑框选**:预生成 45% 暗化背景缓冲,拖拽时直接内存合成高亮选区 + 2px 天蓝色边框,通过单次 +//! `SetDIBitsToDevice` 零闪烁输出,无需启动笨重的 Webview 窗口或落盘临时文件。 + +#![cfg(target_os = "windows")] +#![allow(non_snake_case)] + +use std::cell::RefCell; +use std::ffi::c_void; +use std::ptr; + +use crate::preprocess::RawBgraImage; + +pub enum WindowsCaptureResult { + Captured(RawBgraImage), + Cancelled, +} + +type HWND = *mut c_void; +type HDC = *mut c_void; +type HBITMAP = *mut c_void; +type HGDIOBJ = *mut c_void; +type HICON = *mut c_void; +type HCURSOR = *mut c_void; +type HBRUSH = *mut c_void; +type HINSTANCE = *mut c_void; +type WPARAM = usize; +type LPARAM = isize; +type LRESULT = isize; +type BOOL = i32; + +const SM_CXSCREEN: i32 = 0; +const SM_CYSCREEN: i32 = 1; +const SM_XVIRTUALSCREEN: i32 = 76; +const SM_YVIRTUALSCREEN: i32 = 77; +const SM_CXVIRTUALSCREEN: i32 = 78; +const SM_CYVIRTUALSCREEN: i32 = 79; + +const SRCCOPY: u32 = 0x00CC0020; +const CAPTUREBLT: u32 = 0x40000000; +const BI_RGB: u32 = 0; +const DIB_RGB_COLORS: u32 = 0; + +const WS_POPUP: u32 = 0x80000000; +const WS_VISIBLE: u32 = 0x10000000; +const WS_EX_TOPMOST: u32 = 0x00000008; +const WS_EX_TOOLWINDOW: u32 = 0x00000080; + +const WM_DESTROY: u32 = 0x0002; +const WM_PAINT: u32 = 0x000F; +const WM_ERASEBKGND: u32 = 0x0014; +const WM_SETCURSOR: u32 = 0x0020; +const WM_KEYDOWN: u32 = 0x0100; +const WM_MOUSEMOVE: u32 = 0x0200; +const WM_LBUTTONDOWN: u32 = 0x0201; +const WM_LBUTTONUP: u32 = 0x0202; +const WM_RBUTTONDOWN: u32 = 0x0204; + +const VK_ESCAPE: usize = 0x1B; +const IDC_CROSS: *const u16 = 32515 as *const u16; +const SW_SHOW: i32 = 5; + +const DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2: isize = -4; + +#[repr(C)] +#[derive(Copy, Clone)] +struct POINT { + x: i32, + y: i32, +} + +#[repr(C)] +#[derive(Copy, Clone)] +struct MSG { + hwnd: HWND, + message: u32, + wParam: WPARAM, + lParam: LPARAM, + time: u32, + pt: POINT, +} + +#[repr(C)] +struct PAINTSTRUCT { + hdc: HDC, + fErase: BOOL, + rcPaint: [i32; 4], + fRestore: BOOL, + fIncUpdate: BOOL, + rgbReserved: [u8; 32], +} + +#[repr(C)] +struct WNDCLASSEXW { + cbSize: u32, + style: u32, + lpfnWndProc: unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT, + cbClsExtra: i32, + cbWndExtra: i32, + hInstance: HINSTANCE, + hIcon: HICON, + hCursor: HCURSOR, + hbrBackground: HBRUSH, + lpszMenuName: *const u16, + lpszClassName: *const u16, + hIconSm: HICON, +} + +#[repr(C)] +struct BITMAPINFOHEADER { + biSize: u32, + biWidth: i32, + biHeight: i32, + biPlanes: u16, + biBitCount: u16, + biCompression: u32, + biSizeImage: u32, + biXPelsPerMeter: i32, + biYPelsPerMeter: i32, + biClrUsed: u32, + biClrImportant: u32, +} + +#[repr(C)] +struct BITMAPINFO { + bmiHeader: BITMAPINFOHEADER, + bmiColors: [u32; 1], +} + +extern "system" { + fn SetThreadDpiAwarenessContext(dpiContext: isize) -> isize; + fn GetSystemMetrics(nIndex: i32) -> i32; + fn GetDC(hWnd: HWND) -> HDC; + fn ReleaseDC(hWnd: HWND, hDC: HDC) -> i32; + fn CreateCompatibleDC(hdc: HDC) -> HDC; + fn DeleteDC(hdc: HDC) -> BOOL; + fn CreateCompatibleBitmap(hdc: HDC, cx: i32, cy: i32) -> HBITMAP; + fn SelectObject(hdc: HDC, h: HGDIOBJ) -> HGDIOBJ; + fn DeleteObject(ho: HGDIOBJ) -> BOOL; + fn BitBlt( + hdc: HDC, + x: i32, + y: i32, + cx: i32, + cy: i32, + hdcSrc: HDC, + x1: i32, + y1: i32, + rop: u32, + ) -> BOOL; + fn GetDIBits( + hdc: HDC, + hbm: HBITMAP, + start: u32, + cLines: u32, + lpvBits: *mut c_void, + lpbmi: *mut BITMAPINFO, + usage: u32, + ) -> i32; + fn SetDIBitsToDevice( + hdc: HDC, + xDest: i32, + yDest: i32, + w: u32, + h: u32, + xSrc: i32, + ySrc: i32, + StartScan: u32, + cLines: u32, + lpvBits: *const c_void, + lpbmi: *const BITMAPINFO, + ColorUse: u32, + ) -> i32; + + fn GetModuleHandleW(lpModuleName: *const u16) -> HINSTANCE; + fn RegisterClassExW(lpWndClass: *const WNDCLASSEXW) -> u16; + fn CreateWindowExW( + dwExStyle: u32, + lpClassName: *const u16, + lpWindowName: *const u16, + dwStyle: u32, + X: i32, + Y: i32, + nWidth: i32, + nHeight: i32, + hWndParent: HWND, + hMenu: *mut c_void, + hInstance: HINSTANCE, + lpParam: *mut c_void, + ) -> HWND; + fn DestroyWindow(hWnd: HWND) -> BOOL; + fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL; + fn SetForegroundWindow(hWnd: HWND) -> BOOL; + fn SetFocus(hWnd: HWND) -> HWND; + fn SetCapture(hWnd: HWND) -> HWND; + fn ReleaseCapture() -> BOOL; + fn InvalidateRect(hWnd: HWND, lpRect: *const i32, bErase: BOOL) -> BOOL; + fn BeginPaint(hWnd: HWND, lpPaint: *mut PAINTSTRUCT) -> HDC; + fn EndPaint(hWnd: HWND, lpPaint: *const PAINTSTRUCT) -> BOOL; + fn LoadCursorW(hInstance: HINSTANCE, lpCursorName: *const u16) -> HCURSOR; + fn SetCursor(hCursor: HCURSOR) -> HCURSOR; + fn DefWindowProcW(hWnd: HWND, Msg: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT; + fn GetMessageW(lpMsg: *mut MSG, hWnd: HWND, wMsgFilterMin: u32, wMsgFilterMax: u32) -> BOOL; + fn TranslateMessage(lpMsg: *const MSG) -> BOOL; + fn DispatchMessageW(lpMsg: *const MSG) -> LRESULT; + fn PostQuitMessage(nExitCode: i32); +} + +struct OverlayState { + width: u32, + height: u32, + orig_bgra: Vec, + dimmed_bgra: Vec, + frame_bgra: Vec, + dragging: bool, + start_x: i32, + start_y: i32, + curr_x: i32, + curr_y: i32, + selected_rect: Option<(u32, u32, u32, u32)>, + cancelled: bool, +} + +thread_local! { + static OVERLAY_STATE: RefCell> = const { RefCell::new(None) }; +} + +fn wide_null(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +#[inline] +fn loword_i16(lparam: LPARAM) -> i32 { + (lparam as u32 & 0xFFFF) as i16 as i32 +} + +#[inline] +fn hiword_i16(lparam: LPARAM) -> i32 { + ((lparam as u32 >> 16) & 0xFFFF) as i16 as i32 +} + +fn normalized_rect( + x1: i32, + y1: i32, + x2: i32, + y2: i32, + max_w: u32, + max_h: u32, +) -> (u32, u32, u32, u32) { + let left = x1.min(x2).clamp(0, max_w as i32) as u32; + let right = x1.max(x2).clamp(0, max_w as i32) as u32; + let top = y1.min(y2).clamp(0, max_h as i32) as u32; + let bottom = y1.max(y2).clamp(0, max_h as i32) as u32; + (left, top, right.saturating_sub(left), bottom.saturating_sub(top)) +} + +/// 执行一次原生全屏框选截图,返回内存中的 `RawBgraImage`。 +pub fn capture_interactive_region() -> Result { + unsafe { + // 切换当前线程到 Per-Monitor DPI v2,确保截屏与鼠标坐标均为 1:1 物理像素 + let prev_dpi = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + let result = run_capture_session(); + if prev_dpi != 0 { + SetThreadDpiAwarenessContext(prev_dpi); + } + result + } +} + +unsafe fn run_capture_session() -> Result { + let mut vx = GetSystemMetrics(SM_XVIRTUALSCREEN); + let mut vy = GetSystemMetrics(SM_YVIRTUALSCREEN); + let mut vw = GetSystemMetrics(SM_CXVIRTUALSCREEN); + let mut vh = GetSystemMetrics(SM_CYVIRTUALSCREEN); + if vw <= 0 || vh <= 0 { + vx = 0; + vy = 0; + vw = GetSystemMetrics(SM_CXSCREEN); + vh = GetSystemMetrics(SM_CYSCREEN); + } + if vw <= 0 || vh <= 0 { + return Err("获取 Windows 屏幕分辨率失败".to_string()); + } + + let orig_bgra = capture_virtual_screen_bgra(vx, vy, vw, vh)?; + let mut dimmed_bgra = orig_bgra.clone(); + for px in dimmed_bgra.chunks_exact_mut(4) { + px[0] = ((px[0] as u16 * 115) >> 8) as u8; + px[1] = ((px[1] as u16 * 115) >> 8) as u8; + px[2] = ((px[2] as u16 * 115) >> 8) as u8; + px[3] = 255; + } + let frame_bgra = dimmed_bgra.clone(); + + OVERLAY_STATE.with(|cell| { + *cell.borrow_mut() = Some(OverlayState { + width: vw as u32, + height: vh as u32, + orig_bgra, + dimmed_bgra, + frame_bgra, + dragging: false, + start_x: 0, + start_y: 0, + curr_x: 0, + curr_y: 0, + selected_rect: None, + cancelled: false, + }); + }); + + let hinstance = GetModuleHandleW(ptr::null()); + let class_name = wide_null("TTreeScreenCaptureOverlayClass"); + let cursor = LoadCursorW(ptr::null_mut(), IDC_CROSS); + + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: 0, + lpfnWndProc: overlay_wnd_proc, + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: hinstance, + hIcon: ptr::null_mut(), + hCursor: cursor, + hbrBackground: ptr::null_mut(), + lpszMenuName: ptr::null(), + lpszClassName: class_name.as_ptr(), + hIconSm: ptr::null_mut(), + }; + let _ = RegisterClassExW(&wc); + + let win_title = wide_null("TTREE Screenshot Selection"); + let hwnd = CreateWindowExW( + WS_EX_TOPMOST | WS_EX_TOOLWINDOW, + class_name.as_ptr(), + win_title.as_ptr(), + WS_POPUP | WS_VISIBLE, + vx, + vy, + vw, + vh, + ptr::null_mut(), + ptr::null_mut(), + hinstance, + ptr::null_mut(), + ); + if hwnd.is_null() { + OVERLAY_STATE.with(|cell| *cell.borrow_mut() = None); + return Err("创建全屏截图框选窗口失败".to_string()); + } + + ShowWindow(hwnd, SW_SHOW); + SetForegroundWindow(hwnd); + SetFocus(hwnd); + + let mut msg: MSG = std::mem::zeroed(); + while GetMessageW(&mut msg, ptr::null_mut(), 0, 0) > 0 { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + let state = OVERLAY_STATE + .with(|cell| cell.borrow_mut().take()) + .ok_or_else(|| "截图框选状态丢失".to_string())?; + + if state.cancelled { + return Ok(WindowsCaptureResult::Cancelled); + } + let Some((rx, ry, rw, rh)) = state.selected_rect else { + return Ok(WindowsCaptureResult::Cancelled); + }; + if rw < 4 || rh < 4 { + return Ok(WindowsCaptureResult::Cancelled); + } + + let full = RawBgraImage::new(state.width, state.height, state.orig_bgra)?; + let cropped = full.crop(rx, ry, rw, rh)?; + Ok(WindowsCaptureResult::Captured(cropped)) +} + +unsafe fn capture_virtual_screen_bgra( + vx: i32, + vy: i32, + vw: i32, + vh: i32, +) -> Result, String> { + let hdc_screen = GetDC(ptr::null_mut()); + if hdc_screen.is_null() { + return Err("获取屏幕 DC 失败".to_string()); + } + + let hdc_mem = CreateCompatibleDC(hdc_screen); + if hdc_mem.is_null() { + ReleaseDC(ptr::null_mut(), hdc_screen); + return Err("创建内存 DC 失败".to_string()); + } + + let hbmp = CreateCompatibleBitmap(hdc_screen, vw, vh); + if hbmp.is_null() { + DeleteDC(hdc_mem); + ReleaseDC(ptr::null_mut(), hdc_screen); + return Err("创建屏幕位图失败".to_string()); + } + + let old_obj = SelectObject(hdc_mem, hbmp); + let ok = BitBlt( + hdc_mem, + 0, + 0, + vw, + vh, + hdc_screen, + vx, + vy, + SRCCOPY | CAPTUREBLT, + ); + + let mut bmi: BITMAPINFO = std::mem::zeroed(); + bmi.bmiHeader.biSize = std::mem::size_of::() as u32; + bmi.bmiHeader.biWidth = vw; + // 负值 biHeight 表示 Top-Down 位图,免去手动上下翻转 + bmi.bmiHeader.biHeight = -vh; + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + let mut bgra = vec![0u8; (vw as usize) * (vh as usize) * 4]; + let lines = if ok != 0 { + GetDIBits( + hdc_screen, + hbmp, + 0, + vh as u32, + bgra.as_mut_ptr() as *mut c_void, + &mut bmi, + DIB_RGB_COLORS, + ) + } else { + 0 + }; + + SelectObject(hdc_mem, old_obj); + DeleteObject(hbmp); + DeleteDC(hdc_mem); + ReleaseDC(ptr::null_mut(), hdc_screen); + + if ok == 0 || lines == 0 { + return Err("抓取屏幕像素失败".to_string()); + } + Ok(bgra) +} + +unsafe extern "system" fn overlay_wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_ERASEBKGND => 1, + WM_SETCURSOR => { + SetCursor(LoadCursorW(ptr::null_mut(), IDC_CROSS)); + 1 + } + WM_KEYDOWN => { + if wparam == VK_ESCAPE { + OVERLAY_STATE.with(|cell| { + if let Some(st) = cell.borrow_mut().as_mut() { + st.cancelled = true; + } + }); + ReleaseCapture(); + DestroyWindow(hwnd); + return 0; + } + DefWindowProcW(hwnd, msg, wparam, lparam) + } + WM_RBUTTONDOWN => { + OVERLAY_STATE.with(|cell| { + if let Some(st) = cell.borrow_mut().as_mut() { + st.cancelled = true; + } + }); + ReleaseCapture(); + DestroyWindow(hwnd); + 0 + } + WM_LBUTTONDOWN => { + let x = loword_i16(lparam); + let y = hiword_i16(lparam); + OVERLAY_STATE.with(|cell| { + if let Some(st) = cell.borrow_mut().as_mut() { + st.dragging = true; + st.start_x = x; + st.start_y = y; + st.curr_x = x; + st.curr_y = y; + } + }); + SetCapture(hwnd); + InvalidateRect(hwnd, ptr::null(), 0); + 0 + } + WM_MOUSEMOVE => { + let x = loword_i16(lparam); + let y = hiword_i16(lparam); + let mut should_repaint = false; + OVERLAY_STATE.with(|cell| { + if let Some(st) = cell.borrow_mut().as_mut() { + if st.dragging { + st.curr_x = x; + st.curr_y = y; + should_repaint = true; + } + } + }); + if should_repaint { + InvalidateRect(hwnd, ptr::null(), 0); + } + 0 + } + WM_LBUTTONUP => { + let x = loword_i16(lparam); + let y = hiword_i16(lparam); + ReleaseCapture(); + let mut should_close = false; + OVERLAY_STATE.with(|cell| { + if let Some(st) = cell.borrow_mut().as_mut() { + if st.dragging { + st.dragging = false; + st.curr_x = x; + st.curr_y = y; + let rect = normalized_rect( + st.start_x, st.start_y, st.curr_x, st.curr_y, st.width, st.height, + ); + if rect.2 >= 6 && rect.3 >= 6 { + st.selected_rect = Some(rect); + should_close = true; + } + } + } + }); + if should_close { + DestroyWindow(hwnd); + } else { + InvalidateRect(hwnd, ptr::null(), 0); + } + 0 + } + WM_PAINT => { + let mut ps: PAINTSTRUCT = std::mem::zeroed(); + let hdc = BeginPaint(hwnd, &mut ps); + OVERLAY_STATE.with(|cell| { + if let Some(st) = cell.borrow_mut().as_mut() { + render_overlay_frame(hdc, st); + } + }); + EndPaint(hwnd, &ps); + 0 + } + WM_DESTROY => { + PostQuitMessage(0); + 0 + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +unsafe fn render_overlay_frame(hdc: HDC, st: &mut OverlayState) { + st.frame_bgra.copy_from_slice(&st.dimmed_bgra); + + if st.dragging { + let (rx, ry, rw, rh) = + normalized_rect(st.start_x, st.start_y, st.curr_x, st.curr_y, st.width, st.height); + if rw > 0 && rh > 0 { + let stride = (st.width as usize) * 4; + let row_bytes = (rw as usize) * 4; + for row in ry..(ry + rh) { + let offset = (row as usize) * stride + (rx as usize) * 4; + st.frame_bgra[offset..offset + row_bytes] + .copy_from_slice(&st.orig_bgra[offset..offset + row_bytes]); + } + + // 绘制 2px 天蓝色 (#38BDF8 -> B=0xF8, G=0xBD, R=0x38) 选区外框 + draw_rect_border_bgra( + &mut st.frame_bgra, + st.width, + st.height, + rx, + ry, + rw, + rh, + [0xF8, 0xBD, 0x38, 0xFF], + ); + } + } + + let mut bmi: BITMAPINFO = std::mem::zeroed(); + bmi.bmiHeader.biSize = std::mem::size_of::() as u32; + bmi.bmiHeader.biWidth = st.width as i32; + bmi.bmiHeader.biHeight = -(st.height as i32); + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + SetDIBitsToDevice( + hdc, + 0, + 0, + st.width, + st.height, + 0, + 0, + 0, + st.height, + st.frame_bgra.as_ptr() as *const c_void, + &bmi, + DIB_RGB_COLORS, + ); +} + +#[allow(clippy::too_many_arguments)] +fn draw_rect_border_bgra( + buf: &mut [u8], + img_w: u32, + img_h: u32, + rx: u32, + ry: u32, + rw: u32, + rh: u32, + color: [u8; 4], +) { + let x_end = (rx + rw).min(img_w); + let y_end = (ry + rh).min(img_h); + let mut set_px = |x: u32, y: u32| { + if x < img_w && y < img_h { + let idx = ((y as usize) * (img_w as usize) + (x as usize)) * 4; + buf[idx..idx + 4].copy_from_slice(&color); + } + }; + + for t in 0..2u32 { + let top_y = ry.saturating_add(t); + let bot_y = y_end.saturating_sub(1 + t); + for x in rx..x_end { + set_px(x, top_y); + set_px(x, bot_y); + } + let left_x = rx.saturating_add(t); + let right_x = x_end.saturating_sub(1 + t); + for y in ry..y_end { + set_px(left_x, y); + set_px(right_x, y); + } + } +} diff --git a/crates/ttree-ocr/src/windows_ocr.rs b/crates/ttree-ocr/src/windows_ocr.rs new file mode 100644 index 0000000..7ae0c09 --- /dev/null +++ b/crates/ttree-ocr/src/windows_ocr.rs @@ -0,0 +1,339 @@ +//! Windows 增强型多语言、双通道自适应 `Windows.Media.Ocr` 引擎。 +//! +//! 针对旧版 Windows OCR 识别效果差的 5 项根本修复: +//! 1. **内存直构 `SoftwareBitmap`**:交互式截图直接由 `CryptographicBuffer::CreateFromByteArray` + +//! `SoftwareBitmap::CreateCopyWithAlphaFromBuffer(Bgra8)` 构建,彻底绕过易出错的临时文件与 `BitmapDecoder`; +//! 2. **双语优先的多引擎调度**:Windows 的 `zh-Hans`(简体中文)OCR 模型天然具备「中英双语混合识别」能力, +//! 而纯 `en-US` 引擎遇到汉字会输出乱码符号。因此优先探测并启用 `zh-Hans-CN` / `zh-Hans` / `zh-Hant`, +//! 当首选引擎得分过低时自动在已安装的其余语言引擎间回退择优; +//! 3. **双通道图像预处理重试(Pass-1 自然放大延展 + Pass-2 深色反相高对比度拉伸)**: +//! 解决小字号单行截图与深色模式(白字黑底/灰字暗底)漏识问题; +//! 4. **分词级 CJK 伪空格消除**:遍历 `OcrLine::Words()`,通过 `postprocess::join_ocr_words` 消除汉字间多余空格; +//! 5. **独立 MTA 线程执行 WinRT 异步等待**:在 `RO_INIT_MULTITHREADED` 线程内调用 `.get()`,杜绝 Win32 UI 线程 STA 死锁。 + +#![cfg(target_os = "windows")] + +use windows::core::HSTRING; +use windows::Globalization::Language; +use windows::Graphics::Imaging::{ + BitmapAlphaMode, BitmapDecoder, BitmapPixelFormat, SoftwareBitmap, +}; +use windows::Media::Ocr::{OcrEngine, OcrResult}; +use windows::Security::Cryptography::CryptographicBuffer; +use windows::Storage::Streams::{DataWriter, InMemoryRandomAccessStream}; +use windows::Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED}; + +use crate::postprocess::{clean_cjk_ocr_spaces, join_ocr_words, score_ocr_text}; +use crate::preprocess::{ + prepare_high_contrast_for_ocr, prepare_primary_for_ocr, RawBgraImage, +}; +use crate::OcrOutcome; + +const MISSING_LANG_PACK_HINT: &str = "未检测到可用的 Windows OCR 识别引擎。\n\ +请在「系统设置 → 时间和语言 → 语言和区域」中为「中文(简体)」或「English」安装「光学字符识别 (OCR)」功能,\n\ +或以管理员运行 PowerShell 执行:\n\ +Add-WindowsCapability -Online -Name \"Language.OCR~~~zh-CN~0.0.1.0\""; + +/// 对内存中的原始选区位图执行双通道增强 + 多语言择优 OCR。 +pub fn recognize_raw_bgra(raw: RawBgraImage) -> Result { + std::thread::spawn(move || { + unsafe { + let _ = RoInitialize(RO_INIT_MULTITHREADED); + } + recognize_raw_bgra_on_mta(&raw) + }) + .join() + .map_err(|_| "Windows OCR 工作线程异常退出".to_string())? +} + +/// 对磁盘上的 PNG / BMP / JPEG 图片文件执行 OCR。 +pub fn recognize_file(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("读取截图文件失败: {e}"))?; + std::thread::spawn(move || { + unsafe { + let _ = RoInitialize(RO_INIT_MULTITHREADED); + } + recognize_encoded_bytes_on_mta(&bytes) + }) + .join() + .map_err(|_| "Windows OCR 工作线程异常退出".to_string())? +} + +fn recognize_raw_bgra_on_mta(raw: &RawBgraImage) -> Result { + let engines = build_candidate_engines(); + if engines.is_empty() { + return Ok(OcrOutcome::PermissionDenied( + MISSING_LANG_PACK_HINT.to_string(), + )); + } + + // Pass 1:双线性放大 + 外圈自适应背景色填充边距 + let pass1_img = prepare_primary_for_ocr(raw)?; + let pass1_bmp = software_bitmap_from_bgra(&pass1_img)?; + + let mut best_text = String::new(); + let mut best_score: i64 = 0; + + if let Ok(text) = run_engine_and_format(&engines[0], &pass1_bmp) { + let sc = score_ocr_text(&text); + if sc > best_score { + best_score = sc; + best_text = text; + } + } + + // 若首选引擎在 Pass 1 已获得充足有效文本(例如 >= 6 分),直接返回以保证极速响应 + if best_score >= 6 { + return Ok(OcrOutcome::Text(best_text)); + } + + // Pass 2:深色模式自动反相 + 分位数对比度拉伸 + S 曲线锐化 + if let Ok(pass2_img) = prepare_high_contrast_for_ocr(raw) { + if let Ok(pass2_bmp) = software_bitmap_from_bgra(&pass2_img) { + if let Ok(text) = run_engine_and_format(&engines[0], &pass2_bmp) { + let sc = score_ocr_text(&text); + if sc > best_score { + best_score = sc; + best_text = text; + } + } + + // 若仍为空或得分极低,尝试备用语言引擎(例如用户框选了日文/韩文或纯英文) + if best_score < 6 { + for fallback_engine in engines.iter().skip(1).take(2) { + for bmp in [&pass1_bmp, &pass2_bmp] { + if let Ok(text) = run_engine_and_format(fallback_engine, bmp) { + let sc = score_ocr_text(&text); + if sc > best_score { + best_score = sc; + best_text = text; + } + } + } + if best_score >= 6 { + break; + } + } + } + } + } + + let trimmed = best_text.trim(); + if trimmed.is_empty() { + Ok(OcrOutcome::EmptyText) + } else { + Ok(OcrOutcome::Text(trimmed.to_string())) + } +} + +fn recognize_encoded_bytes_on_mta(bytes: &[u8]) -> Result { + let engines = build_candidate_engines(); + if engines.is_empty() { + return Ok(OcrOutcome::PermissionDenied( + MISSING_LANG_PACK_HINT.to_string(), + )); + } + + let stream = + InMemoryRandomAccessStream::new().map_err(|e| format!("创建内存流失败: {e}"))?; + let output = stream + .GetOutputStreamAt(0) + .map_err(|e| format!("获取输出流失败: {e}"))?; + let writer = + DataWriter::CreateDataWriter(&output).map_err(|e| format!("创建 DataWriter 失败: {e}"))?; + writer + .WriteBytes(bytes) + .map_err(|e| format!("写入图片字节失败: {e}"))?; + writer + .StoreAsync() + .map_err(|e| format!("存储流数据失败: {e}"))? + .get() + .map_err(|e| format!("等待流存储完成失败: {e}"))?; + stream + .Seek(0) + .map_err(|e| format!("重置流偏移失败: {e}"))?; + + let decoder = BitmapDecoder::CreateAsync(&stream) + .map_err(|e| format!("创建图片解码器失败: {e}"))? + .get() + .map_err(|e| format!("解码图片失败: {e}"))?; + + let decoded_bmp = decoder + .GetSoftwareBitmapAsync() + .map_err(|e| format!("读取位图失败: {e}"))? + .get() + .map_err(|e| format!("等待位图就绪失败: {e}"))?; + + let bgra_bmp = SoftwareBitmap::ConvertWithAlpha( + &decoded_bmp, + BitmapPixelFormat::Bgra8, + BitmapAlphaMode::Ignore, + ) + .unwrap_or(decoded_bmp); + + let mut best_text = String::new(); + let mut best_score: i64 = 0; + + for engine in engines.iter().take(3) { + if let Ok(text) = run_engine_and_format(engine, &bgra_bmp) { + let sc = score_ocr_text(&text); + if sc > best_score { + best_score = sc; + best_text = text; + } + if best_score >= 6 { + break; + } + } + } + + let trimmed = best_text.trim(); + if trimmed.is_empty() { + Ok(OcrOutcome::EmptyText) + } else { + Ok(OcrOutcome::Text(trimmed.to_string())) + } +} + +fn software_bitmap_from_bgra(img: &RawBgraImage) -> Result { + let ibuffer = CryptographicBuffer::CreateFromByteArray(&img.bgra) + .map_err(|e| format!("创建 WinRT 像素缓冲区失败: {e}"))?; + SoftwareBitmap::CreateCopyWithAlphaFromBuffer( + &ibuffer, + BitmapPixelFormat::Bgra8, + img.width as i32, + img.height as i32, + BitmapAlphaMode::Ignore, + ) + .map_err(|e| format!("构建 SoftwareBitmap 失败: {e}")) +} + +fn try_push_tag(engines: &mut Vec, seen_tags: &mut Vec, tag_str: &str) { + let lower = tag_str.to_ascii_lowercase(); + if seen_tags.contains(&lower) { + return; + } + if let Ok(lang) = Language::CreateLanguage(&HSTRING::from(tag_str)) { + if let Ok(eng) = OcrEngine::TryCreateFromLanguage(&lang) { + if let Ok(eng_lang) = eng.RecognizerLanguage() { + if let Ok(resolved) = eng_lang.LanguageTag() { + let resolved_lower = resolved.to_string().to_ascii_lowercase(); + if !seen_tags.contains(&resolved_lower) { + seen_tags.push(resolved_lower); + engines.push(eng); + return; + } + } + } + seen_tags.push(lower); + engines.push(eng); + } + } +} + +/// 按优先级构建已安装的 OCR 语言引擎列表: +/// 1. 简体中文 (`zh-Hans-CN` / `zh-Hans` / `zh-CN`) —— 支持中英双语混排,优先级最高 +/// 2. 繁体中文 (`zh-Hant-TW` / `zh-Hant-HK` / `zh-Hant`) +/// 3. 用户系统首选语言 (`TryCreateFromUserProfileLanguages`) +/// 4. 英文 / 日文 / 韩文 (`en-US` / `ja` / `ko`) +fn build_candidate_engines() -> Vec { + let mut engines = Vec::new(); + let mut seen_tags = Vec::::new(); + + // 1. 显式尝试中英双语能力最强的中文识别包 + for preferred in [ + "zh-Hans-CN", + "zh-Hans", + "zh-CN", + "zh-Hant-TW", + "zh-Hant-HK", + "zh-Hant", + ] { + try_push_tag(&mut engines, &mut seen_tags, preferred); + } + + // 2. 枚举系统已安装的识别语言,优先把任何 zh-* 排在前面 + if let Ok(installed) = OcrEngine::AvailableRecognizerLanguages() { + let count = installed.Size().unwrap_or(0); + for i in 0..count { + if let Ok(lang) = installed.GetAt(i) { + if let Ok(tag) = lang.LanguageTag() { + let s = tag.to_string(); + if s.to_ascii_lowercase().starts_with("zh") { + try_push_tag(&mut engines, &mut seen_tags, &s); + } + } + } + } + } + + // 3. 用户系统 Profile 默认语言 + if let Ok(user_eng) = OcrEngine::TryCreateFromUserProfileLanguages() { + let already_in = user_eng + .RecognizerLanguage() + .and_then(|l| l.LanguageTag()) + .map(|t| seen_tags.contains(&t.to_string().to_ascii_lowercase())) + .unwrap_or(false); + if !already_in { + engines.push(user_eng); + } + } + + // 4. 其余常用语言包兜底 + for extra in ["en-US", "en-GB", "ja", "ko"] { + try_push_tag(&mut engines, &mut seen_tags, extra); + } + + engines +} + +fn run_engine_and_format(engine: &OcrEngine, bitmap: &SoftwareBitmap) -> Result { + let result: OcrResult = engine + .RecognizeAsync(bitmap) + .map_err(|e| format!("启动 OCR 识别失败: {e}"))? + .get() + .map_err(|e| format!("等待 OCR 识别完成失败: {e}"))?; + + let lines = result + .Lines() + .map_err(|e| format!("读取 OCR 文本行失败: {e}"))?; + let line_count = lines.Size().unwrap_or(0); + if line_count == 0 { + return Ok(String::new()); + } + + let mut out_lines = Vec::with_capacity(line_count as usize); + for i in 0..line_count { + let Ok(line) = lines.GetAt(i) else { + continue; + }; + let mut word_pieces = Vec::new(); + if let Ok(words) = line.Words() { + let word_count = words.Size().unwrap_or(0); + for w_idx in 0..word_count { + if let Ok(w) = words.GetAt(w_idx) { + if let Ok(t) = w.Text() { + let s = t.to_string(); + if !s.trim().is_empty() { + word_pieces.push(s); + } + } + } + } + } + + let formatted_line = if !word_pieces.is_empty() { + join_ocr_words(&word_pieces) + } else if let Ok(raw_line) = line.Text() { + clean_cjk_ocr_spaces(&raw_line.to_string()) + } else { + String::new() + }; + + if !formatted_line.is_empty() { + out_lines.push(formatted_line); + } + } + + Ok(out_lines.join("\n")) +} diff --git a/crates/ttree-platform-macos/src/selection.rs b/crates/ttree-platform-macos/src/selection.rs index b66bbf9..949fc6b 100644 --- a/crates/ttree-platform-macos/src/selection.rs +++ b/crates/ttree-platform-macos/src/selection.rs @@ -218,6 +218,240 @@ mod imp { } } +#[cfg(target_os = "windows")] +#[allow(non_snake_case)] +mod win_imp { + use std::thread; + use std::time::{Duration, Instant}; + + use anyhow::{anyhow, Result}; + + use super::normalize_selected_text; + + const COPY_TIMEOUT: Duration = Duration::from_millis(280); + const COPY_POLL_INTERVAL: Duration = Duration::from_millis(12); + + type HANDLE = *mut core::ffi::c_void; + type BOOL = i32; + const NULL_HANDLE: HANDLE = std::ptr::null_mut(); + + const INPUT_KEYBOARD: u32 = 1; + const KEYEVENTF_KEYUP: u32 = 2; + const VK_SHIFT: u16 = 0x10; + const VK_CONTROL: u16 = 0x11; + const VK_MENU: u16 = 0x12; + const VK_C: u16 = 0x43; + const VK_LWIN: u16 = 0x5B; + const VK_RWIN: u16 = 0x5C; + const VK_LSHIFT: u16 = 0xA0; + const VK_RSHIFT: u16 = 0xA1; + const VK_LMENU: u16 = 0xA4; + const VK_RMENU: u16 = 0xA5; + const CF_UNICODETEXT: u32 = 13; + const GMEM_MOVEABLE: u32 = 0x0002; + + #[repr(C)] + #[derive(Copy, Clone)] + struct KEYBDINPUT { + wVk: u16, + wScan: u16, + dwFlags: u32, + time: u32, + dwExtraInfo: usize, + } + + #[repr(C)] + #[derive(Copy, Clone)] + union INPUT_0 { + ki: KEYBDINPUT, + _padding: [u8; 24], + } + + #[repr(C)] + #[derive(Copy, Clone)] + struct INPUT { + r#type: u32, + Anonymous: INPUT_0, + } + + extern "system" { + fn OpenClipboard(hWndNewOwner: HANDLE) -> BOOL; + fn CloseClipboard() -> BOOL; + fn EmptyClipboard() -> BOOL; + fn GetClipboardData(uFormat: u32) -> HANDLE; + fn SetClipboardData(uFormat: u32, hMem: HANDLE) -> HANDLE; + fn EnumClipboardFormats(format: u32) -> u32; + fn GetClipboardSequenceNumber() -> u32; + fn GlobalAlloc(uFlags: u32, dwBytes: usize) -> HANDLE; + fn GlobalLock(hMem: HANDLE) -> *mut core::ffi::c_void; + fn GlobalUnlock(hMem: HANDLE) -> BOOL; + fn GlobalSize(hMem: HANDLE) -> usize; + fn GlobalFree(hMem: HANDLE) -> HANDLE; + fn SendInput(cInputs: u32, pInputs: *const INPUT, cbSize: i32) -> u32; + } + + struct ClipboardSnapshot { + sequence: u32, + unicode_bytes: Option>, + } + + pub fn capture_selected_text() -> Result> { + unsafe { + let snapshot = snapshot_clipboard(); + post_copy_keypress()?; + + let Some(_new_seq) = wait_for_clipboard_change(snapshot.sequence) else { + return Ok(None); + }; + + let copied_text = read_clipboard_text().and_then(normalize_selected_text); + + // 若剪贴板仅发生本次 Ctrl+C 一次更新,无感还原用户原剪贴板文本 + if OpenClipboard(NULL_HANDLE) != 0 { + let current_seq = GetClipboardSequenceNumber(); + if current_seq == snapshot.sequence.wrapping_add(1) { + EmptyClipboard(); + if let Some(ref data) = snapshot.unicode_bytes { + let hmem = GlobalAlloc(GMEM_MOVEABLE, data.len()); + if !hmem.is_null() { + let ptr = GlobalLock(hmem); + if !ptr.is_null() { + std::ptr::copy_nonoverlapping( + data.as_ptr(), + ptr as *mut u8, + data.len(), + ); + GlobalUnlock(hmem); + SetClipboardData(CF_UNICODETEXT, hmem); + } else { + GlobalFree(hmem); + } + } + } + } + CloseClipboard(); + } + + Ok(copied_text) + } + } + + unsafe fn snapshot_clipboard() -> ClipboardSnapshot { + let sequence = GetClipboardSequenceNumber(); + let mut unicode_bytes = None; + + if OpenClipboard(NULL_HANDLE) != 0 { + let mut format = 0u32; + loop { + format = EnumClipboardFormats(format); + if format == 0 { + break; + } + if format == CF_UNICODETEXT { + let handle = GetClipboardData(CF_UNICODETEXT); + if !handle.is_null() { + let size = GlobalSize(handle); + let ptr = GlobalLock(handle); + if !ptr.is_null() && size > 0 { + unicode_bytes = + Some(std::slice::from_raw_parts(ptr as *const u8, size).to_vec()); + GlobalUnlock(handle); + } + } + break; + } + } + CloseClipboard(); + } + + ClipboardSnapshot { + sequence, + unicode_bytes, + } + } + + unsafe fn read_clipboard_text() -> Option { + if OpenClipboard(NULL_HANDLE) == 0 { + return None; + } + let mut text = None; + let handle = GetClipboardData(CF_UNICODETEXT); + if !handle.is_null() { + let ptr = GlobalLock(handle); + if !ptr.is_null() { + let mut len = 0usize; + let base = ptr as *const u16; + let max_u16 = GlobalSize(handle) / 2; + while len < max_u16 && *base.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(base, len); + text = String::from_utf16(slice).ok(); + GlobalUnlock(handle); + } + } + CloseClipboard(); + text + } + + fn key_input(vk: u16, flags: u32) -> INPUT { + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } + } + + fn post_copy_keypress() -> Result<()> { + unsafe { + // 先释放触发全局热键时用户可能仍按住的 Alt / Shift / Win 修饰键,防止合成出 Ctrl+Alt+C + let inputs = [ + key_input(VK_MENU, KEYEVENTF_KEYUP), + key_input(VK_LMENU, KEYEVENTF_KEYUP), + key_input(VK_RMENU, KEYEVENTF_KEYUP), + key_input(VK_SHIFT, KEYEVENTF_KEYUP), + key_input(VK_LSHIFT, KEYEVENTF_KEYUP), + key_input(VK_RSHIFT, KEYEVENTF_KEYUP), + key_input(VK_LWIN, KEYEVENTF_KEYUP), + key_input(VK_RWIN, KEYEVENTF_KEYUP), + key_input(VK_CONTROL, 0), + key_input(VK_C, 0), + key_input(VK_C, KEYEVENTF_KEYUP), + key_input(VK_CONTROL, KEYEVENTF_KEYUP), + ]; + let sent = SendInput( + inputs.len() as u32, + inputs.as_ptr(), + std::mem::size_of::() as i32, + ); + if sent != inputs.len() as u32 { + return Err(anyhow!("SendInput 发送 Ctrl+C 不完整")); + } + } + thread::sleep(Duration::from_millis(35)); + Ok(()) + } + + fn wait_for_clipboard_change(before: u32) -> Option { + let deadline = Instant::now() + COPY_TIMEOUT; + while Instant::now() < deadline { + let current = unsafe { GetClipboardSequenceNumber() }; + if current != before { + return Some(current); + } + thread::sleep(COPY_POLL_INTERVAL); + } + None + } +} + #[cfg(target_os = "macos")] pub fn capture_selected_text() -> Result> { imp::capture_selected_text() @@ -228,12 +462,22 @@ pub fn is_accessibility_trusted() -> bool { imp::is_accessibility_trusted() } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] +pub fn capture_selected_text() -> Result> { + win_imp::capture_selected_text() +} + +#[cfg(target_os = "windows")] +pub fn is_accessibility_trusted() -> bool { + true +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] pub fn capture_selected_text() -> Result> { Ok(None) } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] pub fn is_accessibility_trusted() -> bool { false } diff --git a/index.html b/index.html deleted file mode 100644 index 453f02e..0000000 --- a/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - TTREE - - -
- - - diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index bef6ba1..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2845 +0,0 @@ -{ - "name": "ttree", - "version": "0.1.5", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "ttree", - "version": "0.1.5", - "license": "MIT", - "dependencies": { - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-autostart": "^2", - "@tauri-apps/plugin-global-shortcut": "^2", - "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-store": "^2", - "@tauri-apps/plugin-updater": "^2.10.1" - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "@tailwindcss/vite": "^4.0.0", - "@tauri-apps/cli": "^2", - "svelte": "^5.0.0", - "svelte-check": "^4.0.0", - "tailwindcss": "^4.0.0", - "typescript": "~5.6.2", - "vite": "^6.0.3", - "vitest": "^4.1.9" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", - "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/load-config": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", - "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", - "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.1", - "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.17", - "vitefu": "^1.0.6" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", - "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.7" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "vite": "^6.0.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", - "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.1" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", - "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-x64": "4.3.1", - "@tailwindcss/oxide-freebsd-x64": "4.3.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-x64-musl": "4.3.1", - "@tailwindcss/oxide-wasm32-wasi": "4.3.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", - "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", - "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", - "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", - "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", - "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", - "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", - "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", - "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", - "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", - "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", - "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", - "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", - "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.1", - "@tailwindcss/oxide": "4.3.1", - "tailwindcss": "4.3.1" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@tauri-apps/api": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", - "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", - "license": "Apache-2.0 OR MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - } - }, - "node_modules/@tauri-apps/cli": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.3.tgz", - "integrity": "sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ==", - "dev": true, - "license": "Apache-2.0 OR MIT", - "bin": { - "tauri": "tauri.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - }, - "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.11.3", - "@tauri-apps/cli-darwin-x64": "2.11.3", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.3", - "@tauri-apps/cli-linux-arm64-gnu": "2.11.3", - "@tauri-apps/cli-linux-arm64-musl": "2.11.3", - "@tauri-apps/cli-linux-riscv64-gnu": "2.11.3", - "@tauri-apps/cli-linux-x64-gnu": "2.11.3", - "@tauri-apps/cli-linux-x64-musl": "2.11.3", - "@tauri-apps/cli-win32-arm64-msvc": "2.11.3", - "@tauri-apps/cli-win32-ia32-msvc": "2.11.3", - "@tauri-apps/cli-win32-x64-msvc": "2.11.3" - } - }, - "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.3.tgz", - "integrity": "sha512-BxpaM8bsCoXs3wd4WKYhas/G1gs7+r7B+e4WnyRk2GEoVOouJB1hoL6E6YLXZDXbYci6VFdrNnobQwd2uVL4ew==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.3.tgz", - "integrity": "sha512-DbZYuPB1ZEzcAHYeyCvo3ltzM27+aXwPloCrtexPnmgPgulYJm3TOq6aC4S+wPhSXteddg8zImtNkvx/gQzmwg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.3.tgz", - "integrity": "sha512-741NduqBmz1XkdU8yz3OI/kBZtqHbvxo9F9ytIeWYU69/Ba9dcZEbqOU++Dp0G/XU8vAI0TfTywEl+p+BbLvaA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.3.tgz", - "integrity": "sha512-RWAXT8pTqIczXcoic+LXlo6uEbAXGB0cgh6Pg7Y9xVnEbzryQ1JHtRGj9SxzrKSemBIDBH6Qc24kK2G69i8ofA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.3.tgz", - "integrity": "sha512-qomqYS+yAkd0gXMRmhguWXc7RfVN+XKKXaEwbf5QmKURwydLFOTldd6F8/WoZDSsBMrV8dpNxz0YneGLmobiSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.3.tgz", - "integrity": "sha512-jOCXbDqeDj5XcclsOBAaXjtTgwZCVg8zEZ+dbPUCoADOgljFgL0rOkYTc96vUYgOrYEfuHYihWMxIDGaD6GwJw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.3.tgz", - "integrity": "sha512-+u3HO/F3gHwL48t9gWN/urqZvpaEJzBFmTaq5eSIhvy8TOvnhb+LgJr3Q3BG+5JxuBrCUjqtOEz6gMttdJFSBA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.3.tgz", - "integrity": "sha512-spr5Jpr6KF/vehkLwJ0YmdGv8QwpWU+uw7J8bgijO0sox6ZCYsSNMbcsQjTqPi4xl+p0woIYpWXgChgHYpAc8g==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.3.tgz", - "integrity": "sha512-abkoRQih5xBa3vz2spWaex0kP/MzVzVPQHom2f8jnCq46R/luOD6Uy85EMU9/bfzf6ZzdorWJsgO+OMX90Fx2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.3.tgz", - "integrity": "sha512-Vy6AvzFm1G40hg3r+OYDB3jkuu7R4wnMzbQBKuun9v6Cgg8IierpLL7toMzrZKs/8NlG8Sg4x1iLFR52oknyHg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.3.tgz", - "integrity": "sha512-GlciF75GdbseajOyib2aCHwE3BXIqZ1liGKWLFRvCdN5wm8h8hFssEVKQ/6E+2jsMLg9v7LCTb983YFnn0QSww==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/plugin-autostart": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz", - "integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "node_modules/@tauri-apps/plugin-global-shortcut": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.2.tgz", - "integrity": "sha512-UReHNXrLvpEjylE4jb4oCYiy96uRykPUthoCQCmRXYrd5hs5X9DrW+qOn7GLW57EJN4tdK8bgK5twBTz2NOxzA==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-process": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", - "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "node_modules/@tauri-apps/plugin-store": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.3.tgz", - "integrity": "sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-updater": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", - "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.9", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.12.tgz", - "integrity": "sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/svelte": { - "version": "5.56.4", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", - "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.8.1", - "esm-env": "^1.2.1", - "esrap": "^2.2.12", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/svelte-check": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.1.tgz", - "integrity": "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "@sveltejs/load-config": "^0.2.0", - "chokidar": "^4.0.1", - "fdir": "^6.2.0", - "picocolors": "^1.0.0", - "sade": "^1.7.4" - }, - "bin": { - "svelte-check": "bin/svelte-check" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" - } - }, - "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 08e6887..0000000 --- a/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "ttree", - "version": "0.1.5", - "description": "轻量 macOS AI 翻译器", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.json", - "test": "vitest run", - "ci:install": "bash scripts/ci-install.sh", - "test:release": "bash scripts/tests/run-release-tests.sh", - "tauri": "tauri" - }, - "license": "MIT", - "dependencies": { - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-autostart": "^2", - "@tauri-apps/plugin-global-shortcut": "^2", - "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-store": "^2", - "@tauri-apps/plugin-updater": "^2.10.1" - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "@tailwindcss/vite": "^4.0.0", - "@tauri-apps/cli": "^2", - "svelte": "^5.0.0", - "svelte-check": "^4.0.0", - "tailwindcss": "^4.0.0", - "typescript": "~5.6.2", - "vite": "^6.0.3", - "vitest": "^4.1.9" - } -} diff --git a/scripts/package-pure-rust-macos.sh b/scripts/package-pure-rust-macos.sh index 3814038..e70a918 100755 --- a/scripts/package-pure-rust-macos.sh +++ b/scripts/package-pure-rust-macos.sh @@ -30,8 +30,8 @@ mkdir -p "$MACOS_BIN_DIR" "$RESOURCES_DIR" cp "$TARGET_DIR/release/ttree" "$MACOS_BIN_DIR/ttree" chmod +x "$MACOS_BIN_DIR/ttree" -if [[ -f "$ROOT_DIR/src-tauri/icons/icon.icns" ]]; then - cp "$ROOT_DIR/src-tauri/icons/icon.icns" "$RESOURCES_DIR/icon.icns" +if [[ -f "$ROOT_DIR/assets/icons/icon.icns" ]]; then + cp "$ROOT_DIR/assets/icons/icon.icns" "$RESOURCES_DIR/icon.icns" fi cat > "$CONTENTS_DIR/Info.plist" < - - - - LSUIElement - - - diff --git a/src-tauri/build.rs b/src-tauri/build.rs deleted file mode 100644 index d860e1e..0000000 --- a/src-tauri/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tauri_build::build() -} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json deleted file mode 100644 index 940ae61..0000000 --- a/src-tauri/capabilities/default.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "主窗口能力", - "windows": ["main"], - "permissions": [ - "core:default", - "core:window:allow-show", - "core:window:allow-hide", - "core:window:allow-set-focus", - "core:window:allow-center", - "core:window:allow-start-dragging", - "core:event:default", - "core:app:default", - "autostart:allow-enable", - "autostart:allow-disable", - "autostart:allow-is-enabled", - "store:allow-load", - "store:allow-get", - "store:allow-set", - "store:allow-save", - "updater:allow-check", - "updater:allow-download-and-install", - "process:allow-restart" - ] -} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs deleted file mode 100644 index 1d0754b..0000000 --- a/src-tauri/src/commands.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! 暴露给前端的 Tauri command。 - -use tauri::{AppHandle, Runtime}; - -use crate::config::{self, AppearanceConfig, ChatAiConfig, OpenAiConfig, ShortcutConfig}; -use crate::history; -use crate::ocr; -use crate::screenshot; -use crate::shortcut; -use crate::translate::{self, ChatMessage, Engine, TranslateRequest}; -use crate::window; - -/// 读取 OpenAI 配置。 -#[tauri::command] -pub fn get_openai_config(app: AppHandle) -> Result { - config::load_openai(&app) -} - -/// 保存 OpenAI 配置。 -#[tauri::command] -pub fn set_openai_config( - app: AppHandle, - config: OpenAiConfig, -) -> Result<(), String> { - crate::config::save_openai(&app, &config) -} - -/// 读取 AI 对话配置。 -#[tauri::command] -pub fn get_chat_ai_config(app: AppHandle) -> Result { - config::load_chat_ai(&app) -} - -/// 保存 AI 对话配置。 -#[tauri::command] -pub fn set_chat_ai_config( - app: AppHandle, - config: ChatAiConfig, -) -> Result<(), String> { - crate::config::save_chat_ai(&app, &config) -} - -/// 读取快捷键配置。 -#[tauri::command] -pub fn get_shortcut_config(app: AppHandle) -> ShortcutConfig { - config::load_shortcuts(&app) -} - -/// 保存快捷键配置并立即重注册。 -#[tauri::command] -pub fn set_shortcut_config( - app: AppHandle, - config: ShortcutConfig, -) -> Result<(), String> { - shortcut::apply_shortcuts(&app, &config).map_err(|e| e.to_string())?; - crate::config::save_shortcuts(&app, &config) -} - -/// 录制快捷键时临时取消注册全局快捷键,使按键能传到前端;结束时恢复。 -#[tauri::command] -pub fn set_shortcut_recording(app: AppHandle, active: bool) -> Result<(), String> { - shortcut::set_recording_active(&app, active) -} - -/// 显示并聚焦主窗口(前端在 OCR 完成后调用)。 -#[tauri::command] -pub fn show_main(app: AppHandle) { - window::show_main(&app); -} - -/// 普通 AI 对话命令:不使用翻译提示词。 -#[tauri::command] -pub async fn chat(app: AppHandle, messages: Vec) -> Result<(), String> { - if messages.is_empty() { - return Ok(()); - } - translate::validate_messages(&messages)?; - let cfg = config::load_chat_ai_for_request(&app)?; - translate::openai::chat_stream(&app, &cfg, &messages).await -} - -/// 读取固定窗口状态。 -#[tauri::command] -pub fn get_pinned() -> bool { - window::is_pinned() -} - -/// 读取外观配置。 -#[tauri::command] -pub fn get_appearance_config(app: AppHandle) -> AppearanceConfig { - config::load_appearance(&app) -} - -/// 保存外观配置。 -#[tauri::command] -pub fn set_appearance_config( - app: AppHandle, - config: AppearanceConfig, -) -> Result<(), String> { - crate::config::save_appearance(&app, &config) -} - -/// 设置固定窗口状态。固定时置顶且失焦不隐藏。 -#[tauri::command] -pub fn set_pinned(app: AppHandle, pinned: bool) -> Result { - let Some(w) = window::main_window(&app) else { - return Err("主窗口不存在".to_string()); - }; - window::set_pinned(&w, pinned).map_err(|e| format!("设置固定窗口失败: {e}")) -} - -/// 翻译命令。 -/// -/// - Auto:免费服务自动分流和故障切换,一次性返回完整译文 -/// - OpenAI:流式,通过 `translate-chunk` 事件推送,命令本身返回空串 -#[tauri::command] -pub async fn translate( - app: AppHandle, - req: TranslateRequest, -) -> Result { - let text = req.text.trim(); - if text.is_empty() { - return Ok(String::new()); - } - translate::validate_translation_text(text)?; - translate::validate_language_pair(&req.source, &req.target)?; - - match req.engine { - Engine::Auto => translate::auto::translate(text, &req.source, &req.target).await, - Engine::Google => translate::google::translate(text, &req.source, &req.target).await, - Engine::Bing => translate::bing::translate(text, &req.source, &req.target).await, - Engine::Openai => { - let cfg = config::load_openai_for_request(&app)?; - translate::openai::translate_stream( - &app, - &cfg, - req.request_id, - text, - &req.source, - &req.target, - ) - .await?; - Ok(String::new()) - } - } -} - -/// 截图 OCR 命令:Apple 自带自由框选 → 系统 OCR → 返回识别文本。 -/// -/// 用户取消截图时返回空串。在阻塞线程执行(screencapture 与 Vision 均为阻塞调用)。 -#[tauri::command] -pub async fn screenshot_ocr(app: AppHandle) -> Result { - static OCR_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - let Ok(_guard) = OCR_LOCK.try_lock() else { - // 忽略重复快捷键,避免多个系统框选进程和 Vision 任务抢占资源。 - return Ok(String::new()); - }; - let main = window::main_window(&app); - let was_visible = main.as_ref().is_some_and(|w| w.is_visible().unwrap_or(false)); - if let Some(main) = main.as_ref() { - window::hide_window(main); - } - // 等待窗口退出屏幕合成,固定窗口也不会遮挡用户要截取的文字。 - tokio::time::sleep(std::time::Duration::from_millis(120)).await; - let result = tauri::async_runtime::spawn_blocking(|| { - let Some(path) = screenshot::capture_interactive()? else { - return Ok(String::new()); - }; - let result = ocr::recognize_file(&path.to_string_lossy()); - // TempPath 离开作用域时自动清理临时截图。 - result - }) - .await - .map_err(|e| format!("OCR 任务失败: {e}"))?; - if was_visible && result.as_ref().map_or(true, |text| text.is_empty()) { - window::show_main(&app); - } - result -} - -/// 读取全部 AI 对话历史(JSONL 流式解析,损坏行容错)。 -#[tauri::command] -pub fn load_chat_history(app: AppHandle) -> Result, String> { - history::read_all(&app) -} - -/// 以对话为单位读取历史(按 session_start 标记切分)。 -#[tauri::command] -pub fn load_conversations( - app: AppHandle, -) -> Result, String> { - history::read_conversations(&app) -} - -/// 写入对话分隔标记,开启一段新对话(不影响已有历史)。 -#[tauri::command] -pub fn start_new_conversation(app: AppHandle) -> Result<(), String> { - history::start_new_conversation(&app) -} - -/// 追加一轮对话到历史文件(追加写、永不重写)。 -#[tauri::command] -pub fn append_chat_history( - app: AppHandle, - conversation_id: Option, - round: Vec, -) -> Result<(), String> { - translate::validate_messages(&round)?; - history::append_round(&app, conversation_id, &round) -} - -/// 清空全部对话历史(删除历史文件)。 -#[tauri::command] -pub fn clear_chat_history(app: AppHandle) -> Result<(), String> { - history::clear(&app) -} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs deleted file mode 100644 index 82a96db..0000000 --- a/src-tauri/src/config.rs +++ /dev/null @@ -1,1130 +0,0 @@ -//! 配置读写:基于 tauri-plugin-store 持久化用户设置。 -//! -//! 存储项:OpenAI base_url / model / 提示词、默认引擎、默认语言对、快捷键。 -//! 敏感凭据(api_key)不落盘,改存 macOS Keychain(见 `keychain` 模块)。 - -use std::sync::Mutex; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; -use tauri::{async_runtime, AppHandle, Runtime}; -use tauri_plugin_store::StoreExt; - -use crate::keychain; - -pub const STORE_FILE: &str = "settings.json"; -const MAX_BASE_URL_BYTES: usize = 2 * 1024; -const MAX_MODEL_BYTES: usize = 256; -const MAX_PROMPT_BYTES: usize = 64 * 1024; -const MAX_API_KEY_BYTES: usize = 16 * 1024; - -/// 串行化凭据配置的读取/保存,避免 Store 与 Keychain 两阶段更新期间被并发读取。 -static CREDENTIAL_CONFIG_LOCK: Mutex<()> = Mutex::new(()); - -fn lock_credential_config() -> Result, String> { - CREDENTIAL_CONFIG_LOCK - .lock() - .map_err(|e| format!("配置锁中毒: {e}")) -} - -fn validate_config_field(label: &str, value: &str, max_bytes: usize) -> Result<(), String> { - if value.len() > max_bytes { - return Err(format!("{label}超出 {max_bytes} 字节上限")); - } - Ok(()) -} - -/// 校验 OpenAI 兼容接口的 base_url: -/// - 远程地址必须使用 HTTPS -/// - HTTP 仅允许 localhost / loopback IP,兼容本地 LLM -/// - 拒绝 file:/data:/其它非网络 scheme,避免请求被引向本地文件协议 -/// -/// 返回规范化后的 URL(去除尾部斜杠),非法则返回 Err。 -fn normalize_base_url(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err("base_url 不能为空".to_string()); - } - validate_config_field("base_url", trimmed, MAX_BASE_URL_BYTES)?; - let url = url::Url::parse(trimmed).map_err(|e| format!("base_url 非法: {e}"))?; - match url.scheme() { - "https" => {} - "http" if is_loopback_url(&url) => {} - "http" => return Err("远程 base_url 必须使用 HTTPS;HTTP 仅允许本机回环地址".to_string()), - other => return Err(format!("base_url 必须为 HTTPS,不支持 {other}")), - } - if url.host().is_none() { - return Err("base_url 必须包含主机名或 IP 地址".to_string()); - } - let mut out = trimmed.trim_end_matches('/').to_string(); - if out.is_empty() { - out = trimmed.to_string(); - } - Ok(out) -} - -fn is_loopback_url(url: &url::Url) -> bool { - match url.host() { - Some(url::Host::Domain(host)) => { - host.trim_end_matches('.').eq_ignore_ascii_case("localhost") - } - Some(url::Host::Ipv4(host)) => host.is_loopback(), - Some(url::Host::Ipv6(host)) => host.is_loopback(), - None => false, - } -} - -/// OpenAI 兼容接口配置。 -#[derive(Clone, Serialize, Deserialize)] -pub struct OpenAiConfig { - pub base_url: String, - /// 仅作为保存请求中的新密钥;读取配置时始终为空,真实密钥不会发往 WebView。 - #[serde(default, serialize_with = "serialize_empty_api_key")] - pub api_key: String, - /// 当前端点是否已有绑定密钥,仅供设置页展示。 - #[serde(default)] - pub has_api_key: bool, - /// 保存时显式删除密钥;空 api_key 且 false 表示保留。 - #[serde(default)] - pub clear_api_key: bool, - pub model: String, - pub translate_prompt: String, -} - -impl std::fmt::Debug for OpenAiConfig { - // 脱敏:api_key 永不进入 Debug 输出,避免日志/dbg! 泄漏凭据。 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("OpenAiConfig") - .field("base_url", &self.base_url) - .field("api_key", &"") - .field("model", &self.model) - .finish_non_exhaustive() - } -} - -impl Default for OpenAiConfig { - fn default() -> Self { - Self { - base_url: "https://api.openai.com/v1".to_string(), - api_key: String::new(), - has_api_key: false, - clear_api_key: false, - model: "gpt-4o-mini".to_string(), - translate_prompt: "你是一个专业翻译引擎。将用户输入从 {source} 翻译成 {target}。只输出译文,不要解释、不要引号、不要附加任何说明。".to_string(), - } - } -} - -/// AI 对话接口配置(与 AI 翻译分开)。 -#[derive(Clone, Serialize, Deserialize)] -pub struct ChatAiConfig { - pub base_url: String, - /// 仅作为保存请求中的新密钥;读取配置时始终为空,真实密钥不会发往 WebView。 - #[serde(default, serialize_with = "serialize_empty_api_key")] - pub api_key: String, - #[serde(default)] - pub has_api_key: bool, - #[serde(default)] - pub clear_api_key: bool, - pub model: String, - /// 自定义系统提示词;空字符串表示不附加。 - #[serde(default)] - pub chat_prompt: String, -} - -impl std::fmt::Debug for ChatAiConfig { - // 脱敏:api_key 永不进入 Debug 输出,避免日志/dbg! 泄漏凭据。 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ChatAiConfig") - .field("base_url", &self.base_url) - .field("api_key", &"") - .field("model", &self.model) - .finish_non_exhaustive() - } -} - -impl Default for ChatAiConfig { - fn default() -> Self { - Self { - base_url: "https://api.openai.com/v1".to_string(), - api_key: String::new(), - has_api_key: false, - clear_api_key: false, - model: "gpt-4o-mini".to_string(), - chat_prompt: String::new(), - } - } -} - -fn serialize_empty_api_key(_value: &String, serializer: S) -> Result -where - S: serde::Serializer, -{ - serializer.serialize_str("") -} - -/// 全局快捷键配置。空字符串表示该快捷键未启用。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ShortcutConfig { - /// 呼出/收起翻译窗口。 - pub toggle: String, - /// 截图 OCR 翻译。 - pub ocr: String, - /// 呼出 AI 对话窗口。 - #[serde(default)] - pub ai_dialog: String, - /// 划词翻译:捕获选中文本并翻译。 - #[serde(default)] - pub selection_translate: String, - /// 划词 AI 对话:捕获选中文本并发送给 AI。 - #[serde(default)] - pub selection_ai_dialog: String, -} - -impl Default for ShortcutConfig { - fn default() -> Self { - Self { - toggle: "CmdOrCtrl+Shift+Space".to_string(), - ocr: "CmdOrCtrl+Shift+S".to_string(), - ai_dialog: String::new(), - selection_translate: String::new(), - selection_ai_dialog: String::new(), - } - } -} - -/// 外观配置。目前仅含面板透明度(0.0–1.0,默认完全不透明)。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppearanceConfig { - /// 面板整体透明度,范围 0.0–1.0。低于下限会被钳制。 - #[serde(default = "default_panel_opacity")] - pub panel_opacity: f64, -} - -fn default_panel_opacity() -> f64 { - 1.0 -} - -impl Default for AppearanceConfig { - fn default() -> Self { - Self { - panel_opacity: default_panel_opacity(), - } - } -} - -const PANEL_OPACITY_MIN: f64 = 0.35; -const PANEL_OPACITY_MAX: f64 = 1.0; - -impl AppearanceConfig { - /// 钳制到合法范围。 - pub fn clamp_opacity(value: f64) -> f64 { - value.clamp(PANEL_OPACITY_MIN, PANEL_OPACITY_MAX) - } -} - -/// 读取用于设置页展示的 OpenAI 配置。真实密钥不会返回给 WebView。 -pub fn load_openai(app: &AppHandle) -> Result { - let _guard = lock_credential_config()?; - load_openai_inner(app, false) -} - -/// 读取用于实际请求的 OpenAI 配置,包含与端点绑定的真实密钥。 -pub fn load_openai_for_request(app: &AppHandle) -> Result { - let _guard = lock_credential_config()?; - load_openai_inner(app, true) -} - -fn load_openai_inner( - app: &AppHandle, - include_secret: bool, -) -> Result { - let store = app - .store(STORE_FILE) - .map_err(|e| format!("打开配置失败: {e}"))?; - let mut cfg = OpenAiConfig::default(); - let persisted_endpoint = normalized_persisted_endpoint( - store - .get("openai.base_url") - .and_then(|v| v.as_str().map(String::from)), - )?; - if let Some(endpoint) = persisted_endpoint.as_ref() { - cfg.base_url = endpoint.clone(); - } - if let Some(v) = store - .get("openai.model") - .and_then(|v| v.as_str().map(String::from)) - { - if !v.is_empty() { - cfg.model = v; - } - } - if let Some(v) = store - .get("openai.translate_prompt") - .and_then(|v| v.as_str().map(String::from)) - { - if !v.is_empty() { - cfg.translate_prompt = v; - } - } - validate_config_field("模型", &cfg.model, MAX_MODEL_BYTES)?; - validate_config_field("翻译提示词", &cfg.translate_prompt, MAX_PROMPT_BYTES)?; - let secret = load_secret_migrated( - app, - "openai.api_key", - keychain::ACCOUNT_OPENAI_KEY, - persisted_endpoint.as_deref(), - )?; - cfg.has_api_key = secret.is_some(); - if include_secret { - cfg.api_key = secret.unwrap_or_default(); - } - Ok(cfg) -} - -/// 保存 OpenAI 配置:非敏感字段写 store,api_key 写 Keychain。 -pub fn save_openai(app: &AppHandle, cfg: &OpenAiConfig) -> Result<(), String> { - let _guard = lock_credential_config()?; - let base_url = normalize_base_url(&cfg.base_url)?; - validate_config_field("模型", &cfg.model, MAX_MODEL_BYTES)?; - validate_config_field("翻译提示词", &cfg.translate_prompt, MAX_PROMPT_BYTES)?; - let update = credential_update(&cfg.api_key, cfg.clear_api_key)?; - let store = app - .store(STORE_FILE) - .map_err(|e| format!("打开配置失败: {e}"))?; - if update == CredentialUpdate::Preserve { - let current_endpoint = normalized_persisted_endpoint( - store - .get("openai.base_url") - .and_then(|v| v.as_str().map(String::from)), - )?; - let existing = load_secret_migrated( - app, - "openai.api_key", - keychain::ACCOUNT_OPENAI_KEY, - current_endpoint.as_deref(), - )? - .map(ResolvedSecret::Current) - .unwrap_or(ResolvedSecret::Missing); - validate_preserved_secret_endpoint(current_endpoint.as_deref(), &base_url, &existing)?; - } - let values = [ - ( - "openai.base_url", - serde_json::Value::String(base_url.clone()), - ), - ("openai.model", serde_json::Value::String(cfg.model.clone())), - ( - "openai.translate_prompt", - serde_json::Value::String(cfg.translate_prompt.clone()), - ), - ]; - save_config_transaction( - &store, - keychain::ACCOUNT_OPENAI_KEY, - &base_url, - update, - &values, - "openai.api_key", - "保存配置失败", - ) -} - -/// 读取用于设置页展示的 AI 对话配置。真实密钥不会返回给 WebView。 -pub fn load_chat_ai(app: &AppHandle) -> Result { - let _guard = lock_credential_config()?; - load_chat_ai_inner(app, false) -} - -/// 读取用于实际请求的 AI 对话配置。 -pub fn load_chat_ai_for_request(app: &AppHandle) -> Result { - let _guard = lock_credential_config()?; - load_chat_ai_inner(app, true) -} - -fn load_chat_ai_inner( - app: &AppHandle, - include_secret: bool, -) -> Result { - let store = app - .store(STORE_FILE) - .map_err(|e| format!("打开配置失败: {e}"))?; - let mut cfg = ChatAiConfig::default(); - let persisted_endpoint = normalized_persisted_endpoint( - store - .get("chat_ai.base_url") - .and_then(|v| v.as_str().map(String::from)), - )?; - if let Some(endpoint) = persisted_endpoint.as_ref() { - cfg.base_url = endpoint.clone(); - } - if let Some(v) = store - .get("chat_ai.model") - .and_then(|v| v.as_str().map(String::from)) - { - if !v.is_empty() { - cfg.model = v; - } - } - if let Some(v) = store - .get("chat_ai.chat_prompt") - .and_then(|v| v.as_str().map(String::from)) - { - cfg.chat_prompt = v; - } - validate_config_field("模型", &cfg.model, MAX_MODEL_BYTES)?; - validate_config_field("AI 对话提示词", &cfg.chat_prompt, MAX_PROMPT_BYTES)?; - let secret = load_secret_migrated( - app, - "chat_ai.api_key", - keychain::ACCOUNT_CHAT_AI_KEY, - persisted_endpoint.as_deref(), - )?; - cfg.has_api_key = secret.is_some(); - if include_secret { - cfg.api_key = secret.unwrap_or_default(); - } - Ok(cfg) -} - -/// 保存 AI 对话配置:非敏感字段写 store,api_key 写 Keychain。 -pub fn save_chat_ai(app: &AppHandle, cfg: &ChatAiConfig) -> Result<(), String> { - let _guard = lock_credential_config()?; - let base_url = normalize_base_url(&cfg.base_url)?; - validate_config_field("模型", &cfg.model, MAX_MODEL_BYTES)?; - validate_config_field("AI 对话提示词", &cfg.chat_prompt, MAX_PROMPT_BYTES)?; - let update = credential_update(&cfg.api_key, cfg.clear_api_key)?; - let store = app - .store(STORE_FILE) - .map_err(|e| format!("打开配置失败: {e}"))?; - if update == CredentialUpdate::Preserve { - let current_endpoint = normalized_persisted_endpoint( - store - .get("chat_ai.base_url") - .and_then(|v| v.as_str().map(String::from)), - )?; - let existing = load_secret_migrated( - app, - "chat_ai.api_key", - keychain::ACCOUNT_CHAT_AI_KEY, - current_endpoint.as_deref(), - )? - .map(ResolvedSecret::Current) - .unwrap_or(ResolvedSecret::Missing); - validate_preserved_secret_endpoint(current_endpoint.as_deref(), &base_url, &existing)?; - } - let values = [ - ( - "chat_ai.base_url", - serde_json::Value::String(base_url.clone()), - ), - ( - "chat_ai.model", - serde_json::Value::String(cfg.model.clone()), - ), - ( - "chat_ai.chat_prompt", - serde_json::Value::String(cfg.chat_prompt.clone()), - ), - ]; - save_config_transaction( - &store, - keychain::ACCOUNT_CHAT_AI_KEY, - &base_url, - update, - &values, - "chat_ai.api_key", - "保存 AI 对话配置失败", - ) -} - -/// 从 store 读取快捷键配置,缺失则回退默认值。 -pub fn load_shortcuts(app: &AppHandle) -> ShortcutConfig { - let Ok(store) = app.store(STORE_FILE) else { - return ShortcutConfig::default(); - }; - let defaults = ShortcutConfig::default(); - ShortcutConfig { - toggle: persisted_shortcut_value( - store - .get("shortcut.toggle") - .and_then(|v| v.as_str().map(String::from)), - &defaults.toggle, - ), - ocr: persisted_shortcut_value( - store - .get("shortcut.ocr") - .and_then(|v| v.as_str().map(String::from)), - &defaults.ocr, - ), - ai_dialog: persisted_shortcut_value( - store - .get("shortcut.ai_dialog") - .and_then(|v| v.as_str().map(String::from)), - &defaults.ai_dialog, - ), - selection_translate: persisted_shortcut_value( - store - .get("shortcut.selection_translate") - .and_then(|v| v.as_str().map(String::from)), - &defaults.selection_translate, - ), - selection_ai_dialog: persisted_shortcut_value( - store - .get("shortcut.selection_ai_dialog") - .and_then(|v| v.as_str().map(String::from)), - &defaults.selection_ai_dialog, - ), - } -} - -fn persisted_shortcut_value(value: Option, default: &str) -> String { - value.unwrap_or_else(|| default.to_string()) -} - -/// 保存快捷键配置到 store。 -pub fn save_shortcuts(app: &AppHandle, cfg: &ShortcutConfig) -> Result<(), String> { - let store = app - .store(STORE_FILE) - .map_err(|e| format!("打开配置失败: {e}"))?; - store.set("shortcut.toggle", cfg.toggle.as_str()); - store.set("shortcut.ocr", cfg.ocr.as_str()); - store.set("shortcut.ai_dialog", cfg.ai_dialog.as_str()); - store.set( - "shortcut.selection_translate", - cfg.selection_translate.as_str(), - ); - store.set( - "shortcut.selection_ai_dialog", - cfg.selection_ai_dialog.as_str(), - ); - store.save().map_err(|e| format!("保存快捷键失败: {e}"))?; - Ok(()) -} - -/// 从 store 读取外观配置,缺失或非法则回退默认值并钳制。 -pub fn load_appearance(app: &AppHandle) -> AppearanceConfig { - let mut cfg = AppearanceConfig::default(); - if let Ok(store) = app.store(STORE_FILE) { - if let Some(v) = store - .get("appearance.panel_opacity") - .and_then(|v| v.as_f64()) - { - cfg.panel_opacity = AppearanceConfig::clamp_opacity(v); - } - } - cfg -} - -/// 保存外观配置到 store(写入前先钳制)。 -pub fn save_appearance( - app: &AppHandle, - cfg: &AppearanceConfig, -) -> Result<(), String> { - let store = app - .store(STORE_FILE) - .map_err(|e| format!("打开配置失败: {e}"))?; - store.set( - "appearance.panel_opacity", - AppearanceConfig::clamp_opacity(cfg.panel_opacity), - ); - store.save().map_err(|e| format!("保存外观配置失败: {e}"))?; - Ok(()) -} - -/// 窗口尺寸下限,与 tauri.conf.json 的 minWidth/minHeight 保持一致。 -pub const WINDOW_MIN_WIDTH: f64 = 480.0; -pub const WINDOW_MIN_HEIGHT: f64 = 300.0; - -/// 读取上次用户调整后的窗口尺寸;缺失或过小则返回 None(沿用配置默认值)。 -pub fn load_window_size(app: &AppHandle) -> Option<(f64, f64)> { - let store = app.store(STORE_FILE).ok()?; - let width = store.get("window.width").and_then(|v| v.as_f64())?; - let height = store.get("window.height").and_then(|v| v.as_f64())?; - if width < WINDOW_MIN_WIDTH || height < WINDOW_MIN_HEIGHT { - return None; - } - Some((width, height)) -} - -#[derive(Default)] -struct WindowSizeQueue { - pending: Option<(f64, f64)>, - worker_running: bool, -} - -impl WindowSizeQueue { - /// 返回 true 表示调用方需要启动唯一的落盘 worker。 - fn enqueue(&mut self, size: (f64, f64)) -> bool { - self.pending = Some(size); - if self.worker_running { - return false; - } - self.worker_running = true; - true - } - - fn take_pending(&mut self) -> Option<(f64, f64)> { - self.pending.take() - } - - /// 一轮写盘结束后调用。返回 true 表示期间又有 resize,worker 应继续。 - fn finish_flush(&mut self) -> bool { - if self.pending.is_some() { - true - } else { - self.worker_running = false; - false - } - } -} - -/// 待落盘尺寸与 worker 状态必须受同一把锁保护,避免“取值”和“放行调度”之间丢事件。 -static WINDOW_SIZE_QUEUE: Mutex = Mutex::new(WindowSizeQueue { - pending: None, - worker_running: false, -}); -/// 尾部防抖间隔:停止调整 500ms 后才写一次盘。 -const SIZE_FLUSH_INTERVAL: Duration = Duration::from_millis(500); - -/// 记录用户调整后的窗口尺寸,并防抖落盘。 -/// -/// `WindowEvent::Resized` 在拖拽过程中每秒触发数十次;若每次都同步 `store.save()` -/// 会在主线程上反复序列化整个 settings.json 并写盘,导致明显卡顿。 -/// 这里只更新内存中的最新尺寸,并保证同一时刻最多只有一个落盘任务在排队: -/// 该任务先睡 500ms(吸收后续高频事件),醒来后取出最新值写盘。 -/// 写盘在阻塞线程执行,不阻塞主线程。 -pub fn save_window_size(app: &AppHandle, width: f64, height: f64) { - let should_start = WINDOW_SIZE_QUEUE - .lock() - .map(|mut queue| { - queue.enqueue((width.max(WINDOW_MIN_WIDTH), height.max(WINDOW_MIN_HEIGHT))) - }) - .unwrap_or(false); - if !should_start { - return; - } - let app = app.clone(); - async_runtime::spawn(async move { - async_runtime::spawn_blocking(move || window_size_worker(&app)) - .await - .ok(); - }); -} - -fn window_size_worker(app: &AppHandle) { - loop { - std::thread::sleep(SIZE_FLUSH_INTERVAL); - let size = WINDOW_SIZE_QUEUE - .lock() - .ok() - .and_then(|mut queue| queue.take_pending()); - if let Some((width, height)) = size { - flush_window_size(app, width, height); - } - let should_continue = WINDOW_SIZE_QUEUE - .lock() - .map(|mut queue| queue.finish_flush()) - .unwrap_or(false); - if !should_continue { - break; - } - } -} - -fn flush_window_size(app: &AppHandle, width: f64, height: f64) { - let Ok(store) = app.store(STORE_FILE) else { - return; - }; - store.set("window.width", width); - store.set("window.height", height); - if let Err(e) = store.save() { - eprintln!("[config] 持久化窗口尺寸失败: {e}"); - } -} - -// ── Keychain 凭据辅助 ────────────────────────────────────────── - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CredentialUpdate<'a> { - Preserve, - Clear, - Replace(&'a str), -} - -fn credential_update(api_key: &str, clear_api_key: bool) -> Result, String> { - let api_key = api_key.trim(); - validate_config_field("API Key", api_key, MAX_API_KEY_BYTES)?; - match (api_key.is_empty(), clear_api_key) { - (true, false) => Ok(CredentialUpdate::Preserve), - (true, true) => Ok(CredentialUpdate::Clear), - (false, false) => Ok(CredentialUpdate::Replace(api_key)), - (false, true) => Err("不能同时设置新 API Key 和清除 API Key".to_string()), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ResolvedSecret { - Missing, - Current(String), - Legacy(String), -} - -fn normalized_persisted_endpoint(raw: Option) -> Result, String> { - raw.filter(|value| !value.trim().is_empty()) - .map(|value| normalize_base_url(&value)) - .transpose() -} - -fn resolve_secret_read( - endpoint: Option<&str>, - read: Result, String>, -) -> Result { - let record = read.map_err(|e| format!("读取 Keychain 失败: {e}"))?; - match record { - None => Ok(ResolvedSecret::Missing), - Some(keychain::SecretRecord::Legacy(value)) => { - if value.is_empty() { - return Err("Keychain 中的 API Key 为空".to_string()); - } - validate_config_field("API Key", &value, MAX_API_KEY_BYTES)?; - if endpoint.is_none() { - return Err("配置缺少已持久化接口地址,拒绝使用未绑定的 API Key".to_string()); - } - Ok(ResolvedSecret::Legacy(value)) - } - Some(keychain::SecretRecord::Bound { - endpoint: bound_endpoint, - value, - }) => { - let Some(endpoint) = endpoint else { - return Err("配置缺少已持久化接口地址,拒绝使用已保存的 API Key".to_string()); - }; - let bound_endpoint = normalize_base_url(&bound_endpoint) - .map_err(|e| format!("Keychain 绑定的接口地址非法: {e}"))?; - if bound_endpoint != endpoint { - return Err("API Key 与当前接口地址不匹配,请重新输入或显式清除密钥".to_string()); - } - validate_config_field("API Key", &value, MAX_API_KEY_BYTES)?; - Ok(ResolvedSecret::Current(value)) - } - } -} - -/// 读取 API Key,并把旧版裸 Keychain 值或 store 明文迁移为端点绑定记录。 -fn load_secret_migrated( - app: &AppHandle, - legacy_store_key: &str, - account: &str, - endpoint: Option<&str>, -) -> Result, String> { - match resolve_secret_read(endpoint, keychain::get_secret_record(account))? { - ResolvedSecret::Current(value) => { - let store = app - .store(STORE_FILE) - .map_err(|e| format!("打开配置失败: {e}"))?; - clear_legacy_secret(&store, legacy_store_key, "清理旧明文 API Key 失败")?; - return Ok(Some(value)); - } - ResolvedSecret::Legacy(value) => { - let endpoint = endpoint.expect("legacy secret resolution requires an endpoint"); - keychain::set_bound_secret(account, endpoint, &value) - .map_err(|e| format!("迁移 Keychain({account}) 失败: {e}"))?; - let store = app - .store(STORE_FILE) - .map_err(|e| format!("打开配置失败: {e}"))?; - clear_legacy_secret(&store, legacy_store_key, "清理旧明文 API Key 失败")?; - return Ok(Some(value)); - } - ResolvedSecret::Missing => {} - } - - let store = app - .store(STORE_FILE) - .map_err(|e| format!("打开配置失败: {e}"))?; - let legacy_value = store - .get(legacy_store_key) - .and_then(|v| v.as_str().map(str::to_owned)) - .filter(|value| !value.is_empty()); - let Some(value) = legacy_value else { - return Ok(None); - }; - validate_config_field("API Key", &value, MAX_API_KEY_BYTES)?; - let Some(endpoint) = endpoint else { - return Err("配置缺少已持久化接口地址,拒绝迁移未绑定的 API Key".to_string()); - }; - keychain::set_bound_secret(account, endpoint, &value) - .map_err(|e| format!("迁移 Keychain({account}) 失败: {e}"))?; - clear_legacy_secret(&store, legacy_store_key, "清理旧明文 API Key 失败")?; - Ok(Some(value)) -} - -fn validate_preserved_secret_endpoint( - current_endpoint: Option<&str>, - new_endpoint: &str, - secret: &ResolvedSecret, -) -> Result<(), String> { - if matches!(secret, ResolvedSecret::Missing) { - return Ok(()); - } - if current_endpoint == Some(new_endpoint) { - return Ok(()); - } - Err("接口地址已改变;请重新输入 API Key,或显式清除已保存的密钥".to_string()) -} - -fn apply_credential_update( - account: &str, - endpoint: &str, - update: CredentialUpdate<'_>, -) -> Result<(), String> { - match update { - CredentialUpdate::Preserve => Ok(()), - CredentialUpdate::Clear => keychain::delete_secret(account), - CredentialUpdate::Replace(value) => keychain::set_bound_secret(account, endpoint, value), - } -} - -fn restore_raw_secret(account: &str, value: &Option) -> Result<(), String> { - match value { - Some(value) => keychain::set_secret(account, value), - None => keychain::delete_secret(account), - } -} - -#[derive(Clone)] -struct StoreSnapshot { - entries: Vec<(String, Option)>, -} - -fn snapshot_store( - store: &tauri_plugin_store::Store, - keys: &[&str], -) -> StoreSnapshot { - StoreSnapshot { - entries: keys - .iter() - .map(|key| ((*key).to_string(), store.get(key))) - .collect(), - } -} - -fn restore_store(store: &tauri_plugin_store::Store, snapshot: &StoreSnapshot) { - for (key, value) in &snapshot.entries { - if let Some(value) = value { - store.set(key, value.clone()); - } else { - store.delete(key); - } - } -} - -fn run_compensating_update( - mut apply_credential: ApplyCredential, - mut commit_store: CommitStore, - mut rollback_store: RollbackStore, - mut rollback_credential: RollbackCredential, -) -> Result<(), String> -where - ApplyCredential: FnMut() -> Result<(), String>, - CommitStore: FnMut() -> Result<(), String>, - RollbackStore: FnMut() -> Result<(), String>, - RollbackCredential: FnMut() -> Result<(), String>, -{ - apply_credential()?; - let Err(commit_error) = commit_store() else { - return Ok(()); - }; - - let store_rollback_error = rollback_store().err(); - let credential_rollback_error = rollback_credential().err(); - let mut message = format!("提交配置失败: {commit_error}"); - if let Some(error) = store_rollback_error { - message.push_str(&format!(";回滚 Store 失败: {error}")); - } - if let Some(error) = credential_rollback_error { - message.push_str(&format!(";回滚 Keychain 失败: {error}")); - } - Err(message) -} - -fn save_config_transaction( - store: &tauri_plugin_store::Store, - account: &str, - endpoint: &str, - update: CredentialUpdate<'_>, - values: &[(&str, serde_json::Value)], - legacy_store_key: &str, - error_context: &str, -) -> Result<(), String> { - let mut keys: Vec<&str> = values.iter().map(|(key, _)| *key).collect(); - keys.push(legacy_store_key); - let snapshot = snapshot_store(store, &keys); - let previous_secret = - keychain::get_secret(account).map_err(|e| format!("读取 Keychain 以准备保存失败: {e}"))?; - - run_compensating_update( - || { - apply_credential_update(account, endpoint, update) - .map_err(|e| format!("更新 Keychain 失败: {e}")) - }, - || { - for (key, value) in values { - store.set(*key, value.clone()); - } - store.delete(legacy_store_key); - store.save().map_err(|e| format!("{error_context}: {e}")) - }, - || { - restore_store(store, &snapshot); - store.save().map_err(|e| format!("恢复原配置失败: {e}")) - }, - || { - restore_raw_secret(account, &previous_secret) - .map_err(|e| format!("恢复原 Keychain 凭据失败: {e}")) - }, - ) -} - -fn persist_deletion_with_restore( - mut delete: Delete, - mut persist: Persist, - mut restore: Restore, -) -> Result<(), String> -where - Delete: FnMut(), - Persist: FnMut() -> Result<(), String>, - Restore: FnMut(), -{ - delete(); - if let Err(error) = persist() { - restore(); - return Err(error); - } - Ok(()) -} - -fn clear_legacy_secret( - store: &tauri_plugin_store::Store, - legacy_store_key: &str, - error_context: &str, -) -> Result<(), String> { - let Some(previous_value) = store.get(legacy_store_key) else { - return Ok(()); - }; - persist_deletion_with_restore( - || { - store.delete(legacy_store_key); - }, - || store.save().map_err(|e| format!("{error_context}: {e}")), - || store.set(legacy_store_key, previous_value.clone()), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::keychain::SecretRecord; - use std::cell::RefCell; - - #[test] - fn rejects_plain_http_for_remote_hosts() { - let error = normalize_base_url("http://api.example.com/v1").unwrap_err(); - - assert!(error.contains("HTTPS")); - } - - #[test] - fn allows_plain_http_for_loopback_hosts() { - assert_eq!( - normalize_base_url("http://localhost:11434/v1/").unwrap(), - "http://localhost:11434/v1" - ); - assert_eq!( - normalize_base_url("http://127.0.0.1:8080/v1").unwrap(), - "http://127.0.0.1:8080/v1" - ); - assert_eq!( - normalize_base_url("http://[::1]:8080/v1").unwrap(), - "http://[::1]:8080/v1" - ); - } - - #[test] - fn credential_update_distinguishes_preserve_clear_and_replace() { - assert_eq!( - credential_update("", false).unwrap(), - CredentialUpdate::Preserve - ); - assert_eq!( - credential_update("", true).unwrap(), - CredentialUpdate::Clear - ); - assert_eq!( - credential_update(" new-secret ", false).unwrap(), - CredentialUpdate::Replace("new-secret") - ); - assert!(credential_update("new-secret", true).is_err()); - } - - #[test] - fn serialized_configs_never_expose_real_api_keys() { - let config = OpenAiConfig { - api_key: "top-secret".to_string(), - has_api_key: true, - ..OpenAiConfig::default() - }; - - let serialized = serde_json::to_value(config).unwrap(); - assert_eq!(serialized["api_key"], ""); - assert!(!serialized.to_string().contains("top-secret")); - } - - #[test] - fn keychain_read_errors_fail_closed() { - let result = resolve_secret_read( - Some("https://api.example.com/v1"), - Err("Keychain access denied".to_string()), - ); - - assert!(result.unwrap_err().contains("Keychain access denied")); - } - - #[test] - fn bound_secret_must_match_the_persisted_endpoint() { - let record = SecretRecord::Bound { - endpoint: "https://old.example.com/v1".to_string(), - value: "secret".to_string(), - }; - - let error = - resolve_secret_read(Some("https://new.example.com/v1"), Ok(Some(record))).unwrap_err(); - assert!(error.contains("不匹配")); - } - - #[test] - fn secret_without_a_persisted_endpoint_is_rejected() { - let record = SecretRecord::Legacy("secret".to_string()); - - assert!(resolve_secret_read(None, Ok(Some(record))).is_err()); - } - - #[test] - fn changing_endpoint_requires_replacing_or_clearing_the_key() { - let existing = ResolvedSecret::Current("secret".to_string()); - - assert!(validate_preserved_secret_endpoint( - Some("https://old.example.com/v1"), - "https://new.example.com/v1", - &existing, - ) - .is_err()); - assert!(validate_preserved_secret_endpoint( - Some("https://old.example.com/v1"), - "https://old.example.com/v1", - &existing, - ) - .is_ok()); - } - - #[test] - fn shortcut_empty_values_override_defaults() { - assert_eq!( - persisted_shortcut_value(Some(String::new()), "CmdOrCtrl+Shift+Space"), - "" - ); - assert_eq!( - persisted_shortcut_value(None, "CmdOrCtrl+Shift+Space"), - "CmdOrCtrl+Shift+Space" - ); - } - - #[test] - fn window_size_queue_never_loses_resize_during_a_flush() { - let mut queue = WindowSizeQueue::default(); - - assert!(queue.enqueue((640.0, 480.0))); - assert!(!queue.enqueue((700.0, 500.0))); - assert_eq!(queue.take_pending(), Some((700.0, 500.0))); - - // A resize that arrives while the worker is writing must keep the same worker alive. - assert!(!queue.enqueue((800.0, 600.0))); - assert!(queue.finish_flush()); - assert_eq!(queue.take_pending(), Some((800.0, 600.0))); - assert!(!queue.finish_flush()); - - // Once the worker exits, the next resize must schedule a new worker. - assert!(queue.enqueue((900.0, 700.0))); - } - - #[test] - fn failed_store_commit_runs_both_compensating_rollbacks() { - let events = RefCell::new(Vec::new()); - - let error = run_compensating_update( - || { - events.borrow_mut().push("apply-credential"); - Ok(()) - }, - || { - events.borrow_mut().push("commit-store"); - Err("disk denied".to_string()) - }, - || { - events.borrow_mut().push("rollback-store"); - Ok(()) - }, - || { - events.borrow_mut().push("rollback-credential"); - Ok(()) - }, - ) - .unwrap_err(); - - assert!(error.contains("disk denied")); - assert_eq!( - events.into_inner(), - [ - "apply-credential", - "commit-store", - "rollback-store", - "rollback-credential", - ] - ); - } - - #[test] - fn rejects_oversized_config_fields() { - assert!(normalize_base_url(&format!( - "https://example.com/{}", - "x".repeat(MAX_BASE_URL_BYTES) - )) - .is_err()); - assert!( - validate_config_field("模型", &"x".repeat(MAX_MODEL_BYTES + 1), MAX_MODEL_BYTES) - .is_err() - ); - assert!(validate_config_field( - "提示词", - &"x".repeat(MAX_PROMPT_BYTES + 1), - MAX_PROMPT_BYTES, - ) - .is_err()); - assert!(credential_update(&"x".repeat(MAX_API_KEY_BYTES + 1), false).is_err()); - } - - #[test] - fn failed_legacy_secret_cleanup_restores_the_cached_value() { - let events = RefCell::new(Vec::new()); - - let error = persist_deletion_with_restore( - || events.borrow_mut().push("delete"), - || Err("disk denied".to_string()), - || events.borrow_mut().push("restore"), - ) - .unwrap_err(); - - assert!(error.contains("disk denied")); - assert_eq!(events.into_inner(), ["delete", "restore"]); - } -} diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs deleted file mode 100644 index 8dbc604..0000000 --- a/src-tauri/src/history.rs +++ /dev/null @@ -1,458 +0,0 @@ -//! AI 对话历史持久化 —— 行分隔 JSON (JSONL) 追加文件。 -//! -//! 设计目标:**最省存储 + 全量 + 以对话为单位**。 -//! - 追加写:每轮结束把消息各写一行,`OpenOptions::append`,**永不重写**旧数据, -//! O(1) 写入、最小 I/O。 -//! - 行分隔:无 JSON 数组括号/缩进,每行一条 JSON,天然最小体积。 -//! - 流式读:`BufReader::lines()` 逐行解析,单行损坏不影响其余(容错)。 -//! - 独立文件 `chat-history.jsonl`,与 `settings.json`(含 API 配置)物理隔离。 -//! - 对话边界:用一条 `{"type":"session_start"}` 标记新对话开始。 -//! 旧数据(无标记)自动归为最旧的一段,向后兼容。 - -use std::fs::{self, File, OpenOptions}; -use std::io::{BufRead, BufReader, Write}; -use std::path::Path; -use std::sync::Mutex; - -use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Manager, Runtime}; - -use crate::translate::ChatMessage; - -/// 历史文件名(与 settings.json 同目录,但独立文件)。 -const HISTORY_FILE: &str = "chat-history.jsonl"; - -/// 序列化所有历史读写:多个 Tauri 命令(append/read/clear)可能并发执行, -/// 若各自 open + write_all 会交错写入、破坏 JSONL。临界区很短,单把锁足够。 -static HISTORY_LOCK: Mutex<()> = Mutex::new(()); - -/// 单段对话:包含一组消息,以及用于列表展示的摘要(首句用户提问)。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Conversation { - /// 稳定会话 id;恢复旧对话后返回顺序会变化,但 id 保持不变。 - pub id: usize, - pub messages: Vec, - /// 摘要:该段对话第一条用户提问(截断),用作列表标题。 - pub summary: String, -} - -/// 文件行:可能是对话分隔标记或普通消息。 -/// 用未标记的 `#[serde(untagged)]` 让旧格式 `{"role","content"}` 继续可解析。 -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum HistoryLine { - /// 恢复已有对话,后续消息继续归入指定稳定 id。 - SessionResume { - #[serde(rename = "type")] - kind: String, - id: usize, - }, - /// 新对话开始标记(`{"type":"session_start"}`)。 - SessionStart { - #[serde(rename = "type")] - kind: String, - }, - /// 普通消息(role/content)。 - Message(ChatMessage), -} - -const SESSION_START_MARKER: &str = r#"{"type":"session_start"}"#; -const SUMMARY_MAX_CHARS: usize = 40; - -/// 解析历史文件绝对路径。目录不存在时尝试创建。 -fn history_path(app: &AppHandle) -> Result { - let dir = app - .path() - .app_config_dir() - .map_err(|e| format!("解析配置目录失败: {e}"))?; - if !dir.exists() { - fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败: {e}"))?; - } - Ok(dir.join(HISTORY_FILE)) -} - -/// 写入对话分隔标记,表示之后的消息属于一段新对话。 -/// 仅在文件已有内容时才需要写标记(首段对话无需前导标记)。 -pub fn start_new_conversation(app: &AppHandle) -> Result<(), String> { - let _guard = HISTORY_LOCK - .lock() - .map_err(|e| format!("历史锁中毒: {e}"))?; - let path = history_path(app)?; - // 空文件无需分隔标记——它本身已是新对话起点 - let needs_marker = match fs::metadata(&path) { - Ok(meta) => meta.len() > 0, - Err(_) => false, // 不存在 → 视为空,首段无需标记 - }; - if !needs_marker { - return Ok(()); - } - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .map_err(|e| format!("打开历史文件失败: {e}"))?; - let mut buf = String::from(SESSION_START_MARKER); - buf.push('\n'); - file.write_all(buf.as_bytes()) - .map_err(|e| format!("写入对话分隔标记失败: {e}")) -} - -/// 追加一轮对话(多条消息)。 -/// -/// 每条消息独占一行,单次 write 调用写完整批,保证原子性尽量高、I/O 最小。 -/// 空切片直接返回 Ok。 -pub fn append_round( - app: &AppHandle, - conversation_id: Option, - msgs: &[ChatMessage], -) -> Result<(), String> { - if msgs.is_empty() { - return Ok(()); - } - let _guard = HISTORY_LOCK - .lock() - .map_err(|e| format!("历史锁中毒: {e}"))?; - let path = history_path(app)?; - // 预先序列化所有行,合并成一次写入 - let mut buf = String::with_capacity(128 * msgs.len()); - if let Some(id) = conversation_id { - let conversations = read_conversations_from_path(&path)?; - if !conversations - .iter() - .any(|conversation| conversation.id == id) - { - return Err(format!("对话不存在: {id}")); - } - let marker = serde_json::json!({ "type": "session_resume", "id": id }); - buf.push_str( - &serde_json::to_string(&marker).map_err(|e| format!("序列化对话恢复标记失败: {e}"))?, - ); - buf.push('\n'); - } - for m in msgs { - let line = serde_json::to_string(m).map_err(|e| format!("序列化消息失败: {e}"))?; - buf.push_str(&line); - buf.push('\n'); - } - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .map_err(|e| format!("打开历史文件失败: {e}"))?; - file.write_all(buf.as_bytes()) - .map_err(|e| format!("写入历史文件失败: {e}")) -} - -/// 读取全部历史消息(扁平流,向后兼容旧前端调用)。 -pub fn read_all(app: &AppHandle) -> Result, String> { - Ok(read_conversations(app)? - .into_iter() - .flat_map(|c| c.messages) - .collect()) -} - -/// 以对话为单位读取历史:按 `session_start` 标记切分,旧数据(无标记) -/// 归为第一段(id=0)。返回顺序为最久未活动 → 最近活动。 -pub fn read_conversations(app: &AppHandle) -> Result, String> { - let _guard = HISTORY_LOCK - .lock() - .map_err(|e| format!("历史锁中毒: {e}"))?; - let path = history_path(app)?; - read_conversations_from_path(&path) -} - -fn read_conversations_from_path(path: &Path) -> Result, String> { - let file = match File::open(path) { - Ok(f) => f, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(format!("打开历史文件失败: {e}")), - }; - let reader = BufReader::new(file); - - Ok(parse_conversation_lines( - reader.lines().map_while(Result::ok), - )) -} - -fn parse_conversation_lines(lines: I) -> Vec -where - I: IntoIterator, -{ - let mut conversations: Vec = Vec::new(); - let mut current_id: Option = None; - let mut next_id: usize = 0; - - for line in lines { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - // 单行损坏不影响其余历史 - let parsed = match serde_json::from_str::(trimmed) { - Ok(p) => p, - Err(_) => continue, - }; - match parsed { - HistoryLine::SessionResume { kind, id } if kind == "session_resume" => { - current_id = Some(id); - next_id = next_id.max(id.saturating_add(1)); - if let Some(index) = conversations.iter().position(|c| c.id == id) { - let conversation = conversations.remove(index); - conversations.push(conversation); - } - } - HistoryLine::SessionStart { kind } if kind == "session_start" => { - current_id = Some(next_id); - next_id = next_id.saturating_add(1); - } - HistoryLine::Message(msg) => { - let id = current_id.unwrap_or_else(|| { - let id = next_id; - next_id = next_id.saturating_add(1); - current_id = Some(id); - id - }); - if !conversations - .iter() - .any(|conversation| conversation.id == id) - { - conversations.push(Conversation { - id, - messages: Vec::new(), - summary: String::new(), - }); - } - let conv = conversations - .iter_mut() - .find(|conversation| conversation.id == id) - .expect("conversation was inserted above"); - if conv.summary.is_empty() && msg.role == "user" { - conv.summary = truncate_summary(&msg.content); - } - conv.messages.push(msg); - } - // 非法 type 值:忽略,不影响其余 - _ => continue, - } - } - - conversations -} - -/// 截取摘要:首行 + 限定长度,避免超长提问撑爆列表标题。 -fn truncate_summary(content: &str) -> String { - let first_line = content.lines().next().unwrap_or("").trim(); - if first_line.chars().count() <= SUMMARY_MAX_CHARS { - return first_line.to_string(); - } - let truncated: String = first_line.chars().take(SUMMARY_MAX_CHARS).collect(); - format!("{truncated}…") -} - -/// 清空全部历史:直接删除文件(下次 append 会重建)。 -pub fn clear(app: &AppHandle) -> Result<(), String> { - let _guard = HISTORY_LOCK - .lock() - .map_err(|e| format!("历史锁中毒: {e}"))?; - let path = history_path(app)?; - match fs::remove_file(&path) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(format!("删除历史文件失败: {e}")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn msg(role: &str, content: &str) -> ChatMessage { - ChatMessage { - role: role.to_string(), - content: content.to_string(), - } - } - - #[test] - fn parse_recovers_around_corrupt_lines() { - // 构造一段含损坏行的 JSONL:第 1、3 行合法,第 2 行损坏 - let data = concat!( - r#"{"role":"user","content":"你好"}"#, - "\n", - r#"this is not json"#, - "\n", - r#"{"role":"assistant","content":"你好!有什么可以帮你?"}"#, - "\n", - ); - let mut out = Vec::new(); - for line in data.lines() { - let t = line.trim(); - if t.is_empty() { - continue; - } - if let Ok(m) = serde_json::from_str::(t) { - out.push(m); - } - } - assert_eq!(out.len(), 2); - assert_eq!(out[0].role, "user"); - assert_eq!(out[1].content, "你好!有什么可以帮你?"); - } - - #[test] - fn append_then_read_roundtrip() { - // 临时文件:序列化 → 反序列化,验证行格式正确 - let round = vec![msg("user", "翻译"), msg("assistant", "translate")]; - let mut buf = String::new(); - for m in &round { - buf.push_str(&serde_json::to_string(m).unwrap()); - buf.push('\n'); - } - let parsed: Vec = buf - .lines() - .filter_map(|l| serde_json::from_str(l.trim()).ok()) - .collect(); - assert_eq!(parsed.len(), 2); - assert_eq!(parsed[0].content, "翻译"); - assert_eq!(parsed[1].role, "assistant"); - } - - #[test] - fn empty_input_is_noop() { - // 追加空切片应直接成功(由调用侧保证,这里验证逻辑) - let round: Vec = vec![]; - assert!(round.is_empty()); - } - - #[test] - fn each_message_is_one_line_compact() { - // 验证最省存储:无缩进、无多余空白,每条恰好一行 - let s = serde_json::to_string(&msg("user", "hi")).unwrap(); - assert!(!s.contains('\n')); - assert!(!s.contains(" ")); // 无两连空格(缩进) - assert_eq!(s, r#"{"role":"user","content":"hi"}"#); - } - - /// 解析 JSONL 为对话列表的核心逻辑(不依赖 AppHandle,便于单测)。 - fn parse_conversations(data: &str) -> Vec { - parse_conversation_lines(data.lines().map(str::to_string)) - } - - #[test] - fn legacy_data_without_markers_becomes_single_conversation() { - // 旧格式(无 session_start 标记):全部归为一段对话(id=0) - let data = concat!( - r#"{"role":"user","content":"你好"}"#, - "\n", - r#"{"role":"assistant","content":"你好!"}"#, - "\n", - r#"{"role":"user","content":"再见"}"#, - "\n", - ); - let convs = parse_conversations(data); - assert_eq!(convs.len(), 1); - assert_eq!(convs[0].id, 0); - assert_eq!(convs[0].messages.len(), 3); - assert_eq!(convs[0].summary, "你好"); - } - - #[test] - fn session_start_marker_splits_conversations() { - // 两个 session_start → 三段对话,摘要各取首句用户提问 - let data = concat!( - r#"{"role":"user","content":"第一个问题"}"#, - "\n", - r#"{"role":"assistant","content":"回答一"}"#, - "\n", - r#"{"type":"session_start"}"#, - "\n", - r#"{"role":"user","content":"第二个问题"}"#, - "\n", - r#"{"role":"assistant","content":"回答二"}"#, - "\n", - r#"{"type":"session_start"}"#, - "\n", - r#"{"role":"user","content":"第三个问题"}"#, - "\n", - ); - let convs = parse_conversations(data); - assert_eq!(convs.len(), 3); - assert_eq!(convs[0].messages.len(), 2); - assert_eq!(convs[1].messages.len(), 2); - assert_eq!(convs[2].messages.len(), 1); - assert_eq!(convs[0].summary, "第一个问题"); - assert_eq!(convs[1].summary, "第二个问题"); - assert_eq!(convs[2].summary, "第三个问题"); - // id 严格递增 - assert_eq!(convs[0].id, 0); - assert_eq!(convs[1].id, 1); - assert_eq!(convs[2].id, 2); - } - - #[test] - fn long_summary_is_truncated() { - // 超过 SUMMARY_MAX_CHARS(40) 的提问应被截断并加省略号 - let long = "一".repeat(60); - let summary = truncate_summary(&long); - assert!(summary.ends_with('…')); - // 截断后 = 40 字符 + 1 省略号 - assert_eq!(summary.chars().count(), SUMMARY_MAX_CHARS + 1); - } - - #[test] - fn short_summary_is_kept_intact() { - let short = "简短提问".to_string(); - assert_eq!(truncate_summary(&short), "简短提问"); - } - - #[test] - fn summary_takes_first_line_only() { - let multi = "第一行提问\n第二行不该出现".to_string(); - assert_eq!(truncate_summary(&multi), "第一行提问"); - } - - #[test] - fn summary_skips_assistant_until_first_user() { - // 首条是 assistant(异常但需容错),摘要应取第一条 user - let data = concat!( - r#"{"role":"assistant","content":"嗨"}"#, - "\n", - r#"{"role":"user","content":"真正的问题"}"#, - "\n", - ); - let convs = parse_conversations(data); - assert_eq!(convs.len(), 1); - assert_eq!(convs[0].summary, "真正的问题"); - } - - #[test] - fn resumed_conversation_receives_appended_round_and_becomes_most_recent() { - let data = concat!( - r#"{"role":"user","content":"A1"}"#, - "\n", - r#"{"role":"assistant","content":"A2"}"#, - "\n", - r#"{"type":"session_start"}"#, - "\n", - r#"{"role":"user","content":"B1"}"#, - "\n", - r#"{"role":"assistant","content":"B2"}"#, - "\n", - r#"{"type":"session_resume","id":0}"#, - "\n", - r#"{"role":"user","content":"A3"}"#, - "\n", - r#"{"role":"assistant","content":"A4"}"#, - "\n", - ); - - let convs = parse_conversations(data); - - assert_eq!(convs.len(), 2); - assert_eq!(convs[0].id, 1); - assert_eq!(convs[0].messages.last().unwrap().content, "B2"); - assert_eq!(convs[1].id, 0); - assert_eq!(convs[1].messages.len(), 4); - assert_eq!(convs[1].messages.last().unwrap().content, "A4"); - } -} diff --git a/src-tauri/src/keychain.rs b/src-tauri/src/keychain.rs deleted file mode 100644 index a129ea3..0000000 --- a/src-tauri/src/keychain.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! macOS Keychain 封装 —— 把敏感凭据(API key)存进系统钥匙串, -//! 避免明文落盘到 settings.json。 -//! -//! 用「通用密码」(generic password) 项存储,service 固定为 bundle id, -//! account 区分不同 key。非 macOS 平台返回错误(本项目仅面向 macOS)。 - -#[cfg(target_os = "macos")] -use security_framework::passwords; -use serde::{Deserialize, Serialize}; - -/// Keychain 项的 service 名(沿用 bundle identifier)。 -pub const SERVICE: &str = "com.samwstu.ttree"; - -/// OpenAI 翻译配置的 API key 账户名。 -pub const ACCOUNT_OPENAI_KEY: &str = "openai.api_key"; -/// AI 对话配置的 API key 账户名。 -pub const ACCOUNT_CHAT_AI_KEY: &str = "chat_ai.api_key"; - -/// errSecItemNotFound:Keychain 中找不到该条目(首次使用属正常情况)。 -const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300; - -const BOUND_SECRET_PREFIX: &str = "ttree-bound-secret-v1:"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SecretRecord { - Legacy(String), - Bound { endpoint: String, value: String }, -} - -#[derive(Serialize, Deserialize)] -struct BoundSecretPayload { - endpoint: String, - value: String, -} - -pub fn encode_bound_secret(endpoint: &str, value: &str) -> Result { - if endpoint.is_empty() || value.is_empty() { - return Err("接口地址和密钥均不能为空".to_string()); - } - let payload = serde_json::to_string(&BoundSecretPayload { - endpoint: endpoint.to_string(), - value: value.to_string(), - }) - .map_err(|e| format!("序列化钥匙串记录失败: {e}"))?; - Ok(format!("{BOUND_SECRET_PREFIX}{payload}")) -} - -pub fn decode_secret_record(raw: &str) -> Result { - let Some(payload) = raw.strip_prefix(BOUND_SECRET_PREFIX) else { - return Ok(SecretRecord::Legacy(raw.to_string())); - }; - let record: BoundSecretPayload = - serde_json::from_str(payload).map_err(|e| format!("钥匙串记录损坏: {e}"))?; - if record.endpoint.is_empty() || record.value.is_empty() { - return Err("钥匙串记录损坏: 接口地址或密钥为空".to_string()); - } - Ok(SecretRecord::Bound { - endpoint: record.endpoint, - value: record.value, - }) -} - -pub fn get_secret_record(account: &str) -> Result, String> { - get_secret(account)? - .map(|raw| decode_secret_record(&raw)) - .transpose() -} - -pub fn set_bound_secret(account: &str, endpoint: &str, value: &str) -> Result<(), String> { - let encoded = encode_bound_secret(endpoint, value)?; - set_secret(account, &encoded) -} - -/// 读取一个凭据。项不存在时返回 `Ok(None)`(首次使用),其余错误原样上抛。 -#[cfg(target_os = "macos")] -pub fn get_secret(account: &str) -> Result, String> { - match passwords::get_generic_password(SERVICE, account) { - Ok(bytes) => Ok(Some( - String::from_utf8(bytes).map_err(|e| format!("密钥内容非 UTF-8: {e}"))?, - )), - Err(err) => { - // 项不存在属于正常情况,转成 None - let code = err.code(); - if code == ERR_SEC_ITEM_NOT_FOUND { - Ok(None) - } else { - Err(format!("读取钥匙串失败 (code {code}): {err}")) - } - } - } -} - -/// 写入凭据;若已存在则覆盖。 -#[cfg(target_os = "macos")] -pub fn set_secret(account: &str, value: &str) -> Result<(), String> { - passwords::set_generic_password(SERVICE, account, value.as_bytes()) - .map_err(|e| format!("写入钥匙串失败: {e}")) -} - -/// 删除凭据;项不存在视为成功(幂等)。 -#[cfg(target_os = "macos")] -pub fn delete_secret(account: &str) -> Result<(), String> { - match passwords::delete_generic_password(SERVICE, account) { - Ok(()) => Ok(()), - Err(err) if err.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(()), - Err(err) => Err(format!("删除钥匙串失败 (code {}): {err}", err.code())), - } -} - -// ── 非 macOS 占位:本项目目前仅面向 macOS,这里仅作编译护栏 ── -#[cfg(not(target_os = "macos"))] -pub fn get_secret(_account: &str) -> Result, String> { - Err("此平台不支持 Keychain".to_string()) -} -#[cfg(not(target_os = "macos"))] -pub fn set_secret(_account: &str, _value: &str) -> Result<(), String> { - Err("此平台不支持 Keychain".to_string()) -} -#[cfg(not(target_os = "macos"))] -pub fn delete_secret(_account: &str) -> Result<(), String> { - Err("此平台不支持 Keychain".to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_trips_endpoint_bound_secrets() { - let encoded = encode_bound_secret("https://api.example.com/v1", "top-secret").unwrap(); - - assert_eq!( - decode_secret_record(&encoded).unwrap(), - SecretRecord::Bound { - endpoint: "https://api.example.com/v1".to_string(), - value: "top-secret".to_string(), - } - ); - assert!(!encoded.contains("\"api_key\"")); - } - - #[test] - fn treats_existing_raw_keychain_values_as_legacy() { - assert_eq!( - decode_secret_record("sk-existing").unwrap(), - SecretRecord::Legacy("sk-existing".to_string()) - ); - } - - #[test] - fn rejects_corrupt_bound_secret_records() { - assert!(decode_secret_record("ttree-bound-secret-v1:{broken").is_err()); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs deleted file mode 100644 index 6651e0e..0000000 --- a/src-tauri/src/lib.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! TTREE —— 轻量 macOS AI 翻译器后端入口。 - -mod commands; -mod config; -mod history; -mod keychain; -mod ocr; -mod screenshot; -mod selection; -mod shortcut; -mod translate; -mod tray; -mod window; - -use tauri::{Manager, WindowEvent}; - -#[cfg(target_os = "macos")] -use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState}; - -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - tauri::Builder::default() - .plugin(tauri_plugin_positioner::init()) - .plugin(tauri_plugin_store::Builder::default().build()) - .plugin(tauri_plugin_autostart::init( - tauri_plugin_autostart::MacosLauncher::LaunchAgent, - None, - )) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_process::init()) - .plugin(shortcut::build_plugin()) - .invoke_handler(tauri::generate_handler![ - commands::translate, - commands::chat, - commands::screenshot_ocr, - commands::show_main, - commands::get_openai_config, - commands::set_openai_config, - commands::get_chat_ai_config, - commands::set_chat_ai_config, - commands::get_shortcut_config, - commands::set_shortcut_config, - commands::set_shortcut_recording, - commands::get_pinned, - commands::set_pinned, - commands::get_appearance_config, - commands::set_appearance_config, - commands::load_chat_history, - commands::load_conversations, - commands::start_new_conversation, - commands::append_chat_history, - commands::clear_chat_history - ]) - .setup(|app| { - // 菜单栏应用:隐藏 Dock 与应用切换器入口,只保留状态栏托盘图标。 - #[cfg(target_os = "macos")] - app.handle() - .set_activation_policy(tauri::ActivationPolicy::Accessory)?; - - // 应用毛玻璃磨砂底色 —— Material 3 扁平风格:保留 Popover 通透材质作为窗口底, - // 圆角与 CSS 的 --radius-window(14px) 对齐,控件一律走实色表面。 - #[cfg(target_os = "macos")] - if let Some(win) = app.get_webview_window(window::MAIN_WINDOW) { - let _ = apply_vibrancy( - &win, - NSVisualEffectMaterial::Popover, - Some(NSVisualEffectState::Active), - Some(14.0), - ); - } - - // 恢复上次用户调整后的窗口尺寸;缺失则沿用配置默认值(最小宽度)。 - if let Some(win) = app.get_webview_window(window::MAIN_WINDOW) { - if let Some((w, h)) = config::load_window_size(app.handle()) { - let _ = win.set_size(tauri::LogicalSize::new(w, h)); - } - } - - // 注册持久化快捷键配置 - shortcut::register_saved(app.handle())?; - - // 构建系统托盘 - tray::setup_tray(app.handle())?; - - Ok(()) - }) - .on_window_event(|win, event| { - // 失焦自动隐藏(仅主窗口)。固定窗口时不隐藏。 - if win.label() == window::MAIN_WINDOW && !window::is_pinned() { - if let WindowEvent::Focused(false) = event { - let _ = win.hide(); - } - } - // 用户调整窗口尺寸后持久化,下次呼出沿用。 - // 仅在窗口可见时记录,避免启动期间窗口隐藏时的初始化尺寸覆盖已保存值。 - if win.label() == window::MAIN_WINDOW && window::is_visible_safe(win) { - if let WindowEvent::Resized(physical) = event { - let scale = win.scale_factor().unwrap_or(1.0); - let w = physical.width as f64 / scale; - let h = physical.height as f64 / scale; - config::save_window_size(win.app_handle(), w, h); - } - } - }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs deleted file mode 100644 index db2c0fa..0000000 --- a/src-tauri/src/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -// Prevents additional console window on Windows in release, DO NOT REMOVE!! -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -fn main() { - ttree_lib::run() -} diff --git a/src-tauri/src/ocr/image.rs b/src-tauri/src/ocr/image.rs deleted file mode 100644 index 01426ec..0000000 --- a/src-tauri/src/ocr/image.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! 将系统 PNG 解码并绘制成 Vision 可以稳定读取的 8-bit sRGB 位图。 - -#![cfg(target_os = "macos")] - -use objc2_core_foundation::{CFData, CFRetained, CGPoint, CGRect, CGSize}; -use objc2_core_graphics::{ - kCGColorSpaceSRGB, CGBitmapContextCreate, CGBitmapContextCreateImage, - CGColorSpace, CGContext, CGImage, CGImageAlphaInfo, CGImageByteOrderInfo, -}; -use objc2_image_io::CGImageSource; - -const MIN_DIMENSION: usize = 128; -const BORDER: usize = 16; -const MAX_PIXELS: usize = 32_000_000; - -pub(super) fn prepare_png(bytes: &[u8]) -> Result, String> { - if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 24 { - return Err("截图不是有效的 PNG 图片,请重新框选。".to_string()); - } - // 在原生解码前限制 IHDR 尺寸,避免异常文件触发巨量分配。 - if &bytes[12..16] != b"IHDR" { - return Err("截图 PNG 缺少图片尺寸信息,请重新框选。".to_string()); - } - let width = u32::from_be_bytes(bytes[16..20].try_into().unwrap()) as usize; - let height = u32::from_be_bytes(bytes[20..24].try_into().unwrap()) as usize; - canvas_size(width, height)?; - - let data = CFData::from_bytes(bytes); - // SAFETY: 使用 ImageIO 默认选项,data 的内容在整个解码期间不变。 - let source = unsafe { CGImageSource::with_data(&data, None) } - .ok_or_else(|| "系统无法读取截图 PNG,请重新框选。".to_string())?; - let image = unsafe { source.image_at_index(0, None) } - .ok_or_else(|| "截图 PNG 解码失败,请重新框选。".to_string())?; - let width = CGImage::width(Some(&image)); - let height = CGImage::height(Some(&image)); - let (canvas_width, canvas_height) = canvas_size(width, height)?; - - // 显示器截图可能包含灰度、透明度、广色域或 16-bit 像素。统一为 - // 8-bit sRGB、32-bit RGBX,并用白色背景消除透明通道的歧义。 - let color_space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })) - .ok_or_else(|| "创建 OCR 色彩空间失败".to_string())?; - let bitmap_info = CGImageAlphaInfo::NoneSkipLast.0 | CGImageByteOrderInfo::Order32Big.0; - // SAFETY: null 让 CoreGraphics 自行管理内存;尺寸已检查且每像素 4 字节。 - let context = unsafe { - CGBitmapContextCreate( - std::ptr::null_mut(), - canvas_width, - canvas_height, - 8, - canvas_width * 4, - Some(&color_space), - bitmap_info, - ) - } - .ok_or_else(|| "创建 OCR 位图失败".to_string())?; - CGContext::set_rgb_fill_color(Some(&context), 1.0, 1.0, 1.0, 1.0); - CGContext::fill_rect(Some(&context), rect(0, 0, canvas_width, canvas_height)); - // 保持原始像素大小;窄单行截图加边距,避免极小图像进入文字模型。 - CGContext::draw_image( - Some(&context), - rect((canvas_width - width) / 2, (canvas_height - height) / 2, width, height), - Some(&image), - ); - CGBitmapContextCreateImage(Some(&context)) - .ok_or_else(|| "生成 OCR 位图失败".to_string()) -} - -fn canvas_size(width: usize, height: usize) -> Result<(usize, usize), String> { - if width == 0 || height == 0 { - return Err("截图尺寸为空,请重新框选。".to_string()); - } - let padded = width - .checked_add(BORDER * 2) - .zip(height.checked_add(BORDER * 2)) - .map(|(width, height)| (width.max(MIN_DIMENSION), height.max(MIN_DIMENSION))) - .filter(|(width, height)| { - width.checked_mul(*height).is_some_and(|pixels| pixels <= MAX_PIXELS) - }); - padded.ok_or_else(|| "截图区域过大,请缩小框选范围后重试。".to_string()) -} - -fn rect(x: usize, y: usize, width: usize, height: usize) -> CGRect { - CGRect { - origin: CGPoint { x: x as _, y: y as _ }, - size: CGSize { width: width as _, height: height as _ }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pads_single_line_captures_without_stretching_text() { - assert_eq!(canvas_size(400, 20).unwrap(), (432, 128)); - assert_eq!(canvas_size(1, 1).unwrap(), (128, 128)); - } - - #[test] - fn refuses_zero_or_excessive_dimensions() { - assert!(canvas_size(0, 20).is_err()); - assert!(canvas_size(20, 0).is_err()); - assert!(canvas_size(usize::MAX, 20).is_err()); - assert!(canvas_size(10_000, 10_000).is_err()); - } - - #[test] - fn rejects_truncated_png_without_entering_vision() { - assert!(prepare_png(b"\x89PNG\r\n\x1a\n").is_err()); - } -} diff --git a/src-tauri/src/ocr/mod.rs b/src-tauri/src/ocr/mod.rs deleted file mode 100644 index 5841f2a..0000000 --- a/src-tauri/src/ocr/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! OCR 模块入口。 - -#[cfg(target_os = "macos")] -mod image; - -#[cfg(target_os = "macos")] -pub mod vision; - -#[cfg(all(test, target_os = "macos"))] -mod tests; - -/// 对图片文件执行 OCR,返回识别文本。 -#[cfg(target_os = "macos")] -pub fn recognize_file(path: &str) -> Result { - vision::recognize_file(path) -} - -#[cfg(not(target_os = "macos"))] -pub fn recognize_file(_path: &str) -> Result { - Err("当前平台暂不支持 OCR".to_string()) -} diff --git a/src-tauri/src/ocr/tests.rs b/src-tauri/src/ocr/tests.rs deleted file mode 100644 index 4bc01b1..0000000 --- a/src-tauri/src/ocr/tests.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! 真正执行 ImageIO + Vision 的 macOS 回归测试,不能只检查 request 属性。 - -use std::path::Path; -use std::process::Command; -use std::sync::OnceLock; - -use objc2::rc::autoreleasepool; -use objc2_core_graphics::CGImage; -use tempfile::TempDir; - -use super::{image, vision}; - -fn fixtures() -> &'static Path { - static FIXTURES: OnceLock = OnceLock::new(); - FIXTURES - .get_or_init(|| { - let directory = TempDir::new().unwrap(); - let script = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/ocr/tests/generate-fixtures.swift"); - let output = Command::new("/usr/bin/swift") - .arg(script) - .arg(directory.path()) - .output() - .expect("macOS OCR tests require the Xcode Command Line Tools"); - assert!( - output.status.success(), - "生成 OCR 样例失败: {}", - String::from_utf8_lossy(&output.stderr) - ); - directory - }) - .path() -} - -fn fixture(name: &str) -> Vec { - std::fs::read(fixtures().join(format!("{name}.png"))).unwrap() -} - -#[test] -fn native_ocr_reads_real_png_formats_and_single_line_captures() { - for name in ["english", "single-line", "grayscale", "grayscale-16bit", "transparent"] { - let text = vision::recognize_bytes(&fixture(name)) - .unwrap_or_else(|error| panic!("{name}: {error}")); - assert!(text.contains("Hello OCR"), "{name}: {text:?}"); - } - let text = vision::recognize_bytes(&fixture("chinese")).unwrap(); - let compact: String = text.chars().filter(|ch| !ch.is_whitespace()).collect(); - assert!(compact.contains("你好世界"), "chinese: {text:?}"); - assert!(text.contains("Hello OCR"), "mixed Chinese/English: {text:?}"); - if objc2::available!(macos = 13.0) { - let text = vision::recognize_bytes(&fixture("japanese")).unwrap(); - assert!(text.contains("テキスト"), "japanese: {text:?}"); - } -} - -#[test] -fn imageio_decodes_16bit_png_and_normalizes_pixels_before_vision() { - autoreleasepool(|_| { - let bytes = fixture("grayscale-16bit"); - assert_eq!(bytes[24], 16, "fixture must actually have 16-bit PNG samples"); - let image = image::prepare_png(&bytes).unwrap(); - assert_eq!(CGImage::bits_per_component(Some(&image)), 8); - assert_eq!(CGImage::bits_per_pixel(Some(&image)), 32); - assert!(CGImage::height(Some(&image)) >= 128); - }); -} - -#[test] -fn compatibility_retry_really_recognizes_text_on_cpu() { - autoreleasepool(|_| { - let image = image::prepare_png(&fixture("english")).unwrap(); - let text = vision::recognize_once(&image, true).unwrap(); - assert!(text.contains("Hello OCR"), "CPU retry: {text:?}"); - }); -} diff --git a/src-tauri/src/ocr/tests/diagnose-vision.swift b/src-tauri/src/ocr/tests/diagnose-vision.swift deleted file mode 100644 index cf6e5db..0000000 --- a/src-tauri/src/ocr/tests/diagnose-vision.swift +++ /dev/null @@ -1,71 +0,0 @@ -// CI-only comparison against Apple's minimal Vision request; synthetic text only. -import Foundation -import ImageIO -import Vision -import CoreML -import CoreImage - -let file = URL(fileURLWithPath: CommandLine.arguments[1]) -let data = try Data(contentsOf: file) -let source = CGImageSourceCreateWithData(data as CFData, nil)! -let image = CGImageSourceCreateImageAtIndex(source, 0, nil)! -print("Vision default revision: \(VNRecognizeTextRequest.defaultRevision)") -print("Vision supported revisions: \(VNRecognizeTextRequest.supportedRevisions)") - -func assignCPU(_ request: VNRecognizeTextRequest) throws { - if #available(macOS 14.0, *) { - let stages = try request.supportedComputeStageDevices - for (stage, devices) in stages { - print("compute stage \(stage): \(devices)") - if let cpu = devices.first(where: { - if case .cpu(_) = $0 { return true } - return false - }) { - request.setComputeDevice(cpu, for: stage) - } - } - } -} - -let profiles: [(String, (VNRecognizeTextRequest) throws -> Void)] = [ - ("apple-default", { _ in }), - ("default-explicit-cpu", { try assignCPU($0) }), - ("rev3-english-cpu", { request in - request.revision = 3 - request.recognitionLanguages = ["en-US"] - try assignCPU(request) - }), - ("rev3-chinese-english-cpu", { request in - request.revision = 3 - request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"] - try assignCPU(request) - }), - ("rev2-chinese-english-cpu", { request in - request.revision = 2 - request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"] - try assignCPU(request) - }), - ("fast-english-cpu", { request in - request.recognitionLevel = .fast - request.recognitionLanguages = ["en-US"] - try assignCPU(request) - }), -] -for (name, configure) in profiles { - for software in [false, true] { - do { - let request = VNRecognizeTextRequest() - try configure(request) - let options: [VNImageOption: Any] = software - ? [.ciContext: CIContext(options: [.useSoftwareRenderer: true])] - : [:] - let handler = VNImageRequestHandler(cgImage: image, options: options) - try handler.perform([request]) - let text = request.results?.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n") ?? "" - print("\(name) software=\(software): OK \(text)") - } catch { - let error = error as NSError - print("\(name) software=\(software): ERROR domain=\(error.domain) code=\(error.code) details=\(error.userInfo)") - } - } -} diff --git a/src-tauri/src/ocr/tests/generate-fixtures.swift b/src-tauri/src/ocr/tests/generate-fixtures.swift deleted file mode 100644 index 6da29ff..0000000 --- a/src-tauri/src/ocr/tests/generate-fixtures.swift +++ /dev/null @@ -1,45 +0,0 @@ -// Deterministic OCR fixtures generated with macOS system fonts, entirely offline. -import AppKit - -let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) - -func writeFixture( - _ name: String, text: String, width: Int = 440, height: Int = 80, - fontSize: CGFloat = 32, bits: Int = 8, grayscale: Bool = false, - transparent: Bool = false -) throws { - guard let bitmap = NSBitmapImageRep( - bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height, - bitsPerSample: bits, samplesPerPixel: grayscale ? 1 : 4, - hasAlpha: !grayscale, isPlanar: false, - colorSpaceName: grayscale ? .deviceWhite : .deviceRGB, - bytesPerRow: 0, bitsPerPixel: 0 - ), let context = NSGraphicsContext(bitmapImageRep: bitmap) else { - fatalError("Could not create fixture bitmap: \(name)") - } - NSGraphicsContext.saveGraphicsState() - NSGraphicsContext.current = context - context.cgContext.setFillColor((transparent ? NSColor.clear : NSColor.white).cgColor) - context.cgContext.setBlendMode(.copy) - context.cgContext.fill(CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height))) - context.cgContext.setBlendMode(.normal) - (text as NSString).draw( - at: NSPoint(x: 4, y: 2), - withAttributes: [.font: NSFont.systemFont(ofSize: fontSize), - .foregroundColor: NSColor.black] - ) - context.flushGraphics() - NSGraphicsContext.restoreGraphicsState() - guard let png = bitmap.representation(using: .png, properties: [:]) else { - fatalError("Could not encode fixture PNG: \(name)") - } - try png.write(to: directory.appendingPathComponent(name + ".png")) -} - -try writeFixture("english", text: "Hello OCR 123") -try writeFixture("single-line", text: "Hello OCR 123", width: 160, height: 22, fontSize: 14) -try writeFixture("grayscale", text: "Hello OCR 123", grayscale: true) -try writeFixture("grayscale-16bit", text: "Hello OCR 123", bits: 16, grayscale: true) -try writeFixture("transparent", text: "Hello OCR 123", transparent: true) -try writeFixture("chinese", text: "你好世界 Hello OCR") -try writeFixture("japanese", text: "日本語テキスト") diff --git a/src-tauri/src/ocr/vision.rs b/src-tauri/src/ocr/vision.rs deleted file mode 100644 index dda3750..0000000 --- a/src-tauri/src/ocr/vision.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! macOS 原生 OCR —— 通过 Apple Vision 框架的 VNRecognizeTextRequest 识别图片文字。 -//! -//! 仅 macOS。PNG → ImageIO 解码 → 8-bit RGB CGImage → Vision 同步 perform → -//! 读取 VNRecognizedTextObservation 的 topCandidates(1) 拼接。 - -#![cfg(target_os = "macos")] - -use objc2::rc::{autoreleasepool, Retained}; -use objc2::runtime::AnyObject; -use objc2::{AnyThread, ClassType}; -use objc2_core_graphics::CGImage; -use objc2_core_image::{kCIContextUseSoftwareRenderer, CIContext}; -use objc2_core_ml::MLCPUComputeDevice; -use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSObjectProtocol, NSString}; -use objc2_vision::{ - VNImageOptionCIContext, VNImageRequestHandler, VNRecognizeTextRequest, - VNRequestTextRecognitionLevel, -}; - -/// 对指定图片文件路径执行 OCR,返回识别文本(按行拼接)。 -pub fn recognize_file(path: &str) -> Result { - let bytes = std::fs::read(path).map_err(|e| format!("读取截图失败: {e}"))?; - recognize_bytes(&bytes) -} - -/// 对内存中的图片字节执行 OCR。 -pub fn recognize_bytes(bytes: &[u8]) -> Result { - // Tokio 阻塞线程没有 AppKit 事件循环的自动释放池;每次识别单独释放 - // Vision 创建的临时原生对象,防止多次截图后内存和计算资源累积。 - autoreleasepool(|_| { - let image = super::image::prepare_png(bytes)?; - let first_error = match recognize_once(&image, false) { - Ok(text) if !text.trim().is_empty() => return Ok(text), - Ok(_) => "未识别到文字".to_string(), - Err(error) => error, - }; - // 复用同一截图,但创建全新的 request/handler。CPU 回退避开临时 - // GPU/Metal 故障,关闭语言自动检测和纠错以覆盖模型判断失败。 - match recognize_once(&image, true) { - Ok(text) if !text.trim().is_empty() => Ok(text), - Ok(_) => Err("截图中未识别到文字,请框选清晰的文字区域后重试。".to_string()), - Err(error) => Err(format!("OCR 识别及兼容重试失败: {first_error}; {error}")), - } - }) -} - -pub(super) fn recognize_once(image: &CGImage, compatibility: bool) -> Result { - // SAFETY: Vision API 须在持有有效图像数据期间调用;以下对象均由我们创建并在本函数内存活。 - unsafe { - // 创建并配置多语言文字识别请求。 - let request: Retained = VNRecognizeTextRequest::new(); - configure_request(&request, compatibility)?; - - let options = if compatibility || objc2::available!(macos = 27.0) { - let software = NSNumber::numberWithBool(true); - let values: [&AnyObject; 1] = [&software]; - let context_options = - NSDictionary::from_slices(&[kCIContextUseSoftwareRenderer], &values); - let context = CIContext::contextWithOptions(Some(&context_options)); - let values: [&AnyObject; 1] = [&context]; - // CPU 模型选择不控制图像缩放器。显式软件 CIContext 避开 - // 新系统后台/虚拟环境中不可用的硬件图像处理路径。 - NSDictionary::from_slices(&[VNImageOptionCIContext], &values) - } else { - NSDictionary::new() - }; - - // 直接提供已规范化的 CGImage,避免 Vision 再走压缩数据解码路径。 - let handler = VNImageRequestHandler::initWithCGImage_options( - VNImageRequestHandler::alloc(), - image, - &options, - ); - - let requests = NSArray::from_retained_slice(&[Retained::into_super(Retained::into_super( - request.clone(), - ))]); - handler - .performRequests_error(&requests) - .map_err(|e| { - format!( - "OCR 执行失败: {e}(图像 {}×{},{} bit)", - CGImage::width(Some(image)), - CGImage::height(Some(image)), - CGImage::bits_per_component(Some(image)), - ) - })?; - - Ok(collect_text(&request)) - } -} - -fn configure_request(request: &VNRecognizeTextRequest, compatibility: bool) -> Result<(), String> { - request.setRecognitionLevel(VNRequestTextRecognitionLevel::Accurate); - request.setUsesLanguageCorrection(!compatibility); - - // 自动检测需要 revision 3,不能只开启属性而依赖系统默认 revision。 - let revision = if !compatibility && objc2::available!(macos = 13.0) { - 3 - } else { - 2 - }; - // 兼容重试使用 revision 2,避开新版识别模型与图像读取器的同一失败路径。 - // SAFETY: revision 2 在最低支持版本 macOS 11 可用;revision 3 经版本判断。 - unsafe { - request.setRevision(revision); - request.setPreferBackgroundProcessing(true); - } - if objc2::available!(macos = 13.0) { - request.setAutomaticallyDetectsLanguage(!compatibility); - } - // 语言支持随 macOS 和 revision 变化;传入不支持的 ja-JP 等语言会使 - // 整个请求失败。实例查询从 macOS 12 才存在,11 使用旧的类查询。 - let supported = unsafe { - if objc2::available!(macos = 12.0) { - request.supportedRecognitionLanguagesAndReturnError() - } else { - #[allow(deprecated)] - VNRecognizeTextRequest::supportedRecognitionLanguagesForTextRecognitionLevel_revision_error( - VNRequestTextRecognitionLevel::Accurate, - revision, - ) - } - } - .map_err(|error| format!("读取 OCR 支持语言失败: {error}"))?; - let supported_codes: Vec = supported.iter().map(|code| code.to_string()).collect(); - let languages: Vec> = preferred_languages(&supported_codes) - .into_iter() - .map(NSString::from_str) - .collect(); - if !languages.is_empty() { - request.setRecognitionLanguages(&NSArray::from_retained_slice(&languages)); - } - // 新系统后台 OCR 不能依赖 ANE 自动选择。旧 usesCPUOnly 属性在新 - // Vision 上不足以保证 CPU 执行,必须按模型支持的计算阶段显式选择。 - if compatibility || objc2::available!(macos = 27.0) { - configure_cpu(request)?; - } - Ok(()) -} - -fn configure_cpu(request: &VNRecognizeTextRequest) -> Result<(), String> { - // SAFETY: 新计算设备 API 和 MLCPUComputeDevice 从 macOS 14 才可用。 - unsafe { - if objc2::available!(macos = 14.0) { - let stages = request - .supportedComputeStageDevicesAndReturnError() - .map_err(|error| format!("读取 OCR 计算设备失败: {error}"))?; - let mut assigned = false; - for stage in stages.allKeys().iter() { - if let Some(devices) = stages.objectForKey(&stage) { - if let Some(cpu) = devices - .iter() - .find(|device| device.isKindOfClass(MLCPUComputeDevice::class())) - { - request.setComputeDevice_forComputeStage(Some(&cpu), &stage); - assigned = true; - } - } - } - if !assigned { - return Err("此 OCR 模型没有可用的 CPU 计算设备".to_string()); - } - } else { - #[allow(deprecated)] - request.setUsesCPUOnly(true); - } - } - Ok(()) -} - -fn preferred_languages(supported: &[String]) -> Vec<&str> { - // Apple 的语言支持查询只说明各语言能单独识别。中文模型只能与 - // 英文配对,不能混入日语、韩语或欧洲语言;自动检测负责选择脚本。 - ["zh-Hans", "zh-Hant", "en-US"] - .into_iter() - .filter(|code| supported.iter().any(|supported| supported.as_str() == *code)) - .collect() -} - -/// 从请求结果收集识别文本。 -unsafe fn collect_text(request: &VNRecognizeTextRequest) -> String { - let Some(observations) = request.results() else { - return String::new(); - }; - let mut lines: Vec = Vec::new(); - for obs in observations.iter() { - let candidates = obs.topCandidates(1); - if let Some(top) = candidates.firstObject() { - lines.push(top.string().to_string()); - } - } - lines.join("\n") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn enables_automatic_language_detection_by_default_when_available() { - let request = VNRecognizeTextRequest::new(); - configure_request(&request, false).unwrap(); - - if objc2::available!(macos = 13.0) { - assert!(request.automaticallyDetectsLanguage()); - } - } - - #[test] - fn only_configures_languages_supported_by_the_current_request() { - let request = VNRecognizeTextRequest::new(); - configure_request(&request, false).unwrap(); - let supported = unsafe { - if objc2::available!(macos = 12.0) { - request - .supportedRecognitionLanguagesAndReturnError() - .unwrap() - } else { - #[allow(deprecated)] - VNRecognizeTextRequest::supportedRecognitionLanguagesForTextRecognitionLevel_revision_error( - request.recognitionLevel(), - request.revision(), - ) - .unwrap() - } - }; - for configured in unsafe { request.recognitionLanguages() }.iter() { - assert!(supported.iter().any(|code| code == configured)); - } - } - - #[test] - fn compatibility_retry_disables_automatic_detection_and_correction() { - let request = VNRecognizeTextRequest::new(); - configure_request(&request, true).unwrap(); - assert!(!request.usesLanguageCorrection()); - assert_eq!(unsafe { request.revision() }, 2); - if objc2::available!(macos = 13.0) { - assert!(!request.automaticallyDetectsLanguage()); - } - } - - #[test] - fn older_language_sets_exclude_unsupported_japanese() { - let languages = ["en-US".to_string(), "zh-Hans".to_string()]; - assert_eq!(preferred_languages(&languages), ["zh-Hans", "en-US"]); - assert!(preferred_languages(&[]).is_empty()); - } - - #[test] - fn supported_languages_do_not_form_an_invalid_chinese_combination() { - let supported = ["zh-Hans", "zh-Hant", "en-US", "ja-JP", "ko-KR", "fr-FR"] - .map(String::from); - assert_eq!(preferred_languages(&supported), ["zh-Hans", "zh-Hant", "en-US"]); - } - - #[test] - fn rejects_invalid_image_data_before_starting_vision() { - assert!(recognize_bytes(&[]).unwrap_err().contains("PNG")); - assert!(recognize_bytes(b"not an image").is_err()); - } -} diff --git a/src-tauri/src/screenshot.rs b/src-tauri/src/screenshot.rs deleted file mode 100644 index cdb5943..0000000 --- a/src-tauri/src/screenshot.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! 屏幕区域截图 —— 调用 macOS 系统 `screencapture -i` 让用户自由框选。 - -use std::path::Path; -use std::process::Command; -use tempfile::{NamedTempFile, TempPath}; - -fn screencapture_args() -> [&'static str; 3] { - ["-i", "-x", "-r"] -} - -/// 触发 Apple 自带的交互式区域截图,落到临时 PNG 文件。 -/// -/// 返回截图文件路径;用户按 Esc 取消选择时返回 `Ok(None)`。 -pub fn capture_interactive() -> Result, String> { - let target = new_capture_target()?; - - let output = Command::new("/usr/sbin/screencapture") - .args(screencapture_args()) - .arg(target.path()) - .output() - .map_err(|e| format!("调用 screencapture 失败: {e}"))?; - - let has_capture = validate_capture_output(target.path())?; - if !output.status.success() { - let detail = String::from_utf8_lossy(&output.stderr); - // 部分系统版本在 Esc 时返回非零状态,并可能删除输出文件。 - if !has_capture - && (detail.trim().is_empty() || detail.to_ascii_lowercase().contains("cancel")) - { - return Ok(None); - } - let detail: String = detail.trim().chars().take(300).collect(); - return Err(format!( - "系统截图失败({}): {detail}。请检查系统设置 → 隐私与安全性 → 屏幕与系统音频录制中的 TTREE 权限,授权后重启应用。", - output.status - )); - } - - // NamedTempFile 预先创建了空文件;Esc 取消时它仍为空。 - if !has_capture { - return Ok(None); - } - Ok(Some(target.into_temp_path())) -} - -fn new_capture_target() -> Result { - tempfile::Builder::new() - .prefix("ttree-shot-") - .suffix(".png") - .tempfile() - .map_err(|e| format!("创建截图临时文件失败: {e}")) -} - -/// 返回 false 表示用户取消(文件为空);非普通文件一律拒绝。 -fn validate_capture_output(path: &Path) -> Result { - let metadata = match std::fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => return Err(format!("读取截图临时文件失败: {error}")), - }; - if !metadata.file_type().is_file() { - return Err("截图输出不是普通文件".to_string()); - } - Ok(metadata.len() > 0) -} - -#[cfg(test)] -mod tests { - use std::fs; - - use super::*; - - #[test] - fn uses_native_interactive_screencapture_flags() { - assert_eq!(screencapture_args(), ["-i", "-x", "-r"]); - } - - #[test] - fn capture_targets_are_random_private_regular_files() { - let first = new_capture_target().unwrap(); - let second = new_capture_target().unwrap(); - - assert_ne!(first.path(), second.path()); - assert!(first.path().is_file()); - assert!(second.path().is_file()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - let mode = first.as_file().metadata().unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - } - } - - #[test] - fn empty_capture_file_is_treated_as_cancellation() { - let target = new_capture_target().unwrap(); - - assert!(!validate_capture_output(target.path()).unwrap()); - } - - #[test] - fn deleted_capture_file_is_treated_as_cancellation() { - let target = new_capture_target().unwrap(); - let path = target.path().to_path_buf(); - drop(target); - assert!(!validate_capture_output(&path).unwrap()); - } - - #[test] - fn non_regular_capture_output_is_rejected() { - let target = new_capture_target().unwrap(); - let path = target.path().to_path_buf(); - drop(target); - fs::create_dir(&path).unwrap(); - - let result = validate_capture_output(&path); - fs::remove_dir(&path).unwrap(); - assert!(result.is_err()); - } -} diff --git a/src-tauri/src/selection/macos.rs b/src-tauri/src/selection/macos.rs deleted file mode 100644 index 5b8fcff..0000000 --- a/src-tauri/src/selection/macos.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! macOS 选中文本捕获实现。 - -use std::ptr; -use std::thread; -use std::time::{Duration, Instant}; - -use anyhow::{anyhow, Result}; -use core_foundation::base::{Boolean, CFRelease, CFTypeRef, TCFType}; -use core_foundation::string::{CFString, CFStringRef}; -use objc2_app_kit::{NSPasteboard, NSPasteboardType, NSPasteboardTypeString}; -use objc2_core_graphics::{CGEvent, CGEventFlags, CGEventTapLocation}; -use objc2_foundation::{NSData, NSInteger, NSString}; - -use super::normalize_selected_text; - -const AX_ERROR_SUCCESS: i32 = 0; -const COPY_KEY_CODE: u16 = 8; -const COPY_TIMEOUT: Duration = Duration::from_millis(450); -const COPY_POLL_INTERVAL: Duration = Duration::from_millis(20); - -#[repr(C)] -struct __AXUIElement { - _private: [u8; 0], -} - -type AXUIElementRef = *const __AXUIElement; - -#[link(name = "ApplicationServices", kind = "framework")] -extern "C" { - fn AXIsProcessTrusted() -> Boolean; - fn AXUIElementCreateSystemWide() -> AXUIElementRef; - fn AXUIElementCopyAttributeValue( - element: AXUIElementRef, - attribute: CFStringRef, - value: *mut CFTypeRef, - ) -> i32; -} - -#[derive(Debug, Clone)] -struct PasteboardSnapshot { - entries: Vec, -} - -#[derive(Debug, Clone)] -struct PasteboardEntry { - type_name: String, - data: Vec, -} - -pub(crate) fn capture_selected_text() -> Result> { - if let Some(text) = capture_accessibility_selected_text()? { - return Ok(Some(text)); - } - - capture_clipboard_selected_text() -} - -fn capture_accessibility_selected_text() -> Result> { - // SAFETY: We only call documented ApplicationServices Accessibility APIs. All returned - // CoreFoundation objects that follow the Create/Copy rule are released or wrapped exactly once. - unsafe { - if AXIsProcessTrusted() == 0 { - return Ok(None); - } - - let system_element = AXUIElementCreateSystemWide(); - if system_element.is_null() { - return Ok(None); - } - - let focused_attr = CFString::new("AXFocusedUIElement"); - let mut focused_value: CFTypeRef = ptr::null(); - let focused_error = AXUIElementCopyAttributeValue( - system_element, - focused_attr.as_concrete_TypeRef(), - &mut focused_value, - ); - CFRelease(system_element as CFTypeRef); - - if focused_error != AX_ERROR_SUCCESS || focused_value.is_null() { - return Ok(None); - } - - let selected_attr = CFString::new("AXSelectedText"); - let mut selected_value: CFTypeRef = ptr::null(); - let selected_error = AXUIElementCopyAttributeValue( - focused_value as AXUIElementRef, - selected_attr.as_concrete_TypeRef(), - &mut selected_value, - ); - CFRelease(focused_value); - - if selected_error != AX_ERROR_SUCCESS || selected_value.is_null() { - return Ok(None); - } - - let selected = CFString::wrap_under_create_rule(selected_value as CFStringRef).to_string(); - Ok(normalize_selected_text(selected)) - } -} - -fn capture_clipboard_selected_text() -> Result> { - let pasteboard = NSPasteboard::generalPasteboard(); - let snapshot = snapshot_pasteboard(&pasteboard); - let before_copy = pasteboard.changeCount(); - - post_copy_keypress()?; - - let Some(copy_change_count) = wait_for_pasteboard_change(&pasteboard, before_copy) else { - return Ok(None); - }; - - let copied_text = read_plain_text(&pasteboard).and_then(normalize_selected_text); - - if should_restore_pasteboard(pasteboard.changeCount(), copy_change_count) { - restore_pasteboard(&pasteboard, &snapshot); - } - - Ok(copied_text) -} - -fn snapshot_pasteboard(pasteboard: &NSPasteboard) -> PasteboardSnapshot { - let entries = pasteboard - .types() - .map(|types| { - types - .to_vec() - .into_iter() - .filter_map(|type_name| { - pasteboard - .dataForType(&type_name) - .map(|data| PasteboardEntry { - type_name: type_name.to_string(), - data: data.to_vec(), - }) - }) - .collect() - }) - .unwrap_or_default(); - - PasteboardSnapshot { entries } -} - -fn restore_pasteboard(pasteboard: &NSPasteboard, snapshot: &PasteboardSnapshot) { - let _ = pasteboard.clearContents(); - - for entry in &snapshot.entries { - let type_name = NSString::from_str(&entry.type_name); - let data = NSData::with_bytes(&entry.data); - let _ = pasteboard.setData_forType(Some(&data), &type_name); - } -} - -fn should_restore_pasteboard( - current_change_count: NSInteger, - copy_change_count: NSInteger, -) -> bool { - current_change_count == copy_change_count -} - -fn wait_for_pasteboard_change( - pasteboard: &NSPasteboard, - before_copy: NSInteger, -) -> Option { - let deadline = Instant::now() + COPY_TIMEOUT; - - while Instant::now() < deadline { - let current = pasteboard.changeCount(); - if current != before_copy { - return Some(current); - } - thread::sleep(COPY_POLL_INTERVAL); - } - - None -} - -fn read_plain_text(pasteboard: &NSPasteboard) -> Option { - pasteboard - .stringForType(string_pasteboard_type()) - .map(|text| text.to_string()) -} - -fn string_pasteboard_type() -> &'static NSPasteboardType { - // SAFETY: NSPasteboardTypeString is an AppKit constant with static lifetime. - unsafe { NSPasteboardTypeString } -} - -fn post_copy_keypress() -> Result<()> { - let key_down = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, true) - .ok_or_else(|| anyhow!("创建复制按下事件失败"))?; - let key_up = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, false) - .ok_or_else(|| anyhow!("创建复制释放事件失败"))?; - - CGEvent::set_flags(Some(&key_down), CGEventFlags::MaskCommand); - CGEvent::set_flags(Some(&key_up), CGEventFlags::MaskCommand); - CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_down)); - CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_up)); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pasteboard_restore_requires_unchanged_copy_count() { - assert!(should_restore_pasteboard(42, 42)); - assert!(!should_restore_pasteboard(43, 42)); - } -} diff --git a/src-tauri/src/selection/mod.rs b/src-tauri/src/selection/mod.rs deleted file mode 100644 index b5eb45d..0000000 --- a/src-tauri/src/selection/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! 系统选中文本捕获。 -//! -//! macOS 上优先通过 Accessibility 读取当前焦点控件的选中文本,失败后回退到 -//! 临时 Cmd+C + 剪贴板读取。非 macOS 平台返回空结果。 - -#[cfg(target_os = "macos")] -mod macos; - -#[cfg(target_os = "macos")] -pub(crate) use macos::capture_selected_text; - -#[cfg(not(target_os = "macos"))] -pub(crate) fn capture_selected_text() -> anyhow::Result> { - Ok(None) -} - -pub(crate) fn normalize_selected_text(text: impl AsRef) -> Option { - let trimmed = text.as_ref().trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalizes_blank_text_to_none() { - assert_eq!(normalize_selected_text(" \n\t "), None); - } - - #[test] - fn trims_non_empty_selected_text() { - assert_eq!( - normalize_selected_text(" hello "), - Some("hello".to_string()) - ); - } -} diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs deleted file mode 100644 index afee9f1..0000000 --- a/src-tauri/src/shortcut.rs +++ /dev/null @@ -1,682 +0,0 @@ -//! 全局快捷键注册与分发。 -//! -//! - 呼出快捷键:toggle 主窗口可见性 -//! - 截图 OCR 快捷键:广播 `trigger-ocr`,前端调用截图/OCR/翻译流程 -//! - AI 对话快捷键:呼出 AI 对话窗口 -//! - 划词翻译快捷键:捕获选中文本并自动翻译 -//! - 划词 AI 对话快捷键:捕获选中文本并发送给 AI -//! -//! 所有快捷键均可选;空字符串表示未启用。 - -use std::sync::atomic::{AtomicBool, Ordering}; - -use serde::Serialize; -use tauri::{AppHandle, Emitter}; -use tauri_plugin_global_shortcut::{ - Builder as ShortcutBuilder, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState, -}; - -use crate::config::{self, ShortcutConfig}; -use crate::selection; -use crate::window; - -static IS_RECORDING_SHORTCUT: AtomicBool = AtomicBool::new(false); -const MAX_SHORTCUT_SPEC_BYTES: usize = 128; - -#[derive(Debug, Clone, Default)] -struct ParsedShortcuts { - toggle: Option, - ocr: Option, - ai_dialog: Option, - selection_translate: Option, - selection_ai_dialog: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ShortcutAction { - Toggle, - Ocr, - AiDialog, - SelectionTranslate, - SelectionAiDialog, -} - -#[derive(Debug, Clone, Serialize)] -struct SelectedTextPayload { - text: String, -} - -#[derive(Debug, Clone, Serialize)] -struct AiDialogPayload { - text: Option, -} - -impl ParsedShortcuts { - /// 分发优先级顺序:划词动作排在对应的纯呼出动作之前, - /// 这样当两者共用同一快捷键时,按更丰富的划词行为执行。 - fn entries(&self) -> [(ShortcutAction, &Option); 5] { - [ - ( - ShortcutAction::SelectionTranslate, - &self.selection_translate, - ), - (ShortcutAction::SelectionAiDialog, &self.selection_ai_dialog), - (ShortcutAction::Toggle, &self.toggle), - (ShortcutAction::Ocr, &self.ocr), - (ShortcutAction::AiDialog, &self.ai_dialog), - ] - } - - /// 需要注册的快捷键集合,去重(共用同一键的只注册一次)。 - fn shortcuts(&self) -> Vec { - let mut out: Vec = Vec::new(); - for (_, shortcut) in self.entries() { - if let Some(shortcut) = shortcut { - if !out.contains(shortcut) { - out.push(*shortcut); - } - } - } - out - } - - fn action_for(&self, shortcut: &Shortcut) -> Option { - self.entries() - .into_iter() - .find(|(_, candidate)| candidate.as_ref() == Some(shortcut)) - .map(|(action, _)| action) - } - - /// 校验快捷键不冲突。允许两组共用同一键: - /// 呼出翻译↔划词翻译、呼出 AI 对话↔划词 AI 对话;其余两两不得相同。 - fn reject_disallowed_duplicates(&self) -> Result<(), anyhow::Error> { - let fields: [(&str, &Option); 5] = [ - ("呼出翻译快捷键", &self.toggle), - ("截图 OCR 快捷键", &self.ocr), - ("呼出 AI 对话快捷键", &self.ai_dialog), - ("划词翻译快捷键", &self.selection_translate), - ("划词 AI 对话快捷键", &self.selection_ai_dialog), - ]; - - for (i, (left_label, left)) in fields.iter().enumerate() { - let Some(left) = left else { continue }; - for (right_label, right) in fields.iter().skip(i + 1) { - let Some(right) = right else { continue }; - if left == right && !is_allowed_shared_pair(left_label, right_label) { - return Err(anyhow::anyhow!("{left_label}和{right_label}不能相同")); - } - } - } - - Ok(()) - } -} - -/// 这两组快捷键允许共用同一键(划词动作叠加在纯呼出动作上)。 -fn is_allowed_shared_pair(a: &str, b: &str) -> bool { - const ALLOWED: [(&str, &str); 2] = [ - ("呼出翻译快捷键", "划词翻译快捷键"), - ("呼出 AI 对话快捷键", "划词 AI 对话快捷键"), - ]; - ALLOWED - .iter() - .any(|&(x, y)| (a == x && b == y) || (a == y && b == x)) -} - -/// 进入/退出快捷键录制。 -/// -/// 录制期间取消注册所有全局快捷键,否则与已注册键相同的按键会被 OS 级全局热键 -/// 拦截而无法作为普通 keydown 事件传到前端(表现为“按相同键没反应”)。 -/// 退出录制时按已保存配置重新注册。 -pub fn set_recording_active( - app: &AppHandle, - is_active: bool, -) -> Result<(), String> { - let operation = if is_active { - app.global_shortcut() - .unregister_all() - .map_err(|e| format!("暂停全局快捷键失败: {e}")) - } else { - let saved = config::load_shortcuts(app); - apply_shortcuts(app, &saved).map_err(|e| format!("恢复全局快捷键失败: {e}")) - }; - transition_recording_state(&IS_RECORDING_SHORTCUT, is_active, operation) -} - -fn transition_recording_state( - state: &AtomicBool, - is_active: bool, - operation: Result<(), E>, -) -> Result<(), E> { - operation?; - state.store(is_active, Ordering::SeqCst); - Ok(()) -} - -/// 构建 global-shortcut 插件,带统一处理器。 -pub fn build_plugin() -> tauri::plugin::TauriPlugin { - ShortcutBuilder::::new() - .with_handler(move |app, shortcut, event| { - if event.state() != ShortcutState::Pressed { - return; - } - handle_shortcut(app, shortcut); - }) - .build() -} - -fn handle_shortcut(app: &AppHandle, shortcut: &Shortcut) { - if IS_RECORDING_SHORTCUT.load(Ordering::SeqCst) { - return; - } - - let cfg = config::load_shortcuts(app); - let Ok(shortcuts) = parse_shortcut_config(&cfg) else { - return; - }; - - match shortcuts.action_for(shortcut) { - Some(ShortcutAction::Toggle) => { - // 仅在切换为显示时复位到翻译页。 - if window::toggle_window(app) { - let _ = app.emit("trigger-translate", ()); - } - } - Some(ShortcutAction::Ocr) => { - let _ = app.emit("trigger-ocr", ()); - } - Some(ShortcutAction::AiDialog) => handle_ai_dialog_shortcut(app), - Some(ShortcutAction::SelectionTranslate) => handle_selection_translate_shortcut(app), - Some(ShortcutAction::SelectionAiDialog) => handle_selection_ai_dialog_shortcut(app), - None => {} - } -} - -/// 划词翻译:有选中文本则翻译,否则仅 toggle 窗口。 -fn handle_selection_translate_shortcut(app: &AppHandle) { - match selection::capture_selected_text() { - Ok(Some(text)) => { - window::show_main(app); - let _ = app.emit("trigger-selected-translate", SelectedTextPayload { text }); - } - Ok(None) | Err(_) => { - window::toggle_window(app); - } - } -} - -/// 呼出 AI 对话窗口(不捕获划词)。 -fn handle_ai_dialog_shortcut(app: &AppHandle) { - window::show_main(app); - let _ = app.emit("trigger-ai-dialog", AiDialogPayload { text: None }); -} - -/// 划词 AI 对话:有选中文本则连同原文发送给 AI,否则仅呼出对话窗口。 -fn handle_selection_ai_dialog_shortcut(app: &AppHandle) { - let text = selection::capture_selected_text().ok().flatten(); - window::show_main(app); - let _ = app.emit("trigger-ai-dialog", AiDialogPayload { text }); -} - -/// 注册持久化配置中的快捷键。在 setup 中调用。 -pub fn register_saved(app: &AppHandle) -> tauri::Result<()> { - let saved = config::load_shortcuts(app); - if parse_shortcut_config(&saved).is_ok() && apply_shortcuts(app, &saved).is_ok() { - return Ok(()); - } - - let defaults = ShortcutConfig::default(); - if apply_shortcuts(app, &defaults).is_ok() { - let _ = config::save_shortcuts(app, &defaults); - } - - Ok(()) -} - -/// 验证并应用快捷键配置。保存设置时调用,立即生效。 -pub fn apply_shortcuts( - app: &AppHandle, - cfg: &ShortcutConfig, -) -> Result<(), anyhow::Error> { - let shortcuts = parse_shortcut_config(cfg)?; - - let previous = parse_shortcut_config(&config::load_shortcuts(app)).ok(); - let gs = app.global_shortcut(); - - gs.unregister_all()?; - - for shortcut in shortcuts.shortcuts() { - if let Err(error) = gs.register(shortcut) { - let _ = gs.unregister_all(); - restore_shortcuts(app, previous); - return Err(error.into()); - } - } - - Ok(()) -} - -fn restore_shortcuts(app: &AppHandle, previous: Option) { - let Some(previous) = previous else { - return; - }; - - let gs = app.global_shortcut(); - for shortcut in previous.shortcuts() { - let _ = gs.register(shortcut); - } -} - -fn parse_shortcut_config(cfg: &ShortcutConfig) -> Result { - let toggle = parse_optional_shortcut("呼出翻译快捷键", &cfg.toggle)?; - let ocr = parse_optional_shortcut("截图 OCR 快捷键", &cfg.ocr)?; - let ai_dialog = parse_optional_shortcut("呼出 AI 对话快捷键", &cfg.ai_dialog)?; - let selection_translate = parse_optional_shortcut("划词翻译快捷键", &cfg.selection_translate)?; - let selection_ai_dialog = - parse_optional_shortcut("划词 AI 对话快捷键", &cfg.selection_ai_dialog)?; - - let parsed = ParsedShortcuts { - toggle, - ocr, - ai_dialog, - selection_translate, - selection_ai_dialog, - }; - parsed.reject_disallowed_duplicates()?; - Ok(parsed) -} - -fn parse_required_shortcut(label: &str, spec: &str) -> Result { - let shortcut = spec - .trim() - .parse::() - .map_err(|e| anyhow::anyhow!("{label}无效: {e}"))?; - - if !shortcut - .mods - .intersects(Modifiers::SUPER | Modifiers::CONTROL | Modifiers::ALT) - { - return Err(anyhow::anyhow!( - "{label}必须包含 Cmd/Ctrl/Option 中的至少一个修饰键" - )); - } - - Ok(shortcut) -} - -fn parse_optional_shortcut(label: &str, spec: &str) -> Result, anyhow::Error> { - if spec.len() > MAX_SHORTCUT_SPEC_BYTES { - return Err(anyhow::anyhow!( - "{label}超出 {MAX_SHORTCUT_SPEC_BYTES} 字节上限" - )); - } - if spec.trim().is_empty() { - return Ok(None); - } - - parse_required_shortcut(label, spec).map(Some) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn shortcut_config(toggle: &str, ocr: &str, ai_dialog: &str) -> ShortcutConfig { - ShortcutConfig { - toggle: toggle.to_string(), - ocr: ocr.to_string(), - ai_dialog: ai_dialog.to_string(), - selection_translate: String::new(), - selection_ai_dialog: String::new(), - } - } - - #[test] - fn accepts_default_shortcuts_with_unset_ai_dialog() { - let result = parse_shortcut_config(&shortcut_config( - "CmdOrCtrl+Shift+Space", - "CmdOrCtrl+Shift+S", - "", - )); - - assert!(result.is_ok()); - assert!(result.unwrap().ai_dialog.is_none()); - } - - #[test] - fn accepts_configured_ai_dialog_shortcut() { - let result = parse_shortcut_config(&shortcut_config( - "CmdOrCtrl+Shift+Space", - "CmdOrCtrl+Shift+S", - "CmdOrCtrl+Shift+A", - )); - - assert!(result.is_ok()); - assert!(result.unwrap().ai_dialog.is_some()); - } - - #[test] - fn accepts_all_empty_shortcuts() { - let result = parse_shortcut_config(&shortcut_config("", "", "")); - - assert!(result.is_ok()); - let parsed = result.unwrap(); - assert!(parsed.toggle.is_none()); - assert!(parsed.ocr.is_none()); - } - - #[test] - fn accepts_selection_shortcuts() { - let cfg = ShortcutConfig { - toggle: "CmdOrCtrl+Shift+Space".to_string(), - ocr: "CmdOrCtrl+Shift+S".to_string(), - ai_dialog: String::new(), - selection_translate: "CmdOrCtrl+Shift+T".to_string(), - selection_ai_dialog: "CmdOrCtrl+Shift+D".to_string(), - }; - let result = parse_shortcut_config(&cfg); - - assert!(result.is_ok()); - let parsed = result.unwrap(); - assert!(parsed.selection_translate.is_some()); - assert!(parsed.selection_ai_dialog.is_some()); - } - - #[test] - fn accepts_option_space_with_modifier() { - let result = - parse_shortcut_config(&shortcut_config("Option+Space", "CmdOrCtrl+Shift+S", "")); - - assert!(result.is_ok()); - } - - #[test] - fn treats_whitespace_ai_dialog_as_unset() { - let result = parse_shortcut_config(&shortcut_config( - "CmdOrCtrl+Shift+Space", - "CmdOrCtrl+Shift+S", - " ", - )); - - assert!(result.is_ok()); - assert!(result.unwrap().ai_dialog.is_none()); - } - - #[test] - fn rejects_toggle_without_modifier() { - let result = parse_shortcut_config(&shortcut_config("Space", "CmdOrCtrl+Shift+S", "")); - - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("呼出翻译快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键")); - } - - #[test] - fn rejects_ocr_without_modifier() { - let result = parse_shortcut_config(&shortcut_config("CmdOrCtrl+Shift+Space", "S", "")); - - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("截图 OCR 快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键")); - } - - #[test] - fn rejects_ai_dialog_without_modifier() { - let result = parse_shortcut_config(&shortcut_config( - "CmdOrCtrl+Shift+Space", - "CmdOrCtrl+Shift+S", - "A", - )); - - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("呼出 AI 对话快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键")); - } - - #[test] - fn rejects_selection_translate_without_modifier() { - let cfg = ShortcutConfig { - toggle: "CmdOrCtrl+Shift+Space".to_string(), - ocr: "CmdOrCtrl+Shift+S".to_string(), - ai_dialog: String::new(), - selection_translate: "T".to_string(), - selection_ai_dialog: String::new(), - }; - let result = parse_shortcut_config(&cfg); - - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("划词翻译快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键")); - } - - #[test] - fn rejects_shift_only_shortcut() { - let result = parse_shortcut_config(&shortcut_config("Shift+S", "CmdOrCtrl+Shift+S", "")); - - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("呼出翻译快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键")); - } - - #[test] - fn rejects_modifier_only_shortcut() { - let result = parse_shortcut_config(&shortcut_config("Shift", "CmdOrCtrl+Shift+S", "")); - - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("呼出翻译快捷键无效")); - } - - #[test] - fn rejects_duplicate_toggle_and_ocr_shortcuts() { - let result = parse_shortcut_config(&shortcut_config( - "CmdOrCtrl+Shift+Space", - "CmdOrCtrl+Shift+Space", - "", - )); - - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("呼出翻译快捷键和截图 OCR 快捷键不能相同")); - } - - #[test] - fn rejects_duplicate_toggle_and_ai_dialog_shortcuts() { - let result = parse_shortcut_config(&shortcut_config( - "CmdOrCtrl+Shift+Space", - "CmdOrCtrl+Shift+S", - "CmdOrCtrl+Shift+Space", - )); - - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("呼出翻译快捷键和呼出 AI 对话快捷键不能相同")); - } - - #[test] - fn rejects_duplicate_ocr_and_ai_dialog_shortcuts() { - let result = parse_shortcut_config(&shortcut_config( - "CmdOrCtrl+Shift+Space", - "CmdOrCtrl+Shift+S", - "CmdOrCtrl+Shift+S", - )); - - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("截图 OCR 快捷键和呼出 AI 对话快捷键不能相同")); - } - - #[test] - fn rejects_duplicate_selection_shortcuts() { - let cfg = ShortcutConfig { - toggle: "CmdOrCtrl+Shift+Space".to_string(), - ocr: "CmdOrCtrl+Shift+S".to_string(), - ai_dialog: String::new(), - selection_translate: "CmdOrCtrl+Shift+T".to_string(), - selection_ai_dialog: "CmdOrCtrl+Shift+T".to_string(), - }; - let result = parse_shortcut_config(&cfg); - - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("划词翻译快捷键和划词 AI 对话快捷键不能相同")); - } - - #[test] - fn allows_shared_toggle_and_selection_translate() { - let cfg = ShortcutConfig { - toggle: "CmdOrCtrl+Shift+Space".to_string(), - ocr: "CmdOrCtrl+Shift+S".to_string(), - ai_dialog: String::new(), - selection_translate: "CmdOrCtrl+Shift+Space".to_string(), - selection_ai_dialog: String::new(), - }; - let result = parse_shortcut_config(&cfg); - - assert!(result.is_ok()); - // 共用同一键时只注册一次。 - assert_eq!(result.unwrap().shortcuts().len(), 2); - } - - #[test] - fn allows_shared_ai_dialog_and_selection_ai_dialog() { - let cfg = ShortcutConfig { - toggle: "CmdOrCtrl+Shift+Space".to_string(), - ocr: "CmdOrCtrl+Shift+S".to_string(), - ai_dialog: "CmdOrCtrl+Shift+A".to_string(), - selection_translate: String::new(), - selection_ai_dialog: "CmdOrCtrl+Shift+A".to_string(), - }; - let result = parse_shortcut_config(&cfg); - - assert!(result.is_ok()); - assert_eq!(result.unwrap().shortcuts().len(), 3); - } - - #[test] - fn shared_translate_key_dispatches_to_selection_action() { - // 呼出翻译与划词翻译共用同一键时,划词动作优先。 - let cfg = ShortcutConfig { - toggle: "CmdOrCtrl+Shift+Space".to_string(), - ocr: "CmdOrCtrl+Shift+S".to_string(), - ai_dialog: String::new(), - selection_translate: "CmdOrCtrl+Shift+Space".to_string(), - selection_ai_dialog: String::new(), - }; - let shortcuts = parse_shortcut_config(&cfg).unwrap(); - - assert_eq!( - shortcuts.action_for(&"CmdOrCtrl+Shift+Space".parse().unwrap()), - Some(ShortcutAction::SelectionTranslate) - ); - } - - #[test] - fn shared_ai_key_dispatches_to_selection_action() { - let cfg = ShortcutConfig { - toggle: "CmdOrCtrl+Shift+Space".to_string(), - ocr: "CmdOrCtrl+Shift+S".to_string(), - ai_dialog: "CmdOrCtrl+Shift+A".to_string(), - selection_translate: String::new(), - selection_ai_dialog: "CmdOrCtrl+Shift+A".to_string(), - }; - let shortcuts = parse_shortcut_config(&cfg).unwrap(); - - assert_eq!( - shortcuts.action_for(&"CmdOrCtrl+Shift+A".parse().unwrap()), - Some(ShortcutAction::SelectionAiDialog) - ); - } - - #[test] - fn rejects_cross_group_shared_key() { - // 呼出翻译与划词 AI 对话不属于允许共用的组合。 - let cfg = ShortcutConfig { - toggle: "CmdOrCtrl+Shift+Space".to_string(), - ocr: "CmdOrCtrl+Shift+S".to_string(), - ai_dialog: String::new(), - selection_translate: String::new(), - selection_ai_dialog: "CmdOrCtrl+Shift+Space".to_string(), - }; - let result = parse_shortcut_config(&cfg); - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("不能相同")); - } - - #[test] - fn resolves_known_shortcut_actions() { - let cfg = ShortcutConfig { - toggle: "CmdOrCtrl+Shift+Space".to_string(), - ocr: "CmdOrCtrl+Shift+S".to_string(), - ai_dialog: "CmdOrCtrl+Shift+A".to_string(), - selection_translate: "CmdOrCtrl+Shift+T".to_string(), - selection_ai_dialog: "CmdOrCtrl+Shift+D".to_string(), - }; - let shortcuts = parse_shortcut_config(&cfg).unwrap(); - - assert_eq!( - shortcuts.action_for(&"CmdOrCtrl+Shift+Space".parse().unwrap()), - Some(ShortcutAction::Toggle) - ); - assert_eq!( - shortcuts.action_for(&"CmdOrCtrl+Shift+S".parse().unwrap()), - Some(ShortcutAction::Ocr) - ); - assert_eq!( - shortcuts.action_for(&"CmdOrCtrl+Shift+A".parse().unwrap()), - Some(ShortcutAction::AiDialog) - ); - assert_eq!( - shortcuts.action_for(&"CmdOrCtrl+Shift+T".parse().unwrap()), - Some(ShortcutAction::SelectionTranslate) - ); - assert_eq!( - shortcuts.action_for(&"CmdOrCtrl+Shift+D".parse().unwrap()), - Some(ShortcutAction::SelectionAiDialog) - ); - assert_eq!( - shortcuts.action_for(&"CmdOrCtrl+Shift+X".parse().unwrap()), - None - ); - } - - #[test] - fn recording_state_changes_only_after_registration_operation_succeeds() { - let state = AtomicBool::new(false); - - let failed: Result<(), &str> = transition_recording_state(&state, true, Err("failed")); - assert_eq!(failed, Err("failed")); - assert!(!state.load(Ordering::SeqCst)); - - transition_recording_state(&state, true, Ok::<(), &str>(())).unwrap(); - assert!(state.load(Ordering::SeqCst)); - } - - #[test] - fn rejects_oversized_shortcut_specs_before_parsing() { - let error = parse_optional_shortcut("测试快捷键", &"x".repeat(129)).unwrap_err(); - - assert!(error.to_string().contains("128 字节")); - } -} diff --git a/src-tauri/src/translate/auto.rs b/src-tauri/src/translate/auto.rs deleted file mode 100644 index 7d67f94..0000000 --- a/src-tauri/src/translate/auto.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! 免费翻译自动分流:健康服务轮询,失败切换,故障服务指数退避。 - -use std::future::Future; -use std::sync::{Mutex, OnceLock}; -use std::time::{Duration, Instant}; - -use super::{bing, google}; - -const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(20); -const BASE_COOLDOWN: Duration = Duration::from_secs(30); -const MAX_COOLDOWN: Duration = Duration::from_secs(300); -// Bing 网页端限制较小;同时避免 Google GET 的 URL 随长文本膨胀。 -const CHUNK_UTF16_LIMIT: usize = 1_000; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Provider { - Google, - Bing, -} - -impl Provider { - fn index(self) -> usize { - match self { - Self::Google => 0, - Self::Bing => 1, - } - } -} - -#[derive(Default)] -struct Health { - failures: u32, - retry_after: Option, -} - -#[derive(Default)] -struct Pool { - health: [Health; 2], - next: usize, -} - -impl Pool { - fn candidates(&mut self, now: Instant) -> Vec { - let providers = [Provider::Google, Provider::Bing]; - let start = self.next; - self.next = (self.next + 1) % providers.len(); - (0..providers.len()) - .map(|offset| providers[(start + offset) % providers.len()]) - .filter(|provider| self.available(*provider, now)) - .collect() - } - - fn available(&self, provider: Provider, now: Instant) -> bool { - self.health[provider.index()] - .retry_after - .is_none_or(|retry_after| now >= retry_after) - } - - fn succeeded(&mut self, provider: Provider) { - self.health[provider.index()] = Health::default(); - } - - fn failed(&mut self, provider: Provider, now: Instant) { - let health = &mut self.health[provider.index()]; - health.failures = health.failures.saturating_add(1); - let multiplier = 1 << health.failures.saturating_sub(1).min(4); - let cooldown = (BASE_COOLDOWN * multiplier).min(MAX_COOLDOWN); - health.retry_after = Some(now + cooldown); - } -} - -pub async fn translate(text: &str, source: &str, target: &str) -> Result { - static POOL: OnceLock> = OnceLock::new(); - let pool = POOL.get_or_init(|| Mutex::new(Pool::default())); - let chunks = split_text(text); - let timeout = ATTEMPT_TIMEOUT - .saturating_mul(chunks.len() as u32) - .min(Duration::from_secs(120)); - let chunks = &chunks; - translate_with(pool, timeout, |provider| async move { - let mut output = String::new(); - for &chunk in chunks { - let content = chunk.trim(); - if content.is_empty() { - output.push_str(chunk); - continue; - } - let translated = match provider { - Provider::Google => google::translate(content, source, target).await?, - Provider::Bing => bing::translate(content, source, target).await?, - }; - // 保留分块边界上的空白和换行,避免多段译文粘在一起。 - output.push_str(&chunk[..chunk.len() - chunk.trim_start().len()]); - output.push_str(translated.trim()); - output.push_str(&chunk[chunk.trim_end().len()..]); - } - Ok(output) - }) - .await -} - -async fn translate_with( - pool: &Mutex, - timeout: Duration, - mut call: F, -) -> Result -where - F: FnMut(Provider) -> Fut, - Fut: Future>, -{ - let candidates = pool - .lock() - .map_err(|_| "翻译服务状态不可用".to_string())? - .candidates(Instant::now()); - - for provider in candidates { - // 另一个并发请求可能已将此服务熔断;不在锁内等待网络。 - if !pool - .lock() - .map_err(|_| "翻译服务状态不可用".to_string())? - .available(provider, Instant::now()) - { - continue; - } - let result = tokio::time::timeout(timeout, call(provider)) - .await - .unwrap_or_else(|_| Err("翻译服务请求超时".to_string())) - .and_then(|text| { - if text.trim().is_empty() { - Err("翻译服务未返回译文".to_string()) - } else { - Ok(text) - } - }); - let mut state = pool - .lock() - .map_err(|_| "翻译服务状态不可用".to_string())?; - match result { - Ok(text) => { - state.succeeded(provider); - return Ok(text); - } - Err(error) => { - state.failed(provider, Instant::now()); - eprintln!("免费翻译服务 {provider:?} 失败,暂时停用: {error}"); - } - } - } - Err("翻译服务暂时不可用,已自动尝试其他服务,请稍后重试。".to_string()) -} - -fn split_text(mut text: &str) -> Vec<&str> { - let mut chunks = Vec::new(); - while !text.is_empty() { - let mut units = 0; - let mut end = text.len(); - let mut boundary = None; - for (index, ch) in text.char_indices() { - units += ch.len_utf16(); - if units > CHUNK_UTF16_LIMIT { - end = boundary.unwrap_or(index); - break; - } - if units >= CHUNK_UTF16_LIMIT / 2 - && (ch.is_whitespace() || matches!(ch, '.' | '!' | '?' | '。' | '!' | '?')) - { - boundary = Some(index + ch.len_utf8()); - } - } - let (chunk, remaining) = text.split_at(end); - chunks.push(chunk); - text = remaining; - } - chunks -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn distributes_successful_requests_between_providers() { - let pool = Mutex::new(Pool::default()); - let mut called = Vec::new(); - for _ in 0..4 { - translate_with(&pool, ATTEMPT_TIMEOUT, |provider| { - called.push(provider); - async { Ok("译文".to_string()) } - }) - .await - .unwrap(); - } - assert_eq!( - called, - [Provider::Google, Provider::Bing, Provider::Google, Provider::Bing] - ); - } - - #[tokio::test] - async fn fails_over_and_skips_a_service_during_cooldown() { - let pool = Mutex::new(Pool::default()); - let mut called = Vec::new(); - for _ in 0..3 { - let result = translate_with(&pool, ATTEMPT_TIMEOUT, |provider| { - called.push(provider); - async move { - match provider { - Provider::Google => Err("429 Too Many Requests".to_string()), - Provider::Bing => Ok("备用译文".to_string()), - } - } - }) - .await; - assert_eq!(result.unwrap(), "备用译文"); - } - assert_eq!( - called, - [Provider::Google, Provider::Bing, Provider::Bing, Provider::Bing] - ); - } - - #[tokio::test] - async fn empty_results_trigger_failover() { - let pool = Mutex::new(Pool::default()); - let result = translate_with(&pool, ATTEMPT_TIMEOUT, |provider| async move { - Ok(if provider == Provider::Google { - " " - } else { - "备用译文" - } - .to_string()) - }) - .await; - assert_eq!(result.unwrap(), "备用译文"); - } - - #[tokio::test] - async fn timeout_triggers_failover() { - let pool = Mutex::new(Pool::default()); - let result = translate_with(&pool, Duration::from_millis(10), |provider| async move { - if provider == Provider::Google { - std::future::pending::<()>().await; - } - Ok("备用译文".to_string()) - }) - .await; - assert_eq!(result.unwrap(), "备用译文"); - } - - #[tokio::test] - async fn stops_requesting_when_all_providers_are_cooling_down() { - let pool = Mutex::new(Pool::default()); - let mut count = 0; - for _ in 0..2 { - assert!(translate_with(&pool, ATTEMPT_TIMEOUT, |_| { - count += 1; - async { Err("offline".to_string()) } - }) - .await - .is_err()); - } - assert_eq!(count, 2); - } - - #[test] - fn recovers_after_cooldown_and_resets_backoff_on_success() { - let mut pool = Pool::default(); - let now = Instant::now(); - pool.failed(Provider::Google, now); - assert!(!pool.available(Provider::Google, now)); - assert!(pool.available(Provider::Google, now + BASE_COOLDOWN)); - pool.failed(Provider::Google, now + BASE_COOLDOWN); - assert!(!pool.available(Provider::Google, now + BASE_COOLDOWN * 2)); - pool.succeeded(Provider::Google); - assert!(pool.available(Provider::Google, now)); - assert_eq!(pool.health[0].failures, 0); - } - - #[test] - fn chunks_unicode_within_service_limits_without_losing_text() { - let text = format!("{}\n{}", "截图😀中文 ".repeat(500), "long text ".repeat(300)); - let chunks = split_text(&text); - assert!(chunks.len() > 1); - assert!(chunks - .iter() - .all(|chunk| chunk.encode_utf16().count() <= CHUNK_UTF16_LIMIT)); - assert_eq!(chunks.concat(), text); - assert_eq!(split_text("short text"), ["short text"]); - } -} diff --git a/src-tauri/src/translate/bing.rs b/src-tauri/src/translate/bing.rs deleted file mode 100644 index caac3dc..0000000 --- a/src-tauri/src/translate/bing.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! 必应翻译 —— 免费网页端接口(bing.com/ttranslatev3)。 -//! -//! 注意:这是非官方端点。需要先抓取翻译页拿到一次性令牌(IG / IID / key / token), -//! 再带令牌 POST 翻译文本。可能随时失效或被限流。 - -use std::time::Duration; - -use serde_json::Value; - -use super::{ - read_error_body, read_limited_response, shared_client, MAX_BING_PAGE_BYTES, - MAX_TRANSLATION_RESPONSE_BYTES, -}; - -const TRANSLATOR_URL: &str = "https://www.bing.com/translator"; -const TRANSLATE_URL: &str = "https://www.bing.com/ttranslatev3"; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); -const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \ - (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; - -/// 翻译页里抓取的一次性令牌。 -struct BingToken { - ig: String, - iid: String, - key: String, - token: String, -} - -/// 把内部语言代码映射成必应使用的代码。 -fn map_lang(code: &str) -> &str { - match code { - "auto" => "auto-detect", - "zh-CN" => "zh-Hans", - "zh-TW" => "zh-Hant", - other => other, - } -} - -/// 调用必应免费端接口,返回完整译文。 -pub async fn translate(text: &str, source: &str, target: &str) -> Result { - let client = shared_client(REQUEST_TIMEOUT, true); - - let token = fetch_token(&client).await?; - - let url = format!( - "{TRANSLATE_URL}?isVertical=1&IG={ig}&IID={iid}", - ig = urlencoding::encode(&token.ig), - iid = urlencoding::encode(&token.iid), - ); - - let resp = client - .post(&url) - .header("User-Agent", USER_AGENT) - .header("Referer", TRANSLATOR_URL) - .form(&[ - ("fromLang", map_lang(source)), - ("to", map_lang(target)), - ("text", text), - ("token", &token.token), - ("key", &token.key), - ]) - .send() - .await - .map_err(|e| format!("必应翻译请求失败: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = read_error_body(resp).await; - return Err(format!("必应翻译返回状态 {status}: {body}")); - } - - let body = read_limited_response(resp, MAX_TRANSLATION_RESPONSE_BYTES, "必应翻译响应").await?; - let body: Value = - serde_json::from_slice(&body).map_err(|e| format!("必应翻译响应解析失败: {e}"))?; - - parse_response(&body) - .filter(|text| !text.trim().is_empty()) - .ok_or_else(|| "必应翻译未返回译文".to_string()) -} - -/// 抓取翻译页并解析出一次性令牌。 -async fn fetch_token(client: &reqwest::Client) -> Result { - let response = client - .get(TRANSLATOR_URL) - .header("User-Agent", USER_AGENT) - .send() - .await - .map_err(|e| format!("必应翻译页请求失败: {e}"))?; - if !response.status().is_success() { - let status = response.status(); - let body = read_error_body(response).await; - return Err(format!("必应翻译页返回状态 {status}: {body}")); - } - let html = read_limited_response(response, MAX_BING_PAGE_BYTES, "必应翻译页").await?; - let html = std::str::from_utf8(&html).map_err(|e| format!("必应翻译页不是 UTF-8: {e}"))?; - - parse_token(html).ok_or_else(|| "必应翻译令牌解析失败(端点可能已变更)".to_string()) -} - -/// 从翻译页 HTML 解析 IG / IID / key / token。 -fn parse_token(html: &str) -> Option { - let ig = extract_between(html, "IG:\"", "\"")?; - // 页内其他控件也有 data-iid,优先寻找翻译器自己的 IID。 - let iid = format!( - "translator.{}", - extract_between(html, "data-iid=\"translator.", "\"")? - ); - - // params_AbusePreventionHelper = [key,"token",time]; - let assignment = html - .split_once("params_AbusePreventionHelper")? - .1 - .trim_start(); - let assignment = assignment.strip_prefix('=')?.trim_start(); - let helper = assignment.strip_prefix('[')?.split_once(']')?.0; - let parts: Vec = serde_json::from_str(&format!("[{helper}]")).ok()?; - let key = parts.first()?.as_u64()?.to_string(); - let token = parts.get(1)?.as_str()?.to_string(); - - if key.is_empty() || token.is_empty() { - return None; - } - - Some(BingToken { - ig, - iid, - key, - token, - }) -} - -/// 取 `start` 与其后第一个 `end` 之间的子串。 -fn extract_between(haystack: &str, start: &str, end: &str) -> Option { - let begin = haystack.find(start)? + start.len(); - let rest = &haystack[begin..]; - let stop = rest.find(end)?; - Some(rest[..stop].to_string()) -} - -/// 解析必应响应:结构为 `[{"translations":[{"text":"译文"}], ...}]`。 -fn parse_response(body: &Value) -> Option { - body.get(0)? - .get("translations")? - .get(0)? - .get("text")? - .as_str() - .map(|s| s.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn maps_internal_lang_codes() { - assert_eq!(map_lang("auto"), "auto-detect"); - assert_eq!(map_lang("zh-CN"), "zh-Hans"); - assert_eq!(map_lang("zh-TW"), "zh-Hant"); - assert_eq!(map_lang("en"), "en"); - } - - #[test] - fn extracts_substring_between_markers() { - assert_eq!( - extract_between("a IG:\"ABC123\" b", "IG:\"", "\""), - Some("ABC123".to_string()) - ); - assert_eq!(extract_between("no marker", "IG:\"", "\""), None); - } - - #[test] - fn parses_token_fields() { - let html = r#"var IG:"FF00";
- "#; - let token = parse_token(html).expect("token"); - assert_eq!(token.ig, "FF00"); - assert_eq!(token.iid, "translator.5028"); - assert_eq!(token.key, "167060"); - assert_eq!(token.token, "abcdef"); - } - - #[test] - fn returns_none_when_token_missing() { - assert!(parse_token("nothing here").is_none()); - } - - #[test] - fn parses_compact_token_assignment_and_ignores_unrelated_iids() { - let html = r#"IG:"FF00";
-
- params_AbusePreventionHelper=[167060,"abc,def",3600000];"#; - let token = parse_token(html).unwrap(); - assert_eq!(token.iid, "translator.5028"); - assert_eq!(token.token, "abc,def"); - } - - #[test] - fn parses_translation_text() { - let body = json!([{ - "detectedLanguage": { "language": "en", "score": 1.0 }, - "translations": [{ "text": "你好", "to": "zh-Hans" }] - }]); - assert_eq!(parse_response(&body), Some("你好".to_string())); - } - - #[test] - fn returns_none_on_malformed_response() { - assert_eq!(parse_response(&json!({ "error": "bad" })), None); - } -} diff --git a/src-tauri/src/translate/google.rs b/src-tauri/src/translate/google.rs deleted file mode 100644 index af55ed6..0000000 --- a/src-tauri/src/translate/google.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! 谷歌翻译 —— 免费网页端接口(translate.googleapis.com)。 -//! -//! 注意:这是非官方端点,可能被限流。设置项预留官方 Cloud API key 入口。 - -use std::time::Duration; - -use serde_json::Value; - -use super::{ - read_error_body, read_limited_response, shared_client, MAX_TRANSLATION_RESPONSE_BYTES, -}; - -const ENDPOINT: &str = "https://translate.googleapis.com/translate_a/single"; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); - -/// 调用谷歌免费端接口,返回完整译文。 -pub async fn translate(text: &str, source: &str, target: &str) -> Result { - let url = format!( - "{ENDPOINT}?client=gtx&sl={sl}&tl={tl}&dt=t&q={q}", - sl = urlencoding::encode(source), - tl = urlencoding::encode(target), - q = urlencoding::encode(text), - ); - - let resp = shared_client(REQUEST_TIMEOUT, false) - .get(&url) - .header("User-Agent", "Mozilla/5.0") - .send() - .await - .map_err(|e| format!("谷歌翻译请求失败: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = read_error_body(resp).await; - return Err(format!("谷歌翻译返回状态 {status}: {body}")); - } - - let body = read_limited_response(resp, MAX_TRANSLATION_RESPONSE_BYTES, "谷歌翻译响应").await?; - let body: Value = - serde_json::from_slice(&body).map_err(|e| format!("谷歌翻译响应解析失败: {e}"))?; - - let text = parse_response(&body); - if text.trim().is_empty() { - return Err("谷歌翻译未返回有效译文".to_string()); - } - Ok(text) -} - -/// 解析谷歌响应:结构为 `[[[译文, 原文, ...], ...], ...]`。 -/// 拼接第一段数组中所有片段的译文。 -fn parse_response(body: &Value) -> String { - let mut out = String::new(); - if let Some(segments) = body.get(0).and_then(|v| v.as_array()) { - for seg in segments { - if let Some(t) = seg.get(0).and_then(|v| v.as_str()) { - out.push_str(t); - } - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn parses_single_segment() { - let body = json!([[["你好", "hello", null, null, 10]], null, "en"]); - assert_eq!(parse_response(&body), "你好"); - } - - #[test] - fn concatenates_multiple_segments() { - let body = json!([[["你好", "hello"], ["世界", "world"]], null, "en"]); - assert_eq!(parse_response(&body), "你好世界"); - } - - #[test] - fn returns_empty_on_malformed() { - let body = json!({ "error": "bad" }); - assert_eq!(parse_response(&body), ""); - } -} diff --git a/src-tauri/src/translate/mod.rs b/src-tauri/src/translate/mod.rs deleted file mode 100644 index b59820c..0000000 --- a/src-tauri/src/translate/mod.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! 翻译引擎:统一分发 + 各引擎实现。 - -pub mod auto; -pub mod bing; -pub mod google; -pub mod openai; - -use std::collections::HashMap; -use std::sync::{Mutex, OnceLock}; -use std::time::Duration; - -use futures_util::StreamExt; -use reqwest::{Client, Response}; -use serde::{Deserialize, Serialize}; - -/// 复用连接池的 HTTP 客户端。 -/// -/// `cookie_store=true` 时返回一个独立实例(带 cookie jar),否则返回默认实例。 -/// 相同参数多次调用复用同一客户端,避免反复握手建连。 -pub(crate) fn shared_client(timeout: Duration, cookie_store: bool) -> Client { - static CLIENTS: OnceLock>> = OnceLock::new(); - - let clients = CLIENTS.get_or_init(|| Mutex::new(HashMap::new())); - let mut clients = clients.lock().expect("HTTP 客户端缓存锁已损坏"); - clients - .entry((timeout, cookie_store)) - .or_insert_with(|| { - Client::builder() - .timeout(timeout) - .cookie_store(cookie_store) - // 禁止跟随重定向:OpenAI 兼容接口为单跳 POST,禁止 3xx 可避免 - // 服务器把带 api_key 的请求重定向到内网/其它地址(防 SSRF 二跳)。 - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("构建 HTTP 客户端失败") - }) - .clone() -} - -pub(crate) const MAX_TRANSLATION_TEXT_BYTES: usize = 64 * 1024; -pub(crate) const MAX_TRANSLATION_RESPONSE_BYTES: usize = 2 * 1024 * 1024; -pub(crate) const MAX_BING_PAGE_BYTES: usize = 4 * 1024 * 1024; -pub(crate) const MAX_ERROR_BODY_BYTES: usize = 16 * 1024; -pub(crate) const MAX_SSE_FRAME_BYTES: usize = 256 * 1024; -pub(crate) const MAX_STREAM_OUTPUT_BYTES: usize = 4 * 1024 * 1024; - -pub fn validate_translation_text(text: &str) -> Result<(), String> { - if text.len() > MAX_TRANSLATION_TEXT_BYTES { - return Err(format!( - "待翻译文本超出 {} 字节上限", - MAX_TRANSLATION_TEXT_BYTES - )); - } - Ok(()) -} - -const SUPPORTED_SOURCE_LANGUAGES: &[&str] = &[ - "auto", "zh-CN", "zh-TW", "en", "ja", "ko", "fr", "de", "es", "ru", -]; -const SUPPORTED_TARGET_LANGUAGES: &[&str] = - &["zh-CN", "zh-TW", "en", "ja", "ko", "fr", "de", "es", "ru"]; - -pub fn validate_language_pair(source: &str, target: &str) -> Result<(), String> { - if !SUPPORTED_SOURCE_LANGUAGES.contains(&source) { - return Err(format!("不支持的源语言代码: {source}")); - } - if !SUPPORTED_TARGET_LANGUAGES.contains(&target) { - return Err(format!("不支持的目标语言代码: {target}")); - } - Ok(()) -} - -struct LimitedBody<'a> { - bytes: Vec, - limit: usize, - label: &'a str, -} - -impl<'a> LimitedBody<'a> { - fn new(limit: usize, label: &'a str) -> Self { - Self { - bytes: Vec::new(), - limit, - label, - } - } - - fn push(&mut self, chunk: &[u8]) -> Result<(), String> { - let new_len = self - .bytes - .len() - .checked_add(chunk.len()) - .ok_or_else(|| format!("{}大小溢出", self.label))?; - if new_len > self.limit { - return Err(format!("{}超出 {} 字节上限", self.label, self.limit)); - } - self.bytes.extend_from_slice(chunk); - Ok(()) - } - - fn finish(self) -> Vec { - self.bytes - } -} - -pub(crate) async fn read_limited_response( - response: Response, - limit: usize, - label: &str, -) -> Result, String> { - if response - .content_length() - .is_some_and(|length| length > limit as u64) - { - return Err(format!("{label}超出 {limit} 字节上限")); - } - let mut body = LimitedBody::new(limit, label); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| format!("读取{label}失败: {e}"))?; - body.push(&chunk)?; - } - Ok(body.finish()) -} - -struct ErrorBody { - bytes: Vec, - limit: usize, - truncated: bool, -} - -impl ErrorBody { - fn new(limit: usize) -> Self { - Self { - bytes: Vec::new(), - limit, - truncated: false, - } - } - - /// 返回 true 表示已达到上限,调用方应停止读取响应体。 - fn push(&mut self, chunk: &[u8]) -> bool { - let remaining = self.limit.saturating_sub(self.bytes.len()); - if chunk.len() > remaining { - self.bytes.extend_from_slice(&chunk[..remaining]); - self.truncated = true; - true - } else { - self.bytes.extend_from_slice(chunk); - false - } - } - - fn finish(self) -> String { - let mut body = String::from_utf8_lossy(&self.bytes).into_owned(); - if self.truncated { - body.push_str("…[truncated]"); - } - body - } -} - -pub(crate) async fn read_error_body(response: Response) -> String { - let mut body = ErrorBody::new(MAX_ERROR_BODY_BYTES); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - match chunk { - Ok(chunk) if body.push(&chunk) => break, - Ok(_) => {} - Err(error) => return format!("<读取错误响应失败: {error}>"), - } - } - body.finish() -} - -/// 翻译引擎类型。 -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum Engine { - Auto, - Openai, - Google, - Bing, -} - -/// 翻译请求参数。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TranslateRequest { - /// 前端生成的单调请求标识,用于隔离并发/过期流事件。 - pub request_id: u64, - pub text: String, - pub engine: Engine, - /// 源语言代码,"auto" 表示自动检测。 - pub source: String, - /// 目标语言代码。 - pub target: String, -} - -/// AI 对话消息。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatMessage { - pub role: String, - pub content: String, -} - -/// 允许转发的对话角色(白名单)。其余角色一律拒绝,避免任意 system 注入。 -const ALLOWED_ROLES: &[&str] = &["user", "assistant", "system"]; -/// 单条消息内容长度上限(字节),防御磁盘占用与上游 payload 滥用。 -const MAX_CONTENT_BYTES: usize = 64 * 1024; -/// 单次对话请求的消息条数上限。 -const MAX_MESSAGES: usize = 200; - -/// 校验一组对话消息:角色白名单、单条长度、条数上限。 -/// 在 Tauri 命令边界调用,确保 IPC 来的不可信载荷不会原样落盘或转发上游。 -pub fn validate_messages(msgs: &[ChatMessage]) -> Result<(), String> { - if msgs.len() > MAX_MESSAGES { - return Err(format!("消息数超出上限 {MAX_MESSAGES} 条")); - } - for m in msgs { - if !ALLOWED_ROLES.contains(&m.role.as_str()) { - return Err(format!("非法消息角色: {}", m.role)); - } - if m.content.len() > MAX_CONTENT_BYTES { - return Err(format!("单条消息超出 {} 字节上限", MAX_CONTENT_BYTES)); - } - } - Ok(()) -} - -/// 流式 chunk 事件载荷(emit 到前端)。 -#[derive(Debug, Clone, Serialize)] -pub struct TranslateChunk { - /// 翻译流携带请求标识;普通 AI 对话流不需要,值为 null。 - pub request_id: Option, - /// 本次增量文本。 - pub delta: String, - /// 是否结束。 - pub done: bool, -} - -#[cfg(test)] -mod tests { - use super::*; - use std::net::SocketAddr; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - async fn delayed_response(delay: Duration) -> (SocketAddr, tokio::task::JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let task = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut request = [0_u8; 1024]; - let _ = socket.read(&mut request).await; - tokio::time::sleep(delay).await; - let _ = socket - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") - .await; - }); - (address, task) - } - - #[tokio::test] - async fn clients_with_different_timeouts_do_not_share_the_first_timeout() { - let (slow_address, slow_task) = delayed_response(Duration::from_millis(80)).await; - let short = shared_client(Duration::from_millis(10), false); - assert!(short - .get(format!("http://{slow_address}")) - .send() - .await - .is_err()); - slow_task.await.unwrap(); - - let (fast_address, fast_task) = delayed_response(Duration::from_millis(50)).await; - let long = shared_client(Duration::from_secs(1), false); - assert!(long - .get(format!("http://{fast_address}")) - .send() - .await - .is_ok()); - fast_task.await.unwrap(); - } - - #[test] - fn rejects_oversized_translation_input() { - assert!(validate_translation_text(&"x".repeat(MAX_TRANSLATION_TEXT_BYTES + 1)).is_err()); - assert!(validate_translation_text(&"x".repeat(MAX_TRANSLATION_TEXT_BYTES)).is_ok()); - } - - #[test] - fn limited_body_rejects_chunks_over_the_cap() { - let mut body = LimitedBody::new(4, "test body"); - - assert!(body.push(b"1234").is_ok()); - assert!(body.push(b"5").is_err()); - } - - #[test] - fn error_body_is_truncated_at_the_cap() { - let mut body = ErrorBody::new(4); - - assert!(body.push(b"123456")); - assert_eq!(body.finish(), "1234…[truncated]"); - } - - #[test] - fn stream_chunks_serialize_the_originating_request_id() { - let value = serde_json::to_value(TranslateChunk { - request_id: Some(42), - delta: "hello".to_string(), - done: false, - }) - .unwrap(); - - assert_eq!(value["request_id"], 42); - } - - #[test] - fn rejects_unknown_or_oversized_language_codes() { - assert!(validate_language_pair("auto", "zh-CN").is_ok()); - assert!(validate_language_pair("xx-custom", "zh-CN").is_err()); - assert!(validate_language_pair(&"x".repeat(128), "en").is_err()); - assert!(validate_language_pair("en", "auto").is_err()); - } -} diff --git a/src-tauri/src/translate/openai.rs b/src-tauri/src/translate/openai.rs deleted file mode 100644 index 3a5e808..0000000 --- a/src-tauri/src/translate/openai.rs +++ /dev/null @@ -1,422 +0,0 @@ -//! OpenAI 兼容流式能力:翻译流 + 普通 AI 对话流。 - -use std::time::Duration; - -use futures_util::StreamExt; -use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Runtime}; - -use crate::config::{ChatAiConfig, OpenAiConfig}; -use crate::translate::{ - read_error_body, shared_client, ChatMessage, TranslateChunk, MAX_SSE_FRAME_BYTES, - MAX_STREAM_OUTPUT_BYTES, -}; - -/// 流式翻译事件名。 -pub const CHUNK_EVENT: &str = "translate-chunk"; -/// 流式 AI 对话事件名。 -pub const CHAT_CHUNK_EVENT: &str = "chat-chunk"; - -/// 单次请求的整体超时(含流式),避免网络挂起永久占用 loading 态。 -const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); - -fn render_translate_prompt(cfg: &OpenAiConfig, source: &str, target: &str) -> String { - let src = if source == "auto" { - "自动检测的源语言".to_string() - } else { - source.to_string() - }; - cfg.translate_prompt - .replace("{source}", &src) - .replace("{target}", target) -} - -fn build_translate_messages( - cfg: &OpenAiConfig, - text: &str, - source: &str, - target: &str, -) -> Vec { - let prompt = render_translate_prompt(cfg, source, target); - vec![ - json!({ "role": "system", "content": prompt }), - json!({ - "role": "user", - "content": format!( - "请严格执行以下翻译提示词:\n{prompt}\n\n待处理文本:\n{text}" - ) - }), - ] -} - -/// 调用 OpenAI 兼容接口做流式翻译,每个 delta 通过事件推送前端。 -pub async fn translate_stream( - app: &AppHandle, - cfg: &OpenAiConfig, - request_id: u64, - text: &str, - source: &str, - target: &str, -) -> Result<(), String> { - let messages = build_translate_messages(cfg, text, source, target); - stream_messages( - app, - &cfg.base_url, - &cfg.api_key, - &cfg.model, - messages, - CHUNK_EVENT, - Some(request_id), - ) - .await -} - -/// 普通 AI 对话:使用独立 AI 对话配置。若配置了自定义提示词则作为 system 消息前置。 -pub async fn chat_stream( - app: &AppHandle, - cfg: &ChatAiConfig, - messages: &[ChatMessage], -) -> Result<(), String> { - let mut payload: Vec = Vec::with_capacity(messages.len() + 1); - if !cfg.chat_prompt.trim().is_empty() { - payload.push(json!({ "role": "system", "content": cfg.chat_prompt })); - } - payload.extend( - messages - .iter() - .map(|m| json!({ "role": m.role, "content": m.content })), - ); - stream_messages( - app, - &cfg.base_url, - &cfg.api_key, - &cfg.model, - payload, - CHAT_CHUNK_EVENT, - None, - ) - .await -} - -async fn stream_messages( - app: &AppHandle, - base_url: &str, - api_key: &str, - model: &str, - messages: Vec, - event_name: &str, - request_id: Option, -) -> Result<(), String> { - if api_key.is_empty() { - return Err("未配置 API Key".to_string()); - } - - let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); - let payload = json!({ - "model": model, - "stream": true, - "messages": messages, - }); - - let resp = shared_client(REQUEST_TIMEOUT, false) - .post(&url) - .bearer_auth(api_key) - .json(&payload) - .send() - .await - .map_err(|e| format!("OpenAI 请求失败: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = read_error_body(resp).await; - return Err(format!("OpenAI 返回状态 {status}: {body}")); - } - - let mut stream = resp.bytes_stream(); - let mut decoder = SseDecoder::with_limits(MAX_SSE_FRAME_BYTES, MAX_STREAM_OUTPUT_BYTES); - - while let Some(chunk) = stream.next().await { - let bytes = chunk.map_err(|e| format!("读取流失败: {e}"))?; - if emit_deltas(app, event_name, request_id, decoder.push(&bytes)?)? { - return Ok(()); - } - } - - if emit_deltas(app, event_name, request_id, decoder.finish()?)? { - Ok(()) - } else { - Err("OpenAI 流结束但未收到 [DONE]".to_string()) - } -} - -/// 单行 SSE 解析结果。 -#[derive(Debug, Clone, PartialEq, Eq)] -enum SseDelta { - Content(String), - Done, -} - -/// 解析一行 SSE:`data: {...}` 或 `data: [DONE]`。 -fn parse_sse_line(line: &str) -> Result, String> { - let Some(data) = line.strip_prefix("data:") else { - return Ok(None); - }; - let data = data.trim(); - if data.is_empty() { - return Ok(None); - } - if data == "[DONE]" { - return Ok(Some(SseDelta::Done)); - } - let v: Value = - serde_json::from_str(data).map_err(|e| format!("OpenAI SSE JSON 解析失败: {e}"))?; - let content = v - .get("choices") - .and_then(|choices| choices.get(0)) - .and_then(|choice| choice.get("delta")) - .and_then(|delta| delta.get("content")) - .and_then(Value::as_str); - Ok(content.map(|content| SseDelta::Content(content.to_string()))) -} - -struct SseDecoder { - buffer: Vec, - frame_limit: usize, - output_limit: usize, - output_bytes: usize, - saw_done: bool, -} - -impl SseDecoder { - fn with_limits(frame_limit: usize, output_limit: usize) -> Self { - Self { - buffer: Vec::new(), - frame_limit, - output_limit, - output_bytes: 0, - saw_done: false, - } - } - - fn push(&mut self, bytes: &[u8]) -> Result, String> { - let mut deltas = Vec::new(); - for byte in bytes { - if *byte == b'\n' { - if let Some(delta) = self.take_line()? { - deltas.push(delta); - } - continue; - } - if self.buffer.len() >= self.frame_limit { - return Err(format!("OpenAI SSE 单帧超出 {} 字节上限", self.frame_limit)); - } - self.buffer.push(*byte); - } - Ok(deltas) - } - - fn finish(mut self) -> Result, String> { - let mut deltas = Vec::new(); - if !self.buffer.is_empty() { - if let Some(delta) = self.take_line()? { - deltas.push(delta); - } - } - if !self.saw_done { - return Err("OpenAI 流结束但未收到 [DONE]".to_string()); - } - Ok(deltas) - } - - fn take_line(&mut self) -> Result, String> { - let line_bytes = std::mem::take(&mut self.buffer); - let line = std::str::from_utf8(&line_bytes) - .map_err(|e| format!("OpenAI SSE 包含非法 UTF-8: {e}"))? - .trim(); - let Some(delta) = parse_sse_line(line)? else { - return Ok(None); - }; - match &delta { - SseDelta::Content(content) => { - self.output_bytes = self - .output_bytes - .checked_add(content.len()) - .ok_or_else(|| "OpenAI 流式输出大小溢出".to_string())?; - if self.output_bytes > self.output_limit { - return Err(format!( - "OpenAI 流式输出超出 {} 字节上限", - self.output_limit - )); - } - } - SseDelta::Done => self.saw_done = true, - } - Ok(Some(delta)) - } -} - -fn emit_deltas( - app: &AppHandle, - event_name: &str, - request_id: Option, - deltas: Vec, -) -> Result { - for delta in deltas { - match delta { - SseDelta::Content(delta) => app - .emit( - event_name, - TranslateChunk { - request_id, - delta, - done: false, - }, - ) - .map_err(|e| format!("发送流式结果失败: {e}"))?, - SseDelta::Done => { - app.emit( - event_name, - TranslateChunk { - request_id, - delta: String::new(), - done: true, - }, - ) - .map_err(|e| format!("发送流式完成事件失败: {e}"))?; - return Ok(true); - } - } - } - Ok(false) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_content_delta() { - let line = r#"data: {"choices":[{"delta":{"content":"你好"}}]}"#; - match parse_sse_line(line).unwrap() { - Some(SseDelta::Content(t)) => assert_eq!(t, "你好"), - _ => panic!("expected content"), - } - } - - #[test] - fn parses_done() { - assert!(matches!( - parse_sse_line("data: [DONE]").unwrap(), - Some(SseDelta::Done) - )); - } - - #[test] - fn ignores_non_data_lines() { - assert!(parse_sse_line(": keep-alive").unwrap().is_none()); - assert!(parse_sse_line("").unwrap().is_none()); - } - - #[test] - fn ignores_delta_without_content() { - let line = r#"data: {"choices":[{"delta":{"role":"assistant"}}]}"#; - assert!(parse_sse_line(line).unwrap().is_none()); - } - - #[test] - fn renders_translate_prompt_placeholders() { - let cfg = OpenAiConfig { - translate_prompt: "from {source} to {target}".to_string(), - ..OpenAiConfig::default() - }; - assert_eq!( - render_translate_prompt(&cfg, "auto", "zh-CN"), - "from 自动检测的源语言 to zh-CN" - ); - } - - #[test] - fn includes_custom_translate_prompt_in_system_and_user_messages() { - let cfg = OpenAiConfig { - translate_prompt: "CUSTOM {source} => {target}".to_string(), - ..OpenAiConfig::default() - }; - let messages = build_translate_messages(&cfg, "hello", "en", "zh-CN"); - assert_eq!(messages[0]["role"], "system"); - assert_eq!(messages[0]["content"], "CUSTOM en => zh-CN"); - assert!(messages[1]["content"] - .as_str() - .unwrap() - .contains("CUSTOM en => zh-CN")); - assert!(messages[1]["content"].as_str().unwrap().contains("hello")); - } - - #[test] - fn prepends_chat_prompt_as_system_message() { - let prompt = "你是一个友好的助手"; - let history = [ChatMessage { - role: "user".to_string(), - content: "你好".to_string(), - }]; - - let mut payload: Vec = Vec::new(); - if !prompt.trim().is_empty() { - payload.push(json!({ "role": "system", "content": prompt })); - } - payload.extend( - history - .iter() - .map(|m| json!({ "role": m.role, "content": m.content })), - ); - - assert_eq!(payload.len(), 2); - assert_eq!(payload[0]["role"], "system"); - assert_eq!(payload[0]["content"], prompt); - assert_eq!(payload[1]["role"], "user"); - } - - #[test] - fn parses_final_sse_frame_without_a_trailing_newline() { - let mut decoder = SseDecoder::with_limits(1024, 1024); - let deltas = decoder - .push(b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\ndata: [DONE]") - .unwrap(); - assert!(matches!(deltas.as_slice(), [SseDelta::Content(text)] if text == "hello")); - - let final_deltas = decoder.finish().unwrap(); - assert!(matches!(final_deltas.as_slice(), [SseDelta::Done])); - } - - #[test] - fn rejects_eof_without_done_marker() { - let mut decoder = SseDecoder::with_limits(1024, 1024); - decoder - .push(b"data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n") - .unwrap(); - - assert!(decoder.finish().unwrap_err().contains("[DONE]")); - } - - #[test] - fn rejects_oversized_sse_frames() { - let mut decoder = SseDecoder::with_limits(8, 1024); - - assert!(decoder.push(b"data: 123456789").is_err()); - } - - #[test] - fn rejects_oversized_stream_output() { - let mut decoder = SseDecoder::with_limits(1024, 3); - - assert!(decoder - .push(b"data: {\"choices\":[{\"delta\":{\"content\":\"four\"}}]}\n") - .is_err()); - } - - #[test] - fn rejects_malformed_data_frames_instead_of_silently_skipping_them() { - let mut decoder = SseDecoder::with_limits(1024, 1024); - - assert!(decoder.push(b"data: {broken}\n").is_err()); - } -} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs deleted file mode 100644 index b9c6647..0000000 --- a/src-tauri/src/tray.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! 系统托盘:图标 + 菜单(翻译 / 设置 / 退出)。 - -use tauri::{ - menu::{Menu, MenuItem, PredefinedMenuItem}, - tray::TrayIconBuilder, - AppHandle, Emitter, Runtime, -}; - -use crate::window; - -/// 在 setup 中构建系统托盘。 -pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { - let open = MenuItem::with_id(app, "open", "翻译", true, None::<&str>)?; - let settings = MenuItem::with_id(app, "settings", "设置…", true, None::<&str>)?; - let sep = PredefinedMenuItem::separator(app)?; - let quit = MenuItem::with_id(app, "quit", "退出 TTREE", true, None::<&str>)?; - - let menu = Menu::with_items(app, &[&open, &settings, &sep, &quit])?; - - let icon = app - .default_window_icon() - .ok_or_else(|| tauri::Error::AssetNotFound("window icon".into()))?; - - TrayIconBuilder::with_id("main-tray") - .icon(icon.clone()) - .menu(&menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { - "open" => { - if let Some(w) = window::main_window(app) { - window::show_window(&w); - } - } - "settings" => { - if let Some(w) = window::main_window(app) { - window::show_window(&w); - } - let _ = app.emit("open-settings", ()); - } - "quit" => { - app.exit(0); - } - _ => {} - }) - .build(app)?; - - Ok(()) -} diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs deleted file mode 100644 index 27dc195..0000000 --- a/src-tauri/src/window.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! 窗口管理:显示/隐藏/切换,以及失焦自动隐藏。 -//! -//! 关键策略:窗口**隐藏而非销毁**,保证快捷键秒级呼出。 - -use std::sync::atomic::{AtomicBool, Ordering}; - -use tauri::{AppHandle, Manager, WebviewWindow}; - -pub const MAIN_WINDOW: &str = "main"; - -static IS_PINNED: AtomicBool = AtomicBool::new(false); - -/// 获取主窗口句柄。 -pub fn main_window(app: &AppHandle) -> Option> { - app.get_webview_window(MAIN_WINDOW) -} - -/// 显示并聚焦窗口(居中)。 -pub fn show_window(window: &WebviewWindow) { - let _ = window.center(); - let _ = window.set_always_on_top(is_pinned()); - let _ = window.show(); - let _ = window.set_focus(); -} - -/// 显示并聚焦主窗口。 -pub fn show_main(app: &AppHandle) { - if let Some(window) = main_window(app) { - show_window(&window); - } -} - -/// 主窗口当前是否拥有焦点。 -#[allow(dead_code)] -pub fn is_main_focused(app: &AppHandle) -> bool { - main_window(app) - .and_then(|window| window.is_focused().ok()) - .unwrap_or(false) -} - -/// 窗口当前是否可见(查询失败时保守返回 false)。 -pub fn is_visible_safe(window: &tauri::Window) -> bool { - window.is_visible().unwrap_or(false) -} - -/// 隐藏主窗口。 -pub fn hide_window(window: &WebviewWindow) { - let _ = window.hide(); -} - -/// 当前是否处于固定窗口模式。 -pub fn is_pinned() -> bool { - IS_PINNED.load(Ordering::Relaxed) -} - -/// 设置固定窗口模式。固定时窗口置顶且失焦不自动隐藏。 -pub fn set_pinned( - window: &WebviewWindow, - pinned: bool, -) -> tauri::Result { - let pinned = commit_pinned_state(&IS_PINNED, pinned, window.set_always_on_top(pinned))?; - if pinned { - let _ = window.show(); - let _ = window.set_focus(); - } - Ok(pinned) -} - -fn commit_pinned_state( - state: &AtomicBool, - pinned: bool, - operation: Result<(), E>, -) -> Result { - operation?; - state.store(pinned, Ordering::Relaxed); - Ok(pinned) -} - -/// 切换主窗口可见性。返回 true 表示切换后窗口为显示状态。 -pub fn toggle_window(app: &AppHandle) -> bool { - let Some(window) = main_window(app) else { - return false; - }; - match window.is_visible() { - Ok(true) => { - hide_window(&window); - false - } - _ => { - show_window(&window); - true - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pinned_state_changes_only_after_window_operation_succeeds() { - let state = AtomicBool::new(false); - - let failed: Result = commit_pinned_state(&state, true, Err("failed")); - assert_eq!(failed, Err("failed")); - assert!(!state.load(Ordering::Relaxed)); - - assert_eq!( - commit_pinned_state(&state, true, Ok::<(), &str>(())), - Ok(true) - ); - assert!(state.load(Ordering::Relaxed)); - } -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json deleted file mode 100644 index b9da833..0000000 --- a/src-tauri/tauri.conf.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "TTREE", - "version": "0.1.5", - "identifier": "com.samwstu.ttree", - "build": { - "beforeDevCommand": "npm run dev", - "devUrl": "http://localhost:1420", - "beforeBuildCommand": "npm run build", - "frontendDist": "../dist" - }, - "app": { - "macOSPrivateApi": true, - "windows": [ - { - "label": "main", - "title": "TTREE", - "width": 480, - "height": 460, - "minWidth": 480, - "minHeight": 300, - "resizable": true, - "decorations": false, - "transparent": true, - "alwaysOnTop": false, - "visible": false, - "skipTaskbar": true, - "center": true, - "shadow": true, - "titleBarStyle": "Overlay" - } - ], - "security": { - "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" - } - }, - "bundle": { - "active": true, - "targets": "all", - "category": "Productivity", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "macOS": { - "minimumSystemVersion": "11.0" - }, - "createUpdaterArtifacts": true - }, - "plugins": { - "updater": { - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgOEZCRkNDMDRGMDBDQTQxNApSV1FVcEF6d0JNeS9qM0dGdUpCUUo1ZW5SdUhGeTBxMkhKYlhkMC9jRHhOeG5QcWFnQ1Z0dCt5UAo=", - "endpoints": [ - "https://github.com/ZASENJC/ttree/releases/latest/download/latest.json" - ] - } - } -} diff --git a/src/App.svelte b/src/App.svelte deleted file mode 100644 index d1b2ff7..0000000 --- a/src/App.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - -{#if view === "settings"} - (view = "main")} /> -{:else if mode === "chat"} - (mode = nextMode)} - onSettings={() => (view = "settings")} - /> -{:else} - (mode = nextMode)} - onSettings={() => (view = "settings")} - /> -{/if} diff --git a/src/lib/api/tauri.test.ts b/src/lib/api/tauri.test.ts deleted file mode 100644 index 3091c0b..0000000 --- a/src/lib/api/tauri.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); - -vi.mock("@tauri-apps/api/core", () => ({ invoke })); - -import { appendChatHistory } from "./tauri"; - -describe("appendChatHistory", () => { - beforeEach(() => invoke.mockReset()); - - it("passes the stable conversation id when resuming old history", async () => { - invoke.mockResolvedValue(undefined); - const round = [ - { role: "user" as const, content: "follow up" }, - { role: "assistant" as const, content: "answer" }, - ]; - - await appendChatHistory(round, 7); - - expect(invoke).toHaveBeenCalledWith("append_chat_history", { - round, - conversationId: 7, - }); - }); - - it("uses a null id for a newly started conversation", async () => { - invoke.mockResolvedValue(undefined); - - await appendChatHistory([], null); - - expect(invoke).toHaveBeenCalledWith("append_chat_history", { - round: [], - conversationId: null, - }); - }); -}); diff --git a/src/lib/api/tauri.ts b/src/lib/api/tauri.ts deleted file mode 100644 index 2030dc0..0000000 --- a/src/lib/api/tauri.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** Tauri 后端调用与事件订阅封装。 */ -import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; - -export type Engine = "openai" | "auto" | "google" | "bing"; - -export interface TranslateRequest { - request_id: number; - text: string; - engine: Engine; - source: string; - target: string; -} - -export interface TranslateChunk { - request_id: number | null; - delta: string; - done: boolean; -} - -export interface ChatMessage { - role: "user" | "assistant"; - content: string; -} - -/** 单段对话:一组消息 + 摘要(首句用户提问,用作列表标题)。 */ -export interface Conversation { - /** 稳定会话 id;列表排序变化时保持不变。 */ - id: number; - messages: ChatMessage[]; - summary: string; -} - -/** 普通 AI 对话:不附加翻译提示词,结果通过 chat-chunk 事件返回。 */ -export function chat(messages: ChatMessage[]): Promise { - return invoke("chat", { messages }); -} - -/** 读取全部对话历史(扁平消息流,向后兼容)。 */ -export function loadChatHistory(): Promise { - return invoke("load_chat_history"); -} - -/** 以对话为单位读取历史(最久未活动 → 最近活动)。 */ -export function loadConversations(): Promise { - return invoke("load_conversations"); -} - -/** 写入对话分隔标记,开启一段新对话(不删除已有历史)。 */ -export function startNewConversation(): Promise { - return invoke("start_new_conversation"); -} - -/** 追加一轮对话;conversationId 非空时恢复并续写指定历史对话。 */ -export function appendChatHistory( - round: ChatMessage[], - conversationId: number | null, -): Promise { - return invoke("append_chat_history", { round, conversationId }); -} - -/** 清空全部对话历史(删除历史文件)。 */ -export function clearChatHistory(): Promise { - return invoke("clear_chat_history"); -} - -/** 订阅 AI 对话 chunk 事件。 */ -export function onChatChunk( - handler: (chunk: TranslateChunk) => void, -): Promise { - return listen("chat-chunk", (e) => handler(e.payload)); -} - -/** - * 触发翻译。 - * - auto:免费服务自动分流和故障切换,返回完整译文字符串 - * - openai:返回空串,结果通过 onChunk 流式到达 - */ -export function translate(req: TranslateRequest): Promise { - return invoke("translate", { req }); -} - -/** 订阅流式翻译 chunk 事件。 */ -export function onChunk( - handler: (chunk: TranslateChunk) => void, -): Promise { - return listen("translate-chunk", (e) => handler(e.payload)); -} - -/** 触发交互式截图 + 系统 OCR,返回识别文本(用户取消时为空串)。 */ -export function screenshotOcr(): Promise { - return invoke("screenshot_ocr"); -} - -/** 显示并聚焦主窗口。 */ -export function showMain(): Promise { - return invoke("show_main"); -} - -/** 读取固定窗口状态。 */ -export function getPinned(): Promise { - return invoke("get_pinned"); -} - -/** 设置固定窗口状态。 */ -export function setPinned(pinned: boolean): Promise { - return invoke("set_pinned", { pinned }); -} - -export interface OpenAiConfig { - base_url: string; - api_key: string; - has_api_key: boolean; - clear_api_key: boolean; - model: string; - translate_prompt: string; -} - -export interface ChatAiConfig { - base_url: string; - api_key: string; - has_api_key: boolean; - clear_api_key: boolean; - model: string; - chat_prompt: string; -} - -export interface ShortcutConfig { - toggle: string; - ocr: string; - ai_dialog: string; - selection_translate: string; - selection_ai_dialog: string; -} - -export interface AppearanceConfig { - /** 面板整体透明度,0–1。后端会钳制到合法范围。 */ - panel_opacity: number; -} - -export interface SelectedTranslatePayload { - text: string; -} - -export interface AiDialogPayload { - text: string | null; -} - -/** 读取 OpenAI 配置。 */ -export function getOpenAiConfig(): Promise { - return invoke("get_openai_config"); -} - -/** 保存 OpenAI 配置。 */ -export function setOpenAiConfig(config: OpenAiConfig): Promise { - return invoke("set_openai_config", { config }); -} - -/** 读取 AI 对话配置。 */ -export function getChatAiConfig(): Promise { - return invoke("get_chat_ai_config"); -} - -/** 保存 AI 对话配置。 */ -export function setChatAiConfig(config: ChatAiConfig): Promise { - return invoke("set_chat_ai_config", { config }); -} - -/** 读取快捷键配置。 */ -export function getShortcutConfig(): Promise { - return invoke("get_shortcut_config"); -} - -/** 保存快捷键配置并让后端立即重注册。 */ -export function setShortcutConfig(config: ShortcutConfig): Promise { - return invoke("set_shortcut_config", { config }); -} - -/** 录制快捷键时临时忽略全局快捷键触发。 */ -export function setShortcutRecording(active: boolean): Promise { - return invoke("set_shortcut_recording", { active }); -} - -/** 读取外观配置(面板透明度等)。 */ -export function getAppearanceConfig(): Promise { - return invoke("get_appearance_config"); -} - -/** 保存外观配置。 */ -export function setAppearanceConfig(config: AppearanceConfig): Promise { - return invoke("set_appearance_config", { config }); -} - -/** 订阅托盘"设置"菜单事件。 */ -export function onOpenSettings(handler: () => void): Promise { - return listen("open-settings", () => handler()); -} - -/** 订阅截图 OCR 触发事件(快捷键按下时由后端发出)。 */ -export function onTriggerOcr(handler: () => void): Promise { - return listen("trigger-ocr", () => handler()); -} - -/** 订阅呼出翻译页事件(toggle 快捷键显示窗口时由后端发出)。 */ -export function onTriggerTranslate(handler: () => void): Promise { - return listen("trigger-translate", () => handler()); -} - -/** 订阅划词翻译事件。 */ -export function onSelectedTranslate( - handler: (payload: SelectedTranslatePayload) => void, -): Promise { - return listen("trigger-selected-translate", (e) => - handler(e.payload), - ); -} - -/** 订阅 AI 对话呼出事件。 */ -export function onTriggerAiDialog( - handler: (payload: AiDialogPayload) => void, -): Promise { - return listen("trigger-ai-dialog", (e) => handler(e.payload)); -} diff --git a/src/lib/components/EngineTabs.svelte b/src/lib/components/EngineTabs.svelte deleted file mode 100644 index bfc8a36..0000000 --- a/src/lib/components/EngineTabs.svelte +++ /dev/null @@ -1,92 +0,0 @@ - - -
- - {#each tabs as tab (tab.id)} - - {/each} -
- - diff --git a/src/lib/components/LangSelector.svelte b/src/lib/components/LangSelector.svelte deleted file mode 100644 index 83f4a04..0000000 --- a/src/lib/components/LangSelector.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - -
- - - - - -
- - diff --git a/src/lib/components/ResultView.svelte b/src/lib/components/ResultView.svelte deleted file mode 100644 index 4ba729c..0000000 --- a/src/lib/components/ResultView.svelte +++ /dev/null @@ -1,183 +0,0 @@ - - -
- {#if error} -

{error}

- {/if} - -
-
- 翻译 - {#if webOutput} - - {/if} -
- - {#if webError} -

{webError}

- {:else if webOutput} -

{webOutput}

- {:else if webLoading} -

翻译中…

- {:else} -

译文显示在这里

- {/if} -
- -
-
- AI - {#if aiOutput} - - {/if} -
- - {#if aiError} -

{aiError}

- {:else if aiOutput} -

{aiOutput}{#if aiLoading}{/if}

- {:else if aiLoading} -

AI 翻译中…

- {:else} -

AI 译文显示在这里

- {/if} -
-
- - diff --git a/src/lib/components/TranslatePanel.svelte b/src/lib/components/TranslatePanel.svelte deleted file mode 100644 index ed7f773..0000000 --- a/src/lib/components/TranslatePanel.svelte +++ /dev/null @@ -1,341 +0,0 @@ - - -
- -
- -
- - -
-
- - -
- -
- - -
- - void t.setSource(c)} - onTarget={(c) => void t.setTarget(c)} - /> -
- - -
- -
-
- - diff --git a/src/lib/components/panelSizing.test.ts b/src/lib/components/panelSizing.test.ts deleted file mode 100644 index 0a7ce8e..0000000 --- a/src/lib/components/panelSizing.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { clampSourceHeight } from "./panelSizing"; - -describe("clampSourceHeight", () => { - test("shrinks the source pane when the window is restored at 300px high", () => { - expect(clampSourceHeight(210, 300)).toBe(88); - }); - - test("keeps the default source height at the normal 460px window height", () => { - expect(clampSourceHeight(210, 460)).toBe(210); - }); -}); diff --git a/src/lib/components/panelSizing.ts b/src/lib/components/panelSizing.ts deleted file mode 100644 index dd00650..0000000 --- a/src/lib/components/panelSizing.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const MIN_SOURCE_HEIGHT = 88; -export const MIN_RESULT_HEIGHT = 120; -export const PANEL_CHROME_HEIGHT = 128; - -export function maxSourceHeight(panelHeight: number): number { - return Math.max( - MIN_SOURCE_HEIGHT, - panelHeight - MIN_RESULT_HEIGHT - PANEL_CHROME_HEIGHT, - ); -} - -export function clampSourceHeight( - sourceHeight: number, - panelHeight: number, -): number { - return Math.min( - Math.max(sourceHeight, MIN_SOURCE_HEIGHT), - maxSourceHeight(panelHeight), - ); -} diff --git a/src/lib/shortcuts/shortcutRecorder.test.ts b/src/lib/shortcuts/shortcutRecorder.test.ts deleted file mode 100644 index 21deef7..0000000 --- a/src/lib/shortcuts/shortcutRecorder.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - applyShortcutRecord, - clearShortcutField, - recordShortcutFromEvent, - type ShortcutDraft, - type ShortcutKeyboardEvent, -} from "./shortcutRecorder"; - -function keyboardEvent( - overrides: Partial, -): ShortcutKeyboardEvent { - return { - key: "s", - code: "KeyS", - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - repeat: false, - ...overrides, - }; -} - -describe("recordShortcutFromEvent", () => { - test("records command shift space in the backend shortcut format", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ - key: " ", - code: "Space", - metaKey: true, - shiftKey: true, - }), - ); - - expect(result).toEqual({ - status: "recorded", - shortcut: "CmdOrCtrl+Shift+Space", - }); - }); - - test("records option space", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ - key: " ", - code: "Space", - altKey: true, - }), - ); - - expect(result).toEqual({ status: "recorded", shortcut: "Option+Space" }); - }); - - test("normalizes letter keys from the physical keyboard code", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ - key: "s", - code: "KeyS", - metaKey: true, - shiftKey: true, - }), - ); - - expect(result).toEqual({ status: "recorded", shortcut: "CmdOrCtrl+Shift+S" }); - }); - - test("outputs modifiers in a stable order", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ - key: "k", - code: "KeyK", - shiftKey: true, - altKey: true, - ctrlKey: true, - metaKey: true, - }), - ); - - expect(result).toEqual({ - status: "recorded", - shortcut: "CmdOrCtrl+Control+Option+Shift+K", - }); - }); - - test("records function keys", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ key: "F1", code: "F1", metaKey: true }), - ); - - expect(result).toEqual({ status: "recorded", shortcut: "CmdOrCtrl+F1" }); - }); - - test("records arrow keys", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ key: "ArrowUp", code: "ArrowUp", metaKey: true }), - ); - - expect(result).toEqual({ - status: "recorded", - shortcut: "CmdOrCtrl+ArrowUp", - }); - }); - - test("records named editing keys", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ key: "Enter", code: "Enter", ctrlKey: true }), - ); - - expect(result).toEqual({ status: "recorded", shortcut: "Control+Enter" }); - }); - - test("keeps waiting when the user only presses a modifier key", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ - key: "Shift", - code: "ShiftLeft", - shiftKey: true, - }), - ); - - expect(result).toEqual({ status: "pending" }); - }); - - test("rejects shift-only shortcuts", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ - key: "s", - code: "KeyS", - shiftKey: true, - }), - ); - - expect(result.status).toBe("invalid"); - if (result.status === "invalid") { - expect(result.message).toContain("Cmd/Ctrl/Option"); - } - }); - - test("rejects main keys without a modifier", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ key: "s", code: "KeyS" }), - ); - - expect(result.status).toBe("invalid"); - if (result.status === "invalid") { - expect(result.message).toContain("修饰键"); - } - }); - - test("cancels recording with Escape", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ - key: "Escape", - code: "Escape", - metaKey: true, - }), - ); - - expect(result).toEqual({ status: "cancelled" }); - }); - - test("ignores repeated keydown events", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ - key: "s", - code: "KeyS", - metaKey: true, - repeat: true, - }), - ); - - expect(result).toEqual({ status: "pending" }); - }); - - test("rejects unsupported physical keys", () => { - const result = recordShortcutFromEvent( - keyboardEvent({ - key: "¥", - code: "IntlYen", - metaKey: true, - }), - ); - - expect(result.status).toBe("invalid"); - if (result.status === "invalid") { - expect(result.message).toContain("不支持"); - } - }); -}); - -describe("applyShortcutRecord", () => { - const draft: ShortcutDraft = { - toggle: "CmdOrCtrl+Shift+Space", - ocr: "CmdOrCtrl+Shift+S", - ai_dialog: "", - selection_translate: "", - selection_ai_dialog: "", - }; - - test("updates only the selected shortcut field", () => { - const updated = applyShortcutRecord(draft, "ocr", { - status: "recorded", - shortcut: "Option+Space", - }); - - expect(updated).toEqual({ - toggle: "CmdOrCtrl+Shift+Space", - ocr: "Option+Space", - ai_dialog: "", - selection_translate: "", - selection_ai_dialog: "", - }); - expect(draft).toEqual({ - toggle: "CmdOrCtrl+Shift+Space", - ocr: "CmdOrCtrl+Shift+S", - ai_dialog: "", - selection_translate: "", - selection_ai_dialog: "", - }); - }); - - test("updates only the AI dialog shortcut field", () => { - const updated = applyShortcutRecord(draft, "ai_dialog", { - status: "recorded", - shortcut: "CmdOrCtrl+Shift+A", - }); - - expect(updated).toEqual({ - toggle: "CmdOrCtrl+Shift+Space", - ocr: "CmdOrCtrl+Shift+S", - ai_dialog: "CmdOrCtrl+Shift+A", - selection_translate: "", - selection_ai_dialog: "", - }); - expect(draft.ai_dialog).toBe(""); - }); - - test("leaves the draft unchanged for invalid input", () => { - const updated = applyShortcutRecord(draft, "toggle", { - status: "invalid", - message: "快捷键必须包含至少一个修饰键", - }); - - expect(updated).toBe(draft); - }); - - test("leaves the draft unchanged for pending and cancelled input", () => { - expect(applyShortcutRecord(draft, "ai_dialog", { status: "pending" })).toBe( - draft, - ); - expect(applyShortcutRecord(draft, "ai_dialog", { status: "cancelled" })).toBe( - draft, - ); - }); - - test("clears only the selected shortcut field", () => { - const configured: ShortcutDraft = { - ...draft, - ai_dialog: "CmdOrCtrl+Shift+A", - }; - - expect(clearShortcutField(configured, "ai_dialog")).toEqual({ - toggle: "CmdOrCtrl+Shift+Space", - ocr: "CmdOrCtrl+Shift+S", - ai_dialog: "", - selection_translate: "", - selection_ai_dialog: "", - }); - }); - - test("clears a selection shortcut field independently", () => { - const configured: ShortcutDraft = { - ...draft, - selection_translate: "CmdOrCtrl+Shift+T", - selection_ai_dialog: "CmdOrCtrl+Shift+D", - }; - - expect(clearShortcutField(configured, "selection_translate")).toEqual({ - toggle: "CmdOrCtrl+Shift+Space", - ocr: "CmdOrCtrl+Shift+S", - ai_dialog: "", - selection_translate: "", - selection_ai_dialog: "CmdOrCtrl+Shift+D", - }); - }); -}); diff --git a/src/lib/shortcuts/shortcutRecorder.ts b/src/lib/shortcuts/shortcutRecorder.ts deleted file mode 100644 index 2218ebb..0000000 --- a/src/lib/shortcuts/shortcutRecorder.ts +++ /dev/null @@ -1,183 +0,0 @@ -export type ShortcutField = - | "toggle" - | "ocr" - | "ai_dialog" - | "selection_translate" - | "selection_ai_dialog"; - -export interface ShortcutDraft { - toggle: string; - ocr: string; - ai_dialog: string; - selection_translate: string; - selection_ai_dialog: string; -} - -export interface ShortcutKeyboardEvent { - key: string; - code: string; - metaKey: boolean; - ctrlKey: boolean; - altKey: boolean; - shiftKey: boolean; - repeat: boolean; -} - -export type ShortcutRecordResult = - | { status: "recorded"; shortcut: string } - | { status: "invalid"; message: string } - | { status: "cancelled" } - | { status: "pending" }; - -const MODIFIER_KEYS = new Set([ - "Alt", - "AltGraph", - "Control", - "Meta", - "Shift", -]); - -const MODIFIER_CODES = new Set([ - "AltLeft", - "AltRight", - "ControlLeft", - "ControlRight", - "MetaLeft", - "MetaRight", - "ShiftLeft", - "ShiftRight", -]); - -const NAMED_KEYS = new Map([ - ["ArrowDown", "ArrowDown"], - ["ArrowLeft", "ArrowLeft"], - ["ArrowRight", "ArrowRight"], - ["ArrowUp", "ArrowUp"], - ["Backspace", "Backspace"], - ["Delete", "Delete"], - ["End", "End"], - ["Enter", "Enter"], - ["Home", "Home"], - ["Insert", "Insert"], - ["PageDown", "PageDown"], - ["PageUp", "PageUp"], - ["Space", "Space"], - ["Tab", "Tab"], -]); - -const PUNCTUATION_KEYS = new Map([ - ["Backquote", "`"], - ["Backslash", "\\"], - ["BracketLeft", "["], - ["BracketRight", "]"], - ["Comma", ","], - ["Equal", "="], - ["Minus", "-"], - ["Period", "."], - ["Quote", "'"], - ["Semicolon", ";"], - ["Slash", "/"], -]); - -export function recordShortcutFromEvent( - event: ShortcutKeyboardEvent, -): ShortcutRecordResult { - if (event.key === "Escape" || event.code === "Escape") { - return { status: "cancelled" }; - } - - if (event.repeat || isModifierOnlyEvent(event)) { - return { status: "pending" }; - } - - if (!hasRequiredModifier(event)) { - return { - status: "invalid", - message: "快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键。", - }; - } - - const mainKey = getMainKey(event.code); - if (!mainKey) { - return { - status: "invalid", - message: "不支持这个按键,请选择字母、数字、方向键或功能键。", - }; - } - - return { - status: "recorded", - shortcut: [...getModifierParts(event), mainKey].join("+"), - }; -} - -export function applyShortcutRecord( - draft: ShortcutDraft, - field: ShortcutField, - result: ShortcutRecordResult, -): ShortcutDraft { - if (result.status !== "recorded") { - return draft; - } - - return { - ...draft, - [field]: result.shortcut, - }; -} - -export function clearShortcutField( - draft: ShortcutDraft, - field: ShortcutField, -): ShortcutDraft { - return { - ...draft, - [field]: "", - }; -} - -function hasRequiredModifier(event: ShortcutKeyboardEvent): boolean { - return event.metaKey || event.ctrlKey || event.altKey; -} - -function isModifierOnlyEvent(event: ShortcutKeyboardEvent): boolean { - return MODIFIER_KEYS.has(event.key) || MODIFIER_CODES.has(event.code); -} - -function getModifierParts(event: ShortcutKeyboardEvent): string[] { - const parts: string[] = []; - - if (event.metaKey) { - parts.push("CmdOrCtrl"); - } - - if (event.ctrlKey) { - parts.push("Control"); - } - - if (event.altKey) { - parts.push("Option"); - } - - if (event.shiftKey) { - parts.push("Shift"); - } - - return parts; -} - -function getMainKey(code: string): string | null { - if (/^Key[A-Z]$/.test(code)) { - return code.slice(3); - } - - if (/^Digit[0-9]$/.test(code)) { - return code.slice(5); - } - - if (/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(code)) { - return code; - } - - return NAMED_KEYS.get(code) ?? PUNCTUATION_KEYS.get(code) ?? null; -} diff --git a/src/lib/stores/appearance.svelte.ts b/src/lib/stores/appearance.svelte.ts deleted file mode 100644 index 3805a04..0000000 --- a/src/lib/stores/appearance.svelte.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** 外观状态:面板透明度等。在应用根上设置 CSS 变量,供各面板背景引用。 */ -import { getAppearanceConfig } from "../api/tauri"; - -const ROOT_VAR = "--panel-opacity"; -/** 透明度下限与后端 config.rs PANEL_OPACITY_MIN(0.35) 对齐,避免前后端钳制范围不一致。 */ -const OPACITY_MIN = 0.35; -const OPACITY_MAX = 1; - -class AppearanceStore { - /** 面板透明度 0–1;默认完全不透明。 */ - opacity = $state(1); - loaded = $state(false); - - /** 拉取持久化的外观配置,并应用到 document root。 */ - async load() { - try { - const cfg = await getAppearanceConfig(); - this.set(cfg.panel_opacity); - } catch { - this.set(1); - } - this.loaded = true; - } - - /** 更新透明度并实时应用到 DOM(不落盘,由调用方决定何时保存)。 */ - set(opacity: number) { - const clamped = Math.min(OPACITY_MAX, Math.max(OPACITY_MIN, opacity)); - this.opacity = clamped; - if (typeof document !== "undefined") { - document.documentElement.style.setProperty(ROOT_VAR, String(clamped)); - } - } -} - -export const appearance = new AppearanceStore(); diff --git a/src/lib/stores/chat.svelte.test.ts b/src/lib/stores/chat.svelte.test.ts deleted file mode 100644 index db8c5ef..0000000 --- a/src/lib/stores/chat.svelte.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const api = vi.hoisted(() => ({ - append: vi.fn(), - chat: vi.fn(), - clear: vi.fn(), - load: vi.fn(), - onChunk: vi.fn(), - start: vi.fn(), -})); - -vi.mock("../api/tauri", () => ({ - appendChatHistory: api.append, - chat: api.chat, - clearChatHistory: api.clear, - loadConversations: api.load, - onChatChunk: api.onChunk, - startNewConversation: api.start, -})); - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -describe("chat history persistence", () => { - beforeEach(() => { - vi.resetModules(); - Object.values(api).forEach((mock) => mock.mockReset()); - }); - - it("persists a follow-up against the opened historical conversation", async () => { - const conversations = [ - { - id: 0, - summary: "A", - messages: [ - { role: "user" as const, content: "A1" }, - { role: "assistant" as const, content: "A2" }, - ], - }, - { - id: 1, - summary: "B", - messages: [ - { role: "user" as const, content: "B1" }, - { role: "assistant" as const, content: "B2" }, - ], - }, - ]; - api.load.mockResolvedValue(conversations); - api.chat.mockResolvedValue(undefined); - api.append.mockResolvedValue(undefined); - api.onChunk.mockResolvedValue(() => {}); - - const { chatStore } = await import("./chat.svelte"); - await chatStore.loadHistory(); - chatStore.openConversation(0); - chatStore.state.input = "A3"; - - await chatStore.send(); - const chunkHandler = api.onChunk.mock.calls[0]?.[0] as ( - chunk: { request_id: null; delta: string; done: boolean }, - ) => void; - chunkHandler({ request_id: null, delta: "A4", done: false }); - chunkHandler({ request_id: null, delta: "", done: true }); - - await vi.waitFor(() => { - expect(api.append).toHaveBeenCalledWith( - [ - { role: "user", content: "A3" }, - { role: "assistant", content: "A4" }, - ], - 0, - ); - }); - }); - - it("waits for the new-session marker before sending the first message", async () => { - let resolveStart!: () => void; - const start = new Promise((resolve) => { - resolveStart = resolve; - }); - api.load.mockResolvedValue([]); - api.start.mockReturnValue(start); - api.chat.mockResolvedValue(undefined); - api.onChunk.mockResolvedValue(() => {}); - - const { chatStore } = await import("./chat.svelte"); - await chatStore.loadHistory(); - chatStore.newConversation(); - chatStore.state.input = "first message"; - const sending = chatStore.send(); - - await vi.waitFor(() => expect(api.start).toHaveBeenCalledOnce()); - expect(api.chat).not.toHaveBeenCalled(); - - resolveStart(); - await sending; - - expect(api.chat).toHaveBeenCalledOnce(); - }); - - it("recovers when the chunk listener cannot be registered", async () => { - api.load.mockResolvedValue([]); - api.chat.mockResolvedValue(undefined); - api.onChunk - .mockRejectedValueOnce(new Error("listener unavailable")) - .mockResolvedValueOnce(() => {}); - - const { chatStore } = await import("./chat.svelte"); - await chatStore.loadHistory(); - chatStore.state.input = "first"; - - await expect(chatStore.send()).resolves.toBeUndefined(); - expect(chatStore.state.loading).toBe(false); - expect(chatStore.state.error).toContain("listener unavailable"); - expect(api.chat).not.toHaveBeenCalled(); - - chatStore.state.input = "second"; - await chatStore.send(); - - expect(api.onChunk).toHaveBeenCalledTimes(2); - expect(api.chat).toHaveBeenCalledOnce(); - }); - - it("waits for startup history before sending into the active conversation", async () => { - const history = deferred< - Array<{ - id: number; - summary: string; - messages: Array<{ role: "user" | "assistant"; content: string }>; - }> - >(); - api.load.mockReturnValue(history.promise); - api.chat.mockResolvedValue(undefined); - api.onChunk.mockResolvedValue(() => {}); - - const { chatStore } = await import("./chat.svelte"); - const loading = chatStore.loadHistory(); - chatStore.state.input = "follow up"; - const sending = chatStore.send(); - - await Promise.resolve(); - expect(api.chat).not.toHaveBeenCalled(); - - history.resolve([ - { - id: 4, - summary: "existing", - messages: [ - { role: "user", content: "old question" }, - { role: "assistant", content: "old answer" }, - ], - }, - ]); - await Promise.all([loading, sending]); - - expect(chatStore.state.currentId).toBe(4); - expect(api.chat).toHaveBeenCalledWith([ - { role: "user", content: "old question" }, - { role: "assistant", content: "old answer" }, - { role: "user", content: "follow up" }, - ]); - }); - - it("does not start two chat requests when sends queue behind startup loading", async () => { - const history = deferred<[]>(); - api.load.mockReturnValue(history.promise); - api.chat.mockResolvedValue(undefined); - api.onChunk.mockResolvedValue(() => {}); - - const { chatStore } = await import("./chat.svelte"); - const loading = chatStore.loadHistory(); - const first = chatStore.sendText("first"); - const second = chatStore.sendText("second"); - - history.resolve([]); - await Promise.all([loading, first, second]); - - expect(api.chat).toHaveBeenCalledOnce(); - expect(chatStore.state.input).toBe("second"); - expect(chatStore.state.error).toContain("仍在生成"); - }); - - it("does not start two chat requests behind a new-session marker", async () => { - const marker = deferred(); - api.load.mockResolvedValue([]); - api.start.mockReturnValue(marker.promise); - api.chat.mockResolvedValue(undefined); - api.onChunk.mockResolvedValue(() => {}); - - const { chatStore } = await import("./chat.svelte"); - await chatStore.loadHistory(); - chatStore.newConversation(); - const first = chatStore.sendText("first"); - const second = chatStore.sendText("second"); - - marker.resolve(); - await Promise.all([first, second]); - - expect(api.chat).toHaveBeenCalledOnce(); - expect(chatStore.state.input).toBe("second"); - }); - - it("serializes clear behind startup loading", async () => { - const history = deferred< - Array<{ - id: number; - summary: string; - messages: Array<{ role: "user" | "assistant"; content: string }>; - }> - >(); - api.load.mockReturnValue(history.promise); - api.clear.mockResolvedValue(undefined); - - const { chatStore } = await import("./chat.svelte"); - const loading = chatStore.loadHistory(); - const clearing = chatStore.clearHistory(); - - await Promise.resolve(); - expect(api.clear).not.toHaveBeenCalled(); - - history.resolve([ - { - id: 2, - summary: "existing", - messages: [{ role: "user", content: "old" }], - }, - ]); - await Promise.all([loading, clearing]); - - expect(api.clear).toHaveBeenCalledOnce(); - expect(chatStore.state.conversations).toEqual([]); - expect(chatStore.state.messages).toEqual([]); - }); - - it("preserves the visible history when backend clearing fails", async () => { - const conversations = [ - { - id: 3, - summary: "keep me", - messages: [ - { role: "user" as const, content: "question" }, - { role: "assistant" as const, content: "answer" }, - ], - }, - ]; - api.load.mockResolvedValue(conversations); - api.clear.mockRejectedValue(new Error("disk denied")); - - const { chatStore } = await import("./chat.svelte"); - await chatStore.loadHistory(); - await chatStore.clearHistory(); - await vi.waitFor(() => expect(chatStore.state.error).toContain("disk denied")); - - expect(chatStore.state.conversations).toEqual(conversations); - expect(chatStore.state.messages).toEqual(conversations[0].messages); - expect(chatStore.state.currentId).toBe(3); - }); - - it("retries history loading after a transient failure", async () => { - api.load - .mockRejectedValueOnce(new Error("temporary read failure")) - .mockResolvedValueOnce([ - { - id: 8, - summary: "recovered", - messages: [{ role: "user", content: "restored" }], - }, - ]); - - const { chatStore } = await import("./chat.svelte"); - await chatStore.loadHistory(); - expect(chatStore.state.loaded).toBe(false); - - await chatStore.loadHistory(); - - expect(api.load).toHaveBeenCalledTimes(2); - expect(chatStore.state.loaded).toBe(true); - expect(chatStore.state.currentId).toBe(8); - }); - - it("keeps blocking sends after a new-session marker fails", async () => { - api.load.mockResolvedValue([]); - api.start.mockRejectedValue(new Error("marker write failed")); - api.chat.mockResolvedValue(undefined); - api.onChunk.mockResolvedValue(() => {}); - - const { chatStore } = await import("./chat.svelte"); - await chatStore.loadHistory(); - chatStore.newConversation(); - - await chatStore.sendText("first"); - await chatStore.sendText("second"); - - expect(api.chat).not.toHaveBeenCalled(); - expect(chatStore.state.input).toBe("second"); - expect(chatStore.state.error).toContain("重新"); - }); - - it("does not clear history while a send is waiting for the session marker", async () => { - const marker = deferred(); - api.load.mockResolvedValue([]); - api.start.mockReturnValue(marker.promise); - api.clear.mockResolvedValue(undefined); - api.chat.mockResolvedValue(undefined); - api.onChunk.mockResolvedValue(() => {}); - - const { chatStore } = await import("./chat.svelte"); - await chatStore.loadHistory(); - chatStore.newConversation(); - const sending = chatStore.sendText("pending"); - - await chatStore.clearHistory(); - - expect(api.clear).not.toHaveBeenCalled(); - expect(chatStore.state.error).toContain("正在"); - - marker.resolve(); - await sending; - }); -}); diff --git a/src/lib/stores/chat.svelte.ts b/src/lib/stores/chat.svelte.ts deleted file mode 100644 index 0b388a0..0000000 --- a/src/lib/stores/chat.svelte.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** AI 对话状态与编排(Svelte 5 runes)。 */ -import type { UnlistenFn } from "@tauri-apps/api/event"; -import { - appendChatHistory as apiAppendHistory, - chat as apiChat, - clearChatHistory as apiClearHistory, - loadConversations as apiLoadConversations, - onChatChunk, - startNewConversation as apiStartNewConversation, - type ChatMessage, - type Conversation, -} from "../api/tauri"; -import { recentChatContext } from "./chatContext"; - -const SEND_BUSY_ERROR = - "上一条消息正在准备或 AI 回复仍在生成,已将内容放入输入框。"; -const SEND_PREPARING_ERROR = "消息正在准备发送,请稍候。"; - -interface ChatState { - messages: ChatMessage[]; - input: string; - loading: boolean; - error: string | null; - /** 全部历史对话(最久未活动 → 最近活动),供顶栏对话列表使用。 */ - conversations: Conversation[]; - /** 当前对话的 id;null 表示一段尚未落盘的新对话。 */ - currentId: number | null; - /** 是否已从磁盘加载历史;避免空闪与重复覆盖。 */ - loaded: boolean; -} - -function createChatStore() { - const state = $state({ - messages: [], - input: "", - loading: false, - error: null, - conversations: [], - currentId: null, - loaded: false, - }); - - let unlisten: UnlistenFn | null = null; - /** 标记用户已显式开新对话/切换对话:阻止 loadHistory 的自动恢复覆盖。 */ - let userStartedSession = false; - /** 进行中的加载任务:避免并发调用重复请求并互相覆盖。 */ - let loadPromise: Promise | null = null; - /** 最近一次历史加载错误;成功重试时仅清除这条错误。 */ - let loadError: string | null = null; - /** 新对话分隔标记的落盘任务;发送前必须等待它完成。 */ - let sessionStartPromise: Promise | null = null; - /** 新对话分隔标记失败后持续阻断发送,直到显式恢复到有效会话。 */ - let sessionStartError: string | null = null; - /** 清空历史任务;发送/切换必须等待,避免与读取或追加并发。 */ - let clearPromise: Promise | null = null; - /** 发送正在等待历史加载或新会话标记;期间禁止改变会话边界。 */ - let sendPreparing = false; - - /** 启动时加载全部历史对话,并默认显示最新的一段(恢复上次离开的位置)。 */ - function loadHistory(): Promise { - if (state.loaded || loadPromise) return loadPromise ?? Promise.resolve(); - loadPromise = doLoadHistory(); - return loadPromise; - } - - async function doLoadHistory() { - try { - const conversations = await apiLoadConversations(); - state.conversations = conversations; - // 仅在用户尚未介入(开新对话/切换对话)时,自动恢复最新一段。 - if (!userStartedSession) { - const latest = conversations[conversations.length - 1]; - if (latest) { - state.messages = [...latest.messages]; - state.currentId = latest.id; - } else { - state.currentId = null; - } - } - state.loaded = true; - if (loadError && state.error === loadError) { - state.error = null; - } - loadError = null; - } catch (e) { - state.loaded = false; - loadError = typeof e === "string" ? e : String(e); - state.error = loadError; - } finally { - loadPromise = null; - } - } - - async function ensureChunkListener() { - if (unlisten) return; - - unlisten = await onChatChunk((chunk) => { - const lastIndex = state.messages.length - 1; - const last = state.messages[lastIndex]; - - if (last && last.role === "assistant") { - state.messages = state.messages.map((message, index) => - index === lastIndex - ? { ...message, content: message.content + chunk.delta } - : message, - ); - } - - if (chunk.done) { - // 一轮结束:把这轮(用户消息 + 完整 AI 回复)持久化。 - void persistLastRound().finally(() => { - state.loading = false; - }); - } - }); - } - - /** 把最后一轮对话追加写入历史文件;AI 空回复不写入,避免污染历史。 - * 落盘后以后端为唯一事实来源重读对话列表,避免本地 id 猜测与后端序号错位。 */ - async function persistLastRound() { - const msgs = state.messages; - if (msgs.length < 2) return; - const user = msgs[msgs.length - 2]; - const assistant = msgs[msgs.length - 1]; - if (user.role !== "user" || assistant.role !== "assistant") return; - if (assistant.content.trim() === "") return; - - const conversationId = state.currentId; - - try { - await apiAppendHistory( - [ - { role: "user", content: user.content }, - { role: "assistant", content: assistant.content }, - ], - conversationId, - ); - const conversations = await apiLoadConversations(); - state.conversations = conversations; - if (conversationId === null) { - const latest = conversations[conversations.length - 1]; - state.currentId = latest?.id ?? null; - } - } catch (e) { - state.error = `保存对话历史失败: ${typeof e === "string" ? e : String(e)}`; - } - } - - async function send() { - const text = state.input.trim(); - if (!text) return; - if (state.loading || sendPreparing) { - preserveBusyInput(text); - return; - } - - state.input = ""; - await sendMessage(text); - } - - async function sendText(value: string) { - const text = value.trim(); - if (!text) return; - - if (state.loading || sendPreparing) { - preserveBusyInput(text); - return; - } - - await sendMessage(text); - } - - function preserveBusyInput(text: string) { - state.input = text; - state.error = SEND_BUSY_ERROR; - } - - function preserveSessionBlockedInput(text: string) { - state.input = text; - state.error = - sessionStartError ?? - "新对话尚未成功创建,请重新开启新对话、选择已有对话或清空历史后再发送。"; - } - - function clearSessionStartBlock() { - sessionStartPromise = null; - sessionStartError = null; - } - - async function sendMessage(text: string) { - if (state.loading || sendPreparing) { - preserveBusyInput(text); - return; - } - - sendPreparing = true; - try { - if (clearPromise) { - await clearPromise; - } - await loadHistory(); - if (!state.loaded) { - state.input = text; - return; - } - if (clearPromise) { - await clearPromise; - } - if (state.loading) { - preserveBusyInput(text); - return; - } - - if (sessionStartError) { - preserveSessionBlockedInput(text); - return; - } - if (sessionStartPromise) { - const started = await sessionStartPromise; - if (!started || sessionStartError) { - preserveSessionBlockedInput(text); - return; - } - } - if (state.loading) { - preserveBusyInput(text); - return; - } - - const keepBusyNotice = - state.error === SEND_BUSY_ERROR && - state.input.trim() !== "" && - state.input.trim() !== text; - if (!keepBusyNotice) { - state.error = null; - } - state.messages = [ - ...state.messages, - { role: "user", content: text }, - { role: "assistant", content: "" }, - ]; - state.loading = true; - } finally { - sendPreparing = false; - } - - try { - await ensureChunkListener(); - await apiChat(recentChatContext(state.messages.slice(0, -1))); - } catch (e) { - state.error = typeof e === "string" ? e : String(e); - state.loading = false; - // 失败/中断:移除空的 assistant 占位,不写入历史 - const last = state.messages[state.messages.length - 1]; - if (last && last.role === "assistant" && last.content === "") { - state.messages = state.messages.slice(0, -1); - } - } - } - - function clearHistory(): Promise { - if (clearPromise) return clearPromise; - if (sendPreparing) { - state.error = SEND_PREPARING_ERROR; - return Promise.resolve(); - } - if (state.loading) { - state.error = "AI 回复仍在生成,请等待完成后再清空历史。"; - return Promise.resolve(); - } - - const clearing = clearHistoryAfterLoad().finally(() => { - if (clearPromise === clearing) { - clearPromise = null; - } - }); - clearPromise = clearing; - return clearing; - } - - async function clearHistoryAfterLoad() { - await loadHistory(); - if (state.loading) { - state.error = "AI 回复仍在生成,请等待完成后再清空历史。"; - return; - } - - try { - await apiClearHistory(); - userStartedSession = true; - state.messages = []; - state.conversations = []; - state.currentId = null; - state.loaded = true; - loadError = null; - clearSessionStartBlock(); - state.error = null; - } catch (e) { - state.error = `清空对话历史失败: ${typeof e === "string" ? e : String(e)}`; - } - } - - /** 开启一段新对话:写分隔标记 + 清空当前消息视图(历史保留在列表中)。 */ - function newConversation() { - if (sendPreparing) { - state.error = SEND_PREPARING_ERROR; - return; - } - if (state.loading) { - state.error = "AI 回复仍在生成,请等待完成后再开新对话。"; - return; - } - if (clearPromise) { - state.error = "正在清空对话历史,请稍候。"; - return; - } - userStartedSession = true; - state.messages = []; - state.input = ""; - state.error = null; - state.currentId = null; - sessionStartError = null; - const start = apiStartNewConversation() - .then(() => { - if (sessionStartPromise === start) { - sessionStartError = null; - } - return true; - }) - .catch((e) => { - if (sessionStartPromise === start) { - sessionStartError = `开启新对话失败: ${ - typeof e === "string" ? e : String(e) - }。请重新开启新对话、选择已有对话或清空历史后再发送。`; - state.error = sessionStartError; - } - return false; - }) - .finally(() => { - if (sessionStartPromise === start) { - sessionStartPromise = null; - } - }); - sessionStartPromise = start; - } - - /** 打开指定历史对话,加载其全部消息到当前视图。 */ - function openConversation(id: number) { - if (sendPreparing) { - state.error = SEND_PREPARING_ERROR; - return; - } - if (state.loading) { - state.error = "AI 回复仍在生成,无法切换对话。"; - return; - } - if (clearPromise) { - state.error = "正在清空对话历史,请稍候。"; - return; - } - const target = state.conversations.find((c) => c.id === id); - if (!target) return; - userStartedSession = true; - state.messages = [...target.messages]; - state.currentId = id; - state.input = ""; - clearSessionStartBlock(); - state.error = null; - } - - function reset() { - // 呼出快捷键使用:开新对话,与 UI「新对话」按钮一致。 - newConversation(); - } - - return { - get state() { - return state; - }, - loadHistory, - send, - sendText, - clearHistory, - newConversation, - openConversation, - reset, - }; -} - -export const chatStore = createChatStore(); diff --git a/src/lib/stores/chatContext.test.ts b/src/lib/stores/chatContext.test.ts deleted file mode 100644 index b38614b..0000000 --- a/src/lib/stores/chatContext.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { ChatMessage } from "../api/tauri"; -import { CHAT_CONTEXT_LIMIT, recentChatContext } from "./chatContext"; - -function message(index: number): ChatMessage { - return { - role: index % 2 === 0 ? "user" : "assistant", - content: `message-${index}`, - }; -} - -describe("recentChatContext", () => { - test("returns all messages when under the context limit", () => { - const messages = Array.from({ length: 4 }, (_, index) => message(index)); - - const context = recentChatContext(messages); - - expect(context).toEqual(messages); - }); - - test("keeps only the latest messages when history exceeds the context limit", () => { - const messages = Array.from({ length: CHAT_CONTEXT_LIMIT + 6 }, (_, index) => - message(index), - ); - - const context = recentChatContext(messages); - - expect(context).toHaveLength(CHAT_CONTEXT_LIMIT); - expect(context[0].content).toBe("message-6"); - expect(context.at(-1)?.content).toBe(`message-${CHAT_CONTEXT_LIMIT + 5}`); - }); - - test("drops a leading orphan assistant message after trimming", () => { - const messages = Array.from({ length: CHAT_CONTEXT_LIMIT + 5 }, (_, index) => - message(index), - ); - - const context = recentChatContext(messages); - - expect(context[0].role).toBe("user"); - expect(context[0].content).toBe("message-6"); - expect(context.at(-1)?.content).toBe(`message-${CHAT_CONTEXT_LIMIT + 4}`); - }); - - test("returns empty array when the only surviving message is an orphan assistant", () => { - // 切窗后首条恰好是 assistant(异常但需容错):应被丢弃,结果为空数组。 - const messages: ChatMessage[] = [ - { role: "assistant", content: "orphan" }, - { role: "assistant", content: "orphan-2" }, - ]; - - const context = recentChatContext(messages); - - expect(context).toEqual([]); - }); -}); diff --git a/src/lib/stores/chatContext.ts b/src/lib/stores/chatContext.ts deleted file mode 100644 index 0514760..0000000 --- a/src/lib/stores/chatContext.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ChatMessage } from "../api/tauri"; - -export const CHAT_CONTEXT_LIMIT = 24; - -export function recentChatContext(messages: ChatMessage[]): ChatMessage[] { - const context = messages.slice(-CHAT_CONTEXT_LIMIT); - - // 丢弃切窗后开头所有「孤儿」assistant 消息:把 AI 回复作为上下文首条无意义, - // 且会让上游认为对话由 assistant 开场。找到首条非 assistant,从其开始截取。 - const firstUser = context.findIndex((m) => m.role !== "assistant"); - if (firstUser <= 0) { - // 0:首条已是非 assistant,原样返回;-1:全部是 assistant,丢弃为空数组。 - return firstUser === 0 ? context : []; - } - return context.slice(firstUser); -} diff --git a/src/lib/stores/languages.test.ts b/src/lib/stores/languages.test.ts deleted file mode 100644 index 8272a38..0000000 --- a/src/lib/stores/languages.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { resolveAdaptiveTarget } from "./languages"; - -describe("resolveAdaptiveTarget", () => { - test("returns English target when input contains Chinese", () => { - expect(resolveAdaptiveTarget("你好,世界")).toBe("en"); - expect(resolveAdaptiveTarget("hello 世界")).toBe("en"); - }); - - test("returns Simplified Chinese target for English input", () => { - expect(resolveAdaptiveTarget("hello world")).toBe("zh-CN"); - expect(resolveAdaptiveTarget("Translate this sentence, please.")).toBe("zh-CN"); - }); - - test("returns Simplified Chinese target when no Chinese is detected", () => { - expect(resolveAdaptiveTarget("12345!?")).toBe("zh-CN"); - }); -}); diff --git a/src/lib/stores/languages.ts b/src/lib/stores/languages.ts deleted file mode 100644 index 67e2067..0000000 --- a/src/lib/stores/languages.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** 支持的语言列表。 */ -export interface Lang { - code: string; - name: string; -} - -/** 自适应目标:根据输入在中/英之间自动切换。 */ -export const ADAPTIVE_TARGET = "adaptive"; - -export const LANGUAGES: Lang[] = [ - { code: "auto", name: "自动检测" }, - { code: "zh-CN", name: "中文(简体)" }, - { code: "zh-TW", name: "中文(繁体)" }, - { code: "en", name: "英语" }, - { code: "ja", name: "日语" }, - { code: "ko", name: "韩语" }, - { code: "fr", name: "法语" }, - { code: "de", name: "德语" }, - { code: "es", name: "西班牙语" }, - { code: "ru", name: "俄语" }, -]; - -/** 目标语言:首项为自适应,其余不含 auto。 */ -export const TARGET_LANGUAGES: Lang[] = [ - { code: ADAPTIVE_TARGET, name: "自适应 中⇄英" }, - ...LANGUAGES.filter((l) => l.code !== "auto"), -]; - -export function langName(code: string): string { - if (code === ADAPTIVE_TARGET) return "自适应 中⇄英"; - return LANGUAGES.find((l) => l.code === code)?.name ?? code; -} - -/** - * 自适应目标策略:主语言为中/英。 - * 输入含中日韩表意文字(以中文为主)时翻译为英文,否则翻译为中文。 - */ -export function resolveAdaptiveTarget(text: string): "en" | "zh-CN" { - return containsChinese(text) ? "en" : "zh-CN"; -} - -/** 文本是否包含中文字符(CJK 统一表意文字基本区)。 */ -function containsChinese(text: string): boolean { - return /[一-鿿]/.test(text); -} diff --git a/src/lib/stores/shortcuts.ts b/src/lib/stores/shortcuts.ts deleted file mode 100644 index dd2f000..0000000 --- a/src/lib/stores/shortcuts.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** 快捷键展示标签(与后端 shortcut.rs 默认值保持一致)。 */ -export const shortcut = { - toggle: "⌘⇧Space", - ocr: "⌘⇧S", -} as const; diff --git a/src/lib/stores/translation.svelte.test.ts b/src/lib/stores/translation.svelte.test.ts deleted file mode 100644 index 9e133e9..0000000 --- a/src/lib/stores/translation.svelte.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { createTranslationStore } from "./translation.svelte"; - -interface Deferred { - promise: Promise; - resolve: (value: T) => void; - reject: (reason?: unknown) => void; -} - -function deferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function fakeDependencies() { - type TestChunk = { - request_id: number | null; - delta: string; - done: boolean; - }; - let chunkHandler: ((chunk: TestChunk) => void) | null = null; - return { - translate: vi.fn(), - screenshotOcr: vi.fn(async () => ""), - showMain: vi.fn(async () => {}), - onChunk: vi.fn(async (handler: (chunk: TestChunk) => void) => { - chunkHandler = handler; - return vi.fn(); - }), - emitChunk(chunk: TestChunk) { - chunkHandler?.(chunk); - }, - }; -} - -describe("translation store coordination", () => { - test("requests automatic free translation alongside AI", async () => { - const deps = fakeDependencies(); - deps.translate.mockImplementation((req: { engine: string }) => - Promise.resolve(req.engine === "auto" ? "automatic result" : ""), - ); - const store = createTranslationStore(deps); - await store.translateText("hello"); - expect(deps.translate).toHaveBeenCalledWith( - expect.objectContaining({ engine: "auto", text: "hello" }), - ); - expect(deps.translate).toHaveBeenCalledWith( - expect.objectContaining({ engine: "openai", text: "hello" }), - ); - expect(store.state.webOutput).toBe("automatic result"); - }); - - test("ignores repeated OCR triggers while capture is pending", async () => { - const deps = fakeDependencies(); - const capture = deferred(); - deps.screenshotOcr.mockReturnValue(capture.promise); - const store = createTranslationStore(deps); - const first = store.captureAndTranslate(); - await store.captureAndTranslate(); - expect(deps.screenshotOcr).toHaveBeenCalledOnce(); - capture.resolve(""); - await first; - await store.captureAndTranslate(); - expect(deps.screenshotOcr).toHaveBeenCalledTimes(2); - expect(deps.showMain).not.toHaveBeenCalled(); - }); - - test("OCR failure is visible and a later capture can retry", async () => { - const deps = fakeDependencies(); - deps.screenshotOcr.mockRejectedValueOnce("OCR failed").mockResolvedValue(""); - const store = createTranslationStore(deps); - await store.captureAndTranslate(); - expect(store.state.error).toBe("OCR failed"); - expect(deps.showMain).toHaveBeenCalledOnce(); - await store.captureAndTranslate(); - expect(deps.screenshotOcr).toHaveBeenCalledTimes(2); - expect(store.state.error).toBeNull(); - }); - - test("queues the latest selected text while a translation is running", async () => { - const deps = fakeDependencies(); - const pending = new Map>(); - - deps.translate.mockImplementation((req: { text: string; engine: string }) => { - const key = `${req.text}:${req.engine}`; - const task = deferred(); - pending.set(key, task); - return task.promise; - }); - - const store = createTranslationStore(deps); - const firstRun = store.translateText("first"); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2)); - - await store.translateText("second"); - expect(store.state.input).toBe("second"); - expect(store.state.error).toContain("排队"); - - pending.get("first:auto")?.resolve("first web"); - pending.get("first:openai")?.resolve(""); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(4)); - - pending.get("second:auto")?.resolve("second web"); - pending.get("second:openai")?.resolve(""); - await firstRun; - - expect(store.state.input).toBe("second"); - expect(store.state.webOutput).toBe("second web"); - expect(store.state.error).toBeNull(); - }); - - test("manual input replaces an older queued translation", async () => { - const deps = fakeDependencies(); - const pending = new Map>(); - - deps.translate.mockImplementation((req: { text: string; engine: string }) => { - const key = `${req.text}:${req.engine}`; - const task = deferred(); - pending.set(key, task); - return task.promise; - }); - - const store = createTranslationStore(deps); - const firstRun = store.translateText("first"); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2)); - - await store.translateText("second"); - store.setInput("third"); - - pending.get("first:auto")?.resolve("first web"); - pending.get("first:openai")?.resolve(""); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(4)); - - expect(deps.translate).toHaveBeenCalledWith( - expect.objectContaining({ text: "third", engine: "auto" }), - ); - expect(deps.translate).not.toHaveBeenCalledWith( - expect.objectContaining({ text: "second", engine: "auto" }), - ); - - pending.get("third:auto")?.resolve("third web"); - pending.get("third:openai")?.resolve(""); - await firstRun; - }); - - test("captures OCR during an active translation and queues its text", async () => { - const deps = fakeDependencies(); - const firstWeb = deferred(); - const firstAi = deferred(); - - deps.translate.mockImplementation((req: { text: string; engine: string }) => { - if (req.text === "first" && req.engine === "auto") return firstWeb.promise; - if (req.text === "first" && req.engine === "openai") return firstAi.promise; - return Promise.resolve(req.engine === "openai" ? "" : "OCR web"); - }); - deps.screenshotOcr.mockResolvedValue("captured text"); - - const store = createTranslationStore(deps); - const firstRun = store.translateText("first"); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2)); - - await store.captureAndTranslate(); - - expect(deps.screenshotOcr).toHaveBeenCalledOnce(); - expect(deps.showMain).toHaveBeenCalledOnce(); - expect(store.state.input).toBe("captured text"); - expect(store.state.error).toContain("排队"); - - firstWeb.resolve("first web"); - firstAi.resolve(""); - await firstRun; - }); - - test("listener initialization failure clears loading and can be retried", async () => { - const deps = fakeDependencies(); - deps.onChunk.mockRejectedValueOnce(new Error("listener unavailable")); - deps.translate.mockResolvedValue(""); - - const store = createTranslationStore(deps); - await store.translateText("first"); - - expect(store.state.loading).toBe(false); - expect(store.state.aiLoading).toBe(false); - expect(store.state.aiError).toContain("listener unavailable"); - - await store.translateText("second"); - - expect(deps.onChunk).toHaveBeenCalledTimes(2); - expect(store.state.loading).toBe(false); - }); - - test("an older invoke completion cannot clear a newer AI loading state", async () => { - const deps = fakeDependencies(); - const firstAi = deferred(); - const secondAi = deferred(); - - deps.translate.mockImplementation((req: { text: string; engine: string }) => { - if (req.engine !== "openai") return Promise.resolve(`${req.text} web`); - return req.text === "first" ? firstAi.promise : secondAi.promise; - }); - - const store = createTranslationStore(deps); - const firstRun = store.translateText("first"); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2)); - deps.emitChunk({ request_id: 1, delta: "", done: true }); - await vi.waitFor(() => expect(store.state.loading).toBe(false)); - - const secondRun = store.translateText("second"); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(4)); - expect(store.state.aiLoading).toBe(true); - - firstAi.resolve(""); - await firstRun; - - expect(store.state.aiLoading).toBe(true); - - secondAi.resolve(""); - await secondRun; - }); - - test("ignores stream chunks from a superseded AI request", async () => { - const deps = fakeDependencies(); - const aiTasks = new Map>(); - - deps.translate.mockImplementation( - (req: { text: string; engine: string; request_id?: number }) => { - if (req.engine !== "openai") return Promise.resolve(`${req.text} web`); - const task = deferred(); - aiTasks.set(req.request_id ?? -1, task); - return task.promise; - }, - ); - - const store = createTranslationStore(deps); - const firstRun = store.translateText("first"); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2)); - const firstRequest = deps.translate.mock.calls.find( - ([req]) => req.engine === "openai" && req.text === "first", - )?.[0] as { request_id: number }; - - store.reset(); - const secondRun = store.translateText("second"); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(4)); - const secondRequest = deps.translate.mock.calls.find( - ([req]) => req.engine === "openai" && req.text === "second", - )?.[0] as { request_id: number }; - - expect(firstRequest.request_id).not.toBe(secondRequest.request_id); - deps.emitChunk({ - request_id: firstRequest.request_id, - delta: "stale", - done: false, - }); - expect(store.state.aiOutput).toBe(""); - - deps.emitChunk({ - request_id: secondRequest.request_id, - delta: "fresh", - done: false, - }); - expect(store.state.aiOutput).toBe("fresh"); - - deps.emitChunk({ - request_id: secondRequest.request_id, - delta: "", - done: true, - }); - aiTasks.get(firstRequest.request_id)?.resolve(""); - aiTasks.get(secondRequest.request_id)?.resolve(""); - await Promise.all([firstRun, secondRun]); - }); - - test("clears a queued replacement when input returns to the active text", async () => { - const deps = fakeDependencies(); - const firstWeb = deferred(); - const firstAi = deferred(); - - deps.translate.mockImplementation((req: { text: string; engine: string }) => { - if (req.text !== "first") return Promise.resolve("unexpected"); - return req.engine === "openai" ? firstAi.promise : firstWeb.promise; - }); - - const store = createTranslationStore(deps); - const running = store.translateText("first"); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2)); - - await store.translateText("second"); - store.setInput("first"); - firstWeb.resolve("first web"); - firstAi.resolve(""); - await running; - - expect(deps.translate).toHaveBeenCalledTimes(2); - expect(store.state.input).toBe("first"); - expect(store.state.webOutput).toBe("first web"); - }); - - test("restarts both translations when the target language changes", async () => { - const deps = fakeDependencies(); - const tasks = new Map>(); - deps.translate.mockImplementation( - (req: { text: string; target: string; engine: string }) => { - const task = deferred(); - tasks.set(`${req.target}:${req.engine}`, task); - return task.promise; - }, - ); - - const store = createTranslationStore(deps); - const first = store.translateText("hello"); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(2)); - - const switched = store.setTarget("ja"); - await vi.waitFor(() => expect(deps.translate).toHaveBeenCalledTimes(4)); - - tasks.get("zh-CN:auto")?.resolve("stale web"); - tasks.get("zh-CN:openai")?.resolve(""); - tasks.get("ja:auto")?.resolve("fresh web"); - tasks.get("ja:openai")?.resolve(""); - await Promise.all([first, switched]); - - expect(store.state.target).toBe("ja"); - expect(store.state.webOutput).toBe("fresh web"); - }); -}); diff --git a/src/lib/stores/translation.svelte.ts b/src/lib/stores/translation.svelte.ts deleted file mode 100644 index b419856..0000000 --- a/src/lib/stores/translation.svelte.ts +++ /dev/null @@ -1,365 +0,0 @@ -/** 翻译状态与编排(Svelte 5 runes)。 */ -import { - translate as apiTranslate, - screenshotOcr, - showMain, - onChunk, - type TranslateChunk, - type TranslateRequest, -} from "../api/tauri"; -import { ADAPTIVE_TARGET, resolveAdaptiveTarget } from "./languages"; -import type { UnlistenFn } from "@tauri-apps/api/event"; - -const QUEUE_NOTICE = "上一条翻译仍在生成,已排队最新内容。"; - -interface TranslationState { - input: string; - webOutput: string; - aiOutput: string; - source: string; - target: string; - webLoading: boolean; - aiLoading: boolean; - webError: string | null; - aiError: string | null; - loading: boolean; - error: string | null; -} - -export interface TranslationDependencies { - translate: (req: TranslateRequest) => Promise; - screenshotOcr: () => Promise; - showMain: () => Promise; - onChunk: ( - handler: (chunk: TranslateChunk) => void, - ) => Promise; -} - -const defaultDependencies: TranslationDependencies = { - translate: apiTranslate, - screenshotOcr, - showMain, - onChunk, -}; - -export function createTranslationStore( - deps: TranslationDependencies = defaultDependencies, -) { - const state = $state({ - input: "", - webOutput: "", - aiOutput: "", - source: "auto", - target: ADAPTIVE_TARGET, - webLoading: false, - aiLoading: false, - webError: null, - aiError: null, - loading: false, - error: null, - }); - - let unlisten: UnlistenFn | null = null; - let listenerPromise: Promise | null = null; - let lastRunText = ""; - let activeText = ""; - let queuedText: string | null = null; - let drainingQueue = false; - let drainGeneration = 0; - let runGeneration = 0; - let webRequestId = 0; - let aiRequestId = 0; - let capturing = false; - - function refreshLoading() { - state.loading = state.webLoading || state.aiLoading; - } - - async function ensureChunkListener() { - if (unlisten) return; - if (!listenerPromise) { - listenerPromise = deps - .onChunk((chunk) => { - if (!state.aiLoading || chunk.request_id !== aiRequestId) return; - state.aiOutput += chunk.delta; - if (chunk.done) { - state.aiLoading = false; - refreshLoading(); - } - }) - .then((stop) => { - unlisten = stop; - }) - .finally(() => { - listenerPromise = null; - }); - } - await listenerPromise; - } - - async function run() { - const text = state.input.trim(); - if (!text) return; - if (state.loading) { - if (text !== activeText) { - queueText(text); - } - return; - } - - await runText(text); - } - - /** 计算实际请求用的源/目标语言:自适应目标按输入在中/英间切换。 */ - function resolveLangPair(text: string): { source: string; target: string } { - if (state.target === ADAPTIVE_TARGET) { - return { source: "auto", target: resolveAdaptiveTarget(text) }; - } - return { source: state.source, target: state.target }; - } - - function toError(e: unknown): string { - return typeof e === "string" ? e : String(e); - } - - function queueText(text: string) { - queuedText = text; - state.input = text; - state.error = QUEUE_NOTICE; - } - - function clearQueuedText() { - queuedText = null; - if (state.error === QUEUE_NOTICE) state.error = null; - } - - async function runWeb( - text: string, - langPair: { source: string; target: string }, - ) { - const requestId = ++webRequestId; - state.webError = null; - state.webOutput = ""; - state.webLoading = true; - refreshLoading(); - - try { - const result = await deps.translate({ - request_id: requestId, - text, - ...langPair, - engine: "auto", - }); - if (requestId === webRequestId) { - state.webOutput = result; - } - } catch (e) { - if (requestId === webRequestId) { - state.webError = toError(e); - } - } finally { - if (requestId === webRequestId) { - state.webLoading = false; - refreshLoading(); - } - } - } - - async function runAi( - text: string, - langPair: { source: string; target: string }, - ) { - const requestId = ++aiRequestId; - state.aiError = null; - state.aiOutput = ""; - state.aiLoading = true; - refreshLoading(); - - try { - await ensureChunkListener(); - await deps.translate({ - request_id: requestId, - text, - ...langPair, - engine: "openai", - }); - } catch (e) { - if (requestId === aiRequestId) { - state.aiError = toError(e); - } - } finally { - if (requestId === aiRequestId) { - state.aiLoading = false; - refreshLoading(); - } - } - } - - async function runText(text: string) { - const generation = ++runGeneration; - lastRunText = text; - activeText = text; - state.error = null; - state.webError = null; - state.aiError = null; - state.webOutput = ""; - state.aiOutput = ""; - const langPair = resolveLangPair(text); - const webTask = runWeb(text, langPair); - const aiTask = runAi(text, langPair); - - await Promise.allSettled([webTask, aiTask]); - if (generation !== runGeneration) return; - refreshLoading(); - if (activeText === text) { - activeText = ""; - } - await drainQueuedText(); - } - - async function drainQueuedText() { - if (drainingQueue || state.loading || !queuedText) return; - - const generation = ++drainGeneration; - const text = queuedText; - queuedText = null; - drainingQueue = true; - try { - state.input = text; - await runText(text); - } finally { - if (generation === drainGeneration) { - drainingQueue = false; - } - } - - if (generation !== drainGeneration) return; - if (queuedText && !state.loading) { - await drainQueuedText(); - } - } - - async function translateText(value: string) { - const text = value.trim(); - if (!text) return; - state.input = text; - if (state.loading) { - queueText(text); - return; - } - await runText(text); - } - - async function restartForLanguageChange() { - const text = state.input.trim(); - clearQueuedText(); - drainGeneration += 1; - drainingQueue = false; - - if (text) { - await runText(text); - return; - } - - runGeneration += 1; - webRequestId += 1; - aiRequestId += 1; - activeText = ""; - state.webOutput = ""; - state.aiOutput = ""; - state.webError = null; - state.aiError = null; - state.webLoading = false; - state.aiLoading = false; - refreshLoading(); - } - - async function setSource(source: string) { - if (state.source === source) return; - state.source = source; - await restartForLanguageChange(); - } - - async function setTarget(target: string) { - if (state.target === target) return; - state.target = target; - await restartForLanguageChange(); - } - - function setInput(v: string) { - state.input = v; - if (!state.loading) return; - - const text = v.trim(); - if (!text) { - clearQueuedText(); - return; - } - if (text === activeText) { - clearQueuedText(); - return; - } - queueText(text); - } - - function shouldAutoRun(text: string): boolean { - return Boolean(text.trim()) && !state.loading && text.trim() !== lastRunText; - } - - /** 截图 OCR:Apple 自带自由框选 → 识别 → 填入输入框 → 自动翻译。 */ - async function captureAndTranslate() { - if (capturing) return; - capturing = true; - state.error = null; - try { - let text: string; - try { - text = await deps.screenshotOcr(); - } finally { - capturing = false; - } - if (!text.trim()) return; - await deps.showMain(); // OCR 有结果后再唤起窗口 - await translateText(text); - } catch (e) { - state.error = toError(e); - await deps.showMain(); - } - } - - function reset() { - drainGeneration += 1; - runGeneration += 1; - webRequestId += 1; - aiRequestId += 1; - activeText = ""; - queuedText = null; - drainingQueue = false; - lastRunText = ""; - state.input = ""; - state.webOutput = ""; - state.aiOutput = ""; - state.webError = null; - state.aiError = null; - state.error = null; - state.webLoading = false; - state.aiLoading = false; - state.loading = false; - } - - return { - get state() { - return state; - }, - run, - translateText, - setInput, - setSource, - setTarget, - shouldAutoRun, - captureAndTranslate, - reset, - }; -} - -export const translation = createTranslationStore(); diff --git a/src/lib/stores/update.svelte.ts b/src/lib/stores/update.svelte.ts deleted file mode 100644 index 610067f..0000000 --- a/src/lib/stores/update.svelte.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** 应用内更新状态:检查更新、下载、安装并自动重启。 - * - * 采用 Tauri 官方 Updater 插件:在应用内下载并校验更新包,原地替换 .app, - * 随后自动重启加载新版本——无需重新安装 DMG 或手动拖拽。 - * - * 注意:运行中的二进制无法热替换,新代码必须经重启才能生效; - * 这是 macOS 原生应用的固有约束,重启由本流程自动完成。 - */ -import { getVersion } from "@tauri-apps/api/app"; -import { check, type Update } from "@tauri-apps/plugin-updater"; -import { relaunch } from "@tauri-apps/plugin-process"; -import { Store } from "@tauri-apps/plugin-store"; - -export type UpdateStatus = - | "idle" - | "checking" - | "available" - | "downloading" - | "installing" - | "uptodate" - | "error"; - -/** 自动检查间隔:7 天(毫秒)。 */ -const AUTO_CHECK_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; -/** 持久化上次自动检查时间的 store 文件(与设置共用 settings.json)。 */ -const STORE_FILE = "settings.json"; -const LAST_CHECK_KEY = "update.last_check"; - -class UpdateStore { - /** 当前安装版本号。 */ - currentVersion = $state(""); - /** 待安装的新版本号;无更新时为空串。 */ - newVersion = $state(""); - status = $state("idle"); - /** 下载进度 0–1。 */ - progress = $state(0); - /** 用户可读的状态/错误文案。 */ - message = $state(""); - /** 发布说明(来自 latest.json 的 body 字段)。 */ - releaseNotes = $state(""); - - private pending: Update | null = null; - /** 应用常驻期间的周期性检查句柄(兜底:连续运行超过一周时也能触发)。 */ - private periodicTimer: ReturnType | null = null; - /** 周期检查节流:间隔内最多触发一次实际网络请求。 */ - private static readonly PERIODIC_TICK_MS = 6 * 60 * 60 * 1000; - - /** 拉取当前版本号。 */ - async loadVersion() { - if (this.currentVersion) { - return; - } - try { - this.currentVersion = await getVersion(); - } catch { - this.currentVersion = ""; - } - } - - /** 后台静默检查(仅发现更新,不自动下载)。命中时进入 available 状态, - * 用户进设置页可见并自行决定安装。失败不打扰用户。 */ - async autoCheckIfNeeded() { - this.startPeriodicCheck(); - // 已有可用更新、或正在检查/下载/安装时,不重复检查以免打扰用户。 - if ( - this.status === "available" - || this.status === "checking" - || this.status === "downloading" - || this.status === "installing" - ) { - return; - } - if (!(await this.shouldAutoCheck())) { - return; - } - await this.checkForUpdates(); - // 仅当「无更新」或「出错」时刷新时间戳;发现新版本时保留旧时间戳, - // 避免用户暂不安装、下次启动又因满一周被静默刷掉「已是最新」。 - if (this.status === "uptodate" || this.status === "error") { - await this.markChecked(); - } - } - - /** 启动常驻期间周期性检查(每 6h 探一次是否满一周),重复启动安全。 */ - startPeriodicCheck() { - if (this.periodicTimer) { - return; - } - this.periodicTimer = setInterval(() => { - void this.autoCheckIfNeeded(); - }, UpdateStore.PERIODIC_TICK_MS); - } - - /** 停止周期性检查(应用卸载/销毁时调用,避免泄漏定时器)。 */ - stopPeriodicCheck() { - if (this.periodicTimer) { - clearInterval(this.periodicTimer); - this.periodicTimer = null; - } - } - - /** 距上次检查是否已满一周(或从未检查过)。 */ - private async shouldAutoCheck(): Promise { - try { - const store = await Store.load(STORE_FILE); - const last = await store.get(LAST_CHECK_KEY); - if (!last) { - return true; - } - return Date.now() - last >= AUTO_CHECK_INTERVAL_MS; - } catch { - return false; - } - } - - /** 记录本次检查时间为 Unix 毫秒。 */ - private async markChecked() { - try { - const store = await Store.load(STORE_FILE); - await store.set(LAST_CHECK_KEY, Date.now()); - await store.save(); - } catch { - // 持久化失败不影响本次检查结果。 - } - } - - /** 检查更新;命中则记录待安装版本并返回 true。 */ - async checkForUpdates(): Promise { - // 重入守卫:已在检查/下载/安装,或已发现待安装版本时,复用当前状态, - // 不重复发起请求,避免覆盖 pending 与 progress 造成状态抖动。 - if ( - this.status === "checking" - || this.status === "downloading" - || this.status === "installing" - || this.status === "available" - ) { - return this.status === "available"; - } - this.status = "checking"; - this.message = "正在检查更新…"; - this.progress = 0; - this.newVersion = ""; - this.releaseNotes = ""; - - try { - const update = await check(); - if (!update) { - this.pending = null; - this.status = "uptodate"; - this.message = "已是最新版本"; - return false; - } - this.pending = update; - this.newVersion = update.version; - this.releaseNotes = update.body ?? ""; - this.status = "available"; - this.message = `发现新版本 ${update.version}`; - return true; - } catch (e) { - this.pending = null; - this.status = "error"; - this.message = this.toMessage(e, "检查更新失败,请确认网络可达更新服务器"); - return false; - } - } - - /** 下载并安装已发现的更新,完成后自动重启。 */ - async downloadAndInstall() { - const update = this.pending; - if (!update) { - this.status = "error"; - this.message = "没有待安装的更新"; - return; - } - - try { - this.status = "downloading"; - this.message = "下载中…"; - this.progress = 0; - - let total = 0; - await update.downloadAndInstall((event) => { - switch (event.event) { - case "Started": - total = event.data.contentLength ?? 0; - this.progress = 0; - break; - case "Progress": { - if (total > 0) { - const next = (event.data.chunkLength / total) + this.progress; - this.progress = Math.min(1, next); - } - break; - } - case "Finished": - this.progress = 1; - break; - } - }); - - this.status = "installing"; - this.message = "安装完成,正在重启…"; - await relaunch(); - } catch (e) { - this.status = "error"; - this.message = this.toMessage(e, "下载或安装更新失败"); - } - } - - private toMessage(e: unknown, fallback: string): string { - if (typeof e === "string" && e.trim()) { - return e; - } - if (e instanceof Error && e.message) { - return e.message; - } - return fallback; - } -} - -export const updater = new UpdateStore(); diff --git a/src/lib/stores/updateCapabilities.test.ts b/src/lib/stores/updateCapabilities.test.ts deleted file mode 100644 index fa14f85..0000000 --- a/src/lib/stores/updateCapabilities.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from "vitest"; -import capability from "../../../src-tauri/capabilities/default.json"; - -describe("updater store capability", () => { - test("grants only the store operations used by the updater timestamp", () => { - expect( - capability.permissions.filter((permission) => permission.startsWith("store:")), - ).toEqual([ - "store:allow-load", - "store:allow-get", - "store:allow-set", - "store:allow-save", - ]); - }); - - test("grants only the updater and process commands used by the app", () => { - expect( - capability.permissions.filter((permission) => - permission.startsWith("updater:"), - ), - ).toEqual([ - "updater:allow-check", - "updater:allow-download-and-install", - ]); - expect( - capability.permissions.filter((permission) => - permission.startsWith("process:"), - ), - ).toEqual(["process:allow-restart"]); - }); - - test("does not expose unused positioner commands or redundant autostart defaults", () => { - expect( - capability.permissions.filter((permission) => - permission.startsWith("positioner:"), - ), - ).toEqual([]); - expect( - capability.permissions.filter((permission) => - permission.startsWith("autostart:"), - ), - ).toEqual([ - "autostart:allow-enable", - "autostart:allow-disable", - "autostart:allow-is-enabled", - ]); - }); -}); diff --git a/src/lib/views/ChatPanel.svelte b/src/lib/views/ChatPanel.svelte deleted file mode 100644 index 1ad573d..0000000 --- a/src/lib/views/ChatPanel.svelte +++ /dev/null @@ -1,932 +0,0 @@ - - - - -
-
- - -
- - - - - - - - - {#if showHistory} - - - - {/if} -
-
- - -
- - - {#if railOpen} -
-
- 历史提问 - -
- {#if railItems.length === 0} -

还没有提问

- {:else} -
- {#each railItems as item (item.index)} - - {/each} -
- {/if} -
- {/if} -
- -
- {#if c.state.messages.length === 0} -

和 AI 自由对话。

- {/if} - {#each c.state.messages as message, index (`${c.state.currentId}:${index}`)} -
- {message.role === "user" ? "你" : "AI"} -

{message.content}{#if c.state.loading && index === c.state.messages.length - 1}{/if}

-
- {/each} - {#if c.state.error} -

{c.state.error}

- {/if} -
- -
- - -
-
- - diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte deleted file mode 100644 index ade6b1d..0000000 --- a/src/lib/views/Settings.svelte +++ /dev/null @@ -1,1101 +0,0 @@ - - - - -
-
-

设置

-
- - -
-
- -
- - -
- {#if activeSection === "translate"} -
- AI 翻译(OpenAI 兼容) - -
- - - {#if cfg.has_api_key} -
- {clearTranslateApiKey ? "保存后删除已保存密钥" : "已保存;留空会保持不变"} - -
- {/if} -
- - -

这里只用于翻译页的 AI 译文,不影响 AI 对话。

- {#if translateLoadError} - - {/if} - {#if saveError} - - {/if} - -
- - {:else if activeSection === "chat"} -
- AI 对话(OpenAI 兼容) - -
- - - {#if chatCfg.has_api_key} -
- {clearChatApiKey ? "保存后删除已保存密钥" : "已保存;留空会保持不变"} - -
- {/if} -
- - -

只用于 AI 对话,不会套用翻译提示词。

- {#if chatLoadError} - - {/if} - {#if chatSaveError} - - {/if} - -
- - {:else if activeSection === "shortcuts"} -
- 快捷键 - {#each shortcutFields as { field, hint } (field)} -
- {shortcutLabels[field]} -
- - -
-

{hint}

-
- {/each} -

- {recordingShortcut ? `正在录制「${shortcutLabels[recordingShortcut]}」,请按下包含修饰键的组合键。` : "修改后点击“保存快捷键”生效。"} -

- {#if shortcutError} - - {/if} - -

每个快捷键都能单独设置或清空(留空即关闭)。必须包含 Cmd / Ctrl / Option 修饰键。「呼出翻译」和「划词翻译」、「呼出 AI 对话」和「划词 AI 对话」可以共用同一个键:有选中文本时走划词,没有则走呼出。例如 CmdOrCtrl+Shift+Space、Option+Space。

-
- - {:else if activeSection === "general"} -
- 通用 - -
-
- 面板透明度 - {Math.round(panelOpacity * 100)}% -
- -
- - - -
-
- 版本 - {updater.currentVersion || "—"} -
- {#if updater.releaseNotes} -

{updater.releaseNotes}

- {/if} - {#if updater.status === "downloading" || updater.status === "installing"} -
-
-
- {/if} -
- - {#if updater.message} - {updater.message} - {/if} -
-
- -

调整面板透明度可让底层桌面透出。截图 OCR 需要"屏幕录制"权限;划词翻译优先使用"辅助功能"权限,未授权时会临时用复制代替。

- {#if generalError} - - {/if} -
- {/if} -
-
-
- - diff --git a/src/lib/views/settingsLogic.test.ts b/src/lib/views/settingsLogic.test.ts deleted file mode 100644 index 978f795..0000000 --- a/src/lib/views/settingsLogic.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; -import { - buildApiKeySavePayload, - createDebouncedSaver, - performUpdateAction, - saveAndReload, - toggleBooleanPreference, -} from "./settingsLogic"; - -describe("settings actions", () => { - test("checks first and installs only after a second explicit action", async () => { - const updater = { - status: "idle", - checkForUpdates: vi.fn(async () => { - updater.status = "available"; - return true; - }), - downloadAndInstall: vi.fn(async () => {}), - }; - - await performUpdateAction(updater); - expect(updater.checkForUpdates).toHaveBeenCalledOnce(); - expect(updater.downloadAndInstall).not.toHaveBeenCalled(); - - await performUpdateAction(updater); - expect(updater.downloadAndInstall).toHaveBeenCalledOnce(); - }); - - test("returns a visible error when config saving is rejected", async () => { - const original = { model: "model-a" }; - const load = vi.fn(async () => ({ model: "model-b" })); - - const result = await saveAndReload( - original, - vi.fn(async () => { - throw new Error("invalid base URL"); - }), - load, - "保存 AI 翻译配置失败", - ); - - expect(result).toEqual({ - ok: false, - value: original, - error: "保存 AI 翻译配置失败: invalid base URL", - }); - expect(load).not.toHaveBeenCalled(); - }); - - test("preserves an existing API key unless clearing is explicit", () => { - const config = { - api_key: "", - has_api_key: true, - clear_api_key: false, - model: "model-a", - }; - - expect(buildApiKeySavePayload(config, false).clear_api_key).toBe(false); - expect(buildApiKeySavePayload(config, true).clear_api_key).toBe(true); - expect( - buildApiKeySavePayload({ ...config, api_key: "replacement" }, true) - .clear_api_key, - ).toBe(false); - }); - - test("keeps the previous checkbox value when autostart update fails", async () => { - const result = await toggleBooleanPreference( - true, - vi.fn(async () => { - throw new Error("permission denied"); - }), - "切换开机自启失败", - ); - - expect(result).toEqual({ - value: true, - error: "切换开机自启失败: permission denied", - }); - }); -}); - -describe("debounced settings persistence", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - test("flushes the latest pending value exactly once on teardown", async () => { - vi.useFakeTimers(); - const save = vi.fn(async () => {}); - const saver = createDebouncedSaver(400, save); - - saver.schedule(0.6); - saver.schedule(0.7); - await saver.flush(); - await vi.runAllTimersAsync(); - - expect(save).toHaveBeenCalledOnce(); - expect(save).toHaveBeenCalledWith(0.7); - }); -}); diff --git a/src/lib/views/settingsLogic.ts b/src/lib/views/settingsLogic.ts deleted file mode 100644 index 74b4712..0000000 --- a/src/lib/views/settingsLogic.ts +++ /dev/null @@ -1,111 +0,0 @@ -export interface UpdateActionTarget { - status: string; - checkForUpdates: () => Promise; - downloadAndInstall: () => Promise; -} - -export type SaveAndReloadResult = - | { ok: true; value: T; error: null } - | { ok: false; value: T; error: string }; - -function errorMessage(error: unknown): string { - if (typeof error === "string" && error.trim()) return error; - if (error instanceof Error && error.message) return error.message; - return String(error); -} - -export async function performUpdateAction( - updater: UpdateActionTarget, -): Promise<"checked" | "installed"> { - if (updater.status === "available") { - await updater.downloadAndInstall(); - return "installed"; - } - - await updater.checkForUpdates(); - return "checked"; -} - -export async function saveAndReload( - value: T, - save: (value: T) => Promise, - load: () => Promise, - errorPrefix: string, -): Promise> { - try { - await save(value); - return { ok: true, value: await load(), error: null }; - } catch (error) { - return { - ok: false, - value, - error: `${errorPrefix}: ${errorMessage(error)}`, - }; - } -} - -export function buildApiKeySavePayload< - T extends { api_key: string; clear_api_key: boolean }, ->(config: T, clearRequested: boolean): T { - return { - ...config, - clear_api_key: clearRequested && config.api_key.trim() === "", - }; -} - -export async function toggleBooleanPreference( - current: boolean, - apply: (next: boolean) => Promise, - errorPrefix: string, -): Promise<{ value: boolean; error: string | null }> { - const next = !current; - try { - await apply(next); - return { value: next, error: null }; - } catch (error) { - return { - value: current, - error: `${errorPrefix}: ${errorMessage(error)}`, - }; - } -} - -export interface DebouncedSaver { - schedule: (value: T) => void; - flush: () => Promise; -} - -export function createDebouncedSaver( - delayMs: number, - save: (value: T) => Promise, -): DebouncedSaver { - let timer: ReturnType | null = null; - let hasPending = false; - let pendingValue!: T; - - function clearTimer() { - if (!timer) return; - clearTimeout(timer); - timer = null; - } - - async function flush() { - clearTimer(); - if (!hasPending) return; - const value = pendingValue; - hasPending = false; - await save(value); - } - - function schedule(value: T) { - pendingValue = value; - hasPending = true; - clearTimer(); - timer = setTimeout(() => { - timer = null; - void flush().catch(() => {}); - }, delayMs); - } - - return { schedule, flush }; -} diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 78dbeac..0000000 --- a/src/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { mount } from "svelte"; -import "./styles/global.css"; -import App from "./App.svelte"; - -const app = mount(App, { - target: document.getElementById("app")!, -}); - -export default app; diff --git a/src/styles/global.css b/src/styles/global.css deleted file mode 100644 index b30a30c..0000000 --- a/src/styles/global.css +++ /dev/null @@ -1,135 +0,0 @@ -@import "tailwindcss"; -@import "./tokens.css"; - -/* 透明背景 —— 让 macOS 原生 Vibrancy 透出作为底色 */ -html, -body { - margin: 0; - padding: 0; - background: transparent; - overflow: hidden; - font-family: - -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", - "Helvetica Neue", sans-serif; - color: var(--text); - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; -} - -#app { - height: 100vh; - /* 在 webview 整层裁出窗口圆角,任何子元素都不会在圆角处漏出直角, - 从根上消除「圆角 + 直角叠在一起」。圆角与原生 vibrancy 对齐。 */ - border-radius: var(--radius-window); - overflow: hidden; -} - -/* 全局聚焦态 —— MD3 primary 描边,克制分明 */ -*:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; - border-radius: var(--radius-sm); -} - -::selection { - background: var(--accent-soft); -} - -/* ───────────────────────────────────────────── - MD3 扁平可复用表面 - —— 实色填充、纤细描边、无玻璃高光 - ───────────────────────────────────────────── */ -.md-surface, -.md-surface-raised, -.md-popover { - border: 1px solid var(--border); -} - -.md-surface { - background: var(--surface); -} - -.md-surface-raised { - background: var(--surface-raised); - box-shadow: var(--shadow-sm); -} - -.md-popover { - background: var(--surface-popover); - box-shadow: var(--shadow-pop); -} - -/* MD3 状态层 —— 控件 hover/focus/pressed 时叠加的半透明色 */ -.md-state { - position: relative; - overflow: hidden; -} -.md-state::before { - content: ""; - position: absolute; - inset: 0; - pointer-events: none; - background: currentColor; - opacity: 0; - transition: opacity var(--duration-fast) var(--ease); -} -.md-state:hover::before { - opacity: var(--state-hover); -} -.md-state:focus-visible::before { - opacity: var(--state-focus); -} -.md-state:active::before { - opacity: var(--state-pressed); -} - -/* 滚动条 —— 简洁方块,圆角极小 */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} -::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border: 2px solid transparent; - border-radius: var(--radius-sm); - background-clip: padding-box; -} -::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover); - background-clip: padding-box; -} -::-webkit-scrollbar-track { - background: transparent; -} - -@keyframes md-pop-in { - from { - opacity: 0; - transform: scale(0.97) translateY(-4px); - } - to { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -@keyframes md-pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.65; - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - } -} diff --git a/src/styles/tokens.css b/src/styles/tokens.css deleted file mode 100644 index c5cdf44..0000000 --- a/src/styles/tokens.css +++ /dev/null @@ -1,166 +0,0 @@ -/* ───────────────────────────────────────────── - Material 3 设计令牌 - —— 纯扁平化:实色表面 + 小圆角 + MD3 状态层 + 静音色调 - 保留原生 NSVisualEffectMaterial::Popover 通透底,但控件一律实色 - ───────────────────────────────────────────── */ -:root { - /* ── MD3 色调方案(浅色) ── */ - /* 以系统蓝为 seed 色,派生出 MD3 中性 / 强调色调 */ - --md-primary: oklch(48% 0.19 264); - --md-on-primary: oklch(99% 0.01 264); - --md-primary-container: oklch(93% 0.04 268); - --md-on-primary-container: oklch(20% 0.05 268); - - /* 面板透明度 —— 由「设置 > 通用」滑块实时改写(0–1,默认 1)。 - 用作面板底色 alpha,让底层毛玻璃透出。 */ - --panel-opacity: 1; - - /* 表面 —— 实色,让控件清晰可辨;整体仍半透明以透出毛玻璃底 */ - --surface: oklch(98.5% 0.003 264 / 0.86); - --surface-raised: oklch(100% 0 0 / 0.92); - --surface-sunken: oklch(96% 0.004 264 / 0.86); - --surface-popover: oklch(98.5% 0.003 264 / 0.96); - --surface-thick: oklch(98.5% 0.003 264); - /* 弹层专用 —— 完全不透明,避免悬浮在文本之上时透出底层内容 */ - --surface-overlay: oklch(99% 0.003 264); - --surface-card: oklch(99.5% 0.002 264 / 0.9); - --surface-panel: oklch(96.5% 0.004 264 / 0.8); - --surface-high: oklch(97% 0.004 264 / 0.92); - - /* 面板底色 —— 跟随透明度滑块;面板容器统一引用此变量。 */ - --panel-bg: oklch(98.5% 0.003 264 / var(--panel-opacity)); - - /* 描边 —— 极淡,默认几乎不可见;主要靠色调表面区分,框线只在交互时浮现 */ - --border: oklch(72% 0.01 264 / 0.14); - --border-strong: oklch(55% 0.012 264 / 0.28); - - /* 文本 */ - --text: oklch(20% 0.01 264); - --text-muted: oklch(45% 0.014 264); - --text-faint: oklch(55% 0.014 264); - - /* 强调色 —— MD3 primary,扁平实色 */ - --accent: var(--md-primary); - --accent-hover: oklch(43% 0.18 264); - --accent-active: oklch(38% 0.17 264); - --accent-soft: oklch(48% 0.19 264 / 0.12); - --accent-softer: oklch(48% 0.19 264 / 0.08); - --accent-text: var(--md-on-primary); - --accent-container: var(--md-primary-container); - --accent-on-container: var(--md-on-primary-container); - --accent-border: oklch(48% 0.19 264 / 0.6); - --accent-border-soft: oklch(48% 0.19 264 / 0.32); - --accent-shadow: oklch(48% 0.19 264 / 0.28); - --focus-ring: oklch(48% 0.19 264 / 0.5); - - /* 状态 */ - --danger: oklch(58% 0.22 25); - --danger-soft: oklch(58% 0.22 25 / 0.12); - --danger-border: oklch(58% 0.22 25 / 0.4); - --success: oklch(58% 0.16 150); - - /* 字号 —— 流式缩放 */ - --text-xs: 0.75rem; - --text-sm: 0.8125rem; - --text-base: 0.9375rem; - --text-lg: 1.0625rem; - --text-xl: 1.375rem; - - /* 间距节奏 */ - --space-1: 0.25rem; - --space-2: 0.5rem; - --space-3: 0.75rem; - --space-4: 1rem; - --space-5: 1.5rem; - - /* 圆角 —— 偏小但有圆润感,避免直角生硬 */ - --radius-xs: 4px; - --radius-sm: 6px; - --radius: 10px; - --radius-lg: 12px; - --radius-xl: 14px; - --radius-window: 14px; - - /* 动效 —— MD3 标准缓动 */ - --duration-fast: 120ms; - --duration: 200ms; - --duration-slow: 300ms; - --ease: cubic-bezier(0.2, 0, 0, 1); - --ease-spring: cubic-bezier(0.3, 0, 0, 1); - --ease-standard: cubic-bezier(0.2, 0, 0, 1); - --ease-emphasized: cubic-bezier(0.3, 0, 0, 1); - - /* 滚动条 */ - --scrollbar-thumb: oklch(20% 0.01 264 / 0.22); - --scrollbar-thumb-hover: oklch(20% 0.01 264 / 0.34); - - /* ── MD3 海拔阴影(elevation)—— 扁平、轻量、克制 ── */ - /* E0: 无阴影(默认表面) */ - /* E1: 悬浮的卡片/容器 */ - --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.1), 0 1px 3px oklch(0% 0 0 / 0.06); - /* E2: 浮起按钮 */ - --shadow: 0 1px 2px oklch(0% 0 0 / 0.1), 0 2px 6px oklch(0% 0 0 / 0.08); - /* E3: 弹层/对话框 */ - --shadow-lg: 0 4px 8px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08); - /* E4: 主弹层 */ - --shadow-pop: 0 6px 12px oklch(0% 0 0 / 0.14), 0 2px 4px oklch(0% 0 0 / 0.1); - - /* MD3 状态层透明度(hover/focus/pressed 叠加) */ - --state-hover: 0.08; - --state-focus: 0.12; - --state-pressed: 0.12; - - /* 选中态指示器(分段控件高亮块)—— 纯实色,无光晕 */ - --pill-shadow: 0 1px 2px oklch(0% 0 0 / 0.1); -} - -@media (prefers-color-scheme: dark) { - :root { - --md-primary: oklch(72% 0.16 264); - --md-on-primary: oklch(16% 0.03 264); - --md-primary-container: oklch(30% 0.07 268); - --md-on-primary-container: oklch(92% 0.03 268); - - --surface: oklch(20% 0.01 264 / 0.88); - --surface-raised: oklch(26% 0.012 264 / 0.92); - --surface-sunken: oklch(17% 0.01 264 / 0.86); - --surface-popover: oklch(22% 0.012 264 / 0.97); - --surface-thick: oklch(20% 0.01 264); - --surface-overlay: oklch(26% 0.012 264); - --surface-card: oklch(24% 0.012 264 / 0.9); - --surface-panel: oklch(18% 0.01 264 / 0.8); - --surface-high: oklch(26% 0.012 264 / 0.92); - - --panel-bg: oklch(20% 0.01 264 / var(--panel-opacity)); - - --border: oklch(80% 0.01 264 / 0.12); - --border-strong: oklch(90% 0.01 264 / 0.22); - - --text: oklch(95% 0.003 264); - --text-muted: oklch(74% 0.01 264); - --text-faint: oklch(66% 0.01 264); - - --accent: var(--md-primary); - --accent-hover: oklch(78% 0.15 264); - --accent-active: oklch(66% 0.16 264); - --accent-soft: oklch(72% 0.16 264 / 0.18); - --accent-softer: oklch(72% 0.16 264 / 0.1); - --accent-text: var(--md-on-primary); - --accent-container: var(--md-primary-container); - --accent-on-container: var(--md-on-primary-container); - --accent-border: oklch(72% 0.16 264 / 0.64); - --accent-border-soft: oklch(72% 0.16 264 / 0.34); - --accent-shadow: oklch(72% 0.16 264 / 0.32); - --focus-ring: oklch(72% 0.16 264 / 0.5); - - --scrollbar-thumb: oklch(95% 0.003 264 / 0.22); - --scrollbar-thumb-hover: oklch(95% 0.003 264 / 0.34); - - --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.4), 0 1px 3px oklch(0% 0 0 / 0.3); - --shadow: 0 1px 3px oklch(0% 0 0 / 0.45), 0 2px 6px oklch(0% 0 0 / 0.32); - --shadow-lg: 0 4px 10px oklch(0% 0 0 / 0.5), 0 1px 4px oklch(0% 0 0 / 0.35); - --shadow-pop: 0 6px 14px oklch(0% 0 0 / 0.55), 0 2px 6px oklch(0% 0 0 / 0.4); - - --pill-shadow: 0 1px 3px oklch(0% 0 0 / 0.45); - } -} diff --git a/static/favicon.png b/static/favicon.png deleted file mode 100644 index 825b9e65af7c104cfb07089bb28659393b4f2097..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1571 zcmV+;2Hg3HP)Px)-AP12RCwC$UE6KzI1p6{F2N z1VK2vi|pOpn{~#djwYcWXTI_im_u^TJgMZ4JMOsSj!0ma>B?-(Hr@X&W@|R-$}W@Z zgj#$x=!~7LGqHW?IO8+*oE1MyDp!G=L0#^lUx?;!fXv@l^6SvTnf^ac{5OurzC#ZMYc20lI%HhX816AYVs1T3heS1*WaWH z%;x>)-J}YB5#CLzU@GBR6sXYrD>Vw(Fmt#|JP;+}<#6b63Ike{Fuo!?M{yEffez;| zp!PfsuaC)>h>-AdbnwN13g*1LowNjT5?+lFVd#9$!8Z9HA|$*6dQ8EHLu}U|obW6f z2%uGv?vr=KNq7YYa2Roj;|zooo<)lf=&2yxM@e`kM$CmCR#x>gI>I|*Ubr({5Y^rb zghxQU22N}F51}^yfDSt786oMTc!W&V;d?76)9KXX1 z+6Okem(d}YXmmOiZq$!IPk5t8nnS{%?+vDFz3BevmFNgpIod~R{>@#@5x9zJKEHLHv!gHeK~n)Ld!M8DB|Kfe%~123&Hz1Z(86nU7*G5chmyDe ziV7$pB7pJ=96hpxHv9rCR29%bLOXlKU<_13_M8x)6;P8E1Kz6G<&P?$P^%c!M5`2` zfY2zg;VK5~^>TJGQzc+33-n~gKt{{of8GzUkWmU110IgI0DLxRIM>0US|TsM=L|@F z0Bun8U!cRB7-2apz=y-7*UxOxz@Z0)@QM)9wSGki1AZ38ceG7Q72z5`i;i=J`ILzL z@iUO?SBBG-0cQuo+an4TsLy-g-x;8P4UVwk|D8{W@U1Zi z!M)+jqy@nQ$p?5tsHp-6J304Q={v-B>66$P0IDx&YT(`IcZ~bZfmn11#rXd7<5s}y zBi9eim&zQc0Dk|2>$bs0PnLmDfMP5lcXRY&cvJ=zKxI^f0%-d$tD!`LBf9^jMSYUA zI8U?CWdY@}cRq6{5~y+)#h1!*-HcGW@+gZ4B};0OnC~`xQOyH19z*TA!!BJ%9s0V3F?CAJ{hTd#*tf+ur-W9MOURF-@B77_-OshsY}6 zOXRY=5%C^*26z?l)1=$bz30!so5tfABdSYzO+H=CpV~aaUefmjvfZ3Ttu9W&W3Iu6 zROlh0MFA5h;my}8lB0tAV-Rvc2Zs_CCSJnx@d`**$idgy-iMob4dJWWw|21b4NB=LfsYp0Aeh{Ov)yztQi;eL4y5 zMi>8^SzKqk8~k?UiQK^^-5d8c%bV?$F8%X~czyiaKCI2=UH - - - - - diff --git a/svelte.config.js b/svelte.config.js deleted file mode 100644 index d0e6448..0000000 --- a/svelte.config.js +++ /dev/null @@ -1,5 +0,0 @@ -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; - -export default { - preprocess: vitePreprocess(), -}; diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index e3791f0..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "module": "ESNext", - "resolveJsonModule": true, - "allowJs": true, - "checkJs": true, - "isolatedModules": true, - "moduleDetection": "force", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "moduleResolution": "bundler", - "verbatimModuleSyntax": true, - "types": ["svelte", "vite/client"] - }, - "include": ["src/**/*.ts", "src/**/*.svelte"] -} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index ca1e063..0000000 --- a/vite.config.js +++ /dev/null @@ -1,31 +0,0 @@ -import { defineConfig } from "vite"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; -import tailwindcss from "@tailwindcss/vite"; - -// @ts-expect-error process is a nodejs global -const host = process.env.TAURI_DEV_HOST; - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [svelte(), tailwindcss()], - - // prevent Vite from obscuring rust errors - clearScreen: false, - // tauri expects a fixed port, fail if that port is not available - server: { - port: 1420, - strictPort: true, - host: host || false, - hmr: host - ? { - protocol: "ws", - host, - port: 1421, - } - : undefined, - watch: { - // tell Vite to ignore watching `src-tauri` - ignored: ["**/src-tauri/**"], - }, - }, -});