2 Commits
Author SHA1 Message Date
zhongluofeng e09b0567d6 Merge branch 'main' of https://gitea.atie.fun/LFeng/Thing
# Conflicts:
#	README.md
2026-08-06 10:35:33 +08:00
zhongluofeng e66c53e66d 性能优化 2026-08-06 10:33:16 +08:00
105 changed files with 7272 additions and 5001 deletions
+7 -7
View File
@@ -315,11 +315,11 @@ const status = processStore.getProcessStatus('proxy')
| 命令 | 参数 | 返回值 |
|------|------|--------|
| `start_process` | `StartProcessParams` | `ProcessInfo` |
| `stop_process` | `id: String` | `()` |
| `get_process_status` | `id: String` | `Option<ProcessInfo>` |
| `get_all_process_status` | - | `Vec<ProcessInfo>` |
| `stop_all_processes` | - | `()` |
| `process_start` | `StartProcessParams` | `ProcessInfo` |
| `process_stop` | `id: String` | `()` |
| `process_status` | `id: String` | `Option<ProcessInfo>` |
| `process_all_status` | - | `Vec<ProcessInfo>` |
| `process_stop_all` | - | `()` |
### 事件
@@ -388,8 +388,8 @@ rusqlite = { version = "0.30", features = ["bundled"] }
### Rust 调试
- 使用 `println!()` 输出到终端
- 使用 `dbg!()` 宏调试变量
- 使用 `crate::logger::log_info / log_warn / log_error`(或 `log_line`)写入统一日志文件,日志页面可过滤级别
- 使用 `dbg!()` 宏调试变量(仅临时,提交前删除)
- 使用 Visual Studio Code 的 Rust 调试插件
### 构建问题排查
+5 -10
View File
@@ -397,7 +397,7 @@ thread::spawn(move || {
### 后端:内核/二进制下载用流式 + 事件推送
`mihomo_manager.rs` `install_kernel` 确立了"下载二进制资源"的标准模式,未来若有其他模块需要下载外部内核时应复用:
`mihomo_manager/` 目录(`kernel.rs``install_kernel` 确立了"下载二进制资源"的标准模式,未来若有其他模块需要下载外部内核时应复用:
- **流式下载**`reqwest::Response::bytes_stream()` + `futures_util::StreamExt`,避免大文件一次性读入内存
- **进度事件**:通过 `app.emit("xxx-install-progress", progress)` 推送,事件载荷结构参考 `InstallProgress`
@@ -492,13 +492,13 @@ TitleBar 的关闭按钮实际是 `hide()` 到托盘。webview 快速隐藏时
### 前端:浮动标签切换器(TabsList 滚动遮挡时在 TitleBar 显示)
模块详情页内容滚动时,顶部 `TabsList` 会被 `TitleBar` 遮挡,导致用户必须滚回顶部才能切换 Tab。已抽取通用 composable `src/lib/useModuleTabs.ts` 自动处理。
模块详情页内容滚动时,顶部 `TabsList` 会被 `TitleBar` 遮挡,导致用户必须滚回顶部才能切换 Tab。已抽取通用 composable `src/lib/use-module-tabs.ts` 自动处理。
**接入方式**(任何使用 Tabs 的模块都可用,代理/下载器模块已接入):
```ts
// 模块 <script setup> 顶部
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
const activeTab = ref('overview')
const tabsListRef = useModuleTabs(activeTab, [
@@ -613,11 +613,6 @@ const formatEta = (seconds: number): string => {
下载任务工具栏的速度/活跃/等待/已完成数量,以及代理概览页的内核状态/版本,从纯文本改为 `Badge variant="secondary"` 或带颜色的 `Badge`(绿色已安装/红色未安装),视觉更醒目。新模块的状态展示建议统一用 Badge。
### 后端:应用退出清理必须覆盖所有退出路径
### 后端:应用退出清理(单一出口)
`lib.rs` 中应用退出有两种触发路径,**都要**调用 `cleanup_on_exit` + `stop_all`
1. `quit_app` Tauri 命令(前端 `appWindow.destroy()` 触发)
2. 系统托盘的退出菜单项
漏掉任何一个都会导致子进程残留(如 mihomo 继续占用端口、系统代理未清除)。新增管理子进程的模块时,检查这两处是否都调用了清理逻辑。
退出清理已收敛到 `lib.rs` `RunEvent::ExitRequested` 分支(P0-1/V7):`quit_app` 命令与托盘退出项都只触发 `app.exit(0)`,清理逻辑(`cleanup_on_exit` + `stop_all`)只在该分支执行一次。新增管理子进程的模块时,在 `ExitRequested` 分支注册自己的 `cleanup_on_exit` 即可,不要在命令或托盘路径重复清理。
+23 -14
View File
@@ -23,15 +23,15 @@ Thing/
│ ├── components/ # 通用组件
│ │ ├── layout/ # 布局组件(TitleBar、Sidebar、ModuleContainer
│ │ └── ui/ # shadcn-vue UI 组件
│ ├── lib/ # 工具库(logger、useModuleTabs、utils
│ ├── lib/ # 工具库(logger、use-module-tabs、calc、constants
│ ├── modules/ # 功能模块(每个模块含 index.ts 配置 + Vue 组件)
│ │ ├── proxy/ # 代理管理模块
│ │ ├── clipboard/ # 剪贴板增强模块
│ │ ├── screenshot/ # 截图模块
│ │ ├── monitor/ # 硬件监控模块
│ │ ├── downloader/ # 下载器模块
│ │ ├── finder/ # 文件搜索模块
│ │ ├── general/ # 通用设置模块
│ │ ├── quickpanel/ # 快速面板模块(含 providers/ 搜索提供器目录)
│ │ ├── settings/ # 通用设置模块
│ │ ├── icons.ts # 模块图标映射
│ │ ├── index.ts # 模块聚合入口
│ │ └── registry.ts # 模块注册表单例
@@ -46,12 +46,22 @@ Thing/
│ ├── icons/ # 应用图标
│ ├── resources/ # 打包资源(浏览器扩展等)
│ ├── src/
│ │ ├── download_engine/ # 下载引擎(进程内自建,含 engine/storage/http_dl/server 等
│ │ ├── logger.rs # 日志系统
│ │ ├── mihomo_manager.rs # mihomo 内核管理(代理模块后端
│ │ ├── clipboard/ # 剪贴板模块(reader/monitor/storage/popup
│ │ ├── download_engine/ # 下载引擎(进程内自建,含 engine/storage/http_dl 等)
│ │ ├── mihomo_manager/ # mihomo 内核管理(mod/kernel/profiles/system_proxy/commands 等
│ │ ├── quickpanel/ # 快速面板后端(commands/file_index/icon_extractor
│ │ ├── screenshot/ # 截图后端(capture/commands
│ │ ├── constants.rs # 双端常量(窗口 label / 事件名)
│ │ ├── logger.rs # 日志系统(进程级全局日志器)
│ │ ├── monitor_kernel.rs # 硬件监控内核(SSE 订阅)
│ │ ├── network_monitor.rs # 网速监控
│ │ ├── osd_window.rs # OSD 悬浮窗
│ │ ├── process_manager.rs # 子进程统一管理
│ │ ├── setup.rs # 应用初始化
│ │ ├── tray_menu.rs # 系统托盘
│ │ ├── win32_util.rs # Win32 屏幕/DPI 工具
│ │ ├── main.rs # Rust 入口
│ │ └── lib.rs # Rust 库入口(命令注册、应用初始化
│ │ └── lib.rs # Rust 库入口(命令注册、事件循环
│ ├── Cargo.toml # Rust 依赖配置
│ └── tauri.conf.json # Tauri 配置
├── package.json # 前端依赖配置
@@ -108,16 +118,15 @@ Thing/
#### 🔍 快速面板
- [x] 全局弹窗(快捷键触发)
- [x] 全局搜索:拼音模糊匹配,正则表达式支持。支持:程序,文件,文件夹,设置项,最近使用,历史命令(全量考虑,不好实现或无法实现则忽略)
- [x] 快捷命令(例:shutdown,hosts
- [x] 计算器 / 转换器(数学表达式单位转换,时间转换进制转换)
- [ ] 后续可能需求的大量快速、便捷入口可能性(开发者利好功能)
- [x] 全局搜索:拼音模糊匹配,正则表达式支持。支持:程序,文件,文件夹,设置项,最近使用,历史命令
- [x] 快捷命令(例:shutdown, hosts
- [x] 计算器 / 单位换算(数学表达式单位/货币换算;时间转换进制转换后续支持
### 第三阶段:优化与完善
- [ ] 性能优化
- [ ] 错误处理与日志完善
- [ ] 用户体验优化
- [x] 性能优化(P1/P2:轮询随窗口可见性暂停、批量测速限并发、渲染 memo 化等,见 MODULE_REVIEW.md
- [x] 错误处理与日志完善B5 进程级全局日志器、异常兜底)
- [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节)
- [ ] 自动更新机制
- [ ] 打包发布
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "node --test src/modules/quickpanel/engine.test.ts src/lib/calc.test.ts",
"tauri": "tauri"
},
"dependencies": {
+85
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]]
name = "adler2"
version = "2.0.1"
@@ -3019,6 +3025,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pbkdf2"
version = "0.12.2"
@@ -4070,6 +4082,58 @@ dependencies = [
"system-deps",
]
[[package]]
name = "specta"
version = "2.0.0-rc.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50"
dependencies = [
"paste",
"rustc_version",
"serde_json",
"specta-macros",
]
[[package]]
name = "specta-macros"
version = "2.0.0-rc.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ce14957ecc2897f1f848b8255b6531d13ddf49cbcf506b7c2c9fb1d005593bb"
dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "specta-serde"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8a72b755ddb8949fd8f17c5db43f0e8a806ea587d9bc602ee3f73240c00029"
dependencies = [
"specta",
"specta-macros",
]
[[package]]
name = "specta-typescript"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "639404ee95557f2f8b7e4cb773ffefd45304c7ab8ba21ac83b69051595e083c0"
dependencies = [
"specta",
]
[[package]]
name = "specta-util"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29b1fc02b446f7244a92924fe68c0555921209f1d342990cd1539e9138e69502"
dependencies = [
"specta",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -4304,6 +4368,7 @@ dependencies = [
"serde_json",
"serde_repr",
"serialize-to-javascript",
"specta",
"swift-rs",
"tauri-build",
"tauri-macros",
@@ -4577,6 +4642,23 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-specta"
version = "2.0.0-rc.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee080f36d2ac17ce2f3a82fb53f02d664e8345457de51b56dad3c394dacc41a2"
dependencies = [
"heck 0.5.0",
"serde",
"serde_json",
"specta",
"specta-serde",
"specta-typescript",
"specta-util",
"tauri",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-utils"
version = "2.9.3"
@@ -4676,6 +4758,8 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"specta",
"specta-typescript",
"sysinfo",
"tauri",
"tauri-build",
@@ -4685,6 +4769,7 @@ dependencies = [
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-snap-layout",
"tauri-specta",
"tokio",
"url",
"walkdir",
+3
View File
@@ -27,6 +27,9 @@ tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] }
specta-typescript = "0.0.12"
tauri-specta = { version = "=2.0.0-rc.25", features = ["typescript"] }
chrono = "0.4"
reqwest = { version = "0.12", features = ["json", "stream"] }
futures-util = "0.3"
+75 -20
View File
@@ -3,13 +3,14 @@
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
use serde::Serialize;
use specta::Type;
use tauri::{AppHandle, State};
use super::manager::{ClipboardManager, ClipboardSettings};
use super::reader::dib_to_png;
use super::storage::{ClipboardItem, ClipboardItemDetail};
#[derive(Serialize)]
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardStatus {
pub running: bool,
@@ -21,7 +22,7 @@ fn img_to_base64(b: &[u8]) -> Option<String> {
}
/// 历史查询结果(含总数,用于分页)
#[derive(Serialize)]
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HistoryPage {
pub items: Vec<ClipboardItem>,
@@ -29,74 +30,107 @@ pub struct HistoryPage {
}
#[tauri::command]
#[specta::specta]
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 storage = manager.storage().clone();
let kind = kind.unwrap_or_else(|| "all".into());
let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0);
// SQLite 查询移出 async runtime 线程
tauri::async_runtime::spawn_blocking(move || {
let items = storage.get_history(limit, offset, &kind);
let total = storage.count_kind(&kind);
Ok(HistoryPage { items, total })
HistoryPage { items, total }
})
.await
.map_err(|e| format!("查询任务失败: {}", e))
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_get_pinned(
manager: State<'_, ClipboardManager>,
) -> Result<Vec<ClipboardItem>, String> {
Ok(manager.storage().get_pinned())
let storage = manager.storage().clone();
tauri::async_runtime::spawn_blocking(move || storage.get_pinned())
.await
.map_err(|e| format!("查询任务失败: {}", e))
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_search(
query: String,
limit: Option<i64>,
offset: Option<i64>,
manager: State<'_, ClipboardManager>,
) -> Result<HistoryPage, String> {
let storage = manager.storage();
let storage = manager.storage().clone();
let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0);
tauri::async_runtime::spawn_blocking(move || {
let items = storage.search(&query, limit, offset);
let total = storage.count_search(&query);
Ok(HistoryPage { items, total })
HistoryPage { items, total }
})
.await
.map_err(|e| format!("查询任务失败: {}", e))
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_get_item(
id: i64,
manager: State<'_, ClipboardManager>,
) -> Result<Option<ClipboardItemDetail>, String> {
Ok(manager.storage().get_detail(id, img_to_base64))
let storage = manager.storage().clone();
// get_detail 内部:锁内取数 + 锁外 base64 编码,整体移出主线程
tauri::async_runtime::spawn_blocking(move || storage.get_detail(id, img_to_base64))
.await
.map_err(|e| format!("查询任务失败: {}", e))
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_set_pinned(
id: i64,
pinned: bool,
manager: State<'_, ClipboardManager>,
) -> Result<bool, String> {
Ok(manager.storage().set_pinned(id, pinned))
let storage = manager.storage().clone();
tauri::async_runtime::spawn_blocking(move || storage.set_pinned(id, pinned))
.await
.map_err(|e| format!("操作任务失败: {}", e))
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_delete(
id: i64,
manager: State<'_, ClipboardManager>,
) -> Result<bool, String> {
Ok(manager.storage().delete(id))
let storage = manager.storage().clone();
tauri::async_runtime::spawn_blocking(move || storage.delete(id))
.await
.map_err(|e| format!("删除任务失败: {}", e))
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_clear(manager: State<'_, ClipboardManager>) -> Result<bool, String> {
Ok(manager.storage().clear_non_pinned())
let storage = manager.storage().clone();
tauri::async_runtime::spawn_blocking(move || storage.clear_non_pinned())
.await
.map_err(|e| format!("清空任务失败: {}", e))
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_copy_back(
id: i64,
manager: State<'_, ClipboardManager>,
@@ -105,11 +139,16 @@ pub async fn clipboard_copy_back(
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_count(manager: State<'_, ClipboardManager>) -> Result<i64, String> {
Ok(manager.storage().count())
let storage = manager.storage().clone();
tauri::async_runtime::spawn_blocking(move || storage.count())
.await
.map_err(|e| format!("查询任务失败: {}", e))
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_get_settings(
manager: State<'_, ClipboardManager>,
) -> Result<ClipboardSettings, String> {
@@ -117,6 +156,7 @@ pub async fn clipboard_get_settings(
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_save_settings(
settings: ClipboardSettings,
app: AppHandle,
@@ -131,9 +171,11 @@ pub async fn clipboard_save_settings(
} else if !settings.enabled && prev_enabled {
manager.stop();
}
// 快捷键变化时重新注册
// 快捷键变化时重新注册(共享工具模块,原子化 + 冲突检测)
if settings.shortcut != prev_shortcut {
super::popup::register_shortcut(&app, &settings.shortcut)?;
crate::shortcut::register_shortcut(&app, "剪贴板", &settings.shortcut, |a| {
super::popup::show_popup(a)
})?;
// 新快捷键非空时确保弹窗窗口已预创建
if !settings.shortcut.trim().is_empty() {
super::popup::ensure_popup_window(&app);
@@ -143,22 +185,27 @@ pub async fn clipboard_save_settings(
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_status(
manager: State<'_, ClipboardManager>,
) -> Result<ClipboardStatus, String> {
Ok(ClipboardStatus {
running: manager.is_running(),
count: manager.storage().count(),
})
let storage = manager.storage().clone();
let running = manager.is_running();
let count = tauri::async_runtime::spawn_blocking(move || storage.count())
.await
.map_err(|e| format!("查询任务失败: {}", e))?;
Ok(ClipboardStatus { running, count })
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_start(app: AppHandle, manager: State<'_, ClipboardManager>) -> Result<(), String> {
manager.start(&app);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_stop(manager: State<'_, ClipboardManager>) -> Result<(), String> {
manager.stop();
Ok(())
@@ -166,22 +213,27 @@ pub async fn clipboard_stop(manager: State<'_, ClipboardManager>) -> Result<(),
/// 注册(或切换)快捷弹窗全局快捷键
#[tauri::command]
#[specta::specta]
pub async fn clipboard_register_shortcut(
shortcut: String,
app: AppHandle,
) -> Result<(), String> {
super::popup::register_shortcut(&app, &shortcut)
crate::shortcut::register_shortcut(&app, "剪贴板", &shortcut, |a| {
super::popup::show_popup(a)
})
}
/// 注销快捷弹窗全局快捷键
#[tauri::command]
#[specta::specta]
pub async fn clipboard_unregister_shortcut(app: AppHandle) -> Result<(), String> {
super::popup::unregister_shortcut(&app);
crate::shortcut::unregister_shortcut(&app, "剪贴板");
Ok(())
}
/// 手动触发显示快捷弹窗(供 UI 按钮调用)
#[tauri::command]
#[specta::specta]
pub async fn clipboard_show_popup(app: AppHandle) -> Result<(), String> {
super::popup::show_popup(&app);
Ok(())
@@ -189,6 +241,7 @@ pub async fn clipboard_show_popup(app: AppHandle) -> Result<(), String> {
/// 隐藏快捷弹窗
#[tauri::command]
#[specta::specta]
pub async fn clipboard_hide_popup(app: AppHandle) -> Result<(), String> {
super::popup::hide_popup(&app);
Ok(())
@@ -196,6 +249,7 @@ pub async fn clipboard_hide_popup(app: AppHandle) -> Result<(), String> {
/// 显示已创建的弹窗窗口(前端 onMounted 后调用)
#[tauri::command]
#[specta::specta]
pub async fn clipboard_show_window(app: AppHandle) -> Result<(), String> {
super::popup::show_window(&app);
Ok(())
@@ -203,6 +257,7 @@ pub async fn clipboard_show_window(app: AppHandle) -> Result<(), String> {
/// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口
#[tauri::command]
#[specta::specta]
pub async fn clipboard_paste_to_target(app: AppHandle) -> Result<(), String> {
super::popup::paste_to_target(&app);
Ok(())
+22 -10
View File
@@ -7,13 +7,14 @@ use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use serde::{Deserialize, Serialize};
use specta::Type;
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)]
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct ClipboardSettings {
/// 监听是否启用
@@ -66,7 +67,7 @@ impl ClipboardManager {
let storage = match Storage::new(&clip_dir) {
Ok(s) => Arc::new(s),
Err(e) => {
eprintln!("[clipboard] 磁盘存储初始化失败,回退内存: {}", e);
crate::logger::log_warn("clipboard", &format!("磁盘存储初始化失败,回退内存: {}", e));
Arc::new(Storage::new_in_memory())
}
};
@@ -86,7 +87,7 @@ impl ClipboardManager {
/// 启动监听(若已运行则跳过)
pub fn start(&self, app: &tauri::AppHandle) {
let mut handle = self.monitor_handle.lock().unwrap();
let mut handle = self.monitor_handle.lock().unwrap_or_else(|e| e.into_inner());
if handle.is_some() {
return;
}
@@ -99,30 +100,41 @@ impl ClipboardManager {
self.monitor_stop.clone(),
);
*handle = Some(h);
eprintln!("[clipboard] 监听已启动");
crate::logger::log_info("clipboard", "监听已启动");
}
/// 停止监听
pub fn stop(&self) {
self.monitor_stop.store(true, Ordering::SeqCst);
if let Some(h) = self.monitor_handle.lock().unwrap().take() {
// 不阻塞等待;轮询线程最迟 800ms 后退出
if let Some(h) = self
.monitor_handle
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
// 后台等待监听线程退出(最迟 800ms),不阻塞调用者。
// 剪贴板 stop 命令跑在 async runtime 线程上,直接 join 会卡住 tokio worker。
std::thread::spawn(move || {
let _ = h.join();
});
}
eprintln!("[clipboard] 监听已停止");
crate::logger::log_info("clipboard", "监听已停止");
}
pub fn is_running(&self) -> bool {
self.monitor_handle.lock().unwrap().is_some()
self.monitor_handle
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
}
pub fn get_settings(&self) -> ClipboardSettings {
self.settings.lock().unwrap().clone()
self.settings.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
pub fn save_settings(&self, s: ClipboardSettings) {
{
*self.settings.lock().unwrap() = s.clone();
*self.settings.lock().unwrap_or_else(|e| e.into_inner()) = s.clone();
}
save_settings(&self.settings_path, &s);
}
+2 -2
View File
@@ -46,7 +46,7 @@ pub fn start_monitor(
continue;
}
let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = {
let s = settings.lock().unwrap();
let s = settings.lock().unwrap_or_else(|e| e.into_inner());
(
s.record_text,
s.record_image,
@@ -85,7 +85,7 @@ pub fn start_monitor(
};
if let Some(_id) = storage.insert_or_touch(item, dedup) {
storage.prune_to_max(max_items);
let _ = app.emit("clipboard-changed", ());
let _ = app.emit(crate::constants::events::CLIPBOARD_CHANGED, ());
}
})
}
+32 -152
View File
@@ -12,67 +12,17 @@ use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
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);
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
/// 解析快捷键字符串为 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_popup 计算出的待显示位置(物理坐标),供 show_window 应用,
/// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)
static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None);
/// 创建弹窗窗口(隐藏状态)并注册失焦监听。
/// 位置默认在屏幕外,show_popup 时会重新定位。
@@ -99,7 +49,7 @@ fn create_popup_window(app: &AppHandle) {
{
Ok(w) => w,
Err(e) => {
eprintln!("[clipboard] 创建弹窗失败: {}", e);
crate::logger::log_error("clipboard", &format!("创建弹窗失败: {}", e));
return;
}
};
@@ -110,11 +60,11 @@ fn create_popup_window(app: &AppHandle) {
win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
let _ = win_handle.hide();
let _ = app_handle.emit("clipboard-popup-hide", ());
let _ = app_handle.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
}
});
eprintln!("[clipboard] 弹窗窗口已预创建(隐藏状态)");
crate::logger::log_info("clipboard", "弹窗窗口已预创建(隐藏状态)");
}
/// 应用启动时预创建弹窗窗口(隐藏)。
@@ -136,7 +86,7 @@ pub fn show_popup(app: &AppHandle) {
None => return,
};
// 窗口尺寸(逻辑像素
// 窗口尺寸(物理像素,由光标所在屏 DPI 换算
let w = 380.0_f64;
let h = 460.0_f64;
@@ -144,36 +94,35 @@ pub fn show_popup(app: &AppHandle) {
let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx, my)
.unwrap_or((0, 0, 1920, 1040));
// 获取光标所在显示器的 DPI,将物理坐标转为逻辑坐标(DIP
// 光标所在显示器的 DPI:窗口尺寸需按物理像素放大
let dpi = get_dpi_for_point(mx, my).unwrap_or(96);
let scale = dpi as f64 / 96.0;
let w_px = w * scale;
let h_px = h * scale;
let mx_l = mx as f64 / scale;
let my_l = my as f64 / scale;
let wa_left_l = wa_left as f64 / scale;
let wa_top_l = wa_top as f64 / scale;
let wa_right_l = wa_right as f64 / scale;
let wa_bottom_l = wa_bottom as f64 / scale;
// 逻辑坐标 clamping(窗口尺寸 w/h 也是逻辑像素)
let x = mx_l.max(wa_left_l).min(wa_right_l - w);
let y = my_l.max(wa_top_l).min(wa_bottom_l - h);
// 直接以物理坐标 clamping(光标位置 + 工作区均为物理像素,避免混合 DPI 下
// 手动"物理→逻辑"换算后 Tauri 再按窗口所在屏解释导致的定位偏移)
let x = (mx as f64).max(wa_left as f64).min(wa_right as f64 - w_px);
let y = (my as f64).max(wa_top as f64).min(wa_bottom as f64 - h_px);
// 窗口已存在:移动 + 显示 + 请求焦点
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition {
x,
y,
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
x: x as i32,
y: y as i32,
}));
let _ = win.show();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit("clipboard-popup-show", ());
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
return;
}
// 兜底:窗口被销毁时重新创建(隐藏),等前端 onMounted 回调 show_window
POPUP_PENDING_SHOW.store(true, Ordering::SeqCst);
if let Ok(mut pos) = PENDING_POS.lock() {
*pos = Some((x, y));
}
create_popup_window(app);
}
@@ -185,10 +134,18 @@ pub fn show_window(app: &AppHandle) {
return;
}
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
// 应用 show_popup 计算的兜底位置(物理坐标),避免停留在屏幕外
let pos = PENDING_POS.lock().ok().and_then(|p| *p);
if let Some((x, y)) = pos {
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
x: x as i32,
y: y as i32,
}));
}
let _ = win.show();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit("clipboard-popup-show", ());
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
}
}
@@ -250,83 +207,6 @@ fn simulate_paste() {
// 非 Windows 平台暂不支持自动粘贴
}
// ===== Win32 API:获取鼠标位置和工作区 =====
// ===== 屏幕/光标/DPI 工具已迁移至 crate::win32_util(跨模块共享) =====
#[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
}
}
}
/// 获取指定点所在显示器的工作区(排除任务栏),返回 (left, top, right, bottom) 物理像素。
/// 使用 MonitorFromPoint 支持多显示器环境。
pub fn get_work_area_at_point(x: i32, y: i32) -> Option<(i32, i32, i32, i32)> {
use windows_sys::Win32::Graphics::Gdi::{
GetMonitorInfoW, MonitorFromPoint, MONITORINFO, MONITOR_DEFAULTTONEAREST,
};
let pt = POINT { x, y };
let hmon = unsafe { MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST) };
let mut mi: MONITORINFO = unsafe { std::mem::zeroed() };
mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
unsafe {
if GetMonitorInfoW(hmon, &mut mi) != 0 {
let rc = mi.rcWork;
Some((rc.left, rc.top, rc.right, rc.bottom))
} else {
None
}
}
}
/// 获取指定点所在显示器的有效 DPI。
/// scale factor = dpi / 96。
pub fn get_dpi_for_point(x: i32, y: i32) -> Option<u32> {
use windows_sys::Win32::Graphics::Gdi::{MonitorFromPoint, MONITOR_DEFAULTTONEAREST};
use windows_sys::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI};
let pt = POINT { x, y };
let hmon = unsafe { MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST) };
let mut dpi_x: u32 = 0;
let mut dpi_y: u32 = 0;
unsafe {
if GetDpiForMonitor(hmon, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y) == 0 {
Some(dpi_x)
} 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 fn get_work_area_at_point(_x: i32, _y: i32) -> Option<(i32, i32, i32, i32)> { None }
pub fn get_dpi_for_point(_x: i32, _y: i32) -> Option<u32> { None }
}
pub use win_api::{get_cursor_pos, get_work_area, get_work_area_at_point, get_dpi_for_point};
use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
+12 -7
View File
@@ -3,12 +3,13 @@
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
use rusqlite::{params, Connection, OptionalExtension};
use specta::Type;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
/// 列表项(不含大字段,用于历史/搜索结果)
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardItem {
pub id: i64,
@@ -21,7 +22,7 @@ pub struct ClipboardItem {
}
/// 详情(含文本内容或图片 base64)
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardItemDetail {
#[serde(flatten)]
@@ -206,10 +207,11 @@ impl Storage {
}
/// 获取详情(含文本/图片预览 base64)
/// 先锁内查询数据,释放锁后再执行耗时编码(DIB→PNG + base64),避免长时间占用连接锁
pub fn get_detail(&self, id: i64, image_to_base64: impl Fn(&[u8]) -> Option<String>) -> Option<ClipboardItemDetail> {
let (item, content, blob) = {
let conn = self.conn.lock().ok()?;
let row = conn
.query_row(
conn.query_row(
"SELECT id, kind, preview, size, pinned, pinned_order, created_at, content, blob
FROM clipboard_history WHERE id = ?1",
params![id],
@@ -232,8 +234,9 @@ impl Storage {
))
},
)
.ok()?;
let (item, content, blob) = row;
.ok()?
};
// conn 已在此处释放,以下编码不占用连接锁
let image_base64 = if item.kind == "image" {
blob.as_deref().and_then(|b| image_to_base64(b))
} else {
@@ -342,6 +345,7 @@ impl Storage {
}
/// 按搜索关键词统计匹配的非固定条目总数
/// 与 `search` 保持一致的匹配字段(preview + content),避免分页总数错误
pub fn count_search(&self, query: &str) -> i64 {
let conn = match self.conn.lock() {
Ok(c) => c,
@@ -349,7 +353,8 @@ impl Storage {
};
let pattern = format!("%{}%", query);
conn.query_row(
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND preview LIKE ?1",
"SELECT COUNT(*) FROM clipboard_history
WHERE pinned = 0 AND (preview LIKE ?1 OR content LIKE ?1)",
params![pattern],
|r| r.get(0),
)
+49
View File
@@ -0,0 +1,49 @@
//! 全局常量集中定义。
//! 窗口 label / Tauri 事件名,避免魔法字符串散布各处。
//! 与前端 `src/lib/constants.ts` 保持对应。
/// 窗口 label(对应 capabilities/*.json 与前端 constants::WINDOWS
pub mod windows {
pub const MAIN: &str = "main";
// 以下窗口由前端创建,Rust 侧仅作双端对应声明(无直接引用)
#[allow(dead_code)]
pub const OSD_OVERLAY: &str = "osd-overlay";
#[allow(dead_code)]
pub const SCREENSHOT_OVERLAY: &str = "screenshot-overlay";
}
/// Tauri 事件名(与前端 constants::EVENTS 对应)
pub mod events {
// 托盘菜单
pub const TRAY_MENU_SHOW: &str = "tray-menu-show";
pub const TRAY_MENU_STATE_UPDATED: &str = "tray-menu-state-updated";
pub const TRAY_TOGGLE_OSD: &str = "tray:toggle-osd";
pub const TRAY_NEW_DOWNLOAD: &str = "tray:new-download";
pub const TRAY_OPEN_SETTINGS: &str = "tray:open-settings";
// 剪贴板
pub const CLIPBOARD_CHANGED: &str = "clipboard-changed";
pub const CLIPBOARD_POPUP_SHOW: &str = "clipboard-popup-show";
pub const CLIPBOARD_POPUP_HIDE: &str = "clipboard-popup-hide";
// 快速面板
pub const QUICKPANEL_SHOW: &str = "quickpanel-show";
pub const QUICKPANEL_HIDE: &str = "quickpanel-hide";
// 监控
pub const MONITOR_DATA: &str = "monitor-data";
pub const MONITOR_NETWORK: &str = "monitor-network";
pub const MONITOR_ERROR: &str = "monitor-error";
pub const MONITOR_LOADING: &str = "monitor-loading";
pub const MONITOR_READY: &str = "monitor-ready";
pub const MONITOR_DISCONNECTED: &str = "monitor-disconnected";
// OSD 窗口
pub const OSD_SYSTEM_UI_ACTIVE: &str = "osd-system-ui-active";
pub const OSD_SYSTEM_UI_INACTIVE: &str = "osd-system-ui-inactive";
pub const OSD_START_DRAG: &str = "osd-start-drag";
pub const OSD_END_DRAG: &str = "osd-end-drag";
// 截图
pub const SCREENSHOT_SHORTCUT: &str = "screenshot-shortcut";
// 内核安装进度
pub const KERNEL_INSTALL_PROGRESS: &str = "kernel-install-progress";
// 进程与下载
pub const PROCESS_STATUS_CHANGED: &str = "process-status-changed";
pub const DOWNLOAD_ADDED: &str = "download-added";
}
+10
View File
@@ -8,12 +8,14 @@ use super::task::{DownloadTask, DownloaderSettings};
/// 获取所有任务
#[tauri::command]
#[specta::specta]
pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTask> {
engine.get_tasks()
}
/// 检查 URL 重复性并探测文件信息(添加下载前调用)
#[tauri::command]
#[specta::specta]
pub async fn downloader_check_url(
engine: State<'_, DownloadEngine>,
url: String,
@@ -47,6 +49,7 @@ pub async fn downloader_check_url(
/// 添加下载任务
#[tauri::command]
#[specta::specta]
pub async fn downloader_add_task(
engine: State<'_, DownloadEngine>,
url: String,
@@ -60,18 +63,21 @@ pub async fn downloader_add_task(
/// 暂停任务
#[tauri::command]
#[specta::specta]
pub fn downloader_pause_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.pause_task(&id)
}
/// 恢复任务
#[tauri::command]
#[specta::specta]
pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.resume_task(&id)
}
/// 移除任务
#[tauri::command]
#[specta::specta]
pub fn downloader_remove_task(
engine: State<'_, DownloadEngine>,
id: String,
@@ -82,12 +88,14 @@ pub fn downloader_remove_task(
/// 获取设置
#[tauri::command]
#[specta::specta]
pub fn downloader_get_settings(engine: State<'_, DownloadEngine>) -> DownloaderSettings {
engine.get_settings()
}
/// 保存设置
#[tauri::command]
#[specta::specta]
pub fn downloader_save_settings(
engine: State<'_, DownloadEngine>,
settings: DownloaderSettings,
@@ -118,6 +126,7 @@ pub fn downloader_get_extension_info(engine: State<'_, DownloadEngine>) -> serde
/// 用系统资源管理器打开目录
#[tauri::command]
#[specta::specta]
pub fn downloader_open_dir(app: AppHandle, path: String) -> Result<(), String> {
app.opener()
.open_path(path, None::<&str>)
@@ -126,6 +135,7 @@ pub fn downloader_open_dir(app: AppHandle, path: String) -> Result<(), String> {
/// 用系统默认浏览器打开 URL
#[tauri::command]
#[specta::specta]
pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
app.opener()
.open_url(url, None::<&str>)
+110 -46
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -10,9 +10,10 @@ use super::http_dl::{HttpDownloader, split_segments};
use super::rate_limit::RateLimiter;
use super::storage::{EngineState, Storage};
use super::task::{DownloadTask, DownloaderSettings, ProbeResult, Segment, TaskStatus};
use specta::Type;
/// 重复类型
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DuplicateKind {
/// 无重复
@@ -26,7 +27,7 @@ pub enum DuplicateKind {
}
/// 已存在的任务信息(用于前端展示)
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExistingTaskInfo {
pub id: String,
@@ -35,7 +36,7 @@ pub struct ExistingTaskInfo {
}
/// check_url 命令返回的结果
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CheckUrlResult {
/// 探测是否成功
@@ -77,6 +78,9 @@ pub struct CompletePayload {
/// 活跃下载句柄
struct TaskHandle {
/// 代际号:同一任务每次 start_download 递增。
/// 旧代际任务完成时不删除新任务句柄、不覆盖新任务状态(防 pause→resume 竞态)
gen: u64,
cancel: Arc<AtomicBool>,
/// 每个分段的已下载字节(与 segments 一一对应)
progress: Vec<Arc<std::sync::atomic::AtomicU64>>,
@@ -107,6 +111,8 @@ struct EngineInner {
app_handle: AppHandle,
/// 引擎是否已启动
started: AtomicBool,
/// 任务代际计数器(每次 start_download 递增,分配给新句柄)
next_gen: AtomicU64,
/// 持久化节流:上次保存时间
last_save: Mutex<Instant>,
}
@@ -149,6 +155,7 @@ impl DownloadEngine {
http: HttpDownloader::new(),
app_handle,
started: AtomicBool::new(false),
next_gen: AtomicU64::new(0),
last_save: Mutex::new(Instant::now()),
}),
};
@@ -182,7 +189,7 @@ impl DownloadEngine {
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 settings = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).clone();
let task_dir = dir.map(|d| d.to_string()).unwrap_or_else(|| settings.download_dir.clone());
let filename = probe.as_ref().ok()
@@ -199,7 +206,7 @@ impl DownloadEngine {
let mut existing: Option<ExistingTaskInfo> = None;
{
let tasks = self.inner.tasks.lock().unwrap();
let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
for t in tasks.values() {
// URL 完全相同
if t.url == url {
@@ -280,7 +287,7 @@ impl DownloadEngine {
// 探测资源信息
let probe = self.inner.http.probe(&url, &headers).await;
let settings = self.inner.settings.lock().unwrap().clone();
let settings = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).clone();
let task_dir = dir.unwrap_or_else(|| settings.download_dir.clone());
// 确定文件名
@@ -347,14 +354,14 @@ impl DownloadEngine {
};
{
let mut tasks = self.inner.tasks.lock().unwrap();
let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
tasks.insert(id.clone(), task);
}
self.persist_now();
// 如果探测成功,尝试调度
let should_schedule = {
let tasks = self.inner.tasks.lock().unwrap();
let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
tasks.get(&id).map(|t| t.status == TaskStatus::Queued).unwrap_or(false)
};
if should_schedule {
@@ -362,7 +369,7 @@ impl DownloadEngine {
}
// 通知前端有新任务加入(扩展通过 HTTP API 添加时,前端需要刷新)
let _ = self.inner.app_handle.emit("download-added", serde_json::json!({ "id": id }));
let _ = self.inner.app_handle.emit(crate::constants::events::DOWNLOAD_ADDED, serde_json::json!({ "id": id }));
Ok(id)
}
@@ -371,7 +378,7 @@ impl DownloadEngine {
pub fn pause_task(&self, id: &str) -> Result<(), String> {
// 1. 设置取消标志 + 读取进度(锁 handles)
let progress_values: Vec<u64> = {
let handles = self.inner.handles.lock().unwrap();
let handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner());
if let Some(handle) = handles.get(id) {
handle.cancel.store(true, Ordering::SeqCst);
handle.progress.iter().map(|p| p.load(Ordering::Relaxed)).collect()
@@ -382,7 +389,7 @@ impl DownloadEngine {
// 2. 更新任务状态 + 同步进度(锁 tasks,不嵌套锁 handles
{
let mut tasks = self.inner.tasks.lock().unwrap();
let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
if let Some(task) = tasks.get_mut(id) {
if task.status == TaskStatus::Active || task.status == TaskStatus::Queued {
task.status = TaskStatus::Paused;
@@ -407,7 +414,7 @@ impl DownloadEngine {
/// 恢复任务
pub fn resume_task(&self, id: &str) -> Result<(), String> {
{
let mut tasks = self.inner.tasks.lock().unwrap();
let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
if let Some(task) = tasks.get_mut(id) {
if task.status != TaskStatus::Paused && task.status != TaskStatus::Error {
return Err("任务不在可恢复状态".to_string());
@@ -434,7 +441,7 @@ impl DownloadEngine {
pub fn remove_task(&self, id: &str, delete_files: bool) -> Result<(), String> {
// 1. 设置取消标志 + abort join handle(锁 handles
{
let mut handles = self.inner.handles.lock().unwrap();
let mut handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner());
if let Some(handle) = handles.remove(id) {
handle.cancel.store(true, Ordering::SeqCst);
if let Ok(mut join) = handle.join.lock() {
@@ -447,7 +454,7 @@ impl DownloadEngine {
// 2. 从任务列表移除(锁 tasks)
let task = {
let mut tasks = self.inner.tasks.lock().unwrap();
let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
tasks.remove(id)
};
@@ -466,12 +473,12 @@ impl DownloadEngine {
/// 获取所有任务
pub fn get_tasks(&self) -> Vec<DownloadTask> {
self.inner.tasks.lock().unwrap().values().cloned().collect()
self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()).values().cloned().collect()
}
/// 获取设置
pub fn get_settings(&self) -> DownloaderSettings {
self.inner.settings.lock().unwrap().clone()
self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
/// 保存设置
@@ -485,7 +492,7 @@ impl DownloadEngine {
self.inner.global_limiter.set_limit(new_limit);
{
let mut s = self.inner.settings.lock().unwrap();
let mut s = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner());
*s = settings;
}
self.persist_now();
@@ -500,7 +507,7 @@ impl DownloadEngine {
pub fn cleanup_on_exit(&self) {
// 取消所有活跃下载
{
let handles = self.inner.handles.lock().unwrap();
let handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner());
for handle in handles.values() {
handle.cancel.store(true, Ordering::SeqCst);
}
@@ -508,7 +515,7 @@ impl DownloadEngine {
// 将 Active 任务标记为 Paused
{
let mut tasks = self.inner.tasks.lock().unwrap();
let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
for task in tasks.values_mut() {
if task.status == TaskStatus::Active {
task.status = TaskStatus::Paused;
@@ -528,10 +535,10 @@ impl DownloadEngine {
/// 调度:如果活跃任务数 < max_concurrent,启动排队任务
fn schedule(&self) {
let max_concurrent = self.inner.settings.lock().unwrap().max_concurrent as usize;
let max_concurrent = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).max_concurrent as usize;
let (active_count, queued_ids) = {
let tasks = self.inner.tasks.lock().unwrap();
let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
let active = tasks.values().filter(|t| t.status == TaskStatus::Active).count();
let mut queued: Vec<_> = tasks
.values()
@@ -554,7 +561,7 @@ impl DownloadEngine {
/// 启动单个下载任务
fn start_download(&self, id: String) {
let task = {
let mut tasks = self.inner.tasks.lock().unwrap();
let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
match tasks.get_mut(&id) {
Some(task) if task.status == TaskStatus::Queued => {
task.status = TaskStatus::Active;
@@ -565,6 +572,9 @@ impl DownloadEngine {
}
};
// 分配代际号(同一任务每次重启递增)
let gen = self.inner.next_gen.fetch_add(1, Ordering::SeqCst) + 1;
// 创建取消标志和进度计数器
let cancel = Arc::new(AtomicBool::new(false));
let progress: Vec<Arc<std::sync::atomic::AtomicU64>> = task
@@ -573,13 +583,22 @@ impl DownloadEngine {
.map(|s| Arc::new(std::sync::atomic::AtomicU64::new(s.completed)))
.collect();
// 存储句柄
let handle = TaskHandle {
// 存储句柄:先取消旧代际(若存在),确保旧任务尽快退出,避免新旧并发写同一临时文件
{
let mut handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner());
if let Some(old) = handles.get(&id) {
old.cancel.store(true, Ordering::SeqCst);
}
handles.insert(
id.clone(),
TaskHandle {
gen,
cancel: cancel.clone(),
progress: progress.iter().map(|p| p.clone()).collect(),
join: Mutex::new(None),
};
self.inner.handles.lock().unwrap().insert(id.clone(), handle);
},
);
}
// 生成下载 future
let engine = self.clone();
@@ -596,25 +615,44 @@ impl DownloadEngine {
let http = self.inner.http.clone();
let join = tauri::async_runtime::spawn(async move {
let my_gen = gen;
let result = http
.download(&url, &headers, &segments, &temp_file_path, cancel_clone, &progress_clone, limiter)
.await;
// 代际守卫:仅最新代际的任务能更新状态 / 移除句柄 / 发完成事件。
// pause→resume 后旧代际任务才退出,此时句柄已被新代际替换,
// 若仍按旧逻辑执行会覆盖新任务状态并误删新句柄(pause/remove 失效)
let is_current = {
let handles = engine.inner.handles.lock().unwrap_or_else(|e| e.into_inner());
handles.get(&id_clone).map(|h| h.gen == my_gen).unwrap_or(false)
};
if is_current {
// 下载结束,更新任务状态
let final_status = match &result {
let mut final_status = match &result {
Ok(()) => TaskStatus::Complete,
Err(e) if e == "已取消" => TaskStatus::Paused,
Err(_) => TaskStatus::Error,
};
let mut final_error: Option<String> = match &result {
Err(e) if e != "已取消" => Some(e.clone()),
_ => None,
};
// 下载成功后,将临时文件重命名为最终文件名
if final_status == TaskStatus::Complete {
let _ = tokio::fs::rename(&temp_file_path, &final_file_path).await;
if let Err(e) = tokio::fs::rename(&temp_file_path, &final_file_path).await {
// 重命名失败(如目标被占用/路径不可写)→ 置 Error,
// 避免"标记完成但文件缺失"的状态不一致
final_status = TaskStatus::Error;
final_error = Some(format!("移动文件到最终路径失败: {}", e));
}
}
// 同步最终进度到任务
{
let mut tasks = engine.inner.tasks.lock().unwrap();
let mut tasks = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
if let Some(task) = tasks.get_mut(&id_clone) {
for (i, prog) in progress_clone.iter().enumerate() {
if let Some(seg) = task.segments.get_mut(i) {
@@ -624,10 +662,8 @@ impl DownloadEngine {
task.recalc_completed();
task.speed = 0;
task.status = final_status.clone();
if let Err(e) = &result {
if e != "已取消" {
task.error = Some(e.clone());
}
if let Some(e) = final_error {
task.error = Some(e);
}
if final_status == TaskStatus::Complete {
task.completed_size = task.total_size.max(task.completed_size);
@@ -635,11 +671,18 @@ impl DownloadEngine {
}
}
// 从活跃句柄中移除
engine.inner.handles.lock().unwrap().remove(&id_clone);
// 从活跃句柄中移除(仅移除自己代际的句柄)
{
let mut handles = engine.inner.handles.lock().unwrap_or_else(|e| e.into_inner());
if let Some(h) = handles.get(&id_clone) {
if h.gen == my_gen {
handles.remove(&id_clone);
}
}
}
// 发送完成事件
let task = engine.inner.tasks.lock().unwrap().get(&id_clone).cloned();
let task = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()).get(&id_clone).cloned();
if let Some(task) = task {
let _ = engine.inner.app_handle.emit(
"download-complete",
@@ -655,10 +698,11 @@ impl DownloadEngine {
// 持久化 + 调度下一个
engine.persist_now();
engine.schedule();
}
});
// 存储 JoinHandle
if let Some(h) = self.inner.handles.lock().unwrap().get_mut(&id) {
if let Some(h) = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()).get_mut(&id) {
if let Ok(mut join_guard) = h.join.lock() {
*join_guard = Some(join);
}
@@ -670,6 +714,7 @@ impl DownloadEngine {
let progress_monitor = progress;
let cancel_monitor = cancel;
tauri::async_runtime::spawn(async move {
let my_gen = gen;
// 初始化为当前已下载量,避免恢复下载时首次计算速度异常
let mut last_completed: u64 = progress_monitor
.iter()
@@ -681,9 +726,17 @@ impl DownloadEngine {
loop {
interval.tick().await;
// 代际守卫:pause→resume 后旧监控立即退出,避免用过期进度覆盖新任务
let is_current = {
let handles = engine.inner.handles.lock().unwrap_or_else(|e| e.into_inner());
handles.get(&id_monitor).map(|h| h.gen == my_gen).unwrap_or(false)
};
if !is_current {
break;
}
// 如果任务已不在活跃状态,停止监控
let is_active = {
let tasks = engine.inner.tasks.lock().unwrap();
let tasks = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
tasks.get(&id_monitor).map(|t| t.status == TaskStatus::Active).unwrap_or(false)
};
if !is_active || cancel_monitor.load(Ordering::SeqCst) {
@@ -709,7 +762,7 @@ impl DownloadEngine {
// 更新任务状态 + 发送进度事件
let total_size = {
let mut tasks = engine.inner.tasks.lock().unwrap();
let mut tasks = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
if let Some(task) = tasks.get_mut(&id_monitor) {
task.completed_size = completed;
task.speed = speed;
@@ -743,7 +796,7 @@ impl DownloadEngine {
/// 节流持久化(至少间隔 3 秒)
fn persist_throttled(&self) {
let should_save = {
let last = self.inner.last_save.lock().unwrap();
let last = self.inner.last_save.lock().unwrap_or_else(|e| e.into_inner());
last.elapsed() >= Duration::from_secs(3)
};
if should_save {
@@ -751,19 +804,30 @@ impl DownloadEngine {
}
}
/// 立即持久化
/// 立即持久化
/// 保存失败(磁盘不可写/rename 失败)时把进行中任务标记为 Error,
/// 防止用户误以为任务已持久化而关闭应用导致数据丢失。
fn persist_now(&self) {
let tasks: Vec<DownloadTask> = {
let tasks = self.inner.tasks.lock().unwrap();
let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
tasks.values().cloned().collect()
};
let settings = self.inner.settings.lock().unwrap().clone();
let settings = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).clone();
let state = EngineState {
tasks,
settings,
next_id: 0, // storage.save 会从 id_counter 读取
};
self.inner.storage.save(state);
*self.inner.last_save.lock().unwrap() = Instant::now();
if let Err(e) = self.inner.storage.save(state) {
crate::logger::log_error("download", &format!("状态持久化失败,进行中任务标记为 Error: {}", e));
let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner());
for task in tasks.values_mut() {
if task.status == TaskStatus::Active {
task.status = TaskStatus::Error;
}
}
return; // 不更新 last_save,下次定时器会重试
}
*self.inner.last_save.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now();
}
}
+120 -11
View File
@@ -12,6 +12,12 @@ use tokio::task::JoinSet;
use super::rate_limit::RateLimiter;
use super::task::{ProbeResult, Segment};
/// 请求超时:连接 + 响应头必须在 30s 内就绪(分段请求若无超时,
/// 服务器挂死时任务将永久卡在 Active,pause→resume 会出现新旧任务并发写同一临时文件)
const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// 分块读取停滞超时:30s 内无任何数据视为连接挂死,主动中断(配合取消标志及时退出)
const READ_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// HTTP/HTTPS 下载器
#[derive(Clone)]
pub struct HttpDownloader {
@@ -43,7 +49,10 @@ impl HttpDownloader {
req = req.header(k, v);
}
match req.send().await {
match tokio::time::timeout(REQUEST_TIMEOUT, req.send())
.await
.map_err(|_| "探测超时(30s 内未收到响应头)".to_string())?
{
Ok(resp) => {
let status = resp.status();
let headers_map = resp.headers().clone();
@@ -92,9 +101,9 @@ impl HttpDownloader {
for (k, v) in headers {
head_req = head_req.header(k, v);
}
let resp = head_req
.send()
let resp = tokio::time::timeout(REQUEST_TIMEOUT, head_req.send())
.await
.map_err(|_| "探测超时(HEAD 30s 内未收到响应头)".to_string())?
.map_err(|e| format!("探测失败(GET 和 HEAD 均失败): {}", e))?;
let headers_map = resp.headers().clone();
let total_size = headers_map
@@ -295,15 +304,27 @@ async fn download_segment_with_client(
req = req.header(k, v);
}
let resp = req
.send()
let resp = tokio::time::timeout(REQUEST_TIMEOUT, req.send())
.await
.map_err(|_| "请求超时(30s 内未收到响应头)".to_string())?
.map_err(|e| format!("请求失败: {}", e))?;
let status = resp.status();
if !status.is_success() && status.as_u16() != 206 {
if unknown_size {
// 未知大小:未发送 Range 头,接受任意 2xx
if !status.is_success() {
return Err(format!("服务器返回 HTTP {}", status));
}
} else {
// 已发送 Range 头:必须返回 206。若服务器忽略 Range 返回 200 全文,
// 按 range_start 偏移写入会错位 → 静默损坏文件;此处直接中断。
if status.as_u16() != 206 {
return Err(format!(
"服务器未按分段请求响应(期望 206,实际 {}),已中断以避免文件损坏",
status
));
}
}
// 流式读取并写入文件
let mut stream = resp.bytes_stream();
@@ -315,8 +336,9 @@ async fn download_segment_with_client(
return Err("已取消".to_string());
}
match stream.next().await {
Some(Ok(chunk)) => {
// 停滞超时:30s 无数据即中断,确保 cancel 标志能及时被感知(配合代际句柄防并发写)
match tokio::time::timeout(READ_STALL_TIMEOUT, stream.next()).await {
Ok(Some(Ok(chunk))) => {
buf.extend_from_slice(&chunk);
// 接收到数据立即更新进度(避免监控周期内进度无变化导致速度显示为 0)
local_completed += chunk.len() as u64;
@@ -331,10 +353,10 @@ async fn download_segment_with_client(
buf.clear();
}
}
Some(Err(e)) => {
Ok(Some(Err(e))) => {
return Err(format!("读取数据失败: {}", e));
}
None => {
Ok(None) => {
// 流结束,写入剩余数据
if !buf.is_empty() {
file.write_all(&buf)
@@ -346,6 +368,9 @@ async fn download_segment_with_client(
}
break;
}
Err(_) => {
return Err("读取超时(30s 无数据,已中断下载)".to_string());
}
}
}
@@ -436,10 +461,11 @@ fn percent_decode(s: &str) -> String {
/// 将文件大小划分为 N 个分段
pub fn split_segments(total_size: u64, num_connections: u32) -> Vec<Segment> {
if total_size == 0 || num_connections == 0 {
// 空文件或未指定连接数:单段覆盖整个文件(end 按 total_size 推导,不能硬编码 0
return vec![Segment {
index: 0,
start: 0,
end: 0,
end: total_size.saturating_sub(1),
completed: 0,
}];
}
@@ -473,3 +499,86 @@ pub fn split_segments(total_size: u64, num_connections: u32) -> Vec<Segment> {
segments
}
#[cfg(test)]
mod split_segments_tests {
use super::*;
/// 分段必须完整覆盖 [0, total_size),且相互连续无重叠
fn assert_contiguous(segments: &[Segment], total_size: u64) {
assert!(!segments.is_empty());
let mut prev_end: i64 = -1;
for seg in segments {
assert_eq!(seg.start as i64, prev_end + 1, "分段不连续");
assert!(seg.end >= seg.start, "分段 start > end");
prev_end = seg.end as i64;
}
assert_eq!(
segments.last().unwrap().end,
total_size - 1,
"末段未覆盖文件末尾"
);
}
#[test]
fn zero_size_returns_single_segment() {
let segs = split_segments(0, 4);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].start, 0);
assert_eq!(segs[0].end, 0);
}
#[test]
fn zero_connections_covers_whole_file() {
let segs = split_segments(1024, 0);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].start, 0);
assert_eq!(segs[0].end, 1023);
}
#[test]
fn divides_evenly_with_contiguous_coverage() {
// 10MB / 4 连接 → 4 段完整覆盖
let total = 10 * 1024 * 1024;
let segs = split_segments(total, 4);
assert_eq!(segs.len(), 4);
assert_contiguous(&segs, total);
}
#[test]
fn clamps_connections_by_min_segment_size() {
// 2MB 文件请求 8 连接 → 受 1MB 最小分段限制,实际 ≤ 2 段
let total = 2 * 1024 * 1024;
let segs = split_segments(total, 8);
assert!(segs.len() <= 2, "连接数未按最小分段收敛: {}", segs.len());
assert_contiguous(&segs, total);
}
#[test]
fn respects_requested_connection_count() {
// 大文件按请求连接数切分
let total = 100 * 1024 * 1024;
let segs = split_segments(total, 3);
assert_eq!(segs.len(), 3);
assert_contiguous(&segs, total);
// 每段大小均匀
for seg in &segs {
let seg_len = seg.end - seg.start + 1;
assert!(
seg_len >= total / 3,
"分段大小不均: {} 段只有 {} 字节",
seg.index,
seg_len
);
}
}
#[test]
fn tiny_file_single_segment() {
// 小于 1MB 的文件始终单段
let total = 100;
let segs = split_segments(total, 4);
assert_eq!(segs.len(), 1);
assert_contiguous(&segs, total);
}
}
+62 -1
View File
@@ -38,7 +38,7 @@ impl RateLimiter {
// 尝试在当前窗口消费(用作用域确保 MutexGuard 在 await 前释放)
let over_limit = {
let now = std::time::Instant::now();
let mut start = self.window_start.lock().unwrap();
let mut start = self.window_start.lock().unwrap_or_else(|e| e.into_inner());
let elapsed = now.duration_since(*start);
// 窗口过期,重置
@@ -72,3 +72,64 @@ impl Clone for RateLimiter {
Self::new(self.limit.load(Ordering::Relaxed))
}
}
#[cfg(test)]
mod rate_limiter_tests {
use super::*;
use std::time::Instant;
#[tokio::test]
async fn zero_limit_never_blocks() {
let limiter = RateLimiter::new(0);
let start = Instant::now();
limiter.consume(1024 * 1024).await;
limiter.consume(u64::MAX).await;
assert!(start.elapsed() < Duration::from_millis(50));
}
#[tokio::test]
async fn under_limit_returns_immediately() {
let limiter = RateLimiter::new(100_000); // 100KB/s
let start = Instant::now();
limiter.consume(1024).await;
assert!(start.elapsed() < Duration::from_millis(50));
}
#[tokio::test]
async fn exceeding_limit_waits_proportionally() {
// 限速 200 B/s:先消耗 100 未超限,再消耗 150 → 超限 50 → 等待约 250ms
let limiter = RateLimiter::new(200);
limiter.consume(100).await;
let start = Instant::now();
limiter.consume(150).await;
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_millis(200),
"等待时间不足: {:?}",
elapsed
);
assert!(elapsed < Duration::from_millis(1100));
}
#[tokio::test]
async fn window_resets_after_one_second() {
// 限速 100 B/s:第一窗口耗尽后,1.1s 窗口重置,再消耗 100 不应阻塞
let limiter = RateLimiter::new(100);
limiter.consume(100).await;
tokio::time::sleep(Duration::from_millis(1100)).await;
let start = Instant::now();
limiter.consume(100).await;
assert!(start.elapsed() < Duration::from_millis(100));
}
#[tokio::test]
async fn set_limit_takes_effect_dynamically() {
let limiter = RateLimiter::new(0);
limiter.consume(1024).await; // 不限速
limiter.set_limit(100);
limiter.consume(100).await;
let start = Instant::now();
limiter.consume(100).await; // 累计 200 > 100 → 等待 1000ms
assert!(start.elapsed() >= Duration::from_millis(900));
}
}
+3 -3
View File
@@ -57,15 +57,15 @@ impl ExtensionServer {
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("[download_engine] 扩展 HTTP 服务启动失败 ({}): {}", addr, e);
crate::logger::log_error("download", &format!("扩展 HTTP 服务启动失败 ({}): {}", addr, e));
return;
}
};
eprintln!("[download_engine] 扩展 HTTP 服务已启动: http://{}", addr);
crate::logger::log_info("download", &format!("扩展 HTTP 服务已启动: http://{}", addr));
if let Err(e) = axum::serve(listener, app).await {
eprintln!("[download_engine] 扩展 HTTP 服务异常: {}", e);
crate::logger::log_error("download", &format!("扩展 HTTP 服务异常: {}", e));
}
}
}
+14 -13
View File
@@ -40,7 +40,7 @@ impl Storage {
pub fn new(data_dir: PathBuf) -> Self {
// 确保数据目录存在(首次启动或目录被删除时自动创建)
if let Err(e) = fs::create_dir_all(&data_dir) {
eprintln!("[download_engine] 创建数据目录失败: {} ({})", data_dir.display(), e);
crate::logger::log_error("download", &format!("创建数据目录失败: {} ({})", data_dir.display(), e));
}
let state_path = data_dir.join("engine_state.json");
let existing = Self::load_raw(&state_path);
@@ -68,23 +68,24 @@ impl Storage {
Self::load_raw(&self.state_path).unwrap_or_default()
}
/// 保存状态到磁盘
pub fn save(&self, mut state: EngineState) {
/// 保存状态到磁盘(原子写:先写 .tmp 再 rename 覆盖,
/// 避免进程崩溃时产生半写/截断的状态文件导致任务列表丢失)
/// 失败返回 Err,由调用方决定是否将任务置为 Error(防止"看似已保存"的假象)。
pub fn save(&self, mut state: EngineState) -> Result<(), String> {
// 同步 ID 计数器
state.next_id = self.id_counter.load(Ordering::SeqCst);
match serde_json::to_string_pretty(&state) {
Ok(json) => {
let json = serde_json::to_string_pretty(&state)
.map_err(|e| format!("序列化状态失败: {}", e))?;
// 兜底:若父目录被外部删除则在写入前重建
if let Some(parent) = self.state_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::write(&self.state_path, json) {
eprintln!("[download_engine] 保存状态失败: {}", e);
}
}
Err(e) => {
eprintln!("[download_engine] 序列化状态失败: {}", e);
}
}
let tmp_path = PathBuf::from(format!("{}.tmp", self.state_path.display()));
fs::write(&tmp_path, json).map_err(|e| format!("保存状态失败: {}", e))?;
fs::rename(&tmp_path, &self.state_path).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
format!("替换状态文件失败: {}", e)
})?;
Ok(())
}
}
+5 -4
View File
@@ -1,8 +1,9 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
/// 任务状态
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
/// 排队等待(并发数已满)
@@ -18,7 +19,7 @@ pub enum TaskStatus {
}
/// 下载分段(多线程 Range 下载 / 断点续传用)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Segment {
/// 分段索引
@@ -43,7 +44,7 @@ impl Segment {
}
/// 下载任务
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DownloadTask {
/// 任务 ID(自增 hex 字符串)
@@ -98,7 +99,7 @@ impl DownloadTask {
}
/// 下载设置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DownloaderSettings {
/// 下载目录
+98 -156
View File
@@ -1,8 +1,7 @@
use std::sync::Arc;
use tauri::Emitter;
use tauri::Manager;
mod clipboard;
mod constants;
mod download_engine;
mod logger;
mod mihomo_manager;
@@ -12,17 +11,20 @@ mod osd_window;
mod process_manager;
mod quickpanel;
mod screenshot;
mod setup;
mod shortcut;
mod snap_fix;
mod tray_menu;
mod win32_util;
use download_engine::{
DownloadEngine, ExtensionServer,
DownloadEngine,
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,
};
use logger::{
clear_logs, get_log_info, get_logs, log_message, LogManager,
log_clear, log_info_state, log_list, log_message,
};
use mihomo_manager::{
proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_delete_profile,
@@ -37,21 +39,22 @@ use monitor_kernel::{
monitor_set_hardware_config, monitor_start, monitor_start_elevated, monitor_status,
monitor_stop, MonitorKernel,
};
use network_monitor::{network_monitor_status, NetworkMonitor};
use network_monitor::network_status;
use osd_window::{
osd_apply_overlay_style, osd_begin_drag, osd_set_click_through, osd_set_topmost,
osd_start_drag_watch, osd_start_topmost_watch, osd_stop_watch,
};
use process_manager::{
get_all_process_status, get_process_status, start_monitoring_thread, start_process,
stop_all_processes, stop_process, ProcessManager,
process_all_status, process_start, process_status,
process_stop, process_stop_all, ProcessManager,
};
use screenshot::commands::{
screenshot_capture_fullscreen, screenshot_capture_window, screenshot_clear_fullscreen,
screenshot_compose_copy, screenshot_copy_image, screenshot_crop_copy_stored,
screenshot_crop_stored, screenshot_cursor_pos, screenshot_disable_transitions,
screenshot_enum_windows, screenshot_fullscreen_png, screenshot_get_editor_image,
screenshot_get_fullscreen_bmp, screenshot_register_shortcut, screenshot_save_png,
screenshot_crop_stored, screenshot_cursor_pos, screenshot_delete_cache,
screenshot_disable_transitions, screenshot_enum_windows, screenshot_fullscreen_png,
screenshot_get_editor_image, screenshot_get_fullscreen_bmp, screenshot_load_cache,
screenshot_register_shortcut, screenshot_save_cache, screenshot_save_png,
screenshot_set_editor_image, screenshot_unregister_shortcut, screenshot_window_from_point,
};
use clipboard::{
@@ -75,35 +78,73 @@ use quickpanel::{
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
fn quit_app(app: tauri::AppHandle) {
// 资源清理统一收敛到 RunEvent::ExitRequested(覆盖所有退出路径),此处仅请求退出
app.exit(0);
}
#[tauri::command]
fn quit_app(
state: tauri::State<'_, ProcessManager>,
mihomo: tauri::State<'_, MihomoManager>,
engine: tauri::State<'_, DownloadEngine>,
monitor: tauri::State<'_, MonitorKernel>,
clipboard: tauri::State<'_, ClipboardManager>,
app: tauri::AppHandle,
) {
// 退出前清理系统代理,避免遗留导致网络问题
mihomo.cleanup_on_exit();
// 退出前保存下载引擎状态
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,统一退出路径
app.exit(0);
/// 导出 tauri-specta 生成的 TypeScript 类型与命令绑定(仅 debug 构建,开发时自动刷新)。
/// 覆盖 proxy / quickpanel / clipboard / download_engine / screenshot 五个模块;
/// 豁免清单(返回 serde_json::Value 或 tauri::ipc::Response/Requestspecta 无法生成):
/// proxy_version / proxy_get_proxies / proxy_get_connections / proxy_patch_configs、
/// downloader_status / downloader_get_extension_info、
/// screenshot_get_fullscreen_bmp(返回 ipc::Response/ screenshot_compose_copy(接收 ipc::Request)。
#[cfg(debug_assertions)]
fn export_bindings() {
use specta_typescript::Typescript;
use tauri_specta::{Builder, ErrorHandlingMode, collect_commands};
Builder::<tauri::Wry>::new()
// 全局把 u64/i64 映射为 number(项目取值均在 JS 安全整数范围:大小/时间戳/limit)
.dangerously_cast_bigints_to_number()
// 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper
.error_handling(ErrorHandlingMode::Throw)
.commands(collect_commands![
// proxy20
proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy,
proxy_close_connection, proxy_delete_profile, proxy_get_settings,
proxy_get_system_proxy, proxy_import_profile, proxy_install_kernel,
proxy_kernel_info, proxy_restart, proxy_save_settings,
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop,
proxy_test_delay, proxy_update_kernel, proxy_update_profile,
// quickpanel22
quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut,
quickpanel_unregister_shortcut, quickpanel_show_popup, quickpanel_hide_popup,
quickpanel_show_window, quickpanel_lock_screen, quickpanel_init_file_index,
quickpanel_build_file_index, quickpanel_search_files, quickpanel_file_index_stats,
quickpanel_scan_apps, quickpanel_get_app_icon, quickpanel_clear_app_icon_cache,
quickpanel_reveal_in_explorer, quickpanel_open_file, quickpanel_get_special_locations,
quickpanel_open_special, quickpanel_delete_file, quickpanel_run_custom_command,
quickpanel_run_system_command,
// clipboard20
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_hide_popup,
clipboard_show_window, clipboard_paste_to_target,
// download_engine10,豁免 2
downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task,
downloader_resume_task, downloader_remove_task, downloader_get_settings,
downloader_save_settings, downloader_open_dir, downloader_open_url,
// screenshot19,豁免 2get_fullscreen_bmp 返回 ipc::Response、compose_copy 接收 ipc::Request
screenshot_disable_transitions, screenshot_register_shortcut,
screenshot_unregister_shortcut, screenshot_capture_fullscreen, screenshot_fullscreen_png,
screenshot_clear_fullscreen, screenshot_crop_stored, screenshot_crop_copy_stored,
screenshot_window_from_point, screenshot_cursor_pos, screenshot_enum_windows,
screenshot_capture_window, screenshot_set_editor_image, screenshot_get_editor_image,
screenshot_copy_image, screenshot_save_png,
screenshot_save_cache, screenshot_load_cache, screenshot_delete_cache,
])
.export(Typescript::default(), "../src/lib/bindings.ts")
.expect("failed to export bindings");
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(debug_assertions)]
export_bindings();
tauri::Builder::default()
.plugin(tauri_plugin_autostart::Builder::new().build())
.plugin(tauri_plugin_opener::init())
@@ -119,17 +160,16 @@ pub fn run() {
)
.manage(ProcessManager::new())
.invoke_handler(tauri::generate_handler![
greet,
quit_app,
start_process,
stop_process,
get_process_status,
get_all_process_status,
stop_all_processes,
process_start,
process_stop,
process_status,
process_all_status,
process_stop_all,
log_message,
get_logs,
clear_logs,
get_log_info,
log_list,
log_clear,
log_info_state,
proxy_get_settings,
proxy_save_settings,
proxy_kernel_info,
@@ -166,7 +206,7 @@ pub fn run() {
monitor_set_elevate_on_launch,
monitor_get_hardware_config,
monitor_set_hardware_config,
network_monitor_status,
network_status,
osd_apply_overlay_style,
osd_begin_drag,
osd_set_click_through,
@@ -246,123 +286,15 @@ pub fn run() {
screenshot_get_editor_image,
screenshot_copy_image,
screenshot_save_png,
screenshot_save_cache,
screenshot_load_cache,
screenshot_delete_cache,
screenshot_register_shortcut,
screenshot_unregister_shortcut,
screenshot_disable_transitions,
screenshot_compose_copy
])
.setup(|app| {
// 初始化日志系统,日志目录: {app_data_dir}/logs/
let log_dir = app
.path()
.app_data_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
.join("logs");
app.manage(LogManager::new(log_dir));
// 初始化 MihomoManager,数据目录: {app_data_dir}/proxy/
let app_data_dir = app
.path()
.app_data_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."));
// 永久提权检查:如果标志已设置且当前非管理员,以管理员权限重启自身并退出
// 必须在所有模块初始化之前执行(此时无资源需要清理)
if monitor_kernel::check_and_relaunch_if_needed(&app_data_dir) {
std::process::exit(0);
}
let mihomo = MihomoManager::new(app_data_dir.clone());
app.manage(mihomo);
// 初始化 MonitorKernel,数据目录: {app_data_dir}/monitor/
let monitor = MonitorKernel::new(app_data_dir.clone());
app.manage(monitor);
// 初始化 NetworkMonitor(网速监控,独立于 ThingHK Kernel
// 网速采样不依赖提权,应用启动即开始
let network_monitor = Arc::new(NetworkMonitor::new());
app.manage(network_monitor.clone());
network_monitor.start(app.handle().clone());
// 初始化 DownloadEngine,数据目录: {app_data_dir}/downloader/
let engine = DownloadEngine::new(
app_data_dir.join("downloader"),
app.handle().clone(),
);
let settings = engine.get_settings();
app.manage(engine.clone());
// 启动扩展 HTTP API 服务器
let server_engine = engine.clone();
let server_port = settings.extension_port;
let server_secret = settings.extension_secret.clone();
tauri::async_runtime::spawn(async move {
ExtensionServer::start(server_engine, server_port, server_secret).await;
});
// 初始化 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);
}
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
clipboard::popup::ensure_popup_window(&app_handle);
}
app.manage(clipboard);
// 快速面板:应用启动时注册全局快捷键 + 预创建隐藏窗口。
// defaultEnabled:true 假设启用;用户在设置页禁用模块时由前端 onDisable 钩子注销快捷键。
let qp_settings = quickpanel::load_settings(&app.handle());
if !qp_settings.shortcut.trim().is_empty() {
let app_handle = app.handle().clone();
if let Err(e) = quickpanel::register_shortcut(&app_handle, &qp_settings.shortcut) {
eprintln!("[quickpanel] 快捷键注册失败: {}", e);
}
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
quickpanel::ensure_window(&app_handle);
}
// 初始化文件索引数据库(不立即构建,由前端设置页或首次唤起时触发)
quickpanel::file_index::init(&app.handle());
// 自定义托盘菜单(代理/OSD/Kernel/下载/设置/退出)
tray_menu::create_tray_menu(app.handle())?;
// 启动进程监控线程
start_monitoring_thread(app.handle().clone());
// 应用启动时自动启动 mihomo(如果用户在设置中开启了自动启动)
if let Some(mihomo) = app.try_state::<MihomoManager>() {
if let Some(pm) = app.try_state::<ProcessManager>() {
mihomo.auto_start_on_launch(app.handle(), &pm);
}
}
// 应用启动时自动启动 monitor Kernel(硬件监控默认启用,被动读取无副作用)
if let Some(monitor) = app.try_state::<MonitorKernel>() {
let monitor = monitor.inner().clone();
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
match monitor.start_with_subscription(&app_handle).await {
Ok(info) => eprintln!("[monitor] 自动启动成功, pid={:?}", info.pid),
Err(e) => eprintln!("[monitor] 自动启动跳过: {}", e),
}
});
}
// 截图快捷键由前端 screenshotStore 启动时调用 screenshot_register_shortcut 注册
// (支持自定义,默认 Ctrl+Alt+A),此处不再硬编码注册
Ok(())
})
.setup(setup::init)
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
window.hide().ok();
@@ -380,7 +312,17 @@ pub fn run() {
engine.cleanup_on_exit();
}
if let Some(monitor) = app.try_state::<MonitorKernel>() {
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
// cleanup_on_exit 是 async;在事件循环回调中直接 block_on 有 panic 风险且阻塞退出,
// 放到独立 OS 线程执行并限时等待(与托盘旧实现同模式)。
let app_clone = app.clone();
let monitor_clone = monitor.inner().clone();
std::thread::spawn(move || {
tauri::async_runtime::block_on(async move {
monitor_clone.cleanup_on_exit(&app_clone).await;
});
})
.join()
.ok();
}
if let Some(clip) = app.try_state::<ClipboardManager>() {
clip.stop();
+276 -23
View File
@@ -1,8 +1,9 @@
use chrono::Local;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
/// 日志级别
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -34,11 +35,14 @@ pub struct LogInfo {
}
/// 日志管理器 —— 负责文件轮转、写入、查询
/// 所有写/轮转/读操作通过 write_lock 串行化,防止并发写交错与轮转竞争
#[derive(Clone)]
pub struct LogManager {
log_dir: PathBuf,
max_file_size: u64,
max_files: u32,
base_name: String,
write_lock: Arc<Mutex<()>>,
}
impl LogManager {
@@ -48,12 +52,14 @@ impl LogManager {
log_dir,
max_file_size: 5 * 1024 * 1024,
max_files: 5,
base_name: "thing".to_string(),
base_name: "Thing".to_string(),
write_lock: Arc::new(Mutex::new(())),
}
}
/// 写入一条日志
/// 写入一条日志(写 + 轮转在锁内串行执行)
pub fn log(&self, level: LogLevel, module: &str, message: &str) {
let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let level_str = match level {
LogLevel::Debug => "DEBUG",
@@ -114,32 +120,38 @@ impl LogManager {
}
/// 读取日志(支持按模块/级别过滤、条数限制)
/// 从最新文件向旧文件倒序遍历,每个文件从末尾向前读取,达到 limit 即停止,
/// 避免将全部日志读入内存后再排序截断
pub fn get_logs(
&self,
module: Option<&str>,
level: Option<LogLevel>,
limit: Option<usize>,
) -> Vec<LogEntry> {
let limit = limit.unwrap_or(100);
// 与写入共用同一把锁,避免读到轮转半途/写入半行的状态
let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
let mut entries: Vec<LogEntry> = Vec::new();
// 收集所有日志文件(包括轮转文件
let mut log_files: Vec<PathBuf> = Vec::new();
// 收集日志文件(最新在前:thing.log → thing.1.log → …
let mut paths: Vec<PathBuf> = Vec::new();
let current = self.log_dir.join(format!("{}.log", self.base_name));
if current.exists() {
log_files.push(current);
paths.push(current);
}
for i in 1..=self.max_files {
let rotated = self
.log_dir
.join(format!("{}.{}.log", self.base_name, i));
if rotated.exists() {
log_files.push(rotated);
paths.push(rotated);
}
}
for path in &log_files {
if let Ok(file) = File::open(path) {
for line in BufReader::new(file).lines().flatten() {
'outer: for path in &paths {
// 每文件返回最近 limit 行(从新到旧),收集满即整体停止
for line in read_tail_lines(path, limit) {
if let Some(entry) = Self::parse_line(&line) {
if let Some(ref m) = module {
if entry.module != *m {
@@ -152,18 +164,13 @@ impl LogManager {
}
}
entries.push(entry);
if entries.len() >= limit {
break 'outer;
}
}
}
}
// 按时间戳降序(最新在前)
entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
if let Some(n) = limit {
entries.truncate(n);
}
entries
}
@@ -212,6 +219,7 @@ impl LogManager {
/// 清空所有日志文件
pub fn clear_logs(&self) -> Result<(), String> {
let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
let current = self.log_dir.join(format!("{}.log", self.base_name));
fs::remove_file(&current).map_err(|e| e.to_string())?;
for i in 1..=self.max_files {
@@ -259,6 +267,63 @@ impl LogManager {
// ===== Tauri 命令 =====
/// 从文件末尾向前读取日志行(返回时间从新到旧的最近 max_lines 行)。
/// 按 8KB 块向前 seek 读取并拼接跨块半行,只读取文件尾部,避免全量读入
fn read_tail_lines(path: &Path, max_lines: usize) -> Vec<String> {
let mut file = match File::open(path) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let file_len = match file.metadata() {
Ok(m) => m.len(),
Err(_) => return Vec::new(),
};
const CHUNK: u64 = 8192;
// tail 保存"当前块更靠后的半行",下一轮(更早的块)拼在其前
let mut tail = String::new();
let mut lines: Vec<String> = Vec::new();
let mut pos = file_len;
while pos > 0 && lines.len() < max_lines {
let start = pos.saturating_sub(CHUNK);
let len = (pos - start) as usize;
let mut bytes = vec![0u8; len];
if file.seek(SeekFrom::Start(start)).is_err() || file.read_exact(&mut bytes).is_err() {
break;
}
pos = start;
let mut text = String::from_utf8_lossy(&bytes).into_owned();
text.push_str(&tail);
// 最后一段未以 \n 结尾 → 半行,作为下一轮 tail(与本块之前的内容拼接)
let mut parts: Vec<&str> = text.split('\n').collect();
tail = parts.pop().unwrap_or("").to_string();
// 从后往前(新到旧)取完整行
for part in parts.iter().rev() {
let trimmed = part.trim();
if !trimmed.is_empty() {
lines.push(trimmed.to_string());
if lines.len() >= max_lines {
break;
}
}
}
}
// 文件头残余(pos == 0 时 tail 里可能是文件第一行)
if pos == 0 {
let trimmed = tail.trim();
if !trimmed.is_empty() && lines.len() < max_lines {
lines.push(trimmed.to_string());
}
}
lines
}
#[tauri::command]
pub fn log_message(
state: tauri::State<'_, LogManager>,
@@ -278,12 +343,12 @@ pub fn log_message(
}
#[tauri::command]
pub fn get_logs(
pub async fn log_list(
state: tauri::State<'_, LogManager>,
module: Option<String>,
level: Option<String>,
limit: Option<usize>,
) -> Vec<LogEntry> {
) -> Result<Vec<LogEntry>, String> {
let level = level.and_then(|l| match l.as_str() {
"debug" => Some(LogLevel::Debug),
"info" => Some(LogLevel::Info),
@@ -291,15 +356,203 @@ pub fn get_logs(
"error" => Some(LogLevel::Error),
_ => None,
});
state.get_logs(module.as_deref(), level, limit)
// 文件读取(含 seek 尾部扫描)移出 async runtime 线程
let manager = state.inner().clone();
tauri::async_runtime::spawn_blocking(move || manager.get_logs(module.as_deref(), level, limit))
.await
.map_err(|e| format!("读取日志任务失败: {}", e))
}
#[tauri::command]
pub fn clear_logs(state: tauri::State<'_, LogManager>) -> Result<(), String> {
pub fn log_clear(state: tauri::State<'_, LogManager>) -> Result<(), String> {
state.clear_logs()
}
#[tauri::command]
pub fn get_log_info(state: tauri::State<'_, LogManager>) -> LogInfo {
pub fn log_info_state(state: tauri::State<'_, LogManager>) -> LogInfo {
state.get_info()
}
// ===== 进程级全局日志器 =====
// 后端模块(无 AppHandle 上下文)通过 log_line 写入统一的日志文件,
// 与 Tauri 命令 log_message 共用同一 LogManagerwrite_lock 串行化),
// 消除 eprintln!/println! 双轨并行问题。
static GLOBAL_LOGGER: OnceLock<LogManager> = OnceLock::new();
/// 在 setup 中注册全局日志器(与 app.manage 注册的实例共享同一把 write_lock
pub fn install_global(manager: LogManager) {
let _ = GLOBAL_LOGGER.set(manager);
}
/// 写一条后端模块日志。未注册全局日志器时回退到 stderr(如测试环境)。
pub fn log_line(module: &str, level: LogLevel, message: &str) {
match GLOBAL_LOGGER.get() {
Some(m) => m.log(level, module, message),
None => eprintln!("[{}] {}", module, message),
}
}
/// 便捷:INFO 级别
pub fn log_info(module: &str, message: &str) {
log_line(module, LogLevel::Info, message);
}
/// 便捷:WARN 级别
pub fn log_warn(module: &str, message: &str) {
log_line(module, LogLevel::Warn, message);
}
/// 便捷:ERROR 级别
pub fn log_error(module: &str, message: &str) {
log_line(module, LogLevel::Error, message);
}
#[cfg(test)]
mod logger_tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
/// 创建唯一临时目录(进程内多次调用不冲突),测试结束自动清理
fn temp_dir(tag: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let pid = std::process::id();
let seq = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"thing_log_test_{}_{}_{}_{}",
tag, pid, nanos, seq
));
fs::create_dir_all(&dir).unwrap();
dir
}
struct TempGuard(PathBuf);
impl Drop for TempGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn parse_line_roundtrip() {
let line = "[2026-08-05 10:00:00] [INFO] [downloader] 下载完成";
let entry = LogManager::parse_line(line).expect("标准行应能解析");
assert_eq!(entry.timestamp, "2026-08-05 10:00:00");
assert_eq!(entry.level, LogLevel::Info);
assert_eq!(entry.module, "downloader");
assert_eq!(entry.message, "下载完成");
// 非法行返回 None
assert!(LogManager::parse_line("not a log line").is_none());
assert!(LogManager::parse_line("").is_none());
assert!(LogManager::parse_line("[bad] [INFO] [m] msg").is_none());
assert!(LogManager::parse_line("[2026-08-05 10:00:00] [NOPE] [m] msg").is_none());
// 时间戳不做格式校验:仅按方括号切分,日期形式同样可解析
assert!(LogManager::parse_line("[2026-08-05] [INFO] [m] msg").is_some());
}
#[test]
fn rotate_shifts_files_and_caps_count() {
let dir = temp_dir("rotate");
let _guard = TempGuard(dir.clone());
let mgr = LogManager::new(dir.clone());
let current = dir.join("thing.log");
fs::write(&current, "content-0").unwrap();
mgr.rotate();
// thing.log → thing.1.log
assert!(!current.exists());
assert_eq!(
fs::read_to_string(dir.join("thing.1.log")).unwrap(),
"content-0"
);
// 第二次轮转:thing.1.log → thing.2.log,新 thing.log → thing.1.log
fs::write(&current, "content-1").unwrap();
mgr.rotate();
assert_eq!(
fs::read_to_string(dir.join("thing.2.log")).unwrap(),
"content-0"
);
assert_eq!(
fs::read_to_string(dir.join("thing.1.log")).unwrap(),
"content-1"
);
// 轮转超过 max_files(5) 后最旧文件被删除,文件数量不超上限
for i in 0..6 {
fs::write(&current, format!("content-{}", i)).unwrap();
mgr.rotate();
}
let files: Vec<String> = fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.filter(|n| n.ends_with(".log"))
.collect();
assert!(files.len() <= 5, "轮转文件数量超上限: {:?}", files);
assert!(dir.join("thing.1.log").exists(), "最新的旋转文件应存在");
}
#[test]
fn log_writes_and_get_logs_filters() {
let dir = temp_dir("query");
let _guard = TempGuard(dir.clone());
let mgr = LogManager::new(dir.clone());
mgr.log(LogLevel::Info, "downloader", "任务开始");
mgr.log(LogLevel::Error, "downloader", "任务失败");
mgr.log(LogLevel::Info, "proxy", "节点切换");
// 全部(新到旧)
let all = mgr.get_logs(None, None, None);
assert_eq!(all.len(), 3);
assert_eq!(all[0].message, "节点切换");
assert_eq!(all[2].message, "任务开始");
// 按模块过滤
let dl = mgr.get_logs(Some("downloader"), None, None);
assert_eq!(dl.len(), 2);
assert!(dl.iter().all(|e| e.module == "downloader"));
// 按级别过滤
let errs = mgr.get_logs(None, Some(LogLevel::Error), None);
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].message, "任务失败");
// 条数限制
let limited = mgr.get_logs(None, None, Some(2));
assert_eq!(limited.len(), 2);
}
#[test]
fn auto_rotate_triggers_on_size() {
let dir = temp_dir("auto");
let _guard = TempGuard(dir.clone());
let mgr = LogManager::new(dir.clone());
// 写满 5MB 触发自动轮转(meta.len() >= max_file_size
let current = dir.join("thing.log");
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(&current)
.unwrap();
let big = "x".repeat(5 * 1024 * 1024);
f.write_all(big.as_bytes()).unwrap();
drop(f);
mgr.log(LogLevel::Info, "test", "触发轮转");
assert!(dir.join("thing.1.log").exists(), "应自动轮转出 thing.1.log");
// 轮转后旧文件(5MB)被重命名为 thing.1.log,当前文件只含新追加的一行
let rotated_len = fs::metadata(dir.join("thing.1.log")).unwrap().len();
assert_eq!(rotated_len, 5 * 1024 * 1024, "轮转出的文件应保留完整旧内容");
let cur_len = fs::metadata(&current).unwrap().len();
assert!(cur_len < 100, "轮转后的当前文件应只含新行: {}", cur_len);
}
}
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
//! 代理模块 Tauri 命令层。
use tauri::{AppHandle, State};
use super::system_proxy::{clear_system_proxy_windows, get_system_proxy_windows, set_system_proxy_windows};
use super::{
KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus,
};
use crate::process_manager::{ProcessInfo, ProcessManager};
#[tauri::command]
#[specta::specta]
pub fn proxy_get_settings(state: State<'_, MihomoManager>) -> ProxySettings {
state.load_settings()
}
#[tauri::command]
#[specta::specta]
pub fn proxy_save_settings(
state: State<'_, MihomoManager>,
settings: ProxySettings,
) -> Result<(), String> {
state.save_settings(&settings)
}
#[tauri::command]
#[specta::specta]
pub fn proxy_kernel_info(
state: State<'_, MihomoManager>,
app: AppHandle,
) -> Result<KernelInfo, String> {
state.prepare_kernel(&app)
}
#[tauri::command]
#[specta::specta]
pub async fn proxy_check_kernel_update(
state: State<'_, MihomoManager>,
) -> Result<KernelUpdateInfo, String> {
state.check_kernel_update().await
}
#[tauri::command]
#[specta::specta]
pub async fn proxy_update_kernel(
state: State<'_, MihomoManager>,
app: AppHandle,
mirror_prefix: Option<String>,
) -> Result<KernelInfo, String> {
state.install_kernel(&app, mirror_prefix.unwrap_or_default()).await
}
/// 首次安装内核(与 update_kernel 共用 install_kernel 实现,语义独立便于前端区分场景)
#[tauri::command]
#[specta::specta]
pub async fn proxy_install_kernel(
state: State<'_, MihomoManager>,
app: AppHandle,
mirror_prefix: Option<String>,
) -> Result<KernelInfo, String> {
state.install_kernel(&app, mirror_prefix.unwrap_or_default()).await
}
#[tauri::command]
#[specta::specta]
pub fn proxy_status(pm: State<'_, ProcessManager>) -> ProxyStatus {
match pm.get_status("proxy") {
Some(p) => ProxyStatus {
running: matches!(p.status, crate::process_manager::ProcessStatus::Running),
pid: p.pid,
restart_count: p.restart_count,
},
None => ProxyStatus {
running: false,
pid: None,
restart_count: 0,
},
}
}
#[tauri::command]
#[specta::specta]
pub fn proxy_start(
state: State<'_, MihomoManager>,
pm: State<'_, ProcessManager>,
app: AppHandle,
) -> Result<ProcessInfo, String> {
let params = state.prepare_for_start(&app)?;
pm.start(params)
}
#[tauri::command]
#[specta::specta]
pub fn proxy_stop(pm: State<'_, ProcessManager>) -> Result<(), String> {
pm.stop("proxy")
}
#[tauri::command]
#[specta::specta]
pub async fn proxy_restart(
state: State<'_, MihomoManager>,
pm: State<'_, ProcessManager>,
app: AppHandle,
) -> Result<ProcessInfo, String> {
let _ = pm.stop("proxy");
// 等待 TCP 端口释放(Windows 上 kill 后端口释放有延迟),在阻塞线程池中 sleep 避免阻塞主线程
tauri::async_runtime::spawn_blocking(|| {
std::thread::sleep(std::time::Duration::from_millis(800));
})
.await
.map_err(|e| format!("sleep 失败: {}", e))?;
let params = state.prepare_for_start(&app)?;
pm.start(params)
}
#[tauri::command]
pub async fn proxy_version(state: State<'_, MihomoManager>) -> Result<serde_json::Value, String> {
state.get_version().await
}
#[tauri::command]
pub async fn proxy_get_proxies(state: State<'_, MihomoManager>) -> Result<serde_json::Value, String> {
state.get_proxies().await
}
#[tauri::command]
#[specta::specta]
pub async fn proxy_select_proxy(
state: State<'_, MihomoManager>,
group: String,
name: String,
) -> Result<(), String> {
state.select_proxy(&group, &name).await
}
#[tauri::command]
#[specta::specta]
pub async fn proxy_test_delay(
state: State<'_, MihomoManager>,
name: String,
url: Option<String>,
timeout: Option<u32>,
) -> Result<u32, String> {
state
.test_delay(
&name,
url.as_deref().unwrap_or("https://www.gstatic.com/generate_204"),
timeout.unwrap_or(5000),
)
.await
}
#[tauri::command]
pub async fn proxy_get_connections(
state: State<'_, MihomoManager>,
) -> Result<serde_json::Value, String> {
state.get_connections().await
}
#[tauri::command]
#[specta::specta]
pub async fn proxy_close_connection(
state: State<'_, MihomoManager>,
id: String,
) -> Result<(), String> {
state.close_connection(&id).await
}
#[tauri::command]
pub async fn proxy_patch_configs(
state: State<'_, MihomoManager>,
body: serde_json::Value,
) -> Result<(), String> {
state.patch_configs(body).await
}
// ---------- 订阅 ----------
#[tauri::command]
#[specta::specta]
pub async fn proxy_import_profile(
state: State<'_, MihomoManager>,
url: String,
name: String,
) -> Result<ProfileMeta, String> {
state.import_profile(&url, &name).await
}
#[tauri::command]
#[specta::specta]
pub async fn proxy_update_profile(
state: State<'_, MihomoManager>,
id: String,
) -> Result<ProfileMeta, String> {
state.update_profile(&id).await
}
#[tauri::command]
#[specta::specta]
pub fn proxy_delete_profile(
state: State<'_, MihomoManager>,
id: String,
) -> Result<(), String> {
state.delete_profile(&id)
}
#[tauri::command]
#[specta::specta]
pub fn proxy_activate_profile(
state: State<'_, MihomoManager>,
id: String,
) -> Result<(), String> {
state.activate_profile(&id)
}
// ---------- 系统代理 ----------
#[tauri::command]
#[specta::specta]
pub fn proxy_set_system_proxy(
state: State<'_, MihomoManager>,
) -> Result<(), String> {
let settings = state.load_settings();
let addr = format!("127.0.0.1:{}", settings.mixed_port);
set_system_proxy_windows(&addr)?;
let mut settings = settings;
settings.system_proxy = true;
state.save_settings(&settings)
}
#[tauri::command]
#[specta::specta]
pub fn proxy_clear_system_proxy(
state: State<'_, MihomoManager>,
) -> Result<(), String> {
clear_system_proxy_windows()?;
let mut settings = state.load_settings();
settings.system_proxy = false;
state.save_settings(&settings)
}
#[tauri::command]
#[specta::specta]
pub fn proxy_get_system_proxy() -> bool {
get_system_proxy_windows()
}
+459
View File
@@ -0,0 +1,459 @@
//! 内核(mihomo.exe)安装 / 更新 / 版本查询。
//! 子模块通过 `impl super::MihomoManager` 为管理器追加方法,可访问父模块私有字段。
use futures_util::StreamExt;
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
use tauri::{AppHandle, Emitter, Manager};
use crate::constants::events::KERNEL_INSTALL_PROGRESS;
use super::{InstallProgress, KernelInfo, KernelUpdateInfo, MihomoManager};
impl MihomoManager {
// ---------- 内核 ----------
pub fn kernel_info(&self) -> KernelInfo {
let path = self.kernel_path();
let exists = path.exists();
let version = if exists {
let mut cmd = std::process::Command::new(&path);
cmd.arg("-v");
// 隐藏控制台窗口(mihomo.exe -v 也会弹窗)
crate::process_manager::setup_creation_flags(&mut cmd);
// 重定向 stdio,避免继承主进程控制台
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.stdin(std::process::Stdio::null());
cmd.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| {
s.lines()
.find(|l| l.contains("Mihomo Meta") || l.contains("mihomo"))
.map(|l| l.trim().to_string())
})
} else {
None
};
KernelInfo {
path: path.to_string_lossy().to_string(),
exists,
version,
}
}
/// 确保内核就位:若 cores/ 无内核,尝试从 resource 目录复制
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<KernelInfo, String> {
let kernel = self.kernel_path();
if !kernel.exists() {
if let Ok(res) = app.path().resolve("binaries/mihomo.exe", tauri::path::BaseDirectory::Resource) {
if res.exists() {
fs::copy(&res, &kernel).map_err(|e| format!("复制内核失败: {}", e))?;
}
}
}
Ok(self.kernel_info())
}
/// 检查 GitHub 上的最新 mihomo 版本
/// 策略:优先用 API(能拿到完整资产列表,命名变化时更健壮),
/// 失败时回退到重定向解析(不受 API rate limit 限制)
pub async fn check_kernel_update(&self) -> Result<KernelUpdateInfo, String> {
match self.fetch_latest_via_api().await {
Ok(info) => Ok(info),
Err(api_err) => {
crate::logger::log_warn("mihomo", &format!("API 查询失败,回退到重定向解析: {}", api_err));
self.fetch_latest_via_redirect().await
}
}
}
/// 通过 GitHub API 查询最新版本(受 rate limit 限制:未认证 60次/小时/IP)
async fn fetch_latest_via_api(&self) -> Result<KernelUpdateInfo, String> {
let resp = self
.client
.get("https://api.github.com/repos/MetaCubeX/mihomo/releases/latest")
.header("User-Agent", "thing-app")
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("请求 GitHub API 失败: {}", e))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
// 按字符边界截断,避免切在多字节字符中间导致 panic
let preview: String = body.chars().take(300).collect();
return Err(format!(
"GitHub API 返回 HTTP {}{}{}",
status.as_u16(),
preview,
if body.chars().count() > 300 { "..." } else { "" }
));
}
let resp: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("解析 GitHub 响应失败: {}", e))?;
let latest_version = resp
.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let assets_arr = resp.get("assets").and_then(|a| a.as_array());
// 收集所有 windows amd64 zip 候选资产(排除 compatible/arm64/386
let candidates: Vec<(String, String)> = assets_arr
.map(|assets| {
assets.iter().filter_map(|asset| {
let name = asset.get("name")?.as_str()?;
let url = asset.get("browser_download_url")?.as_str()?;
if name.starts_with("mihomo-windows-amd64-")
&& name.ends_with(".zip")
&& !name.contains("compatible")
&& !name.contains("arm64")
&& !name.contains("386")
{
Some((name.to_string(), url.to_string()))
} else {
None
}
}).collect()
})
.unwrap_or_default();
// 按优先级匹配:v3 标准 > v3-go124 > v3-go123 > v3 其他 > v2 > v1 > 旧命名
let download_url = candidates
.iter().find(|(n, _)| n.contains("-v3-v") && !n.contains("-go"))
.or_else(|| candidates.iter().find(|(n, _)| n.contains("-v3-go124-")))
.or_else(|| candidates.iter().find(|(n, _)| n.contains("-v3-go123-")))
.or_else(|| candidates.iter().find(|(n, _)| n.contains("-v3-go")))
.or_else(|| candidates.iter().find(|(n, _)| n.contains("-v2-v")))
.or_else(|| candidates.iter().find(|(n, _)| n.contains("-v1-v")))
.or_else(|| candidates.iter().find(|(n, _)| {
!n.contains("-v1-") && !n.contains("-v2-") && !n.contains("-v3-")
}))
.map(|(_, u)| u.clone())
.ok_or_else(|| {
let candidates_str = candidates.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>()
.join(", ");
format!("API 未找到适用的 Windows amd64 内核资产。候选:[{}]", candidates_str)
})?;
Ok(self.build_update_info(latest_version, download_url))
}
/// 通过 releases/latest 重定向解析版本号(不受 API rate limit 限制)
/// 访问 https://github.com/MetaCubeX/mihomo/releases/latest 会 302 到
/// https://github.com/MetaCubeX/mihomo/releases/tag/v1.19.13
/// 从最终 URL 提取版本号后,按 v1.19+ 稳定命名规则构造下载 URL
async fn fetch_latest_via_redirect(&self) -> Result<KernelUpdateInfo, String> {
let resp = self
.client
.get("https://github.com/MetaCubeX/mihomo/releases/latest")
.header("User-Agent", "thing-app")
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("请求 GitHub releases 页面失败: {}", e))?;
// 从重定向后的最终 URL 提取版本号
let final_url = resp.url().to_string();
let latest_version = final_url
.rsplit('/')
.next()
.filter(|s| s.starts_with('v') && s.chars().any(|c| c == '.'))
.ok_or_else(|| format!("无法从重定向 URL 提取版本号: {}", final_url))?
.to_string();
// 构造下载 URLmihomo v1.19+ 稳定使用 -v3-vX.X.X.zip 命名(CPU level v3
let download_url = format!(
"https://github.com/MetaCubeX/mihomo/releases/download/{}/mihomo-windows-amd64-v3-{}.zip",
latest_version, latest_version
);
crate::logger::log_info(
"mihomo",
&format!("重定向解析成功: version={}, url={}", latest_version, download_url),
);
Ok(self.build_update_info(latest_version, download_url))
}
/// 根据最新版本和下载 URL 构造更新信息(含当前版本比较)
fn build_update_info(&self, latest_version: String, download_url: String) -> KernelUpdateInfo {
let current = self.kernel_info().version;
let has_update = match &current {
Some(c) => {
let cur_ver = c
.split_whitespace()
.find(|s| s.starts_with('v') && s.chars().filter(|c| *c == '.').count() >= 2)
.unwrap_or("");
cur_ver != latest_version && !latest_version.is_empty()
}
None => true,
};
KernelUpdateInfo {
current_version: current,
latest_version,
download_url,
has_update,
}
}
/// 下载并安装内核(首次安装与更新共用此方法)
/// - mirror_prefix: 用户选择的镜像源前缀(空串=直连 GitHub)
/// - 流式下载:实时推送下载进度到前端
/// - zip crate 解压:替代 PowerShell,避免执行策略问题
/// - 备份旧内核:替换前备份为 .bak
/// 任何阶段失败都会 emit error 事件,避免前端进度卡在初始状态
pub async fn install_kernel(&self, app: &AppHandle, mirror_prefix: String) -> Result<KernelInfo, String> {
let result = self.install_kernel_inner(app, mirror_prefix).await;
if let Err(ref e) = result {
let _ = app.emit(
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "error".into(),
percent: 0,
downloaded_bytes: 0,
total_bytes: None,
message: e.clone(),
},
);
}
result
}
async fn install_kernel_inner(&self, app: &AppHandle, mirror_prefix: String) -> Result<KernelInfo, String> {
let info = self.check_kernel_update().await?;
let zip_path = self.cores_dir().join("mihomo-update.zip");
let extract_dir = self.cores_dir().join("mihomo-update-tmp");
// 拼接用户选择的镜像源 URL
let url = if mirror_prefix.is_empty() {
info.download_url.clone()
} else {
format!("{}{}", mirror_prefix, info.download_url)
};
let label = if mirror_prefix.is_empty() { "GitHub 直连".to_string() } else { mirror_prefix.clone() };
let _ = app.emit(
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "downloading".into(),
percent: 0,
downloaded_bytes: 0,
total_bytes: None,
message: format!("正在下载:{}", label),
},
);
// 单源下载(用户已选择)
match self.download_with_progress(app, &url, &zip_path).await {
Ok(()) => {}
Err(e) => {
let msg = format!("下载失败({}):{}", label, e);
let _ = app.emit(
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "error".into(),
percent: 0,
downloaded_bytes: 0,
total_bytes: None,
message: msg.clone(),
},
);
let _ = fs::remove_file(&zip_path);
return Err(msg);
}
}
// 解压阶段
let _ = app.emit(
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "extracting".into(),
percent: 92,
downloaded_bytes: 0,
total_bytes: None,
message: "正在解压...".into(),
},
);
if extract_dir.exists() {
fs::remove_dir_all(&extract_dir).ok();
}
fs::create_dir_all(&extract_dir).map_err(|e| e.to_string())?;
if let Err(e) = self.extract_zip(&zip_path, &extract_dir) {
let _ = app.emit(
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "error".into(),
percent: 0,
downloaded_bytes: 0,
total_bytes: None,
message: format!("解压失败:{}", e),
},
);
return Err(e);
}
// 在解压目录中递归查找 exe 文件
// mihomo zip 内的 exe 名字通常与 zip 同名(如 mihomo-windows-amd64-v3-v1.19.13.exe),
// 不是固定的 mihomo.exe,所以查找唯一的 .exe 文件即可
let new_exe = self
.find_exe_in_dir(&extract_dir)
.ok_or_else(|| "解压后未找到任何 .exe 文件".to_string())?;
// 替换阶段
let _ = app.emit(
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "replacing".into(),
percent: 96,
downloaded_bytes: 0,
total_bytes: None,
message: "正在安装...".into(),
},
);
let kernel = self.kernel_path();
if kernel.exists() {
let bak = self.cores_dir().join("mihomo.exe.bak");
fs::remove_file(&bak).ok();
fs::rename(&kernel, &bak).map_err(|e| format!("备份旧内核失败: {}", e))?;
}
fs::rename(&new_exe, &kernel).map_err(|e| format!("替换内核失败: {}", e))?;
// 清理临时文件
fs::remove_file(&zip_path).ok();
fs::remove_dir_all(&extract_dir).ok();
let final_info = self.kernel_info();
let _ = app.emit(
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "done".into(),
percent: 100,
downloaded_bytes: 0,
total_bytes: None,
message: format!(
"安装完成 ({})",
final_info.version.as_deref().unwrap_or("unknown")
),
},
);
Ok(final_info)
}
/// 流式下载并实时推送进度事件
async fn download_with_progress(
&self,
app: &AppHandle,
url: &str,
dest: &PathBuf,
) -> Result<(), String> {
let resp = self
.client
.get(url)
.header("User-Agent", "thing-app")
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| format!("请求失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let total = resp.content_length();
let mut stream = resp.bytes_stream();
let mut file = fs::File::create(dest).map_err(|e| format!("创建文件失败: {}", e))?;
let mut downloaded: u64 = 0;
let mut last_percent: u8 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("读取流失败: {}", e))?;
file.write_all(&chunk).map_err(|e| format!("写入文件失败: {}", e))?;
downloaded += chunk.len() as u64;
// 下载占总进度的 0-90%
let percent = match total {
Some(t) if t > 0 => ((downloaded as f64 / t as f64) * 90.0) as u8,
_ => 0,
};
// 仅在变化超过 1% 时 emit,避免事件轰炸
if percent >= last_percent + 1 {
last_percent = percent;
let _ = app.emit(
KERNEL_INSTALL_PROGRESS,
InstallProgress {
stage: "downloading".into(),
percent,
downloaded_bytes: downloaded,
total_bytes: total,
message: format!("已下载 {:.2} MB", downloaded as f64 / 1024.0 / 1024.0),
},
);
}
}
file.flush().map_err(|e| format!("flush 失败: {}", e))?;
Ok(())
}
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
fn extract_zip(&self, zip_path: &PathBuf, dest: &PathBuf) -> Result<(), String> {
let file = fs::File::open(zip_path).map_err(|e| format!("打开 zip 失败: {}", e))?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("读取 zip 失败: {}", e))?;
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| format!("读取条目失败: {}", e))?;
let outpath = match entry.enclosed_name() {
Some(p) => dest.join(p),
None => continue,
};
if entry.is_dir() {
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
} else {
if let Some(parent) = outpath.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut outfile = fs::File::create(&outpath).map_err(|e| e.to_string())?;
let mut buf = [0u8; 8192];
loop {
let n = entry.read(&mut buf).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
outfile.write_all(&buf[..n]).map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
/// 递归查找目录中的 .exe 文件
/// mihomo zip 内的 exe 名字不固定(可能含版本号、CPU level 等),
/// 策略:收集所有 .exe,优先返回名字含 "mihomo" 的,否则返回第一个
fn find_exe_in_dir(&self, dir: &PathBuf) -> Option<PathBuf> {
let mut exes: Vec<PathBuf> = Vec::new();
self.collect_exes(dir, &mut exes);
if exes.is_empty() {
return None;
}
// 优先选名字含 mihomo 的
exes.iter()
.find(|p| p.file_name().and_then(|n| n.to_str()).map(|s| s.to_lowercase().contains("mihomo")).unwrap_or(false))
.or_else(|| exes.first())
.cloned()
}
fn collect_exes(&self, dir: &PathBuf, out: &mut Vec<PathBuf>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
self.collect_exes(&path, out);
} else if path.extension().and_then(|e| e.to_str()).map(|s| s.eq_ignore_ascii_case("exe")).unwrap_or(false) {
out.push(path);
}
}
}
}
}
+436
View File
@@ -0,0 +1,436 @@
//! 代理模块(mihomo 管理器):按功能域拆分子模块。
//!
//! - [`MihomoManager`]:核心状态与目录/设置/配置/API 方法
//! - [`kernel`]:内核安装与更新
//! - [`profiles`]:订阅管理
//! - [`system_proxy`]Windows 系统代理开关
//! - [`commands`]Tauri 命令层
mod commands;
mod kernel;
mod profiles;
mod pseudo;
mod system_proxy;
mod types;
pub use pseudo::is_pseudo_node;
pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus};
pub use commands::{
proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection,
proxy_delete_profile, proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy,
proxy_import_profile, proxy_install_kernel, proxy_kernel_info, proxy_patch_configs, proxy_restart,
proxy_save_settings, proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop,
proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version,
};
use reqwest::Client;
use serde_yaml::Value as YamlValue;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::AppHandle;
use crate::process_manager::{ProcessManager, StartProcessParams};
// ===================== MihomoManager =====================
/// settings 内存缓存条目(短时复用,避免高频状态轮询反复读盘)
struct SettingsCacheEntry {
read_at: Instant,
settings: ProxySettings,
}
pub struct MihomoManager {
root: PathBuf,
client: Client,
settings_cache: Mutex<Option<SettingsCacheEntry>>,
}
impl MihomoManager {
pub fn new(app_data_dir: PathBuf) -> Self {
let root = app_data_dir.join("proxy");
for d in ["cores", "mihomo", "profiles", "logs"] {
fs::create_dir_all(root.join(d)).ok();
}
Self {
root,
client: Client::builder()
// 默认 30s 兜底超时,防止遗漏显式 timeout 的请求永久悬挂
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new()),
settings_cache: Mutex::new(None),
}
}
fn cores_dir(&self) -> PathBuf {
self.root.join("cores")
}
pub fn kernel_path(&self) -> PathBuf {
self.cores_dir().join("mihomo.exe")
}
fn mihomo_dir(&self) -> PathBuf {
self.root.join("mihomo")
}
fn config_path(&self) -> PathBuf {
self.mihomo_dir().join("config.yaml")
}
fn profiles_dir(&self) -> PathBuf {
self.root.join("profiles")
}
#[allow(dead_code)]
fn logs_dir(&self) -> PathBuf {
self.root.join("logs")
}
fn settings_path(&self) -> PathBuf {
self.root.join("settings.json")
}
// ---------- 设置 ----------
pub fn load_settings(&self) -> ProxySettings {
// 内存缓存:500ms 内复用(高频调用如状态轮询/测速避免反复读盘)
if let Ok(cache) = self.settings_cache.lock() {
if let Some(entry) = cache.as_ref() {
if entry.read_at.elapsed() < Duration::from_millis(500) {
return entry.settings.clone();
}
}
}
let mut settings = fs::read_to_string(self.settings_path())
.ok()
.and_then(|s| serde_json::from_str::<ProxySettings>(&s).ok())
.unwrap_or_default();
// 恢复机制:扫描磁盘 profile 文件,补全 settings.profiles
// 防止 settings.json 损坏(如反序列化失败被 default 覆盖)导致订阅丢失
if self.reconcile_profiles(&mut settings) {
let _ = self.save_settings(&settings);
}
// 刷新缓存
if let Ok(mut cache) = self.settings_cache.lock() {
*cache = Some(SettingsCacheEntry {
read_at: Instant::now(),
settings: settings.clone(),
});
}
settings
}
/// 扫描磁盘 profile 文件,补全 settings.profiles 中缺失的条目。
/// 返回 true 表示有变化需要保存。
fn reconcile_profiles(&self, settings: &mut ProxySettings) -> bool {
let mut changed = false;
let existing_ids: std::collections::HashSet<String> =
settings.profiles.iter().map(|p| p.id.clone()).collect();
if let Ok(entries) = fs::read_dir(self.profiles_dir()) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
continue;
}
let Some(id) = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
else {
continue;
};
if existing_ids.contains(&id) {
continue;
}
let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
let updated_at = fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, 0))
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_default();
settings.profiles.push(ProfileMeta {
added_at: updated_at.clone(),
id: id.clone(),
name: id,
url: String::new(),
updated_at,
size,
});
changed = true;
}
}
// 如果 currentProfile 为 null 但有 profile,设置为第一个
if settings.current_profile.is_none() && !settings.profiles.is_empty() {
settings.current_profile = Some(settings.profiles[0].id.clone());
changed = true;
}
changed
}
pub fn save_settings(&self, settings: &ProxySettings) -> Result<(), String> {
let s = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
fs::write(self.settings_path(), s).map_err(|e| e.to_string())?;
// 写盘成功后同步刷新内存缓存(避免旧缓存被后续 load_settings 复用)
if let Ok(mut cache) = self.settings_cache.lock() {
*cache = Some(SettingsCacheEntry {
read_at: Instant::now(),
settings: settings.clone(),
});
}
Ok(())
}
// ---------- 配置生成 ----------
/// 合并 profile + 控制器设置,生成运行时 config.yaml
pub fn generate_config(&self) -> Result<(), String> {
let settings = self.load_settings();
let mut value: YamlValue = if let Some(id) = &settings.current_profile {
let path = self.profiles_dir().join(format!("{}.yaml", id));
if path.exists() {
let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
serde_yaml::from_str(&content).unwrap_or(YamlValue::Mapping(serde_yaml::Mapping::new()))
} else {
YamlValue::Mapping(serde_yaml::Mapping::new())
}
} else {
YamlValue::Mapping(serde_yaml::Mapping::new())
};
if !value.is_mapping() {
value = YamlValue::Mapping(serde_yaml::Mapping::new());
}
let m = value.as_mapping_mut().unwrap();
m.insert(YamlValue::String("mixed-port".into()), YamlValue::Number(settings.mixed_port.into()));
m.insert(
YamlValue::String("external-controller".into()),
YamlValue::String(settings.external_controller.clone()),
);
if !settings.secret.is_empty() {
m.insert(YamlValue::String("secret".into()), YamlValue::String(settings.secret.clone()));
}
m.insert(YamlValue::String("mode".into()), YamlValue::String(settings.mode.clone()));
m.insert(
YamlValue::String("log-level".into()),
YamlValue::String(settings.log_level.clone()),
);
m.insert(YamlValue::String("allow-lan".into()), YamlValue::Bool(settings.allow_lan));
// 日志写入文件,便于排查问题
let log_file = self.logs_dir().join("mihomo.log");
m.insert(
YamlValue::String("log-file".into()),
YamlValue::String(log_file.to_string_lossy().to_string()),
);
// Geo 数据库下载源(使用 jsdelivr 国内可访问镜像,避免无代理时 GitHub 超时)
let mut geox = serde_yaml::Mapping::new();
geox.insert(
YamlValue::String("mmdb".into()),
YamlValue::String("https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/country.mmdb".into()),
);
geox.insert(
YamlValue::String("geosite".into()),
YamlValue::String("https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat".into()),
);
geox.insert(
YamlValue::String("asn".into()),
YamlValue::String("https://cdn.jsdelivr.net/gh/xishang0128/bdg@master/GeoLite2-ASN.mmdb".into()),
);
m.insert(YamlValue::String("geox-url".into()), YamlValue::Mapping(geox));
let yaml = serde_yaml::to_string(&value).map_err(|e| e.to_string())?;
fs::write(self.config_path(), yaml).map_err(|e| e.to_string())?;
Ok(())
}
/// 构建启动 mihomo 所需的进程参数(含 prepare + config 生成)
pub fn prepare_for_start(&self, app: &AppHandle) -> Result<StartProcessParams, String> {
let info = self.prepare_kernel(app)?;
if !info.exists {
return Err(format!(
"mihomo 内核未安装。请将 mihomo.exe 放置到 src-tauri/binaries/ 后重新运行,或直接放到:\n{}",
self.cores_dir().to_string_lossy()
));
}
self.generate_config()?;
Ok(StartProcessParams {
id: "proxy".into(),
executable: self.kernel_path().to_string_lossy().to_string(),
args: vec![
"-d".into(),
self.mihomo_dir().to_string_lossy().to_string(),
"-f".into(),
self.config_path().to_string_lossy().to_string(),
],
cwd: Some(self.mihomo_dir().to_string_lossy().to_string()),
name: "mihomo".into(),
restart_on_crash: true,
max_restarts: 3,
})
}
/// 应用启动时检查是否需要自动启动 mihomo 和系统代理
pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) {
let settings = self.load_settings();
if !settings.auto_start {
return;
}
match self.prepare_for_start(app) {
Ok(params) => {
if let Err(e) = pm.start(params) {
crate::logger::log_error("mihomo", &format!("自动启动失败: {}", e));
} else if settings.auto_system_proxy {
// 启动成功后开启系统代理
let addr = format!("127.0.0.1:{}", settings.mixed_port);
let _ = system_proxy::set_system_proxy_windows(&addr);
let mut s = settings;
s.system_proxy = true;
let _ = self.save_settings(&s);
}
}
Err(e) => {
crate::logger::log_warn("mihomo", &format!("自动启动跳过: {}", e));
}
}
}
/// 应用退出时清理:关闭系统代理
pub fn cleanup_on_exit(&self) {
let settings = self.load_settings();
if settings.system_proxy || settings.auto_system_proxy {
let _ = system_proxy::clear_system_proxy_windows();
}
}
// ---------- mihomo API ----------
fn api_url(&self, path: &str) -> String {
let s = self.load_settings();
format!("http://{}{}", s.external_controller, path)
}
fn api_bearer(&self) -> Option<String> {
let s = self.load_settings();
if s.secret.is_empty() {
None
} else {
Some(format!("Bearer {}", s.secret))
}
}
async fn api_get(&self, path: &str) -> Result<serde_json::Value, String> {
let mut req = self.client.get(self.api_url(path));
if let Some(b) = self.api_bearer() {
req = req.header("Authorization", b);
}
// 显式超时:mihomo 卡死/未响应时命令立即返回,避免前端按钮永久转圈
let resp = req
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("请求 mihomo 失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("mihomo API 错误: {}", resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}
async fn api_request(
&self,
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
) -> Result<(), String> {
let mut req = self.client.request(method, self.api_url(path));
if let Some(b) = self.api_bearer() {
req = req.header("Authorization", b);
}
if let Some(b) = body {
req = req.json(&b);
}
let resp = req
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("请求 mihomo 失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("mihomo API 错误: {}", resp.status()));
}
Ok(())
}
pub async fn get_version(&self) -> Result<serde_json::Value, String> {
self.api_get("/version").await
}
pub async fn get_proxies(&self) -> Result<serde_json::Value, String> {
self.api_get("/proxies").await
}
pub async fn select_proxy(&self, group: &str, name: &str) -> Result<(), String> {
self.api_request(
reqwest::Method::PUT,
&format!("/proxies/{}", url_encode(group)),
Some(serde_json::json!({ "name": name })),
)
.await
}
pub async fn test_delay(&self, name: &str, url: &str, timeout: u32) -> Result<u32, String> {
let path = format!(
"/proxies/{}/delay?timeout={}&url={}",
url_encode(name),
timeout,
url_encode(url)
);
let v = self.api_get(&path).await?;
v.get("delay")
.and_then(|d| d.as_u64())
.map(|d| d as u32)
.ok_or_else(|| {
v.get("message")
.and_then(|m| m.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "测速失败".into())
})
}
#[allow(dead_code)]
pub async fn get_rules(&self) -> Result<serde_json::Value, String> {
self.api_get("/rules").await
}
pub async fn get_connections(&self) -> Result<serde_json::Value, String> {
self.api_get("/connections").await
}
pub async fn close_connection(&self, id: &str) -> Result<(), String> {
self.api_request(
reqwest::Method::DELETE,
&format!("/connections/{}", url_encode(id)),
None,
)
.await
}
pub async fn patch_configs(&self, body: serde_json::Value) -> Result<(), String> {
self.api_request(reqwest::Method::PATCH, "/configs", Some(body)).await
}
}
fn url_encode(s: &str) -> String {
// 仅对路径段做最小编码,避免引入额外依赖
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
+112
View File
@@ -0,0 +1,112 @@
//! 订阅(profile)管理:导入 / 更新 / 删除 / 激活。
use chrono::Local;
use std::fs;
use super::{MihomoManager, ProfileMeta};
impl MihomoManager {
// ---------- 订阅管理 ----------
pub async fn import_profile(&self, url: &str, name: &str) -> Result<ProfileMeta, String> {
// 先读取当前 settings(此时新 profile 文件还未写入,reconcile 不会误添加)
let mut settings = self.load_settings();
let resp = self
.client
.get(url)
.header("User-Agent", "clash.meta/thing")
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| format!("下载订阅失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("订阅下载失败: HTTP {}", resp.status()));
}
let content = resp.text().await.map_err(|e| e.to_string())?;
if !content.contains("proxies") && !content.contains("Proxy") {
return Err("订阅内容不像有效的 Clash/mihomo 配置".into());
}
let id = format!("profile-{}", Local::now().format("%Y%m%d%H%M%S"));
let path = self.profiles_dir().join(format!("{}.yaml", id));
fs::write(&path, &content).map_err(|e| e.to_string())?;
let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let meta = ProfileMeta {
id: id.clone(),
name: name.to_string(),
url: url.to_string(),
added_at: now.clone(),
updated_at: now,
size: content.len() as u64,
};
// 去重保护:避免 reconcile 已添加同 id(理论上不会,因为文件刚写入)
if !settings.profiles.iter().any(|p| p.id == id) {
settings.profiles.push(meta.clone());
}
if settings.current_profile.is_none() {
settings.current_profile = Some(id);
}
self.save_settings(&settings)?;
Ok(meta)
}
pub async fn update_profile(&self, id: &str) -> Result<ProfileMeta, String> {
let mut settings = self.load_settings();
let meta = settings
.profiles
.iter()
.find(|p| p.id == id)
.cloned()
.ok_or_else(|| "订阅不存在".to_string())?;
let resp = self
.client
.get(&meta.url)
.header("User-Agent", "clash.meta/thing")
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| format!("更新订阅失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("更新订阅失败: HTTP {}", resp.status()));
}
let content = resp.text().await.map_err(|e| e.to_string())?;
let path = self.profiles_dir().join(format!("{}.yaml", id));
fs::write(&path, &content).map_err(|e| e.to_string())?;
let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let size = content.len() as u64;
if let Some(p) = settings.profiles.iter_mut().find(|p| p.id == id) {
p.updated_at = now.clone();
p.size = size;
}
self.save_settings(&settings)?;
Ok(ProfileMeta {
id: id.to_string(),
name: meta.name,
url: meta.url,
added_at: meta.added_at,
updated_at: now,
size,
})
}
pub fn delete_profile(&self, id: &str) -> Result<(), String> {
let path = self.profiles_dir().join(format!("{}.yaml", id));
fs::remove_file(&path).ok();
let mut settings = self.load_settings();
settings.profiles.retain(|p| p.id != id);
if settings.current_profile.as_deref() == Some(id) {
settings.current_profile = settings.profiles.first().map(|p| p.id.clone());
}
self.save_settings(&settings)?;
Ok(())
}
pub fn activate_profile(&self, id: &str) -> Result<(), String> {
let mut settings = self.load_settings();
if !settings.profiles.iter().any(|p| p.id == id) {
return Err("订阅不存在".into());
}
settings.current_profile = Some(id.to_string());
self.save_settings(&settings)?;
self.generate_config()
}
}
+71
View File
@@ -0,0 +1,71 @@
// ===================== 伪节点过滤 =====================
// 单点定义:托盘菜单、自动切换、前端显示过滤(ProxyModule.vue 的 PSEUDO_NODE_KEYWORDS
// 与之对应)统一引用此处,避免多份关键词列表漂移。
/// 订阅节点名中常见的营销/占位关键词(订阅页插入的非真实节点)
const PSEUDO_KEYWORDS: &[&str] = &[
"DIRECT",
"REJECT",
"PASS",
"COMPATIBLE",
"流量",
"套餐",
"到期",
"续费",
"官网",
"网站",
"刷新",
"更新",
"",
"",
"",
"",
"",
"×",
];
/// 判断节点名是否为伪节点(DIRECT/REJECT 等内置策略或订阅营销占位)
pub 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))
}
#[cfg(test)]
mod pseudo_node_tests {
use super::*;
#[test]
fn builtin_policies_are_pseudo() {
assert!(is_pseudo_node("DIRECT"));
assert!(is_pseudo_node("REJECT"));
assert!(is_pseudo_node("PASS"));
assert!(is_pseudo_node("GLOBAL"));
}
#[test]
fn case_insensitive_and_trims_whitespace() {
assert!(is_pseudo_node("direct"));
assert!(is_pseudo_node(" Reject "));
}
#[test]
fn marketing_keywords_are_pseudo() {
assert!(is_pseudo_node("香港流量套餐"));
assert!(is_pseudo_node("官网专线"));
assert!(is_pseudo_node("VIP到期续费"));
assert!(is_pseudo_node("每月更新"));
assert!(is_pseudo_node("★香港节点"));
}
#[test]
fn real_nodes_are_not_pseudo() {
assert!(!is_pseudo_node("HK-01"));
assert!(!is_pseudo_node("美国洛杉矶 01"));
assert!(!is_pseudo_node("JP Tokyo 2G"));
assert!(!is_pseudo_node(""));
assert!(!is_pseudo_node("Node-2024"));
}
}
@@ -0,0 +1,92 @@
//! Windows 系统代理开关。
use super::MihomoManager;
// ===================== 系统代理(Windows =====================
#[cfg(windows)]
pub(crate) fn set_system_proxy_windows(addr: &str) -> Result<(), String> {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (settings, _) = hkcu
.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings")
.map_err(|e| e.to_string())?;
settings
.set_value("ProxyEnable", &1u32)
.map_err(|e| e.to_string())?;
settings
.set_value("ProxyServer", &addr)
.map_err(|e| e.to_string())?;
notify_wininet();
Ok(())
}
#[cfg(windows)]
pub(crate) fn clear_system_proxy_windows() -> Result<(), String> {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (settings, _) = hkcu
.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings")
.map_err(|e| e.to_string())?;
settings
.set_value("ProxyEnable", &0u32)
.map_err(|e| e.to_string())?;
notify_wininet();
Ok(())
}
#[cfg(windows)]
pub(crate) fn get_system_proxy_windows() -> bool {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings")
.ok()
.and_then(|s| s.get_value::<u32, _>("ProxyEnable").ok())
.map(|v| v != 0)
.unwrap_or(false)
}
#[cfg(windows)]
fn notify_wininet() {
unsafe {
use windows_sys::Win32::Networking::WinInet::*;
InternetSetOptionW(std::ptr::null(), INTERNET_OPTION_SETTINGS_CHANGED, std::ptr::null(), 0);
InternetSetOptionW(std::ptr::null(), INTERNET_OPTION_REFRESH, std::ptr::null(), 0);
}
}
#[cfg(not(windows))]
pub(crate) fn set_system_proxy_windows(_addr: &str) -> Result<(), String> {
Err("系统代理仅支持 Windows".into())
}
#[cfg(not(windows))]
pub(crate) fn clear_system_proxy_windows() -> Result<(), String> {
Err("系统代理仅支持 Windows".into())
}
#[cfg(not(windows))]
pub(crate) fn get_system_proxy_windows() -> bool {
false
}
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)
}
}
+140
View File
@@ -0,0 +1,140 @@
// ===================== 数据结构 =====================
// 代理模块的设置 / 订阅元信息 / 内核信息 / 安装进度等共享类型。
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Serialize, Deserialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct ProxySettings {
#[serde(default = "default_mixed_port")]
pub mixed_port: u16,
#[serde(default = "default_external_controller")]
pub external_controller: String,
#[serde(default)]
pub secret: String,
#[serde(default = "default_mode")]
pub mode: String,
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default)]
pub allow_lan: bool,
#[serde(default)]
pub system_proxy: bool,
#[serde(default)]
pub auto_start: bool,
#[serde(default)]
pub auto_system_proxy: bool,
#[serde(default)]
pub current_profile: Option<String>,
#[serde(default)]
pub profiles: Vec<ProfileMeta>,
#[serde(default)]
pub auto_switch_enabled: bool,
#[serde(default = "default_auto_switch_interval")]
pub auto_switch_interval: u32,
#[serde(default)]
pub auto_switch_group: String,
#[serde(default)]
pub auto_switch_region: String,
/// 内核下载镜像源列表(前缀拼接到 GitHub URL 前)。
/// 空字符串 = 直连 GitHub,其余为镜像站前缀(含尾斜杠)。
#[serde(default = "default_kernel_mirrors")]
pub kernel_mirrors: Vec<String>,
}
fn default_mixed_port() -> u16 { 7890 }
fn default_external_controller() -> String { "127.0.0.1:9090".into() }
fn default_mode() -> String { "rule".into() }
fn default_log_level() -> String { "info".into() }
fn default_auto_switch_interval() -> u32 { 5 }
/// 默认镜像源:空串=直连 GitHub 优先,后续为公益镜像(按稳定性排序)
fn default_kernel_mirrors() -> Vec<String> {
vec![
String::new(),
"https://ghproxy.net/".into(),
"https://gh-proxy.com/".into(),
"https://ghfast.top/".into(),
]
}
impl Default for ProxySettings {
fn default() -> Self {
Self {
mixed_port: 7890,
external_controller: "127.0.0.1:9090".into(),
secret: String::new(),
mode: "rule".into(),
log_level: "info".into(),
allow_lan: false,
system_proxy: false,
auto_start: false,
auto_system_proxy: false,
current_profile: None,
profiles: Vec::new(),
auto_switch_enabled: false,
auto_switch_interval: 5,
auto_switch_group: String::new(),
auto_switch_region: String::new(),
kernel_mirrors: default_kernel_mirrors(),
}
}
}
#[derive(Serialize, Deserialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct ProfileMeta {
#[serde(default)]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub added_at: String,
#[serde(default)]
pub updated_at: String,
#[serde(default)]
#[specta(type = f64)]
pub size: u64,
}
#[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct KernelInfo {
pub path: String,
pub exists: bool,
pub version: Option<String>,
}
#[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct KernelUpdateInfo {
pub current_version: Option<String>,
pub latest_version: String,
pub download_url: String,
pub has_update: bool,
}
#[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct ProxyStatus {
pub running: bool,
pub pid: Option<u32>,
pub restart_count: u32,
}
/// 内核安装进度事件载荷
/// - stage: downloading | extracting | replacing | done | error
/// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示)
#[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct InstallProgress {
pub stage: String,
pub percent: u8,
#[specta(type = f64)]
pub downloaded_bytes: u64,
#[specta(type = Option<f64>)]
pub total_bytes: Option<u64>,
pub message: String,
}
+56 -23
View File
@@ -175,11 +175,11 @@ pub fn check_and_relaunch_if_needed(app_data_dir: &std::path::Path) -> bool {
Ok(e) => e,
Err(_) => return false,
};
eprintln!("[monitor] 检测到永久提权标志,以管理员权限重启 Thing");
crate::logger::log_info("monitor", "检测到永久提权标志,以管理员权限重启 Thing");
match shell_execute_elevated(&exe.to_string_lossy(), "", None) {
Ok(()) => true,
Err(e) => {
eprintln!("[monitor] 永久提权重启失败(用户可能取消了 UAC): {}", e);
crate::logger::log_warn("monitor", &format!("永久提权重启失败(用户可能取消了 UAC): {}", e));
false
}
}
@@ -247,6 +247,17 @@ pub struct MonitorKernel {
listener_ids: Arc<Mutex<Vec<tauri::EventId>>>,
/// 是否以管理员权限运行(提权模式下进程不受 ProcessManager 管控,停止走 /shutdown
elevated: Arc<AtomicBool>,
/// SSE 订阅循环活跃标志(重入守卫,防止并发/重复订阅导致双 emit monitor-data
subscribing: Arc<AtomicBool>,
}
/// SSE 订阅循环标志守卫:Drop 时复位 subscribing,覆盖所有 return / abort 路径
struct SubGuard(Arc<AtomicBool>);
impl Drop for SubGuard {
fn drop(&mut self) {
self.0.store(false, Ordering::SeqCst);
}
}
impl MonitorKernel {
@@ -265,6 +276,7 @@ impl MonitorKernel {
sub_handle: Arc::new(Mutex::new(None)),
listener_ids: Arc::new(Mutex::new(Vec::new())),
elevated: Arc::new(AtomicBool::new(false)),
subscribing: Arc::new(AtomicBool::new(false)),
}
}
@@ -381,10 +393,18 @@ impl MonitorKernel {
/// 流程:轮询 /status 等 ready → 订阅 /stream → 解析 SSE 事件 → emit "monitor-data"
/// 写入熔断:SSE 断开后停止 emit,等待外部调用 reconnect 或 process-status-changed 触发重连
pub async fn start_subscription(self: Self, app: AppHandle) {
// 重入守卫:已有订阅循环在运行则跳过,防止并发/重复订阅(双 emit monitor-data
if self.subscribing.swap(true, Ordering::SeqCst) {
crate::logger::log_warn("monitor", "已有 SSE 订阅循环运行中,跳过重复订阅");
return;
}
// Drop 时复位标志,覆盖所有 return 路径(含 stop 时 abort 取消)
let _guard = SubGuard(self.subscribing.clone());
// 1. 轮询等待 Kernel ready(冷启动约 5s
if let Err(e) = self.wait_for_ready(&app).await {
eprintln!("[monitor] 等待 Kernel ready 失败,订阅不启动: {}", e);
let _ = app.emit("monitor-error", serde_json::json!({ "stage": "ready", "message": e }));
crate::logger::log_warn("monitor", &format!("等待 Kernel ready 失败,订阅不启动: {}", e));
let _ = app.emit(crate::constants::events::MONITOR_ERROR, serde_json::json!({ "stage": "ready", "message": e }));
return;
}
@@ -409,7 +429,7 @@ impl MonitorKernel {
match resp.json::<KernelStatus>().await {
Ok(s) if s.ready => {
let _ = app.emit(
"monitor-ready",
crate::constants::events::MONITOR_READY,
serde_json::json!({
"isAdmin": s.is_admin,
"sensorCount": s.sensor_count,
@@ -426,8 +446,10 @@ impl MonitorKernel {
Err(e) => last_err = e.to_string(),
}
// 通知前端正在加载(前端可显示 "Kernel 启动中..."
let elapsed = READY_TIMEOUT_MS.saturating_sub(deadline.duration_since(std::time::Instant::now()).as_millis() as u64);
let _ = app.emit("monitor-loading", serde_json::json!({ "elapsedMs": elapsed }));
// 用 saturating_duration_sincenow 超过 deadline 时返回 0,避免 duration_since panic
let remaining = deadline.saturating_duration_since(std::time::Instant::now()).as_millis() as u64;
let elapsed = READY_TIMEOUT_MS.saturating_sub(remaining);
let _ = app.emit(crate::constants::events::MONITOR_LOADING, serde_json::json!({ "elapsedMs": elapsed }));
tokio::time::sleep(Duration::from_millis(READY_POLL_INTERVAL_MS)).await;
}
Err(format!("Kernel 在 {}ms 内未就绪: {}", READY_TIMEOUT_MS, last_err))
@@ -441,24 +463,24 @@ impl MonitorKernel {
match self.subscribe_once(&url, &app).await {
// 正常结束(客户端取消或服务端关闭)
Ok(()) => {
eprintln!("[monitor] SSE 流正常结束");
crate::logger::log_info("monitor", "SSE 流正常结束");
break;
}
Err(e) => {
eprintln!("[monitor] SSE 流异常断开: {}3s 后重试", e);
crate::logger::log_warn("monitor", &format!("SSE 流异常断开: {}3s 后重试", e));
let _ = app.emit(
"monitor-disconnected",
crate::constants::events::MONITOR_DISCONNECTED,
serde_json::json!({ "message": e }),
);
tokio::time::sleep(Duration::from_secs(3)).await;
// 重连前先确认 Kernel 是否还活着(可能已被 stop)
if !self.is_kernel_alive().await {
eprintln!("[monitor] Kernel 已停止,退出 SSE 循环");
crate::logger::log_info("monitor", "Kernel 已停止,退出 SSE 循环");
// 提权模式下 Kernel 崩溃/退出后重置 elevated 标志,
// 否则 monitor_status 会一直认为提权模式但 Kernel 已死,用户无法重启
if self.is_elevated() {
self.elevated.store(false, Ordering::SeqCst);
eprintln!("[monitor] 提权 Kernel 已退出,重置 elevated 标志");
crate::logger::log_info("monitor", "提权 Kernel 已退出,重置 elevated 标志");
}
break;
}
@@ -497,7 +519,7 @@ impl MonitorKernel {
if let Some(json_str) = parse_sse_data(&event_str) {
if let Ok(snap) = serde_json::from_str::<SensorSnapshot>(&json_str) {
let _ = app.emit("monitor-data", snap);
let _ = app.emit(crate::constants::events::MONITOR_DATA, snap);
}
}
}
@@ -519,9 +541,11 @@ impl MonitorKernel {
/// 停止 SSE 订阅(进程由 ProcessManager.stop 负责)
pub async fn stop_subscription(&self, app: &AppHandle) {
// 取消 SSE 任务
// 取消 SSE 任务并等待其退出,确保 subscribing 重入守卫复位。
// 否则 stop 后立即 start 时旧任务仍在 Drop,新订阅会被守卫误跳过。
if let Some(handle) = self.sub_handle.lock().await.take() {
handle.abort();
let _ = handle.await;
}
// 取消 process-status-changed 监听
let ids = self.listener_ids.lock().await.drain(..).collect::<Vec<_>>();
@@ -552,7 +576,7 @@ impl MonitorKernel {
let this = this.clone();
let app = app_clone.clone();
tauri::async_runtime::spawn(async move {
eprintln!("[monitor] 检测到 Kernel 重启恢复,重新订阅 SSE");
crate::logger::log_info("monitor", "检测到 Kernel 重启恢复,重新订阅 SSE");
// 重启后需要重新等待 ready(冷启动约 5s)
this.clone().start_subscription(app).await;
});
@@ -585,20 +609,23 @@ impl MonitorKernel {
if status.ready {
if thing_elevated {
// Thing 是管理员:停止已有 Kernel(无论什么权限),用 ProcessManager 重启以继承权限
eprintln!("[monitor] Thing 已提权,重启 ThingHK 以继承管理员权限");
crate::logger::log_info("monitor", "Thing 已提权,重启 ThingHK 以继承管理员权限");
let _ = self.shutdown_kernel().await;
tokio::time::sleep(Duration::from_millis(500)).await;
} else if status.is_admin {
// Thing 非管理员,但已有提权 Kernel:直接接管
eprintln!("[monitor] 检测到已有提权 Kernel 运行中,直接接管");
crate::logger::log_info("monitor", "检测到已有提权 Kernel 运行中,直接接管");
self.elevated.store(true, Ordering::SeqCst);
let kernel = self.clone();
kernel.clone().register_auto_reconnect(app.clone()).await;
// 已有订阅循环在运行则不覆盖句柄(否则旧任务句柄丢失,stop 无法取消)
if !kernel.subscribing.load(Ordering::SeqCst) {
let app_clone = app.clone();
let handle = tauri::async_runtime::spawn(async move {
kernel.start_subscription(app_clone).await;
});
*self.sub_handle.lock().await = Some(handle);
}
// 提权模式下无 ProcessInfo,返回一个占位的
return Ok(crate::process_manager::ProcessInfo {
id: PROCESS_ID.into(),
@@ -609,7 +636,7 @@ impl MonitorKernel {
});
} else {
// Thing 非管理员,已有普通 Kernel:先停止
eprintln!("[monitor] 检测到已有普通权限 Kernel 运行中,先停止再重启");
crate::logger::log_info("monitor", "检测到已有普通权限 Kernel 运行中,先停止再重启");
let _ = self.shutdown_kernel().await;
tokio::time::sleep(Duration::from_millis(500)).await;
}
@@ -623,7 +650,7 @@ impl MonitorKernel {
// Thing 是管理员时,ThingHK 继承权限,标记 elevated(仍由 ProcessManager 管理)
if thing_elevated {
self.elevated.store(true, Ordering::SeqCst);
eprintln!("[monitor] ThingHK 已以管理员权限启动(继承自 Thing)");
crate::logger::log_info("monitor", "ThingHK 已以管理员权限启动(继承自 Thing)");
}
// 用 self 的 clone(共享 Arc<Mutex> 状态)启动订阅,
@@ -631,11 +658,14 @@ impl MonitorKernel {
// stop_subscription 时才能正确清理 listener。
let kernel = self.clone();
kernel.clone().register_auto_reconnect(app.clone()).await;
// 已有订阅循环在运行则不覆盖句柄(否则旧任务句柄丢失,stop 无法取消)
if !kernel.subscribing.load(Ordering::SeqCst) {
let app_clone = app.clone();
let handle = tauri::async_runtime::spawn(async move {
kernel.start_subscription(app_clone).await;
});
*self.sub_handle.lock().await = Some(handle);
}
Ok(info)
}
@@ -703,17 +733,20 @@ impl MonitorKernel {
}
self.elevated.store(true, Ordering::SeqCst);
eprintln!("[monitor] 提权启动已发起,等待 Kernel ready");
crate::logger::log_info("monitor", "提权启动已发起,等待 Kernel ready");
// 5. 启动 SSE 订阅(提权模式不注册 register_auto_reconnect
// 提权进程不归 ProcessManager 管,process-status-changed 事件不会触发,
// 注册了反而可能在其他进程状态变化时误触发 SSE 重连)
let kernel = self.clone();
let app_clone = app.clone();
// 已有订阅循环在运行则不覆盖句柄(否则旧任务句柄丢失,stop 无法取消)
if !kernel.subscribing.load(Ordering::SeqCst) {
let handle = tauri::async_runtime::spawn(async move {
kernel.start_subscription(app_clone).await;
});
*self.sub_handle.lock().await = Some(handle);
}
Ok(())
}
@@ -745,7 +778,7 @@ impl MonitorKernel {
self.stop_subscription(app).await;
// 仅当 ThingHK 提权但 Thing 未提权时才需要 /shutdownProcessManager 无法 kill 管理员进程)
if self.is_elevated() && !is_thing_elevated() {
eprintln!("[monitor] 退出清理:停止提权 Kernel/shutdown");
crate::logger::log_info("monitor", "退出清理:停止提权 Kernel/shutdown");
let _ = self.shutdown_kernel().await;
self.elevated.store(false, Ordering::SeqCst);
}
@@ -852,13 +885,13 @@ pub async fn monitor_elevate_self(
if is_thing_elevated() {
// 已是管理员,只需设置标志,无需重启
eprintln!("[monitor] Thing 已是管理员,仅设置永久提权标志");
crate::logger::log_info("monitor", "Thing 已是管理员,仅设置永久提权标志");
return Ok(());
}
let exe = std::env::current_exe().map_err(|e| format!("获取当前路径失败: {}", e))?;
let exe_str = exe.to_string_lossy().to_string();
eprintln!("[monitor] 永久提权:以管理员权限重启 Thing");
crate::logger::log_info("monitor", "永久提权:以管理员权限重启 Thing");
shell_execute_elevated(&exe_str, "", None)?;
// 退出当前进程(非提权),新的提权进程会接管
app.exit(0);
+3 -3
View File
@@ -95,9 +95,9 @@ impl NetworkMonitor {
};
// emit 给前端(失败忽略:窗口可能未就绪)
let _ = app.emit("monitor-network", &speed);
let _ = app.emit(crate::constants::events::MONITOR_NETWORK, &speed);
}
eprintln!("[network] 网速采样任务已退出");
crate::logger::log_info("network", "网速采样任务已退出");
});
}
@@ -116,7 +116,7 @@ impl Default for NetworkMonitor {
/// Tauri 命令:获取网速监控是否运行
#[tauri::command]
pub fn network_monitor_status(
pub fn network_status(
state: tauri::State<'_, NetworkMonitor>,
) -> bool {
state.running.load(Ordering::SeqCst)
+51 -17
View File
@@ -6,8 +6,8 @@
//! - 任务栏覆盖检测:轮询 GetForegroundWindow,检测系统 UI 出现时暂时取消置顶
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::thread;
use std::sync::{Arc, Mutex, OnceLock};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
@@ -15,6 +15,9 @@ use tauri::{AppHandle, Emitter};
static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
/// 任务栏覆盖监视线程停止标志
static TOPMOST_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
/// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏)
static DRAG_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
static TOPMOST_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
fn drag_stop() -> &'static Arc<AtomicBool> {
DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
@@ -24,6 +27,30 @@ fn topmost_stop() -> &'static Arc<AtomicBool> {
TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
}
/// 停止右键拖动监视线程并等待其退出(标志置位后线程最迟一个轮询周期退出)
fn stop_drag_thread() {
drag_stop().store(true, Ordering::SeqCst);
if let Some(h) = DRAG_HANDLE
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
let _ = h.join();
}
}
/// 停止任务栏覆盖监视线程并等待其退出
fn stop_topmost_thread() {
topmost_stop().store(true, Ordering::SeqCst);
if let Some(h) = TOPMOST_HANDLE
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
let _ = h.join();
}
}
#[cfg(windows)]
mod win_api {
use tauri::{AppHandle, Manager};
@@ -186,7 +213,7 @@ pub fn osd_apply_overlay_style(label: String, app: AppHandle) -> Result<(), Stri
let hwnd = win_api::get_hwnd(&label, &app)
.ok_or_else(|| format!("窗口 {} 不存在", label))?;
win_api::apply_no_activate(hwnd);
eprintln!("[osd] 已应用 NoActivate 样式到窗口 {}", label);
crate::logger::log_info("osd", &format!("已应用 NoActivate 样式到窗口 {}", label));
}
Ok(())
}
@@ -198,15 +225,14 @@ pub fn osd_apply_overlay_style(label: String, app: AppHandle) -> Result<(), Stri
/// 右键释放后发出 `osd-end-drag` 事件。
#[tauri::command]
pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String> {
// 停止旧线程,等待退出
drag_stop().store(true, Ordering::SeqCst);
thread::sleep(Duration::from_millis(50));
// 停止旧线程,等待退出后再启动新线程(避免新旧线程并存)
stop_drag_thread();
let stop_flag = drag_stop().clone();
stop_flag.store(false, Ordering::SeqCst);
let app_handle = app.clone();
thread::spawn(move || {
let handle = thread::spawn(move || {
let mut rbutton_was_down = false;
let mut press_start: Option<Instant> = None;
let mut drag_emitted = false;
@@ -234,7 +260,7 @@ pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String>
(win_api::get_cursor_pos(), win_api::get_window_rect(hwnd))
{
if win_api::point_in_rect(pt, rect) {
let _ = app_handle.emit("osd-start-drag", ());
let _ = app_handle.emit(crate::constants::events::OSD_START_DRAG, ());
drag_emitted = true;
}
}
@@ -245,7 +271,7 @@ pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String>
} else if !rbutton_down && rbutton_was_down {
// 右键释放
if drag_emitted {
let _ = app_handle.emit("osd-end-drag", ());
let _ = app_handle.emit(crate::constants::events::OSD_END_DRAG, ());
}
press_start = None;
drag_emitted = false;
@@ -258,6 +284,10 @@ pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String>
}
});
if let Ok(mut guard) = DRAG_HANDLE.lock() {
*guard = Some(handle);
}
Ok(())
}
@@ -268,14 +298,14 @@ pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String>
/// 系统 UI 关闭后发出 `osd-system-ui-inactive` 事件恢复置顶。
#[tauri::command]
pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> {
topmost_stop().store(true, Ordering::SeqCst);
thread::sleep(Duration::from_millis(50));
// 停止旧线程,等待其退出后再启动新线程
stop_topmost_thread();
let stop_flag = topmost_stop().clone();
stop_flag.store(false, Ordering::SeqCst);
let app_handle = app.clone();
thread::spawn(move || {
let handle = thread::spawn(move || {
let mut last_foreground: isize = 0;
let mut system_ui_active = false;
@@ -293,15 +323,15 @@ pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> {
if win_api::is_system_ui_class(&class) {
if !system_ui_active {
system_ui_active = true;
let _ = app_handle.emit("osd-system-ui-active", ());
let _ = app_handle.emit(crate::constants::events::OSD_SYSTEM_UI_ACTIVE, ());
}
} else if system_ui_active {
system_ui_active = false;
let _ = app_handle.emit("osd-system-ui-inactive", ());
let _ = app_handle.emit(crate::constants::events::OSD_SYSTEM_UI_INACTIVE, ());
}
} else if system_ui_active {
system_ui_active = false;
let _ = app_handle.emit("osd-system-ui-inactive", ());
let _ = app_handle.emit(crate::constants::events::OSD_SYSTEM_UI_INACTIVE, ());
}
}
}
@@ -310,14 +340,18 @@ pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> {
}
});
if let Ok(mut guard) = TOPMOST_HANDLE.lock() {
*guard = Some(handle);
}
Ok(())
}
/// 停止所有 OSD 监视线程
#[tauri::command]
pub fn osd_stop_watch() {
drag_stop().store(true, Ordering::SeqCst);
topmost_stop().store(true, Ordering::SeqCst);
stop_drag_thread();
stop_topmost_thread();
}
/// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠)
+77 -65
View File
@@ -1,4 +1,5 @@
use serde::Serialize;
use specta::Type;
use std::collections::HashMap;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
@@ -80,7 +81,7 @@ fn get_job_handle() -> Option<winapi::HANDLE> {
unsafe {
let h = winapi::CreateJobObjectW(std::ptr::null_mut(), std::ptr::null());
if h.is_null() {
eprintln!("[ProcessManager] CreateJobObjectW 失败,异常退出时子进程可能残留");
crate::logger::log_error("process", "CreateJobObjectW 失败,异常退出时子进程可能残留");
return 0;
}
// 设置 KILL_ON_JOB_CLOSE:主进程退出时自动终止所有子进程
@@ -93,7 +94,7 @@ fn get_job_handle() -> Option<winapi::HANDLE> {
std::mem::size_of::<winapi::JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
);
if ok == 0 {
eprintln!("[ProcessManager] SetInformationJobObject 失败");
crate::logger::log_error("process", "SetInformationJobObject 失败");
return 0;
}
h as usize
@@ -136,7 +137,7 @@ fn assign_to_job(child: &Child) {
fn assign_to_job(_child: &Child) {}
/// 进程状态枚举
#[derive(Serialize, Clone, Debug)]
#[derive(Serialize, Clone, Debug, Type)]
#[serde(rename_all = "lowercase")]
pub enum ProcessStatus {
Running,
@@ -147,7 +148,8 @@ pub enum ProcessStatus {
}
/// 进程信息(返回给前端)
#[derive(Serialize, Clone)]
#[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct ProcessInfo {
pub id: String,
pub name: String,
@@ -291,7 +293,7 @@ impl ProcessManager {
Ok(Some(_)) => break,
Ok(None) => {
if std::time::Instant::now() >= deadline {
println!("[ProcessManager] 进程 {} 等待退出超时(3s),放弃等待", id);
crate::logger::log_warn("process", &format!("进程 {} 等待退出超时(3s),放弃等待", id));
break;
}
std::thread::sleep(Duration::from_millis(50));
@@ -299,7 +301,7 @@ impl ProcessManager {
Err(_) => break,
}
}
println!("[ProcessManager] 已停止进程: {}", id);
crate::logger::log_info("process", &format!("已停止进程: {}", id));
}
}
}
@@ -351,9 +353,15 @@ impl ProcessManager {
/// 检查所有进程,处理崩溃的进程(自动重启或移除)
/// 返回状态发生变化的进程列表
/// 注意:kill/wait/sleep(800ms) 等阻塞操作一律在锁外执行,
/// 锁内仅做非阻塞的 try_wait 判定,避免阻塞其他进程的状态查询
pub fn check_and_cleanup(&self) -> Vec<ProcessInfo> {
let mut changes = Vec::new();
// 需要重启的条目(锁外执行 kill + sleep + spawn
let mut restarts: Vec<(String, ProcessEntry)> = Vec::new();
// 阶段 1:锁内快速判定(仅非阻塞 try_wait),收集重启/移除决策
{
let mut processes = match self.processes.lock() {
Ok(p) => p,
Err(_) => return changes,
@@ -362,7 +370,7 @@ impl ProcessManager {
let ids: Vec<String> = processes.keys().cloned().collect();
for id in ids {
if let Some(entry) = processes.get_mut(&id) {
let Some(entry) = processes.get_mut(&id) else { continue };
match entry.child.try_wait() {
Ok(None) => {
// 仍在运行,无需处理
@@ -373,58 +381,10 @@ impl ProcessManager {
&& (entry.max_restarts == 0
|| entry.restart_count < entry.max_restarts)
{
// 自动重启
let restart_count = entry.restart_count + 1;
let executable = entry.executable.clone();
let args = entry.args.clone();
let cwd = entry.cwd.clone();
let name = entry.name.clone();
// 先终止旧进程
let _ = entry.child.kill();
let _ = entry.child.wait();
// 等待 TCP 端口释放(Windows 上 kill 后端口释放有延迟)
std::thread::sleep(std::time::Duration::from_millis(800));
// 重新启动
let mut cmd = Command::new(&executable);
cmd.args(&args);
if let Some(ref dir) = cwd {
cmd.current_dir(dir);
}
cmd.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null());
setup_creation_flags(&mut cmd);
match cmd.spawn() {
Ok(new_child) => {
let pid = new_child.id();
entry.child = new_child;
entry.restart_count = restart_count;
changes.push(ProcessInfo {
id: id.clone(),
name: name.clone(),
status: ProcessStatus::Running,
pid: Some(pid),
restart_count,
});
}
Err(e) => {
eprintln!(
"[ProcessManager] 重启进程 '{}' 失败: {}",
id, e
);
processes.remove(&id);
changes.push(ProcessInfo {
id: id.clone(),
name,
status: ProcessStatus::Crashed,
pid: None,
restart_count,
});
}
// 需要自动重启:从 map 移除,锁外执行
if let Some(mut e) = processes.remove(&id) {
e.restart_count += 1;
restarts.push((id.clone(), e));
}
} else {
// 不自动重启,移除记录
@@ -455,6 +415,58 @@ impl ProcessManager {
}
}
}
} // 锁在此释放
// 阶段 2:锁外执行重启(kill + wait + 端口释放等待 + spawn,不阻塞进程状态查询)
for (id, mut entry) in restarts {
// 先终止旧进程
let _ = entry.child.kill();
let _ = entry.child.wait();
// 等待 TCP 端口释放(Windows 上 kill 后端口释放有延迟)
std::thread::sleep(std::time::Duration::from_millis(800));
// 重新启动
let mut cmd = Command::new(&entry.executable);
cmd.args(&entry.args);
if let Some(ref dir) = entry.cwd {
cmd.current_dir(dir);
}
cmd.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null());
setup_creation_flags(&mut cmd);
let name = entry.name.clone();
let restart_count = entry.restart_count;
match cmd.spawn() {
Ok(new_child) => {
let pid = new_child.id();
entry.child = new_child;
if let Ok(mut processes) = self.processes.lock() {
processes.insert(id.clone(), entry);
}
changes.push(ProcessInfo {
id: id.clone(),
name,
status: ProcessStatus::Running,
pid: Some(pid),
restart_count,
});
}
Err(e) => {
crate::logger::log_error(
"process",
&format!("重启进程 '{}' 失败: {}", id, e),
);
changes.push(ProcessInfo {
id: id.clone(),
name,
status: ProcessStatus::Crashed,
pid: None,
restart_count,
});
}
}
}
changes
@@ -464,7 +476,7 @@ impl ProcessManager {
// ===== Tauri 命令 =====
#[tauri::command]
pub fn start_process(
pub fn process_start(
state: tauri::State<'_, ProcessManager>,
params: StartProcessParams,
) -> Result<ProcessInfo, String> {
@@ -472,7 +484,7 @@ pub fn start_process(
}
#[tauri::command]
pub fn stop_process(
pub fn process_stop(
state: tauri::State<'_, ProcessManager>,
id: String,
) -> Result<(), String> {
@@ -480,7 +492,7 @@ pub fn stop_process(
}
#[tauri::command]
pub fn get_process_status(
pub fn process_status(
state: tauri::State<'_, ProcessManager>,
id: String,
) -> Option<ProcessInfo> {
@@ -488,14 +500,14 @@ pub fn get_process_status(
}
#[tauri::command]
pub fn get_all_process_status(
pub fn process_all_status(
state: tauri::State<'_, ProcessManager>,
) -> Vec<ProcessInfo> {
state.get_all_status()
}
#[tauri::command]
pub fn stop_all_processes(state: tauri::State<'_, ProcessManager>) {
pub fn process_stop_all(state: tauri::State<'_, ProcessManager>) {
state.stop_all()
}
@@ -509,7 +521,7 @@ pub fn start_monitoring_thread(app: AppHandle) {
let changes = state.check_and_cleanup();
for change in changes {
let _ = app.emit("process-status-changed", &change);
let _ = app.emit(crate::constants::events::PROCESS_STATUS_CHANGED, &change);
}
}
});
+3 -1
View File
@@ -8,7 +8,9 @@ use std::path::PathBuf;
use serde::Serialize;
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize)]
use specta::Type;
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AppRecord {
pub name: String,
+63 -18
View File
@@ -7,21 +7,25 @@ use super::{file_index, app_scanner, icon_extractor};
/// 读取快速面板设置(快捷键等)
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_get_settings(app: AppHandle) -> Result<QuickPanelSettings, String> {
Ok(popup::load_settings(&app))
}
/// 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_save_settings(
settings: QuickPanelSettings,
app: AppHandle,
) -> Result<(), String> {
let prev_shortcut = popup::load_settings(&app).shortcut;
popup::save_settings(&app, &settings)?;
// 快捷键变化时重新注册
// 快捷键变化时重新注册(共享工具模块,原子化 + 冲突检测)
if settings.shortcut != prev_shortcut {
popup::register_shortcut(&app, &settings.shortcut)?;
crate::shortcut::register_shortcut(&app, "快速面板", &settings.shortcut, |a| {
popup::show_popup(a)
})?;
// 新快捷键非空时确保弹窗窗口已预创建
if !settings.shortcut.trim().is_empty() {
popup::ensure_window(&app);
@@ -32,22 +36,25 @@ pub async fn quickpanel_save_settings(
/// 注册(或切换)快速面板全局快捷键
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_register_shortcut(
shortcut: String,
app: AppHandle,
) -> Result<(), String> {
popup::register_shortcut(&app, &shortcut)
crate::shortcut::register_shortcut(&app, "快速面板", &shortcut, |a| popup::show_popup(a))
}
/// 注销快速面板全局快捷键
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_unregister_shortcut(app: AppHandle) -> Result<(), String> {
popup::unregister_shortcut(&app);
crate::shortcut::unregister_shortcut(&app, "快速面板");
Ok(())
}
/// 手动触发显示快速面板(供 UI 按钮调用)
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_show_popup(app: AppHandle) -> Result<(), String> {
popup::show_popup(&app);
Ok(())
@@ -55,6 +62,7 @@ pub async fn quickpanel_show_popup(app: AppHandle) -> Result<(), String> {
/// 隐藏快速面板
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_hide_popup(app: AppHandle) -> Result<(), String> {
popup::hide_popup(&app);
Ok(())
@@ -62,6 +70,7 @@ pub async fn quickpanel_hide_popup(app: AppHandle) -> Result<(), String> {
/// 显示已创建的弹窗窗口(前端 onMounted 后调用)
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_show_window(app: AppHandle) -> Result<(), String> {
popup::show_window(&app);
Ok(())
@@ -69,6 +78,7 @@ pub async fn quickpanel_show_window(app: AppHandle) -> Result<(), String> {
/// 锁定屏幕(Windows: rundll32 user32.dll,LockWorkStationCREATE_NO_WINDOW 避免黑窗)
#[tauri::command]
#[specta::specta]
pub fn quickpanel_lock_screen() -> Result<(), String> {
#[cfg(windows)]
{
@@ -87,13 +97,16 @@ pub fn quickpanel_lock_screen() -> Result<(), String> {
/// 初始化文件索引数据库(应用启动时调用)
#[tauri::command]
pub fn quickpanel_init_file_index(app: AppHandle) -> Result<(), String> {
file_index::init(&app);
Ok(())
#[specta::specta]
pub async fn quickpanel_init_file_index(app: AppHandle) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || file_index::init(&app))
.await
.map_err(|e| format!("索引初始化任务失败: {}", e))
}
/// 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用)
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_build_file_index(app: AppHandle) -> Result<i64, String> {
let settings = popup::load_settings(&app);
let dirs = if settings.index_dirs.is_empty() {
@@ -107,33 +120,51 @@ pub async fn quickpanel_build_file_index(app: AppHandle) -> Result<i64, String>
.map_err(|e| format!("索引任务失败: {}", e))?
}
/// 搜索文件索引
/// 搜索文件索引SQLite 查询移出主线程)
#[tauri::command]
pub fn quickpanel_search_files(query: String, limit: Option<i64>) -> Vec<file_index::FileRecord> {
file_index::search(&query, limit.unwrap_or(50))
#[specta::specta]
pub async fn quickpanel_search_files(
query: String,
limit: Option<i64>,
) -> Result<Vec<file_index::FileRecord>, String> {
tauri::async_runtime::spawn_blocking(move || file_index::search(&query, limit.unwrap_or(50)))
.await
.map_err(|e| format!("搜索任务失败: {}", e))
}
/// 获取索引状态
#[tauri::command]
#[specta::specta]
pub fn quickpanel_file_index_stats() -> file_index::IndexStats {
file_index::stats()
}
/// 扫描已安装应用
/// 扫描已安装应用(遍历开始菜单/桌面/磁盘,移出主线程)
#[tauri::command]
pub fn quickpanel_scan_apps() -> Vec<app_scanner::AppRecord> {
app_scanner::scan_apps()
#[specta::specta]
pub async fn quickpanel_scan_apps() -> Result<Vec<app_scanner::AppRecord>, String> {
tauri::async_runtime::spawn_blocking(app_scanner::scan_apps)
.await
.map_err(|e| format!("扫描应用任务失败: {}", e))
}
/// 获取应用图标(data URL)。命中内存/磁盘缓存时零 Windows API 调用。
/// 前端按需为可见项调用,避免一次性加载全部图标。
/// 未命中缓存时 SHGetFileInfoW + 编码 + 落盘为阻塞操作,移出主线程。
#[tauri::command]
pub fn quickpanel_get_app_icon(app: AppHandle, path: String) -> Option<String> {
icon_extractor::get_icon_data_url(&app, &path)
#[specta::specta]
pub async fn quickpanel_get_app_icon(
app: AppHandle,
path: String,
) -> Result<Option<String>, String> {
tauri::async_runtime::spawn_blocking(move || icon_extractor::get_icon_data_url(&app, &path))
.await
.map_err(|e| format!("图标提取任务失败: {}", e))
}
/// 清理图标缓存(磁盘 + 内存)
#[tauri::command]
#[specta::specta]
pub fn quickpanel_clear_app_icon_cache(app: AppHandle) -> Result<(), String> {
icon_extractor::clear_cache(&app);
Ok(())
@@ -141,6 +172,7 @@ pub fn quickpanel_clear_app_icon_cache(app: AppHandle) -> Result<(), String> {
/// 在资源管理器中显示文件(选中)
#[tauri::command]
#[specta::specta]
pub fn quickpanel_reveal_in_explorer(path: String) -> Result<(), String> {
#[cfg(windows)]
{
@@ -192,18 +224,21 @@ pub fn quickpanel_reveal_in_explorer(path: String) -> Result<(), String> {
/// - 目录:explorer.exe 直接打开(修复索引目录点击后未打开的问题)
/// - 文件:ShellExecuteW open,无关联应用时自动 fallback 到「打开方式」对话框(verb: openas
#[tauri::command]
#[specta::specta]
pub fn quickpanel_open_file(path: String) -> Result<(), String> {
super::special_locations::open_path(&path)
}
/// 获取 Windows 常用快捷位置(hosts、回收站、此电脑、用户目录、系统管理工具等)
#[tauri::command]
#[specta::specta]
pub fn quickpanel_get_special_locations() -> Vec<super::special_locations::SpecialLocation> {
super::special_locations::get_special_locations()
}
/// 打开快捷位置(kind: file | shell | cmd
#[tauri::command]
#[specta::specta]
pub fn quickpanel_open_special(
kind: String,
target: String,
@@ -216,13 +251,21 @@ pub fn quickpanel_open_special(
}
}
/// 删除文件(移到回收站)
/// 删除文件(移到回收站PowerShell 阻塞等待移出主线程
#[tauri::command]
pub fn quickpanel_delete_file(path: String) -> Result<(), String> {
#[specta::specta]
pub async fn quickpanel_delete_file(path: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || delete_file_impl(&path))
.await
.map_err(|e| format!("删除任务失败: {}", e))?
}
/// 删除文件实现:PowerShell + Microsoft.VisualBasic 移到回收站
fn delete_file_impl(path: &str) -> Result<(), String> {
#[cfg(windows)]
{
use crate::process_manager::setup_creation_flags;
let p = std::path::Path::new(&path);
let p = std::path::Path::new(path);
let is_dir = p.is_dir();
// 用 PowerShell + Microsoft.VisualBasic 移到回收站
let script = if is_dir {
@@ -258,6 +301,7 @@ pub fn quickpanel_delete_file(path: String) -> Result<(), String> {
/// 运行自定义命令(执行可执行文件 + 参数)
/// .lnk 快捷方式不能直接 spawnos error 193),需通过 cmd /C 启动
#[tauri::command]
#[specta::specta]
pub fn quickpanel_run_custom_command(command: String, args: Vec<String>) -> Result<(), String> {
use crate::process_manager::setup_creation_flags;
let is_lnk = command
@@ -282,6 +326,7 @@ pub fn quickpanel_run_custom_command(command: String, args: Vec<String>) -> Resu
/// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
/// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
#[tauri::command]
#[specta::specta]
pub fn quickpanel_run_system_command(command: String, args: Vec<String>) -> Result<(), String> {
let mut cmd = std::process::Command::new(&command);
cmd.args(&args);
+19 -20
View File
@@ -13,8 +13,10 @@ use serde::Serialize;
use tauri::{AppHandle, Manager};
use walkdir::WalkDir;
use specta::Type;
/// 单个文件记录(返回给前端)
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileRecord {
pub path: String,
@@ -25,7 +27,7 @@ pub struct FileRecord {
}
/// 索引状态(返回给前端)
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct IndexStats {
pub total: i64,
@@ -61,7 +63,7 @@ pub fn init(app: &AppHandle) {
let conn = match Connection::open(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("[quickpanel] 文件索引 DB 初始化失败: {}", e);
crate::logger::log_error("quickpanel", &format!("文件索引 DB 初始化失败: {}", e));
return;
}
};
@@ -84,11 +86,11 @@ pub fn init(app: &AppHandle) {
);",
);
let mut guard = index_slot().lock().unwrap();
let mut guard = index_slot().lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(Inner {
conn: Mutex::new(conn),
});
eprintln!("[quickpanel] 文件索引 DB 已就绪: {}", path.display());
crate::logger::log_info("quickpanel", &format!("文件索引 DB 已就绪: {}", path.display()));
}
/// 判断索引是否已初始化
@@ -96,7 +98,7 @@ fn with_conn<F, R>(f: F) -> Option<R>
where
F: FnOnce(&Connection) -> R,
{
let guard = index_slot().lock().unwrap();
let guard = index_slot().lock().unwrap_or_else(|e| e.into_inner());
if let Some(inner) = guard.as_ref() {
if let Ok(conn) = inner.conn.lock() {
return Some(f(&conn));
@@ -140,7 +142,7 @@ pub fn build_index(dirs: &[String]) -> Result<i64, String> {
// 启动/刷新 notify 监听器
start_watcher(dirs);
eprintln!("[quickpanel] 文件索引完成,共 {}", count);
crate::logger::log_info("quickpanel", &format!("文件索引完成,共 {}", count));
Ok(count)
}
@@ -203,10 +205,13 @@ fn upsert_path(p: &Path) -> bool {
fn remove_path(p: &Path) {
let path_str = p.to_string_lossy().to_string();
let _ = with_conn(|conn| {
// 删除该路径及其子项(目录被删除时,子文件也失效)
// 删除该路径本身及其直接子项(目录被删除时,子文件也失效)
// 用"路径 + 分隔符"的前缀匹配(而非裸前缀),避免误删 dir2/directory 等兄弟目录。
let backslash_prefix = format!("{}\\{}", path_str, "%");
let slash_prefix = format!("{}/{}", path_str, "%");
conn.execute(
"DELETE FROM files WHERE path = ?1 OR path LIKE ?2",
params![path_str, format!("{}%", path_str)],
"DELETE FROM files WHERE path = ?1 OR path LIKE ?2 OR path LIKE ?3",
params![path_str, backslash_prefix, slash_prefix],
)
});
}
@@ -306,7 +311,7 @@ pub fn start_watcher(dirs: &[String]) {
) {
Ok(w) => w,
Err(e) => {
eprintln!("[quickpanel] notify watcher 创建失败: {}", e);
crate::logger::log_error("quickpanel", &format!("notify watcher 创建失败: {}", e));
return;
}
};
@@ -318,14 +323,14 @@ pub fn start_watcher(dirs: &[String]) {
continue;
}
if let Err(e) = watcher.watch(path, RecursiveMode::Recursive) {
eprintln!("[quickpanel] watch {} 失败: {}", dir, e);
crate::logger::log_error("quickpanel", &format!("watch {} 失败: {}", dir, e));
}
}
// 替换旧 watcherdrop 时自动 unwatch
let mut guard = watcher_slot().lock().unwrap();
let mut guard = watcher_slot().lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(watcher);
eprintln!("[quickpanel] notify 监听已启动,监听 {} 个目录", dirs.len());
crate::logger::log_info("quickpanel", &format!("notify 监听已启动,监听 {} 个目录", dirs.len()));
}
/// 处理文件系统事件:创建/修改 → upsert,删除 → remove,重命名 → remove + upsert
@@ -348,9 +353,3 @@ fn handle_fs_event(event: &notify::Event) {
}
}
}
/// 停止 notify 监听器
pub fn stop_watcher() {
let mut guard = watcher_slot().lock().unwrap();
*guard = None;
}
+18 -12
View File
@@ -10,7 +10,7 @@
//! - 返回 base64 data URL 而非文件路径,避免独立弹窗窗口的 asset 协议配置问题
//! - 磁盘缓存避免重复 Windows API 调用(昂贵),内存缓存避免重复磁盘读取 + 编码
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::Mutex;
@@ -18,26 +18,32 @@ use base64::Engine as _;
use tauri::{AppHandle, Manager};
// ===== 内存缓存 =====
static MEM_CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
/// (path → data URL) + FIFO 淘汰队列(队头最旧,超限时先淘汰)
static MEM_CACHE: Mutex<Option<(HashMap<String, String>, VecDeque<String>)>> = Mutex::new(None);
const MEM_CACHE_MAX: usize = 512;
fn mem_get(path: &str) -> Option<String> {
let cache = MEM_CACHE.lock().ok()?;
cache.as_ref()?.get(path).cloned()
cache.as_ref()?.0.get(path).cloned()
}
fn mem_put(path: String, url: String) {
if let Ok(mut guard) = MEM_CACHE.lock() {
let map = guard.get_or_insert_with(HashMap::new);
if map.len() >= MEM_CACHE_MAX {
// 简单清理:丢弃一半(最早插入的,HashMap 无序,近似随机)
let keep = map.len() / 2;
let keys: Vec<String> = map.keys().cloned().collect();
for k in keys.iter().skip(keep) {
map.remove(k);
}
}
let slot = guard.get_or_insert_with(|| (HashMap::new(), VecDeque::new()));
let (map, order) = &mut *slot;
if map.contains_key(&path) {
// 已存在:仅更新值,不重复入队
map.insert(path, url);
return;
}
if map.len() >= MEM_CACHE_MAX {
// FIFO 淘汰最旧条目(O(1)),避免无序淘汰把刚插入的常用图标清掉
if let Some(oldest) = order.pop_front() {
map.remove(&oldest);
}
}
map.insert(path.clone(), url);
order.push_back(path);
}
}
+1 -1
View File
@@ -21,4 +21,4 @@ pub use commands::{
quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window,
quickpanel_unregister_shortcut,
};
pub use popup::{ensure_window, load_settings, register_shortcut};
pub use popup::{ensure_window, load_settings};
+41 -76
View File
@@ -17,9 +17,10 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use tauri::window::{Effect, EffectsBuilder};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use crate::clipboard::popup::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
use specta::Type;
/// 弹窗窗口标签
pub const POPUP_LABEL: &str = "quick-panel";
@@ -28,15 +29,16 @@ pub const POPUP_LABEL: &str = "quick-panel";
const WIN_W: f64 = 600.0;
const WIN_H: f64 = 420.0;
/// 当前注册的快捷键(用于注销旧快捷键)
static CURRENT_SHORTCUT: Mutex<Option<String>> = Mutex::new(None);
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
/// 兜底创建路径下 show_popup 计算出的待显示位置(物理坐标),供 show_window 应用,
/// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。
static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None);
/// 自定义命令
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CustomCommand {
pub id: String,
@@ -47,7 +49,7 @@ pub struct CustomCommand {
}
/// 快速面板设置
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct QuickPanelSettings {
/// 全局快捷键(如 "Alt+Space"),空字符串表示不注册。
@@ -131,56 +133,6 @@ pub fn save_settings(app: &AppHandle, settings: &QuickPanelSettings) -> Result<(
std::fs::write(&path, json).map_err(|e| format!("写入设置文件失败: {}", e))
}
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+Space"、"Ctrl+Shift+P"
/// 失败返回 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!("[quickpanel] 已注册快捷键: {}", 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_popup 时会重新定位到鼠标所在显示器中央。
/// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。
@@ -206,7 +158,7 @@ fn create_popup_window(app: &AppHandle) {
{
Ok(w) => w,
Err(e) => {
eprintln!("[quickpanel] 创建弹窗失败: {}", e);
crate::logger::log_error("quickpanel", &format!("创建弹窗失败: {}", e));
return;
}
};
@@ -217,11 +169,11 @@ fn create_popup_window(app: &AppHandle) {
win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
let _ = win_handle.hide();
let _ = app_handle.emit("quickpanel-hide", ());
let _ = app_handle.emit(crate::constants::events::QUICKPANEL_HIDE, ());
}
});
eprintln!("[quickpanel] 弹窗窗口已预创建(隐藏状态)");
crate::logger::log_info("quickpanel", "弹窗窗口已预创建(隐藏状态)");
}
/// 应用启动时预创建弹窗窗口(隐藏)。
@@ -250,41 +202,46 @@ pub fn show_popup(app: &AppHandle) {
let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx, my)
.unwrap_or((0, 0, 1920, 1040));
// 获取光标所在显示器的 DPI,将物理坐标转为逻辑坐标(DIP
// 光标所在显示器的 DPI:窗口尺寸需按物理像素换算
let dpi = get_dpi_for_point(mx, my).unwrap_or(96);
let scale = dpi as f64 / 96.0;
let win_w_px = WIN_W * scale;
let win_h_px = WIN_H * scale;
let wa_left_l = wa_left as f64 / scale;
let wa_top_l = wa_top as f64 / scale;
let wa_right_l = wa_right as f64 / scale;
let wa_bottom_l = wa_bottom as f64 / scale;
// 直接以物理坐标计算(光标 + 工作区均为物理像素,避免混合 DPI 下换算偏移)
let (x, y) = if cursor_mode {
// 鼠标位置模式:以鼠标为基准偏移,clamp 到工作区内
let mx_l = mx as f64 / scale;
let my_l = my as f64 / scale;
let x = (mx_l + 12.0).min(wa_right_l - WIN_W).max(wa_left_l);
let y = (my_l + 12.0).min(wa_bottom_l - WIN_H).max(wa_top_l);
let x = (mx as f64 + 12.0 * scale).min(wa_right as f64 - win_w_px).max(wa_left as f64);
let y = (my as f64 + 12.0 * scale).min(wa_bottom as f64 - win_h_px).max(wa_top as f64);
(x, y)
} else {
// 中央模式:窗口居中于鼠标所在显示器工作区
let wa_w = wa_right_l - wa_left_l;
let wa_h = wa_bottom_l - wa_top_l;
(wa_left_l + (wa_w - WIN_W) / 2.0, wa_top_l + (wa_h - WIN_H) / 2.0)
let wa_w = (wa_right - wa_left) as f64;
let wa_h = (wa_bottom - wa_top) as f64;
(
wa_left as f64 + (wa_w - win_w_px) / 2.0,
wa_top as f64 + (wa_h - win_h_px) / 2.0,
)
};
// 窗口已存在:移动 + 显示 + 请求焦点
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }));
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
x: x as i32,
y: y as i32,
}));
let _ = win.show();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit("quickpanel-show", ());
let _ = app.emit(crate::constants::events::QUICKPANEL_SHOW, ());
return;
}
// 兜底:窗口被销毁时重新创建(隐藏),等前端 onMounted 回调 show_window
POPUP_PENDING_SHOW.store(true, Ordering::SeqCst);
if let Ok(mut pos) = PENDING_POS.lock() {
*pos = Some((x, y));
}
create_popup_window(app);
}
@@ -296,10 +253,18 @@ pub fn show_window(app: &AppHandle) {
return;
}
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
// 应用 show_popup 计算的兜底位置(物理坐标),避免停留在屏幕外
let pos = PENDING_POS.lock().ok().and_then(|p| *p);
if let Some((x, y)) = pos {
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
x: x as i32,
y: y as i32,
}));
}
let _ = win.show();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit("quickpanel-show", ());
let _ = app.emit(crate::constants::events::QUICKPANEL_SHOW, ());
}
}
@@ -9,8 +9,10 @@
use std::path::PathBuf;
use serde::Serialize;
use specta::Type;
/// 快捷位置条目
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SpecialLocation {
pub id: String,
+99 -3
View File
@@ -15,10 +15,11 @@
//! - screenshot_disable_transitions:禁用窗口显示/隐藏过渡动画(消除覆盖层缩放动画)
use super::{CaptureData, WindowInfo};
use tauri::Manager;
use tauri::{Emitter, Manager};
/// 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画
#[tauri::command]
#[specta::specta]
pub async fn screenshot_disable_transitions(
app: tauri::AppHandle,
label: String,
@@ -48,22 +49,27 @@ pub async fn screenshot_disable_transitions(
/// 注册(或切换)截图全局快捷键。传入空字符串则禁用快捷键。
#[tauri::command]
#[specta::specta]
pub async fn screenshot_register_shortcut(
app: tauri::AppHandle,
shortcut: String,
) -> Result<(), String> {
super::shortcut::register_shortcut(&app, &shortcut)
crate::shortcut::register_shortcut(&app, "截图", &shortcut, |a| {
let _ = a.emit(crate::constants::events::SCREENSHOT_SHORTCUT, ());
})
}
/// 注销截图全局快捷键
#[tauri::command]
#[specta::specta]
pub async fn screenshot_unregister_shortcut(app: tauri::AppHandle) -> Result<(), String> {
super::shortcut::unregister_shortcut(&app);
crate::shortcut::unregister_shortcut(&app, "截图");
Ok(())
}
/// 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码
#[tauri::command]
#[specta::specta]
pub async fn screenshot_capture_fullscreen() -> Result<(), String> {
#[cfg(windows)]
{
@@ -101,6 +107,7 @@ pub async fn screenshot_get_fullscreen_bmp() -> Result<tauri::ipc::Response, Str
/// 全屏捕获编码为 PNG base64 并清除(全屏截图直接进编辑器)
#[tauri::command]
#[specta::specta]
pub async fn screenshot_fullscreen_png() -> Result<CaptureData, String> {
#[cfg(windows)]
{
@@ -116,6 +123,7 @@ pub async fn screenshot_fullscreen_png() -> Result<CaptureData, String> {
/// 清除静态全屏捕获(覆盖层关闭/取消时释放内存)
#[tauri::command]
#[specta::specta]
pub async fn screenshot_clear_fullscreen() -> Result<(), String> {
#[cfg(windows)]
{
@@ -130,6 +138,7 @@ pub async fn screenshot_clear_fullscreen() -> Result<(), String> {
/// 按物理像素坐标裁剪已存储的全屏捕获
#[tauri::command]
#[specta::specta]
pub async fn screenshot_crop_stored(
x: i32,
y: i32,
@@ -153,6 +162,7 @@ pub async fn screenshot_crop_stored(
/// 裁剪已存储的全屏捕获并直接写入剪贴板(一次 IPC 完成"裁剪+复制"
#[tauri::command]
#[specta::specta]
pub async fn screenshot_crop_copy_stored(
x: i32,
y: i32,
@@ -176,6 +186,7 @@ pub async fn screenshot_crop_copy_stored(
/// 拾取指定物理屏幕坐标下的顶层窗口
#[tauri::command]
#[specta::specta]
pub async fn screenshot_window_from_point(
x: i32,
y: i32,
@@ -197,6 +208,7 @@ pub async fn screenshot_window_from_point(
/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口)
#[tauri::command]
#[specta::specta]
pub async fn screenshot_cursor_pos() -> Result<(i32, i32), String> {
#[cfg(windows)]
{
@@ -212,6 +224,7 @@ pub async fn screenshot_cursor_pos() -> Result<(i32, i32), String> {
/// 枚举所有可见顶层窗口
#[tauri::command]
#[specta::specta]
pub async fn screenshot_enum_windows() -> Result<Vec<WindowInfo>, String> {
#[cfg(windows)]
{
@@ -227,6 +240,7 @@ pub async fn screenshot_enum_windows() -> Result<Vec<WindowInfo>, String> {
/// 按 hwnd 捕获指定窗口
#[tauri::command]
#[specta::specta]
pub async fn screenshot_capture_window(hwnd: isize) -> Result<CaptureData, String> {
#[cfg(windows)]
{
@@ -250,6 +264,7 @@ pub async fn screenshot_capture_window(hwnd: isize) -> Result<CaptureData, Strin
/// 存入编辑器图片(base64 PNG
#[tauri::command]
#[specta::specta]
pub async fn screenshot_set_editor_image(png_base64: String) -> Result<(), String> {
super::set_editor_image(png_base64);
Ok(())
@@ -257,12 +272,14 @@ pub async fn screenshot_set_editor_image(png_base64: String) -> Result<(), Strin
/// 取出编辑器图片(编辑器窗口加载时调用,取出即清除)
#[tauri::command]
#[specta::specta]
pub async fn screenshot_get_editor_image() -> Result<Option<String>, String> {
Ok(super::take_editor_image())
}
/// 将 PNG base64 写入系统剪贴板(转 CF_DIB)
#[tauri::command]
#[specta::specta]
pub async fn screenshot_copy_image(png_base64: String) -> Result<(), String> {
#[cfg(windows)]
{
@@ -283,6 +300,7 @@ pub async fn screenshot_copy_image(png_base64: String) -> Result<(), String> {
///
/// 省去前端 toDataURL(PNG 编码+base64) → Rust base64 解码 → PNG 解码 三次往返。
/// body 格式:前 8 字节 = width(i32 LE) + height(i32 LE),之后为 raw RGBA 像素。
/// 注:参数为 tauri::ipc::Request(原始 body),specta 无法生成,豁免标注。
#[tauri::command]
pub async fn screenshot_compose_copy(
request: tauri::ipc::Request<'_>,
@@ -314,6 +332,7 @@ pub async fn screenshot_compose_copy(
/// 将 PNG base64 写入文件
#[tauri::command]
#[specta::specta]
pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(windows)]
@@ -329,3 +348,80 @@ pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(),
.await
.map_err(|e| format!("保存任务失败: {}", e))?
}
// ===== 截图历史缓存:完整 PNG 落盘缓存目录,内存只保留缩略图 =====
/// 历史缓存根目录(app_cache_dir/screenshot/history
fn history_cache_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app
.path()
.app_cache_dir()
.map_err(|e| format!("获取缓存目录失败: {}", e))?
.join("screenshot")
.join("history");
std::fs::create_dir_all(&dir).map_err(|e| format!("创建缓存目录失败: {}", e))?;
Ok(dir)
}
/// 校验 path 属于历史缓存目录(防止路径穿越/任意文件读写)
fn ensure_in_history_dir(app: &tauri::AppHandle, path: &str) -> Result<std::path::PathBuf, String> {
let dir = history_cache_dir(app)?;
let p = std::path::PathBuf::from(path);
if !p.starts_with(&dir) {
return Err("非法路径:不在截图历史缓存目录内".into());
}
Ok(p)
}
/// 将完整 PNG 写入历史缓存目录,返回文件路径
#[tauri::command]
#[specta::specta]
pub async fn screenshot_save_cache(
app: tauri::AppHandle,
png_base64: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let dir = history_cache_dir(&app)?;
// 时间戳微秒命名(避免引入额外依赖;并发截图的同微秒碰撞可忽略)
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros())
.unwrap_or(0);
let path = dir.join(format!("{}.png", ts));
super::capture::save_png_to_file(&png_base64, &path.to_string_lossy())?;
Ok(path.to_string_lossy().into_owned())
})
.await
.map_err(|e| format!("缓存任务失败: {}", e))?
}
/// 从历史缓存目录读取 PNG 并返回 base64(点击历史项复制/保存时一次性加载,不常驻内存)
#[tauri::command]
#[specta::specta]
pub async fn screenshot_load_cache(
app: tauri::AppHandle,
path: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let p = ensure_in_history_dir(&app, &path)?;
let bytes = std::fs::read(&p).map_err(|e| format!("读取缓存失败: {}", e))?;
use base64::Engine as _;
Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
})
.await
.map_err(|e| format!("读取缓存任务失败: {}", e))?
}
/// 删除历史缓存文件(历史项移除/清空时调用,静默忽略不存在文件)
#[tauri::command]
#[specta::specta]
pub async fn screenshot_delete_cache(app: tauri::AppHandle, path: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
if let Ok(p) = ensure_in_history_dir(&app, &path) {
let _ = std::fs::remove_file(p);
}
Ok(())
})
.await
.map_err(|e| format!("删除缓存任务失败: {}", e))?
}
+5 -4
View File
@@ -5,13 +5,14 @@
use std::sync::Mutex;
use specta::Type;
#[cfg(windows)]
pub mod capture;
pub mod commands;
pub mod shortcut;
/// 前端可见的捕获数据
#[derive(serde::Serialize)]
#[derive(serde::Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CaptureData {
pub png_base64: String,
@@ -20,7 +21,7 @@ pub struct CaptureData {
}
/// 窗口信息(窗口拾取 / 枚举)
#[derive(serde::Serialize)]
#[derive(serde::Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WindowInfo {
pub hwnd: isize,
@@ -30,7 +31,7 @@ pub struct WindowInfo {
pub visual_rect: Option<ScreenRect>,
}
#[derive(serde::Serialize, Clone, Copy)]
#[derive(serde::Serialize, Clone, Copy, Type)]
#[serde(rename_all = "camelCase")]
pub struct ScreenRect {
pub x: i32,
-60
View File
@@ -1,60 +0,0 @@
//! 截图全局快捷键:可自定义注册/注销(默认 Ctrl+Alt+A)。
//!
//! 与剪贴板快捷弹窗(clipboard::popup)的实现一致:
//! 用 `on_shortcut` 为每个快捷键绑定独立处理器,切换时先注销旧的再注册新的。
//! 按下时 emit `screenshot-shortcut` 事件,前端 store 监听后触发 startCapture。
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
/// 当前已注册的快捷键字符串(用于切换时注销旧快捷键)
static CURRENT_SHORTCUT: Mutex<Option<String>> = Mutex::new(None);
/// 解析快捷键字符串为 Shortcut(格式如 "Ctrl+Alt+A"、"Shift+PrintScreen"
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 {
let _ = app_handle.emit("screenshot-shortcut", ());
}
})
.map_err(|e| format!("注册快捷键失败: {}", e))?;
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
*cur = Some(shortcut_str.to_string());
}
eprintln!("[screenshot] 已注册快捷键: {}", 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;
}
}
+142
View File
@@ -0,0 +1,142 @@
//! 应用启动初始化 —— 从 lib.rs 的 setup 闭包拆出,按子系统分组。
//!
//! 各子系统职责边界:
//! - 日志:LogManager(全局日志)
//! - 代理:MihomoManager + 自动启动
//! - 监控:MonitorKernel + NetworkMonitor + 自动启动
//! - 下载:DownloadEngine + 扩展 HTTP API 服务
//! - 剪贴板:ClipboardManager + 快捷键 + 预创建弹窗
//! - 快速面板:快捷键 + 预创建弹窗 + 文件索引
//! - 托盘:自定义菜单窗口
//! - 进程:监控线程
use std::sync::Arc;
use tauri::{App, Manager, Wry};
use crate::download_engine::{DownloadEngine, ExtensionServer};
use crate::logger::LogManager;
use crate::mihomo_manager::MihomoManager;
use crate::monitor_kernel::{MonitorKernel, check_and_relaunch_if_needed};
use crate::network_monitor::NetworkMonitor;
use crate::process_manager::{ProcessManager, start_monitoring_thread};
/// 应用启动初始化入口(setup 闭包调用)。
/// 初始化顺序即依赖顺序:日志 → 数据目录 → 各管理器 → 托盘 → 进程监控 → 自动启动。
pub fn init(app: &mut App<Wry>) -> Result<(), Box<dyn std::error::Error>> {
// ===== 日志系统:{app_data_dir}/logs/ =====
let log_dir = app
.path()
.app_data_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
.join("logs");
let log_manager = LogManager::new(log_dir);
// 注册为 Tauri State(供 log_* 命令),同时安装进程级全局日志器(供后端模块 log_line 使用)
crate::logger::install_global(log_manager.clone());
app.manage(log_manager);
// ===== 数据目录:{app_data_dir}/ =====
let app_data_dir = app
.path()
.app_data_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."));
// 永久提权检查:如果标志已设置且当前非管理员,以管理员权限重启自身并退出
// 必须在所有模块初始化之前执行(此时无资源需要清理)
if check_and_relaunch_if_needed(&app_data_dir) {
std::process::exit(0);
}
// ===== 代理模块:MihomoManager =====
let mihomo = MihomoManager::new(app_data_dir.clone());
app.manage(mihomo);
// ===== 监控模块:MonitorKernel(硬件)+ NetworkMonitor(网速) =====
let monitor = MonitorKernel::new(app_data_dir.clone());
app.manage(monitor);
// 网速采样不依赖提权,应用启动即开始
let network_monitor = Arc::new(NetworkMonitor::new());
app.manage(network_monitor.clone());
network_monitor.start(app.handle().clone());
// ===== 下载模块:DownloadEngine + 扩展 HTTP API 服务 =====
let engine = DownloadEngine::new(app_data_dir.join("downloader"), app.handle().clone());
let settings = engine.get_settings();
app.manage(engine.clone());
let server_engine = engine.clone();
let server_port = settings.extension_port;
let server_secret = settings.extension_secret.clone();
tauri::async_runtime::spawn(async move {
ExtensionServer::start(server_engine, server_port, server_secret).await;
});
// ===== 剪贴板模块:监听 + 快捷键 + 预创建弹窗 =====
let clipboard = crate::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) = crate::shortcut::register_shortcut(&app_handle, "剪贴板", &shortcut, |a| {
crate::clipboard::popup::show_popup(a)
}) {
crate::logger::log_error("clipboard", &format!("快捷键注册失败: {}", e));
}
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
crate::clipboard::popup::ensure_popup_window(&app_handle);
}
app.manage(clipboard);
// ===== 快速面板:快捷键 + 预创建弹窗 + 文件索引 =====
// defaultEnabled:true 假设启用;用户在设置页禁用模块时由前端 onDisable 钩子注销快捷键。
let qp_settings = crate::quickpanel::load_settings(&app.handle());
if !qp_settings.shortcut.trim().is_empty() {
let app_handle = app.handle().clone();
if let Err(e) = crate::shortcut::register_shortcut(
&app_handle,
"快速面板",
&qp_settings.shortcut,
|a| crate::quickpanel::popup::show_popup(a),
) {
crate::logger::log_error("quickpanel", &format!("快捷键注册失败: {}", e));
}
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
crate::quickpanel::ensure_window(&app_handle);
}
// 初始化文件索引数据库(不立即构建,由前端设置页或首次唤起时触发)
crate::quickpanel::file_index::init(&app.handle());
// ===== 托盘菜单 =====
crate::tray_menu::create_tray_menu(app.handle())?;
// ===== 进程监控线程 =====
start_monitoring_thread(app.handle().clone());
// ===== 自动启动(随应用启动,不依赖模块启用) =====
// mihomo:用户在设置中开启"自动启动"时随应用启动
if let Some(mihomo) = app.try_state::<MihomoManager>() {
if let Some(pm) = app.try_state::<ProcessManager>() {
mihomo.auto_start_on_launch(app.handle(), &pm);
}
}
// monitor Kernel:硬件监控默认启用,被动读取无副作用
if let Some(monitor) = app.try_state::<MonitorKernel>() {
let monitor = monitor.inner().clone();
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
match monitor.start_with_subscription(&app_handle).await {
Ok(info) => crate::logger::log_info("monitor", &format!("自动启动成功, pid={:?}", info.pid)),
Err(e) => crate::logger::log_warn("monitor", &format!("自动启动跳过: {}", e)),
}
});
}
// 截图快捷键由前端 screenshotStore 启动时调用 screenshot_register_shortcut 注册
// (支持自定义,默认 Ctrl+Alt+A),此处不再硬编码注册
Ok(())
}
+87
View File
@@ -0,0 +1,87 @@
//! 全局快捷键共享工具:剪贴板 / 快速面板 / 截图三处复用的注册逻辑。
//!
//! 提供:
//! - 原子化注册:先注销本模块旧快捷键,冲突检测通过后再注册新的,
//! 注册失败返回错误(快捷键被系统或其他应用占用时前端可提示用户)。
//! - 应用内冲突检测:同一组合键不允许被两个模块同时占用,
//! 避免后注册的 `on_shortcut` 静默覆盖先注册的处理器。
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use tauri::AppHandle;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
/// 快捷键占用表:模块名(中文,用于错误提示)→ 快捷键字符串
static REGISTRY: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
fn registry() -> &'static Mutex<HashMap<String, String>> {
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+V"、"Ctrl+Shift+V"
/// 失败返回 None。
pub fn parse_shortcut(s: &str) -> Option<Shortcut> {
s.trim().parse::<Shortcut>().ok()
}
/// 原子化注册全局快捷键。
///
/// - 先注销 `module` 已注册的旧快捷键(空字符串则仅注销,用于禁用)。
/// - 注册前做应用内冲突检测:同一组合键被其他模块占用时返回错误。
/// - 注册成功后登记占用表;失败则返回错误且不登记(此时本模块快捷键为未注册状态)。
pub fn register_shortcut<F>(
app: &AppHandle,
module: &str,
shortcut_str: &str,
handler: F,
) -> Result<(), String>
where
F: Fn(&AppHandle) + Send + Sync + 'static,
{
// 1. 注销本模块旧快捷键(释放占用条目)
unregister_shortcut(app, module);
let key = shortcut_str.trim().to_string();
if key.is_empty() {
return Ok(());
}
// 2. 应用内冲突检测
if let Ok(reg) = registry().lock() {
if let Some(owner) = reg.values().find(|v| **v == key) {
return Err(format!("快捷键 {} 已被「{}」模块占用,请更换", key, owner));
}
}
let shortcut = parse_shortcut(&key).ok_or_else(|| format!("无效的快捷键: {}", key))?;
let app_handle = app.clone();
app.global_shortcut()
.on_shortcut(shortcut, move |_a, _s, event| {
// 仅在按下时触发(松开不触发)
if event.state == ShortcutState::Pressed {
handler(&app_handle);
}
})
.map_err(|e| format!("注册快捷键 {} 失败: {}", key, e))?;
// 3. 登记占用
if let Ok(mut reg) = registry().lock() {
reg.insert(module.to_string(), key.clone());
}
crate::logger::log_info(module, &format!("已注册快捷键: {}", shortcut_str));
Ok(())
}
/// 注销 `module` 的全局快捷键并释放占用条目
pub fn unregister_shortcut(app: &AppHandle, module: &str) {
let old = registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(module);
if let Some(s) = old {
if let Some(shortcut) = parse_shortcut(&s) {
let _ = app.global_shortcut().unregister(shortcut);
}
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ unsafe extern "system" fn fix_subclass_proc(
#[tauri::command]
pub async fn fix_snap_background(app: AppHandle) -> Result<(), String> {
let main_window = app
.get_webview_window("main")
.get_webview_window(crate::constants::windows::MAIN)
.ok_or("main window not found")?;
let hwnd_isize: isize = {
+46 -102
View File
@@ -20,14 +20,12 @@ use tauri::{
/// 记录窗口最后显示时间,用于失焦防抖(避免显示瞬间因焦点未稳定而被立即隐藏)
static LAST_SHOW_TIME: Mutex<Option<Instant>> = Mutex::new(None);
/// 保存最近一次右键时计算出的定位参数(逻辑坐标),供 `tray_menu_ready` 使用
/// (x, tray_top_l, wa_top_l, wa_bottom_l, scale)
/// 保存最近一次右键时计算出的定位参数(物理坐标),供 `tray_menu_ready` 使用
/// (x, tray_top, wa_top, wa_bottom, scale)
static LAST_MENU_LAYOUT: Mutex<Option<(f64, f64, f64, f64, f64)>> = Mutex::new(None);
use crate::clipboard::popup::{get_work_area, get_work_area_at_point, get_dpi_for_point};
use crate::clipboard::ClipboardManager;
use crate::download_engine::DownloadEngine;
use crate::mihomo_manager::MihomoManager;
use crate::win32_util::{get_work_area, get_work_area_at_point, get_dpi_for_point};
use crate::mihomo_manager::{MihomoManager, is_pseudo_node};
use crate::monitor_kernel::MonitorKernel;
use crate::process_manager::{ProcessManager, ProcessStatus};
@@ -54,37 +52,6 @@ pub struct TrayMenuState {
pub proxy_current: Option<String>,
}
// ===== 伪节点过滤(与前端 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 {
@@ -252,7 +219,7 @@ pub fn precreate_tray_menu_window(app: &AppHandle) {
{
Ok(w) => w,
Err(e) => {
eprintln!("[tray-menu] 预创建菜单窗口失败: {}", e);
crate::logger::log_error("tray", &format!("预创建菜单窗口失败: {}", e));
return;
}
};
@@ -262,7 +229,7 @@ pub fn precreate_tray_menu_window(app: &AppHandle) {
win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
let should_hide = {
let t = LAST_SHOW_TIME.lock().unwrap();
let t = LAST_SHOW_TIME.lock().unwrap_or_else(|e| e.into_inner());
match *t {
Some(time) => time.elapsed() > Duration::from_millis(300),
None => true,
@@ -274,7 +241,7 @@ pub fn precreate_tray_menu_window(app: &AppHandle) {
}
});
eprintln!("[tray-menu] 菜单窗口已预创建(隐藏渲染)");
crate::logger::log_info("tray", "菜单窗口已预创建(隐藏渲染)");
}
/// 右键托盘时调用:计算定位参数、发送状态给前端,但不立即显示窗口。
@@ -289,29 +256,23 @@ pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64), tray_rect: (f64,
let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx as i32, my as i32)
.unwrap_or((0, 0, 1920, 1040));
// 获取光标所在显示器的 DPI,将物理坐标转为逻辑坐标(DIP
// 光标所在显示器的 DPI:菜单宽度按物理像素换算
let dpi = get_dpi_for_point(mx as i32, my as i32).unwrap_or(96);
let scale = dpi as f64 / 96.0;
let menu_w_px = MENU_W * scale;
let mx_l = mx / scale;
let tray_top_l = tray_top / scale;
let wa_left_l = wa_left as f64 / scale;
let wa_right_l = wa_right as f64 / scale;
let wa_top_l = wa_top as f64 / scale;
let wa_bottom_l = wa_bottom as f64 / scale;
// 水平:菜单左边缘对齐鼠标 X(向右延伸),超出右边界则左移(物理坐标)
let x = mx.max(wa_left as f64).min(wa_right as f64 - menu_w_px);
// 水平:菜单左边缘对齐鼠标 X(向右延伸),超出右边界则左移
let x = mx_l.max(wa_left_l).min(wa_right_l - MENU_W);
// 保存布局参数,供 tray_menu_ready 使用
// 保存布局参数(全部物理坐标 + scale,供 tray_menu_ready 换算前端上报的逻辑高度)
{
let mut layout = LAST_MENU_LAYOUT.lock().unwrap();
*layout = Some((x, tray_top_l, wa_top_l, wa_bottom_l, scale));
let mut layout = LAST_MENU_LAYOUT.lock().unwrap_or_else(|e| e.into_inner());
*layout = Some((x, tray_top, wa_top as f64, wa_bottom as f64, scale));
}
// 记录显示时间,用于失焦防抖
{
let mut t = LAST_SHOW_TIME.lock().unwrap();
let mut t = LAST_SHOW_TIME.lock().unwrap_or_else(|e| e.into_inner());
*t = Some(Instant::now());
}
@@ -324,7 +285,7 @@ pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64), tray_rect: (f64,
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
let state = get_tray_menu_state(&app_clone).await;
let _ = app_clone.emit("tray-menu-show", state);
let _ = app_clone.emit(crate::constants::events::TRAY_MENU_SHOW, state);
});
}
@@ -338,7 +299,7 @@ pub fn hide_tray_menu(app: &AppHandle) {
/// 刷新菜单状态并发送给前端
async fn refresh_and_emit_state(app: &AppHandle) {
let state = get_tray_menu_state(app).await;
let _ = app.emit("tray-menu-state-updated", state);
let _ = app.emit(crate::constants::events::TRAY_MENU_STATE_UPDATED, state);
}
// ===== Tauri 命令 =====
@@ -353,13 +314,13 @@ pub async fn tray_menu_action(
match action.as_str() {
"proxy_enable" => {
if let Err(e) = enable_proxy(&app).await {
eprintln!("[tray] 开启代理失败: {}", e);
crate::logger::log_error("tray", &format!("开启代理失败: {}", e));
send_notification(&app, "代理启动失败", &e);
}
}
"proxy_disable" => {
if let Err(e) = disable_proxy(&app).await {
eprintln!("[tray] 关闭代理失败: {}", e);
crate::logger::log_error("tray", &format!("关闭代理失败: {}", e));
send_notification(&app, "代理关闭失败", &e);
}
}
@@ -378,28 +339,28 @@ pub async fn tray_menu_action(
}
}
"osd_toggle" => {
let _ = app.emit("tray:toggle-osd", ());
let _ = app.emit(crate::constants::events::TRAY_TOGGLE_OSD, ());
}
"kernel_restart" => {
if let Err(e) = restart_kernel(&app).await {
eprintln!("[tray] 重启 Kernel 失败: {}", e);
crate::logger::log_error("tray", &format!("重启 Kernel 失败: {}", e));
send_notification(&app, "Kernel 重启失败", &e);
}
}
"download_new" => {
if let Some(window) = app.get_webview_window("main") {
if let Some(window) = app.get_webview_window(crate::constants::windows::MAIN) {
window.show().ok();
window.set_focus().ok();
}
let _ = app.emit("tray:new-download", ());
let _ = app.emit(crate::constants::events::TRAY_NEW_DOWNLOAD, ());
hide_tray_menu(&app);
}
"settings" => {
if let Some(window) = app.get_webview_window("main") {
if let Some(window) = app.get_webview_window(crate::constants::windows::MAIN) {
window.show().ok();
window.set_focus().ok();
}
let _ = app.emit("tray:open-settings", ());
let _ = app.emit(crate::constants::events::TRAY_OPEN_SETTINGS, ());
hide_tray_menu(&app);
}
"quit" => {
@@ -431,24 +392,29 @@ pub async fn tray_menu_ready(content_height: f64, app: AppHandle) -> Result<(),
let win = app.get_webview_window(TRAY_MENU_LABEL)
.ok_or("tray-menu window not found")?;
let (x, tray_top_l, wa_top_l, wa_bottom_l, _scale) = {
let layout = LAST_MENU_LAYOUT.lock().unwrap();
let (x, tray_top, wa_top, wa_bottom, scale) = {
let layout = LAST_MENU_LAYOUT.lock().unwrap_or_else(|e| e.into_inner());
layout.unwrap_or((0.0, 1040.0, 0.0, 1040.0, 1.0))
};
// 将内容高度限制在合理范围内
// 将内容高度限制在合理范围内(前端上报为逻辑像素)
let h = content_height.max(100.0).min(520.0);
// 调整窗口尺寸
let _ = win.set_size(tauri::Size::Logical(tauri::LogicalSize {
width: MENU_W,
height: h,
// 调整窗口尺寸(物理像素,与物理坐标定位保持一致,避免混合 DPI 换算偏移)
let win_w_px = MENU_W * scale;
let win_h_px = h * scale;
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize {
width: win_w_px as u32,
height: win_h_px as u32,
}));
// 垂直:菜单下边缘紧贴托盘图标顶部(向上弹出)
let y = (tray_top_l - h).max(wa_top_l).min(wa_bottom_l - h);
// 垂直:菜单下边缘紧贴托盘图标顶部(向上弹出,物理坐标
let y = (tray_top - win_h_px).max(wa_top).min(wa_bottom - win_h_px);
let pos = tauri::Position::Logical(tauri::LogicalPosition { x, y });
let pos = tauri::Position::Physical(tauri::PhysicalPosition {
x: x as i32,
y: y as i32,
});
let _ = win.set_position(pos);
let _ = win.show();
let _ = win.set_focus();
@@ -605,7 +571,7 @@ async fn select_proxy_node(app: &AppHandle, group: &str, name: &str) {
send_notification(app, "节点已切换", &format!("{}\n延迟: {}", name, delay_text));
}
Err(e) => {
eprintln!("[tray] 切换节点失败: {}", e);
crate::logger::log_error("tray", &format!("切换节点失败: {}", e));
send_notification(app, "切换节点失败", &e);
}
}
@@ -657,34 +623,12 @@ fn send_notification(app: &AppHandle, title: &str, message: &str) {
.show();
}
// ===== 退出清理 =====
// ===== 退出入口 =====
/// 托盘菜单"退出"调用。
/// 资源清理统一收敛到 RunEvent::ExitRequested(覆盖所有退出路径),此处仅请求退出,
/// 避免清理逻辑双份执行(monitor 的 cleanup 涉及子进程/网络,重复执行有竞态风险)。
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();
}
// monitor.cleanup_on_exit 是 async,在 tokio worker 线程中直接 block_on 会 panic
// 放到独立 OS 线程执行 block_on,避免嵌套 runtime。
if let Some(monitor) = app.try_state::<MonitorKernel>() {
let app_clone = app.clone();
let monitor_clone = monitor.inner().clone();
std::thread::spawn(move || {
tauri::async_runtime::block_on(async move {
monitor_clone.cleanup_on_exit(&app_clone).await;
});
})
.join()
.ok();
}
if let Some(clip) = app.try_state::<ClipboardManager>() {
clip.stop();
}
if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all();
}
app.exit(0);
}
@@ -704,7 +648,7 @@ pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> {
..
} => {
// 左键:显示主窗口
if let Some(window) = app.get_webview_window("main") {
if let Some(window) = app.get_webview_window(crate::constants::windows::MAIN) {
window.show().ok();
window.set_focus().ok();
}
+96
View File
@@ -0,0 +1,96 @@
//! Win32 屏幕/光标/DPI 工具 —— 跨模块共享的平台封装。
//!
//! 从 `clipboard::popup` 迁出,供剪贴板弹窗、快速面板、托盘菜单等多窗口模块统一引用,
//! 避免其他模块反向依赖剪贴板模块。
/// 获取鼠标位置(屏幕坐标,物理像素)
#[cfg(windows)]
pub fn get_cursor_pos() -> Option<(i32, i32)> {
use windows_sys::Win32::Foundation::POINT;
use windows_sys::Win32::UI::WindowsAndMessaging::GetCursorPos;
let mut pt = POINT { x: 0, y: 0 };
unsafe {
if GetCursorPos(&mut pt) != 0 {
Some((pt.x, pt.y))
} else {
None
}
}
}
/// 获取主屏工作区尺寸(排除任务栏,物理像素)
#[cfg(windows)]
pub fn get_work_area() -> Option<(f64, f64)> {
use windows_sys::Win32::Foundation::RECT;
use windows_sys::Win32::UI::WindowsAndMessaging::{
SystemParametersInfoW, SPI_GETWORKAREA,
};
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
}
}
}
/// 获取指定点所在显示器的工作区(排除任务栏),返回 (left, top, right, bottom) 物理像素。
/// 使用 MonitorFromPoint 支持多显示器环境。
#[cfg(windows)]
pub fn get_work_area_at_point(x: i32, y: i32) -> Option<(i32, i32, i32, i32)> {
use windows_sys::Win32::Foundation::POINT;
use windows_sys::Win32::Graphics::Gdi::{
GetMonitorInfoW, MonitorFromPoint, MONITORINFO, MONITOR_DEFAULTTONEAREST,
};
let pt = POINT { x, y };
let hmon = unsafe { MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST) };
let mut mi: MONITORINFO = unsafe { std::mem::zeroed() };
mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
unsafe {
if GetMonitorInfoW(hmon, &mut mi) != 0 {
let rc = mi.rcWork;
Some((rc.left, rc.top, rc.right, rc.bottom))
} else {
None
}
}
}
/// 获取指定点所在显示器的有效 DPI。
/// scale factor = dpi / 96。
#[cfg(windows)]
pub fn get_dpi_for_point(x: i32, y: i32) -> Option<u32> {
use windows_sys::Win32::Foundation::POINT;
use windows_sys::Win32::Graphics::Gdi::{MonitorFromPoint, MONITOR_DEFAULTTONEAREST};
use windows_sys::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI};
let pt = POINT { x, y };
let hmon = unsafe { MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST) };
let mut dpi_x: u32 = 0;
let mut dpi_y: u32 = 0;
unsafe {
if GetDpiForMonitor(hmon, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y) == 0 {
Some(dpi_x)
} else {
None
}
}
}
// ===== 非 Windows 平台空实现 =====
#[cfg(not(windows))]
pub fn get_cursor_pos() -> Option<(i32, i32)> { None }
#[cfg(not(windows))]
pub fn get_work_area() -> Option<(f64, f64)> { None }
#[cfg(not(windows))]
pub fn get_work_area_at_point(_x: i32, _y: i32) -> Option<(i32, i32, i32, i32)> { None }
#[cfg(not(windows))]
pub fn get_dpi_for_point(_x: i32, _y: i32) -> Option<u32> { None }
+2 -2
View File
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "thing",
"title": "Thing",
"width": 1000,
"height": 700,
"decorations": false,
@@ -24,7 +24,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self' data:; connect-src ipc: http://ipc.localhost; media-src 'self' data: blob:"
}
},
"bundle": {
+27 -67
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, shallowRef, computed, watch, type Component } from 'vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import TitleBar from '@/components/layout/TitleBar.vue'
import Sidebar from '@/components/layout/Sidebar.vue'
@@ -9,13 +8,18 @@ import ModuleContainer from '@/components/layout/ModuleContainer.vue'
import { Toaster } from '@/components/ui/sonner'
import { useAppStore } from '@/stores/appStore'
import { useScreenshotStore } from '@/stores/screenshotStore'
import { useQuickPanelStore } from '@/stores/quickpanelStore'
import { useMonitorStore } from '@/stores/monitorStore'
import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload, pendingOpenSettings } from '@/lib/trayEvents'
import { pendingNewDownload } from '@/lib/trayEvents'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
const appStore = useAppStore()
const screenshotStore = useScreenshotStore()
const quickpanelStore = useQuickPanelStore()
const monitorStore = useMonitorStore()
/** 侧边栏 / 标题栏需要的模块信息(id + name + icon */
interface NavModule {
@@ -32,19 +36,11 @@ const toNavModule = (meta: ModuleMeta): NavModule => ({
})
/** 上次激活的模块 IDlocalStorage 持久化) */
const LAST_MODULE_KEY = 'thing_last_module'
const LAST_MODULE_KEY = STORAGE_KEYS.lastModule
const activeModule = ref('')
const activeComponent = shallowRef<Component | null>(null)
/** 预加载的监控模块组件(用于隐藏预渲染,确保 OSD 在启动时创建) */
const monitorComponent = shallowRef<Component | null>(null)
/** 监控模块是否已启用 */
const monitorEnabled = computed(() =>
appStore.modules.find(m => m.id === 'monitor')?.enabled ?? false
)
/** 当前可显示的模块(内置模块 + 已启用的用户模块),按用户排序显示 */
const availableModules = computed<NavModule[]>(() => {
const enabledIds = appStore.enabledModules.map(m => m.id)
@@ -65,8 +61,13 @@ const availableModules = computed<NavModule[]>(() => {
})
})
/** 模块加载请求序号:快速切换模块时丢弃过期加载结果,避免旧组件覆盖新组件 */
let moduleLoadSeq = 0
const loadModule = async (moduleId: string) => {
const seq = ++moduleLoadSeq
const component = await moduleRegistry.loadComponent(moduleId)
// activeComponent
if (seq !== moduleLoadSeq) return
activeComponent.value = component
// onActivate
@@ -108,47 +109,9 @@ watch(() => appStore.enabledModules.length, () => {
loadModule(fallback)
}
// /
syncQuickPanelCommands()
quickpanelStore.syncCommands()
})
/** 同步快速面板命令缓存到 localStorage(供独立窗口读取) */
function syncQuickPanelCommands() {
const enabledIds = appStore.enabledModules.map(m => m.id)
const all = moduleRegistry.getAllSearchItems()
const commands: Array<{
moduleId: string
moduleName: string
title: string
description?: string
keywords: string[]
}> = []
for (const { moduleId, items } of all) {
const config = moduleRegistry.getConfig(moduleId)
//
if (!config?.builtin && !enabledIds.includes(moduleId)) continue
for (const item of items) {
commands.push({
moduleId,
moduleName: config?.name ?? moduleId,
title: item.title,
description: item.description,
keywords: item.keywords,
})
}
}
localStorage.setItem('thing_quickpanel_commands', JSON.stringify(commands))
}
/** 同步快速面板设置到 localStorage(供独立窗口的 web provider 读取搜索引擎) */
async function syncQuickPanelSettings() {
try {
const s = await invoke<{ shortcut: string; popupPosition: string; searchEngine: string; indexDirs: string[]; customCommands: unknown[] }>('quickpanel_get_settings')
localStorage.setItem('thing_quickpanel_settings', JSON.stringify(s))
} catch (e) {
console.error('[quickpanel] 同步设置失败:', e)
}
}
/** 计算启动时应打开的默认模块:优先上次记忆,其次排序第一个 */
const resolveDefaultModule = (): string => {
const enabledIds = appStore.enabledModules.map(m => m.id)
@@ -171,20 +134,21 @@ const resolveDefaultModule = (): string => {
onMounted(async () => {
await appStore.init().catch(e => console.error('App init error:', e))
//
// 使MonitorModule onMounted
// OSD OSD
if (monitorEnabled.value) {
monitorComponent.value = await moduleRegistry.loadComponent('monitor')
}
// store monitor-data / monitor-network
// 使 OSD
// init() MonitorModule
monitorStore.init()
// OSD
// 使 OSD /
monitorStore.initOsd()
const defaultModule = resolveDefaultModule()
activeModule.value = defaultModule
loadModule(defaultModule)
// localStorage
syncQuickPanelCommands()
syncQuickPanelSettings()
quickpanelStore.syncCommands()
quickpanelStore.syncSettings()
// +
trayUnlisteners.push(
await listen<{ moduleId: string }>('quickpanel-execute-command', async (e) => {
@@ -201,9 +165,9 @@ onMounted(async () => {
)
//
// tray:toggle-osd MonitorModule
// tray:toggle-osd monitorStore.initOsd()
trayUnlisteners.push(
await listen('tray:new-download', () => {
await listen(EVENTS.trayNewDownload, () => {
// DownloaderModule
pendingNewDownload.value = true
//
@@ -214,8 +178,7 @@ onMounted(async () => {
})
)
trayUnlisteners.push(
await listen('tray:open-settings', () => {
pendingOpenSettings.value = true
await listen(EVENTS.trayOpenSettings, () => {
handleModuleChange('settings')
})
)
@@ -228,6 +191,8 @@ const trayUnlisteners: UnlistenFn[] = []
onUnmounted(() => {
trayUnlisteners.forEach(fn => fn())
screenshotStore.destroyExportListener()
// OSD watcher
monitorStore.disposeOsd()
})
</script>
@@ -245,10 +210,5 @@ onUnmounted(() => {
</div>
</div>
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
<!-- 预渲染监控模块隐藏确保 OSD 窗口在应用启动时创建不依赖用户切换到监控模块
activeModule === 'monitor' 时不渲染 ModuleContainer 正常渲染避免重复实例 -->
<div v-if="monitorComponent && monitorEnabled && activeModule !== 'monitor'" style="display:none">
<component :is="monitorComponent" />
</div>
</TooltipProvider>
</template>
+14 -5
View File
@@ -6,6 +6,8 @@ import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useSearchStore, type SearchItem } from '@/stores/searchStore'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
// /
import { getTextForms, bestScore } from '@/modules/quickpanel/engine'
const tabsStore = useModuleTabsStore()
@@ -23,10 +25,12 @@ const searchStore = useSearchStore()
const filteredModules = computed(() => {
if (!searchQuery.value.trim()) return []
const query = searchQuery.value.toLowerCase()
return props.modules.filter(m =>
m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query)
)
const query = searchQuery.value.trim()
return props.modules
.map(m => ({ m, score: Math.max(bestScore(query, getTextForms(m.name)), bestScore(query, getTextForms(m.id))) }))
.filter(e => e.score > 0)
.sort((a, b) => b.score - a.score)
.map(e => e.m)
})
const searchResults = computed(() => {
@@ -68,6 +72,7 @@ const minimize = async () => {
// /
const isMaximized = ref(false)
let unlistenMaximize: (() => void) | null = null
let unlistenFocus: (() => void) | null = null
const maximize = async () => {
await tauriWindow?.toggleMaximize()
@@ -118,7 +123,8 @@ const close = async () => {
}
if (tauriWindow) {
tauriWindow.onFocusChanged(({ payload: focused }) => {
// unlistenonUnmounted onFocusChanged Promise<UnlistenFn>
void tauriWindow.onFocusChanged(({ payload: focused }) => {
if (focused) {
hoverSuppressed.value = true
if (document.activeElement instanceof HTMLElement) {
@@ -131,6 +137,8 @@ if (tauriWindow) {
} else {
hoverSuppressed.value = true
}
}).then(fn => {
unlistenFocus = fn
})
}
@@ -202,6 +210,7 @@ onUnmounted(() => {
window.removeEventListener('mousemove', handleFirstMouseMove)
if (restoreHoverTimer) clearTimeout(restoreHoverTimer)
if (unlistenMaximize) unlistenMaximize()
if (unlistenFocus) unlistenFocus()
if (scrollViewport) scrollViewport.removeEventListener('scroll', handleMainScroll)
if (rafId !== null) cancelAnimationFrame(rafId)
})
+484
View File
@@ -0,0 +1,484 @@
// This file has been generated by Tauri Specta. Do not edit this file manually.
import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core";
/** Commands */
export const commands = {
proxyActivateProfile: (id: string) => __TAURI_INVOKE<null>("proxy_activate_profile", { id }),
proxyCheckKernelUpdate: () => __TAURI_INVOKE<KernelUpdateInfo>("proxy_check_kernel_update"),
proxyClearSystemProxy: () => __TAURI_INVOKE<null>("proxy_clear_system_proxy"),
proxyCloseConnection: (id: string) => __TAURI_INVOKE<null>("proxy_close_connection", { id }),
proxyDeleteProfile: (id: string) => __TAURI_INVOKE<null>("proxy_delete_profile", { id }),
proxyGetSettings: () => __TAURI_INVOKE<ProxySettings>("proxy_get_settings"),
proxyGetSystemProxy: () => __TAURI_INVOKE<boolean>("proxy_get_system_proxy"),
proxyImportProfile: (url: string, name: string) => __TAURI_INVOKE<ProfileMeta>("proxy_import_profile", { url, name }),
/** 首次安装内核(与 update_kernel 共用 install_kernel 实现,语义独立便于前端区分场景) */
proxyInstallKernel: (mirrorPrefix: string | null) => __TAURI_INVOKE<KernelInfo>("proxy_install_kernel", { mirrorPrefix }),
proxyKernelInfo: () => __TAURI_INVOKE<KernelInfo>("proxy_kernel_info"),
proxyRestart: () => __TAURI_INVOKE<ProcessInfo>("proxy_restart"),
proxySaveSettings: (settings: ProxySettings) => __TAURI_INVOKE<null>("proxy_save_settings", { settings }),
proxySelectProxy: (group: string, name: string) => __TAURI_INVOKE<null>("proxy_select_proxy", { group, name }),
proxySetSystemProxy: () => __TAURI_INVOKE<null>("proxy_set_system_proxy"),
proxyStart: () => __TAURI_INVOKE<ProcessInfo>("proxy_start"),
proxyStatus: () => __TAURI_INVOKE<ProxyStatus>("proxy_status"),
proxyStop: () => __TAURI_INVOKE<null>("proxy_stop"),
proxyTestDelay: (name: string, url: string | null, timeout: number | null) => __TAURI_INVOKE<number>("proxy_test_delay", { name, url, timeout }),
proxyUpdateKernel: (mirrorPrefix: string | null) => __TAURI_INVOKE<KernelInfo>("proxy_update_kernel", { mirrorPrefix }),
proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }),
/** 读取快速面板设置(快捷键等) */
quickpanelGetSettings: () => __TAURI_INVOKE<QuickPanelSettings>("quickpanel_get_settings"),
/** 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口 */
quickpanelSaveSettings: (settings: QuickPanelSettings) => __TAURI_INVOKE<null>("quickpanel_save_settings", { settings }),
/** 注册(或切换)快速面板全局快捷键 */
quickpanelRegisterShortcut: (shortcut: string) => __TAURI_INVOKE<null>("quickpanel_register_shortcut", { shortcut }),
/** 注销快速面板全局快捷键 */
quickpanelUnregisterShortcut: () => __TAURI_INVOKE<null>("quickpanel_unregister_shortcut"),
/** 手动触发显示快速面板(供 UI 按钮调用) */
quickpanelShowPopup: () => __TAURI_INVOKE<null>("quickpanel_show_popup"),
/** 隐藏快速面板 */
quickpanelHidePopup: () => __TAURI_INVOKE<null>("quickpanel_hide_popup"),
/** 显示已创建的弹窗窗口(前端 onMounted 后调用) */
quickpanelShowWindow: () => __TAURI_INVOKE<null>("quickpanel_show_window"),
/** 锁定屏幕(Windows: rundll32 user32.dll,LockWorkStationCREATE_NO_WINDOW 避免黑窗) */
quickpanelLockScreen: () => __TAURI_INVOKE<null>("quickpanel_lock_screen"),
/** 初始化文件索引数据库(应用启动时调用) */
quickpanelInitFileIndex: () => __TAURI_INVOKE<null>("quickpanel_init_file_index"),
/** 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用) */
quickpanelBuildFileIndex: () => __TAURI_INVOKE<number>("quickpanel_build_file_index"),
/** 搜索文件索引(SQLite 查询移出主线程) */
quickpanelSearchFiles: (query: string, limit: number | null) => __TAURI_INVOKE<FileRecord[]>("quickpanel_search_files", { query, limit }),
/** 获取索引状态 */
quickpanelFileIndexStats: () => __TAURI_INVOKE<IndexStats>("quickpanel_file_index_stats"),
/** 扫描已安装应用(遍历开始菜单/桌面/磁盘,移出主线程) */
quickpanelScanApps: () => __TAURI_INVOKE<AppRecord[]>("quickpanel_scan_apps"),
/**
* data URL/ Windows API
*
* SHGetFileInfoW + + 线
*/
quickpanelGetAppIcon: (path: string) => __TAURI_INVOKE<string | null>("quickpanel_get_app_icon", { path }),
/** 清理图标缓存(磁盘 + 内存) */
quickpanelClearAppIconCache: () => __TAURI_INVOKE<null>("quickpanel_clear_app_icon_cache"),
/** 在资源管理器中显示文件(选中) */
quickpanelRevealInExplorer: (path: string) => __TAURI_INVOKE<null>("quickpanel_reveal_in_explorer", { path }),
/**
* /
* - explorer.exe
* - ShellExecuteW open fallback verb: openas
*/
quickpanelOpenFile: (path: string) => __TAURI_INVOKE<null>("quickpanel_open_file", { path }),
/** 获取 Windows 常用快捷位置(hosts、回收站、此电脑、用户目录、系统管理工具等) */
quickpanelGetSpecialLocations: () => __TAURI_INVOKE<SpecialLocation[]>("quickpanel_get_special_locations"),
/** 打开快捷位置(kind: file | shell | cmd */
quickpanelOpenSpecial: (kind: string, target: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_open_special", { kind, target, args }),
/** 删除文件(移到回收站,PowerShell 阻塞等待移出主线程) */
quickpanelDeleteFile: (path: string) => __TAURI_INVOKE<null>("quickpanel_delete_file", { path }),
/**
* +
* .lnk spawnos error 193 cmd /C
*/
quickpanelRunCustomCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_custom_command", { command, args }),
/**
* CREATE_NO_WINDOW使 cmd/powershell/regedit
* regeditshutdowncmdpowershelltaskmgr
*/
quickpanelRunSystemCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_system_command", { command, args }),
clipboardGetHistory: (limit: number | null, offset: number | null, kind: string | null) => __TAURI_INVOKE<HistoryPage>("clipboard_get_history", { limit, offset, kind }),
clipboardGetPinned: () => __TAURI_INVOKE<ClipboardItem[]>("clipboard_get_pinned"),
clipboardSearch: (query: string, limit: number | null, offset: number | null) => __TAURI_INVOKE<HistoryPage>("clipboard_search", { query, limit, offset }),
clipboardGetItem: (id: number) => __TAURI_INVOKE<({
/** 文本内容 / 文件列表 JSON */
content: string | null,
/** 图片 PNG base64(仅 image 类型) */
imageBase64: string | null,
}) & (ClipboardItem) | null>("clipboard_get_item", { id }),
clipboardSetPinned: (id: number, pinned: boolean) => __TAURI_INVOKE<boolean>("clipboard_set_pinned", { id, pinned }),
clipboardDelete: (id: number) => __TAURI_INVOKE<boolean>("clipboard_delete", { id }),
clipboardClear: () => __TAURI_INVOKE<boolean>("clipboard_clear"),
clipboardCopyBack: (id: number) => __TAURI_INVOKE<null>("clipboard_copy_back", { id }),
clipboardCount: () => __TAURI_INVOKE<number>("clipboard_count"),
clipboardGetSettings: () => __TAURI_INVOKE<ClipboardSettings>("clipboard_get_settings"),
clipboardSaveSettings: (settings: ClipboardSettings) => __TAURI_INVOKE<null>("clipboard_save_settings", { settings }),
clipboardStatus: () => __TAURI_INVOKE<ClipboardStatus>("clipboard_status"),
clipboardStart: () => __TAURI_INVOKE<null>("clipboard_start"),
clipboardStop: () => __TAURI_INVOKE<null>("clipboard_stop"),
/** 注册(或切换)快捷弹窗全局快捷键 */
clipboardRegisterShortcut: (shortcut: string) => __TAURI_INVOKE<null>("clipboard_register_shortcut", { shortcut }),
/** 注销快捷弹窗全局快捷键 */
clipboardUnregisterShortcut: () => __TAURI_INVOKE<null>("clipboard_unregister_shortcut"),
/** 手动触发显示快捷弹窗(供 UI 按钮调用) */
clipboardShowPopup: () => __TAURI_INVOKE<null>("clipboard_show_popup"),
/** 隐藏快捷弹窗 */
clipboardHidePopup: () => __TAURI_INVOKE<null>("clipboard_hide_popup"),
/** 显示已创建的弹窗窗口(前端 onMounted 后调用) */
clipboardShowWindow: () => __TAURI_INVOKE<null>("clipboard_show_window"),
/** 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口 */
clipboardPasteToTarget: () => __TAURI_INVOKE<null>("clipboard_paste_to_target"),
/** 获取所有任务 */
downloaderGetTasks: () => __TAURI_INVOKE<DownloadTask[]>("downloader_get_tasks"),
/** 检查 URL 重复性并探测文件信息(添加下载前调用) */
downloaderCheckUrl: (url: string, dir: string | null, headers: { [key in string]: string } | null) => __TAURI_INVOKE<CheckUrlResult>("downloader_check_url", { url, dir, headers }),
/** 添加下载任务 */
downloaderAddTask: (url: string, filename: string | null, dir: string | null, headers: { [key in string]: string } | null, autoRename: boolean | null) => __TAURI_INVOKE<string>("downloader_add_task", { url, filename, dir, headers, autoRename }),
/** 暂停任务 */
downloaderPauseTask: (id: string) => __TAURI_INVOKE<null>("downloader_pause_task", { id }),
/** 恢复任务 */
downloaderResumeTask: (id: string) => __TAURI_INVOKE<null>("downloader_resume_task", { id }),
/** 移除任务 */
downloaderRemoveTask: (id: string, deleteFiles: boolean | null) => __TAURI_INVOKE<null>("downloader_remove_task", { id, deleteFiles }),
/** 获取设置 */
downloaderGetSettings: () => __TAURI_INVOKE<DownloaderSettings>("downloader_get_settings"),
/** 保存设置 */
downloaderSaveSettings: (settings: DownloaderSettings) => __TAURI_INVOKE<null>("downloader_save_settings", { settings }),
/** 用系统资源管理器打开目录 */
downloaderOpenDir: (path: string) => __TAURI_INVOKE<null>("downloader_open_dir", { path }),
/** 用系统默认浏览器打开 URL */
downloaderOpenUrl: (url: string) => __TAURI_INVOKE<null>("downloader_open_url", { url }),
/** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
/** 注册(或切换)截图全局快捷键。传入空字符串则禁用快捷键。 */
screenshotRegisterShortcut: (shortcut: string) => __TAURI_INVOKE<null>("screenshot_register_shortcut", { shortcut }),
/** 注销截图全局快捷键 */
screenshotUnregisterShortcut: () => __TAURI_INVOKE<null>("screenshot_unregister_shortcut"),
/** 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码 */
screenshotCaptureFullscreen: () => __TAURI_INVOKE<null>("screenshot_capture_fullscreen"),
/** 全屏捕获编码为 PNG base64 并清除(全屏截图直接进编辑器) */
screenshotFullscreenPng: () => __TAURI_INVOKE<CaptureData>("screenshot_fullscreen_png"),
/** 清除静态全屏捕获(覆盖层关闭/取消时释放内存) */
screenshotClearFullscreen: () => __TAURI_INVOKE<null>("screenshot_clear_fullscreen"),
/** 按物理像素坐标裁剪已存储的全屏捕获 */
screenshotCropStored: (x: number, y: number, w: number, h: number) => __TAURI_INVOKE<CaptureData>("screenshot_crop_stored", { x, y, w, h }),
/** 裁剪已存储的全屏捕获并直接写入剪贴板(一次 IPC 完成"裁剪+复制" */
screenshotCropCopyStored: (x: number, y: number, w: number, h: number) => __TAURI_INVOKE<CaptureData>("screenshot_crop_copy_stored", { x, y, w, h }),
/** 拾取指定物理屏幕坐标下的顶层窗口 */
screenshotWindowFromPoint: (x: number, y: number) => __TAURI_INVOKE<{
hwnd: number,
title: string,
rect: ScreenRect,
/** DWM 扩展边框矩形(视觉边界,去掉最大化窗口的隐形缩放边框),命中测试用 rect,高亮用 visual_rect */
visualRect: ScreenRect | null,
} | null>("screenshot_window_from_point", { x, y }),
/** 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口) */
screenshotCursorPos: () => __TAURI_INVOKE<[number, number]>("screenshot_cursor_pos"),
/** 枚举所有可见顶层窗口 */
screenshotEnumWindows: () => __TAURI_INVOKE<WindowInfo[]>("screenshot_enum_windows"),
/** 按 hwnd 捕获指定窗口 */
screenshotCaptureWindow: (hwnd: number) => __TAURI_INVOKE<CaptureData>("screenshot_capture_window", { hwnd }),
/** 存入编辑器图片(base64 PNG */
screenshotSetEditorImage: (pngBase64: string) => __TAURI_INVOKE<null>("screenshot_set_editor_image", { pngBase64 }),
/** 取出编辑器图片(编辑器窗口加载时调用,取出即清除) */
screenshotGetEditorImage: () => __TAURI_INVOKE<string | null>("screenshot_get_editor_image"),
/** 将 PNG base64 写入系统剪贴板(转 CF_DIB */
screenshotCopyImage: (pngBase64: string) => __TAURI_INVOKE<null>("screenshot_copy_image", { pngBase64 }),
/** 将 PNG base64 写入文件 */
screenshotSavePng: (pngBase64: string, path: string) => __TAURI_INVOKE<null>("screenshot_save_png", { pngBase64, path }),
/** 将完整 PNG 写入历史缓存目录,返回文件路径 */
screenshotSaveCache: (pngBase64: string) => __TAURI_INVOKE<string>("screenshot_save_cache", { pngBase64 }),
/** 从历史缓存目录读取 PNG 并返回 base64(点击历史项复制/保存时一次性加载,不常驻内存) */
screenshotLoadCache: (path: string) => __TAURI_INVOKE<string>("screenshot_load_cache", { path }),
/** 删除历史缓存文件(历史项移除/清空时调用,静默忽略不存在文件) */
screenshotDeleteCache: (path: string) => __TAURI_INVOKE<null>("screenshot_delete_cache", { path }),
};
/* Types */
export type AppRecord = {
name: string,
path: string,
};
/** 前端可见的捕获数据 */
export type CaptureData = {
pngBase64: string,
width: number,
height: number,
};
/** check_url 命令返回的结果 */
export type CheckUrlResult = {
/** 探测是否成功 */
ok: boolean,
/** 错误信息(探测失败时) */
error: string | null,
/** 文件名(探测成功时) */
filename: string | null,
/** 文件大小(字节) */
totalSize: number | null,
/** 是否支持断点续传 */
supportsResume: boolean,
/** 重复类型 */
duplicate: DuplicateKind,
/** 已存在的任务信息 */
existing: ExistingTaskInfo | null,
};
/** 列表项(不含大字段,用于历史/搜索结果) */
export type ClipboardItem = {
id: number,
kind: string,
preview: string,
size: number,
pinned: boolean,
pinnedOrder: number | null,
createdAt: number,
};
/** 详情(含文本内容或图片 base64) */
export type ClipboardItemDetail = {
/** 文本内容 / 文件列表 JSON */
content: string | null,
/** 图片 PNG base64(仅 image 类型) */
imageBase64: string | null,
} & ClipboardItem;
/** 剪贴板设置(持久化到 clipboard/settings.json */
export type ClipboardSettings = {
/** 监听是否启用 */
enabled?: boolean,
/** 非固定历史最大条数 */
maxItems?: number,
/** 图片大小上限(KB),0 表示不限 */
maxImageKb?: number,
recordText?: boolean,
recordImage?: boolean,
recordFiles?: boolean,
/** 去重(相同内容更新时间而非新增) */
dedup?: boolean,
/** 快捷弹窗全局快捷键(如 "Alt+V",空字符串表示禁用) */
shortcut?: string,
};
export type ClipboardStatus = {
running: boolean,
count: number,
};
/** 自定义命令 */
export type CustomCommand = {
id: string,
title: string,
command: string,
args?: string[],
};
/** 下载任务 */
export type DownloadTask = {
/** 任务 ID(自增 hex 字符串) */
id: string,
/** 下载地址 */
url: string,
/** 文件名 */
filename: string,
/** 保存目录(绝对路径) */
dir: string,
/** 状态 */
status: TaskStatus,
/** 文件总大小(字节),0=未知 */
totalSize: number,
/** 已下载字节 */
completedSize: number,
/** 当前下载速度 bytes/s */
speed: number,
/** 服务器是否支持断点续传 */
supportsResume: boolean,
/** 分段信息 */
segments?: Segment[],
/** 错误信息 */
error?: string | null,
/** 创建时间(Unix 时间戳,毫秒) */
createdAt: number,
/** 自定义请求头(Cookie / Referer 等) */
headers?: { [key in string]: string },
};
/** 下载设置 */
export type DownloaderSettings = {
/** 下载目录 */
downloadDir?: string,
/** 最大同时下载数 */
maxConcurrent?: number,
/** 单任务最大连接数(多线程分段数) */
maxConnections?: number,
/** 断点续传 */
continueDownload?: boolean,
/** 全局速度限制 KB/s0=不限) */
globalSpeedLimit?: number,
/** 扩展 HTTP API 端口 */
extensionPort?: number,
/** 扩展认证密钥(空=不认证) */
extensionSecret?: string,
/** 删除任务时是否同时删除已下载的文件 */
deleteFilesOnRemove?: boolean,
/** 添加下载前检查重复(URL 或文件名重复时询问) */
checkDuplicate?: boolean,
};
/** 重复类型 */
export type DuplicateKind =
/** 无重复 */
"none" |
/** URL 重复(已有相同链接的任务) */
"url" |
/** 文件名重复(已有同名任务下载到同一目录) */
"filename" |
/** 磁盘文件已存在 */
"fileExists";
/** 已存在的任务信息(用于前端展示) */
export type ExistingTaskInfo = {
id: string,
filename: string,
status: TaskStatus,
};
/** 单个文件记录(返回给前端) */
export type FileRecord = {
path: string,
name: string,
ext: string,
size: number,
isDir: boolean,
};
/** 历史查询结果(含总数,用于分页) */
export type HistoryPage = {
items: ClipboardItem[],
total: number,
};
/** 索引状态(返回给前端) */
export type IndexStats = {
total: number,
lastBuiltAt: number,
lastBuiltDirs: string[],
};
export type KernelInfo = {
path: string,
exists: boolean,
version: string | null,
};
export type KernelUpdateInfo = {
currentVersion: string | null,
latestVersion: string,
downloadUrl: string,
hasUpdate: boolean,
};
/** 进程信息(返回给前端) */
export type ProcessInfo = {
id: string,
name: string,
status: ProcessStatus,
pid: number | null,
restartCount: number,
};
/** 进程状态枚举 */
export type ProcessStatus = "running" | "stopped" | "crashed" | "starting";
export type ProfileMeta = {
id?: string,
name?: string,
url?: string,
addedAt?: string,
updatedAt?: string,
size?: number | null,
};
export type ProxySettings = {
mixedPort?: number,
externalController?: string,
secret?: string,
mode?: string,
logLevel?: string,
allowLan?: boolean,
systemProxy?: boolean,
autoStart?: boolean,
autoSystemProxy?: boolean,
currentProfile?: string | null,
profiles?: ProfileMeta[],
autoSwitchEnabled?: boolean,
autoSwitchInterval?: number,
autoSwitchGroup?: string,
autoSwitchRegion?: string,
/**
* GitHub URL
* = GitHub
*/
kernelMirrors?: string[],
};
export type ProxyStatus = {
running: boolean,
pid: number | null,
restartCount: number,
};
/** 快速面板设置 */
export type QuickPanelSettings = {
/** 全局快捷键(如 "Alt+Space"),空字符串表示不注册。 */
shortcut?: string,
/** 唤起位置:center(鼠标所在显示器中央)| cursor(鼠标位置) */
popupPosition?: string,
/** 默认搜索引擎:google | bing | baidu */
searchEngine?: string,
/** 文件索引目录列表(空列表表示使用默认:桌面/文档/下载) */
indexDirs?: string[],
/** 自定义命令列表 */
customCommands?: CustomCommand[],
};
export type ScreenRect = {
x: number,
y: number,
width: number,
height: number,
};
/** 下载分段(多线程 Range 下载 / 断点续传用) */
export type Segment = {
/** 分段索引 */
index: number,
/** 起始字节(含) */
start: number,
/** 结束字节(含) */
end: number,
/** 已下载字节 */
completed: number,
};
/** 快捷位置条目 */
export type SpecialLocation = {
id: string,
title: string,
subtitle: string,
keywords: string[],
/** file: 真实文件/文件夹路径;shell: explorer 打开的 shell 路径;cmd: 可执行命令 */
kind: string,
target: string,
args: string[],
};
/** 任务状态 */
export type TaskStatus =
/** 排队等待(并发数已满) */
"queued" |
/** 下载中 */
"active" |
/** 已暂停 */
"paused" |
/** 已完成 */
"complete" |
/** 错误 */
"error";
/** 窗口信息(窗口拾取 / 枚举) */
export type WindowInfo = {
hwnd: number,
title: string,
rect: ScreenRect,
/** DWM 扩展边框矩形(视觉边界,去掉最大化窗口的隐形缩放边框),命中测试用 rect,高亮用 visual_rect */
visualRect: ScreenRect | null,
};
+57
View File
@@ -0,0 +1,57 @@
/**
* Node test runner
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { evaluateExpression } from './calc.ts'
test('四则运算与优先级', () => {
assert.equal(evaluateExpression('1+2*3'), 7)
assert.equal(evaluateExpression('2*(3+4)'), 14)
assert.equal(evaluateExpression('10-2-3'), 5)
})
test('除法与取模', () => {
assert.equal(evaluateExpression('10/4'), 2.5)
assert.equal(evaluateExpression('10%3'), 1)
})
test('小数与边界写法', () => {
assert.equal(evaluateExpression('0.1+0.2'), 0.30000000000000004)
assert.equal(evaluateExpression('.5+.5'), 1)
assert.equal(evaluateExpression('5.'), 5)
})
test('一元正负号', () => {
assert.equal(evaluateExpression('-5+3'), -2)
assert.equal(evaluateExpression('-(2+3)'), -5)
assert.equal(evaluateExpression('2*-3'), -6)
assert.equal(evaluateExpression('+5'), 5)
assert.equal(evaluateExpression('--5'), 5)
})
test('括号嵌套', () => {
assert.equal(evaluateExpression('(1+2)*(3+4)'), 21)
assert.equal(evaluateExpression('((1+2))'), 3)
})
test('空白容忍', () => {
assert.equal(evaluateExpression(' 1 + 2 '), 3)
assert.equal(evaluateExpression(' '), null)
})
test('非法输入返回 null', () => {
assert.equal(evaluateExpression(''), null)
assert.equal(evaluateExpression('abc'), null)
assert.equal(evaluateExpression('1/'), null)
assert.equal(evaluateExpression('((1+2)'), null)
assert.equal(evaluateExpression('1+2)'), null)
assert.equal(evaluateExpression('1 2'), null)
assert.equal(evaluateExpression('%3'), null)
assert.equal(evaluateExpression('1.2.3'), null)
})
test('非有限结果返回 null(除零)', () => {
assert.equal(evaluateExpression('1/0'), null)
assert.equal(evaluateExpression('5%0'), null)
})
+136
View File
@@ -0,0 +1,136 @@
/**
* CSP Function/eval
* + - * / %
* null
*
* Function `1++2` / `1--2`
* `1++2` 3
*/
type Token =
| { kind: 'num'; value: number }
| { kind: 'op'; value: string }
| { kind: 'end' }
/** 数字 token`12.5` / `12.` / `.5` */
const NUM_RE = /^\d+(\.\d*)?|^\.\d+/
function tokenize(input: string): Token[] | null {
const tokens: Token[] = []
let i = 0
while (i < input.length) {
const ch = input[i]
if (/\s/.test(ch)) {
i++
continue
}
if (/[0-9.]/.test(ch)) {
const m = NUM_RE.exec(input.slice(i))
if (!m) return null
const value = Number(m[0])
if (!Number.isFinite(value)) return null
tokens.push({ kind: 'num', value })
i += m[0].length
continue
}
if ('+-*/%()'.includes(ch)) {
tokens.push({ kind: 'op', value: ch })
i++
continue
}
return null
}
tokens.push({ kind: 'end' })
return tokens
}
/** 递归下降解析器:expr → term → factor(支持优先级与括号) */
class Parser {
private pos = 0
private tokens: Token[]
constructor(tokens: Token[]) {
this.tokens = tokens
}
/** 完整解析:要求消费全部 token 且成功 */
parse(): number | null {
const v = this.parseExpr()
if (v === null) return null
if (this.peek().kind !== 'end') return null
return v
}
private peek(): Token {
return this.tokens[this.pos]
}
private next(): Token {
return this.tokens[this.pos++]
}
/** expr := term (('+' | '-') term)* */
private parseExpr(): number | null {
let left = this.parseTerm()
if (left === null) return null
while (true) {
const tok = this.peek()
if (tok.kind !== 'op' || (tok.value !== '+' && tok.value !== '-')) break
this.next()
const right = this.parseTerm()
if (right === null) return null
left = tok.value === '+' ? left + right : left - right
}
return left
}
/** term := factor (('*' | '/' | '%') factor)* */
private parseTerm(): number | null {
let left = this.parseFactor()
if (left === null) return null
while (true) {
const tok = this.peek()
if (tok.kind !== 'op' || (tok.value !== '*' && tok.value !== '/' && tok.value !== '%')) {
break
}
this.next()
const right = this.parseFactor()
if (right === null) return null
left = tok.value === '*' ? left * right : tok.value === '/' ? left / right : left % right
}
return left
}
/** factor := ('+' | '-') factor | '(' expr ')' | number */
private parseFactor(): number | null {
const tok = this.peek()
if (tok.kind === 'op' && (tok.value === '+' || tok.value === '-')) {
this.next()
const v = this.parseFactor()
if (v === null) return null
return tok.value === '-' ? -v : v
}
if (tok.kind === 'num') {
this.next()
return tok.value
}
if (tok.kind === 'op' && tok.value === '(') {
this.next()
const v = this.parseExpr()
if (v === null) return null
const close = this.next()
if (close.kind !== 'op' || close.value !== ')') return null
return v
}
return null
}
}
/** 求值表达式,非法输入或结果为非有限值返回 null */
export function evaluateExpression(input: string): number | null {
const tokens = tokenize(input)
if (!tokens) return null
const result = new Parser(tokens).parse()
if (result === null || !Number.isFinite(result)) return null
return result
}
+65
View File
@@ -0,0 +1,65 @@
/**
*
* label / Tauri / localStorage
* Rust `src-tauri/src/constants.rs`
*/
/** 窗口 label(对应 Rust constants::windows 与 capabilities/*.json */
export const WINDOWS = {
main: 'main',
osdOverlay: 'osd-overlay',
screenshotOverlay: 'screenshot-overlay',
} as const
/** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */
export const EVENTS = {
// 托盘菜单
trayMenuShow: 'tray-menu-show',
trayMenuStateUpdated: 'tray-menu-state-updated',
trayToggleOsd: 'tray:toggle-osd',
trayNewDownload: 'tray:new-download',
trayOpenSettings: 'tray:open-settings',
// 剪贴板
clipboardChanged: 'clipboard-changed',
clipboardPopupShow: 'clipboard-popup-show',
clipboardPopupHide: 'clipboard-popup-hide',
// 快速面板
quickpanelShow: 'quickpanel-show',
quickpanelHide: 'quickpanel-hide',
quickpanelExecuteCommand: 'quickpanel-execute-command',
// 截图
screenshotBegin: 'screenshot-begin',
screenshotOverlayReady: 'screenshot-overlay-ready',
screenshotShortcut: 'screenshot-shortcut',
screenshotExported: 'screenshot-exported',
// 内核安装进度
kernelInstallProgress: 'kernel-install-progress',
// 监控 OSD
osdStateUpdate: 'osd-state-update',
osdContentSize: 'osd-content-size',
osdSystemUiActive: 'osd-system-ui-active',
osdSystemUiInactive: 'osd-system-ui-inactive',
osdStartDrag: 'osd-start-drag',
osdEndDrag: 'osd-end-drag',
monitorReady: 'monitor-ready',
monitorLoading: 'monitor-loading',
monitorDisconnected: 'monitor-disconnected',
monitorError: 'monitor-error',
monitorData: 'monitor-data',
monitorNetwork: 'monitor-network',
// 其他
processStatusChanged: 'process-status-changed',
downloadAdded: 'download-added',
} as const
/** localStorage 存储键 */
export const STORAGE_KEYS = {
appSettings: 'thing_app_settings',
lastModule: 'thing_last_module',
quickpanelCommands: 'thing_quickpanel_commands',
quickpanelSettings: 'thing_quickpanel_settings',
quickpanelHistory: 'thing_quickpanel_history',
quickpanelHistoryItems: 'thing_quickpanel_history_items',
currencyRates: 'thing_quickpanel_currency_rates',
monitorOsdConfig: 'thing_monitor_osd_config',
} as const
+3 -3
View File
@@ -86,19 +86,19 @@ export async function getLogs(
level?: LogLevel,
limit?: number,
): Promise<LogEntry[]> {
return invoke('get_logs', { module, level, limit })
return invoke('log_list', { module, level, limit })
}
/**
*
*/
export async function clearLogs(): Promise<void> {
return invoke('clear_logs')
return invoke('log_clear')
}
/**
*
*/
export async function getLogInfo(): Promise<LogInfo> {
return invoke('get_log_info')
return invoke('log_info_state')
}
-3
View File
@@ -10,6 +10,3 @@ import { ref } from 'vue'
/** 待打开新建下载对话框(由托盘"新建下载"触发) */
export const pendingNewDownload = ref(false)
/** 待切换到设置模块(由托盘"常规设置"触发) */
export const pendingOpenSettings = ref(false)
+23 -36
View File
@@ -1,4 +1,4 @@
import { createApp } from 'vue'
import { createApp, type Component } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import 'vue-sonner/style.css'
@@ -15,44 +15,31 @@ window.addEventListener('unhandledrejection', (event) => {
logger.error(`未处理的Promise拒绝: ${event.reason}`)
})
// ===== OSD 窗口模式检测 =====
// ===== 独立窗口模式 =====
// 通过 URL hash 识别独立窗口:#osd-overlay / #clipboard-popup / #quick-panel / #tray-menu / #screenshot-overlay / #screenshot-editor
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
// 新增独立窗口只需在此表登记一行(hash → 组件)
const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Promise<{ default: Component }>]> = [
['#osd-overlay', 'OSD', () => import('./modules/monitor/OsdWindow.vue')],
['#clipboard-popup', '剪贴板弹窗', () => import('./modules/clipboard/ClipboardPopup.vue')],
['#quick-panel', '快速面板弹窗', () => import('./modules/quickpanel/QuickPanel.vue')],
['#tray-menu', '托盘菜单', () => import('./modules/tray/TrayMenu.vue')],
['#screenshot-overlay', '截图覆盖层', () => import('./modules/screenshot/ScreenshotOverlay.vue')],
['#screenshot-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')],
]
const winHash = window.location.hash
if (winHash === '#osd-overlay') {
logger.info(`OSD 窗口启动: ${winHash}`)
void import('./modules/monitor/OsdWindow.vue').then(({ default: OsdWindow }) => {
const app = createApp(OsdWindow)
app.mount('#app')
})
} else if (winHash === '#clipboard-popup') {
logger.info(`剪贴板弹窗窗口启动: ${winHash}`)
void import('./modules/clipboard/ClipboardPopup.vue').then(({ default: ClipboardPopup }) => {
const app = createApp(ClipboardPopup)
app.mount('#app')
})
} else if (winHash === '#quick-panel') {
logger.info(`快速面板弹窗窗口启动: ${winHash}`)
void import('./modules/quickpanel/QuickPanel.vue').then(({ default: QuickPanel }) => {
const app = createApp(QuickPanel)
app.mount('#app')
})
} else if (winHash === '#tray-menu') {
logger.info(`托盘菜单窗口启动: ${winHash}`)
void import('./modules/tray/TrayMenu.vue').then(({ default: TrayMenu }) => {
const app = createApp(TrayMenu)
app.mount('#app')
})
} else if (winHash.startsWith('#screenshot-overlay')) {
logger.info(`截图覆盖层窗口启动: ${winHash}`)
void import('./modules/screenshot/ScreenshotOverlay.vue').then(({ default: ScreenshotOverlay }) => {
const app = createApp(ScreenshotOverlay)
app.mount('#app')
})
} else if (winHash === '#screenshot-editor') {
logger.info(`截图编辑器窗口启动: ${winHash}`)
void import('./modules/screenshot/ScreenshotEditor.vue').then(({ default: ScreenshotEditor }) => {
const app = createApp(ScreenshotEditor)
// #screenshot-overlay 带窗口号参数(多屏),按前缀匹配;其余精确匹配
const matched = standaloneWindowApps.find(([hash]) =>
hash === '#screenshot-overlay' ? winHash.startsWith(hash) : winHash === hash
)
if (matched) {
const [, label, loader] = matched
logger.info(`${label}窗口启动: ${winHash}`)
void loader().then(({ default: Comp }) => {
const app = createApp(Comp)
app.mount('#app')
})
} else {
+5 -5
View File
@@ -6,7 +6,7 @@ import {
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -249,14 +249,14 @@ const handleClear = async () => {
toast.success('已清空历史')
}
//
const kindIcon = (k: ClipboardKind) => {
// kind bindings string
const kindIcon = (k: string) => {
if (k === 'text') return FileText
if (k === 'image') return ImageIcon
return Files
}
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) =>
const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: string) =>
k === 'text'
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: k === 'image'
+30 -32
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { Effect, EffectState } from '@tauri-apps/api/window'
// Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings'
import {
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Loader2,
@@ -14,21 +16,9 @@ import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
// ===== Rust camelCase =====
type ClipboardKind = 'text' | 'image' | 'files'
interface ClipboardItem {
id: number
kind: ClipboardKind
preview: string
size: number
pinned: boolean
pinnedOrder: number | null
createdAt: number
}
interface HistoryPage {
items: ClipboardItem[]
total: number
}
// ===== Rust bindings camelCase =====
// kind bindings string
import type { ClipboardItem, HistoryPage } from '@/lib/bindings'
// ===== =====
const items = ref<ClipboardItem[]>([])
@@ -45,27 +35,35 @@ let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
// ===== =====
/** 加载请求序号:翻页/搜索快速操作时丢弃过期请求结果,避免旧请求覆盖新结果 */
let loadSeq = 0
async function loadData() {
const seq = ++loadSeq
loading.value = true
try {
const q = searchQuery.value.trim()
const offset = (currentPage.value - 1) * PAGE_SIZE
let res: HistoryPage
if (q) {
res = await invoke<HistoryPage>('clipboard_search', { query: q, limit: PAGE_SIZE, offset })
res = await commands.clipboardSearch(q, PAGE_SIZE, offset)
} else {
res = await invoke<HistoryPage>('clipboard_get_history', { limit: PAGE_SIZE, offset, kind: 'all' })
res = await commands.clipboardGetHistory(PAGE_SIZE, offset, 'all')
}
if (seq !== loadSeq) return //
items.value = res.items
total.value = res.total
selectedIndex.value = 0
} catch (e) {
if (seq !== loadSeq) return
console.error('[clipboard-popup] 加载失败:', e)
} finally {
loading.value = false
// loading
if (seq === loadSeq) loading.value = false
}
if (seq === loadSeq) {
await nextTick()
scrollSelectedIntoView()
}
}
async function gotoPage(p: number) {
@@ -84,9 +82,9 @@ watch(searchQuery, () => {
/// Ctrl+V
async function selectAndPaste(item: ClipboardItem) {
try {
await invoke('clipboard_copy_back', { id: item.id })
await commands.clipboardCopyBack(item.id)
// paste_to_target Ctrl+V
await invoke('clipboard_paste_to_target')
await commands.clipboardPasteToTarget()
} catch (e) {
console.error('[clipboard-popup] 粘贴失败:', e)
//
@@ -97,7 +95,7 @@ async function selectAndPaste(item: ClipboardItem) {
async function togglePin(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_set_pinned', { id: item.id, pinned: !item.pinned })
await commands.clipboardSetPinned(item.id, !item.pinned)
await loadData()
} catch (e) {
console.error('[clipboard-popup] 固定失败:', e)
@@ -107,7 +105,7 @@ async function togglePin(item: ClipboardItem, ev: Event) {
async function deleteItem(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_delete', { id: item.id })
await commands.clipboardDelete(item.id)
items.value = items.value.filter((i) => i.id !== item.id)
} catch (e) {
console.error('[clipboard-popup] 删除失败:', e)
@@ -116,7 +114,7 @@ async function deleteItem(item: ClipboardItem, ev: Event) {
async function hideWindow() {
try {
await invoke('clipboard_hide_popup')
await commands.clipboardHidePopup()
} catch {
/* 忽略 */
}
@@ -153,14 +151,14 @@ function scrollSelectedIntoView() {
})
}
// ===== =====
const kindIcon = (k: ClipboardKind) => {
// ===== kind bindings string =====
const kindIcon = (k: string) => {
if (k === 'text') return FileText
if (k === 'image') return ImageIcon
return Files
}
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) =>
const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: string) =>
k === 'text'
? 'badge-text'
: k === 'image'
@@ -205,7 +203,7 @@ async function onItemHover(idx: number, item: ClipboardItem) {
try {
let src = imageCache.get(item.id)
if (!src) {
const detail = await invoke<{ imageBase64: string | null } | null>('clipboard_get_item', { id: item.id })
const detail = await commands.clipboardGetItem(item.id)
if (detail?.imageBase64) {
src = buildImageDataUrl(detail.imageBase64)
imageCache.set(item.id, src)
@@ -237,7 +235,7 @@ function onItemLeave() {
/** 从 localStorage 读取主应用的主题设置 */
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return {
@@ -330,7 +328,7 @@ onMounted(async () => {
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// +
unlistenFns.push(await listen('clipboard-popup-show', async () => {
unlistenFns.push(await listen(EVENTS.clipboardPopupShow, async () => {
//
await applyTheme()
searchQuery.value = ''
@@ -351,7 +349,7 @@ onMounted(async () => {
// Rust
try {
await invoke('clipboard_show_window')
await commands.clipboardShowWindow()
} catch {
/* 忽略 */
}
+56 -40
View File
@@ -9,10 +9,11 @@ import {
} from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { invoke } from '@tauri-apps/api/core'
import { open as openDialog } from '@tauri-apps/plugin-dialog'
// Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings'
import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { pendingNewDownload } from '@/lib/trayEvents'
@@ -218,13 +219,21 @@ const onRemoveOpenChange = (open: boolean) => {
}
// ===== =====
const detailDialogState = ref<{ open: boolean; task: DownloadTask | null }>({
// id computed store.tasks
// //
const detailDialogState = ref<{ open: boolean; taskId: string | null }>({
open: false,
task: null
taskId: null
})
const detailTask = computed<DownloadTask | null>(() => {
const id = detailDialogState.value.taskId
if (!id) return null
return store.tasks.find((t) => t.id === id) ?? null
})
const handleShowDetail = (task: DownloadTask) => {
detailDialogState.value = { open: true, task }
detailDialogState.value = { open: true, taskId: task.id }
}
const handleCopyText = async (text: string, label: string) => {
@@ -498,7 +507,7 @@ const handleDialogSave = async () => {
const EXTENSION_STORE_URL = 'https://chromewebstore.google.com/'
const handleInstallExtensionOnline = async () => {
try {
await invoke('downloader_open_url', { url: EXTENSION_STORE_URL })
await commands.downloaderOpenUrl(EXTENSION_STORE_URL)
} catch (e) {
try {
await navigator.clipboard.writeText(EXTENSION_STORE_URL)
@@ -537,6 +546,13 @@ onUnmounted(() => {
const allTasks = computed<DownloadTask[]>(() => store.tasks)
// 4 filter
const statusCounts = computed(() => {
const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0 }
for (const t of allTasks.value) counts[t.status]++
return counts
})
//
const filteredByStatus = computed<DownloadTask[]>(() => {
if (statusFilter.value === 'all') return allTasks.value
@@ -635,19 +651,19 @@ const toggleSortOrder = () => {
<div v-if="running" class="flex items-center gap-2 text-xs">
<Badge variant="secondary" class="gap-1">
<Download class="h-3 w-3" />
下载中 {{ allTasks.filter(t => t.status === 'active').length }}
下载中 {{ statusCounts.active }}
</Badge>
<Badge variant="secondary" class="gap-1">
<Clock class="h-3 w-3" />
等待 {{ allTasks.filter(t => t.status === 'queued').length }}
等待 {{ statusCounts.queued }}
</Badge>
<Badge v-if="allTasks.filter(t => t.status === 'paused').length > 0" variant="secondary" class="gap-1">
<Badge v-if="statusCounts.paused > 0" variant="secondary" class="gap-1">
<Pause class="h-3 w-3" />
已暂停 {{ allTasks.filter(t => t.status === 'paused').length }}
已暂停 {{ statusCounts.paused }}
</Badge>
<Badge variant="secondary" class="gap-1">
<Check class="h-3 w-3" />
已完成 {{ allTasks.filter(t => t.status === 'complete').length }}
已完成 {{ statusCounts.complete }}
</Badge>
</div>
</div>
@@ -1457,21 +1473,21 @@ const toggleSortOrder = () => {
任务详情
</DialogTitle>
<DialogDescription class="text-xs">
任务 ID{{ detailDialogState.task?.id }}
任务 ID{{ detailTask?.id }}
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[55vh] pr-3">
<div v-if="detailDialogState.task" class="flex flex-col gap-3 py-2 text-sm">
<div v-if="detailTask" class="flex flex-col gap-3 py-2 text-sm">
<!-- 文件名 -->
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<span class="text-xs text-muted-foreground">文件名</span>
<span class="font-medium break-all">{{ detailDialogState.task.filename }}</span>
<span class="font-medium break-all">{{ detailTask.filename }}</span>
</div>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailDialogState.task.filename, '文件名')">
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.filename, '文件名')">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@@ -1484,9 +1500,9 @@ const toggleSortOrder = () => {
<!-- 状态 -->
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">状态</span>
<Badge :variant="getTaskStatusBadge(detailDialogState.task).variant" class="gap-1">
<component :is="getTaskStatusBadge(detailDialogState.task).icon" class="h-3 w-3" />
{{ getTaskStatusBadge(detailDialogState.task).text }}
<Badge :variant="getTaskStatusBadge(detailTask).variant" class="gap-1">
<component :is="getTaskStatusBadge(detailTask).icon" class="h-3 w-3" />
{{ getTaskStatusBadge(detailTask).text }}
</Badge>
</div>
@@ -1494,11 +1510,11 @@ const toggleSortOrder = () => {
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<span class="text-xs text-muted-foreground">下载链接</span>
<span class="font-mono text-xs break-all">{{ detailDialogState.task.url }}</span>
<span class="font-mono text-xs break-all">{{ detailTask.url }}</span>
</div>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailDialogState.task.url, '下载链接')">
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.url, '下载链接')">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@@ -1514,15 +1530,15 @@ const toggleSortOrder = () => {
<span class="text-xs text-muted-foreground">保存位置</span>
<Tooltip>
<TooltipTrigger as-child>
<span class="font-mono text-xs break-all cursor-default">{{ detailDialogState.task.dir }}</span>
<span class="font-mono text-xs break-all cursor-default">{{ detailTask.dir }}</span>
</TooltipTrigger>
<TooltipContent class="max-w-[400px] break-all">{{ detailDialogState.task.dir }}</TooltipContent>
<TooltipContent class="max-w-[400px] break-all">{{ detailTask.dir }}</TooltipContent>
</Tooltip>
<span class="font-mono text-xs text-muted-foreground break-all">{{ detailDialogState.task.filename }}</span>
<span class="font-mono text-xs text-muted-foreground break-all">{{ detailTask.filename }}</span>
</div>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailDialogState.task.dir + '\\' + detailDialogState.task.filename, '完整路径')">
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.dir + '\\' + detailTask.filename, '完整路径')">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@@ -1536,19 +1552,19 @@ const toggleSortOrder = () => {
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">文件总大小</span>
<span>{{ formatSize(detailDialogState.task.totalSize) }}</span>
<span>{{ formatSize(detailTask.totalSize) }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">已下载</span>
<span>{{ formatSize(detailDialogState.task.completedSize) }}</span>
<span>{{ formatSize(detailTask.completedSize) }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">下载进度</span>
<span>{{ getProgress(detailDialogState.task) }}%</span>
<span>{{ getProgress(detailTask) }}%</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">当前速度</span>
<span v-if="detailDialogState.task.status === 'active'">{{ formatSpeed(detailDialogState.task.speed) }}</span>
<span v-if="detailTask.status === 'active'">{{ formatSpeed(detailTask.speed) }}</span>
<span v-else>-</span>
</div>
</div>
@@ -1559,41 +1575,41 @@ const toggleSortOrder = () => {
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">断点续传</span>
<span>{{ detailDialogState.task.supportsResume ? '支持' : '不支持' }}</span>
<span>{{ detailTask.supportsResume ? '支持' : '不支持' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">连接数 / 分片数</span>
<span>{{ detailDialogState.task.segments.length }}</span>
<span>{{ detailTask.segments.length }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">创建时间</span>
<span>{{ formatTime(detailDialogState.task.createdAt) }}</span>
<span>{{ formatTime(detailTask.createdAt) }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">剩余时间</span>
<span v-if="detailDialogState.task.status === 'active' && detailDialogState.task.speed > 0">
{{ formatEta(getEta(detailDialogState.task)) }}
<span v-if="detailTask.status === 'active' && detailTask.speed > 0">
{{ formatEta(getEta(detailTask)) }}
</span>
<span v-else>-</span>
</div>
</div>
<!-- 错误信息 -->
<template v-if="detailDialogState.task.error">
<template v-if="detailTask.error">
<Separator />
<div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">错误信息</span>
<span class="text-sm text-destructive break-all">{{ detailDialogState.task.error }}</span>
<span class="text-sm text-destructive break-all">{{ detailTask.error }}</span>
</div>
</template>
<!-- 自定义请求头 -->
<template v-if="detailDialogState.task.headers && Object.keys(detailDialogState.task.headers).length > 0">
<template v-if="detailTask.headers && Object.keys(detailTask.headers).length > 0">
<Separator />
<div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">自定义请求头</span>
<div class="rounded-md bg-muted p-2 text-xs font-mono space-y-0.5">
<div v-for="(value, key) in detailDialogState.task.headers" :key="key" class="flex gap-2">
<div v-for="(value, key) in detailTask.headers" :key="key" class="flex gap-2">
<span class="text-muted-foreground shrink-0">{{ key }}:</span>
<span class="break-all">{{ value }}</span>
</div>
@@ -1602,12 +1618,12 @@ const toggleSortOrder = () => {
</template>
<!-- 分段详情 -->
<template v-if="detailDialogState.task.segments.length > 1">
<template v-if="detailTask.segments.length > 1">
<Separator />
<div class="flex flex-col gap-2">
<span class="text-xs text-muted-foreground">分段详情</span>
<div class="flex flex-col gap-1.5">
<div v-for="(seg, i) in detailDialogState.task.segments" :key="i" class="flex items-center gap-2 text-xs">
<div v-for="(seg, i) in detailTask.segments" :key="i" class="flex items-center gap-2 text-xs">
<span class="w-8 text-muted-foreground shrink-0">#{{ i }}</span>
<div class="flex-1 min-w-0">
<Progress :model-value="segmentProgress(seg)" class="h-1.5" />
@@ -1626,7 +1642,7 @@ const toggleSortOrder = () => {
<DialogClose as-child>
<Button variant="outline">关闭</Button>
</DialogClose>
<Button v-if="detailDialogState.task?.dir" variant="outline" @click="handleOpenDir(detailDialogState.task)">
<Button v-if="detailTask?.dir" variant="outline" @click="handleOpenDir(detailTask)">
<FolderOpen class="h-4 w-4" />
打开目录
</Button>
+2 -2
View File
@@ -8,7 +8,7 @@ import { moduleConfig as screenshot } from './screenshot'
import { moduleConfig as monitor } from './monitor'
import { moduleConfig as downloader } from './downloader'
import { moduleConfig as quickpanel } from './quickpanel'
import { moduleConfig as general } from './general'
import { moduleConfig as settings } from './settings'
const allModules: ModuleConfig[] = [
proxy,
@@ -17,7 +17,7 @@ const allModules: ModuleConfig[] = [
monitor,
downloader,
quickpanel,
general
settings
]
// 启动时注册所有模块
+41 -659
View File
@@ -12,11 +12,19 @@ import { toast } from 'vue-sonner'
import { VueDraggable } from 'vue-draggable-plus'
import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { currentMonitor, LogicalPosition, LogicalSize } from '@tauri-apps/api/window'
import { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import {
useMonitorStore,
type SensorEntry,
type SensorGroup,
type ConnectionState,
type OsdConfig,
type OsdItem,
type ColorTheme,
type AlertConfig,
DEFAULT_COLOR_THEME,
} from '@/stores/monitorStore'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { fmt, tempColor, loadColor, fmtSpeed, typeLabel, groupDisplayName, groupIcon } from './format'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -185,90 +193,6 @@ const storageDrives = computed<StorageDrive[]>(() => {
const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null))
const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null))
// ===== =====
/** 格式化数值:整数型指标(负载/温度)保留 0 位,浮点型(电压/功率)保留 2 位 */
function fmt(v: number | null, digits = 1): string {
if (v == null || !isFinite(v)) return '--'
return v.toFixed(digits)
}
/** 温度颜色:绿(<50) → 黄(<70) → 橙(<85) → 红(>=85) */
function tempColor(t: number | null): string {
if (t == null) return 'text-muted-foreground'
if (t < 50) return 'text-emerald-500'
if (t < 70) return 'text-yellow-500'
if (t < 85) return 'text-orange-500'
return 'text-red-500'
}
/** 负载颜色:蓝(<50) → 紫(<80) → 红(>=80) */
function loadColor(v: number | null): string {
if (v == null) return 'text-muted-foreground'
if (v < 50) return 'text-sky-500'
if (v < 80) return 'text-violet-500'
return 'text-red-500'
}
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s */
function fmtSpeed(bytesPerSec: number | null): { value: string; unit: string } {
if (bytesPerSec == null || !isFinite(bytesPerSec)) return { value: '--', unit: '' }
if (bytesPerSec >= 1_048_576) return { value: (bytesPerSec / 1_048_576).toFixed(2), unit: 'MB/s' }
if (bytesPerSec >= 1024) return { value: (bytesPerSec / 1024).toFixed(1), unit: 'KB/s' }
return { value: bytesPerSec.toFixed(0), unit: 'B/s' }
}
/** 传感器类型 → 中文标签 */
const typeLabels: Record<string, string> = {
temperature: '温度',
load: '负载',
power: '功率',
voltage: '电压',
fan: '风扇',
clock: '时钟',
data: '容量',
smalldata: '容量',
throughput: '吞吐',
level: '等级',
control: '控制',
frequency: '频率',
factor: '因子',
timespan: '时长',
energy: '能量',
noise: '噪声',
conductivity: '电导率',
humidity: '湿度',
flow: '流量',
}
function typeLabel(t: string): string {
return typeLabels[t] ?? t
}
/** 分组 id → 显示名 + 图标组件 */
const groupMeta: Record<string, { name: string; icon: typeof Cpu }> = {
cpu: { name: 'CPU', icon: Cpu },
memory: { name: '内存', icon: MemoryStick },
gpuintel: { name: 'GPU', icon: Gauge },
gpuamd: { name: 'GPU', icon: Gauge },
gpunvidia: { name: 'GPU', icon: Gauge },
storage: { name: '存储', icon: HardDrive },
motherboard: { name: '主板', icon: Activity },
superio: { name: '超级 IO', icon: Activity },
embeddedcontroller: { name: '嵌入式控制器', icon: Activity },
battery: { name: '电池', icon: Activity },
network: { name: '网络', icon: Activity },
psu: { name: '电源', icon: Zap },
}
function groupDisplayName(id: string, fallback: string): string {
return groupMeta[id]?.name ?? fallback
}
function groupIcon(id: string): typeof Cpu {
return groupMeta[id]?.icon ?? Activity
}
// ===== =====
const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
idle: { text: '未启动', class: 'bg-muted text-muted-foreground' },
@@ -281,14 +205,19 @@ const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
// ===== =====
const groups = computed<SensorGroup[]>(() => store.snapshot?.groups ?? [])
/** 按 hardwareName 子分组,再按 type 二级分组(详细页用) */
/** hardwareName type
* 分组结果只依赖传感器的静态元数据硬件名/类型与数值变化无关
* 以传感器数组引用为键缓存WeakMap避免每次渲染对数百传感器全量重算 */
const sensorGroupCache = new WeakMap<SensorEntry[], { hardware: string; byType: { type: string; items: SensorEntry[] }[] }[]>()
function groupSensors(sensors: SensorEntry[]): { hardware: string; byType: { type: string; items: SensorEntry[] }[] }[] {
const cached = sensorGroupCache.get(sensors)
if (cached) return cached
const byHw = new Map<string, SensorEntry[]>()
for (const s of sensors) {
if (!byHw.has(s.hardwareName)) byHw.set(s.hardwareName, [])
byHw.get(s.hardwareName)!.push(s)
}
return Array.from(byHw.entries()).map(([hw, items]) => {
const result = Array.from(byHw.entries()).map(([hw, items]) => {
const byType = new Map<string, SensorEntry[]>()
for (const s of items) {
if (!byType.has(s.type)) byType.set(s.type, [])
@@ -299,6 +228,8 @@ function groupSensors(sensors: SensorEntry[]): { hardware: string; byType: { typ
byType: Array.from(byType.entries()).map(([type, list]) => ({ type, items: list })),
}
})
sensorGroupCache.set(sensors, result)
return result
}
// ===== Accordion =====
@@ -455,203 +386,12 @@ async function handleSaveConfig() {
}
}
// ===== OSD =====
// OSDOn-Screen Display
// localStorage OsdWindow.vue
/** OSD 显示项:从可用传感器中选取并排序 */
interface OsdItem {
/** 唯一 key{groupId}/{hardwareName}/{sensorName}/{type} 小写化,或 special 项的固定 key */
key: string
groupId: string
sensorName: string
hardwareName: string
type: string
unit: string
/** 特殊项标记:非 Kernel 传感器,由前端直接计算(如网速) */
special?: 'net-up' | 'net-down'
}
/** 颜色主题:按硬件/传感器类型着色(类似小飞机风格) */
interface ColorTheme {
/** 按 groupId 着色:cpu/gpu/memory/storage/... */
hardware: Record<string, string>
/** 按 sensor type 着色:temperature/load/power/... */
sensor: Record<string, string>
}
/** 警告色配置:阈值百分比 + 警告/严重颜色 */
interface AlertConfig {
/** 警告色开关 */
enabled: boolean
/** 警告阈值百分比(达到即变警告色,如 80) */
warnThreshold: number
/** 严重阈值百分比(达到即变严重色,如 90) */
criticalThreshold: number
/** 警告色(淡红,hex */
warnColor: string
/** 严重色(大红,hex */
criticalColor: string
/**
* CPU 温度墙默认 100GPU 默认 85 */
maxValues: Record<string, number>
}
/** OSD 配置结构 */
interface OsdConfig {
overlayEnabled: boolean
overlayItems: OsdItem[]
/** 悬浮窗位置 X 百分比(0=最左,50=居中,100=最右) */
positionXPct: number
/** 悬浮窗位置 Y 百分比(0=最上,50=居中,100=最下) */
positionYPct: number
fontSize: number
showUnit: boolean
showLabel: boolean
/** 标题语言:'zh' 中文 / 'en' 英文(原始传感器名) */
labelLanguage: 'zh' | 'en'
/** 'single' |
* 'group' 分组横排标题在上+数据列在下'multiline' 多行每组一行左对齐类小飞机 */
layout: 'single' | 'group' | 'multiline'
updateIntervalMs: number
/** 鼠标穿透:true 时窗口不接收鼠标事件(需关闭穿透才能左键拖动) */
clickThrough: boolean
/** 默认文字颜色(hex),颜色主题关闭时使用 */
fontColor: string
/** 字体不透明度 0-100 */
fontOpacity: number
/** 悬浮窗背景色(CSS 颜色字符串,如 rgba(0,0,0,0.55) */
bgColor: string
/** 启用颜色主题(按硬件/传感器类型着色) */
colorThemeEnabled: boolean
/** 颜色主题配置 */
colorTheme: ColorTheme
/** 字体描边开关(默认关闭) */
fontStrokeEnabled: boolean
/** 字体描边厚度(px,默认 1) */
fontStrokeWidth: number
/** 字体描边颜色(hex,默认 #000000 */
fontStrokeColor: string
/** 警告色配置 */
alert: AlertConfig
/** 悬浮窗窗口保存位置(null=使用百分比计算默认位置) */
overlayX?: number | null
overlayY?: number | null
}
const OSD_STORAGE_KEY = 'thing_monitor_osd_config'
const OSD_CONFIG_VERSION = 11
/** 默认颜色主题(小飞机风格:不同硬件不同颜色,不同传感器不同颜色) */
const DEFAULT_COLOR_THEME: ColorTheme = {
hardware: {
cpu: '#4A9EFF',
gpuintel: '#9D4EFF',
gpuamd: '#9D4EFF',
gpunvidia: '#9D4EFF',
memory: '#FF9F4A',
storage: '#4AFF9F',
motherboard: '#FFD700',
superio: '#B0B0B0',
embeddedcontroller: '#B0B0B0',
battery: '#FF4A9F',
network: '#4AFFFF',
psu: '#FF4A4A',
},
sensor: {
temperature: '#FF6B6B',
load: '#4A9EFF',
power: '#FFD700',
voltage: '#9D4EFF',
fan: '#B0B0B0',
clock: '#4AFF9F',
data: '#FF9F4A',
smalldata: '#FF9F4A',
throughput: '#4AFFFF',
level: '#FF4A9F',
control: '#FFA500',
frequency: '#4AFF9F',
factor: '#FF4A4A',
timespan: '#B0B0B0',
energy: '#FFD700',
noise: '#B0B0B0',
conductivity: '#4AFFFF',
humidity: '#4A9EFF',
flow: '#4AFFFF',
},
}
/** 默认警告色配置:CPU 温度墙 100°C,GPU 85°C;百分比类直接用值 */
const DEFAULT_ALERT_CONFIG: AlertConfig = {
enabled: true,
warnThreshold: 80,
criticalThreshold: 90,
warnColor: '#FF6B6B',
criticalColor: '#FF0000',
maxValues: {
cpu: 100,
gpu: 85,
gpuintel: 85,
gpuamd: 85,
gpunvidia: 85,
},
}
function defaultOsdConfig(): OsdConfig {
return {
overlayEnabled: false,
overlayItems: [],
// top 0 50% 0%
positionXPct: 50,
positionYPct: 0,
fontSize: 14,
showUnit: true,
showLabel: true,
labelLanguage: 'zh',
layout: 'single',
updateIntervalMs: 1000,
// 穿
clickThrough: false,
fontColor: '#ffffff',
fontOpacity: 100,
bgColor: 'transparent',
colorThemeEnabled: true,
colorTheme: { ...DEFAULT_COLOR_THEME },
fontStrokeEnabled: false,
fontStrokeWidth: 1,
fontStrokeColor: '#000000',
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
overlayX: null,
overlayY: null,
}
}
function loadOsdConfig(): OsdConfig {
try {
const saved = localStorage.getItem(OSD_STORAGE_KEY)
if (!saved) return defaultOsdConfig()
const parsed = JSON.parse(saved)
if (parsed.version !== OSD_CONFIG_VERSION) return defaultOsdConfig()
//
const def = defaultOsdConfig()
return { ...def, ...parsed.config }
} catch {
return defaultOsdConfig()
}
}
function saveOsdConfig(cfg: OsdConfig) {
try {
localStorage.setItem(OSD_STORAGE_KEY, JSON.stringify({
version: OSD_CONFIG_VERSION,
config: cfg,
}))
} catch {
/* 忽略 localStorage 写入失败 */
}
}
const osdConfig = ref<OsdConfig>(loadOsdConfig())
// ===== OSD monitorStore UI =====
// /// monitorStoreApp store.initOsd()
const osdConfig = computed<OsdConfig>(() => store.osdConfig)
// store
const saveOsdConfig = (cfg: OsdConfig) => store.saveOsdConfig(cfg)
const saveOsdConfigDebounced = (cfg: OsdConfig) => store.saveOsdConfigDebounced(cfg)
/** 传感器名称中英文字典(覆盖常见 LHB 传感器名 + 硬件名) */
const SENSOR_NAME_ZH: Record<string, string> = {
@@ -1056,7 +796,7 @@ const availableSensors = computed<AvailableSensor[]>(() => {
for (const g of store.snapshot?.groups ?? []) {
// /OSD
if (g.id === 'storage') continue
const groupName = groupMeta[g.id]?.name ?? g.name
const groupName = groupDisplayName(g.id, g.name)
for (const s of g.sensors) {
const key = `${g.id}/${s.hardwareName}/${s.name}/${s.type}`.replace(/\s+/g, '_').toLowerCase()
list.push({
@@ -1188,10 +928,10 @@ function removeOsdItem(key: string) {
}
}
/** OSD 配置项变更时自动保存 */
/** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */
function updateOsdConfig(field: keyof OsdConfig, value: unknown) {
;(osdConfig.value as Record<string, unknown>)[field] = value
saveOsdConfig(osdConfig.value)
;(osdConfig.value as unknown as Record<string, unknown>)[field] = value
saveOsdConfigDebounced(osdConfig.value)
}
/** 解析背景色字符串为 hex + alpha0-100 */
@@ -1272,276 +1012,7 @@ function osdItemColor(item: OsdItem): string {
return withOpacity(osdConfig.value.fontColor, opacity)
}
// ===== OSD / Tauri =====
const OSD_OVERLAY_LABEL = 'osd-overlay'
/** 抑制百分比 watch 的程序定位标志:拖动 onMoved 更新百分比时置 true,避免触发 resetOverlayPosition 循环 */
let suppressPercentWatch = false
/** 构建用于 OSD 窗口的 URL(基于当前页面 URL 替换 hash) */
function osdUrl(hash: string): string {
const base = window.location.href.split('#')[0]
return `${base}#${hash}`
}
/** 推送当前 OSD 状态到所有 OSD 窗口 */
async function pushOsdState() {
const payload = {
config: osdConfig.value,
snapshot: store.snapshot,
networkSpeed: store.networkSpeed,
}
try {
await emit('osd-state-update', payload)
} catch (e) {
console.error('[OSD] 推送状态失败:', e)
}
}
/** 根据百分比位置计算窗口坐标 */
function computePositionFromPct(screenW: number, screenH: number, w: number, h: number, xPct: number, yPct: number): { x: number; y: number } {
// -
const availW = Math.max(0, screenW - w)
const availH = Math.max(0, screenH - h)
return {
x: Math.round((availW * xPct) / 100),
y: Math.round((availH * yPct) / 100),
}
}
/**
* single: 单行分组式组间用 | 分隔固定宽度数据列
* group: 分组横排标题在上 + 数据列在下
* multiline: 多行每组一行标题 + 固定宽度数据列 */
function computeOsdWindowSize(
_itemCount: number,
layout: 'single' | 'group' | 'multiline',
fontSize: number,
_hasNetItem = false,
items?: OsdItem[],
): { w: number; h: number } {
const charW = fontSize * 0.62
const barHPad = 8 // osd-bar padding 4*2
//
const groupMap = new Map<string, OsdItem[]>()
if (items?.length) {
for (const item of items) {
let gkey: string
if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network'
else if (item.groupId.startsWith('gpu')) gkey = 'gpu'
else gkey = item.groupId
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(item)
}
}
const groupCount = Math.max(1, groupMap.size)
// (6ch) + (++/gap)
const groupWidths: number[] = []
for (const [, groupItems] of groupMap) {
const labelW = 6
const dataW = groupItems.reduce((sum, item) => {
const isNet = item.special === 'net-up' || item.special === 'net-down'
return sum + (isNet ? 11 : 8) + 1
}, 0)
groupWidths.push(labelW + dataW)
}
if (layout === 'multiline') {
//
const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10
const w = Math.ceil(maxLineW * charW + barHPad)
const lineH = Math.ceil(fontSize + 2)
const h = Math.ceil(groupCount * lineH + 6)
return { w: Math.max(120, w), h: Math.max(28, h) }
}
if (layout === 'group') {
// +
const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4
const w = Math.ceil(totalW * charW + barHPad)
const titleH = Math.ceil(fontSize * 0.85) + 2
const dataH = Math.ceil(fontSize) + 2
const h = Math.ceil(titleH + dataH + 10)
return { w: Math.max(120, w), h: Math.max(40, h) }
}
// single + | (1ch)
const sepW = (groupCount - 1) * 1
const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW
const w = Math.ceil(totalW * charW + barHPad)
const h = Math.ceil(fontSize + 8)
return { w: Math.max(120, w), h: Math.max(28, h) }
}
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
async function ensureOverlayWindow() {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (existing) {
//
await existing.show()
await updateOsdWindowSize()
await pushOsdState()
return
}
//
const monitor = await currentMonitor()
const screenW = monitor?.size.width ?? 1920
const screenH = monitor?.size.height ?? 1080
const scale = monitor?.scaleFactor ?? 1
const logicalW = screenW / scale
const logicalH = screenH / scale
const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down')
const { w, h } = computeOsdWindowSize(
osdConfig.value.overlayItems.length,
osdConfig.value.layout,
osdConfig.value.fontSize,
hasNetItem,
osdConfig.value.overlayItems,
)
// 使
let x: number, y: number
if (osdConfig.value.overlayX != null && osdConfig.value.overlayY != null) {
x = osdConfig.value.overlayX
y = osdConfig.value.overlayY
} else {
const pos = computePositionFromPct(logicalW, logicalH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
x = pos.x
y = pos.y
}
const win = new WebviewWindow(OSD_OVERLAY_LABEL, {
url: osdUrl('osd-overlay'),
title: 'OSD 悬浮窗',
width: w,
height: h,
x,
y,
decorations: false,
transparent: true,
// Win11 ""
shadow: false,
alwaysOnTop: true,
skipTaskbar: true,
// Windows resize 穿/
resizable: false,
visible: true,
// NoActivate Rust osd_apply_overlay_style
focus: false,
})
win.once('tauri://created', async () => {
// webview
setTimeout(() => pushOsdState(), 300)
//
try {
const winInstance = await win
const unlisten = await winInstance.onMoved(async ({ payload }) => {
osdConfig.value.overlayX = payload.x
osdConfig.value.overlayY = payload.y
// xPct = x / availW * 100availW = screenW - windowW
// suppressPercentWatch=true resetOverlayPosition
suppressPercentWatch = true
try {
const monitor = await currentMonitor()
const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1)
const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1)
const size = await winInstance.outerSize()
const scale = monitor?.scaleFactor ?? 1
const winW = size.width / scale
const winH = size.height / scale
const availW = Math.max(1, screenW - winW)
const availH = Math.max(1, screenH - winH)
osdConfig.value.positionXPct = Math.round((payload.x / availW) * 100)
osdConfig.value.positionYPct = Math.round((payload.y / availH) * 100)
} catch { /* 忽略百分比反算失败 */ }
saveOsdConfig(osdConfig.value)
// watch
queueMicrotask(() => { suppressPercentWatch = false })
})
osdEventUnlisteners.push(unlisten)
} catch { /* 忽略 */ }
})
win.once('tauri://error', (e: unknown) => {
console.error('[OSD] 悬浮窗创建失败:', e)
toast.error('悬浮窗创建失败')
})
}
/** 隐藏悬浮窗 */
async function hideOverlayWindow() {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (existing) {
await existing.hide()
}
}
/** 根据当前配置更新悬浮窗窗口尺寸(显示项数量/布局/字号变化时调用) */
async function updateOsdWindowSize() {
try {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (!existing) return
const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down')
const { w, h } = computeOsdWindowSize(
osdConfig.value.overlayItems.length,
osdConfig.value.layout,
osdConfig.value.fontSize,
hasNetItem,
osdConfig.value.overlayItems,
)
await existing.setSize(new LogicalSize(w, h))
} catch { /* 忽略 */ }
}
/**
* 仅重新定位不改变尺寸尺寸由悬浮窗内容实际测量上报维持 */
async function resetOverlayPosition() {
osdConfig.value.overlayX = null
osdConfig.value.overlayY = null
saveOsdConfig(osdConfig.value)
//
try {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (existing) {
const monitor = await currentMonitor()
const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1)
const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1)
// setSize
const size = await existing.outerSize()
const scale = monitor?.scaleFactor ?? 1
const w = size.width / scale
const h = size.height / scale
const pos = computePositionFromPct(screenW, screenH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
await existing.setPosition(new LogicalPosition(pos.x, pos.y))
}
} catch { /* 忽略 */ }
}
// ===== OSD =====
let osdEventUnlisteners: UnlistenFn[] = []
async function setupOsdEventListeners() {
// MonitorModule
if (osdEventUnlisteners.length) return
const { listen: tauriListen } = await import('@tauri-apps/api/event')
//
// 1px setSize
let lastW = 0
let lastH = 0
const unlisten = await tauriListen<{ width: number; height: number }>('osd-content-size', async (e) => {
const { width, height } = e.payload
if (Math.abs(width - lastW) < 1 && Math.abs(height - lastH) < 1) return
lastW = width
lastH = height
try {
const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (w) await w.setSize(new LogicalSize(width, height))
} catch { /* 忽略 */ }
})
osdEventUnlisteners.push(unlisten)
}
// ===== OSD store.initOsd()/ensureOverlayWindow() =====
// ===== Dialog =====
const colorThemeDialogOpen = ref(false)
@@ -1570,7 +1041,7 @@ function updateAlertConfig(field: keyof AlertConfig | 'maxValues', value: unknow
if (field === 'maxValues' && maxKey) {
osdConfig.value.alert.maxValues[maxKey] = Number(value)
} else {
;(osdConfig.value.alert as Record<string, unknown>)[field] = value
;(osdConfig.value.alert as unknown as Record<string, unknown>)[field] = value
}
saveOsdConfig(osdConfig.value)
}
@@ -1622,43 +1093,17 @@ onMounted(async () => {
try { appDataPath.value = await appDataDir() } catch { /* 忽略 */ }
store.init()
// OSD
setupOsdEventListeners().catch(e => console.error('[OSD] 事件监听注册失败:', e))
//
if (osdConfig.value.overlayEnabled) {
ensureOverlayWindow().catch(e => console.error('[OSD] 初始化悬浮窗失败:', e))
}
// " OSD"
try {
osdEventUnlisteners.push(
await listen('tray:toggle-osd', () => {
osdConfig.value.overlayEnabled = !osdConfig.value.overlayEnabled
saveOsdConfig(osdConfig.value)
if (osdConfig.value.overlayEnabled) {
if (osdConfig.value.overlayItems.length === 0) {
toast.warning('OSD 显示项为空,已开启但未创建窗口')
} else {
ensureOverlayWindow().catch(e => console.error('[OSD] 托盘开启悬浮窗失败:', e))
}
} else {
hideOverlayWindow().catch(e => console.error('[OSD] 托盘关闭悬浮窗失败:', e))
}
})
)
} catch (e) {
console.error('[OSD] 注册 tray:toggle-osd 监听失败:', e)
}
// OSD // monitorStore initOsd()
// App
store.initOsd()
})
onUnmounted(() => {
// dispose storeSSE OSD
// store.subscribe() if unlistenFns.length return init
// OSD OSD 退
// OSD setupOsdEventListeners
osdEventUnlisteners.forEach(fn => fn())
osdEventUnlisteners = []
// OSD App
store.disposeOsd()
})
// Kernel UI
@@ -1668,71 +1113,8 @@ watch(() => store.status?.ready, (ready, prev) => {
}
})
// ===== OSD / =====
watch(() => osdConfig.value.overlayEnabled, (enabled) => {
if (enabled) {
//
if (osdConfig.value.overlayItems.length === 0) return
ensureOverlayWindow().catch(e => console.error('[OSD] 创建悬浮窗失败:', e))
} else {
hideOverlayWindow().catch(e => console.error('[OSD] 隐藏悬浮窗失败:', e))
}
})
// ===== =====
watch(() => osdConfig.value.overlayItems.length, (len) => {
if (!osdConfig.value.overlayEnabled) return
if (len === 0) {
hideOverlayWindow().catch(e => console.error('[OSD] 显示项为空,隐藏悬浮窗失败:', e))
} else {
ensureOverlayWindow().catch(e => console.error('[OSD] 显示项恢复,创建悬浮窗失败:', e))
}
})
// ===== =====
// OSD onMoved suppressPercentWatch=true
watch(() => [osdConfig.value.positionXPct, osdConfig.value.positionYPct], () => {
if (suppressPercentWatch) return
// 使
osdConfig.value.overlayX = null
osdConfig.value.overlayY = null
saveOsdConfig(osdConfig.value)
//
resetOverlayPosition().catch(() => {})
})
// ===== OSD =====
// Kernel SSE OSD
watch(() => store.snapshot, () => {
if (osdConfig.value.overlayEnabled) {
pushOsdState()
}
}, { deep: false })
// OSD
watch(() => store.networkSpeed, () => {
if (osdConfig.value.overlayEnabled) {
pushOsdState()
}
}, { deep: false })
// OSD OSD //
watch(osdConfig, () => {
if (osdConfig.value.overlayEnabled) {
pushOsdState()
}
}, { deep: true })
// //
watch([
() => osdConfig.value.overlayItems.length,
() => osdConfig.value.layout,
() => osdConfig.value.fontSize,
], () => {
if (osdConfig.value.overlayEnabled) {
updateOsdWindowSize().catch(() => {})
}
})
// OSD watch//// store.initOsd()
// OSD
</script>
<template>
+7 -6
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { listen, emit, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { invoke } from '@tauri-apps/api/core'
import { EVENTS, WINDOWS } from '@/lib/constants'
// ===== MonitorModule =====
interface OsdItem {
@@ -450,7 +451,7 @@ async function measureAndReportSize() {
const rect = bar.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
// 1px
await emit('osd-content-size', { width: Math.ceil(rect.width) + 1, height: Math.ceil(rect.height) + 1 })
await emit(EVENTS.osdContentSize, { width: Math.ceil(rect.width) + 1, height: Math.ceil(rect.height) + 1 })
}
/** 防抖测量(数据频繁更新时合并) */
@@ -474,7 +475,7 @@ async function applyClickThrough(ignore: boolean) {
}
// 2. Rust WS_EX_TRANSPARENT 穿
try {
await invoke('osd_set_click_through', { label: 'osd-overlay', enabled: ignore })
await invoke('osd_set_click_through', { label: WINDOWS.osdOverlay, enabled: ignore })
} catch (e) {
console.error('[OSD] osd_set_click_through 失败:', e)
}
@@ -483,7 +484,7 @@ async function applyClickThrough(ignore: boolean) {
// ===== 使 Rust =====
async function applyTopmost(topmost: boolean) {
try {
await invoke('osd_set_topmost', { label: 'osd-overlay', topmost })
await invoke('osd_set_topmost', { label: WINDOWS.osdOverlay, topmost })
} catch (e) {
console.error('[OSD] 设置置顶失败:', e)
}
@@ -498,7 +499,7 @@ watch(() => config.value?.clickThrough, (ignore) => {
onMounted(async () => {
// NoActivate + ToolWindow
try {
await invoke('osd_apply_overlay_style', { label: 'osd-overlay' })
await invoke('osd_apply_overlay_style', { label: WINDOWS.osdOverlay })
} catch (e) {
console.error('[OSD] 应用原生样式失败:', e)
}
@@ -523,11 +524,11 @@ onMounted(async () => {
}))
// UI
unlistenFns.push(await listen('osd-system-ui-active', async () => {
unlistenFns.push(await listen(EVENTS.osdSystemUiActive, async () => {
await applyTopmost(false)
}))
unlistenFns.push(await listen('osd-system-ui-inactive', async () => {
unlistenFns.push(await listen(EVENTS.osdSystemUiInactive, async () => {
await applyTopmost(true)
}))
})
+87
View File
@@ -0,0 +1,87 @@
/**
* MonitorModule / /
*
*/
import { Activity, Cpu, Gauge, HardDrive, MemoryStick, Zap, type LucideIcon } from '@lucide/vue'
/** 格式化数值:整数型指标(负载/温度)保留 0 位,浮点型(电压/功率)保留 2 位 */
export function fmt(v: number | null, digits = 1): string {
if (v == null || !isFinite(v)) return '--'
return v.toFixed(digits)
}
/** 温度颜色:绿(<50) → 黄(<70) → 橙(<85) → 红(>=85) */
export function tempColor(t: number | null): string {
if (t == null) return 'text-muted-foreground'
if (t < 50) return 'text-emerald-500'
if (t < 70) return 'text-yellow-500'
if (t < 85) return 'text-orange-500'
return 'text-red-500'
}
/** 负载颜色:蓝(<50) → 紫(<80) → 红(>=80) */
export function loadColor(v: number | null): string {
if (v == null) return 'text-muted-foreground'
if (v < 50) return 'text-sky-500'
if (v < 80) return 'text-violet-500'
return 'text-red-500'
}
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s */
export function fmtSpeed(bytesPerSec: number | null): { value: string; unit: string } {
if (bytesPerSec == null || !isFinite(bytesPerSec)) return { value: '--', unit: '' }
if (bytesPerSec >= 1_048_576) return { value: (bytesPerSec / 1_048_576).toFixed(2), unit: 'MB/s' }
if (bytesPerSec >= 1024) return { value: (bytesPerSec / 1024).toFixed(1), unit: 'KB/s' }
return { value: bytesPerSec.toFixed(0), unit: 'B/s' }
}
/** 传感器类型 → 中文标签 */
const typeLabels: Record<string, string> = {
temperature: '温度',
load: '负载',
power: '功率',
voltage: '电压',
fan: '风扇',
clock: '时钟',
data: '容量',
smalldata: '容量',
throughput: '吞吐',
level: '等级',
control: '控制',
frequency: '频率',
factor: '因子',
timespan: '时长',
energy: '能量',
noise: '噪声',
conductivity: '电导率',
humidity: '湿度',
flow: '流量',
}
export function typeLabel(t: string): string {
return typeLabels[t] ?? t
}
/** 分组 id → 显示名 + 图标组件 */
const groupMeta: Record<string, { name: string; icon: LucideIcon }> = {
cpu: { name: 'CPU', icon: Cpu },
memory: { name: '内存', icon: MemoryStick },
gpuintel: { name: 'GPU', icon: Gauge },
gpuamd: { name: 'GPU', icon: Gauge },
gpunvidia: { name: 'GPU', icon: Gauge },
storage: { name: '存储', icon: HardDrive },
motherboard: { name: '主板', icon: Activity },
superio: { name: '超级 IO', icon: Activity },
embeddedcontroller: { name: '嵌入式控制器', icon: Activity },
battery: { name: '电池', icon: Activity },
network: { name: '网络', icon: Activity },
psu: { name: '电源', icon: Zap },
}
export function groupDisplayName(id: string, fallback: string): string {
return groupMeta[id]?.name ?? fallback
}
export function groupIcon(id: string): LucideIcon {
return groupMeta[id]?.icon ?? Activity
}
+2 -1
View File
@@ -47,7 +47,8 @@ export const moduleConfig: ModuleConfig = {
// 关闭 OSD 窗口(MonitorModule onUnmounted 不再自动关闭,需在禁用时手动关闭)
try {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const osd = await WebviewWindow.getByLabel('osd-overlay')
const { WINDOWS } = await import('@/lib/constants')
const osd = await WebviewWindow.getByLabel(WINDOWS.osdOverlay)
if (osd) await osd.close()
} catch {
/* 忽略 */
+31 -9
View File
@@ -10,7 +10,7 @@ import { invoke } from '@tauri-apps/api/core'
import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -98,6 +98,8 @@ const autoSwitchInterval = ref(5) // 分钟
const autoSwitchTargetGroup = ref('') //
const autoSwitchRegion = ref('') // =
let autoSwitchTimer: ReturnType<typeof setInterval> | null = null
/** 自动切换执行中标志(防重入:测速超时时上一轮未结束,间隔触发会重叠) */
let autoSwitchRunning = false
// store.settings
const syncAutoSwitchSettings = () => {
@@ -307,13 +309,19 @@ const loadProxiesWithError = async () => {
}
const init = async () => {
try {
// appData %APPDATA%
try {
appDataPath.value = await appDataDir()
} catch {
/* 忽略 */
}
try {
await Promise.all([store.loadSettings(), store.refreshKernel(), store.refreshStatus()])
} catch (e) {
logger.error('代理初始化失败: ' + e)
toast.error('代理模块初始化失败', { description: String(e) })
}
//
syncAutoSwitchSettings()
if (running.value) {
@@ -325,12 +333,17 @@ const init = async () => {
startAutoSwitch()
}
}
} finally {
// ""
store.initialized = true
}
}
onMounted(() => {
init()
statusTimer = setInterval(async () => {
// / tick
if (document.hidden) return
await store.refreshStatus()
}, 3000)
})
@@ -345,18 +358,28 @@ watch(running, async (val, old) => {
await store.waitForApi()
await store.refreshVersion()
await loadProxiesWithError()
// mihomo /
// handleStop
if (autoSwitchEnabled.value) {
startAutoSwitch()
}
}
})
// Tab
// Tab 10s Tab IPC
let lastAutoTestAt = 0
watch(activeTab, async (tab) => {
if (tab === 'proxies' && running.value) {
if (!Object.keys(store.proxies).length) {
await loadProxiesWithError()
}
const now = Date.now()
if (now - lastAutoTestAt > 10000) {
lastAutoTestAt = now
//
autoTestAllGroups()
}
}
})
// ===== =====
@@ -499,13 +522,14 @@ const onAutoSwitchIntervalChange = (val: unknown) => {
}
const runAutoSwitch = async () => {
if (autoSwitchRunning) return
autoSwitchRunning = true
try {
const groupName = autoSwitchTargetGroup.value || mainGroupName.value
if (!groupName || !running.value) return
const nodes = filteredNodes.value
if (!nodes.length) return
toast.info('正在测试节点延迟...')
try {
// 使 testDelayBatch store.proxies[name].history
// UI
await store.testDelayBatch(nodes)
@@ -532,13 +556,11 @@ const runAutoSwitch = async () => {
toast.success('已自动切换到最优节点', {
description: `${best.name} (${best.delay}ms)`
})
} else {
toast.success('当前节点已是最优', {
description: `${best.name} (${best.delay}ms)`
})
}
} catch (e) {
logger.error('自动切换失败: ' + e)
} finally {
autoSwitchRunning = false
}
}
@@ -1465,7 +1487,7 @@ tabsStore.registerSave(saveSettingsForm)
</div>
<p class="text-xs text-muted-foreground truncate">{{ p.url }}</p>
<p class="text-xs text-muted-foreground">
{{ formatSize(p.size) }} · 更新于 {{ p.updatedAt }}
{{ formatSize(p.size ?? 0) }} · 更新于 {{ p.updatedAt }}
</p>
</div>
<div class="flex gap-1 shrink-0">
+5 -4
View File
@@ -1,6 +1,7 @@
import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex'
import { invoke } from '@tauri-apps/api/core'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
const searchItems: SearchIndexItem[] = [
{
@@ -47,9 +48,9 @@ export const moduleConfig: ModuleConfig = {
onEnable: async () => {
// 若用户在代理设置中开启了"自动启动",则随模块启用而运行 mihomo
try {
const s = await invoke<{ autoStart?: boolean }>('proxy_get_settings')
const s = await commands.proxyGetSettings()
if (s.autoStart) {
await invoke('proxy_start')
await commands.proxyStart()
}
} catch {
/* 忽略:可能内核未安装 */
@@ -58,7 +59,7 @@ export const moduleConfig: ModuleConfig = {
// 禁用模块时一并关闭系统代理,避免代理已停但系统仍指向导致无法上网
onDisable: async () => {
try {
await invoke('proxy_clear_system_proxy')
await commands.proxyClearSystemProxy()
} catch {
/* 忽略:可能内核未运行 */
}
+23 -12
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
// Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings'
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, Terminal, History, FolderOpen, Ruler, Trash2 } from '@lucide/vue'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
// ===== =====
const query = ref('')
@@ -29,7 +31,7 @@ const moreHistoryItems = ref<QPItem[]>([])
const moreHistoryCount = ref(0)
// ===== localStorage =====
const HISTORY_KEY = 'thing_quickpanel_history'
const HISTORY_KEY = STORAGE_KEYS.quickpanelHistory
function loadHistory(): Record<string, number> {
try {
@@ -61,11 +63,17 @@ function applyHistoryBoost(items: QPItem[]): QPItem[] {
}
// ===== =====
/** 搜索请求序号:每次 doSearch 自增,过期请求(序号落后)结果直接丢弃,防止慢请求覆盖新结果 */
let searchSeq = 0
async function doSearch() {
const seq = ++searchSeq
const q = query.value.trim()
if (!q) {
// + + 3
results.value = applyHistoryBoost(await aggregateSearch(''))
const items = await aggregateSearch('')
if (seq !== searchSeq) return //
results.value = applyHistoryBoost(items)
selectedIndex.value = 0
// Accordion
moreHistoryItems.value = getMoreHistoryItems()
@@ -81,15 +89,18 @@ async function doSearch() {
loading.value = true
try {
const items = await aggregateSearch(q)
if (seq !== searchSeq) return //
results.value = applyHistoryBoost(items)
selectedIndex.value = 0
//
void loadAppIconsForResults(results.value)
} catch (e) {
if (seq !== searchSeq) return
console.error('[quickpanel] 搜索失败:', e)
results.value = []
} finally {
loading.value = false
// loading
if (seq === searchSeq) loading.value = false
}
}
@@ -103,7 +114,7 @@ watch(query, () => {
// ===== =====
async function hideWindow() {
try {
await invoke('quickpanel_hide_popup')
await commands.quickpanelHidePopup()
} catch {
/* 忽略 */
}
@@ -145,7 +156,7 @@ async function confirmDelete() {
if (!pd) return
pendingDelete.value = null
try {
await invoke('quickpanel_delete_file', { path: pd.path })
await commands.quickpanelDeleteFile(pd.path)
} catch (e) {
console.error('[quickpanel] 删除失败:', e)
}
@@ -294,7 +305,7 @@ const hasResults = () => results.value.length > 0
// ===== =====
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return {
@@ -372,13 +383,13 @@ onMounted(async () => {
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
const onStorage = (e: StorageEvent) => {
if (e.key === 'thing_app_settings') applyTheme()
if (e.key === STORAGE_KEYS.appSettings) applyTheme()
}
window.addEventListener('storage', onStorage)
unlistenFns.push(() => window.removeEventListener('storage', onStorage))
// + +
unlistenFns.push(await listen('quickpanel-show', async () => {
unlistenFns.push(await listen(EVENTS.quickpanelShow, async () => {
await applyTheme()
query.value = ''
await doSearch()
@@ -386,7 +397,7 @@ onMounted(async () => {
inputRef.value?.focus()
}))
unlistenFns.push(await listen('quickpanel-hide', () => {
unlistenFns.push(await listen(EVENTS.quickpanelHide, () => {
query.value = ''
results.value = []
}))
@@ -394,7 +405,7 @@ onMounted(async () => {
//
// setFileIndexReady
try {
const stats = await invoke<{ total: number }>('quickpanel_file_index_stats')
const stats = await commands.quickpanelFileIndexStats()
setFileIndexReady((stats?.total ?? 0) > 0)
} catch {
/* 索引未初始化,忽略 */
@@ -404,7 +415,7 @@ onMounted(async () => {
inputRef.value?.focus()
try {
await invoke('quickpanel_show_window')
await commands.quickpanelShowWindow()
} catch {
/* 忽略 */
}
+11 -7
View File
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { toast } from 'vue-sonner'
// Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings'
import { Command, Zap, Keyboard, Globe, Monitor, MousePointer2, FolderTree, RefreshCw, Plus, X, Loader2, Terminal, Pencil, Check } from '@lucide/vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { setFileIndexReady, invalidateCustomCommandsCache } from './providers'
import { STORAGE_KEYS } from '@/lib/constants'
interface CustomCommand {
id: string
@@ -44,7 +46,7 @@ const building = ref(false)
async function refreshStats() {
try {
indexStats.value = await invoke<IndexStats>('quickpanel_file_index_stats')
indexStats.value = await commands.quickpanelFileIndexStats()
// total > 0
setFileIndexReady((indexStats.value?.total ?? 0) > 0)
} catch (e) {
@@ -56,7 +58,7 @@ async function buildIndex() {
if (building.value) return
building.value = true
try {
const count = await invoke<number>('quickpanel_build_file_index')
const count = await commands.quickpanelBuildFileIndex()
toast.success(`索引完成,共 ${count}`)
await refreshStats()
} catch (e) {
@@ -88,7 +90,7 @@ function formatTime(t: number): string {
onMounted(async () => {
try {
const s = await invoke<QuickPanelSettings>('quickpanel_get_settings')
const s = await commands.quickpanelGetSettings()
Object.assign(form, s)
} catch (e) {
console.error('[quickpanel] 读取设置失败:', e)
@@ -99,9 +101,9 @@ onMounted(async () => {
// ===== =====
async function saveSettings() {
try {
await invoke('quickpanel_save_settings', { settings: { ...form } })
await commands.quickpanelSaveSettings({ ...form })
// localStorage
localStorage.setItem('thing_quickpanel_settings', JSON.stringify({ ...form }))
localStorage.setItem(STORAGE_KEYS.quickpanelSettings, JSON.stringify({ ...form }))
// 使
invalidateCustomCommandsCache()
toast.success('设置已保存')
@@ -231,12 +233,14 @@ async function clearShortcut() {
onUnmounted(() => {
window.removeEventListener('keydown', onRecordKey, true)
// activeTab=settings saveSettings
tabsStore.unregisterTabs()
})
// ===== =====
async function testPopup() {
try {
await invoke('quickpanel_show_popup')
await commands.quickpanelShowPopup()
} catch (e) {
console.error('[quickpanel] 唤起失败:', e)
toast.error('唤起失败')
+88
View File
@@ -0,0 +1,88 @@
/**
* Node test runner
* npm test
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { fuzzyScore, getTextForms, bestScore, type TextForms } from './engine.ts'
// ===== fuzzyScore 基础匹配 =====
test('空 query 返回 0,空 target 返回 -1', () => {
assert.equal(fuzzyScore('', 'abc'), 0)
assert.equal(fuzzyScore('abc', ''), -1)
})
test('精确匹配最高分 1.5(大小写不敏感)', () => {
assert.equal(fuzzyScore('abc', 'ABC'), 1.5)
assert.equal(fuzzyScore('hongkong', 'HongKong'), 1.5)
})
test('前缀匹配 1.2', () => {
assert.equal(fuzzyScore('ab', 'abc'), 1.2)
assert.equal(fuzzyScore('hk', 'hk-01'), 1.2)
})
test('包含匹配 1.0', () => {
assert.equal(fuzzyScore('bc', 'abc'), 1.0)
assert.equal(fuzzyScore('01', 'hk-01'), 1.0)
})
test('子序列匹配得分在 (0.5, 0.99) 区间', () => {
const s = fuzzyScore('ac', 'abc')
assert.ok(s > 0.5 && s <= 0.99, `子序列得分越界: ${s}`)
})
test('不匹配返回 -1', () => {
assert.equal(fuzzyScore('xyz', 'abc'), -1)
assert.equal(fuzzyScore('zz', 'ab'), -1)
})
test('连续命中加权高于非连续', () => {
const contiguous = fuzzyScore('ab', 'xab')
const sparse = fuzzyScore('ab', 'axb')
assert.ok(contiguous > sparse, `连续 ${contiguous} 应高于非连续 ${sparse}`)
})
test('首字母命中加权:target 开头的 query 得分更高', () => {
const atStart = fuzzyScore('a', 'abc')
const inMiddle = fuzzyScore('a', 'xac')
assert.ok(atStart > inMiddle)
})
// ===== getTextForms 形态生成(含拼音) =====
test('纯英文文本:全拼/首字母回退为原文,多单词首字母独立', () => {
const forms = getTextForms('Visual Studio Code')
assert.deepEqual(forms, ['visual studio code', 'visual studio code', 'visual studio code', 'vsc'])
})
test('中文文本生成拼音全拼与首字母', () => {
const forms = getTextForms('香港')
assert.equal(forms[0], '香港')
assert.equal(forms[1], 'xianggang')
assert.equal(forms[2], 'xg')
})
test('同一文本形态结果按内容缓存', () => {
assert.equal(getTextForms('香港'), getTextForms('香港'))
})
// ===== bestScore 多形态取最高分 =====
test('bestScore 对多形态取最高分(中文拼音可匹配)', () => {
const forms: TextForms = getTextForms('香港')
// 拼音全拼命中(子序列)
const byPinyin = bestScore('xiang', forms)
// 原文命中
const byText = bestScore('香港', forms)
assert.ok(byText >= byPinyin, `原文匹配 ${byText} 应不低于拼音 ${byPinyin}`)
assert.ok(byPinyin > 0, `拼音子序列应能匹配: ${byPinyin}`)
// 完全不匹配
assert.equal(bestScore('zzzz', forms), -1)
})
test('bestScore 支持首字母命中', () => {
const forms: TextForms = getTextForms('香港')
assert.ok(bestScore('xg', forms) > 0, '首字母应能匹配')
})
+12 -6
View File
@@ -6,7 +6,9 @@
* - query +
* - item
*
* WeakMap
*
* host WeakMap
* text
*/
import { pinyin } from 'pinyin-pro'
@@ -14,7 +16,9 @@ import { pinyin } from 'pinyin-pro'
/** 一组待匹配的文本形态(原文 / 全拼或原文 / 首字母 / 多单词首字母) */
export type TextForms = readonly [string, string, string, string]
const formsCache = new WeakMap<object, TextForms>()
const formsCache = new Map<string, TextForms>()
/** 缓存上限:超过后整体清空(拼音计算开销小,缓存仅用于避免高频重复计算) */
const FORMS_CACHE_MAX = 2000
/** 判断字符串是否含 CJK 字符(需转拼音) */
function hasCJK(s: string): boolean {
@@ -38,10 +42,10 @@ function extractWordInitials(text: string): string {
/**
* [(), (), (), ()]
* 退 "Visual Studio Code" "vsc"
* host
* text
*/
export function getTextForms(text: string, host: object): TextForms {
const cached = formsCache.get(host)
export function getTextForms(text: string): TextForms {
const cached = formsCache.get(text)
if (cached) return cached
const lower = text.toLowerCase()
@@ -59,7 +63,9 @@ export function getTextForms(text: string, host: object): TextForms {
const firstStr = full.map(s => s.charAt(0)).join('').toLowerCase()
forms = [lower, fullStr, firstStr, initials]
}
formsCache.set(host, forms)
formsCache.set(text, forms)
// 防止缓存无限增长(拼音计算本身开销小,超限时整体清空即可)
if (formsCache.size > FORMS_CACHE_MAX) formsCache.clear()
return forms
}
+5 -5
View File
@@ -1,5 +1,7 @@
import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
const searchItems: SearchIndexItem[] = [
{
@@ -21,11 +23,10 @@ export const moduleConfig: ModuleConfig = {
lifecycle: {
// 模块启用:读取设置并注册全局快捷键
onEnable: async () => {
const { invoke } = await import('@tauri-apps/api/core')
try {
const settings = await invoke<{ shortcut: string }>('quickpanel_get_settings')
const settings = await commands.quickpanelGetSettings()
if (settings.shortcut) {
await invoke('quickpanel_register_shortcut', { shortcut: settings.shortcut })
await commands.quickpanelRegisterShortcut(settings.shortcut)
}
} catch (e) {
console.error('[quickpanel] onEnable 注册快捷键失败:', e)
@@ -33,9 +34,8 @@ export const moduleConfig: ModuleConfig = {
},
// 模块禁用:注销全局快捷键
onDisable: async () => {
const { invoke } = await import('@tauri-apps/api/core')
try {
await invoke('quickpanel_unregister_shortcut')
await commands.quickpanelUnregisterShortcut()
} catch (e) {
console.error('[quickpanel] onDisable 注销快捷键失败:', e)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
/**
* Provider
* Provider
*/
import type { QPItem, QPProvider } from './types'
import { appRankFromPath } from './utils'
import { HistoryProvider } from './history'
import { CommandProvider } from './command'
import { CustomCommandProvider } from './customCommand'
import { AppProvider } from './app'
import { FileProvider } from './file'
import { ClipboardProvider } from './clipboard'
import { CalcProvider } from './calc'
import { UnitProvider } from './unit'
import { SpecialProvider } from './special'
import { SystemProvider } from './system'
import { WebProvider } from './web'
let providers: QPProvider[] | null = null
export function getProviders(): QPProvider[] {
if (!providers) {
providers = [
new HistoryProvider(),
new CommandProvider(),
new CustomCommandProvider(),
new AppProvider(),
new FileProvider(),
new ClipboardProvider(),
new CalcProvider(),
new UnitProvider(),
new SpecialProvider(),
new SystemProvider(),
new WebProvider(),
]
}
return providers
}
/**
* Provider score
* command Provider + system Provider
*/
export async function aggregateSearch(query: string): Promise<QPItem[]> {
const all = getProviders()
const results = await Promise.all(all.map(p => Promise.resolve(p.search(query))))
const merged: QPItem[] = []
results.forEach((items, idx) => {
items.forEach(item => {
// 未打分的项赋予基础分(按 provider 优先级递减)
if (item.score === undefined) {
item.score = (10 - idx) * 0.01
}
merged.push(item)
})
})
// 去重:所有来源的「应用」(含文件索引中的 .lnk)按名称归并,保留可靠性最高的来源
// 可靠性:开始菜单(appRank 0) > 桌面(1) > 其他位置(2);同可靠性时保留分数更高的
// (如 "TRAE Work CN" 在开始菜单 + 桌面 + 某索引目录都有 .lnk,只留开始菜单那条)
const appKey = (title: string): string => {
let t = title.trim().toLowerCase()
if (t.endsWith('.lnk')) t = t.slice(0, -4).trim()
return t
}
// 应用候选:应用分组,以及文件分组中的 .lnk 快捷方式
const isAppLike = (item: QPItem): boolean => {
if (item.group === '应用') return true
if (item.group === '文件' && item.title && item.title.toLowerCase().endsWith('.lnk')) return true
return false
}
const bestAppByKey = new Map<string, QPItem>()
for (const item of merged) {
if (!isAppLike(item) || !item.title) continue
const key = appKey(item.title)
const prev = bestAppByKey.get(key)
if (!prev) {
bestAppByKey.set(key, item)
continue
}
// 比较可靠性:appRank 越小越可靠;文件分组 .lnk 无 appRank 时按路径推断
const rankOf = (i: QPItem): number => {
if (i.appRank !== undefined) return i.appRank
if (i.group === '文件') return appRankFromPath(i.subtitle ?? '')
return 2
}
const rankA = rankOf(item)
const rankB = rankOf(prev)
if (rankA < rankB || (rankA === rankB && (item.score ?? 0) > (prev.score ?? 0))) {
bestAppByKey.set(key, item)
}
}
const keptAppIds = new Set(Array.from(bestAppByKey.values()).map(i => i.id))
const deduped = merged.filter(item => {
if (!isAppLike(item)) return true
return keptAppIds.has(item.id)
})
deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
return deduped
}
+113
View File
@@ -0,0 +1,113 @@
/**
* app Provider
* 1 IPC Map
*/
import { invoke } from '@tauri-apps/api/core'
import { bestScore } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms, makeAppLaunch, makeAppSubActions } from './utils'
interface AppRecord {
name: string
path: string
}
let appCache: AppRecord[] | null = null
let appCacheTime = 0
const APP_CACHE_TTL = 60_000 // 1 分钟缓存
async function loadApps(): Promise<AppRecord[]> {
if (appCache && Date.now() - appCacheTime < APP_CACHE_TTL) {
return appCache
}
try {
const apps = await invoke<AppRecord[]>('quickpanel_scan_apps')
appCache = apps
appCacheTime = Date.now()
return apps
} catch (e) {
console.error('[quickpanel] 扫描应用失败:', e)
return []
}
}
export class AppProvider implements QPProvider {
id = 'app'
label = '应用'
priority = 95
async search(query: string): Promise<QPItem[]> {
const apps = await loadApps()
if (!query.trim()) {
// 空查询:不显示应用(避免列表过长),由命令入口承担
return []
}
const results: Array<{ item: QPItem; score: number }> = []
let idx = 0
for (const app of apps) {
const forms = buildItemForms(app.name)
const score = bestScore(query, forms)
if (score >= 0) {
results.push({
item: {
id: `app-${idx}`,
title: app.name,
subtitle: app.path,
group: '应用',
iconPath: app.path,
action: makeAppLaunch(app.path),
subActions: makeAppSubActions(app.path),
appRank: 0, // 开始菜单:最可靠来源
},
score,
})
}
idx++
}
results.sort((a, b) => b.score - a.score)
return results.slice(0, 15).map(r => ({ ...r.item, score: r.score }))
}
}
// ===== 应用图标按需加载 =====
// 前端缓存(path -> dataUrl)。Rust 侧另有内存 + 磁盘缓存,此处仅避免重复 IPC。
const appIconCache = new Map<string, string>() // path -> dataUrl('' = 无图标)
/** iconPath data URL
* item.iconUrl
* Rust Rust */
export async function loadAppIconsForResults(items: QPItem[]): Promise<void> {
const toLoad: QPItem[] = []
for (const item of items) {
if (!item.iconPath) continue
if (item.iconUrl !== undefined) continue // 已设置(含加载中)
const cached = appIconCache.get(item.iconPath)
if (cached !== undefined) {
item.iconUrl = cached
} else {
item.iconUrl = '' // 标记加载中,避免重复请求
toLoad.push(item)
}
}
if (!toLoad.length) return
await Promise.all(
toLoad.map(async item => {
const path = item.iconPath!
try {
const url = await invoke<string | null>('quickpanel_get_app_icon', { path })
const u = url ?? ''
appIconCache.set(path, u)
item.iconUrl = u
} catch {
appIconCache.set(path, '')
item.iconUrl = ''
}
}),
)
}
/** 清空前端图标缓存(Rust 端清理命令 quickpanel_clear_app_icon_cache 调用后可一并清空) */
export function invalidateAppIconCache() {
appIconCache.clear()
}
+40
View File
@@ -0,0 +1,40 @@
/**
* calc Provider
* CSP 使 evaluateExpression Function CSP unsafe-eval
*/
import { evaluateExpression } from '@/lib/calc'
import type { QPItem, QPProvider } from './types'
const CALC_RE = /^[\d\s+\-*/().%]+$/
export class CalcProvider implements QPProvider {
id = 'calc'
label = '计算'
priority = 90
search(query: string): QPItem[] {
const trimmed = query.trim()
if (!trimmed) return []
// 必须至少包含一个运算符和一个数字
if (!CALC_RE.test(trimmed)) return []
if (!/[\d]/.test(trimmed) || !/[+\-*/%]/.test(trimmed)) return []
const result = evaluateExpression(trimmed)
if (result === null) return []
const display = String(result)
return [{
id: 'calc-result',
title: display,
subtitle: `= ${trimmed}`,
group: '计算',
score: 0.95,
action: async () => {
try {
await navigator.clipboard.writeText(display)
} catch {
/* 忽略剪贴板失败 */
}
},
}]
}
}
@@ -0,0 +1,38 @@
/**
* clipboard Provider
* invoke
*/
import type { QPItem, QPProvider } from './types'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
export class ClipboardProvider implements QPProvider {
id = 'clipboard'
label = '剪贴板'
priority = 70
async search(query: string): Promise<QPItem[]> {
if (!query.trim() || query.trim().length < 2) return []
try {
// 返回 HistoryPage{ items, total }),此处取 items
const page = await commands.clipboardSearch(query.trim(), 8, 0)
return page.items.map((c) => ({
id: `clip-${c.id}`,
title: c.preview.slice(0, 80),
subtitle: `${c.kind === 'text' ? '文本' : c.kind === 'image' ? '图片' : '文件'}`,
group: '剪贴板',
score: 0.5,
action: async () => {
try {
await commands.clipboardCopyBack(c.id)
} catch (e) {
console.error('[quickpanel] 复制失败:', e)
}
},
}))
} catch {
// 剪贴板模块可能未启用,静默忽略
return []
}
}
}
@@ -0,0 +1,72 @@
/**
* command Provider
* store localStorage
* emit `quickpanel-execute-command`
*/
import { emit } from '@tauri-apps/api/event'
import { bestScore } from '../engine'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
const COMMANDS_KEY = STORAGE_KEYS.quickpanelCommands
interface CachedCommand {
moduleId: string
moduleName: string
title: string
description?: string
keywords: string[]
}
function loadCommands(): CachedCommand[] {
try {
const raw = localStorage.getItem(COMMANDS_KEY)
if (!raw) return []
return JSON.parse(raw) as CachedCommand[]
} catch {
return []
}
}
export class CommandProvider implements QPProvider {
id = 'command'
label = '命令'
priority = 100
search(query: string): QPItem[] {
const commands = loadCommands()
if (!query.trim() || !commands.length) {
// 无输入时返回前几条命令作为快捷入口
if (!query.trim()) {
return commands.slice(0, 6).map((c, i) => this.toItem(c, i))
}
return []
}
const results: Array<{ item: QPItem; score: number }> = []
commands.forEach((c, idx) => {
const forms = buildItemForms(c.title, c.keywords)
const score = bestScore(query, forms)
if (score >= 0) {
const item = this.toItem(c, idx)
results.push({ item, score })
}
})
results.sort((a, b) => b.score - a.score)
return results.map(r => ({ ...r.item, score: r.score }))
}
private toItem(c: CachedCommand, idx: number): QPItem {
return {
id: `cmd-${c.moduleId}-${idx}`,
title: c.title,
subtitle: c.description || c.moduleName,
group: '命令',
action: async () => {
// 通知主窗口切换到对应模块
await emit(EVENTS.quickpanelExecuteCommand, { moduleId: c.moduleId })
},
}
}
}
@@ -0,0 +1,71 @@
/**
* customCommand Provider
* Rustquickpanel_get_settings IPC
* invalidateCustomCommandsCache
*/
import { bestScore } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
interface CustomCommandConfig {
id: string
title: string
command: string
args: string[]
}
let customCommandsCache: CustomCommandConfig[] | null = null
async function loadCustomCommands(): Promise<CustomCommandConfig[]> {
if (customCommandsCache) return customCommandsCache
try {
const s = await commands.quickpanelGetSettings()
customCommandsCache = (s.customCommands as CustomCommandConfig[] | undefined) || []
return customCommandsCache
} catch {
return []
}
}
/** 设置页保存后调用,清除缓存使下次搜索重新加载 */
export function invalidateCustomCommandsCache() {
customCommandsCache = null
}
export class CustomCommandProvider implements QPProvider {
id = 'custom'
label = '自定义'
priority = 92
async search(query: string): Promise<QPItem[]> {
const cmds = await loadCustomCommands()
if (!query.trim()) return []
const results: Array<{ item: QPItem; score: number }> = []
for (const cmd of cmds) {
const forms = buildItemForms(cmd.title)
const score = bestScore(query, forms)
if (score >= 0) {
results.push({
item: {
id: `custom-${cmd.id}`,
title: cmd.title,
subtitle: cmd.command,
group: '自定义',
action: async () => {
try {
await commands.quickpanelRunCustomCommand(cmd.command, cmd.args)
} catch (e) {
console.error('[quickpanel] 自定义命令执行失败:', e)
}
},
},
score,
})
}
}
results.sort((a, b) => b.score - a.score)
return results.map(r => ({ ...r.item, score: r.score }))
}
}
+112
View File
@@ -0,0 +1,112 @@
/**
* file Provider
* .lnk
*/
import type { QPItem, QPProvider } from './types'
import { appRankFromPath, makeAppLaunch, makeAppSubActions } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
let fileIndexReady = false
export class FileProvider implements QPProvider {
id = 'file'
label = '文件'
priority = 85
async search(query: string): Promise<QPItem[]> {
if (!query.trim() || query.trim().length < 2) return []
if (!fileIndexReady) return []
try {
const files = await commands.quickpanelSearchFiles(query.trim(), 20)
return files.map((f, idx) => {
// .lnk 快捷方式按应用处理:带图标、用启动命令,并与开始菜单应用统一去重
// 注意:Rust 返回的 ext 不带点(如 "lnk"),这里直接按文件名判断最稳妥
const isLnk = !f.isDir && f.name.toLowerCase().endsWith('.lnk')
if (isLnk) {
return {
id: `file-app-${idx}`,
title: f.name,
subtitle: f.path,
group: '应用',
score: 0.55, // 略低于开始菜单应用(0.6+),去重时让位于开始菜单
iconPath: f.path,
action: makeAppLaunch(f.path),
subActions: makeAppSubActions(f.path, true),
deleteInfo: { path: f.path, isDir: false },
appRank: appRankFromPath(f.path),
}
}
const openFile = async () => {
try {
// 目录:Rust 端用 explorer.exe 打开;文件:默认程序打开(无关联时 fallback 打开方式)
await commands.quickpanelOpenFile(f.path)
} catch (e) {
console.error('[quickpanel] 打开文件失败:', e)
}
}
return {
id: `file-${idx}`,
title: f.name,
subtitle: f.path,
group: '文件',
score: 0.6,
action: openFile,
// 目录:打开即导航到该目录,无需再提供「在资源管理器中显示」,避免重复
subActions: [
{
id: 'open',
label: f.isDir ? '打开文件夹' : '打开',
action: openFile,
},
...(f.isDir
? []
: [{
id: 'reveal',
label: '在资源管理器中显示',
action: async () => {
try {
await commands.quickpanelRevealInExplorer(f.path)
} catch (e) {
console.error('[quickpanel] 资源管理器显示失败:', e)
}
},
}]),
{
id: 'copy-path',
label: '复制路径',
action: async () => {
try {
await navigator.clipboard.writeText(f.path)
} catch {
/* 忽略 */
}
},
},
{
id: 'delete',
label: '删除',
action: async () => {
try {
// 移到回收站(PowerShell + Microsoft.VisualBasic
await commands.quickpanelDeleteFile(f.path)
} catch (e) {
console.error('[quickpanel] 删除失败:', e)
}
},
},
],
deleteInfo: { path: f.path, isDir: f.isDir },
}
})
} catch (e) {
console.error('[quickpanel] 文件搜索失败:', e)
return []
}
}
}
/** 由设置页在索引构建完成后调用,启用 file Provider */
export function setFileIndexReady(ready: boolean) {
fileIndexReady = ready
}
+107
View File
@@ -0,0 +1,107 @@
/**
* history Provider
* localStorage
* action
*/
import { STORAGE_KEYS } from '@/lib/constants'
import type { HistoryEntry, QPItem, QPProvider } from './types'
import { aggregateSearch } from './aggregate'
const HISTORY_ITEMS_KEY = STORAGE_KEYS.quickpanelHistoryItems
const HISTORY_MAX = 50
/** 空查询时默认展示的历史条数(置顶部分) */
export const HISTORY_PREVIEW_COUNT = 3
function loadHistoryEntries(): HistoryEntry[] {
try {
const raw = localStorage.getItem(HISTORY_ITEMS_KEY)
if (!raw) return []
return JSON.parse(raw) as HistoryEntry[]
} catch {
return []
}
}
function saveHistoryEntries(entries: HistoryEntry[]) {
localStorage.setItem(HISTORY_ITEMS_KEY, JSON.stringify(entries.slice(0, HISTORY_MAX)))
}
/** 将一条历史记录转换为可执行的 QPItem */
function buildHistoryItem(e: HistoryEntry): QPItem {
return {
id: `history-${e.id}`,
title: e.title,
subtitle: e.subtitle,
group: '历史',
iconPath: e.iconPath,
historyQuery: e.query,
action: async () => {
// 重新搜索恢复 action 并执行
try {
const results = await aggregateSearch(e.query)
// 按 id 精确匹配原 item
const target = results.find(r => r.id === e.id) ?? results.find(r => r.title === e.title)
if (target) {
await target.action()
}
} catch (err) {
console.error('[quickpanel] 历史项执行失败:', err)
}
},
}
}
/** QuickPanel.vue item
* query action */
export function recordHistoryItem(item: QPItem, query: string) {
if (!item.id || item.group === '历史') return // 历史项自身不重复记录
const entries = loadHistoryEntries()
// 去重:同 id 移除旧的,插到头部
const filtered = entries.filter(e => e.id !== item.id)
filtered.unshift({
id: item.id,
title: item.title,
subtitle: item.subtitle,
group: item.group,
iconPath: item.iconPath,
query: query || item.title,
timestamp: Date.now(),
})
saveHistoryEntries(filtered.slice(0, HISTORY_MAX))
}
/** 清空历史记录 */
export function clearHistory() {
localStorage.removeItem(HISTORY_ITEMS_KEY)
}
/** 获取置顶的历史项(最近 N 条),用于空查询时在结果列表顶部显示 */
export function getTopHistoryItems(): QPItem[] {
const entries = loadHistoryEntries()
return entries.slice(0, HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
}
/** 获取置顶历史之后的剩余历史项,用于 Accordion 折叠显示 */
export function getMoreHistoryItems(): QPItem[] {
const entries = loadHistoryEntries()
return entries.slice(HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
}
/** 获取剩余历史数量(用于 Accordion 标题显示) */
export function getMoreHistoryCount(): number {
const entries = loadHistoryEntries()
return Math.max(0, entries.length - HISTORY_PREVIEW_COUNT)
}
export class HistoryProvider implements QPProvider {
id = 'history'
label = '历史'
priority = 99 // 最高优先级,空查询时显示在最前
async search(query: string): Promise<QPItem[]> {
if (query.trim()) return [] // 历史只在空查询时显示
// 只返回置顶3条,剩余由 Accordion 承载
return getTopHistoryItems()
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Provider
*
* store
* - command Provider localStorage
* emit `quickpanel-execute-command`
* - system/web/calc Provider + Rust invoke
*
* API
*/
export type { QPItem, QPSubAction, QPProvider } from './types'
export { getProviders, aggregateSearch } from './aggregate'
export { loadAppIconsForResults, invalidateAppIconCache } from './app'
export { setFileIndexReady } from './file'
export { invalidateCustomCommandsCache } from './customCommand'
export {
HISTORY_PREVIEW_COUNT,
recordHistoryItem,
clearHistory,
getTopHistoryItems,
getMoreHistoryItems,
getMoreHistoryCount,
} from './history'
@@ -0,0 +1,93 @@
/**
* special ProviderWindows
* Rust quickpanel_get_special_locations1
*/
import { bestScore } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定与类型(bindings.ts
import { commands, type SpecialLocation } from '@/lib/bindings'
let specialCache: SpecialLocation[] | null = null
let specialCacheTime = 0
const SPECIAL_CACHE_TTL = 60_000 // 1 分钟缓存
async function loadSpecials(): Promise<SpecialLocation[]> {
if (specialCache && Date.now() - specialCacheTime < SPECIAL_CACHE_TTL) {
return specialCache
}
try {
const list = await commands.quickpanelGetSpecialLocations()
specialCache = list
specialCacheTime = Date.now()
return list
} catch (e) {
console.error('[quickpanel] 获取快捷位置失败:', e)
return []
}
}
export class SpecialProvider implements QPProvider {
id = 'special'
label = '快捷'
priority = 60
async search(query: string): Promise<QPItem[]> {
const list = await loadSpecials()
if (!list.length) return []
if (!query.trim()) return [] // 空查询不占用列表,由用户主动搜索
const open = async (s: SpecialLocation) => {
try {
await commands.quickpanelOpenSpecial(s.kind, s.target, s.args)
} catch (e) {
console.error('[quickpanel] 打开快捷位置失败:', e)
}
}
const items: QPItem[] = list.map(s => ({
id: `sp-${s.id}`,
title: s.title,
subtitle: s.subtitle,
group: '快捷',
action: () => open(s),
subActions:
s.kind === 'file'
? [
{ id: 'open', label: '打开', action: () => open(s) },
{
id: 'reveal',
label: '在资源管理器中显示',
action: async () => {
try {
await commands.quickpanelRevealInExplorer(s.target)
} catch (e) {
console.error('[quickpanel] 资源管理器显示失败:', e)
}
},
},
{
id: 'copy-path',
label: '复制路径',
action: async () => {
try {
await navigator.clipboard.writeText(s.target)
} catch {
/* 忽略 */
}
},
},
]
: undefined,
}))
const scored: Array<{ item: QPItem; score: number }> = []
items.forEach((item, idx) => {
const forms = buildItemForms(item.title, list[idx].keywords)
const score = bestScore(query, forms)
if (score >= 0) scored.push({ item, score })
})
scored.sort((a, b) => b.score - a.score)
return scored.slice(0, 8).map(s => ({ ...s.item, score: s.score }))
}
}
+176
View File
@@ -0,0 +1,176 @@
/**
* system Provider
* regedit / cmd / powershell title
* keywords / title CJK
*/
import { invoke } from '@tauri-apps/api/core'
import { bestScore, type TextForms } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
interface SystemCommandDef {
id: string
title: string
subtitle: string
/** 额外关键词(英文命令名、中文别名等,用于匹配) */
keywords: string[]
command: string
args: string[]
}
const SYSTEM_COMMANDS: SystemCommandDef[] = [
{
id: 'sys-regedit',
title: '注册表编辑器',
subtitle: 'regedit',
keywords: ['regedit', '注册表', 'registry'],
command: 'regedit',
args: [],
},
{
id: 'sys-cmd',
title: '命令提示符',
subtitle: 'cmd',
keywords: ['cmd', '命令行', '终端', 'command'],
command: 'cmd',
args: [],
},
{
id: 'sys-powershell',
title: 'PowerShell',
subtitle: 'powershell',
keywords: ['powershell', 'pwsh'],
command: 'powershell',
args: [],
},
{
id: 'sys-taskmgr',
title: '任务管理器',
subtitle: 'taskmgr',
keywords: ['taskmgr', '任务管理', '进程'],
command: 'taskmgr',
args: [],
},
{
id: 'sys-explorer',
title: '资源管理器',
subtitle: 'explorer',
keywords: ['explorer', '文件管理器', '资源管理'],
command: 'explorer',
args: [],
},
{
id: 'sys-control',
title: '控制面板',
subtitle: 'control',
keywords: ['control', '控制面板', '设置'],
command: 'control',
args: [],
},
{
id: 'sys-shutdown',
title: '关机',
subtitle: 'shutdown /s /t 0',
keywords: ['shutdown', '关闭计算机', '关闭电脑', 'guanji'],
command: 'shutdown',
args: ['/s', '/t', '0'],
},
{
id: 'sys-restart',
title: '重启',
subtitle: 'shutdown /r /t 0',
keywords: ['restart', 'reboot', '重新启动', '重启电脑', 'chongqi'],
command: 'shutdown',
args: ['/r', '/t', '0'],
},
{
id: 'sys-shutdown-cancel',
title: '取消关机/重启',
subtitle: 'shutdown /a',
keywords: ['cancel', '取消', 'quxiao', 'abort'],
command: 'shutdown',
args: ['/a'],
},
{
id: 'sys-hibernate',
title: '休眠',
subtitle: 'shutdown /h',
keywords: ['hibernate', '睡眠', 'xiu', 'mian'],
command: 'shutdown',
args: ['/h'],
},
]
export class SystemProvider implements QPProvider {
id = 'system'
label = '系统'
priority = 40
private buildItems(): QPItem[] {
const items: QPItem[] = SYSTEM_COMMANDS.map(def => ({
id: def.id,
title: def.title,
subtitle: def.subtitle,
group: '系统',
action: async () => {
try {
await commands.quickpanelRunSystemCommand(def.command, def.args)
} catch (e) {
console.error('[quickpanel] 系统命令失败:', e)
}
},
}))
// 锁屏 + 退出 应用本身
items.push(
{
id: 'sys-lock',
title: '锁定屏幕',
subtitle: '立即锁定计算机',
group: '系统',
action: async () => {
try {
await commands.quickpanelLockScreen()
} catch (e) {
console.error('[quickpanel] 锁屏失败:', e)
}
},
},
{
id: 'sys-quit',
title: '退出 Thing',
subtitle: '关闭应用程序',
group: '系统',
action: async () => {
try {
await invoke('quit_app')
} catch (e) {
console.error('[quickpanel] 退出失败:', e)
}
},
},
)
return items
}
/** 为带 keywords 的 item 构建匹配形态(title + keywords 合并) */
private itemForms(item: QPItem): TextForms {
const def = SYSTEM_COMMANDS.find(d => d.id === item.id)
return buildItemForms(item.title, def?.keywords ?? [])
}
search(query: string): QPItem[] {
const items = this.buildItems()
if (!query.trim()) return items
const scored: Array<{ item: QPItem; score: number }> = []
for (const item of items) {
const forms = this.itemForms(item)
const score = bestScore(query, forms)
if (score >= 0) scored.push({ item, score })
}
scored.sort((a, b) => b.score - a.score)
return scored.map(s => ({ ...s.item, score: s.score }))
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Provider
* Provider search(query) group QPItem
*/
/** 子动作(项的右键/展开菜单) */
export interface QPSubAction {
id: string
label: string
action: () => void | Promise<void>
}
export interface QPItem {
id: string
title: string
subtitle?: string
group: string
score?: number
/** 应用图标 data URL'' = 加载中,undefined = 无图标项) */
iconUrl?: string
/** 应用路径(仅 app 项设置,用于按需获取图标) */
iconPath?: string
/** 执行动作(调用方在执行后负责隐藏窗口) */
action: () => void | Promise<void>
/** 子动作菜单(可选)。执行子动作后同样隐藏窗口 */
subActions?: QPSubAction[]
/** 删除确认信息(仅可删除项设置,如文件/文件夹,用于弹窗确认后执行删除) */
deleteInfo?: { path: string; isDir: boolean }
/** 用于历史记录的查询文本(仅历史项设置,点击历史时用此重新搜索恢复 action) */
historyQuery?: string
/** 应用可靠性排序(仅 group='应用' 项设置,越小越可靠:开始菜单 0 / 桌面 1 / 其他 2) */
appRank?: number
}
export interface QPProvider {
id: string
label: string
priority: number
/** 返回当前 query 的候选结果(引擎尚未打分,score 可留空) */
search(query: string): QPItem[] | Promise<QPItem[]>
}
/** 历史记录条目(history Provider 持久化到 localStorage */
export interface HistoryEntry {
id: string
title: string
subtitle?: string
group: string
iconPath?: string
/** 记录时的查询文本,用于点击历史项时重新搜索恢复 action */
query: string
timestamp: number
}
+296
View File
@@ -0,0 +1,296 @@
/**
* unit Provider / / /
* open.er-api.com仿
*/
import { STORAGE_KEYS } from '@/lib/constants'
import type { QPItem, QPProvider } from './types'
interface UnitDef {
/** 可匹配的符号(含中文),小写优先;带 exactCase 的单位只做精确大小写匹配 */
symbols: string[]
label: string
/** 与基准单位的换算系数(基准单位 = 1) */
factor: number
/** 仅精确大小写匹配(如小写 m = 米,避免与 MB 混淆) */
exactCase?: boolean
}
interface UnitCategory {
id: string
name: string
units: UnitDef[]
}
const UNIT_CATEGORIES: UnitCategory[] = [
{
id: 'length',
name: '长度',
units: [
{ symbols: ['m', 'meter', 'meters', '米', '公尺'], label: '米', factor: 1, exactCase: true },
{ symbols: ['km', 'kilometer', 'kilometers', '千米', '公里'], label: '千米', factor: 1000 },
{ symbols: ['cm', 'centimeter', 'centimeters', '厘米'], label: '厘米', factor: 0.01 },
{ symbols: ['mm', 'millimeter', 'millimeters', '毫米'], label: '毫米', factor: 0.001 },
{ symbols: ['in', 'inch', 'inches', '英寸'], label: '英寸', factor: 0.0254 },
{ symbols: ['ft', 'foot', 'feet', '英尺'], label: '英尺', factor: 0.3048 },
{ symbols: ['yd', 'yard', 'yards', '码'], label: '码', factor: 0.9144 },
{ symbols: ['mi', 'mile', 'miles', '英里'], label: '英里', factor: 1609.344 },
{ symbols: ['里', 'li'], label: '里', factor: 500 },
],
},
{
id: 'data',
name: '数据',
units: [
{ symbols: ['b', 'byte', 'bytes', '字节'], label: '字节', factor: 1 },
{ symbols: ['kb', 'kib', 'kilobyte', 'kilobytes', '千字节'], label: 'KB', factor: 1024 },
{ symbols: ['mb', 'mib', 'megabyte', 'megabytes', '兆字节'], label: 'MB', factor: 1024 ** 2 },
{ symbols: ['gb', 'gib', 'gigabyte', 'gigabytes', '吉字节'], label: 'GB', factor: 1024 ** 3 },
{ symbols: ['tb', 'tib', 'terabyte', 'terabytes', '太字节'], label: 'TB', factor: 1024 ** 4 },
{ symbols: ['bit', 'bits', '比特'], label: 'bit', factor: 1 / 8 },
],
},
{
id: 'speed',
name: '网速',
units: [
{ symbols: ['bps', '比特/秒'], label: 'bps', factor: 1 },
{ symbols: ['kbps', '千比特/秒'], label: 'Kbps', factor: 1024 },
{ symbols: ['mbps', '兆比特/秒'], label: 'Mbps', factor: 1024 ** 2 },
{ symbols: ['gbps', '吉比特/秒'], label: 'Gbps', factor: 1024 ** 3 },
{ symbols: ['b/s'], label: 'B/s', factor: 8 },
{ symbols: ['kb/s'], label: 'KB/s', factor: 8 * 1024 },
{ symbols: ['mb/s'], label: 'MB/s', factor: 8 * 1024 ** 2 },
{ symbols: ['gb/s'], label: 'GB/s', factor: 8 * 1024 ** 3 },
],
},
{
id: 'time',
name: '时间',
units: [
{ symbols: ['s', 'sec', 'secs', 'second', 'seconds', '秒'], label: '秒', factor: 1 },
{ symbols: ['min', 'mins', 'minute', 'minutes', '分钟', '分'], label: '分钟', factor: 60 },
{ symbols: ['h', 'hr', 'hrs', 'hour', 'hours', '小时', '时'], label: '小时', factor: 3600 },
{ symbols: ['day', 'days', '天', '日'], label: '天', factor: 86400 },
{ symbols: ['week', 'weeks', '周', '星期'], label: '周', factor: 604800 },
{ symbols: ['year', 'years', '年'], label: '年', factor: 31536000 },
],
},
{
id: 'weight',
name: '重量',
units: [
{ symbols: ['kg', '千克', '公斤'], label: '千克', factor: 1 },
{ symbols: ['g', 'gram', 'grams', '克'], label: '克', factor: 0.001 },
{ symbols: ['mg', 'milligram', '毫克'], label: '毫克', factor: 1e-6 },
{ symbols: ['t', 'ton', 'tons', '吨'], label: '吨', factor: 1000 },
{ symbols: ['lb', 'lbs', 'pound', 'pounds', '磅'], label: '磅', factor: 0.45359237 },
{ symbols: ['oz', 'ounce', 'ounces', '盎司'], label: '盎司', factor: 0.028349523125 },
{ symbols: ['斤', 'jin'], label: '斤', factor: 0.5 },
{ symbols: ['两', 'liang'], label: '两', factor: 0.05 },
],
},
]
// ===== 货币换算(汇率动态获取,带本地缓存与兜底值) =====
const DEFAULT_CURRENCY_RATES: Record<string, number> = {
usd: 1,
cny: 7.2,
eur: 0.92,
gbp: 0.78,
jpy: 156,
hkd: 7.8,
}
const CURRENCY_CACHE_KEY = STORAGE_KEYS.currencyRates
function getCurrencyRates(): Record<string, number> {
try {
const raw = localStorage.getItem(CURRENCY_CACHE_KEY)
if (raw) {
const p = JSON.parse(raw)
if (p?.rates && Date.now() - p.ts < 24 * 3600 * 1000) return p.rates
}
} catch {
/* 忽略损坏缓存 */
}
return DEFAULT_CURRENCY_RATES
}
let currencyRefreshing = false
/** 后台刷新汇率(失败静默,继续用缓存/兜底值),结果写入 localStorage 供下次使用 */
async function refreshCurrencyRates() {
if (currencyRefreshing) return
currencyRefreshing = true
try {
const res = await fetch('https://open.er-api.com/v6/latest/USD')
const data = await res.json()
if (data?.result === 'success' && data.rates) {
const r = data.rates as Record<string, number | undefined>
const rates: Record<string, number> = {
usd: 1,
cny: r.CNY ?? DEFAULT_CURRENCY_RATES.cny,
eur: r.EUR ?? DEFAULT_CURRENCY_RATES.eur,
gbp: r.GBP ?? DEFAULT_CURRENCY_RATES.gbp,
jpy: r.JPY ?? DEFAULT_CURRENCY_RATES.jpy,
hkd: r.HKD ?? DEFAULT_CURRENCY_RATES.hkd,
}
localStorage.setItem(CURRENCY_CACHE_KEY, JSON.stringify({ ts: Date.now(), rates }))
}
} catch {
/* 网络失败,继续使用默认/缓存汇率 */
} finally {
currencyRefreshing = false
}
}
/** 动态构建货币类别(基准 = 美元;factor 为「1 单位该货币 = ? 美元」) */
function getCurrencyCategory(): UnitCategory {
const r = getCurrencyRates()
const perUsd = (v: number) => (v > 0 ? 1 / v : 0)
return {
id: 'currency',
name: '货币',
units: [
{ symbols: ['$', 'usd', '美元', '美金', '美刀'], label: '美元', factor: 1 },
{ symbols: ['¥', '¥', 'rmb', 'cny', '元', '人民币'], label: '人民币', factor: perUsd(r.cny) },
{ symbols: ['€', 'eur', '欧元'], label: '欧元', factor: perUsd(r.eur) },
{ symbols: ['£', 'gbp', '英镑'], label: '英镑', factor: perUsd(r.gbp) },
{ symbols: ['jpy', '日元', '日圆'], label: '日元', factor: perUsd(r.jpy) },
{ symbols: ['hkd', '港币', '港元'], label: '港元', factor: perUsd(r.hkd) },
],
}
}
/** 温度匹配(仿射换算,单独处理) */
function matchTemperature(token: string): 'C' | 'F' | 'K' | null {
const t = token.toLowerCase().replace(/°/g, '')
if (['c', 'celsius', '摄氏度', '摄氏'].includes(t)) return 'C'
if (['f', 'fahrenheit', '华氏度', '华氏'].includes(t)) return 'F'
if (['kelvin', '开尔文'].includes(t)) return 'K'
return null
}
/** 在(普通 + 货币)类别中匹配单位 token */
function matchUnit(
token: string,
categories: UnitCategory[],
): { cat: UnitCategory; unit: UnitDef } | null {
// 第一轮:精确大小写匹配
for (const cat of categories) {
for (const unit of cat.units) {
if (unit.symbols.some(s => s === token)) return { cat, unit }
}
}
// 第二轮:大小写不敏感;exactCase 单位(如 m=米)跳过,避免 "1M" 误判为 1 米
const lower = token.toLowerCase()
for (const cat of categories) {
for (const unit of cat.units) {
if (unit.exactCase) continue
if (unit.symbols.some(s => s.toLowerCase() === lower)) return { cat, unit }
}
}
return null
}
/** 数值格式化(去掉多余的浮点尾巴) */
function formatUnitValue(v: number): string {
if (!isFinite(v)) return ''
if (v === 0) return '0'
const abs = Math.abs(v)
if (abs >= 1e12) return v.toExponential(2)
if (abs >= 1e6) return Number(v.toFixed(0)).toLocaleString('en-US')
if (abs >= 1000) return Number(v.toFixed(1)).toLocaleString('en-US')
if (abs >= 100) return Number(v.toFixed(1)).toString()
if (abs >= 1) return Number(v.toFixed(2)).toString()
if (abs >= 1e-4) return Number(v.toFixed(4)).toString()
return v.toExponential(2)
}
/** 结果展示优先级:整数 > 常见量级(1~1000) > 其他 */
function unitNiceRank(v: number): number {
if (Number.isInteger(v)) return 0
const abs = Math.abs(v)
if (abs >= 1 && abs < 1000) return 1
return 2
}
function buildUnitResultItem(
value: number,
fromLabel: string,
catName: string,
toLabel: string,
toValue: number,
idx: number,
): QPItem {
const text = `${formatUnitValue(toValue)} ${toLabel}`
return {
id: `unit-${catName}-${idx}`,
title: text,
subtitle: `${value} ${fromLabel}${catName}换算)`,
group: '换算',
score: 0.85,
action: async () => {
try {
await navigator.clipboard.writeText(text)
} catch {
/* 忽略 */
}
},
}
}
export class UnitProvider implements QPProvider {
id = 'unit'
label = '换算'
priority = 80
async search(query: string): Promise<QPItem[]> {
const trimmed = query.trim()
if (!trimmed) return []
const m = trimmed.match(/^(\d+(?:\.\d+)?)\s*(.+)$/)
if (!m) return []
const value = parseFloat(m[1])
if (!isFinite(value) || value <= 0) return []
const token = m[2].trim()
if (!token) return []
// 温度(仿射换算)
const tFrom = matchTemperature(token)
if (tFrom) {
const celsius =
tFrom === 'C' ? value : tFrom === 'F' ? ((value - 32) * 5) / 9 : value - 273.15
const convs: Array<{ label: string; v: number }> = [
{ label: '摄氏度', v: celsius },
{ label: '华氏度', v: (celsius * 9) / 5 + 32 },
{ label: '开尔文', v: celsius + 273.15 },
]
return convs
.filter(c => !(tFrom === 'C' && c.label === '摄氏度') && !(tFrom === 'F' && c.label === '华氏度') && !(tFrom === 'K' && c.label === '开尔文'))
.map((c, i) => buildUnitResultItem(value, `${tFrom}°`, '温度', c.label, c.v, i))
}
// 普通单位 / 货币
const currencyCat = getCurrencyCategory()
const categories = [...UNIT_CATEGORIES, currencyCat]
const matched = matchUnit(token, categories)
if (!matched) return []
const { cat, unit } = matched
if (cat.id === 'currency') {
// 命中货币:后台刷新一次汇率,不阻塞本次结果
void refreshCurrencyRates()
}
const base = value * unit.factor
const results: Array<{ item: QPItem; rank: number }> = []
for (const u of cat.units) {
if (u === unit) continue
const v = base / u.factor
results.push({
item: buildUnitResultItem(value, unit.label, cat.name, u.label, v, results.length),
rank: unitNiceRank(v),
})
}
results.sort((a, b) => a.rank - b.rank)
return results.slice(0, 8).map(r => r.item)
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Provider
* + / / app file Provider
*/
import { getTextForms, type TextForms } from '../engine'
import type { QPSubAction } from './types'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
/** 由 title + keywords 组合出待匹配文本形态(engine 按文本内容缓存,无需外部 host) */
export function buildItemForms(title: string, keywords: string[] = []): TextForms {
return getTextForms([title, ...keywords].join(' '))
}
/** 启动一个应用(.lnk / .exe 等),通过 Rust spawn 子进程 */
export function makeAppLaunch(path: string) {
return async () => {
try {
// .lnk 文件不能用 openUrl 打开,需直接 spawn
await commands.quickpanelRunCustomCommand(path, [])
} catch (e) {
console.error('[quickpanel] 启动应用失败:', e)
}
}
}
/** 应用项的标准子动作:启动 / 在资源管理器中显示 / 复制路径(+ 可选删除) */
export function makeAppSubActions(path: string, includeDelete = false): QPSubAction[] {
const launch = makeAppLaunch(path)
const subs: QPSubAction[] = [
{ id: 'launch', label: '启动', action: launch },
{
id: 'reveal',
label: '在资源管理器中显示',
action: async () => {
try {
await commands.quickpanelRevealInExplorer(path)
} catch (e) {
console.error('[quickpanel] 资源管理器显示失败:', e)
}
},
},
{
id: 'copy-path',
label: '复制路径',
action: async () => {
try {
await navigator.clipboard.writeText(path)
} catch {
/* 忽略 */
}
},
},
]
if (includeDelete) {
subs.push({
id: 'delete',
label: '删除',
action: async () => {
try {
await commands.quickpanelDeleteFile(path)
} catch (e) {
console.error('[quickpanel] 删除失败:', e)
}
},
})
}
return subs
}
/** 根据路径推断应用可靠性排序:桌面 1 / 其他位置 2(开始菜单由调用方直接给 0) */
export function appRankFromPath(path: string): number {
const p = path.toLowerCase()
if (p.includes('\\desktop\\') || p.includes('/desktop/')) return 1
return 2
}
+55
View File
@@ -0,0 +1,55 @@
/**
* web Provider
* quickpanel localStorage
*/
import { openUrl } from '@tauri-apps/plugin-opener'
import { STORAGE_KEYS } from '@/lib/constants'
import type { QPItem, QPProvider } from './types'
type SearchEngine = 'google' | 'bing' | 'baidu'
const ENGINE_URL: Record<SearchEngine, string> = {
google: 'https://www.google.com/search?q=',
bing: 'https://www.bing.com/search?q=',
baidu: 'https://www.baidu.com/s?wd=',
}
function getSearchEngine(): SearchEngine {
try {
const raw = localStorage.getItem(STORAGE_KEYS.quickpanelSettings)
if (raw) {
const s = JSON.parse(raw)
if (s.searchEngine && ENGINE_URL[s.searchEngine as SearchEngine]) {
return s.searchEngine
}
}
} catch {
/* 忽略 */
}
return 'bing'
}
export class WebProvider implements QPProvider {
id = 'web'
label = '网页'
priority = 50
search(query: string): QPItem[] {
const trimmed = query.trim()
if (!trimmed) return []
const engine = getSearchEngine()
return [{
id: 'web-search',
title: `搜索「${trimmed}`,
subtitle: `${engine} 中打开`,
group: '网页',
score: 0.3,
action: async () => {
try {
await openUrl(ENGINE_URL[engine] + encodeURIComponent(trimmed))
} catch {
/* 忽略 */
}
},
}]
}
}
+21 -7
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import type { Component } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { emit } from '@tauri-apps/api/event'
import { EVENTS } from '@/lib/constants'
// Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings'
import { save } from '@tauri-apps/plugin-dialog'
import { toast } from 'vue-sonner'
import {
@@ -257,6 +259,17 @@ function onMouseDown(e: MouseEvent) {
redraw()
}
// ===== rAF =====
/** 连续鼠标移动时每帧最多重绘一次,避免 mousemove 高频事件(每帧多次)触发多次全量重绘 */
let redrawRaf = 0
function scheduleRedraw() {
if (redrawRaf) return
redrawRaf = requestAnimationFrame(() => {
redrawRaf = 0
redraw()
})
}
function onMouseMove(e: MouseEvent) {
if (!isDrawing.value || !draft.value) return
const p = getPoint(e)
@@ -267,7 +280,7 @@ function onMouseMove(e: MouseEvent) {
d.x2 = p.x
d.y2 = p.y
}
redraw()
scheduleRedraw()
}
function onMouseUp() {
@@ -355,9 +368,9 @@ async function copyToClipboard() {
const pngBase64 = getPngBase64()
if (!canvas || !pngBase64) return
try {
await invoke('screenshot_copy_image', { pngBase64 })
await commands.screenshotCopyImage(pngBase64)
toast.success('已复制到剪贴板')
await emit('screenshot-exported', { pngBase64, width: canvas.width, height: canvas.height })
await emit(EVENTS.screenshotExported, { pngBase64, width: canvas.width, height: canvas.height })
} catch (e) {
toast.error('复制失败')
console.error('[screenshot-editor] 复制失败:', e)
@@ -374,9 +387,10 @@ async function saveToFile() {
filters: [{ name: 'PNG', extensions: ['png'] }],
})
if (!path) return
await invoke('screenshot_save_png', { pngBase64, path })
await commands.screenshotSavePng(pngBase64, path)
toast.success('已保存')
await emit('screenshot-exported', { pngBase64, width: canvas.width, height: canvas.height })
await emit(EVENTS.screenshotExported, { pngBase64, width: canvas.width, height: canvas.height })
await close()
} catch (e) {
toast.error('保存失败')
console.error('[screenshot-editor] 保存失败:', e)
@@ -400,7 +414,7 @@ onMounted(() => {
void (async () => {
try {
const b64 = await invoke<string | null>('screenshot_get_editor_image')
const b64 = await commands.screenshotGetEditorImage()
if (!b64) {
loadError.value = true
return
+19 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onUnmounted, watch, nextTick } from 'vue'
import {
Keyboard, Settings, FolderOpen, Camera, Copy, Save, Trash2, Timer,
Image as ImageIcon, Loader2,
@@ -7,7 +7,7 @@ import {
import { toast } from 'vue-sonner'
import { open } from '@tauri-apps/plugin-dialog'
import { useScreenshotStore, type RecentCapture } from '@/stores/screenshotStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -35,7 +35,7 @@ function formatTime(t: number): string {
}
function thumbSrc(item: RecentCapture): string {
return `data:image/png;base64,${item.pngBase64}`
return item.thumb
}
async function handleCapture() {
@@ -51,16 +51,26 @@ async function chooseSaveDir() {
}
async function handleCopy(item: RecentCapture) {
await store.copyImage(item.pngBase64)
try {
//
const full = await store.loadFullImage(item)
await store.copyImage(full)
} catch (e) {
toast.error('加载完整图失败: ' + e)
}
}
async function handleSave(item: RecentCapture) {
await store.saveImage(item.pngBase64)
try {
const full = await store.loadFullImage(item)
await store.saveImage(full)
} catch (e) {
toast.error('加载完整图失败: ' + e)
}
}
function handleDelete(item: RecentCapture) {
const idx = store.recent.findIndex(r => r.id === item.id)
if (idx >= 0) store.recent.splice(idx, 1)
void store.removeRecent(item)
toast.success('已从历史移除')
}
@@ -149,12 +159,10 @@ async function clearShortcut() {
toast.success('已禁用截图快捷键')
}
onMounted(() => {
store.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
})
// screenshot-exportedmain.ts/App.vue
// /
onUnmounted(() => {
store.destroyExportListener()
window.removeEventListener('keydown', onRecordKey, true)
})
</script>
+21 -65
View File
@@ -1,67 +1,23 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import type { Component } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
// Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings'
import {
Square, Circle, MoveUpRight, Pencil, Type, Grid3x3, Highlighter, ListOrdered,
Undo2, Redo2, Eraser, Copy, Save,
} from '@lucide/vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Slider } from '@/components/ui/slider'
// ===== =====
type Phase = 'pick' | 'drawing' | 'selected' | 'editing'
type ToolType = 'rect' | 'ellipse' | 'arrow' | 'pen' | 'text' | 'mosaic' | 'highlight' | 'number'
interface Point { x: number; y: number }
interface Sel { x: number; y: number; w: number; h: number }
interface RectAnno { type: 'rect'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
interface EllipseAnno { type: 'ellipse'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
interface ArrowAnno { type: 'arrow'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
interface PenAnno { type: 'pen'; points: Point[]; color: string; lineWidth: number }
interface TextAnno { type: 'text'; x: number; y: number; text: string; color: string; fontSize: number }
interface MosaicAnno { type: 'mosaic'; x1: number; y1: number; x2: number; y2: number; blockSize: number }
interface HighlightAnno { type: 'highlight'; x1: number; y1: number; x2: number; y2: number; color: string; alpha: number }
interface NumberAnno { type: 'number'; x: number; y: number; n: number; color: string; fontSize: number }
type Annotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | TextAnno | MosaicAnno | HighlightAnno | NumberAnno
type DrawableAnnotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | MosaicAnno | HighlightAnno
interface CaptureData {
pngBase64: string
width: number
height: number
}
interface WindowInfo {
hwnd: number
title: string
rect: { x: number; y: number; width: number; height: number }
/** DWM 视觉边界(去掉最大化窗口隐形缩放边框),优先用于高亮框 */
visualRect: { x: number; y: number; width: number; height: number } | null
}
// ===== =====
const TOOLS: { value: ToolType; icon: Component; label: string }[] = [
{ value: 'rect', icon: Square, label: '矩形' },
{ value: 'ellipse', icon: Circle, label: '椭圆' },
{ value: 'arrow', icon: MoveUpRight, label: '箭头' },
{ value: 'number', icon: ListOrdered, label: '序号' },
{ value: 'pen', icon: Pencil, label: '画笔' },
{ value: 'text', icon: Type, label: '文字' },
{ value: 'mosaic', icon: Grid3x3, label: '马赛克' },
{ value: 'highlight', icon: Highlighter, label: '高亮' },
]
const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#a855f7', '#000000', '#ffffff']
const BLOCK_SIZES = [8, 10, 14]
const ALPHAS = [0.2, 0.4, 0.6]
const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const
type HandleDir = (typeof HANDLES)[number]
const HANDLE_HIT = 10
const DRAG_THRESHOLD = 4
import {
TOOLS, COLORS, BLOCK_SIZES, ALPHAS, HANDLES, HANDLE_HIT, DRAG_THRESHOLD,
type Phase, type ToolType, type Annotation, type DrawableAnnotation,
type Point, type Sel, type CaptureData, type WindowInfo, type HandleDir,
type RectAnno, type EllipseAnno, type ArrowAnno, type PenAnno, type TextAnno,
type MosaicAnno, type HighlightAnno, type NumberAnno,
} from './types'
// ===== / =====
const win = getCurrentWindow()
@@ -1786,7 +1742,7 @@ async function finish() {
if (!out) {
out = await cropFromStored()
if (out) {
await invoke('screenshot_copy_image', { pngBase64: out.b64 }).catch((e) =>
await commands.screenshotCopyImage(out.b64).catch((e) =>
console.error('[screenshot] 复制失败', e)
)
}
@@ -1814,7 +1770,7 @@ async function finish() {
console.error('[screenshot] raw 复制失败,回退 base64 路径', e)
out = await exportBase64()
if (out) {
await invoke('screenshot_copy_image', { pngBase64: out.b64 }).catch((e2) =>
await commands.screenshotCopyImage(out.b64).catch((e2) =>
console.error('[screenshot] 复制失败', e2)
)
}
@@ -1823,7 +1779,7 @@ async function finish() {
}
}
if (!out) return
await emit('screenshot-exported', { pngBase64: out.b64, width: out.w, height: out.h })
await emit(EVENTS.screenshotExported, { pngBase64: out.b64, width: out.w, height: out.h })
} catch (e) {
console.error('[screenshot] 完成失败', e)
} finally {
@@ -1844,8 +1800,8 @@ async function doSave() {
filters: [{ name: 'PNG', extensions: ['png'] }],
})
if (!path) return
await invoke('screenshot_save_png', { pngBase64: out.b64, path })
await emit('screenshot-exported', { pngBase64: out.b64, width: out.w, height: out.h })
await commands.screenshotSavePng(out.b64, path)
await emit(EVENTS.screenshotExported, { pngBase64: out.b64, width: out.w, height: out.h })
await win.hide().catch(() => {})
} catch (e) {
console.error('[screenshot] 保存失败', e)
@@ -1878,7 +1834,7 @@ function applyTheme() {
const root = document.documentElement
let theme = 'system'
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
theme = s.theme ?? 'system'
@@ -1895,7 +1851,7 @@ function applyTheme() {
/** 主应用 localStorage 变化(主题切换)时同步主题 */
function onStorageChange(e: StorageEvent) {
if (e.key === 'thing_app_settings') {
if (e.key === STORAGE_KEYS.appSettings) {
applyTheme()
}
}
@@ -1926,12 +1882,12 @@ onMounted(async () => {
//
await refreshWinSize()
// //
invoke('screenshot_disable_transitions', { label: 'screenshot-overlay' }).catch(() => {})
commands.screenshotDisableTransitions(WINDOWS.screenshotOverlay).catch(() => {})
// begin store
beginUnlisten = await listen('screenshot-begin', () => {
beginUnlisten = await listen(EVENTS.screenshotBegin, () => {
void beginCapture()
})
await emit('screenshot-overlay-ready')
await emit(EVENTS.screenshotOverlayReady)
})
/** 响应 store 的 'screenshot-begin':先装载底图(隐藏中),解码完成后再一次性显示窗口 */
@@ -2017,7 +1973,7 @@ onUnmounted(() => {
magGridCanvas = null
if (objectUrl) URL.revokeObjectURL(objectUrl)
// 退 Rust
void invoke('screenshot_clear_fullscreen').catch(() => {})
void commands.screenshotClearFullscreen().catch(() => {})
})
</script>
+60
View File
@@ -0,0 +1,60 @@
/**
* ScreenshotOverlay
* + //
*/
import type { Component } from 'vue'
import {
Circle, Grid3x3, Highlighter, ListOrdered, MoveUpRight,
Pencil, Square, Type,
} from '@lucide/vue'
export type Phase = 'pick' | 'drawing' | 'selected' | 'editing'
export type ToolType = 'rect' | 'ellipse' | 'arrow' | 'pen' | 'text' | 'mosaic' | 'highlight' | 'number'
export interface Point { x: number; y: number }
export interface Sel { x: number; y: number; w: number; h: number }
export interface RectAnno { type: 'rect'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
export interface EllipseAnno { type: 'ellipse'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
export interface ArrowAnno { type: 'arrow'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
export interface PenAnno { type: 'pen'; points: Point[]; color: string; lineWidth: number }
export interface TextAnno { type: 'text'; x: number; y: number; text: string; color: string; fontSize: number }
export interface MosaicAnno { type: 'mosaic'; x1: number; y1: number; x2: number; y2: number; blockSize: number }
export interface HighlightAnno { type: 'highlight'; x1: number; y1: number; x2: number; y2: number; color: string; alpha: number }
export interface NumberAnno { type: 'number'; x: number; y: number; n: number; color: string; fontSize: number }
export type Annotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | TextAnno | MosaicAnno | HighlightAnno | NumberAnno
export type DrawableAnnotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | MosaicAnno | HighlightAnno
export interface CaptureData {
pngBase64: string
width: number
height: number
}
export interface WindowInfo {
hwnd: number
title: string
rect: { x: number; y: number; width: number; height: number }
/** DWM 视觉边界(去掉最大化窗口隐形缩放边框),优先用于高亮框 */
visualRect: { x: number; y: number; width: number; height: number } | null
}
// ===== 工具与选项 =====
export const TOOLS: { value: ToolType; icon: Component; label: string }[] = [
{ value: 'rect', icon: Square, label: '矩形' },
{ value: 'ellipse', icon: Circle, label: '椭圆' },
{ value: 'arrow', icon: MoveUpRight, label: '箭头' },
{ value: 'number', icon: ListOrdered, label: '序号' },
{ value: 'pen', icon: Pencil, label: '画笔' },
{ value: 'text', icon: Type, label: '文字' },
{ value: 'mosaic', icon: Grid3x3, label: '马赛克' },
{ value: 'highlight', icon: Highlighter, label: '高亮' },
]
export const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#a855f7', '#000000', '#ffffff']
export const BLOCK_SIZES = [8, 10, 14]
export const ALPHAS = [0.2, 0.4, 0.6]
export const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const
export type HandleDir = (typeof HANDLES)[number]
export const HANDLE_HIT = 10
export const DRAG_THRESHOLD = 4
+3 -2
View File
@@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { STORAGE_KEYS } from '@/lib/constants'
import { Effect, EffectState } from '@tauri-apps/api/window'
import {
Globe, Power, PowerOff, RefreshCw, Check, Monitor, Download,
@@ -41,7 +42,7 @@ let unlistenFns: UnlistenFn[] = []
function readOsdVisible(): boolean {
try {
const raw = localStorage.getItem('thing_monitor_osd_config')
const raw = localStorage.getItem(STORAGE_KEYS.monitorOsdConfig)
if (raw) {
const parsed = JSON.parse(raw)
return parsed.config?.overlayEnabled ?? false
@@ -337,7 +338,7 @@ async function measureAndShow() {
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return { theme: s.theme ?? 'system', effect: s.effect ?? 'mica' }
+2 -1
View File
@@ -7,6 +7,7 @@ import { useSearchStore } from '@/stores/searchStore'
import { useProcessStore } from '@/stores/processStore'
import { toast } from 'vue-sonner'
import { createLogger } from '@/lib/logger'
import { STORAGE_KEYS } from '@/lib/constants'
import type { ModuleCategory } from '@/types/module'
const logger = createLogger('app')
@@ -27,7 +28,7 @@ export interface ModuleInfo {
/** localStorage 版本号 —— 结构变更时递增,自动清除旧数据 */
const SETTINGS_VERSION = 4
const STORAGE_KEY = 'thing_app_settings'
const STORAGE_KEY = STORAGE_KEYS.appSettings
/** 从模块注册表初始化模块元信息 */
const initModulesFromRegistry = (): ModuleInfo[] => {
+38 -68
View File
@@ -1,52 +1,30 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { createLogger } from '@/lib/logger'
import { EVENTS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的类型与命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import type {
ClipboardItem,
ClipboardSettings,
ClipboardStatus,
} from '@/lib/bindings'
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
export type {
ClipboardItem,
ClipboardItemDetail,
ClipboardSettings,
ClipboardStatus,
HistoryPage,
} from '@/lib/bindings'
const logger = createLogger('clipboard')
// ===== 与 Rust 端对应的数据结构(camelCase =====
/** 剪贴板内容类型(bindings 的 kind 为 string,此联合为前端业务约束) */
export type ClipboardKind = 'text' | 'image' | 'files'
export interface ClipboardItem {
id: number
kind: ClipboardKind
preview: string
size: number
pinned: boolean
pinnedOrder: number | null
createdAt: number
}
/** 历史分页结果(与 Rust 端 HistoryPage 对应) */
export interface HistoryPage {
items: ClipboardItem[]
total: number
}
export interface ClipboardItemDetail extends ClipboardItem {
content: string | null
imageBase64: string | null
}
export interface ClipboardSettings {
enabled: boolean
maxItems: number
maxImageKb: number
recordText: boolean
recordImage: boolean
recordFiles: boolean
dedup: boolean
shortcut: string
}
export interface ClipboardStatus {
running: boolean
count: number
}
const DEFAULT_SETTINGS: ClipboardSettings = {
enabled: true,
maxItems: 500,
@@ -74,8 +52,8 @@ export const useClipboardStore = defineStore('clipboard', () => {
const init = async () => {
try {
const [s, st] = await Promise.all([
invoke<ClipboardSettings>('clipboard_get_settings'),
invoke<ClipboardStatus>('clipboard_status'),
commands.clipboardGetSettings(),
commands.clipboardStatus(),
])
settings.value = { ...DEFAULT_SETTINGS, ...s }
status.value = st
@@ -83,7 +61,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
logger.error('初始化失败: ' + e)
}
if (!changedUnlisten) {
changedUnlisten = await listen('clipboard-changed', () => {
changedUnlisten = await listen(EVENTS.clipboardChanged, () => {
// 防抖:短时间内多次复制只刷新一次
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
@@ -117,11 +95,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const page = Math.max(1, opts.page ?? 1)
const offset = (page - 1) * pageSize
try {
const res = await invoke<HistoryPage>('clipboard_get_history', {
limit: pageSize,
offset,
kind,
})
const res = await commands.clipboardGetHistory(pageSize, offset, kind)
history.value = res.items
historyTotal.value = res.total
} catch (e) {
@@ -135,7 +109,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const refreshPinned = async () => {
try {
pinned.value = await invoke<ClipboardItem[]>('clipboard_get_pinned')
pinned.value = await commands.clipboardGetPinned()
} catch (e) {
logger.error('获取固定条目失败: ' + e)
}
@@ -148,11 +122,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
return fetchHistoryPage({ page, pageSize })
}
try {
const res = await invoke<HistoryPage>('clipboard_search', {
query,
limit: pageSize,
offset: (page - 1) * pageSize,
})
const res = await commands.clipboardSearch(query, pageSize, (page - 1) * pageSize)
history.value = res.items
historyTotal.value = res.total
} catch (e) {
@@ -166,7 +136,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const getItem = async (id: number) => {
try {
return await invoke<ClipboardItemDetail | null>('clipboard_get_item', { id })
return await commands.clipboardGetItem(id)
} catch (e) {
logger.error('获取详情失败: ' + e)
return null
@@ -175,7 +145,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const refreshStatus = async () => {
try {
status.value = await invoke<ClipboardStatus>('clipboard_status')
status.value = await commands.clipboardStatus()
} catch (e) {
logger.error('获取状态失败: ' + e)
}
@@ -184,7 +154,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
// ===== 操作 =====
const setPinned = async (id: number, pinned: boolean) => {
try {
await invoke('clipboard_set_pinned', { id, pinned })
await commands.clipboardSetPinned(id, pinned)
// 固定/取消后刷新两个列表
await Promise.all([refreshHistory(), refreshPinned()])
} catch (e) {
@@ -194,7 +164,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const remove = async (id: number) => {
try {
await invoke('clipboard_delete', { id })
await commands.clipboardDelete(id)
history.value = history.value.filter((i) => i.id !== id)
pinned.value = pinned.value.filter((i) => i.id !== id)
status.value.count = Math.max(0, status.value.count - 1)
@@ -205,7 +175,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
const clear = async () => {
try {
await invoke('clipboard_clear')
await commands.clipboardClear()
history.value = []
await refreshStatus()
} catch (e) {
@@ -214,13 +184,13 @@ export const useClipboardStore = defineStore('clipboard', () => {
}
const copyBack = async (id: number) => {
await invoke('clipboard_copy_back', { id })
await commands.clipboardCopyBack(id)
// copy_back 会触发 suppress,不会产生 clipboard-changed 事件
}
const saveSettings = async (s: ClipboardSettings) => {
try {
await invoke('clipboard_save_settings', { settings: s })
await commands.clipboardSaveSettings(s)
settings.value = { ...s }
await refreshStatus()
} catch (e) {
@@ -230,35 +200,35 @@ export const useClipboardStore = defineStore('clipboard', () => {
}
const start = async () => {
await invoke('clipboard_start')
await commands.clipboardStart()
await refreshStatus()
}
const stop = async () => {
await invoke('clipboard_stop')
await commands.clipboardStop()
await refreshStatus()
}
// ===== 快捷弹窗 =====
const showPopup = async () => {
await invoke('clipboard_show_popup')
await commands.clipboardShowPopup()
}
const hidePopup = async () => {
await invoke('clipboard_hide_popup')
await commands.clipboardHidePopup()
}
/// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口
const pasteToTarget = async () => {
await invoke('clipboard_paste_to_target')
await commands.clipboardPasteToTarget()
}
const registerShortcut = async (shortcut: string) => {
await invoke('clipboard_register_shortcut', { shortcut })
await commands.clipboardRegisterShortcut(shortcut)
}
const unregisterShortcut = async () => {
await invoke('clipboard_unregister_shortcut')
await commands.clipboardUnregisterShortcut()
}
return {
+76 -77
View File
@@ -3,70 +3,37 @@ import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { createLogger } from '@/lib/logger'
// Rust 端通过 tauri-specta 生成的类型与命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import type {
DownloadTask as BindDownloadTask,
DownloaderSettings as BindDownloaderSettings,
CheckUrlResult as BindCheckUrlResult,
TaskStatus,
} from '@/lib/bindings'
const logger = createLogger('downloader')
// ===== 与 Rust 端对应的数据结构(camelCase =====
// Rust 端字段均带 serde(default),序列化总是完整输出;Required 收窄 bindings 的 optional
// 组件访问 task.segments / settings.downloadDir 等字段无需判空
export type DownloadTask = Required<BindDownloadTask>
export type DownloaderSettings = Required<BindDownloaderSettings>
export type CheckUrlResult = Required<BindCheckUrlResult>
export type TaskStatus = 'queued' | 'active' | 'paused' | 'complete' | 'error'
export interface Segment {
index: number
start: number
end: number
completed: number
}
export interface DownloadTask {
id: string
url: string
filename: string
dir: string
status: TaskStatus
totalSize: number
completedSize: number
speed: number
supportsResume: boolean
segments: Segment[]
error: string | null
createdAt: number
headers: Record<string, string>
}
export interface DownloaderSettings {
downloadDir: string
maxConcurrent: number
maxConnections: number
continueDownload: boolean
globalSpeedLimit: number
extensionPort: number
extensionSecret: string
deleteFilesOnRemove: boolean
checkDuplicate: boolean
}
/** 重复类型 */
export type DuplicateKind = 'none' | 'url' | 'filename' | 'fileExists'
/** check_url 返回的结果 */
export interface CheckUrlResult {
ok: boolean
error: string | null
filename: string | null
totalSize: number | null
supportsResume: boolean
duplicate: DuplicateKind
existing: {
id: string
filename: string
status: TaskStatus
} | null
}
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
export type {
TaskStatus,
Segment,
DuplicateKind,
ExistingTaskInfo,
} from '@/lib/bindings'
/** 下载器运行状态(downloader_status 返回 serde_json::Valuespecta 豁免,保留手动类型) */
export interface DownloaderStatus {
running: boolean
}
/** 扩展信息(downloader_get_extension_info 返回 serde_json::Valuespecta 豁免,保留手动类型) */
export interface ExtensionInfo {
url: string
port: number
@@ -74,7 +41,7 @@ export interface ExtensionInfo {
hasSecret: boolean
}
/** 下载进度事件载荷 */
/** 下载进度事件载荷(事件监听传递,specta 不导出,保留手动定义) */
interface ProgressPayload {
id: string
completedSize: number
@@ -105,7 +72,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
// ===== 任务列表 =====
const refreshTasks = async () => {
try {
tasks.value = await invoke<DownloadTask[]>('downloader_get_tasks')
// Rust 端序列化保证字段完整,断言为 Required 收窄后的类型
const fresh = (await commands.downloaderGetTasks()) as DownloadTask[]
// merge 化:保留本地仍在更新的任务对象(进度事件可能刚修改过它),
// 避免整体替换导致进行中任务的实时进度/速度被快照回退
const merged = fresh.map(freshTask => {
const local = tasks.value.find(t => t.id === freshTask.id)
return local ?? freshTask
})
tasks.value = merged
} catch (e) {
logger.error('获取任务列表失败: ' + e)
}
@@ -137,13 +112,13 @@ export const useDownloaderStore = defineStore('downloader', () => {
headers?: Record<string, string>,
autoRename = false
): Promise<string> => {
const id = await invoke<string>('downloader_add_task', {
const id = await commands.downloaderAddTask(
url,
filename: filename || null,
dir: dir || null,
headers: headers || null,
filename || null,
dir || null,
headers || null,
autoRename
})
)
await refreshTasks()
return id
}
@@ -154,40 +129,40 @@ export const useDownloaderStore = defineStore('downloader', () => {
dir?: string,
headers?: Record<string, string>
): Promise<CheckUrlResult> => {
return await invoke<CheckUrlResult>('downloader_check_url', {
url,
dir: dir || null,
headers: headers || null
})
return (await commands.downloaderCheckUrl(url, dir || null, headers || null)) as CheckUrlResult
}
const pauseTask = async (id: string) => {
await invoke('downloader_pause_task', { id })
await commands.downloaderPauseTask(id)
await refreshTasks()
}
const resumeTask = async (id: string) => {
await invoke('downloader_resume_task', { id })
await commands.downloaderResumeTask(id)
await refreshTasks()
}
const removeTask = async (id: string, deleteFiles = false) => {
await invoke('downloader_remove_task', { id, deleteFiles })
await commands.downloaderRemoveTask(id, deleteFiles)
await refreshTasks()
}
// ===== 设置 =====
const loadSettings = async () => {
settings.value = await invoke<DownloaderSettings>('downloader_get_settings')
try {
settings.value = (await commands.downloaderGetSettings()) as DownloaderSettings
} catch (e) {
logger.error('加载设置失败: ' + e)
}
return settings.value
}
const saveSettings = async (s: DownloaderSettings) => {
await invoke('downloader_save_settings', { settings: s })
await commands.downloaderSaveSettings(s)
settings.value = s
}
// ===== 状态 =====
// ===== 状态(specta 豁免命令,保留原生 invoke) =====
const refreshStatus = async () => {
try {
status.value = await invoke<DownloaderStatus>('downloader_status')
@@ -197,18 +172,41 @@ export const useDownloaderStore = defineStore('downloader', () => {
return status.value
}
// ===== 扩展信息 =====
// ===== 扩展信息(specta 豁免命令,保留原生 invoke) =====
const loadExtensionInfo = async () => {
try {
extensionInfo.value = await invoke<ExtensionInfo>('downloader_get_extension_info')
} catch (e) {
logger.error('获取扩展信息失败: ' + e)
}
return extensionInfo.value
}
// ===== 事件监听 =====
/** rAF 20-100ms
* requestAnimationFrame
* Vue */
const pendingProgress = new Map<string, ProgressPayload>()
let progressRaf = 0
const flushProgress = () => {
progressRaf = 0
for (const payload of pendingProgress.values()) {
updateTaskProgress(payload)
}
pendingProgress.clear()
}
const scheduleProgressFlush = () => {
if (progressRaf) return
progressRaf = requestAnimationFrame(flushProgress)
}
const startEventListeners = async () => {
if (progressUnlisten && completeUnlisten && addedUnlisten) return
if (!progressUnlisten) {
progressUnlisten = await listen<ProgressPayload>('download-progress', (e) => {
updateTaskProgress(e.payload)
// 同名任务只保留最新进度,合并后由 rAF 统一应用
pendingProgress.set(e.payload.id, e.payload)
scheduleProgressFlush()
})
}
if (!completeUnlisten) {
@@ -241,12 +239,13 @@ export const useDownloaderStore = defineStore('downloader', () => {
// ===== 初始化 =====
const init = async () => {
await Promise.all([refreshStatus(), loadSettings(), loadExtensionInfo(), refreshTasks()])
// 任一子调用失败不阻断事件订阅注册:否则一个命令失败会导致进度/完成事件全部缺失
await Promise.allSettled([refreshStatus(), loadSettings(), loadExtensionInfo(), refreshTasks()])
await startEventListeners()
}
// ===== 工具函数 =====
const openDir = (path: string) => invoke<void>('downloader_open_dir', { path })
const openDir = (path: string) => commands.downloaderOpenDir(path)
return {
// state

Some files were not shown because too many files have changed in this diff Show More