This commit is contained in:
zhongluofeng
2026-07-30 09:09:29 +08:00
parent 74902c4cec
commit f452322aad
32 changed files with 4586 additions and 115 deletions
+205
View File
@@ -0,0 +1,205 @@
//! Tauri 命令:剪贴板模块
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
use serde::Serialize;
use tauri::{AppHandle, State};
use super::manager::{ClipboardManager, ClipboardSettings};
use super::reader::dib_to_png;
use super::storage::{ClipboardItem, ClipboardItemDetail};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardStatus {
pub running: bool,
pub count: i64,
}
fn img_to_base64(b: &[u8]) -> Option<String> {
dib_to_png(b).map(|png| STANDARD.encode(&png))
}
/// 历史查询结果(含总数,用于分页)
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryPage {
pub items: Vec<ClipboardItem>,
pub total: i64,
}
#[tauri::command]
pub async fn clipboard_get_history(
limit: Option<i64>,
offset: Option<i64>,
kind: Option<String>,
manager: State<'_, ClipboardManager>,
) -> Result<HistoryPage, String> {
let storage = manager.storage();
let kind = kind.unwrap_or_else(|| "all".into());
let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0);
let items = storage.get_history(limit, offset, &kind);
let total = storage.count_kind(&kind);
Ok(HistoryPage { items, total })
}
#[tauri::command]
pub async fn clipboard_get_pinned(
manager: State<'_, ClipboardManager>,
) -> Result<Vec<ClipboardItem>, String> {
Ok(manager.storage().get_pinned())
}
#[tauri::command]
pub async fn clipboard_search(
query: String,
limit: Option<i64>,
offset: Option<i64>,
manager: State<'_, ClipboardManager>,
) -> Result<HistoryPage, String> {
let storage = manager.storage();
let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0);
let items = storage.search(&query, limit, offset);
let total = storage.count_search(&query);
Ok(HistoryPage { items, total })
}
#[tauri::command]
pub async fn clipboard_get_item(
id: i64,
manager: State<'_, ClipboardManager>,
) -> Result<Option<ClipboardItemDetail>, String> {
Ok(manager.storage().get_detail(id, img_to_base64))
}
#[tauri::command]
pub async fn clipboard_set_pinned(
id: i64,
pinned: bool,
manager: State<'_, ClipboardManager>,
) -> Result<bool, String> {
Ok(manager.storage().set_pinned(id, pinned))
}
#[tauri::command]
pub async fn clipboard_delete(
id: i64,
manager: State<'_, ClipboardManager>,
) -> Result<bool, String> {
Ok(manager.storage().delete(id))
}
#[tauri::command]
pub async fn clipboard_clear(manager: State<'_, ClipboardManager>) -> Result<bool, String> {
Ok(manager.storage().clear_non_pinned())
}
#[tauri::command]
pub async fn clipboard_copy_back(
id: i64,
manager: State<'_, ClipboardManager>,
) -> Result<(), String> {
manager.copy_back(id)
}
#[tauri::command]
pub async fn clipboard_count(manager: State<'_, ClipboardManager>) -> Result<i64, String> {
Ok(manager.storage().count())
}
#[tauri::command]
pub async fn clipboard_get_settings(
manager: State<'_, ClipboardManager>,
) -> Result<ClipboardSettings, String> {
Ok(manager.get_settings())
}
#[tauri::command]
pub async fn clipboard_save_settings(
settings: ClipboardSettings,
app: AppHandle,
manager: State<'_, ClipboardManager>,
) -> Result<(), String> {
let prev_enabled = manager.get_settings().enabled;
let prev_shortcut = manager.get_settings().shortcut.clone();
manager.save_settings(settings.clone());
// 监听开关变化时联动启停
if settings.enabled && !prev_enabled {
manager.start(&app);
} else if !settings.enabled && prev_enabled {
manager.stop();
}
// 快捷键变化时重新注册
if settings.shortcut != prev_shortcut {
super::popup::register_shortcut(&app, &settings.shortcut)?;
}
Ok(())
}
#[tauri::command]
pub async fn clipboard_status(
manager: State<'_, ClipboardManager>,
) -> Result<ClipboardStatus, String> {
Ok(ClipboardStatus {
running: manager.is_running(),
count: manager.storage().count(),
})
}
#[tauri::command]
pub async fn clipboard_start(app: AppHandle, manager: State<'_, ClipboardManager>) -> Result<(), String> {
manager.start(&app);
Ok(())
}
#[tauri::command]
pub async fn clipboard_stop(manager: State<'_, ClipboardManager>) -> Result<(), String> {
manager.stop();
Ok(())
}
/// 注册(或切换)快捷弹窗全局快捷键
#[tauri::command]
pub async fn clipboard_register_shortcut(
shortcut: String,
app: AppHandle,
) -> Result<(), String> {
super::popup::register_shortcut(&app, &shortcut)
}
/// 注销快捷弹窗全局快捷键
#[tauri::command]
pub async fn clipboard_unregister_shortcut(app: AppHandle) -> Result<(), String> {
super::popup::unregister_shortcut(&app);
Ok(())
}
/// 手动触发显示快捷弹窗(供 UI 按钮调用)
#[tauri::command]
pub async fn clipboard_show_popup(app: AppHandle) -> Result<(), String> {
super::popup::show_popup(&app);
Ok(())
}
/// 隐藏快捷弹窗
#[tauri::command]
pub async fn clipboard_hide_popup(app: AppHandle) -> Result<(), String> {
super::popup::hide_popup(&app);
Ok(())
}
/// 显示已创建的弹窗窗口(前端 onMounted 后调用)
#[tauri::command]
pub async fn clipboard_show_window(app: AppHandle) -> Result<(), String> {
super::popup::show_window(&app);
Ok(())
}
/// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口
#[tauri::command]
pub async fn clipboard_paste_to_target(app: AppHandle) -> Result<(), String> {
super::popup::paste_to_target(&app);
Ok(())
}
+187
View File
@@ -0,0 +1,187 @@
//! ClipboardManager:管理监听线程生命周期、设置持久化、copy_back。
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use serde::{Deserialize, Serialize};
use super::monitor::start_monitor;
use super::reader::{write_dib, write_files, write_text};
use super::storage::Storage;
/// 剪贴板设置(持久化到 clipboard/settings.json
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ClipboardSettings {
/// 监听是否启用
pub enabled: bool,
/// 非固定历史最大条数
pub max_items: i64,
/// 图片大小上限(KB),0 表示不限
pub max_image_kb: u64,
pub record_text: bool,
pub record_image: bool,
pub record_files: bool,
/// 去重(相同内容更新时间而非新增)
pub dedup: bool,
/// 快捷弹窗全局快捷键(如 "Alt+V",空字符串表示禁用)
#[serde(default = "default_shortcut")]
pub shortcut: String,
}
fn default_shortcut() -> String {
"Alt+V".to_string()
}
impl Default for ClipboardSettings {
fn default() -> Self {
Self {
enabled: true,
max_items: 500,
max_image_kb: 5120,
record_text: true,
record_image: true,
record_files: true,
dedup: true,
shortcut: default_shortcut(),
}
}
}
pub struct ClipboardManager {
storage: Arc<Storage>,
settings: Arc<Mutex<ClipboardSettings>>,
suppress: Arc<AtomicBool>,
monitor_stop: Arc<AtomicBool>,
monitor_handle: Mutex<Option<JoinHandle<()>>>,
settings_path: PathBuf,
}
impl ClipboardManager {
pub fn new(app_data_dir: PathBuf) -> Self {
let clip_dir = app_data_dir.join("clipboard");
let storage = match Storage::new(&clip_dir) {
Ok(s) => Arc::new(s),
Err(e) => {
eprintln!("[clipboard] 磁盘存储初始化失败,回退内存: {}", e);
Arc::new(Storage::new_in_memory())
}
};
let settings_path = clip_dir.join("settings.json");
let settings = Arc::new(Mutex::new(load_settings(&settings_path)));
let suppress = Arc::new(AtomicBool::new(false));
let monitor_stop = Arc::new(AtomicBool::new(true));
Self {
storage,
settings,
suppress,
monitor_stop,
monitor_handle: Mutex::new(None),
settings_path,
}
}
/// 启动监听(若已运行则跳过)
pub fn start(&self, app: &tauri::AppHandle) {
let mut handle = self.monitor_handle.lock().unwrap();
if handle.is_some() {
return;
}
self.monitor_stop.store(false, Ordering::SeqCst);
let h = start_monitor(
self.storage.clone(),
app.clone(),
self.settings.clone(),
self.suppress.clone(),
self.monitor_stop.clone(),
);
*handle = Some(h);
eprintln!("[clipboard] 监听已启动");
}
/// 停止监听
pub fn stop(&self) {
self.monitor_stop.store(true, Ordering::SeqCst);
if let Some(h) = self.monitor_handle.lock().unwrap().take() {
// 不阻塞等待;轮询线程最迟 800ms 后退出
let _ = h.join();
}
eprintln!("[clipboard] 监听已停止");
}
pub fn is_running(&self) -> bool {
self.monitor_handle.lock().unwrap().is_some()
}
pub fn get_settings(&self) -> ClipboardSettings {
self.settings.lock().unwrap().clone()
}
pub fn save_settings(&self, s: ClipboardSettings) {
{
*self.settings.lock().unwrap() = s.clone();
}
save_settings(&self.settings_path, &s);
}
/// 将某条历史写回剪贴板。写回前置 suppress 标志以避免再次记录。
pub fn copy_back(&self, id: i64) -> Result<(), String> {
let (kind, content, blob) = self
.storage
.get_raw_for_copy(id)
.ok_or_else(|| "条目不存在".to_string())?;
self.suppress.store(true, Ordering::SeqCst);
let ok = match kind.as_str() {
"text" => content.as_deref().map(write_text).unwrap_or(false),
"image" => blob.as_deref().map(write_dib).unwrap_or(false),
"files" => {
let paths: Vec<String> = content
.as_deref()
.and_then(|c| serde_json::from_str(c).ok())
.unwrap_or_default();
if paths.is_empty() {
false
} else {
write_files(&paths)
}
}
_ => false,
};
if ok {
Ok(())
} else {
// 写入失败也清除 suppress,避免误吞下次复制
self.suppress.store(false, Ordering::SeqCst);
Err("写回剪贴板失败".into())
}
}
pub fn storage(&self) -> &Arc<Storage> {
&self.storage
}
}
impl Drop for ClipboardManager {
fn drop(&mut self) {
self.stop();
}
}
fn load_settings(path: &Path) -> ClipboardSettings {
match fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => ClipboardSettings::default(),
}
}
fn save_settings(path: &Path, s: &ClipboardSettings) {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(s) {
let _ = fs::write(path, json);
}
}
+17
View File
@@ -0,0 +1,17 @@
//! 剪贴板增强模块:监听、SQLite 持久化、多格式预览、固定、写回、快捷弹窗。
pub mod commands;
pub mod manager;
pub mod monitor;
pub mod popup;
pub mod reader;
pub mod storage;
pub use commands::{
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup,
clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search,
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
};
pub use manager::ClipboardManager;
+169
View File
@@ -0,0 +1,169 @@
//! 剪贴板监听线程:基于 GetClipboardSequenceNumber 轮询
//!
//! 选用轮询而非 AddClipboardFormatListener 消息窗口:实现更简单、无需消息循环,
//! 800ms 间隔对剪贴板场景延迟可接受,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use super::reader::{read_clipboard, ClipData};
use super::storage::{NewItem, Storage};
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
/// 启动监听线程,返回 JoinHandle。
pub fn start_monitor(
storage: Arc<Storage>,
app: AppHandle,
settings: Arc<Mutex<super::manager::ClipboardSettings>>,
suppress: Arc<AtomicBool>,
stop: Arc<AtomicBool>,
) -> thread::JoinHandle<()> {
thread::spawn(move || loop {
if stop.load(Ordering::SeqCst) {
break;
}
thread::sleep(Duration::from_millis(800));
if stop.load(Ordering::SeqCst) {
break;
}
let seq = unsafe { GetClipboardSequenceNumber() };
// 首次记录基准;后续对比变化
let last = LAST_SEQ.with(|c| {
let prev = c.get().unwrap_or(seq);
c.set(Some(seq));
prev
});
if seq == last {
continue;
}
// 序列号变化,处理一次
if suppress.swap(false, Ordering::SeqCst) {
// 由本应用 copy_back 触发,跳过记录
continue;
}
let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = {
let s = settings.lock().unwrap();
(
s.record_text,
s.record_image,
s.record_files,
s.max_items,
s.max_image_kb,
s.dedup,
)
};
let data = match read_clipboard() {
Some(d) => d,
None => continue,
};
let item = match &data {
ClipData::Text(t) => {
if !rec_text {
continue;
}
build_text_item(t)
}
ClipData::Image { dib, width, height } => {
if !rec_image {
continue;
}
if max_image_kb > 0 && (dib.len() as u64 / 1024) > max_image_kb {
continue;
}
build_image_item(dib, *width, *height)
}
ClipData::Files(fs) => {
if !rec_files {
continue;
}
build_files_item(fs)
}
};
if let Some(_id) = storage.insert_or_touch(item, dedup) {
storage.prune_to_max(max_items);
let _ = app.emit("clipboard-changed", ());
}
})
}
thread_local! {
static LAST_SEQ: std::cell::Cell<Option<u32>> = std::cell::Cell::new(None);
}
fn build_text_item(t: &str) -> NewItem {
NewItem {
kind: "text".into(),
content: Some(t.to_string()),
blob: None,
preview: make_preview(t, 200),
size: t.len() as i64,
hash: hash_str(t),
}
}
fn build_image_item(dib: &[u8], w: u32, h: u32) -> NewItem {
NewItem {
kind: "image".into(),
content: None,
blob: Some(dib.to_vec()),
preview: format!("图片 {}×{}", w, h),
size: dib.len() as i64,
hash: hash_bytes(dib),
}
}
fn build_files_item(files: &[String]) -> NewItem {
let content = serde_json::to_string(files).unwrap_or_default();
let preview = if files.len() == 1 {
make_preview(&files[0], 200)
} else {
format!(
"{} 个文件 · {}",
files.len(),
make_preview(files.first().map(|s| s.as_str()).unwrap_or(""), 60)
)
};
let hash = hash_str(&content);
NewItem {
kind: "files".into(),
content: Some(content),
blob: None,
preview,
size: files.iter().map(|f| f.len()).sum::<usize>() as i64,
hash,
}
}
fn make_preview(s: &str, max_chars: usize) -> String {
let single: String = s
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let trimmed = single.trim().to_string();
if trimmed.chars().count() <= max_chars {
trimmed
} else {
let truncated: String = trimmed.chars().take(max_chars).collect();
format!("{}", truncated)
}
}
fn hash_str(s: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut h = DefaultHasher::new();
h.write(s.as_bytes());
format!("{:016x}", h.finish())
}
fn hash_bytes(b: &[u8]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut h = DefaultHasher::new();
h.write(b);
format!("{:016x}", h.finish())
}
+254
View File
@@ -0,0 +1,254 @@
//! 剪贴板快捷弹窗:在鼠标位置显示历史记录弹窗。
//!
//! 流程:
//! 1. 全局快捷键触发 → `show_popup` 在鼠标位置创建(隐藏)或显示窗口
//! 2. 前端 Vue 挂载完成、主题应用后调用 `clipboard_show_window` 显示窗口
//! 3. 前端监听 `clipboard-popup-show` 事件刷新数据
//! 4. 用户选择 → 前端调用 `clipboard_copy_back` 写回剪贴板
//! 5. 前端调用 `clipboard_paste_to_target` → 隐藏窗口 → 延迟模拟 Ctrl+V 粘贴
//! 6. 窗口失焦自动隐藏
use std::sync::Mutex;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
use tauri::window::{Effect, EffectsBuilder};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
/// 弹窗窗口标签
pub const POPUP_LABEL: &str = "clipboard-popup";
/// 当前注册的快捷键(用于注销旧快捷键)
static CURRENT_SHORTCUT: Mutex<Option<String>> = Mutex::new(None);
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+V"、"Ctrl+Shift+V"
/// 失败返回 None。
pub fn parse_shortcut(s: &str) -> Option<Shortcut> {
s.trim().parse::<Shortcut>().ok()
}
/// 注册全局快捷键。重复调用会先注销旧快捷键。
/// 传入空字符串则仅注销不注册。
pub fn register_shortcut(app: &AppHandle, shortcut_str: &str) -> Result<(), String> {
// 先注销旧快捷键
unregister_shortcut(app);
if shortcut_str.trim().is_empty() {
return Ok(());
}
let shortcut = parse_shortcut(shortcut_str)
.ok_or_else(|| format!("无效的快捷键: {}", shortcut_str))?;
let app_handle = app.clone();
app.global_shortcut()
.on_shortcut(shortcut, move |_app, _shortcut, event| {
// 仅在按下时触发(松开不触发)
if event.state == ShortcutState::Pressed {
show_popup(&app_handle);
}
})
.map_err(|e| format!("注册快捷键失败: {}", e))?;
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
*cur = Some(shortcut_str.to_string());
}
eprintln!("[clipboard] 已注册快捷键: {}", shortcut_str);
Ok(())
}
/// 注销当前快捷键
pub fn unregister_shortcut(app: &AppHandle) {
if let Ok(cur) = CURRENT_SHORTCUT.lock() {
if let Some(ref s) = *cur {
if let Some(shortcut) = parse_shortcut(s) {
let _ = app.global_shortcut().unregister(shortcut);
}
}
}
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
*cur = None;
}
}
/// 在鼠标当前位置显示弹窗。
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
/// 窗口已存在则移动到鼠标位置并显示。
pub fn show_popup(app: &AppHandle) {
// 获取鼠标位置(屏幕坐标)
let (mx, my) = match get_cursor_pos() {
Some(p) => p,
None => return,
};
// 窗口尺寸(逻辑像素)
let w = 380.0_f64;
let h = 460.0_f64;
// 获取屏幕工作区以避免弹出位置超出屏幕
let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0));
// 计算窗口位置:鼠标位置作为左上角,超出屏幕则调整
let x = (mx as f64).min(screen_w - w).max(0.0);
let y = (my as f64).min(screen_h - h).max(0.0);
// 窗口已存在:移动 + 显示 + 请求焦点
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition {
x,
y,
}));
let _ = win.show();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit("clipboard-popup-show", ());
return;
}
// 首次创建窗口:先隐藏,等前端挂载完成后再显示
// 这样确保主题效果(dark class、mica)在窗口可见前已应用
let win = match WebviewWindowBuilder::new(
app,
POPUP_LABEL,
WebviewUrl::App("index.html#clipboard-popup".into()),
)
.title("剪贴板")
.inner_size(w, h)
.position(x, y)
.decorations(false)
.transparent(true)
.shadow(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false) // 关键:先隐藏,前端 onMounted 后调用 show_window
.focused(true)
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
.build()
{
Ok(w) => w,
Err(e) => {
eprintln!("[clipboard] 创建弹窗失败: {}", e);
return;
}
};
// 监听窗口失焦:自动隐藏
let app_handle = app.clone();
let win_handle = win.clone();
win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
let _ = win_handle.hide();
let _ = app_handle.emit("clipboard-popup-hide", ());
}
});
eprintln!("[clipboard] 弹窗窗口已创建(隐藏状态)");
}
/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。
pub fn show_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.show();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit("clipboard-popup-show", ());
}
}
/// 隐藏弹窗(不销毁,保留复用)
pub fn hide_popup(app: &AppHandle) {
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.hide();
}
}
/// 隐藏弹窗后延迟模拟 Ctrl+V 粘贴到之前聚焦的窗口。
/// 延迟 150ms 确保焦点切换回原窗口后再发送按键。
pub fn paste_to_target(app: &AppHandle) {
// 先隐藏弹窗,让焦点回到原窗口
hide_popup(app);
// 在独立线程中延迟模拟 Ctrl+V
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(150));
simulate_paste();
});
}
/// 使用 Win32 SendInput 模拟 Ctrl+V 按键
#[cfg(windows)]
fn simulate_paste() {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP,
VK_CONTROL,
};
const VK_V: u16 = 0x56;
// 安全初始化为零值,避免未定义字段
let mut inputs: [INPUT; 4] = unsafe { std::mem::zeroed() };
// Ctrl down
inputs[0].r#type = INPUT_KEYBOARD;
inputs[0].Anonymous.ki = KEYBDINPUT { wVk: VK_CONTROL, wScan: 0, dwFlags: 0, time: 0, dwExtraInfo: 0 };
// V down
inputs[1].r#type = INPUT_KEYBOARD;
inputs[1].Anonymous.ki = KEYBDINPUT { wVk: VK_V, wScan: 0, dwFlags: 0, time: 0, dwExtraInfo: 0 };
// V up
inputs[2].r#type = INPUT_KEYBOARD;
inputs[2].Anonymous.ki = KEYBDINPUT { wVk: VK_V, wScan: 0, dwFlags: KEYEVENTF_KEYUP, time: 0, dwExtraInfo: 0 };
// Ctrl up
inputs[3].r#type = INPUT_KEYBOARD;
inputs[3].Anonymous.ki = KEYBDINPUT { wVk: VK_CONTROL, wScan: 0, dwFlags: KEYEVENTF_KEYUP, time: 0, dwExtraInfo: 0 };
unsafe {
SendInput(4, inputs.as_ptr(), std::mem::size_of::<INPUT>() as i32);
}
}
#[cfg(not(windows))]
fn simulate_paste() {
// 非 Windows 平台暂不支持自动粘贴
}
// ===== Win32 API:获取鼠标位置和工作区 =====
#[cfg(windows)]
mod win_api {
use windows_sys::Win32::Foundation::POINT;
use windows_sys::Win32::UI::WindowsAndMessaging::{GetCursorPos, SystemParametersInfoW, SPI_GETWORKAREA};
/// 获取鼠标位置(屏幕坐标,逻辑像素)
pub fn get_cursor_pos() -> Option<(i32, i32)> {
let mut pt = POINT { x: 0, y: 0 };
unsafe {
if GetCursorPos(&mut pt) != 0 {
Some((pt.x, pt.y))
} else {
None
}
}
}
/// 获取屏幕工作区(排除任务栏,逻辑像素)
pub fn get_work_area() -> Option<(f64, f64)> {
use windows_sys::Win32::Foundation::RECT;
let mut rect = RECT { left: 0, top: 0, right: 0, bottom: 0 };
unsafe {
if SystemParametersInfoW(SPI_GETWORKAREA, 0, &mut rect as *mut _ as *mut _, 0) != 0 {
Some(((rect.right - rect.left) as f64, (rect.bottom - rect.top) as f64))
} else {
None
}
}
}
}
#[cfg(not(windows))]
mod win_api {
pub fn get_cursor_pos() -> Option<(i32, i32)> { None }
pub fn get_work_area() -> Option<(f64, f64)> { None }
}
pub use win_api::{get_cursor_pos, get_work_area};
+280
View File
@@ -0,0 +1,280 @@
//! Win32 剪贴板读取与写回
//!
//! 读取优先级:文本 > 图片 > 文件。写回支持三种格式。
//! 句柄类型说明(windows-sys 0.52):
//! - HWND / HANDLE = isize
//! - HGLOBAL / HLOCAL = *mut c_void
//! - HDROP = isize(与 HANDLE 同)
use windows_sys::Win32::System::DataExchange::{
CloseClipboard, EmptyClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard,
SetClipboardData,
};
use windows_sys::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT};
use windows_sys::Win32::System::Ole::{CF_DIB, CF_HDROP, CF_UNICODETEXT};
use windows_sys::Win32::UI::Shell::DragQueryFileW;
/// 剪贴板当前内容
pub enum ClipData {
Text(String),
Image { dib: Vec<u8>, width: u32, height: u32 },
Files(Vec<String>),
}
/// 读取剪贴板(需在 monitor 线程或命令线程调用)。OpenClipboard 失败返回 None。
pub fn read_clipboard() -> Option<ClipData> {
unsafe {
if OpenClipboard(0) == 0 {
return None;
}
let result = if IsClipboardFormatAvailable(CF_UNICODETEXT as u32) != 0 {
read_text()
} else if IsClipboardFormatAvailable(CF_DIB as u32) != 0 {
read_image()
} else if IsClipboardFormatAvailable(CF_HDROP as u32) != 0 {
read_files()
} else {
None
};
CloseClipboard();
result
}
}
unsafe fn read_text() -> Option<ClipData> {
let h = GetClipboardData(CF_UNICODETEXT as u32); // HANDLE = isize
if h == 0 {
return None;
}
let hglob = h as *mut core::ffi::c_void; // 实际是 HGLOBAL
let ptr = GlobalLock(hglob) as *const u16;
if ptr.is_null() {
return None;
}
let mut len = 0usize;
while *ptr.add(len) != 0 {
len += 1;
}
let slice = core::slice::from_raw_parts(ptr, len);
let s = String::from_utf16_lossy(slice);
GlobalUnlock(hglob);
if s.is_empty() {
None
} else {
Some(ClipData::Text(s))
}
}
unsafe fn read_image() -> Option<ClipData> {
let h = GetClipboardData(CF_DIB as u32);
if h == 0 {
return None;
}
let hglob = h as *mut core::ffi::c_void;
let ptr = GlobalLock(hglob) as *const u8;
if ptr.is_null() {
return None;
}
let size = GlobalSize(hglob);
let slice = core::slice::from_raw_parts(ptr, size);
let dib = slice.to_vec();
GlobalUnlock(hglob);
let (w, hgt) = dib_info(&dib).unwrap_or((0, 0));
Some(ClipData::Image {
dib,
width: w,
height: hgt,
})
}
unsafe fn read_files() -> Option<ClipData> {
let h = GetClipboardData(CF_HDROP as u32); // HANDLE = isize
if h == 0 {
return None;
}
let hdrop = h; // HDROP = isize
let count = DragQueryFileW(hdrop, 0xFFFFFFFF, core::ptr::null_mut(), 0);
let mut files = Vec::with_capacity(count as usize);
for i in 0..count {
let len = DragQueryFileW(hdrop, i, core::ptr::null_mut(), 0);
if len == 0 {
continue;
}
let mut buf = vec![0u16; (len as usize) + 1];
let got = DragQueryFileW(hdrop, i, buf.as_mut_ptr(), buf.len() as u32);
let s = String::from_utf16_lossy(&buf[..got as usize]);
files.push(s);
}
// 剪贴板拥有句柄,不调用 DragFinish
if files.is_empty() {
None
} else {
Some(ClipData::Files(files))
}
}
// ===== 写回剪贴板(copy_back =====
/// 写回文本。失败返回 false。写回会清空其他格式。
pub fn write_text(s: &str) -> bool {
unsafe {
if OpenClipboard(0) == 0 {
return false;
}
let r = write_text_inner(s);
CloseClipboard();
r
}
}
unsafe fn write_text_inner(s: &str) -> bool {
if EmptyClipboard() == 0 {
return false;
}
let mut utf16: Vec<u16> = s.encode_utf16().collect();
utf16.push(0); // null terminator
let byte_len = utf16.len() * 2;
let hglob = GlobalAlloc(GMEM_MOVEABLE, byte_len);
if hglob.is_null() {
return false;
}
let ptr = GlobalLock(hglob) as *mut u16;
if ptr.is_null() {
return false;
}
core::ptr::copy_nonoverlapping(utf16.as_ptr(), ptr, utf16.len());
GlobalUnlock(hglob);
SetClipboardData(CF_UNICODETEXT as u32, hglob as isize) != 0
}
/// 写回图片(CF_DIB 原始字节)。
pub fn write_dib(dib: &[u8]) -> bool {
unsafe {
if OpenClipboard(0) == 0 {
return false;
}
let r = (|| {
if EmptyClipboard() == 0 {
return false;
}
let hglob = GlobalAlloc(GMEM_MOVEABLE, dib.len());
if hglob.is_null() {
return false;
}
let ptr = GlobalLock(hglob) as *mut u8;
if ptr.is_null() {
return false;
}
core::ptr::copy_nonoverlapping(dib.as_ptr(), ptr, dib.len());
GlobalUnlock(hglob);
SetClipboardData(CF_DIB as u32, hglob as isize) != 0
})();
CloseClipboard();
r
}
}
/// 写回文件列表(CF_HDROP)。
pub fn write_files(paths: &[String]) -> bool {
unsafe {
if OpenClipboard(0) == 0 {
return false;
}
let r = (|| {
if EmptyClipboard() == 0 {
return false;
}
// DROPFILES 头部 20 字节:pFiles(u32) + pt.x(i32) + pt.y(i32) + fNC(i32) + fWide(i32)
const DF_SIZE: usize = 20;
let mut wide: Vec<u16> = Vec::new();
for p in paths {
wide.extend(p.encode_utf16());
wide.push(0); // 每条字符串 null 结尾
}
wide.push(0); // 末尾额外 null 表示列表结束
let total = DF_SIZE + wide.len() * 2;
let hglob = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, total);
if hglob.is_null() {
return false;
}
let ptr = GlobalLock(hglob) as *mut u8;
if ptr.is_null() {
return false;
}
let offset = DF_SIZE as u32;
let off_bytes = offset.to_ne_bytes();
core::ptr::copy_nonoverlapping(off_bytes.as_ptr(), ptr, 4); // pFiles
let fwide: u32 = 1;
let fwide_bytes = fwide.to_ne_bytes();
core::ptr::copy_nonoverlapping(fwide_bytes.as_ptr(), ptr.add(16), 4); // fWide = 1
let str_ptr = ptr.add(DF_SIZE) as *mut u16;
core::ptr::copy_nonoverlapping(wide.as_ptr(), str_ptr, wide.len());
GlobalUnlock(hglob);
SetClipboardData(CF_HDROP as u32, hglob as isize) != 0
})();
CloseClipboard();
r
}
}
// ===== DIB → PNG(用于预览) =====
/// 从 DIB 字节解析宽高
pub fn dib_info(dib: &[u8]) -> Option<(u32, u32)> {
if dib.len() < 40 {
return None;
}
let width = i32::from_le_bytes([dib[4], dib[5], dib[6], dib[7]]);
let height = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]);
if width <= 0 {
return None;
}
Some((width as u32, height.abs() as u32))
}
/// 将 CF_DIB 字节转换为 PNG 字节(仅支持 24/32bpp BI_RGB)。
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
use image::codecs::png::PngEncoder;
use image::ImageEncoder;
if dib.len() < 40 {
return None;
}
let header_size = u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]);
let width = i32::from_le_bytes([dib[4], dib[5], dib[6], dib[7]]);
let height_raw = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]);
let bpp = u16::from_le_bytes([dib[14], dib[15]]);
let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]);
if width <= 0 || compression != 0 {
return None;
}
if bpp != 24 && bpp != 32 {
return None;
}
let height = height_raw.abs();
let top_down = height_raw < 0;
let pixel_offset = header_size as usize; // 24/32bpp 无调色板
let row_size = ((bpp as u32 * width as u32 + 31) / 32) * 4;
let bytes_per_pixel = (bpp / 8) as usize;
let needed = pixel_offset + (row_size as usize) * (height as usize);
if dib.len() < needed {
return None;
}
let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4];
for y in 0..height {
let src_row = if top_down { y } else { height - 1 - y };
let src_off = pixel_offset + (src_row as usize) * (row_size as usize);
for x in 0..width {
let sp = src_off + (x as usize) * bytes_per_pixel;
let dp = (y as usize) * (width as usize) * 4 + (x as usize) * 4;
rgba[dp] = dib[sp + 2]; // R
rgba[dp + 1] = dib[sp + 1]; // G
rgba[dp + 2] = dib[sp]; // B
rgba[dp + 3] = 255; // A
}
}
let mut buf = Vec::new();
let enc = PngEncoder::new(&mut buf);
enc.write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8)
.ok()?;
Some(buf)
}
+375
View File
@@ -0,0 +1,375 @@
//! SQLite 持久化:剪贴板历史记录
//!
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
use rusqlite::{params, Connection, OptionalExtension};
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
/// 列表项(不含大字段,用于历史/搜索结果)
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardItem {
pub id: i64,
pub kind: String,
pub preview: String,
pub size: i64,
pub pinned: bool,
pub pinned_order: Option<i64>,
pub created_at: i64,
}
/// 详情(含文本内容或图片 base64)
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardItemDetail {
#[serde(flatten)]
pub item: ClipboardItem,
/// 文本内容 / 文件列表 JSON
pub content: Option<String>,
/// 图片 PNG base64(仅 image 类型)
pub image_base64: Option<String>,
}
/// 新捕获的剪贴板数据
pub struct NewItem {
pub kind: String,
pub content: Option<String>,
pub blob: Option<Vec<u8>>,
pub preview: String,
pub size: i64,
pub hash: String,
}
pub struct Storage {
conn: Mutex<Connection>,
}
impl Storage {
pub fn new(data_dir: &PathBuf) -> std::io::Result<Self> {
fs::create_dir_all(data_dir)?;
let db_path = data_dir.join("history.db");
let conn = Connection::open(&db_path)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
conn.busy_timeout(std::time::Duration::from_secs(3)).ok();
Self::init_db(&conn);
Ok(Self {
conn: Mutex::new(conn),
})
}
/// 内存数据库回退(磁盘初始化失败时使用,数据不持久化)
pub fn new_in_memory() -> Self {
let conn = Connection::open_in_memory().expect("open_in_memory");
Self::init_db(&conn);
Self {
conn: Mutex::new(conn),
}
}
fn init_db(conn: &Connection) {
let _ = conn.execute_batch(
"CREATE TABLE IF NOT EXISTS clipboard_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
content TEXT,
blob BLOB,
preview TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
hash TEXT NOT NULL DEFAULT '',
pinned INTEGER NOT NULL DEFAULT 0,
pinned_order INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_history(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_pinned ON clipboard_history(pinned, pinned_order);
CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_history(hash);
CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_history(kind);",
);
}
/// 插入新条目;若 dedup 为 true 且 hash 已存在则仅更新 created_at,返回条目 id。
/// 返回 None 表示未插入(内容为空)。
pub fn insert_or_touch(&self, item: NewItem, dedup: bool) -> Option<i64> {
if item.preview.is_empty() && item.content.is_none() && item.blob.is_none() {
return None;
}
let conn = self.conn.lock().ok()?;
if dedup {
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM clipboard_history WHERE hash = ?1",
params![item.hash],
|r| r.get(0),
)
.optional()
.ok()
.flatten();
if let Some(id) = existing {
// 更新时间戳到当前(置顶到列表最前)
let now = now_ms();
let _ = conn.execute(
"UPDATE clipboard_history SET created_at = ?1 WHERE id = ?2",
params![now, id],
);
return Some(id);
}
}
let now = now_ms();
let res = conn.execute(
"INSERT INTO clipboard_history
(kind, content, blob, preview, size, hash, pinned, pinned_order, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)",
params![
item.kind,
item.content,
item.blob.as_deref(),
item.preview,
item.size,
item.hash,
now,
],
);
if res.is_err() {
return None;
}
Some(conn.last_insert_rowid())
}
/// 获取非固定的历史记录(按时间倒序)
pub fn get_history(&self, limit: i64, offset: i64, kind: &str) -> Vec<ClipboardItem> {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return vec![],
};
let sql = if kind == "all" {
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
FROM clipboard_history WHERE pinned = 0
ORDER BY created_at DESC LIMIT ?1 OFFSET ?2"
} else {
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
FROM clipboard_history WHERE pinned = 0 AND kind = ?1
ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
};
let mut stmt = match conn.prepare(sql) {
Ok(s) => s,
Err(_) => return vec![],
};
let rows = if kind == "all" {
stmt.query_map(params![limit, offset], row_to_item)
} else {
stmt.query_map(params![kind, limit, offset], row_to_item)
};
rows.map(|r| r.filter_map(|i| i.ok()).collect())
.unwrap_or_default()
}
/// 获取固定条目(按 pinned_order,未设置则按时间倒序)
pub fn get_pinned(&self) -> Vec<ClipboardItem> {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return vec![],
};
let mut stmt = match conn.prepare(
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
FROM clipboard_history WHERE pinned = 1
ORDER BY pinned_order ASC NULLS LAST, created_at DESC",
) {
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map([], row_to_item)
.map(|r| r.filter_map(|i| i.ok()).collect())
.unwrap_or_default()
}
/// 搜索(跨固定/非固定,按时间倒序)
pub fn search(&self, query: &str, limit: i64, offset: i64) -> Vec<ClipboardItem> {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return vec![],
};
let like = format!("%{}%", query);
let mut stmt = match conn.prepare(
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
FROM clipboard_history
WHERE pinned = 0 AND (preview LIKE ?1 OR content LIKE ?1)
ORDER BY created_at DESC LIMIT ?2 OFFSET ?3",
) {
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map(params![like, limit, offset], row_to_item)
.map(|r| r.filter_map(|i| i.ok()).collect())
.unwrap_or_default()
}
/// 获取详情(含文本/图片预览 base64)
pub fn get_detail(&self, id: i64, image_to_base64: impl Fn(&[u8]) -> Option<String>) -> Option<ClipboardItemDetail> {
let conn = self.conn.lock().ok()?;
let row = conn
.query_row(
"SELECT id, kind, preview, size, pinned, pinned_order, created_at, content, blob
FROM clipboard_history WHERE id = ?1",
params![id],
|r| {
let kind: String = r.get(1)?;
let content: Option<String> = r.get(7)?;
let blob: Option<Vec<u8>> = r.get(8)?;
Ok((
ClipboardItem {
id: r.get(0)?,
kind,
preview: r.get(2)?,
size: r.get(3)?,
pinned: r.get::<_, i64>(4)? != 0,
pinned_order: r.get(5)?,
created_at: r.get(6)?,
},
content,
blob,
))
},
)
.ok()?;
let (item, content, blob) = row;
let image_base64 = if item.kind == "image" {
blob.as_deref().and_then(|b| image_to_base64(b))
} else {
None
};
Some(ClipboardItemDetail {
item,
content,
image_base64,
})
}
/// 获取原始字段供 copy_back 写回(避免 base64 转换开销)
pub fn get_raw_for_copy(&self, id: i64) -> Option<(String, Option<String>, Option<Vec<u8>>)> {
let conn = self.conn.lock().ok()?;
conn.query_row(
"SELECT kind, content, blob FROM clipboard_history WHERE id = ?1",
params![id],
|r| Ok((r.get::<_, String>(0)?, r.get(1)?, r.get(2)?)),
)
.ok()
}
/// 切换固定状态。设为固定时分配一个递减的 pinned_order(靠前)。
pub fn set_pinned(&self, id: i64, pinned: bool) -> bool {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return false,
};
if pinned {
// 取当前最小 pinned_order,再 -1 使其排到最前
let min_order: Option<i64> = conn
.query_row("SELECT MIN(pinned_order) FROM clipboard_history WHERE pinned = 1", [], |r| r.get(0))
.ok()
.flatten();
let new_order = min_order.unwrap_or(0) - 1;
conn.execute(
"UPDATE clipboard_history SET pinned = 1, pinned_order = ?1 WHERE id = ?2",
params![new_order, id],
)
.is_ok()
} else {
conn.execute(
"UPDATE clipboard_history SET pinned = 0, pinned_order = NULL WHERE id = ?1",
params![id],
)
.is_ok()
}
}
pub fn delete(&self, id: i64) -> bool {
self.conn
.lock()
.ok()
.map(|c| c.execute("DELETE FROM clipboard_history WHERE id = ?1", params![id]).is_ok())
.unwrap_or(false)
}
/// 清空所有非固定条目
pub fn clear_non_pinned(&self) -> bool {
self.conn
.lock()
.ok()
.map(|c| c.execute("DELETE FROM clipboard_history WHERE pinned = 0", []).is_ok())
.unwrap_or(false)
}
/// 超过 max 时自动清理最旧的非固定条目
pub fn prune_to_max(&self, max_items: i64) {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return,
};
let _ = conn.execute(
"DELETE FROM clipboard_history
WHERE pinned = 0 AND id IN (
SELECT id FROM clipboard_history
WHERE pinned = 0
ORDER BY created_at DESC
LIMIT -1 OFFSET ?1
)",
params![max_items],
);
}
pub fn count(&self) -> i64 {
self.count_kind("all")
}
/// 按类型统计非固定条目总数(kind="all" 表示全部)
pub fn count_kind(&self, kind: &str) -> i64 {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return 0,
};
let sql = if kind == "all" {
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0"
} else {
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND kind = ?1"
};
if kind == "all" {
conn.query_row(sql, [], |r| r.get(0)).ok().unwrap_or(0)
} else {
conn.query_row(sql, params![kind], |r| r.get(0)).ok().unwrap_or(0)
}
}
/// 按搜索关键词统计匹配的非固定条目总数
pub fn count_search(&self, query: &str) -> i64 {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return 0,
};
let pattern = format!("%{}%", query);
conn.query_row(
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND preview LIKE ?1",
params![pattern],
|r| r.get(0),
)
.ok()
.unwrap_or(0)
}
}
fn row_to_item(r: &rusqlite::Row<'_>) -> rusqlite::Result<ClipboardItem> {
Ok(ClipboardItem {
id: r.get(0)?,
kind: r.get(1)?,
preview: r.get(2)?,
size: r.get(3)?,
pinned: r.get::<_, i64>(4)? != 0,
pinned_order: r.get(5)?,
created_at: r.get(6)?,
})
}
pub fn now_ms() -> i64 {
chrono::Local::now().timestamp_millis()
}
+36 -2
View File
@@ -3,7 +3,7 @@ use std::collections::HashMap;
use tauri::{AppHandle, State};
use tauri_plugin_opener::OpenerExt;
use super::engine::DownloadEngine;
use super::engine::{CheckUrlResult, DownloadEngine};
use super::task::{DownloadTask, DownloaderSettings};
/// 获取所有任务
@@ -12,6 +12,39 @@ pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTa
engine.get_tasks()
}
/// 检查 URL 重复性并探测文件信息(添加下载前调用)
#[tauri::command]
pub async fn downloader_check_url(
engine: State<'_, DownloadEngine>,
url: String,
dir: Option<String>,
headers: Option<HashMap<String, String>>,
) -> Result<CheckUrlResult, String> {
let headers = headers.unwrap_or_default();
let (probe, duplicate, existing) = engine.check_url(&url, dir.as_deref(), &headers).await;
let result = match probe {
Ok(p) => CheckUrlResult {
ok: true,
error: None,
filename: p.filename.clone(),
total_size: p.total_size,
supports_resume: p.supports_resume,
duplicate,
existing,
},
Err(e) => CheckUrlResult {
ok: false,
error: Some(e),
filename: None,
total_size: None,
supports_resume: false,
duplicate,
existing,
},
};
Ok(result)
}
/// 添加下载任务
#[tauri::command]
pub async fn downloader_add_task(
@@ -20,8 +53,9 @@ pub async fn downloader_add_task(
filename: Option<String>,
dir: Option<String>,
headers: Option<HashMap<String, String>>,
auto_rename: Option<bool>,
) -> Result<String, String> {
engine.add_task(url, filename, dir, headers.unwrap_or_default()).await
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false)).await
}
/// 暂停任务
+146 -2
View File
@@ -9,7 +9,50 @@ use tauri::{AppHandle, Emitter};
use super::http_dl::{HttpDownloader, split_segments};
use super::rate_limit::RateLimiter;
use super::storage::{EngineState, Storage};
use super::task::{DownloadTask, DownloaderSettings, Segment, TaskStatus};
use super::task::{DownloadTask, DownloaderSettings, ProbeResult, Segment, TaskStatus};
/// 重复类型
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum DuplicateKind {
/// 无重复
None,
/// URL 重复(已有相同链接的任务)
Url,
/// 文件名重复(已有同名任务下载到同一目录)
Filename,
/// 磁盘文件已存在
FileExists,
}
/// 已存在的任务信息(用于前端展示)
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExistingTaskInfo {
pub id: String,
pub filename: String,
pub status: TaskStatus,
}
/// check_url 命令返回的结果
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckUrlResult {
/// 探测是否成功
pub ok: bool,
/// 错误信息(探测失败时)
pub error: Option<String>,
/// 文件名(探测成功时)
pub filename: Option<String>,
/// 文件大小(字节)
pub total_size: Option<u64>,
/// 是否支持断点续传
pub supports_resume: bool,
/// 重复类型
pub duplicate: DuplicateKind,
/// 已存在的任务信息
pub existing: Option<ExistingTaskInfo>,
}
/// 进度事件载荷(发给前端 download-progress 事件)
#[derive(Debug, Clone, serde::Serialize)]
@@ -130,13 +173,109 @@ impl DownloadEngine {
// ===================== 公开 API =====================
/// 检查 URL 重复性并探测文件信息。
/// 返回 (探测结果, 重复类型, 已存在任务信息)。
pub async fn check_url(
&self,
url: &str,
dir: Option<&str>,
headers: &HashMap<String, String>,
) -> (Result<ProbeResult, String>, DuplicateKind, Option<ExistingTaskInfo>) {
let probe = self.inner.http.probe(url, headers).await;
let settings = self.inner.settings.lock().unwrap().clone();
let task_dir = dir.map(|d| d.to_string()).unwrap_or_else(|| settings.download_dir.clone());
let filename = probe.as_ref().ok()
.and_then(|p| p.filename.clone())
.unwrap_or_else(|| {
url.split('?').next()
.and_then(|u| u.rsplit('/').next())
.filter(|n| !n.is_empty())
.map(|n| n.to_string())
.unwrap_or_else(|| format!("download_{}", chrono::Utc::now().timestamp()))
});
let mut duplicate = DuplicateKind::None;
let mut existing: Option<ExistingTaskInfo> = None;
{
let tasks = self.inner.tasks.lock().unwrap();
for t in tasks.values() {
// URL 完全相同
if t.url == url {
duplicate = DuplicateKind::Url;
existing = Some(ExistingTaskInfo {
id: t.id.clone(),
filename: t.filename.clone(),
status: t.status.clone(),
});
break;
}
// 目标文件名 + 目录相同(可能 URL 不同但下载到同一文件)
if t.filename == filename && t.dir == task_dir {
duplicate = DuplicateKind::Filename;
existing = Some(ExistingTaskInfo {
id: t.id.clone(),
filename: t.filename.clone(),
status: t.status.clone(),
});
break;
}
}
}
// 检查磁盘文件是否已存在(仅探测成功时)
if duplicate == DuplicateKind::None {
if let Ok(ref _p) = probe {
let filepath = PathBuf::from(&task_dir).join(&filename);
if filepath.exists() {
duplicate = DuplicateKind::FileExists;
existing = Some(ExistingTaskInfo {
id: String::new(),
filename,
status: TaskStatus::Complete,
});
}
}
}
(probe, duplicate, existing)
}
/// 生成不冲突的文件名(同名时追加 (1)、(2)...)
fn generate_unique_filename(&self, dir: &str, filename: &str) -> String {
let path = PathBuf::from(dir).join(filename);
if !path.exists() {
return filename.to_string();
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("download");
let ext = path.extension().and_then(|s| s.to_str());
for i in 1..1000 {
let new_name = match ext {
Some(e) => format!("{} ({}).{}", stem, i, e),
None => format!("{} ({})", stem, i),
};
let new_path = PathBuf::from(dir).join(&new_name);
if !new_path.exists() {
return new_name;
}
}
// 极端情况:追加时间戳
match ext {
Some(e) => format!("{} ({}).{}", stem, chrono::Utc::now().timestamp_millis(), e),
None => format!("{} ({})", stem, chrono::Utc::now().timestamp_millis()),
}
}
/// 添加下载任务
/// auto_rename: 同名时自动重命名(追加 (1)、(2)...),否则覆盖
pub async fn add_task(
&self,
url: String,
filename: Option<String>,
dir: Option<String>,
headers: HashMap<String, String>,
auto_rename: bool,
) -> Result<String, String> {
// 探测资源信息
let probe = self.inner.http.probe(&url, &headers).await;
@@ -145,7 +284,7 @@ impl DownloadEngine {
let task_dir = dir.unwrap_or_else(|| settings.download_dir.clone());
// 确定文件名
let task_filename = filename
let mut task_filename = filename
.or_else(|| probe.as_ref().ok().and_then(|p| p.filename.clone()))
.unwrap_or_else(|| {
url.split('?')
@@ -156,6 +295,11 @@ impl DownloadEngine {
.unwrap_or_else(|| format!("download_{}", chrono::Utc::now().timestamp()))
});
// 自动重命名:若磁盘已存在同名文件,追加 (1)、(2)...
if auto_rename {
task_filename = self.generate_unique_filename(&task_dir, &task_filename);
}
let id = self.inner.storage.next_task_id();
// 创建分段(受 continue_download 设置控制:关闭时强制单线程、不支持续传)
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod storage;
pub mod task;
pub use commands::{
downloader_add_task, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status,
};
+1 -1
View File
@@ -108,7 +108,7 @@ async fn create_download(
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
}
match state.engine.add_task(req.url, req.filename, req.dir, req.headers).await {
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await {
Ok(id) => Ok(Json(CreateDownloadResponse { id })),
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
}
+5
View File
@@ -125,6 +125,9 @@ pub struct DownloaderSettings {
/// 删除任务时是否同时删除已下载的文件
#[serde(default)]
pub delete_files_on_remove: bool,
/// 添加下载前检查重复(URL 或文件名重复时询问)
#[serde(default = "default_true")]
pub check_duplicate: bool,
}
fn default_max_concurrent() -> u32 {
@@ -162,11 +165,13 @@ impl Default for DownloaderSettings {
extension_port: default_extension_port(),
extension_secret: String::new(),
delete_files_on_remove: false,
check_duplicate: true,
}
}
}
/// HEAD/Range 探测结果
#[derive(Debug, Clone)]
pub struct ProbeResult {
/// 文件大小(字节),None=未知
pub total_size: Option<u64>,
+59 -47
View File
@@ -1,6 +1,7 @@
use std::sync::Arc;
use tauri::Manager;
mod clipboard;
mod download_engine;
mod logger;
mod mihomo_manager;
@@ -8,10 +9,12 @@ mod monitor_kernel;
mod network_monitor;
mod osd_window;
mod process_manager;
mod snap_fix;
mod tray_menu;
use download_engine::{
DownloadEngine, ExtensionServer,
downloader_add_task, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status,
};
@@ -40,6 +43,14 @@ use process_manager::{
get_all_process_status, get_process_status, start_monitoring_thread, start_process,
stop_all_processes, stop_process, ProcessManager,
};
use clipboard::{
ClipboardManager,
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup,
clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search,
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
};
#[tauri::command]
fn greet(name: &str) -> String {
@@ -52,6 +63,7 @@ fn quit_app(
mihomo: tauri::State<'_, MihomoManager>,
engine: tauri::State<'_, DownloadEngine>,
monitor: tauri::State<'_, MonitorKernel>,
clipboard: tauri::State<'_, ClipboardManager>,
app: tauri::AppHandle,
) {
// 退出前清理系统代理,避免遗留导致网络问题
@@ -60,6 +72,8 @@ fn quit_app(
engine.cleanup_on_exit();
// 停止提权 Kernel(普通权限由 stop_all 统一清理,提权 Kernel 需通过 /shutdown
tauri::async_runtime::block_on(monitor.cleanup_on_exit(&app));
// 停止剪贴板监听线程
clipboard.stop();
// 停止所有子进程(同步 kill + 带超时的 wait,确保进程真正终止)
state.stop_all();
// 通过 app.exit 触发 RunEvent::ExitRequested,统一退出路径
@@ -72,6 +86,7 @@ pub fn run() {
.plugin(tauri_plugin_autostart::Builder::new().build())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(
tauri_plugin_snap_layout::init()
.button_id("titlebar-maximize")
@@ -136,6 +151,7 @@ pub fn run() {
osd_stop_watch,
downloader_get_tasks,
downloader_add_task,
downloader_check_url,
downloader_pause_task,
downloader_resume_task,
downloader_remove_task,
@@ -144,7 +160,28 @@ pub fn run() {
downloader_status,
downloader_get_extension_info,
downloader_open_dir,
downloader_open_url
downloader_open_url,
clipboard_get_history,
clipboard_get_pinned,
clipboard_search,
clipboard_get_item,
clipboard_set_pinned,
clipboard_delete,
clipboard_clear,
clipboard_copy_back,
clipboard_count,
clipboard_get_settings,
clipboard_save_settings,
clipboard_status,
clipboard_start,
clipboard_stop,
clipboard_register_shortcut,
clipboard_unregister_shortcut,
clipboard_show_popup,
clipboard_show_window,
clipboard_hide_popup,
clipboard_paste_to_target,
snap_fix::fix_snap_background
])
.setup(|app| {
// 初始化日志系统,日志目录: {app_data_dir}/logs/
@@ -196,52 +233,24 @@ pub fn run() {
ExtensionServer::start(server_engine, server_port, server_secret).await;
});
let open = tauri::menu::MenuItem::with_id(app, "open", "设置", true, None::<&str>)?;
let quit = tauri::menu::MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?;
// 初始化 ClipboardManager,数据目录: {app_data_dir}/clipboard/
let clipboard = ClipboardManager::new(app_data_dir.clone());
// 应用启动时若已启用则自动开始监听
if clipboard.get_settings().enabled {
clipboard.start(&app.handle());
}
// 应用启动时注册快捷弹窗全局快捷键
let shortcut = clipboard.get_settings().shortcut.clone();
if !shortcut.trim().is_empty() {
let app_handle = app.handle().clone();
if let Err(e) = clipboard::popup::register_shortcut(&app_handle, &shortcut) {
eprintln!("[clipboard] 快捷键注册失败: {}", e);
}
}
app.manage(clipboard);
let _tray = tauri::tray::TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
"open" => {
if let Some(window) = app.get_webview_window("main") {
window.show().ok();
window.set_focus().ok();
}
}
"quit" => {
if let Some(mihomo) = app.try_state::<MihomoManager>() {
mihomo.cleanup_on_exit();
}
if let Some(engine) = app.try_state::<DownloadEngine>() {
engine.cleanup_on_exit();
}
if let Some(monitor) = app.try_state::<MonitorKernel>() {
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
}
if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all();
}
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::Click {
button: tauri::tray::MouseButton::Left,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
window.show().ok();
window.set_focus().ok();
}
}
})
.build(app)?;
// 自定义托盘菜单(代理/OSD/Kernel/下载/设置/退出)
tray_menu::create_tray_menu(app.handle())?;
// 启动进程监控线程
start_monitoring_thread(app.handle().clone());
@@ -286,6 +295,9 @@ pub fn run() {
if let Some(monitor) = app.try_state::<MonitorKernel>() {
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
}
if let Some(clip) = app.try_state::<ClipboardManager>() {
clip.stop();
}
if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all();
}
+18
View File
@@ -825,6 +825,24 @@ impl MihomoManager {
}
}
/// 开启系统代理(托盘菜单调用)
pub fn enable_system_proxy(&self) -> Result<(), String> {
let settings = self.load_settings();
let addr = format!("127.0.0.1:{}", settings.mixed_port);
set_system_proxy_windows(&addr)?;
let mut s = settings;
s.system_proxy = true;
self.save_settings(&s)
}
/// 关闭系统代理(托盘菜单调用)
pub fn disable_system_proxy(&self) -> Result<(), String> {
clear_system_proxy_windows()?;
let mut s = self.load_settings();
s.system_proxy = false;
self.save_settings(&s)
}
// ---------- 订阅管理 ----------
pub async fn import_profile(&self, url: &str, name: &str) -> Result<ProfileMeta, String> {
// 先读取当前 settings(此时新 profile 文件还未写入,reconcile 不会误添加)
+92
View File
@@ -0,0 +1,92 @@
//! 修复 tauri-plugin-snap-layout 子窗口的白色背景问题。
//!
//! snap-layout 插件创建的原生 Win32 STATIC 子窗口默认有白色背景,
//! 切换主题/效果时 clearEffects/setEffects 触发窗口重绘,
//! 导致子窗口白色背景显现,遮挡按钮样式。
//!
//! 本模块通过子类化拦截 WM_ERASEBKGND 和 WM_PAINT,阻止白色背景绘制。
//! 幂等:可多次调用,SetWindowSubclass 重复调用会替换旧实例。
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use tauri::{AppHandle, Manager};
use windows_sys::Win32::{
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
Graphics::Gdi::{BeginPaint, EndPaint, PAINTSTRUCT},
UI::{
Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass},
WindowsAndMessaging::{GetPropW, IsWindow, WM_ERASEBKGND, WM_NCDESTROY, WM_PAINT, WM_PRINTCLIENT},
},
};
/// snap-layout 在父窗口上存储子窗口 HWND 的属性名(与 snap.rs 中 CHILD_PROP 一致)
const CHILD_PROP: &[u16] = &[
b'T' as u16, b'a' as u16, b'u' as u16, b'r' as u16, b'i' as u16,
b'S' as u16, b'n' as u16, b'a' as u16, b'p' as u16, b'C' as u16,
b'h' as u16, b'i' as u16, b'l' as u16, b'd' as u16, 0,
];
/// 子类化 ID(与 snap-layout 的 SUBCLASS_ID 不同)
const FIX_SUBCLASS_ID: usize = 0x736E_6170_6669_7800;
/// 子类化过程:拦截背景绘制相关消息
/// - WM_ERASEBKGND: 返回 1,阻止 DefWindowProcW 用白色画刷填充背景
/// - WM_PAINT: 调用 BeginPaint/EndPaint 但不绘制任何内容,阻止 DefWindowProcW 绘制白色背景
/// - WM_PRINTCLIENT: 返回 0,阻止打印白色背景
unsafe extern "system" fn fix_subclass_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
_subclass_id: usize,
_ref_data: usize,
) -> LRESULT {
match msg {
WM_ERASEBKGND => return 1,
WM_PAINT => {
// 调用 BeginPaint/EndPaint 清除 WM_PAINT 消息,但不绘制任何内容
let mut ps: PAINTSTRUCT = std::mem::zeroed();
BeginPaint(hwnd, &mut ps);
EndPaint(hwnd, &ps);
return 0;
}
WM_PRINTCLIENT => return 0,
WM_NCDESTROY => {
RemoveWindowSubclass(hwnd, Some(fix_subclass_proc), FIX_SUBCLASS_ID);
}
_ => {}
}
DefSubclassProc(hwnd, msg, wparam, lparam)
}
/// 查找主窗口的 snap-layout 子窗口并做子类化修复。
/// 幂等:可多次调用,重复子类化无副作用。
#[tauri::command]
pub async fn fix_snap_background(app: AppHandle) -> Result<(), String> {
let main_window = app
.get_webview_window("main")
.ok_or("main window not found")?;
let hwnd_isize: isize = {
let handle = main_window.window_handle().map_err(|e| e.to_string())?;
match handle.as_raw() {
RawWindowHandle::Win32(h) => h.hwnd.get() as isize,
_ => return Err("not win32".to_string()),
}
};
main_window
.run_on_main_thread(move || unsafe {
let parent = hwnd_isize as HWND;
if IsWindow(parent) == 0 {
return;
}
// snap-layout 把子窗口 HWND 存在父窗口的 "TauriSnapChild" 属性中
let child = GetPropW(parent, CHILD_PROP.as_ptr()) as HWND;
if child != 0 && IsWindow(child) != 0 {
SetWindowSubclass(child, Some(fix_subclass_proc), FIX_SUBCLASS_ID, 0);
}
})
.map_err(|e| e.to_string())?;
Ok(())
}
+738
View File
@@ -0,0 +1,738 @@
//! tray_menu.rs — 自定义系统托盘右键菜单
//!
//! 菜单结构:
//! 代理 ▸
//! 开启代理 (启动mihomo + 择优选节点 + 系统代理 + Windows通知)
//! 关闭代理
//! ---
//! 选择代理 ▸
//! 刷新节点列表
//! ---
//! 节点1 (120ms)
//! 节点2 (超时)
//! ...
//! ---
//! 切换 OSD
//! 重启 Kernel
//! ---
//! 新建下载
//! ---
//! 常规设置
//! 退出
use std::sync::{Mutex, OnceLock};
use tauri::{
AppHandle, Emitter, Manager,
menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
tray::{MouseButton, TrayIconBuilder, TrayIconEvent, TrayIconId},
};
use crate::clipboard::ClipboardManager;
use crate::download_engine::DownloadEngine;
use crate::mihomo_manager::MihomoManager;
use crate::monitor_kernel::MonitorKernel;
use crate::process_manager::{ProcessManager, ProcessStatus};
// ===== 全局状态:代理节点菜单项映射 =====
/// menu_id → (group, node_name)
static PROXY_NODE_MAP: OnceLock<Mutex<Vec<(String, String)>>> = OnceLock::new();
fn proxy_node_map() -> &'static Mutex<Vec<(String, String)>> {
PROXY_NODE_MAP.get_or_init(|| Mutex::new(Vec::new()))
}
// ===== 伪节点过滤(与前端 ProxyModule 保持一致) =====
const PSEUDO_KEYWORDS: &[&str] = &[
"DIRECT",
"REJECT",
"PASS",
"COMPATIBLE",
"流量",
"套餐",
"到期",
"续费",
"官网",
"网站",
"刷新",
"更新",
"",
"",
"",
"",
"",
"×",
];
fn is_pseudo_node(name: &str) -> bool {
let upper = name.trim().to_uppercase();
if upper == "DIRECT" || upper == "REJECT" || upper == "PASS" || upper == "GLOBAL" {
return true;
}
PSEUDO_KEYWORDS.iter().any(|kw| name.contains(kw))
}
// ===== 状态查询 =====
fn is_proxy_running(app: &AppHandle) -> bool {
if let Some(pm) = app.try_state::<ProcessManager>() {
if let Some(info) = pm.get_status("proxy") {
return matches!(info.status, ProcessStatus::Running);
}
}
false
}
fn is_monitor_running(app: &AppHandle) -> bool {
if let Some(pm) = app.try_state::<ProcessManager>() {
if let Some(info) = pm.get_status("monitor") {
if matches!(info.status, ProcessStatus::Running) {
return true;
}
}
}
if let Some(monitor) = app.try_state::<MonitorKernel>() {
if monitor.is_elevated() {
return true;
}
}
false
}
// ===== 从 mihomo /proxies 响应中提取主选择组及其节点 =====
/// 返回 (组名, 有效节点列表, 当前节点)
fn parse_main_group(proxies_response: &serde_json::Value) -> Option<(String, Vec<String>, String)> {
let proxies = proxies_response.get("proxies")?.as_object()?;
// 收集所有可选择组
let selector_groups: Vec<&str> = proxies
.iter()
.filter(|(_, v)| {
v.get("type").and_then(|t| t.as_str()).map(|t| t == "Selector").unwrap_or(false)
})
.map(|(k, _)| k.as_str())
.collect();
if selector_groups.is_empty() {
return None;
}
// 优先选择名为 PROXY / Proxy / 节点选择 / 代理 的组
let preferred = selector_groups
.iter()
.find(|name| matches!(**name, "PROXY" | "Proxy" | "节点选择" | "代理"))
.copied();
let group_name = preferred.unwrap_or(selector_groups[0]);
let group = &proxies[group_name];
let now = group.get("now").and_then(|n| n.as_str()).unwrap_or("").to_string();
let all: Vec<String> = group
.get("all")
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.filter(|n| !is_pseudo_node(n))
.collect()
})
.unwrap_or_default();
Some((group_name.to_string(), all, now))
}
/// 从 /proxies 响应中读取某节点最近一次延迟
fn parse_node_delay(proxies_response: &serde_json::Value, name: &str) -> Option<u32> {
proxies_response
.get("proxies")
.and_then(|p| p.get(name))
.and_then(|n| n.get("history"))
.and_then(|h| h.as_array())
.and_then(|arr| arr.first())
.and_then(|first| first.get("delay"))
.and_then(|d| d.as_u64())
.map(|d| d as u32)
}
// ===== 菜单构建 =====
/// 获取代理节点数据(异步,调用 mihomo API)
async fn fetch_proxy_nodes(app: &AppHandle) -> Option<(String, Vec<(String, Option<u32>)>, String)> {
let mihomo = app.state::<MihomoManager>();
let proxies = mihomo.get_proxies().await.ok()?;
let (group, nodes, now) = parse_main_group(&proxies)?;
let node_delays: Vec<(String, Option<u32>)> = nodes
.iter()
.map(|name| (name.clone(), parse_node_delay(&proxies, name)))
.collect();
Some((group, node_delays, now))
}
fn build_menu(
app: &AppHandle,
proxy_running: bool,
monitor_running: bool,
proxy_nodes: Option<(String, Vec<(String, Option<u32>)>, String)>,
) -> Result<Menu<tauri::Wry>, tauri::Error> {
// ---------- 代理子菜单 ----------
let enable_proxy = MenuItem::with_id(
app,
"proxy_enable",
"开启代理",
!proxy_running,
None::<&str>,
)?;
let disable_proxy = MenuItem::with_id(
app,
"proxy_disable",
"关闭代理",
proxy_running,
None::<&str>,
)?;
let proxy_sep = PredefinedMenuItem::separator(app)?;
// 选择代理子菜单
let (node_menu_items, node_sep) = build_proxy_select_items(app, proxy_running, &proxy_nodes)?;
// "刷新节点列表"项(仅代理运行时创建,提前声明以保证引用生命周期)
let refresh = if proxy_running {
Some(MenuItem::with_id(app, "proxy_refresh", "🔄 刷新节点列表", true, None::<&str>)?)
} else {
None
};
// 组装选择代理子菜单
let mut select_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = Vec::new();
if !proxy_running {
// 只有一个 hint 项
for item in &node_menu_items {
select_refs.push(item);
}
} else {
if let Some(ref r) = refresh {
select_refs.push(r);
}
if let Some(ref sep) = node_sep {
select_refs.push(sep);
}
for item in &node_menu_items {
select_refs.push(item);
}
}
let select_proxy = Submenu::with_items(app, "选择代理", proxy_running, &select_refs)?;
// 组装代理子菜单
let proxy_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = vec![&enable_proxy, &disable_proxy, &proxy_sep, &select_proxy];
let proxy_submenu = Submenu::with_items(app, "代理", true, &proxy_refs)?;
// ---------- 分隔线 ----------
let sep1 = PredefinedMenuItem::separator(app)?;
// ---------- OSD + Kernel ----------
let osd_toggle = MenuItem::with_id(app, "osd_toggle", "切换 OSD", true, None::<&str>)?;
let kernel_restart = MenuItem::with_id(
app,
"kernel_restart",
"重启 Kernel",
monitor_running,
None::<&str>,
)?;
// ---------- 分隔线 ----------
let sep2 = PredefinedMenuItem::separator(app)?;
// ---------- 新建下载 ----------
let download_new = MenuItem::with_id(app, "download_new", "新建下载", true, None::<&str>)?;
// ---------- 分隔线 ----------
let sep3 = PredefinedMenuItem::separator(app)?;
// ---------- 系统菜单 ----------
let settings = MenuItem::with_id(app, "settings", "常规设置", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
// ---------- 组装主菜单 ----------
let main_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = vec![
&proxy_submenu,
&sep1,
&osd_toggle,
&kernel_restart,
&sep2,
&download_new,
&sep3,
&settings,
&quit,
];
Menu::with_items(app, &main_refs)
}
/// 构建代理节点菜单项(返回 MenuItem 列表 + 可选分隔线)
fn build_proxy_select_items(
app: &AppHandle,
proxy_running: bool,
proxy_nodes: &Option<(String, Vec<(String, Option<u32>)>, String)>,
) -> Result<(Vec<MenuItem<tauri::Wry>>, Option<PredefinedMenuItem<tauri::Wry>>), tauri::Error> {
if !proxy_running {
let hint = MenuItem::with_id(app, "proxy_noop", "代理未运行", false, None::<&str>)?;
return Ok((vec![hint], None));
}
let (group, nodes, now) = match proxy_nodes {
Some(data) => data,
None => {
let hint = MenuItem::with_id(app, "proxy_loading", "加载中…", false, None::<&str>)?;
return Ok((vec![hint], None));
}
};
if nodes.is_empty() {
let hint = MenuItem::with_id(app, "proxy_noop", "无可用节点", false, None::<&str>)?;
return Ok((vec![hint], None));
}
let sep = PredefinedMenuItem::separator(app)?;
// 清空并重建全局映射
{
let mut map = proxy_node_map().lock().unwrap();
map.clear();
}
let mut items: Vec<MenuItem<tauri::Wry>> = Vec::new();
// 限制节点数量避免菜单过长
for (idx, (name, delay)) in nodes.iter().take(30).enumerate() {
let delay_text = match delay {
Some(0) => "(超时)".to_string(),
Some(d) => format!("({}ms)", d),
None => "(未测试)".to_string(),
};
let is_current = name == now;
let prefix = if is_current { "" } else { " " };
let text = format!("{}{} {}", prefix, name, delay_text);
let menu_id = format!("proxy_node_{}", idx);
let item = MenuItem::with_id(app, &menu_id, &text, true, None::<&str>)?;
// 记录映射
{
let mut map = proxy_node_map().lock().unwrap();
map.push((group.clone(), name.clone()));
}
items.push(item);
}
Ok((items, Some(sep)))
}
// ===== 异步菜单重建 =====
async fn rebuild_menu(app: &AppHandle) {
let proxy_running = is_proxy_running(app);
let monitor_running = is_monitor_running(app);
let proxy_nodes = if proxy_running {
fetch_proxy_nodes(app).await
} else {
None
};
if let Ok(menu) = build_menu(app, proxy_running, monitor_running, proxy_nodes) {
if let Some(tray) = app.tray_by_id(&TrayIconId::new("main")) {
let _ = tray.set_menu(Some(menu));
}
}
}
// ===== 菜单事件处理 =====
fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
let id = event.id().as_ref().to_string();
match id.as_str() {
"proxy_enable" => {
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = enable_proxy(&app).await {
eprintln!("[tray] 开启代理失败: {}", e);
send_notification("代理启动失败", &e);
}
});
}
"proxy_disable" => {
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = disable_proxy(&app).await {
eprintln!("[tray] 关闭代理失败: {}", e);
send_notification("代理关闭失败", &e);
}
});
}
"proxy_refresh" => {
let app = app.clone();
tauri::async_runtime::spawn(async move {
// 先重建菜单(保留旧数据),然后并行测试延迟并再次重建
rebuild_menu(&app).await;
test_all_delays_and_rebuild(&app).await;
});
}
"osd_toggle" => {
let _ = app.emit("tray:toggle-osd", ());
}
"kernel_restart" => {
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = restart_kernel(&app).await {
eprintln!("[tray] 重启 Kernel 失败: {}", e);
send_notification("Kernel 重启失败", &e);
}
});
}
"download_new" => {
if let Some(window) = app.get_webview_window("main") {
window.show().ok();
window.set_focus().ok();
}
let _ = app.emit("tray:new-download", ());
}
"settings" => {
if let Some(window) = app.get_webview_window("main") {
window.show().ok();
window.set_focus().ok();
}
let _ = app.emit("tray:open-settings", ());
}
"quit" => {
quit_cleanup(app);
}
id_str if id_str.starts_with("proxy_node_") => {
let idx: usize = id_str
.strip_prefix("proxy_node_")
.and_then(|s| s.parse().ok())
.unwrap_or(usize::MAX);
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Some((group, name)) = group_name_from_map(idx) {
select_proxy_node(&app, &group, &name).await;
} else {
eprintln!("[tray] 节点索引无效: {}", idx);
}
});
}
_ => {}
}
}
/// 从全局映射中获取 (group, name)
fn group_name_from_map(idx: usize) -> Option<(String, String)> {
let map = proxy_node_map().lock().unwrap();
map.get(idx).cloned()
}
/// 选择代理节点并测试延迟
async fn select_proxy_node(app: &AppHandle, group: &str, name: &str) {
let mihomo = app.state::<MihomoManager>();
match mihomo.select_proxy(group, name).await {
Ok(()) => {
// 测试延迟
let delay = mihomo
.test_delay(name, "https://www.gstatic.com/generate_204", 5000)
.await
.ok();
let delay_text = match delay {
Some(0) => "超时".to_string(),
Some(d) => format!("{}ms", d),
None => "未测速".to_string(),
};
send_notification("节点已切换", &format!("{}\n延迟: {}", name, delay_text));
// 重建菜单
rebuild_menu(app).await;
}
Err(e) => {
eprintln!("[tray] 切换节点失败: {}", e);
send_notification("切换节点失败", &e);
}
}
}
// ===== 代理开启/关闭 =====
async fn enable_proxy(app: &AppHandle) -> Result<(), String> {
let mihomo = app.state::<MihomoManager>();
let pm = app.state::<ProcessManager>();
// 1. 启动 mihomo(如果未运行)
if !is_proxy_running(app) {
let params = mihomo.prepare_for_start(app)?;
pm.start(params)?;
// 等待 API 就绪
if !wait_for_mihomo_api(&mihomo, 10_000).await {
return Err("mihomo API 启动超时".into());
}
}
// 2. 获取节点列表
let proxies = mihomo.get_proxies().await?;
let (group, nodes, _now) = parse_main_group(&proxies)
.ok_or_else(|| "无法解析代理组".to_string())?;
if !nodes.is_empty() {
// 3. 并行测试所有节点延迟
let best = test_and_select_best(&mihomo, &group, &nodes).await;
// 4. 发送通知
match &best {
Some((name, delay)) => {
send_notification(
"代理已开启",
&format!("当前节点: {} ({}ms)", name, delay),
);
}
None => {
send_notification("代理已开启", "所有节点均超时,未自动选择");
}
}
} else {
send_notification("代理已开启", "无可用节点");
}
// 5. 开启系统代理
mihomo.enable_system_proxy()?;
// 6. 重建菜单
rebuild_menu(app).await;
Ok(())
}
async fn disable_proxy(app: &AppHandle) -> Result<(), String> {
let mihomo = app.state::<MihomoManager>();
let pm = app.state::<ProcessManager>();
// 关闭系统代理
mihomo.disable_system_proxy()?;
// 停止 mihomo
pm.stop("proxy")?;
send_notification("代理已关闭", "mihomo 已停止,系统代理已清除");
// 重建菜单
rebuild_menu(app).await;
Ok(())
}
// ===== 择优选择节点 =====
/// 并行测试所有节点延迟,选择最低延迟节点
async fn test_and_select_best(
mihomo: &MihomoManager,
group: &str,
nodes: &[String],
) -> Option<(String, u32)> {
use futures_util::future::join_all;
let futures: Vec<_> = nodes
.iter()
.map(|name| async move {
let delay = mihomo
.test_delay(name, "https://www.gstatic.com/generate_204", 5000)
.await
.ok();
(name.clone(), delay)
})
.collect();
let results = join_all(futures).await;
// 找出有效延迟中最低的
let best = results
.into_iter()
.filter_map(|(name, delay)| delay.map(|d| (name, d)))
.filter(|(_, d)| *d > 0)
.min_by_key(|(_, d)| *d);
// 选择最优节点
if let Some((ref name, _)) = best {
let _ = mihomo.select_proxy(group, name).await;
}
best
}
/// 测试所有节点延迟后重建菜单
async fn test_all_delays_and_rebuild(app: &AppHandle) {
let mihomo = app.state::<MihomoManager>();
let proxies = match mihomo.get_proxies().await {
Ok(p) => p,
Err(_) => {
return;
}
};
let (_group, nodes, _) = match parse_main_group(&proxies) {
Some(g) => g,
None => return,
};
// 并行测试延迟(更新 mihomo 内部 history
use futures_util::future::join_all;
let futures: Vec<_> = nodes
.iter()
.map(|name| async {
let _ = mihomo
.test_delay(name, "https://www.gstatic.com/generate_204", 5000)
.await;
})
.collect();
join_all(futures).await;
// 再次获取 proxies(此时 history 已更新)并重建菜单
rebuild_menu(app).await;
}
// ===== 等待 mihomo API 就绪 =====
async fn wait_for_mihomo_api(mihomo: &MihomoManager, timeout_ms: u64) -> bool {
let start = std::time::Instant::now();
while start.elapsed().as_millis() < timeout_ms as u128 {
if mihomo.get_version().await.is_ok() {
return true;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
false
}
// ===== 重启 Kernel =====
async fn restart_kernel(app: &AppHandle) -> Result<(), String> {
let monitor = app.state::<MonitorKernel>();
let pm = app.state::<ProcessManager>();
// 停止当前 Kernel
monitor.stop_subscription(app).await;
let _ = pm.stop("monitor");
// 等待端口释放
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
// 重新启动
monitor.start_with_subscription(app).await?;
send_notification("Kernel 已重启", "ThingHK 监控内核已重新启动");
rebuild_menu(app).await;
Ok(())
}
// ===== Windows 通知 =====
#[cfg(windows)]
fn send_notification(title: &str, message: &str) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
// 转义 PowerShell 单引号
let title_escaped = title.replace('\'', "''");
let msg_escaped = message.replace('\'', "''").replace('\n', "`n");
let script = format!(
"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')|Out-Null;\
$n=New-Object System.Windows.Forms.NotifyIcon;\
$n.Icon=[System.Drawing.SystemIcons]::Information;\
$n.Visible=$true;\
$n.BalloonTipTitle='{}';\
$n.BalloonTipText='{}';\
$n.ShowBalloonTip(5000);\
Start-Sleep -Seconds 6;\
$n.Dispose()",
title_escaped, msg_escaped
);
let _ = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-Command",
&script,
])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(not(windows))]
fn send_notification(_title: &str, _message: &str) {
// 非 Windows 平台不显示通知
}
// ===== 创建托盘 =====
/// 创建自定义托盘菜单(在 setup 中调用)
pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> {
let proxy_running = is_proxy_running(app);
let monitor_running = is_monitor_running(app);
// 初始菜单(不含代理节点,异步获取后替换)
let menu = build_menu(app, proxy_running, monitor_running, None)?;
let _tray = TrayIconBuilder::with_id("main")
.icon(app.default_window_icon().unwrap().clone())
.tooltip("Thing")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(handle_menu_event)
.on_tray_icon_event(|tray, event| {
let app = tray.app_handle().clone();
match event {
TrayIconEvent::Click {
button: MouseButton::Left,
..
} => {
if let Some(window) = app.get_webview_window("main") {
window.show().ok();
window.set_focus().ok();
}
}
TrayIconEvent::Enter { .. } => {
// 鼠标进入托盘区域时预刷新菜单
tauri::async_runtime::spawn(async move {
rebuild_menu(&app).await;
});
}
_ => {}
}
})
.build(app)?;
// 异步获取代理节点并重建菜单
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
rebuild_menu(&app_clone).await;
});
Ok(())
}
/// 退出应用时调用(与 quit_app 命令共享清理逻辑)
pub fn quit_cleanup(app: &AppHandle) {
if let Some(mihomo) = app.try_state::<MihomoManager>() {
mihomo.cleanup_on_exit();
}
if let Some(engine) = app.try_state::<DownloadEngine>() {
engine.cleanup_on_exit();
}
if let Some(monitor) = app.try_state::<MonitorKernel>() {
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
}
if let Some(clip) = app.try_state::<ClipboardManager>() {
clip.stop();
}
if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all();
}
app.exit(0);
}