This commit is contained in:
zhongluofeng
2026-07-30 09:09:29 +08:00
parent 74902c4cec
commit f452322aad
32 changed files with 4586 additions and 115 deletions
+1
View File
@@ -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",
+193
View File
@@ -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"
+9
View File
@@ -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",
@@ -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"
]
}
+205
View File
@@ -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<String> {
dib_to_png(b).map(|png| STANDARD.encode(&png))
}
/// 历史查询结果(含总数,用于分页)
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryPage {
pub items: Vec<ClipboardItem>,
pub total: i64,
}
#[tauri::command]
pub async fn clipboard_get_history(
limit: Option<i64>,
offset: Option<i64>,
kind: Option<String>,
manager: State<'_, ClipboardManager>,
) -> Result<HistoryPage, String> {
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<Vec<ClipboardItem>, String> {
Ok(manager.storage().get_pinned())
}
#[tauri::command]
pub async fn clipboard_search(
query: String,
limit: Option<i64>,
offset: Option<i64>,
manager: State<'_, ClipboardManager>,
) -> Result<HistoryPage, String> {
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<Option<ClipboardItemDetail>, 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<bool, String> {
Ok(manager.storage().set_pinned(id, pinned))
}
#[tauri::command]
pub async fn clipboard_delete(
id: i64,
manager: State<'_, ClipboardManager>,
) -> Result<bool, String> {
Ok(manager.storage().delete(id))
}
#[tauri::command]
pub async fn clipboard_clear(manager: State<'_, ClipboardManager>) -> Result<bool, String> {
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<i64, String> {
Ok(manager.storage().count())
}
#[tauri::command]
pub async fn clipboard_get_settings(
manager: State<'_, ClipboardManager>,
) -> Result<ClipboardSettings, String> {
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<ClipboardStatus, String> {
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(())
}
+187
View File
@@ -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<Storage>,
settings: Arc<Mutex<ClipboardSettings>>,
suppress: Arc<AtomicBool>,
monitor_stop: Arc<AtomicBool>,
monitor_handle: Mutex<Option<JoinHandle<()>>>,
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<String> = 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<Storage> {
&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);
}
}
+17
View File
@@ -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;
+169
View File
@@ -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<Storage>,
app: AppHandle,
settings: Arc<Mutex<super::manager::ClipboardSettings>>,
suppress: Arc<AtomicBool>,
stop: Arc<AtomicBool>,
) -> 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<Option<u32>> = 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::<usize>() 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())
}
+254
View File
@@ -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<Option<String>> = Mutex::new(None);
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+V"、"Ctrl+Shift+V"
/// 失败返回 None。
pub fn parse_shortcut(s: &str) -> Option<Shortcut> {
s.trim().parse::<Shortcut>().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::<INPUT>() 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};
+280
View File
@@ -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<u8>, width: u32, height: u32 },
Files(Vec<String>),
}
/// 读取剪贴板(需在 monitor 线程或命令线程调用)。OpenClipboard 失败返回 None。
pub fn read_clipboard() -> Option<ClipData> {
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<ClipData> {
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<ClipData> {
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<ClipData> {
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<u16> = 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<u16> = 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<Vec<u8>> {
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)
}
+375
View File
@@ -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<i64>,
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<String>,
/// 图片 PNG base64(仅 image 类型)
pub image_base64: Option<String>,
}
/// 新捕获的剪贴板数据
pub struct NewItem {
pub kind: String,
pub content: Option<String>,
pub blob: Option<Vec<u8>>,
pub preview: String,
pub size: i64,
pub hash: String,
}
pub struct Storage {
conn: Mutex<Connection>,
}
impl Storage {
pub fn new(data_dir: &PathBuf) -> std::io::Result<Self> {
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<i64> {
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<i64> = 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<ClipboardItem> {
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<ClipboardItem> {
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<ClipboardItem> {
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<String>) -> Option<ClipboardItemDetail> {
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<String> = r.get(7)?;
let blob: Option<Vec<u8>> = 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<String>, Option<Vec<u8>>)> {
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<i64> = 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<ClipboardItem> {
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()
}
+36 -2
View File
@@ -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<DownloadTa
engine.get_tasks()
}
/// 检查 URL 重复性并探测文件信息(添加下载前调用)
#[tauri::command]
pub async fn downloader_check_url(
engine: State<'_, DownloadEngine>,
url: String,
dir: Option<String>,
headers: Option<HashMap<String, String>>,
) -> Result<CheckUrlResult, String> {
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<String>,
dir: Option<String>,
headers: Option<HashMap<String, String>>,
auto_rename: Option<bool>,
) -> Result<String, String> {
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
}
/// 暂停任务
+146 -2
View File
@@ -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<String>,
/// 文件名(探测成功时)
pub filename: Option<String>,
/// 文件大小(字节)
pub total_size: Option<u64>,
/// 是否支持断点续传
pub supports_resume: bool,
/// 重复类型
pub duplicate: DuplicateKind,
/// 已存在的任务信息
pub existing: Option<ExistingTaskInfo>,
}
/// 进度事件载荷(发给前端 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<String, String>,
) -> (Result<ProbeResult, String>, DuplicateKind, Option<ExistingTaskInfo>) {
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<ExistingTaskInfo> = 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<String>,
dir: Option<String>,
headers: HashMap<String, String>,
auto_rename: bool,
) -> Result<String, String> {
// 探测资源信息
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 设置控制:关闭时强制单线程、不支持续传)
+1 -1
View File
@@ -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,
};
+1 -1
View File
@@ -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 }))),
}
+5
View File
@@ -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<u64>,
+59 -47
View File
@@ -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::<MihomoManager>() {
mihomo.cleanup_on_exit();
}
if let Some(engine) = app.try_state::<DownloadEngine>() {
engine.cleanup_on_exit();
}
if let Some(monitor) = app.try_state::<MonitorKernel>() {
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
}
if let Some(pm) = app.try_state::<ProcessManager>() {
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::<MonitorKernel>() {
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
}
if let Some(clip) = app.try_state::<ClipboardManager>() {
clip.stop();
}
if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all();
}
+18
View File
@@ -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<ProfileMeta, String> {
// 先读取当前 settings(此时新 profile 文件还未写入,reconcile 不会误添加)
+92
View File
@@ -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(())
}
+738
View File
@@ -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<Mutex<Vec<(String, String)>>> = OnceLock::new();
fn proxy_node_map() -> &'static Mutex<Vec<(String, String)>> {
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::<ProcessManager>() {
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::<ProcessManager>() {
if let Some(info) = pm.get_status("monitor") {
if matches!(info.status, ProcessStatus::Running) {
return true;
}
}
}
if let Some(monitor) = app.try_state::<MonitorKernel>() {
if monitor.is_elevated() {
return true;
}
}
false
}
// ===== 从 mihomo /proxies 响应中提取主选择组及其节点 =====
/// 返回 (组名, 有效节点列表, 当前节点)
fn parse_main_group(proxies_response: &serde_json::Value) -> Option<(String, Vec<String>, 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<String> = 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<u32> {
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<u32>)>, String)> {
let mihomo = app.state::<MihomoManager>();
let proxies = mihomo.get_proxies().await.ok()?;
let (group, nodes, now) = parse_main_group(&proxies)?;
let node_delays: Vec<(String, Option<u32>)> = 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<u32>)>, String)>,
) -> Result<Menu<tauri::Wry>, 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<tauri::Wry>> = 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<tauri::Wry>> = 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<tauri::Wry>> = 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<u32>)>, String)>,
) -> Result<(Vec<MenuItem<tauri::Wry>>, Option<PredefinedMenuItem<tauri::Wry>>), 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<MenuItem<tauri::Wry>> = 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::<MihomoManager>();
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::<MihomoManager>();
let pm = app.state::<ProcessManager>();
// 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::<MihomoManager>();
let pm = app.state::<ProcessManager>();
// 关闭系统代理
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::<MihomoManager>();
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::<MonitorKernel>();
let pm = app.state::<ProcessManager>();
// 停止当前 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::<MihomoManager>() {
mihomo.cleanup_on_exit();
}
if let Some(engine) = app.try_state::<DownloadEngine>() {
engine.cleanup_on_exit();
}
if let Some(monitor) = app.try_state::<MonitorKernel>() {
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
}
if let Some(clip) = app.try_state::<ClipboardManager>() {
clip.stop();
}
if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all();
}
app.exit(0);
}
+50 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, shallowRef, computed, watch, type Component } from 'vue'
import { ref, onMounted, onUnmounted, shallowRef, computed, watch, type Component } from 'vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import TitleBar from '@/components/layout/TitleBar.vue'
import Sidebar from '@/components/layout/Sidebar.vue'
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
@@ -8,6 +9,7 @@ import { useAppStore } from '@/stores/appStore'
import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload, pendingOpenSettings } from '@/lib/trayEvents'
const appStore = useAppStore()
@@ -31,6 +33,14 @@ const activeModule = ref('')
const activeComponent = shallowRef<Component | null>(null)
/** 预加载的监控模块组件(用于隐藏预渲染,确保 OSD 在启动时创建) */
const monitorComponent = shallowRef<Component | null>(null)
/** 监控模块是否已启用 */
const monitorEnabled = computed(() =>
appStore.modules.find(m => m.id === 'monitor')?.enabled ?? false
)
/** 当前可显示的模块(内置模块 + 已启用的用户模块),按用户排序显示 */
const availableModules = computed<NavModule[]>(() => {
const enabledIds = appStore.enabledModules.map(m => m.id)
@@ -116,9 +126,43 @@ const resolveDefaultModule = (): string => {
onMounted(async () => {
await appStore.init().catch(e => console.error('App init error:', e))
// 预加载监控模块组件,用于隐藏预渲染
// 这样即使启动时默认模块不是监控,MonitorModule 的 onMounted 也会执行
// 从而在应用启动时自动创建 OSD 窗口(如果 OSD 配置已开启)
if (monitorEnabled.value) {
monitorComponent.value = await moduleRegistry.loadComponent('monitor')
}
const defaultModule = resolveDefaultModule()
activeModule.value = defaultModule
loadModule(defaultModule)
// 监听托盘菜单事件
// tray:toggle-osd 由 MonitorModule 直接监听(预渲染实例始终挂载)
trayUnlisteners.push(
await listen('tray:new-download', () => {
// 设置标志位,DownloaderModule 挂载后消费
pendingNewDownload.value = true
// 切换到下载模块(如果未启用则切换到设置)
const enabledIds = appStore.enabledModules.map(m => m.id)
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) {
handleModuleChange('downloader')
}
})
)
trayUnlisteners.push(
await listen('tray:open-settings', () => {
pendingOpenSettings.value = true
handleModuleChange('settings')
})
)
})
const trayUnlisteners: UnlistenFn[] = []
onUnmounted(() => {
trayUnlisteners.forEach(fn => fn())
})
</script>
@@ -136,5 +180,10 @@ onMounted(async () => {
</div>
</div>
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
<!-- 预渲染监控模块隐藏确保 OSD 窗口在应用启动时创建不依赖用户切换到监控模块
activeModule === 'monitor' 时不渲染 ModuleContainer 正常渲染避免重复实例 -->
<div v-if="monitorComponent && monitorEnabled && activeModule !== 'monitor'" style="display:none">
<component :is="monitorComponent" />
</div>
</TooltipProvider>
</template>
+5
View File
@@ -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)
}
})
+15
View File
@@ -0,0 +1,15 @@
import { ref } from 'vue'
/**
* 托盘菜单触发的待处理动作
*
* Rust 后端通过 Tauri 事件通知前端执行模块切换 / 对话框打开等操作。
* 由于目标模块可能尚未挂载(延迟加载),使用 ref 作为共享标志位,
* 模块在 onMounted 中检查并消费。
*/
/** 待打开新建下载对话框(由托盘"新建下载"触发) */
export const pendingNewDownload = ref(false)
/** 待切换到设置模块(由托盘"常规设置"触发) */
export const pendingOpenSettings = ref(false)
+11 -5
View File
@@ -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 }) => {
+473 -18
View File
@@ -1,24 +1,479 @@
<script setup lang="ts">
import { ClipboardList } from '@lucide/vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import {
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Check, Keyboard,
ChevronLeft, ChevronRight,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useClipboardStore, type ClipboardItem, type ClipboardKind } from '@/stores/clipboardStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog'
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'
const store = useClipboardStore()
const activeTab = ref('history')
const tabsListRef = useModuleTabs(activeTab, [
{ value: 'history', label: '历史' },
{ value: 'pinned', label: '固定' },
{ value: 'settings', label: '设置' },
])
// 历史搜索/过滤
const searchQuery = ref('')
const kindFilter = ref<'all' | ClipboardKind>('all')
const PAGE_SIZE = 50
const currentPage = ref(1)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() =>
Math.max(1, Math.ceil(store.historyTotal / PAGE_SIZE))
)
const loadPage = async () => {
if (searchQuery.value.trim()) {
await store.searchPage(searchQuery.value.trim(), currentPage.value, PAGE_SIZE)
} else {
await store.fetchHistoryPage({ kind: kindFilter.value, page: currentPage.value, pageSize: PAGE_SIZE })
}
}
watch([searchQuery, kindFilter], () => {
// 过滤/搜索变化时回到第一页
currentPage.value = 1
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(loadPage, 300)
})
const gotoPage = async (p: number) => {
currentPage.value = Math.min(Math.max(1, p), totalPages.value)
await loadPage()
}
// 详情弹窗
const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref<ReturnType<typeof Object> & { content?: string | null; imageBase64?: string | null; kind?: string } | null>(null)
const openDetail = async (item: ClipboardItem) => {
detailOpen.value = true
detailLoading.value = true
detail.value = null
const d = await store.getItem(item.id)
detail.value = d
detailLoading.value = false
}
// 清空确认
const clearOpen = ref(false)
// 设置表单(本地副本,保存时提交)
const form = ref({ ...store.settings })
watch(() => store.settings, (s) => { form.value = { ...s } }, { deep: true })
const handleEnabledToggle = async (val: boolean) => {
form.value.enabled = val
try {
await store.saveSettings({ ...form.value })
toast.success(val ? '已开启剪贴板监听' : '已停止剪贴板监听')
} catch {
toast.error('切换监听失败')
}
}
const handleSaveSettings = async () => {
try {
await store.saveSettings({ ...form.value })
toast.success('设置已保存')
} catch {
toast.error('保存设置失败')
}
}
const handleShortcutChange = async () => {
// 快捷键变化立即保存并注册(不等点击"保存设置")
try {
await store.saveSettings({ ...form.value })
toast.success(`快捷键已更新为 ${form.value.shortcut || '(已禁用)'}`)
} catch {
toast.error('快捷键注册失败,可能被其他程序占用')
}
}
const handleTestPopup = async () => {
try {
await store.showPopup()
} catch {
toast.error('打开弹窗失败')
}
}
// 操作
const handleCopy = async (item: ClipboardItem) => {
try {
await store.copyBack(item.id)
toast.success('已复制到剪贴板')
} catch {
toast.error('复制失败')
}
}
const handlePin = async (item: ClipboardItem) => {
await store.setPinned(item.id, !item.pinned)
toast.success(item.pinned ? '已取消固定' : '已固定')
// 固定/取消后当前页数据变化,重新加载
await loadPage()
}
const handleDelete = async (item: ClipboardItem) => {
await store.remove(item.id)
toast.success('已删除')
// 删除后总数可能变化,若当前页已空则回退一页
if (!store.history.length && currentPage.value > 1) {
currentPage.value -= 1
}
await loadPage()
}
const handleClear = async () => {
clearOpen.value = false
await store.clear()
currentPage.value = 1
await loadPage()
toast.success('已清空历史')
}
// 显示辅助
const kindIcon = (k: ClipboardKind) => {
if (k === 'text') return FileText
if (k === 'image') return ImageIcon
return Files
}
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) =>
k === 'text'
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: k === 'image'
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
const formatSize = (bytes: number) => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const formatTime = (ms: number) => {
const diff = Date.now() - ms
if (diff < 60_000) return '刚刚'
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86_400_000) return `${Math.floor(diff / 3600_000)} 小时前`
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
const d = new Date(ms)
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const historyList = computed(() => store.history)
const pinnedList = computed(() => store.pinned)
onMounted(async () => {
await store.init()
await Promise.all([loadPage(), store.refreshPinned()])
form.value = { ...store.settings }
})
onUnmounted(() => {
store.dispose()
})
</script>
<template>
<div class="h-full p-6 overflow-y-auto">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<ClipboardList class="h-5 w-5 text-primary" />
剪贴板增强模块
</CardTitle>
</CardHeader>
<CardContent>
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
<ClipboardList class="h-16 w-16 mb-4 opacity-50" />
<p>剪贴板增强功能开发中...</p>
<p class="text-sm mt-2">支持剪贴板历史记录搜索固定常用条目等功能</p>
<div class="h-full p-6 overflow-hidden flex flex-col">
<Tabs v-model="activeTab" class="flex-1 min-h-0 flex flex-col">
<div ref="tabsListRef" class="shrink-0">
<TabsList class="grid w-full max-w-md grid-cols-3 !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="history" class="gap-1.5"><ClipboardList class="size-3.5" />历史</TabsTrigger>
<TabsTrigger value="pinned" class="gap-1.5"><Pin class="size-3.5" />固定</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
</TabsList>
</div>
<!-- 历史 -->
<TabsContent value="history" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate">
<div class="flex items-center gap-2 mb-3 shrink-0">
<div class="relative flex-1 max-w-sm">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input v-model="searchQuery" placeholder="搜索历史..." class="pl-8" />
</div>
<div class="flex items-center gap-1">
<Button
v-for="k in (['all','text','image','files'] as const)"
:key="k"
:variant="kindFilter === k ? 'default' : 'outline'"
size="sm"
@click="kindFilter = k"
>
{{ k === 'all' ? '全部' : k === 'text' ? '文本' : k === 'image' ? '图片' : '文件' }}
</Button>
</div>
<div class="flex-1" />
<Badge variant="secondary">
<Loader2 v-if="store.status.running" class="h-3 w-3 mr-1 animate-spin" />
{{ store.historyTotal }}
</Badge>
<Button variant="outline" size="sm" @click="clearOpen = true" :disabled="!historyList.length">
<Eraser class="h-4 w-4 mr-1" />清空
</Button>
</div>
</CardContent>
</Card>
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1">
<div
v-if="!historyList.length"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<ClipboardList class="h-12 w-12 mb-3 opacity-40" />
<p class="text-sm">暂无历史记录复制内容后将自动收录</p>
</div>
<Card
v-for="item in historyList"
:key="item.id"
class="group hover:shadow-md transition-shadow py-0"
>
<CardContent class="flex items-center gap-3 px-3 py-2">
<component :is="kindIcon(item.kind)" class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="flex-1 min-w-0 cursor-pointer" @click="openDetail(item)">
<p class="text-sm break-all line-clamp-1" :title="item.preview">{{ item.preview }}</p>
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
<Badge variant="outline" :class="['px-1.5 py-0 text-[10px] border', kindBadgeClass(item.kind)]">{{ kindLabel(item.kind) }}</Badge>
<span>{{ formatTime(item.createdAt) }}</span>
<span v-if="item.size">· {{ formatSize(item.size) }}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition">
<Button variant="ghost" size="icon" class="h-7 w-7" title="复制" @click.stop="handleCopy(item)">
<Copy class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" :title="item.pinned ? '取消固定' : '固定'" @click.stop="handlePin(item)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 pt-3 shrink-0">
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
<ChevronLeft class="h-4 w-4" />
</Button>
<span class="text-xs text-muted-foreground tabular-nums">{{ currentPage }} / {{ totalPages }}</span>
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</TabsContent>
<!-- 固定 -->
<TabsContent value="pinned" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate">
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1">
<div
v-if="!pinnedList.length"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<Pin class="h-12 w-12 mb-3 opacity-40" />
<p class="text-sm">暂无固定条目</p>
<p class="text-xs mt-1">鼠标悬停历史条目点击图钉按钮即可固定</p>
</div>
<Card v-for="item in pinnedList" :key="item.id" class="group hover:shadow-md transition-shadow py-0">
<CardContent class="flex items-center gap-3 px-3 py-2">
<component :is="kindIcon(item.kind)" class="h-4 w-4 text-primary shrink-0" />
<div class="flex-1 min-w-0 cursor-pointer" @click="openDetail(item)">
<p class="text-sm break-all line-clamp-1" :title="item.preview">{{ item.preview }}</p>
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
<Badge variant="outline" :class="['px-1.5 py-0 text-[10px] border', kindBadgeClass(item.kind)]">{{ kindLabel(item.kind) }}</Badge>
<span>{{ formatTime(item.createdAt) }}</span>
<span v-if="item.size">· {{ formatSize(item.size) }}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition">
<Button variant="ghost" size="icon" class="h-7 w-7" title="复制" @click.stop="handleCopy(item)">
<Copy class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" title="取消固定" @click.stop="handlePin(item)">
<PinOff class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<!-- 设置 -->
<TabsContent value="settings" class="flex-1 min-h-0 overflow-y-auto mt-4 tab-animate">
<div class="max-w-xl space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-base font-medium flex items-center gap-2">
<SettingsIcon class="h-4 w-4" />基本设置
</h3>
<Button @click="handleSaveSettings"><Check class="h-4 w-4 mr-1" />保存设置</Button>
</div>
<Card>
<CardContent class="space-y-4 p-4">
<div class="flex items-center justify-between">
<div>
<Label>启用剪贴板监听</Label>
<p class="text-xs text-muted-foreground mt-1">关闭后将停止记录剪贴板内容</p>
</div>
<Switch
:model-value="form.enabled"
@update:model-value="handleEnabledToggle"
/>
</div>
<div class="flex items-center justify-between">
<div>
<Label>记录文本</Label>
<p class="text-xs text-muted-foreground mt-1">收录复制/剪切的文本</p>
</div>
<Switch :model-value="form.recordText" @update:model-value="form.recordText = $event" />
</div>
<div class="flex items-center justify-between">
<div>
<Label>记录图片</Label>
<p class="text-xs text-muted-foreground mt-1">收录复制的图片截图等</p>
</div>
<Switch :model-value="form.recordImage" @update:model-value="form.recordImage = $event" />
</div>
<div class="flex items-center justify-between">
<div>
<Label>记录文件</Label>
<p class="text-xs text-muted-foreground mt-1">收录复制的文件列表</p>
</div>
<Switch :model-value="form.recordFiles" @update:model-value="form.recordFiles = $event" />
</div>
<div class="flex items-center justify-between">
<div>
<Label>内容去重</Label>
<p class="text-xs text-muted-foreground mt-1">相同内容只保留一条重复复制将置顶</p>
</div>
<Switch :model-value="form.dedup" @update:model-value="form.dedup = $event" />
</div>
</CardContent>
</Card>
<Card>
<CardContent class="space-y-4 p-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>最大历史条数</Label>
<Input v-model.number="form.maxItems" type="number" min="50" max="10000" />
<p class="text-xs text-muted-foreground">超出后自动清理最旧的非固定项</p>
</div>
<div class="space-y-2">
<Label>图片大小上限 (KB)</Label>
<Input v-model.number="form.maxImageKb" type="number" min="0" />
<p class="text-xs text-muted-foreground">0 表示不限制</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent class="space-y-4 p-4">
<div class="flex items-center justify-between">
<div>
<Label class="flex items-center gap-2">
<Keyboard class="h-4 w-4" />快捷弹窗快捷键
</Label>
<p class="text-xs text-muted-foreground mt-1">全局快捷键触发鼠标位置历史弹窗留空禁用</p>
</div>
<Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button>
</div>
<Input
v-model="form.shortcut"
placeholder="如 Alt+V / Ctrl+Shift+V"
@change="handleShortcutChange"
/>
<p class="text-xs text-muted-foreground">格式修饰键+主键 Ctrl+CAlt+VShift+F1修改后自动生效</p>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
<!-- 详情弹窗 -->
<Dialog v-model:open="detailOpen">
<DialogContent class="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>内容详情</DialogTitle>
<DialogDescription>{{ detail ? kindLabel(detail.kind as ClipboardKind) : '' }}</DialogDescription>
</DialogHeader>
<div class="flex-1 min-h-0 overflow-auto">
<div v-if="detailLoading" class="flex items-center justify-center py-12">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<template v-else-if="detail">
<img
v-if="detail.kind === 'image' && detail.imageBase64"
:src="`data:image/png;base64,${detail.imageBase64}`"
class="max-w-full max-h-[60vh] mx-auto rounded"
alt="剪贴板图片"
/>
<pre v-else-if="detail.kind === 'text'" class="text-sm whitespace-pre-wrap break-all font-mono bg-muted/50 p-3 rounded">{{ detail.content }}</pre>
<ul v-else-if="detail.kind === 'files'" class="space-y-1 text-sm">
<li
v-for="(p, i) in (parseFiles(detail.content))"
:key="i"
class="font-mono break-all p-2 rounded bg-muted/50"
>{{ p }}</li>
</ul>
</template>
</div>
</DialogContent>
</Dialog>
<!-- 清空确认 -->
<AlertDialog v-model:open="clearOpen">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清空历史记录</AlertDialogTitle>
<AlertDialogDescription>
将删除所有非固定历史条目此操作不可撤销
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction class="bg-destructive text-destructive-foreground hover:bg-destructive/90" @click="handleClear">
清空
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
</template>
<script lang="ts">
function parseFiles(content: string | null): string[] {
if (!content) return []
try {
return JSON.parse(content) as string[]
} catch {
return []
}
}
export default {}
</script>
+633
View File
@@ -0,0 +1,633 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { Effect, EffectState } from '@tauri-apps/api/window'
import {
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Loader2, ChevronLeft, ChevronRight,
} from '@lucide/vue'
import { ScrollArea } from '@/components/ui/scroll-area'
// ===== 与 Rust 端对应的数据结构(camelCase =====
type ClipboardKind = 'text' | 'image' | 'files'
interface ClipboardItem {
id: number
kind: ClipboardKind
preview: string
size: number
pinned: boolean
pinnedOrder: number | null
createdAt: number
}
interface HistoryPage {
items: ClipboardItem[]
total: number
}
// ===== 状态 =====
const items = ref<ClipboardItem[]>([])
const total = ref(0)
const currentPage = ref(1)
const PAGE_SIZE = 50
const searchQuery = ref('')
const selectedIndex = ref(0)
const loading = ref(false)
const searchInputRef = ref<HTMLInputElement | null>(null)
let unlistenFns: UnlistenFn[] = []
let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
// ===== 数据加载 =====
async function loadData() {
loading.value = true
try {
const q = searchQuery.value.trim()
const offset = (currentPage.value - 1) * PAGE_SIZE
let res: HistoryPage
if (q) {
res = await invoke<HistoryPage>('clipboard_search', { query: q, limit: PAGE_SIZE, offset })
} else {
res = await invoke<HistoryPage>('clipboard_get_history', { limit: PAGE_SIZE, offset, kind: 'all' })
}
items.value = res.items
total.value = res.total
selectedIndex.value = 0
} catch (e) {
console.error('[clipboard-popup] 加载失败:', e)
} finally {
loading.value = false
}
await nextTick()
scrollSelectedIntoView()
}
async function gotoPage(p: number) {
currentPage.value = Math.min(Math.max(1, p), totalPages.value)
await loadData()
}
// 防抖搜索
watch(searchQuery, () => {
currentPage.value = 1
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(loadData, 200)
})
// ===== 选择与粘贴 =====
/// 选中条目 → 写回剪贴板 → 隐藏窗口 → 模拟 Ctrl+V 粘贴到原窗口
async function selectAndPaste(item: ClipboardItem) {
try {
await invoke('clipboard_copy_back', { id: item.id })
// paste_to_target 会先隐藏窗口,再延迟模拟 Ctrl+V
await invoke('clipboard_paste_to_target')
} catch (e) {
console.error('[clipboard-popup] 粘贴失败:', e)
// 失败时至少隐藏窗口
await hideWindow()
}
}
async function togglePin(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_set_pinned', { id: item.id, pinned: !item.pinned })
await loadData()
} catch (e) {
console.error('[clipboard-popup] 固定失败:', e)
}
}
async function deleteItem(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_delete', { id: item.id })
items.value = items.value.filter((i) => i.id !== item.id)
} catch (e) {
console.error('[clipboard-popup] 删除失败:', e)
}
}
async function hideWindow() {
try {
await invoke('clipboard_hide_popup')
} catch {
/* 忽略 */
}
}
// ===== 键盘导航 =====
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
scrollSelectedIntoView()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
scrollSelectedIntoView()
} else if (e.key === 'Enter') {
e.preventDefault()
const item = items.value[selectedIndex.value]
if (item) selectAndPaste(item)
} else if (e.key === 'Escape') {
e.preventDefault()
hideWindow()
}
}
function scrollSelectedIntoView() {
nextTick(() => {
const el = document.querySelector('.popup-item-selected') as HTMLElement | null
el?.scrollIntoView({ block: 'nearest' })
})
}
// ===== 显示辅助 =====
const kindIcon = (k: ClipboardKind) => {
if (k === 'text') return FileText
if (k === 'image') return ImageIcon
return Files
}
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) =>
k === 'text'
? 'badge-text'
: k === 'image'
? 'badge-image'
: 'badge-files'
const formatTime = (ms: number) => {
const diff = Date.now() - ms
if (diff < 60_000) return '刚刚'
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}分钟前`
if (diff < 86_400_000) return `${Math.floor(diff / 3600_000)}小时前`
const d = new Date(ms)
return `${d.getMonth() + 1}/${d.getDate()}`
}
const hasItems = computed(() => items.value.length > 0)
// ===== 主题应用(与主应用同步) =====
/** 从 localStorage 读取主应用的主题设置 */
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
if (raw) {
const s = JSON.parse(raw)
return {
theme: s.theme ?? 'system',
effect: s.effect ?? 'mica',
}
}
} catch {
/* 忽略 */
}
return { theme: 'system', effect: 'mica' }
}
/** 判断当前是否应为深色主题。
* 弹窗是独立窗口,主应用的 setTheme 不影响弹窗的 matchMedia
* 因此 system 模式下用 matchMedia 是可靠的。 */
function resolveIsDark(theme: string): boolean {
if (theme === 'dark') return true
if (theme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
/** 应用主题和窗口效果(与主应用同步)。
* 关键:先 setTheme 让窗口主题正确,再用通用 Effect.Mica(自动跟随窗口主题深浅)。 */
async function applyTheme() {
const root = document.documentElement
const { theme, effect } = readMainTheme()
// 1. 先设置窗口原生主题(system → null 跟随系统)
try {
const tauriWin = getCurrentWindow()
if (theme === 'system') {
await tauriWin.setTheme(null)
} else {
await tauriWin.setTheme(theme as 'dark' | 'light')
}
} catch {
/* 非 Tauri 环境忽略 */
}
// 2. 窗口主题已正确,用 matchMedia 判断深浅(弹窗自身不受主应用污染)
const isDark = resolveIsDark(theme)
// 3. 设置 DOM class
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
// 4. 设置窗口效果与背景色
// 弹窗窗口创建时 transparent=true,透明窗口下原生背景色不显示,
// 需在实际可见的 DOM 元素(.popup-root)上设置背景色。
// 用 CSS 变量 --popup-bg 控制,mica/acrylic 模式下保持透明。
try {
const tauriWin = getCurrentWindow()
await tauriWin.clearEffects()
if (effect === 'mica') {
await tauriWin.setEffects({
effects: [Effect.Mica],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await tauriWin.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else if (effect === 'acrylic') {
await tauriWin.setEffects({
effects: [Effect.Acrylic],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await tauriWin.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else {
// 普通模式:透明窗口下原生背景不显示,由 DOM 提供背景色
await tauriWin.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
}
} catch {
/* 非 Tauri 环境忽略 */
}
}
onMounted(async () => {
// 先应用主题(含窗口效果)
await applyTheme()
// 监听系统主题变化(仅在 system 模式下有意义)
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onThemeChange = () => applyTheme()
mq.addEventListener('change', onThemeChange)
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// 监听弹窗显示事件:每次显示时重新同步主题 + 刷新数据
unlistenFns.push(await listen('clipboard-popup-show', async () => {
// 主应用可能切换了主题,每次显示前重新应用
await applyTheme()
searchQuery.value = ''
currentPage.value = 1
await loadData()
await nextTick()
searchInputRef.value?.focus()
}))
// 加载初始数据
await loadData()
await nextTick()
searchInputRef.value?.focus()
// 主题和数据都就绪后,调用 Rust 端显示窗口
try {
await invoke('clipboard_show_window')
} catch {
/* 忽略 */
}
})
onUnmounted(() => {
unlistenFns.forEach((fn) => fn())
})
</script>
<template>
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
<!-- 搜索栏 -->
<div class="popup-header shrink-0">
<div class="search-wrap">
<Search class="h-4 w-4 text-muted-foreground" />
<input
ref="searchInputRef"
v-model="searchQuery"
placeholder="搜索剪贴板历史..."
class="search-input"
spellcheck="false"
/>
</div>
<span class="popup-count">{{ total }} </span>
</div>
<!-- 列表 -->
<ScrollArea class="popup-list flex-1 min-h-0">
<div v-if="loading && !hasItems" class="popup-empty">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<div v-else-if="!hasItems" class="popup-empty">
<ClipboardList class="h-10 w-10 mb-2 opacity-40" />
<p class="text-sm text-muted-foreground">
{{ searchQuery ? '无匹配结果' : '暂无历史记录' }}
</p>
</div>
<div
v-for="(item, idx) in items"
:key="item.id"
class="popup-item"
:class="{ 'popup-item-selected': idx === selectedIndex }"
@click="selectAndPaste(item)"
@mouseenter="selectedIndex = idx"
>
<component :is="kindIcon(item.kind)" class="popup-item-icon" />
<div class="popup-item-body">
<p class="popup-item-preview">{{ item.preview }}</p>
<div class="popup-item-meta">
<span class="popup-item-kind" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
<span>{{ formatTime(item.createdAt) }}</span>
</div>
</div>
<div class="popup-item-actions">
<button class="popup-action-btn" title="固定" @click="togglePin(item, $event)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</button>
<button class="popup-action-btn hover:text-destructive" title="删除" @click="deleteItem(item, $event)">
<Trash2 class="h-3.5 w-3.5" />
</button>
</div>
</div>
</ScrollArea>
<!-- 分页 -->
<div v-if="totalPages > 1" class="popup-pagination">
<button class="popup-page-btn" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
<ChevronLeft class="h-3.5 w-3.5" />
</button>
<span class="popup-page-info">{{ currentPage }} / {{ totalPages }}</span>
<button class="popup-page-btn" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
<ChevronRight class="h-3.5 w-3.5" />
</button>
</div>
<!-- 底部提示 -->
<div class="popup-footer shrink-0">
<span><kbd></kbd><kbd></kbd> 导航</span>
<span><kbd>Enter</kbd> 粘贴</span>
<span><kbd>Esc</kbd> 关闭</span>
</div>
</div>
</template>
<style scoped>
/* 弹窗根容器:背景色由 --popup-bg 控制(mica/acrylic 透明,普通模式不透明) */
.popup-root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', sans-serif;
background: var(--popup-bg, transparent);
color: var(--foreground);
border-radius: 8px;
overflow: hidden;
}
/* 搜索栏 */
.popup-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.search-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
background: var(--input);
border-radius: 6px;
padding: 6px 10px;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--foreground);
font-size: 13px;
}
.search-input::placeholder {
color: var(--muted-foreground);
}
.popup-count {
font-size: 11px;
color: var(--muted-foreground);
white-space: nowrap;
}
/* 列表 */
.popup-list {
padding: 4px;
}
/* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */
.popup-list :deep([data-slot="scroll-area-viewport"] > div) {
min-height: 100%;
}
.popup-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
/* 预留边框空间,避免 hover/选中时布局抖动 */
border: 1px solid transparent;
/* 统一 hover 和 selected 为同一高亮效果,避免错乱 */
transition: background-color 0.1s, border-color 0.1s;
}
/* 鼠标悬停和键盘选中统一使用 accent 高亮 + 反色边框增强提醒 */
.popup-item:hover,
.popup-item-selected {
background: var(--accent);
border-color: var(--foreground);
}
.popup-item-icon {
width: 18px;
height: 18px;
margin-top: 1px;
color: var(--muted-foreground);
flex-shrink: 0;
}
.popup-item-body {
flex: 1;
min-width: 0;
}
.popup-item-preview {
font-size: 13px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
}
.popup-item-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 3px;
font-size: 11px;
color: var(--muted-foreground);
}
.popup-item-kind {
background: var(--secondary);
color: var(--secondary-foreground);
padding: 0 5px;
border-radius: 3px;
font-size: 10px;
border: 1px solid transparent;
}
/* 类型 badge 配色(与主界面一致) */
.popup-item-kind.badge-text {
background: rgba(59, 130, 246, 0.12);
color: rgb(37, 99, 235);
border-color: rgba(59, 130, 246, 0.4);
}
.popup-item-kind.badge-image {
background: rgba(16, 185, 129, 0.12);
color: rgb(5, 150, 105);
border-color: rgba(16, 185, 129, 0.4);
}
.popup-item-kind.badge-files {
background: rgba(245, 158, 11, 0.12);
color: rgb(217, 119, 6);
border-color: rgba(245, 158, 11, 0.4);
}
/* 深色主题下调整 badge 文字色 */
.dark .popup-item-kind.badge-text {
color: rgb(96, 165, 250);
}
.dark .popup-item-kind.badge-image {
color: rgb(52, 211, 153);
}
.dark .popup-item-kind.badge-files {
color: rgb(251, 191, 36);
}
.popup-item-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.1s;
flex-shrink: 0;
}
/* 选中项和悬停项都显示操作按钮 */
.popup-item:hover .popup-item-actions,
.popup-item-selected .popup-item-actions {
opacity: 1;
}
.popup-action-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--muted-foreground);
display: flex;
align-items: center;
}
.popup-action-btn:hover {
background: var(--muted);
}
/* 空状态 */
.popup-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: var(--muted-foreground);
}
/* 底部 */
.popup-footer {
display: flex;
justify-content: center;
gap: 16px;
padding: 6px 12px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--muted-foreground);
}
/* 分页 */
.popup-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 4px 12px;
border-top: 1px solid var(--border);
}
.popup-page-btn {
background: transparent;
border: 1px solid var(--border);
cursor: pointer;
padding: 3px;
border-radius: 4px;
color: var(--foreground);
display: flex;
align-items: center;
}
.popup-page-btn:hover:not(:disabled) {
background: var(--accent);
}
.popup-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.popup-page-info {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
min-width: 50px;
text-align: center;
}
.popup-footer kbd {
background: var(--muted);
color: var(--foreground);
padding: 1px 5px;
border-radius: 3px;
font-size: 10px;
margin-right: 2px;
font-family: inherit;
}
/* 滚动条 */
.popup-list::-webkit-scrollbar {
width: 6px;
}
.popup-list::-webkit-scrollbar-thumb {
background: var(--muted-foreground);
opacity: 0.3;
border-radius: 3px;
}
.popup-list::-webkit-scrollbar-track {
background: transparent;
}
</style>
+220 -21
View File
@@ -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 = () => {
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 tab-animate">
<div class="h-full flex flex-col gap-4">
<!-- 状态栏 -->
<Card class="shrink-0">
<CardContent class="pl-4 pr-4">
<Card class="shrink-0 !py-0 !gap-0">
<CardContent class="pl-4 pr-4 py-3">
<div class="flex items-center justify-between flex-wrap gap-3">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
@@ -611,8 +766,8 @@ const toggleSortOrder = () => {
</Button>
</div>
<div v-else key="task-list" class="flex flex-col gap-3 pb-4">
<Card v-for="task in pagedTasks" :key="task.id" class="overflow-hidden">
<CardContent class="pl-4 pr-4">
<Card v-for="task in pagedTasks" :key="task.id" class="overflow-hidden !py-0 !gap-0">
<CardContent class="pl-4 pr-4 py-3">
<div class="flex items-start justify-between gap-3 mb-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
@@ -893,6 +1048,17 @@ const toggleSortOrder = () => {
@update:model-value="(v: boolean) => store.settings && (store.settings.deleteFilesOnRemove = v)"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label for="check-duplicate" class="cursor-pointer">重复下载检查</Label>
<span class="text-xs text-muted-foreground">添加下载前检查 URL 和文件名重复,发现重复时询问</span>
</div>
<Switch
id="check-duplicate"
:model-value="store.settings?.checkDuplicate ?? true"
@update:model-value="(v: boolean) => store.settings && (store.settings.checkDuplicate = v)"
/>
</div>
</CardContent>
</Card>
@@ -1218,6 +1384,39 @@ const toggleSortOrder = () => {
</DialogContent>
</Dialog>
<!-- ===== 重复下载确认弹窗 ===== -->
<AlertDialog :open="duplicateDialogState.open">
<AlertDialogContent class="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle class="flex items-center gap-2">
<AlertCircle class="h-4 w-4 text-amber-500" />
下载重复提醒
</AlertDialogTitle>
<AlertDialogDescription class="text-xs">
<span class="block mb-2">
{{ duplicateKindText(duplicateDialogState.result?.duplicate ?? '') }}
</span>
<span class="block font-mono text-xs break-all">{{ duplicateDialogState.url }}</span>
<span v-if="duplicateDialogState.result?.existing" class="block mt-2 text-xs">
已存在
<span class="font-medium">{{ duplicateDialogState.result.existing.filename }}</span>
</span>
<span class="block mt-2 text-muted-foreground">
选择"仍然下载"将自动重命名追加序号选择"跳过"将不下载此链接
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="onDuplicateCancel">取消全部</AlertDialogCancel>
<Button variant="outline" @click="onDuplicateSkip">跳过此链接</Button>
<AlertDialogAction @click="onDuplicateConfirm">
<Download class="h-4 w-4" />
仍然下载
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- ===== 删除任务专用对话框"同时删除文件"开关 ===== -->
<AlertDialog :open="removeDialogState.open" @update:open="onRemoveOpenChange">
<AlertDialogContent>
+28 -15
View File
@@ -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 storeSSE 订阅保持,确保切走监控模块后 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
+8
View File
@@ -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
+8
View File
@@ -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 和窗口效果。
+293
View File
@@ -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<ClipboardItem[]>([])
const historyTotal = ref(0)
const pinned = ref<ClipboardItem[]>([])
const settings = ref<ClipboardSettings>({ ...DEFAULT_SETTINGS })
const status = ref<ClipboardStatus>({ running: false, count: 0 })
const loading = ref(false)
// 事件监听(应用级单例,只注册一次)
let changedUnlisten: UnlistenFn | null = null
let debounceTimer: ReturnType<typeof setTimeout> | null = null
/** 初始化:加载状态/设置,注册事件监听 */
const init = async () => {
try {
const [s, st] = await Promise.all([
invoke<ClipboardSettings>('clipboard_get_settings'),
invoke<ClipboardStatus>('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<HistoryPage>('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<ClipboardItem[]>('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<HistoryPage>('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<ClipboardItemDetail | null>('clipboard_get_item', { id })
} catch (e) {
logger.error('获取详情失败: ' + e)
return null
}
}
const refreshStatus = async () => {
try {
status.value = await invoke<ClipboardStatus>('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,
}
})
+37 -2
View File
@@ -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<string, string>
headers?: Record<string, string>,
autoRename = false
): Promise<string> => {
const id = await invoke<string>('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<string, string>
): Promise<CheckUrlResult> => {
return await invoke<CheckUrlResult>('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,