From f452322aadbd954f975e33c66c1a9552e433e4a7 Mon Sep 17 00:00:00 2001 From: zhongluofeng Date: Thu, 30 Jul 2026 09:09:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=98=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src-tauri/Cargo.lock | 193 +++++ src-tauri/Cargo.toml | 9 + src-tauri/capabilities/clipboard-popup.json | 19 + src-tauri/src/clipboard/commands.rs | 205 ++++++ src-tauri/src/clipboard/manager.rs | 187 +++++ src-tauri/src/clipboard/mod.rs | 17 + src-tauri/src/clipboard/monitor.rs | 169 +++++ src-tauri/src/clipboard/popup.rs | 254 +++++++ src-tauri/src/clipboard/reader.rs | 280 ++++++++ src-tauri/src/clipboard/storage.rs | 375 ++++++++++ src-tauri/src/download_engine/commands.rs | 38 +- src-tauri/src/download_engine/engine.rs | 148 +++- src-tauri/src/download_engine/mod.rs | 2 +- src-tauri/src/download_engine/server.rs | 2 +- src-tauri/src/download_engine/task.rs | 5 + src-tauri/src/lib.rs | 106 +-- src-tauri/src/mihomo_manager.rs | 18 + src-tauri/src/snap_fix.rs | 92 +++ src-tauri/src/tray_menu.rs | 738 ++++++++++++++++++++ src/App.vue | 51 +- src/components/layout/TitleBar.vue | 5 + src/lib/trayEvents.ts | 15 + src/main.ts | 16 +- src/modules/clipboard/ClipboardModule.vue | 491 ++++++++++++- src/modules/clipboard/ClipboardPopup.vue | 633 +++++++++++++++++ src/modules/downloader/DownloaderModule.vue | 241 ++++++- src/modules/monitor/MonitorModule.vue | 43 +- src/modules/monitor/index.ts | 8 + src/stores/appStore.ts | 8 + src/stores/clipboardStore.ts | 293 ++++++++ src/stores/downloaderStore.ts | 39 +- 32 files changed, 4586 insertions(+), 115 deletions(-) create mode 100644 src-tauri/capabilities/clipboard-popup.json create mode 100644 src-tauri/src/clipboard/commands.rs create mode 100644 src-tauri/src/clipboard/manager.rs create mode 100644 src-tauri/src/clipboard/mod.rs create mode 100644 src-tauri/src/clipboard/monitor.rs create mode 100644 src-tauri/src/clipboard/popup.rs create mode 100644 src-tauri/src/clipboard/reader.rs create mode 100644 src-tauri/src/clipboard/storage.rs create mode 100644 src-tauri/src/snap_fix.rs create mode 100644 src-tauri/src/tray_menu.rs create mode 100644 src/lib/trayEvents.ts create mode 100644 src/modules/clipboard/ClipboardPopup.vue create mode 100644 src/stores/clipboardStore.ts diff --git a/package.json b/package.json index cb86f99..1b95aff 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-autostart": "~2", "@tauri-apps/plugin-dialog": "^2", + "@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-opener": "^2", "@vueuse/core": "^14.3.0", "class-variance-authority": "^0.7.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f8bf094..b449ec6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -19,6 +19,18 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -420,6 +432,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -1172,6 +1190,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1469,6 +1499,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1590,6 +1630,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1678,12 +1736,30 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1993,6 +2069,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2244,6 +2333,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2351,6 +2451,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.3" @@ -3019,6 +3129,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.41.0" @@ -3270,6 +3386,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -4168,6 +4298,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -4333,10 +4478,14 @@ name = "thing" version = "0.1.0" dependencies = [ "axum", + "base64 0.22.1", "chrono", "dirs 5.0.1", "futures-util", + "image", + "raw-window-handle", "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "serde_yaml", @@ -4345,6 +4494,7 @@ dependencies = [ "tauri-build", "tauri-plugin-autostart", "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", "tauri-plugin-opener", "tauri-plugin-snap-layout", "tokio", @@ -5814,6 +5964,29 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "xz2" version = "0.1.7" @@ -5907,6 +6080,26 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a1ea7ba..b93b278 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" tauri-plugin-snap-layout = "1" +tauri-plugin-global-shortcut = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" @@ -34,15 +35,23 @@ url = "2" zip = "2" dirs = "5" sysinfo = "0.32" +rusqlite = { version = "0.32", features = ["bundled"] } +base64 = "0.22" +image = { version = "0.25", default-features = false, features = ["png"] } [target.'cfg(windows)'.dependencies] winreg = "0.52" +raw-window-handle = "0.6" windows-sys = { version = "0.52", features = [ "Win32_Networking_WinInet", "Win32_Foundation", "Win32_Security", "Win32_System_Threading", + "Win32_System_Ole", + "Win32_System_Memory", + "Win32_System_DataExchange", "Win32_UI_WindowsAndMessaging", + "Win32_UI_Shell", "Win32_UI_HiDpi", "Win32_UI_Input_KeyboardAndMouse", "Win32_Graphics_Gdi", diff --git a/src-tauri/capabilities/clipboard-popup.json b/src-tauri/capabilities/clipboard-popup.json new file mode 100644 index 0000000..5107bbe --- /dev/null +++ b/src-tauri/capabilities/clipboard-popup.json @@ -0,0 +1,19 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "clipboard-popup", + "description": "Capability for clipboard quick popup window", + "windows": ["clipboard-popup"], + "permissions": [ + "core:default", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-set-focus", + "core:window:allow-close", + "core:window:allow-set-theme", + "core:window:allow-set-effects", + "core:window:allow-set-background-color", + "core:event:allow-emit", + "core:event:allow-listen", + "snap-layout:default" + ] +} diff --git a/src-tauri/src/clipboard/commands.rs b/src-tauri/src/clipboard/commands.rs new file mode 100644 index 0000000..c183998 --- /dev/null +++ b/src-tauri/src/clipboard/commands.rs @@ -0,0 +1,205 @@ +//! Tauri 命令:剪贴板模块 + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use serde::Serialize; +use tauri::{AppHandle, State}; + +use super::manager::{ClipboardManager, ClipboardSettings}; +use super::reader::dib_to_png; +use super::storage::{ClipboardItem, ClipboardItemDetail}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClipboardStatus { + pub running: bool, + pub count: i64, +} + +fn img_to_base64(b: &[u8]) -> Option { + dib_to_png(b).map(|png| STANDARD.encode(&png)) +} + +/// 历史查询结果(含总数,用于分页) +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryPage { + pub items: Vec, + pub total: i64, +} + +#[tauri::command] +pub async fn clipboard_get_history( + limit: Option, + offset: Option, + kind: Option, + manager: State<'_, ClipboardManager>, +) -> Result { + let storage = manager.storage(); + let kind = kind.unwrap_or_else(|| "all".into()); + let limit = limit.unwrap_or(50); + let offset = offset.unwrap_or(0); + let items = storage.get_history(limit, offset, &kind); + let total = storage.count_kind(&kind); + Ok(HistoryPage { items, total }) +} + +#[tauri::command] +pub async fn clipboard_get_pinned( + manager: State<'_, ClipboardManager>, +) -> Result, String> { + Ok(manager.storage().get_pinned()) +} + +#[tauri::command] +pub async fn clipboard_search( + query: String, + limit: Option, + offset: Option, + manager: State<'_, ClipboardManager>, +) -> Result { + let storage = manager.storage(); + let limit = limit.unwrap_or(50); + let offset = offset.unwrap_or(0); + let items = storage.search(&query, limit, offset); + let total = storage.count_search(&query); + Ok(HistoryPage { items, total }) +} + +#[tauri::command] +pub async fn clipboard_get_item( + id: i64, + manager: State<'_, ClipboardManager>, +) -> Result, String> { + Ok(manager.storage().get_detail(id, img_to_base64)) +} + +#[tauri::command] +pub async fn clipboard_set_pinned( + id: i64, + pinned: bool, + manager: State<'_, ClipboardManager>, +) -> Result { + Ok(manager.storage().set_pinned(id, pinned)) +} + +#[tauri::command] +pub async fn clipboard_delete( + id: i64, + manager: State<'_, ClipboardManager>, +) -> Result { + Ok(manager.storage().delete(id)) +} + +#[tauri::command] +pub async fn clipboard_clear(manager: State<'_, ClipboardManager>) -> Result { + Ok(manager.storage().clear_non_pinned()) +} + +#[tauri::command] +pub async fn clipboard_copy_back( + id: i64, + manager: State<'_, ClipboardManager>, +) -> Result<(), String> { + manager.copy_back(id) +} + +#[tauri::command] +pub async fn clipboard_count(manager: State<'_, ClipboardManager>) -> Result { + Ok(manager.storage().count()) +} + +#[tauri::command] +pub async fn clipboard_get_settings( + manager: State<'_, ClipboardManager>, +) -> Result { + Ok(manager.get_settings()) +} + +#[tauri::command] +pub async fn clipboard_save_settings( + settings: ClipboardSettings, + app: AppHandle, + manager: State<'_, ClipboardManager>, +) -> Result<(), String> { + let prev_enabled = manager.get_settings().enabled; + let prev_shortcut = manager.get_settings().shortcut.clone(); + manager.save_settings(settings.clone()); + // 监听开关变化时联动启停 + if settings.enabled && !prev_enabled { + manager.start(&app); + } else if !settings.enabled && prev_enabled { + manager.stop(); + } + // 快捷键变化时重新注册 + if settings.shortcut != prev_shortcut { + super::popup::register_shortcut(&app, &settings.shortcut)?; + } + Ok(()) +} + +#[tauri::command] +pub async fn clipboard_status( + manager: State<'_, ClipboardManager>, +) -> Result { + Ok(ClipboardStatus { + running: manager.is_running(), + count: manager.storage().count(), + }) +} + +#[tauri::command] +pub async fn clipboard_start(app: AppHandle, manager: State<'_, ClipboardManager>) -> Result<(), String> { + manager.start(&app); + Ok(()) +} + +#[tauri::command] +pub async fn clipboard_stop(manager: State<'_, ClipboardManager>) -> Result<(), String> { + manager.stop(); + Ok(()) +} + +/// 注册(或切换)快捷弹窗全局快捷键 +#[tauri::command] +pub async fn clipboard_register_shortcut( + shortcut: String, + app: AppHandle, +) -> Result<(), String> { + super::popup::register_shortcut(&app, &shortcut) +} + +/// 注销快捷弹窗全局快捷键 +#[tauri::command] +pub async fn clipboard_unregister_shortcut(app: AppHandle) -> Result<(), String> { + super::popup::unregister_shortcut(&app); + Ok(()) +} + +/// 手动触发显示快捷弹窗(供 UI 按钮调用) +#[tauri::command] +pub async fn clipboard_show_popup(app: AppHandle) -> Result<(), String> { + super::popup::show_popup(&app); + Ok(()) +} + +/// 隐藏快捷弹窗 +#[tauri::command] +pub async fn clipboard_hide_popup(app: AppHandle) -> Result<(), String> { + super::popup::hide_popup(&app); + Ok(()) +} + +/// 显示已创建的弹窗窗口(前端 onMounted 后调用) +#[tauri::command] +pub async fn clipboard_show_window(app: AppHandle) -> Result<(), String> { + super::popup::show_window(&app); + Ok(()) +} + +/// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口 +#[tauri::command] +pub async fn clipboard_paste_to_target(app: AppHandle) -> Result<(), String> { + super::popup::paste_to_target(&app); + Ok(()) +} diff --git a/src-tauri/src/clipboard/manager.rs b/src-tauri/src/clipboard/manager.rs new file mode 100644 index 0000000..509b233 --- /dev/null +++ b/src-tauri/src/clipboard/manager.rs @@ -0,0 +1,187 @@ +//! ClipboardManager:管理监听线程生命周期、设置持久化、copy_back。 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use serde::{Deserialize, Serialize}; + +use super::monitor::start_monitor; +use super::reader::{write_dib, write_files, write_text}; +use super::storage::Storage; + +/// 剪贴板设置(持久化到 clipboard/settings.json) +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ClipboardSettings { + /// 监听是否启用 + pub enabled: bool, + /// 非固定历史最大条数 + pub max_items: i64, + /// 图片大小上限(KB),0 表示不限 + pub max_image_kb: u64, + pub record_text: bool, + pub record_image: bool, + pub record_files: bool, + /// 去重(相同内容更新时间而非新增) + pub dedup: bool, + /// 快捷弹窗全局快捷键(如 "Alt+V",空字符串表示禁用) + #[serde(default = "default_shortcut")] + pub shortcut: String, +} + +fn default_shortcut() -> String { + "Alt+V".to_string() +} + +impl Default for ClipboardSettings { + fn default() -> Self { + Self { + enabled: true, + max_items: 500, + max_image_kb: 5120, + record_text: true, + record_image: true, + record_files: true, + dedup: true, + shortcut: default_shortcut(), + } + } +} + +pub struct ClipboardManager { + storage: Arc, + settings: Arc>, + suppress: Arc, + monitor_stop: Arc, + monitor_handle: Mutex>>, + settings_path: PathBuf, +} + +impl ClipboardManager { + pub fn new(app_data_dir: PathBuf) -> Self { + let clip_dir = app_data_dir.join("clipboard"); + let storage = match Storage::new(&clip_dir) { + Ok(s) => Arc::new(s), + Err(e) => { + eprintln!("[clipboard] 磁盘存储初始化失败,回退内存: {}", e); + Arc::new(Storage::new_in_memory()) + } + }; + let settings_path = clip_dir.join("settings.json"); + let settings = Arc::new(Mutex::new(load_settings(&settings_path))); + let suppress = Arc::new(AtomicBool::new(false)); + let monitor_stop = Arc::new(AtomicBool::new(true)); + Self { + storage, + settings, + suppress, + monitor_stop, + monitor_handle: Mutex::new(None), + settings_path, + } + } + + /// 启动监听(若已运行则跳过) + pub fn start(&self, app: &tauri::AppHandle) { + let mut handle = self.monitor_handle.lock().unwrap(); + if handle.is_some() { + return; + } + self.monitor_stop.store(false, Ordering::SeqCst); + let h = start_monitor( + self.storage.clone(), + app.clone(), + self.settings.clone(), + self.suppress.clone(), + self.monitor_stop.clone(), + ); + *handle = Some(h); + eprintln!("[clipboard] 监听已启动"); + } + + /// 停止监听 + pub fn stop(&self) { + self.monitor_stop.store(true, Ordering::SeqCst); + if let Some(h) = self.monitor_handle.lock().unwrap().take() { + // 不阻塞等待;轮询线程最迟 800ms 后退出 + let _ = h.join(); + } + eprintln!("[clipboard] 监听已停止"); + } + + pub fn is_running(&self) -> bool { + self.monitor_handle.lock().unwrap().is_some() + } + + pub fn get_settings(&self) -> ClipboardSettings { + self.settings.lock().unwrap().clone() + } + + pub fn save_settings(&self, s: ClipboardSettings) { + { + *self.settings.lock().unwrap() = s.clone(); + } + save_settings(&self.settings_path, &s); + } + + /// 将某条历史写回剪贴板。写回前置 suppress 标志以避免再次记录。 + pub fn copy_back(&self, id: i64) -> Result<(), String> { + let (kind, content, blob) = self + .storage + .get_raw_for_copy(id) + .ok_or_else(|| "条目不存在".to_string())?; + self.suppress.store(true, Ordering::SeqCst); + let ok = match kind.as_str() { + "text" => content.as_deref().map(write_text).unwrap_or(false), + "image" => blob.as_deref().map(write_dib).unwrap_or(false), + "files" => { + let paths: Vec = content + .as_deref() + .and_then(|c| serde_json::from_str(c).ok()) + .unwrap_or_default(); + if paths.is_empty() { + false + } else { + write_files(&paths) + } + } + _ => false, + }; + if ok { + Ok(()) + } else { + // 写入失败也清除 suppress,避免误吞下次复制 + self.suppress.store(false, Ordering::SeqCst); + Err("写回剪贴板失败".into()) + } + } + + pub fn storage(&self) -> &Arc { + &self.storage + } +} + +impl Drop for ClipboardManager { + fn drop(&mut self) { + self.stop(); + } +} + +fn load_settings(path: &Path) -> ClipboardSettings { + match fs::read_to_string(path) { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => ClipboardSettings::default(), + } +} + +fn save_settings(path: &Path, s: &ClipboardSettings) { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(s) { + let _ = fs::write(path, json); + } +} diff --git a/src-tauri/src/clipboard/mod.rs b/src-tauri/src/clipboard/mod.rs new file mode 100644 index 0000000..c362dea --- /dev/null +++ b/src-tauri/src/clipboard/mod.rs @@ -0,0 +1,17 @@ +//! 剪贴板增强模块:监听、SQLite 持久化、多格式预览、固定、写回、快捷弹窗。 + +pub mod commands; +pub mod manager; +pub mod monitor; +pub mod popup; +pub mod reader; +pub mod storage; + +pub use commands::{ + clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history, + clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup, + clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search, + clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start, + clipboard_status, clipboard_stop, clipboard_unregister_shortcut, +}; +pub use manager::ClipboardManager; diff --git a/src-tauri/src/clipboard/monitor.rs b/src-tauri/src/clipboard/monitor.rs new file mode 100644 index 0000000..0686f99 --- /dev/null +++ b/src-tauri/src/clipboard/monitor.rs @@ -0,0 +1,169 @@ +//! 剪贴板监听线程:基于 GetClipboardSequenceNumber 轮询 +//! +//! 选用轮询而非 AddClipboardFormatListener 消息窗口:实现更简单、无需消息循环, +//! 800ms 间隔对剪贴板场景延迟可接受,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use tauri::{AppHandle, Emitter}; + +use super::reader::{read_clipboard, ClipData}; +use super::storage::{NewItem, Storage}; +use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber; + +/// 启动监听线程,返回 JoinHandle。 +pub fn start_monitor( + storage: Arc, + app: AppHandle, + settings: Arc>, + suppress: Arc, + stop: Arc, +) -> thread::JoinHandle<()> { + thread::spawn(move || loop { + if stop.load(Ordering::SeqCst) { + break; + } + thread::sleep(Duration::from_millis(800)); + if stop.load(Ordering::SeqCst) { + break; + } + let seq = unsafe { GetClipboardSequenceNumber() }; + // 首次记录基准;后续对比变化 + let last = LAST_SEQ.with(|c| { + let prev = c.get().unwrap_or(seq); + c.set(Some(seq)); + prev + }); + if seq == last { + continue; + } + // 序列号变化,处理一次 + if suppress.swap(false, Ordering::SeqCst) { + // 由本应用 copy_back 触发,跳过记录 + continue; + } + let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = { + let s = settings.lock().unwrap(); + ( + s.record_text, + s.record_image, + s.record_files, + s.max_items, + s.max_image_kb, + s.dedup, + ) + }; + let data = match read_clipboard() { + Some(d) => d, + None => continue, + }; + let item = match &data { + ClipData::Text(t) => { + if !rec_text { + continue; + } + build_text_item(t) + } + ClipData::Image { dib, width, height } => { + if !rec_image { + continue; + } + if max_image_kb > 0 && (dib.len() as u64 / 1024) > max_image_kb { + continue; + } + build_image_item(dib, *width, *height) + } + ClipData::Files(fs) => { + if !rec_files { + continue; + } + build_files_item(fs) + } + }; + if let Some(_id) = storage.insert_or_touch(item, dedup) { + storage.prune_to_max(max_items); + let _ = app.emit("clipboard-changed", ()); + } + }) +} + +thread_local! { + static LAST_SEQ: std::cell::Cell> = std::cell::Cell::new(None); +} + +fn build_text_item(t: &str) -> NewItem { + NewItem { + kind: "text".into(), + content: Some(t.to_string()), + blob: None, + preview: make_preview(t, 200), + size: t.len() as i64, + hash: hash_str(t), + } +} + +fn build_image_item(dib: &[u8], w: u32, h: u32) -> NewItem { + NewItem { + kind: "image".into(), + content: None, + blob: Some(dib.to_vec()), + preview: format!("图片 {}×{}", w, h), + size: dib.len() as i64, + hash: hash_bytes(dib), + } +} + +fn build_files_item(files: &[String]) -> NewItem { + let content = serde_json::to_string(files).unwrap_or_default(); + let preview = if files.len() == 1 { + make_preview(&files[0], 200) + } else { + format!( + "{} 个文件 · {}", + files.len(), + make_preview(files.first().map(|s| s.as_str()).unwrap_or(""), 60) + ) + }; + let hash = hash_str(&content); + NewItem { + kind: "files".into(), + content: Some(content), + blob: None, + preview, + size: files.iter().map(|f| f.len()).sum::() as i64, + hash, + } +} + +fn make_preview(s: &str, max_chars: usize) -> String { + let single: String = s + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let trimmed = single.trim().to_string(); + if trimmed.chars().count() <= max_chars { + trimmed + } else { + let truncated: String = trimmed.chars().take(max_chars).collect(); + format!("{}…", truncated) + } +} + +fn hash_str(s: &str) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::Hasher; + let mut h = DefaultHasher::new(); + h.write(s.as_bytes()); + format!("{:016x}", h.finish()) +} + +fn hash_bytes(b: &[u8]) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::Hasher; + let mut h = DefaultHasher::new(); + h.write(b); + format!("{:016x}", h.finish()) +} diff --git a/src-tauri/src/clipboard/popup.rs b/src-tauri/src/clipboard/popup.rs new file mode 100644 index 0000000..df618f2 --- /dev/null +++ b/src-tauri/src/clipboard/popup.rs @@ -0,0 +1,254 @@ +//! 剪贴板快捷弹窗:在鼠标位置显示历史记录弹窗。 +//! +//! 流程: +//! 1. 全局快捷键触发 → `show_popup` 在鼠标位置创建(隐藏)或显示窗口 +//! 2. 前端 Vue 挂载完成、主题应用后调用 `clipboard_show_window` 显示窗口 +//! 3. 前端监听 `clipboard-popup-show` 事件刷新数据 +//! 4. 用户选择 → 前端调用 `clipboard_copy_back` 写回剪贴板 +//! 5. 前端调用 `clipboard_paste_to_target` → 隐藏窗口 → 延迟模拟 Ctrl+V 粘贴 +//! 6. 窗口失焦自动隐藏 + +use std::sync::Mutex; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter}; +use tauri::window::{Effect, EffectsBuilder}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; + +/// 弹窗窗口标签 +pub const POPUP_LABEL: &str = "clipboard-popup"; + +/// 当前注册的快捷键(用于注销旧快捷键) +static CURRENT_SHORTCUT: Mutex> = Mutex::new(None); + +/// 解析快捷键字符串为 Shortcut(格式如 "Alt+V"、"Ctrl+Shift+V") +/// 失败返回 None。 +pub fn parse_shortcut(s: &str) -> Option { + s.trim().parse::().ok() +} + +/// 注册全局快捷键。重复调用会先注销旧快捷键。 +/// 传入空字符串则仅注销不注册。 +pub fn register_shortcut(app: &AppHandle, shortcut_str: &str) -> Result<(), String> { + // 先注销旧快捷键 + unregister_shortcut(app); + + if shortcut_str.trim().is_empty() { + return Ok(()); + } + + let shortcut = parse_shortcut(shortcut_str) + .ok_or_else(|| format!("无效的快捷键: {}", shortcut_str))?; + + let app_handle = app.clone(); + app.global_shortcut() + .on_shortcut(shortcut, move |_app, _shortcut, event| { + // 仅在按下时触发(松开不触发) + if event.state == ShortcutState::Pressed { + show_popup(&app_handle); + } + }) + .map_err(|e| format!("注册快捷键失败: {}", e))?; + + if let Ok(mut cur) = CURRENT_SHORTCUT.lock() { + *cur = Some(shortcut_str.to_string()); + } + eprintln!("[clipboard] 已注册快捷键: {}", shortcut_str); + Ok(()) +} + +/// 注销当前快捷键 +pub fn unregister_shortcut(app: &AppHandle) { + if let Ok(cur) = CURRENT_SHORTCUT.lock() { + if let Some(ref s) = *cur { + if let Some(shortcut) = parse_shortcut(s) { + let _ = app.global_shortcut().unregister(shortcut); + } + } + } + if let Ok(mut cur) = CURRENT_SHORTCUT.lock() { + *cur = None; + } +} + +/// 在鼠标当前位置显示弹窗。 +/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。 +/// 窗口已存在则移动到鼠标位置并显示。 +pub fn show_popup(app: &AppHandle) { + // 获取鼠标位置(屏幕坐标) + let (mx, my) = match get_cursor_pos() { + Some(p) => p, + None => return, + }; + + // 窗口尺寸(逻辑像素) + let w = 380.0_f64; + let h = 460.0_f64; + + // 获取屏幕工作区以避免弹出位置超出屏幕 + let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0)); + + // 计算窗口位置:鼠标位置作为左上角,超出屏幕则调整 + let x = (mx as f64).min(screen_w - w).max(0.0); + let y = (my as f64).min(screen_h - h).max(0.0); + + // 窗口已存在:移动 + 显示 + 请求焦点 + if let Some(win) = app.get_webview_window(POPUP_LABEL) { + let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition { + x, + y, + })); + let _ = win.show(); + let _ = win.set_focus(); + // 通知前端刷新数据 + let _ = app.emit("clipboard-popup-show", ()); + return; + } + + // 首次创建窗口:先隐藏,等前端挂载完成后再显示 + // 这样确保主题效果(dark class、mica)在窗口可见前已应用 + let win = match WebviewWindowBuilder::new( + app, + POPUP_LABEL, + WebviewUrl::App("index.html#clipboard-popup".into()), + ) + .title("剪贴板") + .inner_size(w, h) + .position(x, y) + .decorations(false) + .transparent(true) + .shadow(true) + .always_on_top(true) + .skip_taskbar(true) + .resizable(false) + .visible(false) // 关键:先隐藏,前端 onMounted 后调用 show_window + .focused(true) + .effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build()) + .build() + { + Ok(w) => w, + Err(e) => { + eprintln!("[clipboard] 创建弹窗失败: {}", e); + return; + } + }; + + // 监听窗口失焦:自动隐藏 + let app_handle = app.clone(); + let win_handle = win.clone(); + win.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(false) = event { + let _ = win_handle.hide(); + let _ = app_handle.emit("clipboard-popup-hide", ()); + } + }); + + eprintln!("[clipboard] 弹窗窗口已创建(隐藏状态)"); +} + +/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。 +pub fn show_window(app: &AppHandle) { + if let Some(win) = app.get_webview_window(POPUP_LABEL) { + let _ = win.show(); + let _ = win.set_focus(); + // 通知前端刷新数据 + let _ = app.emit("clipboard-popup-show", ()); + } +} + +/// 隐藏弹窗(不销毁,保留复用) +pub fn hide_popup(app: &AppHandle) { + if let Some(win) = app.get_webview_window(POPUP_LABEL) { + let _ = win.hide(); + } +} + +/// 隐藏弹窗后延迟模拟 Ctrl+V 粘贴到之前聚焦的窗口。 +/// 延迟 150ms 确保焦点切换回原窗口后再发送按键。 +pub fn paste_to_target(app: &AppHandle) { + // 先隐藏弹窗,让焦点回到原窗口 + hide_popup(app); + // 在独立线程中延迟模拟 Ctrl+V + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(150)); + simulate_paste(); + }); +} + +/// 使用 Win32 SendInput 模拟 Ctrl+V 按键 +#[cfg(windows)] +fn simulate_paste() { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, + VK_CONTROL, + }; + + const VK_V: u16 = 0x56; + + // 安全初始化为零值,避免未定义字段 + let mut inputs: [INPUT; 4] = unsafe { std::mem::zeroed() }; + + // Ctrl down + inputs[0].r#type = INPUT_KEYBOARD; + inputs[0].Anonymous.ki = KEYBDINPUT { wVk: VK_CONTROL, wScan: 0, dwFlags: 0, time: 0, dwExtraInfo: 0 }; + + // V down + inputs[1].r#type = INPUT_KEYBOARD; + inputs[1].Anonymous.ki = KEYBDINPUT { wVk: VK_V, wScan: 0, dwFlags: 0, time: 0, dwExtraInfo: 0 }; + + // V up + inputs[2].r#type = INPUT_KEYBOARD; + inputs[2].Anonymous.ki = KEYBDINPUT { wVk: VK_V, wScan: 0, dwFlags: KEYEVENTF_KEYUP, time: 0, dwExtraInfo: 0 }; + + // Ctrl up + inputs[3].r#type = INPUT_KEYBOARD; + inputs[3].Anonymous.ki = KEYBDINPUT { wVk: VK_CONTROL, wScan: 0, dwFlags: KEYEVENTF_KEYUP, time: 0, dwExtraInfo: 0 }; + + unsafe { + SendInput(4, inputs.as_ptr(), std::mem::size_of::() as i32); + } +} + +#[cfg(not(windows))] +fn simulate_paste() { + // 非 Windows 平台暂不支持自动粘贴 +} + +// ===== Win32 API:获取鼠标位置和工作区 ===== + +#[cfg(windows)] +mod win_api { + use windows_sys::Win32::Foundation::POINT; + use windows_sys::Win32::UI::WindowsAndMessaging::{GetCursorPos, SystemParametersInfoW, SPI_GETWORKAREA}; + + /// 获取鼠标位置(屏幕坐标,逻辑像素) + pub fn get_cursor_pos() -> Option<(i32, i32)> { + let mut pt = POINT { x: 0, y: 0 }; + unsafe { + if GetCursorPos(&mut pt) != 0 { + Some((pt.x, pt.y)) + } else { + None + } + } + } + + /// 获取屏幕工作区(排除任务栏,逻辑像素) + pub fn get_work_area() -> Option<(f64, f64)> { + use windows_sys::Win32::Foundation::RECT; + let mut rect = RECT { left: 0, top: 0, right: 0, bottom: 0 }; + unsafe { + if SystemParametersInfoW(SPI_GETWORKAREA, 0, &mut rect as *mut _ as *mut _, 0) != 0 { + Some(((rect.right - rect.left) as f64, (rect.bottom - rect.top) as f64)) + } else { + None + } + } + } +} + +#[cfg(not(windows))] +mod win_api { + pub fn get_cursor_pos() -> Option<(i32, i32)> { None } + pub fn get_work_area() -> Option<(f64, f64)> { None } +} + +pub use win_api::{get_cursor_pos, get_work_area}; diff --git a/src-tauri/src/clipboard/reader.rs b/src-tauri/src/clipboard/reader.rs new file mode 100644 index 0000000..9681a33 --- /dev/null +++ b/src-tauri/src/clipboard/reader.rs @@ -0,0 +1,280 @@ +//! Win32 剪贴板读取与写回 +//! +//! 读取优先级:文本 > 图片 > 文件。写回支持三种格式。 +//! 句柄类型说明(windows-sys 0.52): +//! - HWND / HANDLE = isize +//! - HGLOBAL / HLOCAL = *mut c_void +//! - HDROP = isize(与 HANDLE 同) + +use windows_sys::Win32::System::DataExchange::{ + CloseClipboard, EmptyClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard, + SetClipboardData, +}; +use windows_sys::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT}; +use windows_sys::Win32::System::Ole::{CF_DIB, CF_HDROP, CF_UNICODETEXT}; +use windows_sys::Win32::UI::Shell::DragQueryFileW; + +/// 剪贴板当前内容 +pub enum ClipData { + Text(String), + Image { dib: Vec, width: u32, height: u32 }, + Files(Vec), +} + +/// 读取剪贴板(需在 monitor 线程或命令线程调用)。OpenClipboard 失败返回 None。 +pub fn read_clipboard() -> Option { + unsafe { + if OpenClipboard(0) == 0 { + return None; + } + let result = if IsClipboardFormatAvailable(CF_UNICODETEXT as u32) != 0 { + read_text() + } else if IsClipboardFormatAvailable(CF_DIB as u32) != 0 { + read_image() + } else if IsClipboardFormatAvailable(CF_HDROP as u32) != 0 { + read_files() + } else { + None + }; + CloseClipboard(); + result + } +} + +unsafe fn read_text() -> Option { + let h = GetClipboardData(CF_UNICODETEXT as u32); // HANDLE = isize + if h == 0 { + return None; + } + let hglob = h as *mut core::ffi::c_void; // 实际是 HGLOBAL + let ptr = GlobalLock(hglob) as *const u16; + if ptr.is_null() { + return None; + } + let mut len = 0usize; + while *ptr.add(len) != 0 { + len += 1; + } + let slice = core::slice::from_raw_parts(ptr, len); + let s = String::from_utf16_lossy(slice); + GlobalUnlock(hglob); + if s.is_empty() { + None + } else { + Some(ClipData::Text(s)) + } +} + +unsafe fn read_image() -> Option { + let h = GetClipboardData(CF_DIB as u32); + if h == 0 { + return None; + } + let hglob = h as *mut core::ffi::c_void; + let ptr = GlobalLock(hglob) as *const u8; + if ptr.is_null() { + return None; + } + let size = GlobalSize(hglob); + let slice = core::slice::from_raw_parts(ptr, size); + let dib = slice.to_vec(); + GlobalUnlock(hglob); + let (w, hgt) = dib_info(&dib).unwrap_or((0, 0)); + Some(ClipData::Image { + dib, + width: w, + height: hgt, + }) +} + +unsafe fn read_files() -> Option { + let h = GetClipboardData(CF_HDROP as u32); // HANDLE = isize + if h == 0 { + return None; + } + let hdrop = h; // HDROP = isize + let count = DragQueryFileW(hdrop, 0xFFFFFFFF, core::ptr::null_mut(), 0); + let mut files = Vec::with_capacity(count as usize); + for i in 0..count { + let len = DragQueryFileW(hdrop, i, core::ptr::null_mut(), 0); + if len == 0 { + continue; + } + let mut buf = vec![0u16; (len as usize) + 1]; + let got = DragQueryFileW(hdrop, i, buf.as_mut_ptr(), buf.len() as u32); + let s = String::from_utf16_lossy(&buf[..got as usize]); + files.push(s); + } + // 剪贴板拥有句柄,不调用 DragFinish + if files.is_empty() { + None + } else { + Some(ClipData::Files(files)) + } +} + +// ===== 写回剪贴板(copy_back) ===== + +/// 写回文本。失败返回 false。写回会清空其他格式。 +pub fn write_text(s: &str) -> bool { + unsafe { + if OpenClipboard(0) == 0 { + return false; + } + let r = write_text_inner(s); + CloseClipboard(); + r + } +} + +unsafe fn write_text_inner(s: &str) -> bool { + if EmptyClipboard() == 0 { + return false; + } + let mut utf16: Vec = s.encode_utf16().collect(); + utf16.push(0); // null terminator + let byte_len = utf16.len() * 2; + let hglob = GlobalAlloc(GMEM_MOVEABLE, byte_len); + if hglob.is_null() { + return false; + } + let ptr = GlobalLock(hglob) as *mut u16; + if ptr.is_null() { + return false; + } + core::ptr::copy_nonoverlapping(utf16.as_ptr(), ptr, utf16.len()); + GlobalUnlock(hglob); + SetClipboardData(CF_UNICODETEXT as u32, hglob as isize) != 0 +} + +/// 写回图片(CF_DIB 原始字节)。 +pub fn write_dib(dib: &[u8]) -> bool { + unsafe { + if OpenClipboard(0) == 0 { + return false; + } + let r = (|| { + if EmptyClipboard() == 0 { + return false; + } + let hglob = GlobalAlloc(GMEM_MOVEABLE, dib.len()); + if hglob.is_null() { + return false; + } + let ptr = GlobalLock(hglob) as *mut u8; + if ptr.is_null() { + return false; + } + core::ptr::copy_nonoverlapping(dib.as_ptr(), ptr, dib.len()); + GlobalUnlock(hglob); + SetClipboardData(CF_DIB as u32, hglob as isize) != 0 + })(); + CloseClipboard(); + r + } +} + +/// 写回文件列表(CF_HDROP)。 +pub fn write_files(paths: &[String]) -> bool { + unsafe { + if OpenClipboard(0) == 0 { + return false; + } + let r = (|| { + if EmptyClipboard() == 0 { + return false; + } + // DROPFILES 头部 20 字节:pFiles(u32) + pt.x(i32) + pt.y(i32) + fNC(i32) + fWide(i32) + const DF_SIZE: usize = 20; + let mut wide: Vec = Vec::new(); + for p in paths { + wide.extend(p.encode_utf16()); + wide.push(0); // 每条字符串 null 结尾 + } + wide.push(0); // 末尾额外 null 表示列表结束 + let total = DF_SIZE + wide.len() * 2; + let hglob = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, total); + if hglob.is_null() { + return false; + } + let ptr = GlobalLock(hglob) as *mut u8; + if ptr.is_null() { + return false; + } + let offset = DF_SIZE as u32; + let off_bytes = offset.to_ne_bytes(); + core::ptr::copy_nonoverlapping(off_bytes.as_ptr(), ptr, 4); // pFiles + let fwide: u32 = 1; + let fwide_bytes = fwide.to_ne_bytes(); + core::ptr::copy_nonoverlapping(fwide_bytes.as_ptr(), ptr.add(16), 4); // fWide = 1 + let str_ptr = ptr.add(DF_SIZE) as *mut u16; + core::ptr::copy_nonoverlapping(wide.as_ptr(), str_ptr, wide.len()); + GlobalUnlock(hglob); + SetClipboardData(CF_HDROP as u32, hglob as isize) != 0 + })(); + CloseClipboard(); + r + } +} + +// ===== DIB → PNG(用于预览) ===== + +/// 从 DIB 字节解析宽高 +pub fn dib_info(dib: &[u8]) -> Option<(u32, u32)> { + if dib.len() < 40 { + return None; + } + let width = i32::from_le_bytes([dib[4], dib[5], dib[6], dib[7]]); + let height = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]); + if width <= 0 { + return None; + } + Some((width as u32, height.abs() as u32)) +} + +/// 将 CF_DIB 字节转换为 PNG 字节(仅支持 24/32bpp BI_RGB)。 +pub fn dib_to_png(dib: &[u8]) -> Option> { + use image::codecs::png::PngEncoder; + use image::ImageEncoder; + if dib.len() < 40 { + return None; + } + let header_size = u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]); + let width = i32::from_le_bytes([dib[4], dib[5], dib[6], dib[7]]); + let height_raw = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]); + let bpp = u16::from_le_bytes([dib[14], dib[15]]); + let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]); + if width <= 0 || compression != 0 { + return None; + } + if bpp != 24 && bpp != 32 { + return None; + } + let height = height_raw.abs(); + let top_down = height_raw < 0; + let pixel_offset = header_size as usize; // 24/32bpp 无调色板 + let row_size = ((bpp as u32 * width as u32 + 31) / 32) * 4; + let bytes_per_pixel = (bpp / 8) as usize; + let needed = pixel_offset + (row_size as usize) * (height as usize); + if dib.len() < needed { + return None; + } + let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4]; + for y in 0..height { + let src_row = if top_down { y } else { height - 1 - y }; + let src_off = pixel_offset + (src_row as usize) * (row_size as usize); + for x in 0..width { + let sp = src_off + (x as usize) * bytes_per_pixel; + let dp = (y as usize) * (width as usize) * 4 + (x as usize) * 4; + rgba[dp] = dib[sp + 2]; // R + rgba[dp + 1] = dib[sp + 1]; // G + rgba[dp + 2] = dib[sp]; // B + rgba[dp + 3] = 255; // A + } + } + let mut buf = Vec::new(); + let enc = PngEncoder::new(&mut buf); + enc.write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8) + .ok()?; + Some(buf) +} diff --git a/src-tauri/src/clipboard/storage.rs b/src-tauri/src/clipboard/storage.rs new file mode 100644 index 0000000..3f4005c --- /dev/null +++ b/src-tauri/src/clipboard/storage.rs @@ -0,0 +1,375 @@ +//! SQLite 持久化:剪贴板历史记录 +//! +//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。 + +use rusqlite::{params, Connection, OptionalExtension}; +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; + +/// 列表项(不含大字段,用于历史/搜索结果) +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClipboardItem { + pub id: i64, + pub kind: String, + pub preview: String, + pub size: i64, + pub pinned: bool, + pub pinned_order: Option, + pub created_at: i64, +} + +/// 详情(含文本内容或图片 base64) +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClipboardItemDetail { + #[serde(flatten)] + pub item: ClipboardItem, + /// 文本内容 / 文件列表 JSON + pub content: Option, + /// 图片 PNG base64(仅 image 类型) + pub image_base64: Option, +} + +/// 新捕获的剪贴板数据 +pub struct NewItem { + pub kind: String, + pub content: Option, + pub blob: Option>, + pub preview: String, + pub size: i64, + pub hash: String, +} + +pub struct Storage { + conn: Mutex, +} + +impl Storage { + pub fn new(data_dir: &PathBuf) -> std::io::Result { + fs::create_dir_all(data_dir)?; + let db_path = data_dir.join("history.db"); + let conn = Connection::open(&db_path) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + conn.busy_timeout(std::time::Duration::from_secs(3)).ok(); + Self::init_db(&conn); + Ok(Self { + conn: Mutex::new(conn), + }) + } + + /// 内存数据库回退(磁盘初始化失败时使用,数据不持久化) + pub fn new_in_memory() -> Self { + let conn = Connection::open_in_memory().expect("open_in_memory"); + Self::init_db(&conn); + Self { + conn: Mutex::new(conn), + } + } + + fn init_db(conn: &Connection) { + let _ = conn.execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + content TEXT, + blob BLOB, + preview TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + hash TEXT NOT NULL DEFAULT '', + pinned INTEGER NOT NULL DEFAULT 0, + pinned_order INTEGER, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_history(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_pinned ON clipboard_history(pinned, pinned_order); + CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_history(hash); + CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_history(kind);", + ); + } + + /// 插入新条目;若 dedup 为 true 且 hash 已存在则仅更新 created_at,返回条目 id。 + /// 返回 None 表示未插入(内容为空)。 + pub fn insert_or_touch(&self, item: NewItem, dedup: bool) -> Option { + if item.preview.is_empty() && item.content.is_none() && item.blob.is_none() { + return None; + } + let conn = self.conn.lock().ok()?; + if dedup { + let existing: Option = conn + .query_row( + "SELECT id FROM clipboard_history WHERE hash = ?1", + params![item.hash], + |r| r.get(0), + ) + .optional() + .ok() + .flatten(); + if let Some(id) = existing { + // 更新时间戳到当前(置顶到列表最前) + let now = now_ms(); + let _ = conn.execute( + "UPDATE clipboard_history SET created_at = ?1 WHERE id = ?2", + params![now, id], + ); + return Some(id); + } + } + let now = now_ms(); + let res = conn.execute( + "INSERT INTO clipboard_history + (kind, content, blob, preview, size, hash, pinned, pinned_order, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)", + params![ + item.kind, + item.content, + item.blob.as_deref(), + item.preview, + item.size, + item.hash, + now, + ], + ); + if res.is_err() { + return None; + } + Some(conn.last_insert_rowid()) + } + + /// 获取非固定的历史记录(按时间倒序) + pub fn get_history(&self, limit: i64, offset: i64, kind: &str) -> Vec { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(_) => return vec![], + }; + let sql = if kind == "all" { + "SELECT id, kind, preview, size, pinned, pinned_order, created_at + FROM clipboard_history WHERE pinned = 0 + ORDER BY created_at DESC LIMIT ?1 OFFSET ?2" + } else { + "SELECT id, kind, preview, size, pinned, pinned_order, created_at + FROM clipboard_history WHERE pinned = 0 AND kind = ?1 + ORDER BY created_at DESC LIMIT ?2 OFFSET ?3" + }; + let mut stmt = match conn.prepare(sql) { + Ok(s) => s, + Err(_) => return vec![], + }; + let rows = if kind == "all" { + stmt.query_map(params![limit, offset], row_to_item) + } else { + stmt.query_map(params![kind, limit, offset], row_to_item) + }; + rows.map(|r| r.filter_map(|i| i.ok()).collect()) + .unwrap_or_default() + } + + /// 获取固定条目(按 pinned_order,未设置则按时间倒序) + pub fn get_pinned(&self) -> Vec { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(_) => return vec![], + }; + let mut stmt = match conn.prepare( + "SELECT id, kind, preview, size, pinned, pinned_order, created_at + FROM clipboard_history WHERE pinned = 1 + ORDER BY pinned_order ASC NULLS LAST, created_at DESC", + ) { + Ok(s) => s, + Err(_) => return vec![], + }; + stmt.query_map([], row_to_item) + .map(|r| r.filter_map(|i| i.ok()).collect()) + .unwrap_or_default() + } + + /// 搜索(跨固定/非固定,按时间倒序) + pub fn search(&self, query: &str, limit: i64, offset: i64) -> Vec { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(_) => return vec![], + }; + let like = format!("%{}%", query); + let mut stmt = match conn.prepare( + "SELECT id, kind, preview, size, pinned, pinned_order, created_at + FROM clipboard_history + WHERE pinned = 0 AND (preview LIKE ?1 OR content LIKE ?1) + ORDER BY created_at DESC LIMIT ?2 OFFSET ?3", + ) { + Ok(s) => s, + Err(_) => return vec![], + }; + stmt.query_map(params![like, limit, offset], row_to_item) + .map(|r| r.filter_map(|i| i.ok()).collect()) + .unwrap_or_default() + } + + /// 获取详情(含文本/图片预览 base64) + pub fn get_detail(&self, id: i64, image_to_base64: impl Fn(&[u8]) -> Option) -> Option { + let conn = self.conn.lock().ok()?; + let row = conn + .query_row( + "SELECT id, kind, preview, size, pinned, pinned_order, created_at, content, blob + FROM clipboard_history WHERE id = ?1", + params![id], + |r| { + let kind: String = r.get(1)?; + let content: Option = r.get(7)?; + let blob: Option> = r.get(8)?; + Ok(( + ClipboardItem { + id: r.get(0)?, + kind, + preview: r.get(2)?, + size: r.get(3)?, + pinned: r.get::<_, i64>(4)? != 0, + pinned_order: r.get(5)?, + created_at: r.get(6)?, + }, + content, + blob, + )) + }, + ) + .ok()?; + let (item, content, blob) = row; + let image_base64 = if item.kind == "image" { + blob.as_deref().and_then(|b| image_to_base64(b)) + } else { + None + }; + Some(ClipboardItemDetail { + item, + content, + image_base64, + }) + } + + /// 获取原始字段供 copy_back 写回(避免 base64 转换开销) + pub fn get_raw_for_copy(&self, id: i64) -> Option<(String, Option, Option>)> { + let conn = self.conn.lock().ok()?; + conn.query_row( + "SELECT kind, content, blob FROM clipboard_history WHERE id = ?1", + params![id], + |r| Ok((r.get::<_, String>(0)?, r.get(1)?, r.get(2)?)), + ) + .ok() + } + + /// 切换固定状态。设为固定时分配一个递减的 pinned_order(靠前)。 + pub fn set_pinned(&self, id: i64, pinned: bool) -> bool { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(_) => return false, + }; + if pinned { + // 取当前最小 pinned_order,再 -1 使其排到最前 + let min_order: Option = conn + .query_row("SELECT MIN(pinned_order) FROM clipboard_history WHERE pinned = 1", [], |r| r.get(0)) + .ok() + .flatten(); + let new_order = min_order.unwrap_or(0) - 1; + conn.execute( + "UPDATE clipboard_history SET pinned = 1, pinned_order = ?1 WHERE id = ?2", + params![new_order, id], + ) + .is_ok() + } else { + conn.execute( + "UPDATE clipboard_history SET pinned = 0, pinned_order = NULL WHERE id = ?1", + params![id], + ) + .is_ok() + } + } + + pub fn delete(&self, id: i64) -> bool { + self.conn + .lock() + .ok() + .map(|c| c.execute("DELETE FROM clipboard_history WHERE id = ?1", params![id]).is_ok()) + .unwrap_or(false) + } + + /// 清空所有非固定条目 + pub fn clear_non_pinned(&self) -> bool { + self.conn + .lock() + .ok() + .map(|c| c.execute("DELETE FROM clipboard_history WHERE pinned = 0", []).is_ok()) + .unwrap_or(false) + } + + /// 超过 max 时自动清理最旧的非固定条目 + pub fn prune_to_max(&self, max_items: i64) { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(_) => return, + }; + let _ = conn.execute( + "DELETE FROM clipboard_history + WHERE pinned = 0 AND id IN ( + SELECT id FROM clipboard_history + WHERE pinned = 0 + ORDER BY created_at DESC + LIMIT -1 OFFSET ?1 + )", + params![max_items], + ); + } + + pub fn count(&self) -> i64 { + self.count_kind("all") + } + + /// 按类型统计非固定条目总数(kind="all" 表示全部) + pub fn count_kind(&self, kind: &str) -> i64 { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(_) => return 0, + }; + let sql = if kind == "all" { + "SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0" + } else { + "SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND kind = ?1" + }; + if kind == "all" { + conn.query_row(sql, [], |r| r.get(0)).ok().unwrap_or(0) + } else { + conn.query_row(sql, params![kind], |r| r.get(0)).ok().unwrap_or(0) + } + } + + /// 按搜索关键词统计匹配的非固定条目总数 + pub fn count_search(&self, query: &str) -> i64 { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(_) => return 0, + }; + let pattern = format!("%{}%", query); + conn.query_row( + "SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND preview LIKE ?1", + params![pattern], + |r| r.get(0), + ) + .ok() + .unwrap_or(0) + } +} + +fn row_to_item(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(ClipboardItem { + id: r.get(0)?, + kind: r.get(1)?, + preview: r.get(2)?, + size: r.get(3)?, + pinned: r.get::<_, i64>(4)? != 0, + pinned_order: r.get(5)?, + created_at: r.get(6)?, + }) +} + +pub fn now_ms() -> i64 { + chrono::Local::now().timestamp_millis() +} diff --git a/src-tauri/src/download_engine/commands.rs b/src-tauri/src/download_engine/commands.rs index 3c6a4d3..bb0f553 100644 --- a/src-tauri/src/download_engine/commands.rs +++ b/src-tauri/src/download_engine/commands.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use tauri::{AppHandle, State}; use tauri_plugin_opener::OpenerExt; -use super::engine::DownloadEngine; +use super::engine::{CheckUrlResult, DownloadEngine}; use super::task::{DownloadTask, DownloaderSettings}; /// 获取所有任务 @@ -12,6 +12,39 @@ pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec, + url: String, + dir: Option, + headers: Option>, +) -> Result { + let headers = headers.unwrap_or_default(); + let (probe, duplicate, existing) = engine.check_url(&url, dir.as_deref(), &headers).await; + let result = match probe { + Ok(p) => CheckUrlResult { + ok: true, + error: None, + filename: p.filename.clone(), + total_size: p.total_size, + supports_resume: p.supports_resume, + duplicate, + existing, + }, + Err(e) => CheckUrlResult { + ok: false, + error: Some(e), + filename: None, + total_size: None, + supports_resume: false, + duplicate, + existing, + }, + }; + Ok(result) +} + /// 添加下载任务 #[tauri::command] pub async fn downloader_add_task( @@ -20,8 +53,9 @@ pub async fn downloader_add_task( filename: Option, dir: Option, headers: Option>, + auto_rename: Option, ) -> Result { - engine.add_task(url, filename, dir, headers.unwrap_or_default()).await + engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false)).await } /// 暂停任务 diff --git a/src-tauri/src/download_engine/engine.rs b/src-tauri/src/download_engine/engine.rs index 4df709e..14bdec6 100644 --- a/src-tauri/src/download_engine/engine.rs +++ b/src-tauri/src/download_engine/engine.rs @@ -9,7 +9,50 @@ use tauri::{AppHandle, Emitter}; use super::http_dl::{HttpDownloader, split_segments}; use super::rate_limit::RateLimiter; use super::storage::{EngineState, Storage}; -use super::task::{DownloadTask, DownloaderSettings, Segment, TaskStatus}; +use super::task::{DownloadTask, DownloaderSettings, ProbeResult, Segment, TaskStatus}; + +/// 重复类型 +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum DuplicateKind { + /// 无重复 + None, + /// URL 重复(已有相同链接的任务) + Url, + /// 文件名重复(已有同名任务下载到同一目录) + Filename, + /// 磁盘文件已存在 + FileExists, +} + +/// 已存在的任务信息(用于前端展示) +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExistingTaskInfo { + pub id: String, + pub filename: String, + pub status: TaskStatus, +} + +/// check_url 命令返回的结果 +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckUrlResult { + /// 探测是否成功 + pub ok: bool, + /// 错误信息(探测失败时) + pub error: Option, + /// 文件名(探测成功时) + pub filename: Option, + /// 文件大小(字节) + pub total_size: Option, + /// 是否支持断点续传 + pub supports_resume: bool, + /// 重复类型 + pub duplicate: DuplicateKind, + /// 已存在的任务信息 + pub existing: Option, +} /// 进度事件载荷(发给前端 download-progress 事件) #[derive(Debug, Clone, serde::Serialize)] @@ -130,13 +173,109 @@ impl DownloadEngine { // ===================== 公开 API ===================== + /// 检查 URL 重复性并探测文件信息。 + /// 返回 (探测结果, 重复类型, 已存在任务信息)。 + pub async fn check_url( + &self, + url: &str, + dir: Option<&str>, + headers: &HashMap, + ) -> (Result, DuplicateKind, Option) { + let probe = self.inner.http.probe(url, headers).await; + let settings = self.inner.settings.lock().unwrap().clone(); + let task_dir = dir.map(|d| d.to_string()).unwrap_or_else(|| settings.download_dir.clone()); + + let filename = probe.as_ref().ok() + .and_then(|p| p.filename.clone()) + .unwrap_or_else(|| { + url.split('?').next() + .and_then(|u| u.rsplit('/').next()) + .filter(|n| !n.is_empty()) + .map(|n| n.to_string()) + .unwrap_or_else(|| format!("download_{}", chrono::Utc::now().timestamp())) + }); + + let mut duplicate = DuplicateKind::None; + let mut existing: Option = None; + + { + let tasks = self.inner.tasks.lock().unwrap(); + for t in tasks.values() { + // URL 完全相同 + if t.url == url { + duplicate = DuplicateKind::Url; + existing = Some(ExistingTaskInfo { + id: t.id.clone(), + filename: t.filename.clone(), + status: t.status.clone(), + }); + break; + } + // 目标文件名 + 目录相同(可能 URL 不同但下载到同一文件) + if t.filename == filename && t.dir == task_dir { + duplicate = DuplicateKind::Filename; + existing = Some(ExistingTaskInfo { + id: t.id.clone(), + filename: t.filename.clone(), + status: t.status.clone(), + }); + break; + } + } + } + + // 检查磁盘文件是否已存在(仅探测成功时) + if duplicate == DuplicateKind::None { + if let Ok(ref _p) = probe { + let filepath = PathBuf::from(&task_dir).join(&filename); + if filepath.exists() { + duplicate = DuplicateKind::FileExists; + existing = Some(ExistingTaskInfo { + id: String::new(), + filename, + status: TaskStatus::Complete, + }); + } + } + } + + (probe, duplicate, existing) + } + + /// 生成不冲突的文件名(同名时追加 (1)、(2)...) + fn generate_unique_filename(&self, dir: &str, filename: &str) -> String { + let path = PathBuf::from(dir).join(filename); + if !path.exists() { + return filename.to_string(); + } + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("download"); + let ext = path.extension().and_then(|s| s.to_str()); + for i in 1..1000 { + let new_name = match ext { + Some(e) => format!("{} ({}).{}", stem, i, e), + None => format!("{} ({})", stem, i), + }; + let new_path = PathBuf::from(dir).join(&new_name); + if !new_path.exists() { + return new_name; + } + } + // 极端情况:追加时间戳 + match ext { + Some(e) => format!("{} ({}).{}", stem, chrono::Utc::now().timestamp_millis(), e), + None => format!("{} ({})", stem, chrono::Utc::now().timestamp_millis()), + } + } + /// 添加下载任务 + /// auto_rename: 同名时自动重命名(追加 (1)、(2)...),否则覆盖 pub async fn add_task( &self, url: String, filename: Option, dir: Option, headers: HashMap, + auto_rename: bool, ) -> Result { // 探测资源信息 let probe = self.inner.http.probe(&url, &headers).await; @@ -145,7 +284,7 @@ impl DownloadEngine { let task_dir = dir.unwrap_or_else(|| settings.download_dir.clone()); // 确定文件名 - let task_filename = filename + let mut task_filename = filename .or_else(|| probe.as_ref().ok().and_then(|p| p.filename.clone())) .unwrap_or_else(|| { url.split('?') @@ -156,6 +295,11 @@ impl DownloadEngine { .unwrap_or_else(|| format!("download_{}", chrono::Utc::now().timestamp())) }); + // 自动重命名:若磁盘已存在同名文件,追加 (1)、(2)... + if auto_rename { + task_filename = self.generate_unique_filename(&task_dir, &task_filename); + } + let id = self.inner.storage.next_task_id(); // 创建分段(受 continue_download 设置控制:关闭时强制单线程、不支持续传) diff --git a/src-tauri/src/download_engine/mod.rs b/src-tauri/src/download_engine/mod.rs index 97efa9c..8733bb1 100644 --- a/src-tauri/src/download_engine/mod.rs +++ b/src-tauri/src/download_engine/mod.rs @@ -7,7 +7,7 @@ pub mod storage; pub mod task; pub use commands::{ - downloader_add_task, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, + downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task, downloader_resume_task, downloader_save_settings, downloader_status, }; diff --git a/src-tauri/src/download_engine/server.rs b/src-tauri/src/download_engine/server.rs index f2305b3..d070e34 100644 --- a/src-tauri/src/download_engine/server.rs +++ b/src-tauri/src/download_engine/server.rs @@ -108,7 +108,7 @@ async fn create_download( return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() }))); } - match state.engine.add_task(req.url, req.filename, req.dir, req.headers).await { + match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await { Ok(id) => Ok(Json(CreateDownloadResponse { id })), Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))), } diff --git a/src-tauri/src/download_engine/task.rs b/src-tauri/src/download_engine/task.rs index 68e8ba1..2a5b385 100644 --- a/src-tauri/src/download_engine/task.rs +++ b/src-tauri/src/download_engine/task.rs @@ -125,6 +125,9 @@ pub struct DownloaderSettings { /// 删除任务时是否同时删除已下载的文件 #[serde(default)] pub delete_files_on_remove: bool, + /// 添加下载前检查重复(URL 或文件名重复时询问) + #[serde(default = "default_true")] + pub check_duplicate: bool, } fn default_max_concurrent() -> u32 { @@ -162,11 +165,13 @@ impl Default for DownloaderSettings { extension_port: default_extension_port(), extension_secret: String::new(), delete_files_on_remove: false, + check_duplicate: true, } } } /// HEAD/Range 探测结果 +#[derive(Debug, Clone)] pub struct ProbeResult { /// 文件大小(字节),None=未知 pub total_size: Option, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 049ced3..f63a9c7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use tauri::Manager; +mod clipboard; mod download_engine; mod logger; mod mihomo_manager; @@ -8,10 +9,12 @@ mod monitor_kernel; mod network_monitor; mod osd_window; mod process_manager; +mod snap_fix; +mod tray_menu; use download_engine::{ DownloadEngine, ExtensionServer, - downloader_add_task, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, + downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task, downloader_resume_task, downloader_save_settings, downloader_status, }; @@ -40,6 +43,14 @@ use process_manager::{ get_all_process_status, get_process_status, start_monitoring_thread, start_process, stop_all_processes, stop_process, ProcessManager, }; +use clipboard::{ + ClipboardManager, + clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history, + clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup, + clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search, + clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start, + clipboard_status, clipboard_stop, clipboard_unregister_shortcut, +}; #[tauri::command] fn greet(name: &str) -> String { @@ -52,6 +63,7 @@ fn quit_app( mihomo: tauri::State<'_, MihomoManager>, engine: tauri::State<'_, DownloadEngine>, monitor: tauri::State<'_, MonitorKernel>, + clipboard: tauri::State<'_, ClipboardManager>, app: tauri::AppHandle, ) { // 退出前清理系统代理,避免遗留导致网络问题 @@ -60,6 +72,8 @@ fn quit_app( engine.cleanup_on_exit(); // 停止提权 Kernel(普通权限由 stop_all 统一清理,提权 Kernel 需通过 /shutdown) tauri::async_runtime::block_on(monitor.cleanup_on_exit(&app)); + // 停止剪贴板监听线程 + clipboard.stop(); // 停止所有子进程(同步 kill + 带超时的 wait,确保进程真正终止) state.stop_all(); // 通过 app.exit 触发 RunEvent::ExitRequested,统一退出路径 @@ -72,6 +86,7 @@ pub fn run() { .plugin(tauri_plugin_autostart::Builder::new().build()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin( tauri_plugin_snap_layout::init() .button_id("titlebar-maximize") @@ -136,6 +151,7 @@ pub fn run() { osd_stop_watch, downloader_get_tasks, downloader_add_task, + downloader_check_url, downloader_pause_task, downloader_resume_task, downloader_remove_task, @@ -144,7 +160,28 @@ pub fn run() { downloader_status, downloader_get_extension_info, downloader_open_dir, - downloader_open_url + downloader_open_url, + clipboard_get_history, + clipboard_get_pinned, + clipboard_search, + clipboard_get_item, + clipboard_set_pinned, + clipboard_delete, + clipboard_clear, + clipboard_copy_back, + clipboard_count, + clipboard_get_settings, + clipboard_save_settings, + clipboard_status, + clipboard_start, + clipboard_stop, + clipboard_register_shortcut, + clipboard_unregister_shortcut, + clipboard_show_popup, + clipboard_show_window, + clipboard_hide_popup, + clipboard_paste_to_target, + snap_fix::fix_snap_background ]) .setup(|app| { // 初始化日志系统,日志目录: {app_data_dir}/logs/ @@ -196,52 +233,24 @@ pub fn run() { ExtensionServer::start(server_engine, server_port, server_secret).await; }); - let open = tauri::menu::MenuItem::with_id(app, "open", "设置", true, None::<&str>)?; - let quit = tauri::menu::MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; - let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?; + // 初始化 ClipboardManager,数据目录: {app_data_dir}/clipboard/ + let clipboard = ClipboardManager::new(app_data_dir.clone()); + // 应用启动时若已启用则自动开始监听 + if clipboard.get_settings().enabled { + clipboard.start(&app.handle()); + } + // 应用启动时注册快捷弹窗全局快捷键 + let shortcut = clipboard.get_settings().shortcut.clone(); + if !shortcut.trim().is_empty() { + let app_handle = app.handle().clone(); + if let Err(e) = clipboard::popup::register_shortcut(&app_handle, &shortcut) { + eprintln!("[clipboard] 快捷键注册失败: {}", e); + } + } + app.manage(clipboard); - let _tray = tauri::tray::TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) - .menu(&menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id().as_ref() { - "open" => { - if let Some(window) = app.get_webview_window("main") { - window.show().ok(); - window.set_focus().ok(); - } - } - "quit" => { - if let Some(mihomo) = app.try_state::() { - mihomo.cleanup_on_exit(); - } - if let Some(engine) = app.try_state::() { - engine.cleanup_on_exit(); - } - if let Some(monitor) = app.try_state::() { - tauri::async_runtime::block_on(monitor.cleanup_on_exit(app)); - } - if let Some(pm) = app.try_state::() { - pm.stop_all(); - } - app.exit(0); - } - _ => {} - }) - .on_tray_icon_event(|tray, event| { - if let tauri::tray::TrayIconEvent::Click { - button: tauri::tray::MouseButton::Left, - .. - } = event - { - let app = tray.app_handle(); - if let Some(window) = app.get_webview_window("main") { - window.show().ok(); - window.set_focus().ok(); - } - } - }) - .build(app)?; + // 自定义托盘菜单(代理/OSD/Kernel/下载/设置/退出) + tray_menu::create_tray_menu(app.handle())?; // 启动进程监控线程 start_monitoring_thread(app.handle().clone()); @@ -286,6 +295,9 @@ pub fn run() { if let Some(monitor) = app.try_state::() { tauri::async_runtime::block_on(monitor.cleanup_on_exit(app)); } + if let Some(clip) = app.try_state::() { + clip.stop(); + } if let Some(pm) = app.try_state::() { pm.stop_all(); } diff --git a/src-tauri/src/mihomo_manager.rs b/src-tauri/src/mihomo_manager.rs index 5e48b4a..df7ea27 100644 --- a/src-tauri/src/mihomo_manager.rs +++ b/src-tauri/src/mihomo_manager.rs @@ -825,6 +825,24 @@ impl MihomoManager { } } + /// 开启系统代理(托盘菜单调用) + pub fn enable_system_proxy(&self) -> Result<(), String> { + let settings = self.load_settings(); + let addr = format!("127.0.0.1:{}", settings.mixed_port); + set_system_proxy_windows(&addr)?; + let mut s = settings; + s.system_proxy = true; + self.save_settings(&s) + } + + /// 关闭系统代理(托盘菜单调用) + pub fn disable_system_proxy(&self) -> Result<(), String> { + clear_system_proxy_windows()?; + let mut s = self.load_settings(); + s.system_proxy = false; + self.save_settings(&s) + } + // ---------- 订阅管理 ---------- pub async fn import_profile(&self, url: &str, name: &str) -> Result { // 先读取当前 settings(此时新 profile 文件还未写入,reconcile 不会误添加) diff --git a/src-tauri/src/snap_fix.rs b/src-tauri/src/snap_fix.rs new file mode 100644 index 0000000..67b0590 --- /dev/null +++ b/src-tauri/src/snap_fix.rs @@ -0,0 +1,92 @@ +//! 修复 tauri-plugin-snap-layout 子窗口的白色背景问题。 +//! +//! snap-layout 插件创建的原生 Win32 STATIC 子窗口默认有白色背景, +//! 切换主题/效果时 clearEffects/setEffects 触发窗口重绘, +//! 导致子窗口白色背景显现,遮挡按钮样式。 +//! +//! 本模块通过子类化拦截 WM_ERASEBKGND 和 WM_PAINT,阻止白色背景绘制。 +//! 幂等:可多次调用,SetWindowSubclass 重复调用会替换旧实例。 + +use raw_window_handle::{HasWindowHandle, RawWindowHandle}; +use tauri::{AppHandle, Manager}; +use windows_sys::Win32::{ + Foundation::{HWND, LPARAM, LRESULT, WPARAM}, + Graphics::Gdi::{BeginPaint, EndPaint, PAINTSTRUCT}, + UI::{ + Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass}, + WindowsAndMessaging::{GetPropW, IsWindow, WM_ERASEBKGND, WM_NCDESTROY, WM_PAINT, WM_PRINTCLIENT}, + }, +}; + +/// snap-layout 在父窗口上存储子窗口 HWND 的属性名(与 snap.rs 中 CHILD_PROP 一致) +const CHILD_PROP: &[u16] = &[ + b'T' as u16, b'a' as u16, b'u' as u16, b'r' as u16, b'i' as u16, + b'S' as u16, b'n' as u16, b'a' as u16, b'p' as u16, b'C' as u16, + b'h' as u16, b'i' as u16, b'l' as u16, b'd' as u16, 0, +]; + +/// 子类化 ID(与 snap-layout 的 SUBCLASS_ID 不同) +const FIX_SUBCLASS_ID: usize = 0x736E_6170_6669_7800; + +/// 子类化过程:拦截背景绘制相关消息 +/// - WM_ERASEBKGND: 返回 1,阻止 DefWindowProcW 用白色画刷填充背景 +/// - WM_PAINT: 调用 BeginPaint/EndPaint 但不绘制任何内容,阻止 DefWindowProcW 绘制白色背景 +/// - WM_PRINTCLIENT: 返回 0,阻止打印白色背景 +unsafe extern "system" fn fix_subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + _subclass_id: usize, + _ref_data: usize, +) -> LRESULT { + match msg { + WM_ERASEBKGND => return 1, + WM_PAINT => { + // 调用 BeginPaint/EndPaint 清除 WM_PAINT 消息,但不绘制任何内容 + let mut ps: PAINTSTRUCT = std::mem::zeroed(); + BeginPaint(hwnd, &mut ps); + EndPaint(hwnd, &ps); + return 0; + } + WM_PRINTCLIENT => return 0, + WM_NCDESTROY => { + RemoveWindowSubclass(hwnd, Some(fix_subclass_proc), FIX_SUBCLASS_ID); + } + _ => {} + } + DefSubclassProc(hwnd, msg, wparam, lparam) +} + +/// 查找主窗口的 snap-layout 子窗口并做子类化修复。 +/// 幂等:可多次调用,重复子类化无副作用。 +#[tauri::command] +pub async fn fix_snap_background(app: AppHandle) -> Result<(), String> { + let main_window = app + .get_webview_window("main") + .ok_or("main window not found")?; + + let hwnd_isize: isize = { + let handle = main_window.window_handle().map_err(|e| e.to_string())?; + match handle.as_raw() { + RawWindowHandle::Win32(h) => h.hwnd.get() as isize, + _ => return Err("not win32".to_string()), + } + }; + + main_window + .run_on_main_thread(move || unsafe { + let parent = hwnd_isize as HWND; + if IsWindow(parent) == 0 { + return; + } + // snap-layout 把子窗口 HWND 存在父窗口的 "TauriSnapChild" 属性中 + let child = GetPropW(parent, CHILD_PROP.as_ptr()) as HWND; + if child != 0 && IsWindow(child) != 0 { + SetWindowSubclass(child, Some(fix_subclass_proc), FIX_SUBCLASS_ID, 0); + } + }) + .map_err(|e| e.to_string())?; + + Ok(()) +} diff --git a/src-tauri/src/tray_menu.rs b/src-tauri/src/tray_menu.rs new file mode 100644 index 0000000..e607cb3 --- /dev/null +++ b/src-tauri/src/tray_menu.rs @@ -0,0 +1,738 @@ +//! tray_menu.rs — 自定义系统托盘右键菜单 +//! +//! 菜单结构: +//! 代理 ▸ +//! 开启代理 (启动mihomo + 择优选节点 + 系统代理 + Windows通知) +//! 关闭代理 +//! --- +//! 选择代理 ▸ +//! 刷新节点列表 +//! --- +//! 节点1 (120ms) +//! 节点2 (超时) +//! ... +//! --- +//! 切换 OSD +//! 重启 Kernel +//! --- +//! 新建下载 +//! --- +//! 常规设置 +//! 退出 + +use std::sync::{Mutex, OnceLock}; + +use tauri::{ + AppHandle, Emitter, Manager, + menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}, + tray::{MouseButton, TrayIconBuilder, TrayIconEvent, TrayIconId}, +}; + +use crate::clipboard::ClipboardManager; +use crate::download_engine::DownloadEngine; +use crate::mihomo_manager::MihomoManager; +use crate::monitor_kernel::MonitorKernel; +use crate::process_manager::{ProcessManager, ProcessStatus}; + +// ===== 全局状态:代理节点菜单项映射 ===== + +/// menu_id → (group, node_name) +static PROXY_NODE_MAP: OnceLock>> = OnceLock::new(); + +fn proxy_node_map() -> &'static Mutex> { + PROXY_NODE_MAP.get_or_init(|| Mutex::new(Vec::new())) +} + +// ===== 伪节点过滤(与前端 ProxyModule 保持一致) ===== + +const PSEUDO_KEYWORDS: &[&str] = &[ + "DIRECT", + "REJECT", + "PASS", + "COMPATIBLE", + "流量", + "套餐", + "到期", + "续费", + "官网", + "网站", + "刷新", + "更新", + "⭐", + "★", + "☆", + "✕", + "✖", + "×", +]; + +fn is_pseudo_node(name: &str) -> bool { + let upper = name.trim().to_uppercase(); + if upper == "DIRECT" || upper == "REJECT" || upper == "PASS" || upper == "GLOBAL" { + return true; + } + PSEUDO_KEYWORDS.iter().any(|kw| name.contains(kw)) +} + +// ===== 状态查询 ===== + +fn is_proxy_running(app: &AppHandle) -> bool { + if let Some(pm) = app.try_state::() { + if let Some(info) = pm.get_status("proxy") { + return matches!(info.status, ProcessStatus::Running); + } + } + false +} + +fn is_monitor_running(app: &AppHandle) -> bool { + if let Some(pm) = app.try_state::() { + if let Some(info) = pm.get_status("monitor") { + if matches!(info.status, ProcessStatus::Running) { + return true; + } + } + } + if let Some(monitor) = app.try_state::() { + if monitor.is_elevated() { + return true; + } + } + false +} + +// ===== 从 mihomo /proxies 响应中提取主选择组及其节点 ===== + +/// 返回 (组名, 有效节点列表, 当前节点) +fn parse_main_group(proxies_response: &serde_json::Value) -> Option<(String, Vec, String)> { + let proxies = proxies_response.get("proxies")?.as_object()?; + + // 收集所有可选择组 + let selector_groups: Vec<&str> = proxies + .iter() + .filter(|(_, v)| { + v.get("type").and_then(|t| t.as_str()).map(|t| t == "Selector").unwrap_or(false) + }) + .map(|(k, _)| k.as_str()) + .collect(); + + if selector_groups.is_empty() { + return None; + } + + // 优先选择名为 PROXY / Proxy / 节点选择 / 代理 的组 + let preferred = selector_groups + .iter() + .find(|name| matches!(**name, "PROXY" | "Proxy" | "节点选择" | "代理")) + .copied(); + let group_name = preferred.unwrap_or(selector_groups[0]); + let group = &proxies[group_name]; + + let now = group.get("now").and_then(|n| n.as_str()).unwrap_or("").to_string(); + let all: Vec = group + .get("all") + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .filter(|n| !is_pseudo_node(n)) + .collect() + }) + .unwrap_or_default(); + + Some((group_name.to_string(), all, now)) +} + +/// 从 /proxies 响应中读取某节点最近一次延迟 +fn parse_node_delay(proxies_response: &serde_json::Value, name: &str) -> Option { + proxies_response + .get("proxies") + .and_then(|p| p.get(name)) + .and_then(|n| n.get("history")) + .and_then(|h| h.as_array()) + .and_then(|arr| arr.first()) + .and_then(|first| first.get("delay")) + .and_then(|d| d.as_u64()) + .map(|d| d as u32) +} + +// ===== 菜单构建 ===== + +/// 获取代理节点数据(异步,调用 mihomo API) +async fn fetch_proxy_nodes(app: &AppHandle) -> Option<(String, Vec<(String, Option)>, String)> { + let mihomo = app.state::(); + let proxies = mihomo.get_proxies().await.ok()?; + let (group, nodes, now) = parse_main_group(&proxies)?; + let node_delays: Vec<(String, Option)> = nodes + .iter() + .map(|name| (name.clone(), parse_node_delay(&proxies, name))) + .collect(); + Some((group, node_delays, now)) +} + +fn build_menu( + app: &AppHandle, + proxy_running: bool, + monitor_running: bool, + proxy_nodes: Option<(String, Vec<(String, Option)>, String)>, +) -> Result, tauri::Error> { + // ---------- 代理子菜单 ---------- + let enable_proxy = MenuItem::with_id( + app, + "proxy_enable", + "开启代理", + !proxy_running, + None::<&str>, + )?; + let disable_proxy = MenuItem::with_id( + app, + "proxy_disable", + "关闭代理", + proxy_running, + None::<&str>, + )?; + let proxy_sep = PredefinedMenuItem::separator(app)?; + + // 选择代理子菜单 + let (node_menu_items, node_sep) = build_proxy_select_items(app, proxy_running, &proxy_nodes)?; + + // "刷新节点列表"项(仅代理运行时创建,提前声明以保证引用生命周期) + let refresh = if proxy_running { + Some(MenuItem::with_id(app, "proxy_refresh", "🔄 刷新节点列表", true, None::<&str>)?) + } else { + None + }; + + // 组装选择代理子菜单 + let mut select_refs: Vec<&dyn IsMenuItem> = Vec::new(); + if !proxy_running { + // 只有一个 hint 项 + for item in &node_menu_items { + select_refs.push(item); + } + } else { + if let Some(ref r) = refresh { + select_refs.push(r); + } + if let Some(ref sep) = node_sep { + select_refs.push(sep); + } + for item in &node_menu_items { + select_refs.push(item); + } + } + let select_proxy = Submenu::with_items(app, "选择代理", proxy_running, &select_refs)?; + + // 组装代理子菜单 + let proxy_refs: Vec<&dyn IsMenuItem> = vec![&enable_proxy, &disable_proxy, &proxy_sep, &select_proxy]; + let proxy_submenu = Submenu::with_items(app, "代理", true, &proxy_refs)?; + + // ---------- 分隔线 ---------- + let sep1 = PredefinedMenuItem::separator(app)?; + + // ---------- OSD + Kernel ---------- + let osd_toggle = MenuItem::with_id(app, "osd_toggle", "切换 OSD", true, None::<&str>)?; + let kernel_restart = MenuItem::with_id( + app, + "kernel_restart", + "重启 Kernel", + monitor_running, + None::<&str>, + )?; + + // ---------- 分隔线 ---------- + let sep2 = PredefinedMenuItem::separator(app)?; + + // ---------- 新建下载 ---------- + let download_new = MenuItem::with_id(app, "download_new", "新建下载", true, None::<&str>)?; + + // ---------- 分隔线 ---------- + let sep3 = PredefinedMenuItem::separator(app)?; + + // ---------- 系统菜单 ---------- + let settings = MenuItem::with_id(app, "settings", "常规设置", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + + // ---------- 组装主菜单 ---------- + let main_refs: Vec<&dyn IsMenuItem> = vec![ + &proxy_submenu, + &sep1, + &osd_toggle, + &kernel_restart, + &sep2, + &download_new, + &sep3, + &settings, + &quit, + ]; + Menu::with_items(app, &main_refs) +} + +/// 构建代理节点菜单项(返回 MenuItem 列表 + 可选分隔线) +fn build_proxy_select_items( + app: &AppHandle, + proxy_running: bool, + proxy_nodes: &Option<(String, Vec<(String, Option)>, String)>, +) -> Result<(Vec>, Option>), tauri::Error> { + if !proxy_running { + let hint = MenuItem::with_id(app, "proxy_noop", "代理未运行", false, None::<&str>)?; + return Ok((vec![hint], None)); + } + + let (group, nodes, now) = match proxy_nodes { + Some(data) => data, + None => { + let hint = MenuItem::with_id(app, "proxy_loading", "加载中…", false, None::<&str>)?; + return Ok((vec![hint], None)); + } + }; + + if nodes.is_empty() { + let hint = MenuItem::with_id(app, "proxy_noop", "无可用节点", false, None::<&str>)?; + return Ok((vec![hint], None)); + } + + let sep = PredefinedMenuItem::separator(app)?; + + // 清空并重建全局映射 + { + let mut map = proxy_node_map().lock().unwrap(); + map.clear(); + } + + let mut items: Vec> = Vec::new(); + + // 限制节点数量避免菜单过长 + for (idx, (name, delay)) in nodes.iter().take(30).enumerate() { + let delay_text = match delay { + Some(0) => "(超时)".to_string(), + Some(d) => format!("({}ms)", d), + None => "(未测试)".to_string(), + }; + let is_current = name == now; + let prefix = if is_current { "✓ " } else { " " }; + let text = format!("{}{} {}", prefix, name, delay_text); + + let menu_id = format!("proxy_node_{}", idx); + let item = MenuItem::with_id(app, &menu_id, &text, true, None::<&str>)?; + + // 记录映射 + { + let mut map = proxy_node_map().lock().unwrap(); + map.push((group.clone(), name.clone())); + } + + items.push(item); + } + + Ok((items, Some(sep))) +} + +// ===== 异步菜单重建 ===== + +async fn rebuild_menu(app: &AppHandle) { + let proxy_running = is_proxy_running(app); + let monitor_running = is_monitor_running(app); + let proxy_nodes = if proxy_running { + fetch_proxy_nodes(app).await + } else { + None + }; + + if let Ok(menu) = build_menu(app, proxy_running, monitor_running, proxy_nodes) { + if let Some(tray) = app.tray_by_id(&TrayIconId::new("main")) { + let _ = tray.set_menu(Some(menu)); + } + } +} + +// ===== 菜单事件处理 ===== + +fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { + let id = event.id().as_ref().to_string(); + match id.as_str() { + "proxy_enable" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = enable_proxy(&app).await { + eprintln!("[tray] 开启代理失败: {}", e); + send_notification("代理启动失败", &e); + } + }); + } + "proxy_disable" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = disable_proxy(&app).await { + eprintln!("[tray] 关闭代理失败: {}", e); + send_notification("代理关闭失败", &e); + } + }); + } + "proxy_refresh" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + // 先重建菜单(保留旧数据),然后并行测试延迟并再次重建 + rebuild_menu(&app).await; + test_all_delays_and_rebuild(&app).await; + }); + } + "osd_toggle" => { + let _ = app.emit("tray:toggle-osd", ()); + } + "kernel_restart" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = restart_kernel(&app).await { + eprintln!("[tray] 重启 Kernel 失败: {}", e); + send_notification("Kernel 重启失败", &e); + } + }); + } + "download_new" => { + if let Some(window) = app.get_webview_window("main") { + window.show().ok(); + window.set_focus().ok(); + } + let _ = app.emit("tray:new-download", ()); + } + "settings" => { + if let Some(window) = app.get_webview_window("main") { + window.show().ok(); + window.set_focus().ok(); + } + let _ = app.emit("tray:open-settings", ()); + } + "quit" => { + quit_cleanup(app); + } + id_str if id_str.starts_with("proxy_node_") => { + let idx: usize = id_str + .strip_prefix("proxy_node_") + .and_then(|s| s.parse().ok()) + .unwrap_or(usize::MAX); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some((group, name)) = group_name_from_map(idx) { + select_proxy_node(&app, &group, &name).await; + } else { + eprintln!("[tray] 节点索引无效: {}", idx); + } + }); + } + _ => {} + } +} + +/// 从全局映射中获取 (group, name) +fn group_name_from_map(idx: usize) -> Option<(String, String)> { + let map = proxy_node_map().lock().unwrap(); + map.get(idx).cloned() +} + +/// 选择代理节点并测试延迟 +async fn select_proxy_node(app: &AppHandle, group: &str, name: &str) { + let mihomo = app.state::(); + match mihomo.select_proxy(group, name).await { + Ok(()) => { + // 测试延迟 + let delay = mihomo + .test_delay(name, "https://www.gstatic.com/generate_204", 5000) + .await + .ok(); + let delay_text = match delay { + Some(0) => "超时".to_string(), + Some(d) => format!("{}ms", d), + None => "未测速".to_string(), + }; + send_notification("节点已切换", &format!("{}\n延迟: {}", name, delay_text)); + // 重建菜单 + rebuild_menu(app).await; + } + Err(e) => { + eprintln!("[tray] 切换节点失败: {}", e); + send_notification("切换节点失败", &e); + } + } +} + +// ===== 代理开启/关闭 ===== + +async fn enable_proxy(app: &AppHandle) -> Result<(), String> { + let mihomo = app.state::(); + let pm = app.state::(); + + // 1. 启动 mihomo(如果未运行) + if !is_proxy_running(app) { + let params = mihomo.prepare_for_start(app)?; + pm.start(params)?; + // 等待 API 就绪 + if !wait_for_mihomo_api(&mihomo, 10_000).await { + return Err("mihomo API 启动超时".into()); + } + } + + // 2. 获取节点列表 + let proxies = mihomo.get_proxies().await?; + let (group, nodes, _now) = parse_main_group(&proxies) + .ok_or_else(|| "无法解析代理组".to_string())?; + + if !nodes.is_empty() { + // 3. 并行测试所有节点延迟 + let best = test_and_select_best(&mihomo, &group, &nodes).await; + + // 4. 发送通知 + match &best { + Some((name, delay)) => { + send_notification( + "代理已开启", + &format!("当前节点: {} ({}ms)", name, delay), + ); + } + None => { + send_notification("代理已开启", "所有节点均超时,未自动选择"); + } + } + } else { + send_notification("代理已开启", "无可用节点"); + } + + // 5. 开启系统代理 + mihomo.enable_system_proxy()?; + + // 6. 重建菜单 + rebuild_menu(app).await; + + Ok(()) +} + +async fn disable_proxy(app: &AppHandle) -> Result<(), String> { + let mihomo = app.state::(); + let pm = app.state::(); + + // 关闭系统代理 + mihomo.disable_system_proxy()?; + + // 停止 mihomo + pm.stop("proxy")?; + + send_notification("代理已关闭", "mihomo 已停止,系统代理已清除"); + + // 重建菜单 + rebuild_menu(app).await; + + Ok(()) +} + +// ===== 择优选择节点 ===== + +/// 并行测试所有节点延迟,选择最低延迟节点 +async fn test_and_select_best( + mihomo: &MihomoManager, + group: &str, + nodes: &[String], +) -> Option<(String, u32)> { + use futures_util::future::join_all; + + let futures: Vec<_> = nodes + .iter() + .map(|name| async move { + let delay = mihomo + .test_delay(name, "https://www.gstatic.com/generate_204", 5000) + .await + .ok(); + (name.clone(), delay) + }) + .collect(); + + let results = join_all(futures).await; + + // 找出有效延迟中最低的 + let best = results + .into_iter() + .filter_map(|(name, delay)| delay.map(|d| (name, d))) + .filter(|(_, d)| *d > 0) + .min_by_key(|(_, d)| *d); + + // 选择最优节点 + if let Some((ref name, _)) = best { + let _ = mihomo.select_proxy(group, name).await; + } + + best +} + +/// 测试所有节点延迟后重建菜单 +async fn test_all_delays_and_rebuild(app: &AppHandle) { + let mihomo = app.state::(); + let proxies = match mihomo.get_proxies().await { + Ok(p) => p, + Err(_) => { + return; + } + }; + let (_group, nodes, _) = match parse_main_group(&proxies) { + Some(g) => g, + None => return, + }; + + // 并行测试延迟(更新 mihomo 内部 history) + use futures_util::future::join_all; + let futures: Vec<_> = nodes + .iter() + .map(|name| async { + let _ = mihomo + .test_delay(name, "https://www.gstatic.com/generate_204", 5000) + .await; + }) + .collect(); + join_all(futures).await; + + // 再次获取 proxies(此时 history 已更新)并重建菜单 + rebuild_menu(app).await; +} + +// ===== 等待 mihomo API 就绪 ===== + +async fn wait_for_mihomo_api(mihomo: &MihomoManager, timeout_ms: u64) -> bool { + let start = std::time::Instant::now(); + while start.elapsed().as_millis() < timeout_ms as u128 { + if mihomo.get_version().await.is_ok() { + return true; + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + false +} + +// ===== 重启 Kernel ===== + +async fn restart_kernel(app: &AppHandle) -> Result<(), String> { + let monitor = app.state::(); + let pm = app.state::(); + + // 停止当前 Kernel + monitor.stop_subscription(app).await; + let _ = pm.stop("monitor"); + + // 等待端口释放 + tokio::time::sleep(std::time::Duration::from_millis(800)).await; + + // 重新启动 + monitor.start_with_subscription(app).await?; + + send_notification("Kernel 已重启", "ThingHK 监控内核已重新启动"); + rebuild_menu(app).await; + + Ok(()) +} + +// ===== Windows 通知 ===== + +#[cfg(windows)] +fn send_notification(title: &str, message: &str) { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + + // 转义 PowerShell 单引号 + let title_escaped = title.replace('\'', "''"); + let msg_escaped = message.replace('\'', "''").replace('\n', "`n"); + + let script = format!( + "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')|Out-Null;\ + $n=New-Object System.Windows.Forms.NotifyIcon;\ + $n.Icon=[System.Drawing.SystemIcons]::Information;\ + $n.Visible=$true;\ + $n.BalloonTipTitle='{}';\ + $n.BalloonTipText='{}';\ + $n.ShowBalloonTip(5000);\ + Start-Sleep -Seconds 6;\ + $n.Dispose()", + title_escaped, msg_escaped + ); + + let _ = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + &script, + ]) + .creation_flags(CREATE_NO_WINDOW) + .spawn(); +} + +#[cfg(not(windows))] +fn send_notification(_title: &str, _message: &str) { + // 非 Windows 平台不显示通知 +} + +// ===== 创建托盘 ===== + +/// 创建自定义托盘菜单(在 setup 中调用) +pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> { + let proxy_running = is_proxy_running(app); + let monitor_running = is_monitor_running(app); + + // 初始菜单(不含代理节点,异步获取后替换) + let menu = build_menu(app, proxy_running, monitor_running, None)?; + + let _tray = TrayIconBuilder::with_id("main") + .icon(app.default_window_icon().unwrap().clone()) + .tooltip("Thing") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(handle_menu_event) + .on_tray_icon_event(|tray, event| { + let app = tray.app_handle().clone(); + match event { + TrayIconEvent::Click { + button: MouseButton::Left, + .. + } => { + if let Some(window) = app.get_webview_window("main") { + window.show().ok(); + window.set_focus().ok(); + } + } + TrayIconEvent::Enter { .. } => { + // 鼠标进入托盘区域时预刷新菜单 + tauri::async_runtime::spawn(async move { + rebuild_menu(&app).await; + }); + } + _ => {} + } + }) + .build(app)?; + + // 异步获取代理节点并重建菜单 + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + rebuild_menu(&app_clone).await; + }); + + Ok(()) +} + +/// 退出应用时调用(与 quit_app 命令共享清理逻辑) +pub fn quit_cleanup(app: &AppHandle) { + if let Some(mihomo) = app.try_state::() { + mihomo.cleanup_on_exit(); + } + if let Some(engine) = app.try_state::() { + engine.cleanup_on_exit(); + } + if let Some(monitor) = app.try_state::() { + tauri::async_runtime::block_on(monitor.cleanup_on_exit(app)); + } + if let Some(clip) = app.try_state::() { + clip.stop(); + } + if let Some(pm) = app.try_state::() { + pm.stop_all(); + } + app.exit(0); +} diff --git a/src/App.vue b/src/App.vue index 2bdfade..6d4ae0a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,6 @@ @@ -136,5 +180,10 @@ onMounted(async () => { + +
+ +
diff --git a/src/components/layout/TitleBar.vue b/src/components/layout/TitleBar.vue index 011162a..5c8e098 100644 --- a/src/components/layout/TitleBar.vue +++ b/src/components/layout/TitleBar.vue @@ -2,6 +2,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { Search, Settings, ChevronRight } from '@lucide/vue' import { Input } from '@/components/ui/input' +import { invoke } from '@tauri-apps/api/core' import { getCurrentWindow } from '@tauri-apps/api/window' import { useSearchStore, type SearchItem } from '@/stores/searchStore' import { useModuleTabsStore } from '@/stores/moduleTabsStore' @@ -146,6 +147,10 @@ onMounted(async () => { } catch { // 非 Tauri 环境忽略 } + // 延迟修复 snap-layout 子窗口背景(等待插件初始化创建子窗口) + setTimeout(() => { + invoke('fix_snap_background').catch(() => {}) + }, 1000) } }) diff --git a/src/lib/trayEvents.ts b/src/lib/trayEvents.ts new file mode 100644 index 0000000..de13811 --- /dev/null +++ b/src/lib/trayEvents.ts @@ -0,0 +1,15 @@ +import { ref } from 'vue' + +/** + * 托盘菜单触发的待处理动作 + * + * Rust 后端通过 Tauri 事件通知前端执行模块切换 / 对话框打开等操作。 + * 由于目标模块可能尚未挂载(延迟加载),使用 ref 作为共享标志位, + * 模块在 onMounted 中检查并消费。 + */ + +/** 待打开新建下载对话框(由托盘"新建下载"触发) */ +export const pendingNewDownload = ref(false) + +/** 待切换到设置模块(由托盘"常规设置"触发) */ +export const pendingOpenSettings = ref(false) diff --git a/src/main.ts b/src/main.ts index d05d3ce..ba8f7f7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,15 +16,21 @@ window.addEventListener('unhandledrejection', (event) => { }) // ===== OSD 窗口模式检测 ===== -// 通过 URL hash 识别:#osd-overlay(悬浮窗) -// OSD 窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块 -const osdHash = window.location.hash -if (osdHash === '#osd-overlay') { - logger.info(`OSD 窗口启动: ${osdHash}`) +// 通过 URL hash 识别:#osd-overlay(悬浮窗)/ #clipboard-popup(剪贴板快捷弹窗) +// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块 +const winHash = window.location.hash +if (winHash === '#osd-overlay') { + logger.info(`OSD 窗口启动: ${winHash}`) void import('./modules/monitor/OsdWindow.vue').then(({ default: OsdWindow }) => { const app = createApp(OsdWindow) app.mount('#app') }) +} else if (winHash === '#clipboard-popup') { + logger.info(`剪贴板弹窗窗口启动: ${winHash}`) + void import('./modules/clipboard/ClipboardPopup.vue').then(({ default: ClipboardPopup }) => { + const app = createApp(ClipboardPopup) + app.mount('#app') + }) } else { // ===== 主应用模式 ===== void import('./App.vue').then(async ({ default: App }) => { diff --git a/src/modules/clipboard/ClipboardModule.vue b/src/modules/clipboard/ClipboardModule.vue index 4c51d8c..f35483f 100644 --- a/src/modules/clipboard/ClipboardModule.vue +++ b/src/modules/clipboard/ClipboardModule.vue @@ -1,24 +1,479 @@ \ No newline at end of file + + + diff --git a/src/modules/clipboard/ClipboardPopup.vue b/src/modules/clipboard/ClipboardPopup.vue new file mode 100644 index 0000000..0ac6ca2 --- /dev/null +++ b/src/modules/clipboard/ClipboardPopup.vue @@ -0,0 +1,633 @@ + + + + + diff --git a/src/modules/downloader/DownloaderModule.vue b/src/modules/downloader/DownloaderModule.vue index 58e7555..cd1f245 100644 --- a/src/modules/downloader/DownloaderModule.vue +++ b/src/modules/downloader/DownloaderModule.vue @@ -11,9 +11,10 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { toast } from 'vue-sonner' import { invoke } from '@tauri-apps/api/core' import { open as openDialog } from '@tauri-apps/plugin-dialog' -import { useDownloaderStore, type DownloadTask, type TaskStatus } from '@/stores/downloaderStore' +import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore' import { useModuleTabs } from '@/lib/useModuleTabs' import { createLogger } from '@/lib/logger' +import { pendingNewDownload } from '@/lib/trayEvents' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -257,6 +258,15 @@ const handleOpenDir = async (task: DownloadTask) => { } // ===== 添加下载 ===== +/** 重复确认对话框状态 */ +const duplicateDialogState = ref<{ + open: boolean + url: string + result: CheckUrlResult | null + pendingUrls: string[] + currentIndex: number +}>({ open: false, url: '', result: null, pendingUrls: [], currentIndex: 0 }) + const handleAddDownload = async () => { const text = addUriText.value.trim() if (!text) { @@ -272,23 +282,23 @@ const handleAddDownload = async () => { addingTask.value = true try { const dir = addDir.value.trim() || undefined - let successCount = 0 - for (const uri of uris) { - try { - await store.addTask(uri, undefined, dir) - successCount++ - } catch (e) { - logger.error(`添加 ${uri} 失败: ` + e) - } - } - if (successCount > 0) { - toast.success(`已添加 ${successCount} 个下载任务`) - addUriText.value = '' - addDir.value = '' - addDialogOpen.value = false - activeTab.value = 'tasks' + // 检查是否启用重复检查 + const checkEnabled = store.settings?.checkDuplicate ?? true + if (checkEnabled) { + // 逐个检查重复 + await processUrlsWithCheck(uris, dir) } else { - toast.error('添加任务失败') + // 直接添加 + let successCount = 0 + for (const uri of uris) { + try { + await store.addTask(uri, undefined, dir, undefined, true) + successCount++ + } catch (e) { + logger.error(`添加 ${uri} 失败: ` + e) + } + } + finishAdd(successCount) } } catch (e) { toast.error('添加失败: ' + e) @@ -297,6 +307,146 @@ const handleAddDownload = async () => { } } +/** 逐个检查 URL 重复性,发现重复时弹出确认对话框 */ +async function processUrlsWithCheck(urls: string[], dir: string | undefined) { + let successCount = 0 + for (let i = 0; i < urls.length; i++) { + const uri = urls[i] + try { + const result = await store.checkUrl(uri, dir) + if (!result.ok) { + // 探测失败:直接添加(后端会标记为 error) + try { + await store.addTask(uri, undefined, dir, undefined, true) + successCount++ + } catch (e) { + logger.error(`添加 ${uri} 失败: ` + e) + } + continue + } + if (result.duplicate === 'none') { + // 无重复:直接添加(自动重命名以防磁盘文件冲突) + try { + await store.addTask(uri, undefined, dir, undefined, true) + successCount++ + } catch (e) { + logger.error(`添加 ${uri} 失败: ` + e) + } + continue + } + // 发现重复:弹出确认对话框,暂停处理 + duplicateDialogState.value = { + open: true, + url: uri, + result, + pendingUrls: urls, + currentIndex: i, + } + return // 等待用户确认后继续 + } catch (e) { + logger.error(`检查 ${uri} 失败: ` + e) + // 检查失败:直接添加 + try { + await store.addTask(uri, undefined, dir, undefined, true) + successCount++ + } catch (e2) { + logger.error(`添加 ${uri} 失败: ` + e2) + } + } + } + finishAdd(successCount) +} + +/** 完成添加:显示提示并关闭对话框 */ +function finishAdd(successCount: number) { + if (successCount > 0) { + toast.success(`已添加 ${successCount} 个下载任务`) + addUriText.value = '' + addDir.value = '' + addDialogOpen.value = false + activeTab.value = 'tasks' + } else { + toast.error('添加任务失败') + } +} + +/** 重复确认对话框:用户选择"仍然下载"(自动重命名) */ +const onDuplicateConfirm = async () => { + const { url, pendingUrls, currentIndex } = duplicateDialogState.value + const dir = addDir.value.trim() || undefined + duplicateDialogState.value.open = false + + let successCount = 0 + try { + await store.addTask(url, undefined, dir, undefined, true) // autoRename=true + successCount++ + } catch (e) { + logger.error(`添加 ${url} 失败: ` + e) + } + + // 继续处理剩余 URL + const remaining = pendingUrls.slice(currentIndex + 1) + for (const uri of remaining) { + try { + const result = await store.checkUrl(uri, dir) + if (result.ok && result.duplicate !== 'none') { + // 又发现重复,再次弹出确认 + duplicateDialogState.value = { + open: true, + url: uri, + result, + pendingUrls, + currentIndex: pendingUrls.indexOf(uri), + } + // 已添加的成功数通过闭包传递不太方便,直接在这里 finish 后再继续 + // 简化处理:保存 successCount 到 state,下一次确认时累加 + duplicateSuccessCount.value = successCount + return + } + await store.addTask(uri, undefined, dir, undefined, true) + successCount++ + } catch (e) { + logger.error(`添加 ${uri} 失败: ` + e) + } + } + finishAdd(successCount) +} + +/** 累积的成功计数(跨多次重复确认) */ +const duplicateSuccessCount = ref(0) + +/** 重复确认对话框:用户选择"跳过" */ +const onDuplicateSkip = () => { + const { pendingUrls, currentIndex } = duplicateDialogState.value + duplicateDialogState.value.open = false + const remaining = pendingUrls.slice(currentIndex + 1) + if (remaining.length > 0) { + // 继续处理剩余 URL(不添加当前这个) + const dir = addDir.value.trim() || undefined + processUrlsWithCheck(remaining, dir).then(() => {}) + } else { + finishAdd(duplicateSuccessCount.value) + duplicateSuccessCount.value = 0 + } +} + +/** 重复确认对话框:用户取消整个添加流程 */ +const onDuplicateCancel = () => { + duplicateDialogState.value.open = false + finishAdd(duplicateSuccessCount.value) + duplicateSuccessCount.value = 0 +} + +/** 重复类型文本 */ +const duplicateKindText = (kind: string): string => { + switch (kind) { + case 'url': return 'URL 已存在下载任务' + case 'filename': return '同名文件已在下载列表中' + case 'fileExists': return '目标目录已存在同名文件' + default: return '重复' + } +} + const handleSelectDir = async () => { try { const selected = await openDialog({ directory: true, multiple: false }) @@ -367,6 +517,11 @@ const handleCopy = async (text: string, label: string) => { onMounted(async () => { await store.init() + // 消费托盘菜单"新建下载"标志位 + if (pendingNewDownload.value) { + pendingNewDownload.value = false + addDialogOpen.value = true + } }) onUnmounted(() => { @@ -458,8 +613,8 @@ const toggleSortOrder = () => {
- - + +
@@ -611,8 +766,8 @@ const toggleSortOrder = () => {
- - + +
@@ -893,6 +1048,17 @@ const toggleSortOrder = () => { @update:model-value="(v: boolean) => store.settings && (store.settings.deleteFilesOnRemove = v)" />
+
+
+ + 添加下载前检查 URL 和文件名重复,发现重复时询问 +
+ +
@@ -1218,6 +1384,39 @@ const toggleSortOrder = () => { + + + + + + + 下载重复提醒 + + + + {{ duplicateKindText(duplicateDialogState.result?.duplicate ?? '') }} + + {{ duplicateDialogState.url }} + + 已存在: + {{ duplicateDialogState.result.existing.filename }} + + + 选择"仍然下载"将自动重命名(追加序号),选择"跳过"将不下载此链接。 + + + + + 取消全部 + + + + 仍然下载 + + + + + diff --git a/src/modules/monitor/MonitorModule.vue b/src/modules/monitor/MonitorModule.vue index 8fdad25..57e1082 100644 --- a/src/modules/monitor/MonitorModule.vue +++ b/src/modules/monitor/MonitorModule.vue @@ -13,7 +13,7 @@ import { VueDraggable } from 'vue-draggable-plus' import { appDataDir } from '@tauri-apps/api/path' import { revealItemInDir } from '@tauri-apps/plugin-opener' import { WebviewWindow } from '@tauri-apps/api/webviewWindow' -import { emit, type UnlistenFn } from '@tauri-apps/api/event' +import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event' import { currentMonitor, LogicalPosition, LogicalSize } from '@tauri-apps/api/window' import { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore' import { useModuleTabs } from '@/lib/useModuleTabs' @@ -1493,20 +1493,12 @@ async function resetOverlayPosition() { } catch { /* 忽略 */ } } -/** 关闭所有 OSD 窗口(组件卸载时调用) */ -async function closeAllOsdWindows() { - try { - const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL) - if (w) await w.close() - } catch { - /* 忽略 */ - } -} - // ===== OSD 窗口事件监听 ===== let osdEventUnlisteners: UnlistenFn[] = [] async function setupOsdEventListeners() { + // 守卫:避免重复注册(MonitorModule 可能因预渲染多次挂载) + if (osdEventUnlisteners.length) return const { listen: tauriListen } = await import('@tauri-apps/api/event') // 监听悬浮窗上报的实际内容尺寸,按内容调整窗口大小(替代不准确的估算) // 仅当尺寸变化超过 1px 时才 setSize,避免无意义的频繁调用 @@ -1611,15 +1603,36 @@ onMounted(async () => { if (osdConfig.value.overlayEnabled) { ensureOverlayWindow().catch(e => console.error('[OSD] 初始化悬浮窗失败:', e)) } + + // 监听托盘菜单"切换 OSD"事件 + try { + osdEventUnlisteners.push( + await listen('tray:toggle-osd', () => { + osdConfig.value.overlayEnabled = !osdConfig.value.overlayEnabled + saveOsdConfig(osdConfig.value) + if (osdConfig.value.overlayEnabled) { + if (osdConfig.value.overlayItems.length === 0) { + toast.warning('OSD 显示项为空,已开启但未创建窗口') + } else { + ensureOverlayWindow().catch(e => console.error('[OSD] 托盘开启悬浮窗失败:', e)) + } + } else { + hideOverlayWindow().catch(e => console.error('[OSD] 托盘关闭悬浮窗失败:', e)) + } + }) + ) + } catch (e) { + console.error('[OSD] 注册 tray:toggle-osd 监听失败:', e) + } }) onUnmounted(() => { - store.dispose() - // 清理 OSD 事件监听 + // 不 dispose store:SSE 订阅保持,确保切走监控模块后 OSD 仍有数据 + // store.subscribe() 内部有守卫(if unlistenFns.length return),重复 init 不会重复订阅 + // 不关闭 OSD 窗口:切走监控模块时保持 OSD 显示,由模块禁用或应用退出时统一清理 + // 仅清理组件级 OSD 事件监听(下次挂载会重新注册,setupOsdEventListeners 有守卫) osdEventUnlisteners.forEach(fn => fn()) osdEventUnlisteners = [] - // 关闭悬浮窗(切走监控模块时释放 OSD 窗口) - closeAllOsdWindows().catch(e => console.error('[OSD] 关闭窗口失败:', e)) }) // 当 Kernel 从未就绪变成就绪时,主动拉一次快照填充 UI diff --git a/src/modules/monitor/index.ts b/src/modules/monitor/index.ts index 28b5330..a541943 100644 --- a/src/modules/monitor/index.ts +++ b/src/modules/monitor/index.ts @@ -44,6 +44,14 @@ export const moduleConfig: ModuleConfig = { } catch { /* 忽略:可能 Kernel 未运行 */ } + // 关闭 OSD 窗口(MonitorModule onUnmounted 不再自动关闭,需在禁用时手动关闭) + try { + const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') + const osd = await WebviewWindow.getByLabel('osd-overlay') + if (osd) await osd.close() + } catch { + /* 忽略 */ + } } }, order: 40 diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index be818f5..2224579 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -391,6 +391,14 @@ export const useAppStore = defineStore('app', () => { } catch (e) { // 非 Tauri 环境下忽略 } + + // 切换效果后修复 snap-layout 子窗口背景(clearEffects 触发重绘会导致白色背景显现) + try { + const { invoke } = await import('@tauri-apps/api/core') + invoke('fix_snap_background').catch(() => {}) + } catch { + /* 非 Tauri 环境忽略 */ + } } // 系统主题变化时,若当前为"跟随系统"模式,同步更新 DOM 和窗口效果。 diff --git a/src/stores/clipboardStore.ts b/src/stores/clipboardStore.ts new file mode 100644 index 0000000..f28e031 --- /dev/null +++ b/src/stores/clipboardStore.ts @@ -0,0 +1,293 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { invoke } from '@tauri-apps/api/core' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import { createLogger } from '@/lib/logger' + +const logger = createLogger('clipboard') + +// ===== 与 Rust 端对应的数据结构(camelCase) ===== + +export type ClipboardKind = 'text' | 'image' | 'files' + +export interface ClipboardItem { + id: number + kind: ClipboardKind + preview: string + size: number + pinned: boolean + pinnedOrder: number | null + createdAt: number +} + +/** 历史分页结果(与 Rust 端 HistoryPage 对应) */ +export interface HistoryPage { + items: ClipboardItem[] + total: number +} + +export interface ClipboardItemDetail extends ClipboardItem { + content: string | null + imageBase64: string | null +} + +export interface ClipboardSettings { + enabled: boolean + maxItems: number + maxImageKb: number + recordText: boolean + recordImage: boolean + recordFiles: boolean + dedup: boolean + shortcut: string +} + +export interface ClipboardStatus { + running: boolean + count: number +} + +const DEFAULT_SETTINGS: ClipboardSettings = { + enabled: true, + maxItems: 500, + maxImageKb: 5120, + recordText: true, + recordImage: true, + recordFiles: true, + dedup: true, + shortcut: 'Alt+V', +} + +export const useClipboardStore = defineStore('clipboard', () => { + const history = ref([]) + const historyTotal = ref(0) + const pinned = ref([]) + const settings = ref({ ...DEFAULT_SETTINGS }) + const status = ref({ running: false, count: 0 }) + const loading = ref(false) + + // 事件监听(应用级单例,只注册一次) + let changedUnlisten: UnlistenFn | null = null + let debounceTimer: ReturnType | null = null + + /** 初始化:加载状态/设置,注册事件监听 */ + const init = async () => { + try { + const [s, st] = await Promise.all([ + invoke('clipboard_get_settings'), + invoke('clipboard_status'), + ]) + settings.value = { ...DEFAULT_SETTINGS, ...s } + status.value = st + } catch (e) { + logger.error('初始化失败: ' + e) + } + if (!changedUnlisten) { + changedUnlisten = await listen('clipboard-changed', () => { + // 防抖:短时间内多次复制只刷新一次 + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + refreshHistory() + refreshStatus() + }, 250) + }) + } + } + + const dispose = () => { + if (changedUnlisten) { + changedUnlisten() + changedUnlisten = null + } + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + } + + // ===== 查询 ===== + /** 拉取指定页的历史数据。pageSize 默认 50。 */ + const fetchHistoryPage = async (opts: { + kind?: string + page?: number + pageSize?: number + } = {}) => { + const kind = opts.kind ?? 'all' + const pageSize = opts.pageSize ?? 50 + const page = Math.max(1, opts.page ?? 1) + const offset = (page - 1) * pageSize + try { + const res = await invoke('clipboard_get_history', { + limit: pageSize, + offset, + kind, + }) + history.value = res.items + historyTotal.value = res.total + } catch (e) { + logger.error('获取历史失败: ' + e) + } + return history.value + } + + /** 兼容旧调用:拉取第一页 */ + const refreshHistory = async (kind: string = 'all') => fetchHistoryPage({ kind, page: 1 }) + + const refreshPinned = async () => { + try { + pinned.value = await invoke('clipboard_get_pinned') + } catch (e) { + logger.error('获取固定条目失败: ' + e) + } + return pinned.value + } + + /** 搜索(分页)。pageSize 默认 50。 */ + const searchPage = async (query: string, page: number = 1, pageSize: number = 50) => { + if (!query.trim()) { + return fetchHistoryPage({ page, pageSize }) + } + try { + const res = await invoke('clipboard_search', { + query, + limit: pageSize, + offset: (page - 1) * pageSize, + }) + history.value = res.items + historyTotal.value = res.total + } catch (e) { + logger.error('搜索失败: ' + e) + } + return history.value + } + + /** 兼容旧调用:搜索第一页 */ + const search = async (query: string) => searchPage(query, 1) + + const getItem = async (id: number) => { + try { + return await invoke('clipboard_get_item', { id }) + } catch (e) { + logger.error('获取详情失败: ' + e) + return null + } + } + + const refreshStatus = async () => { + try { + status.value = await invoke('clipboard_status') + } catch (e) { + logger.error('获取状态失败: ' + e) + } + } + + // ===== 操作 ===== + const setPinned = async (id: number, pinned: boolean) => { + try { + await invoke('clipboard_set_pinned', { id, pinned }) + // 固定/取消后刷新两个列表 + await Promise.all([refreshHistory(), refreshPinned()]) + } catch (e) { + logger.error('切换固定失败: ' + e) + } + } + + const remove = async (id: number) => { + try { + await invoke('clipboard_delete', { id }) + history.value = history.value.filter((i) => i.id !== id) + pinned.value = pinned.value.filter((i) => i.id !== id) + status.value.count = Math.max(0, status.value.count - 1) + } catch (e) { + logger.error('删除失败: ' + e) + } + } + + const clear = async () => { + try { + await invoke('clipboard_clear') + history.value = [] + await refreshStatus() + } catch (e) { + logger.error('清空失败: ' + e) + } + } + + const copyBack = async (id: number) => { + await invoke('clipboard_copy_back', { id }) + // copy_back 会触发 suppress,不会产生 clipboard-changed 事件 + } + + const saveSettings = async (s: ClipboardSettings) => { + try { + await invoke('clipboard_save_settings', { settings: s }) + settings.value = { ...s } + await refreshStatus() + } catch (e) { + logger.error('保存设置失败: ' + e) + throw e + } + } + + const start = async () => { + await invoke('clipboard_start') + await refreshStatus() + } + + const stop = async () => { + await invoke('clipboard_stop') + await refreshStatus() + } + + // ===== 快捷弹窗 ===== + const showPopup = async () => { + await invoke('clipboard_show_popup') + } + + const hidePopup = async () => { + await invoke('clipboard_hide_popup') + } + + /// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口 + const pasteToTarget = async () => { + await invoke('clipboard_paste_to_target') + } + + const registerShortcut = async (shortcut: string) => { + await invoke('clipboard_register_shortcut', { shortcut }) + } + + const unregisterShortcut = async () => { + await invoke('clipboard_unregister_shortcut') + } + + return { + history, + historyTotal, + pinned, + settings, + status, + loading, + init, + dispose, + fetchHistoryPage, + refreshHistory, + refreshPinned, + searchPage, + search, + getItem, + refreshStatus, + setPinned, + remove, + clear, + copyBack, + saveSettings, + start, + stop, + showPopup, + hidePopup, + pasteToTarget, + registerShortcut, + unregisterShortcut, + } +}) diff --git a/src/stores/downloaderStore.ts b/src/stores/downloaderStore.ts index 6a90539..2e50d80 100644 --- a/src/stores/downloaderStore.ts +++ b/src/stores/downloaderStore.ts @@ -42,6 +42,25 @@ export interface DownloaderSettings { extensionPort: number extensionSecret: string deleteFilesOnRemove: boolean + checkDuplicate: boolean +} + +/** 重复类型 */ +export type DuplicateKind = 'none' | 'url' | 'filename' | 'fileExists' + +/** check_url 返回的结果 */ +export interface CheckUrlResult { + ok: boolean + error: string | null + filename: string | null + totalSize: number | null + supportsResume: boolean + duplicate: DuplicateKind + existing: { + id: string + filename: string + status: TaskStatus + } | null } export interface DownloaderStatus { @@ -115,18 +134,33 @@ export const useDownloaderStore = defineStore('downloader', () => { url: string, filename?: string, dir?: string, - headers?: Record + headers?: Record, + autoRename = false ): Promise => { const id = await invoke('downloader_add_task', { url, filename: filename || null, dir: dir || null, - headers: headers || null + headers: headers || null, + autoRename }) await refreshTasks() return id } + /** 检查 URL 重复性并探测文件信息 */ + const checkUrl = async ( + url: string, + dir?: string, + headers?: Record + ): Promise => { + return await invoke('downloader_check_url', { + url, + dir: dir || null, + headers: headers || null + }) + } + const pauseTask = async (id: string) => { await invoke('downloader_pause_task', { id }) await refreshTasks() @@ -223,6 +257,7 @@ export const useDownloaderStore = defineStore('downloader', () => { // tasks refreshTasks, addTask, + checkUrl, pauseTask, resumeTask, removeTask,