托盘
This commit is contained in:
Generated
+193
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// 暂停任务
|
||||
|
||||
@@ -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 设置控制:关闭时强制单线程、不支持续传)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 }))),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 不会误添加)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user