18 Commits
Author SHA1 Message Date
zhongluofeng 0a19b4b38a Merge branch 'main' of https://gitea.atie.fun/LFeng/Thing 2026-08-14 17:47:28 +08:00
zhongluofeng 7d49a7395f 优化调整 2026-08-14 17:47:06 +08:00
LFeng d0705b1ffe 进度更正 2026-08-11 17:33:37 +08:00
zhongluofeng 6c7897bf47 版本管理,优化 2026-08-11 17:19:36 +08:00
zhongluofeng 2f20161010 状态栏修正 2026-08-11 15:12:30 +08:00
zhongluofeng d09599fa95 优化,贴图 2026-08-07 18:09:13 +08:00
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
LFeng 4f0f9e08b7 更新 README.md 2026-08-04 16:01:49 +08:00
zhongluofeng c7578a2e6b 快速面板模块 2026-08-04 16:00:57 +08:00
zhongluofeng 126f8896b6 快速面板模块 2026-08-04 13:37:42 +08:00
LFeng 5656193b5c 更新 README.md 2026-08-03 17:57:55 +08:00
LFeng d85f7b2ad0 更新 README.md 2026-08-03 17:44:13 +08:00
LFeng a745df0880 更新 README.md 2026-08-03 17:40:52 +08:00
zhongluofeng c913379e2b 截图模块优化调整 2026-08-03 17:36:07 +08:00
LFeng f51a7f894e 截图模块调整 2026-08-01 22:24:33 +08:00
zhongluofeng 89e5b7bed5 截图模块初始化 2026-07-31 18:31:13 +08:00
zhongluofeng 66575c6166 截图模块初始化 2026-07-31 18:30:55 +08:00
151 changed files with 22045 additions and 4013 deletions
+3
View File
@@ -7,6 +7,9 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
# 发布暂存目录
release_stage/
node_modules node_modules
dist dist
dist-ssr dist-ssr
+7 -7
View File
@@ -315,11 +315,11 @@ const status = processStore.getProcessStatus('proxy')
| 命令 | 参数 | 返回值 | | 命令 | 参数 | 返回值 |
|------|------|--------| |------|------|--------|
| `start_process` | `StartProcessParams` | `ProcessInfo` | | `process_start` | `StartProcessParams` | `ProcessInfo` |
| `stop_process` | `id: String` | `()` | | `process_stop` | `id: String` | `()` |
| `get_process_status` | `id: String` | `Option<ProcessInfo>` | | `process_status` | `id: String` | `Option<ProcessInfo>` |
| `get_all_process_status` | - | `Vec<ProcessInfo>` | | `process_all_status` | - | `Vec<ProcessInfo>` |
| `stop_all_processes` | - | `()` | | `process_stop_all` | - | `()` |
### 事件 ### 事件
@@ -388,8 +388,8 @@ rusqlite = { version = "0.30", features = ["bundled"] }
### Rust 调试 ### Rust 调试
- 使用 `println!()` 输出到终端 - 使用 `crate::logger::log_info / log_warn / log_error`(或 `log_line`)写入统一日志文件,日志页面可过滤级别
- 使用 `dbg!()` 宏调试变量 - 使用 `dbg!()` 宏调试变量(仅临时,提交前删除)
- 使用 Visual Studio Code 的 Rust 调试插件 - 使用 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`,避免大文件一次性读入内存 - **流式下载**`reqwest::Response::bytes_stream()` + `futures_util::StreamExt`,避免大文件一次性读入内存
- **进度事件**:通过 `app.emit("xxx-install-progress", progress)` 推送,事件载荷结构参考 `InstallProgress` - **进度事件**:通过 `app.emit("xxx-install-progress", progress)` 推送,事件载荷结构参考 `InstallProgress`
@@ -492,13 +492,13 @@ TitleBar 的关闭按钮实际是 `hide()` 到托盘。webview 快速隐藏时
### 前端:浮动标签切换器(TabsList 滚动遮挡时在 TitleBar 显示) ### 前端:浮动标签切换器(TabsList 滚动遮挡时在 TitleBar 显示)
模块详情页内容滚动时,顶部 `TabsList` 会被 `TitleBar` 遮挡,导致用户必须滚回顶部才能切换 Tab。已抽取通用 composable `src/lib/useModuleTabs.ts` 自动处理。 模块详情页内容滚动时,顶部 `TabsList` 会被 `TitleBar` 遮挡,导致用户必须滚回顶部才能切换 Tab。已抽取通用 composable `src/lib/use-module-tabs.ts` 自动处理。
**接入方式**(任何使用 Tabs 的模块都可用,代理/下载器模块已接入): **接入方式**(任何使用 Tabs 的模块都可用,代理/下载器模块已接入):
```ts ```ts
// 模块 <script setup> 顶部 // 模块 <script setup> 顶部
import { useModuleTabs } from '@/lib/useModuleTabs' import { useModuleTabs } from '@/lib/use-module-tabs'
const activeTab = ref('overview') const activeTab = ref('overview')
const tabsListRef = useModuleTabs(activeTab, [ const tabsListRef = useModuleTabs(activeTab, [
@@ -613,11 +613,6 @@ const formatEta = (seconds: number): string => {
下载任务工具栏的速度/活跃/等待/已完成数量,以及代理概览页的内核状态/版本,从纯文本改为 `Badge variant="secondary"` 或带颜色的 `Badge`(绿色已安装/红色未安装),视觉更醒目。新模块的状态展示建议统一用 Badge。 下载任务工具栏的速度/活跃/等待/已完成数量,以及代理概览页的内核状态/版本,从纯文本改为 `Badge variant="secondary"` 或带颜色的 `Badge`(绿色已安装/红色未安装),视觉更醒目。新模块的状态展示建议统一用 Badge。
### 后端:应用退出清理必须覆盖所有退出路径 ### 后端:应用退出清理(单一出口)
`lib.rs` 中应用退出有两种触发路径,**都要**调用 `cleanup_on_exit` + `stop_all` 退出清理已收敛到 `lib.rs` `RunEvent::ExitRequested` 分支(P0-1/V7):`quit_app` 命令与托盘退出项都只触发 `app.exit(0)`,清理逻辑(`cleanup_on_exit` + `stop_all`)只在该分支执行一次。新增管理子进程的模块时,在 `ExitRequested` 分支注册自己的 `cleanup_on_exit` 即可,不要在命令或托盘路径重复清理。
1. `quit_app` Tauri 命令(前端 `appWindow.destroy()` 触发)
2. 系统托盘的退出菜单项
漏掉任何一个都会导致子进程残留(如 mihomo 继续占用端口、系统代理未清除)。新增管理子进程的模块时,检查这两处是否都调用了清理逻辑。
+35 -28
View File
@@ -23,15 +23,15 @@ Thing/
│ ├── components/ # 通用组件 │ ├── components/ # 通用组件
│ │ ├── layout/ # 布局组件(TitleBar、Sidebar、ModuleContainer │ │ ├── layout/ # 布局组件(TitleBar、Sidebar、ModuleContainer
│ │ └── ui/ # shadcn-vue UI 组件 │ │ └── ui/ # shadcn-vue UI 组件
│ ├── lib/ # 工具库(logger、useModuleTabs、utils │ ├── lib/ # 工具库(logger、use-module-tabs、calc、constants
│ ├── modules/ # 功能模块(每个模块含 index.ts 配置 + Vue 组件) │ ├── modules/ # 功能模块(每个模块含 index.ts 配置 + Vue 组件)
│ │ ├── proxy/ # 代理管理模块 │ │ ├── proxy/ # 代理管理模块
│ │ ├── clipboard/ # 剪贴板增强模块 │ │ ├── clipboard/ # 剪贴板增强模块
│ │ ├── screenshot/ # 截图模块 │ │ ├── screenshot/ # 截图模块
│ │ ├── monitor/ # 硬件监控模块 │ │ ├── monitor/ # 硬件监控模块
│ │ ├── downloader/ # 下载器模块 │ │ ├── downloader/ # 下载器模块
│ │ ├── finder/ # 文件搜索模块 │ │ ├── quickpanel/ # 快速面板模块(含 providers/ 搜索提供器目录)
│ │ ├── general/ # 通用设置模块 │ │ ├── settings/ # 通用设置模块
│ │ ├── icons.ts # 模块图标映射 │ │ ├── icons.ts # 模块图标映射
│ │ ├── index.ts # 模块聚合入口 │ │ ├── index.ts # 模块聚合入口
│ │ └── registry.ts # 模块注册表单例 │ │ └── registry.ts # 模块注册表单例
@@ -46,12 +46,22 @@ Thing/
│ ├── icons/ # 应用图标 │ ├── icons/ # 应用图标
│ ├── resources/ # 打包资源(浏览器扩展等) │ ├── resources/ # 打包资源(浏览器扩展等)
│ ├── src/ │ ├── src/
│ │ ├── download_engine/ # 下载引擎(进程内自建,含 engine/storage/http_dl/server 等 │ │ ├── clipboard/ # 剪贴板模块(reader/monitor/storage/popup
│ │ ├── logger.rs # 日志系统 │ │ ├── download_engine/ # 下载引擎(进程内自建,含 engine/storage/http_dl 等)
│ │ ├── mihomo_manager.rs # mihomo 内核管理(代理模块后端 │ │ ├── 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 # 子进程统一管理 │ │ ├── process_manager.rs # 子进程统一管理
│ │ ├── setup.rs # 应用初始化
│ │ ├── tray_menu.rs # 系统托盘
│ │ ├── win32_util.rs # Win32 屏幕/DPI 工具
│ │ ├── main.rs # Rust 入口 │ │ ├── main.rs # Rust 入口
│ │ └── lib.rs # Rust 库入口(命令注册、应用初始化 │ │ └── lib.rs # Rust 库入口(命令注册、事件循环
│ ├── Cargo.toml # Rust 依赖配置 │ ├── Cargo.toml # Rust 依赖配置
│ └── tauri.conf.json # Tauri 配置 │ └── tauri.conf.json # Tauri 配置
├── package.json # 前端依赖配置 ├── package.json # 前端依赖配置
@@ -81,18 +91,16 @@ Thing/
- [x] 订阅管理 - [x] 订阅管理
#### 📋 剪贴板增强 #### 📋 剪贴板增强
- [ ] 剪贴板历史记录(排序,搜索) - [x] 剪贴板历史记录(排序,搜索)
- [ ] 鼠标位置快携键历史弹窗 - [x] 鼠标位置快携键历史弹窗
- [ ] 固定常用条目(快捷输入) - [x] 固定常用条目(快捷输入)
- [ ] 多格式预览(文本、图片、文件) - [x] 多格式预览(文本、图片、文件)
- [ ] SQLite 存储 - [x] SQLite 存储
#### 📸 截图 #### 📸 截图
- [ ] 区域截图 - [x] 截图及相关
- [ ] 窗口截图 - [ ] 滚动截图(后续支持)
- [ ] 全屏截 - [x]
- [ ] 滚动截图
- [ ] 图片编辑工具
#### 📊 硬件监控 #### 📊 硬件监控
- [x] 悬浮窗显示 - [x] 悬浮窗显示
@@ -108,20 +116,19 @@ Thing/
- [x] 速度限制 - [x] 速度限制
- [x] 断点续传 - [x] 断点续传
#### 🔍 文件 / 程序搜索 #### 🔍 快速面板
- [ ] 快速搜索弹窗(快捷键触发) - [x] 全局弹窗(快捷键触发)
- [ ] 文件搜索(Everything SDK - [x] 全局搜索:拼音模糊匹配,正则表达式支持。支持:程序,文件,文件夹,设置项,最近使用,历史命令
- [ ] 应用程序搜索 - [x] 快捷命令(例:shutdown, hosts
- [ ] 拼音模糊匹配 - [x] 计算器 / 单位换算(数学表达式、单位/货币换算;时间转换、进制转换后续支持)
- [ ] 正则表达式支持
### 第三阶段:优化与完善 ### 第三阶段:优化与完善
- [ ] 性能优化 - [x] 性能优化(P1/P2:轮询随窗口可见性暂停、批量测速限并发、渲染 memo 化等,见 MODULE_REVIEW.md
- [ ] 错误处理与日志完善 - [x] 错误处理与日志完善B5 进程级全局日志器、异常兜底)
- [ ] 用户体验优化 - [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节)
- [ ] 自动更新机制 - [x] 自动更新机制
- [ ] 打包发布 - [x] 打包发布
## 模块管理架构 ## 模块管理架构
+15 -6
View File
@@ -5,13 +5,14 @@
"": { "": {
"name": "thing", "name": "thing",
"dependencies": { "dependencies": {
"@lucide/vue": "^1.25.0", "@lucide/vue": "^1.28.0",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "~2", "@tauri-apps/plugin-autostart": "~2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
@@ -116,7 +117,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lucide/vue": ["@lucide/vue@1.25.0", "https://registry.npmmirror.com/@lucide/vue/-/vue-1.25.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-hkEetV+v48ScIn3uwqwWQ66sI8foeP2q6OMI09GzLFH4SfvBlfe3JHYlMBdBCqFC7WRlhFsndyDn/awRKRc2OQ=="], "@lucide/vue": ["@lucide/vue@1.28.0", "https://registry.npmmirror.com/@lucide/vue/-/vue-1.28.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-cO89UyNM/0i2Srdi04nFWeEOQkqzDZlM2ehtWyBhcMpRTJDKpMZPCGAVlmVBgBgr5HaeL5L6FsU8RHsOdwXtKw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
@@ -234,6 +235,8 @@
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="], "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="],
"@tauri-apps/plugin-global-shortcut": ["@tauri-apps/plugin-global-shortcut@2.3.2", "https://registry.npmmirror.com/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-UReHNXrLvpEjylE4jb4oCYiy96uRykPUthoCQCmRXYrd5hs5X9DrW+qOn7GLW57EJN4tdK8bgK5twBTz2NOxzA=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="], "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -280,11 +283,11 @@
"@vue/shared": ["@vue/shared@3.5.39", "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.39.tgz", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="], "@vue/shared": ["@vue/shared@3.5.39", "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.39.tgz", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="],
"@vueuse/core": ["@vueuse/core@14.3.0", "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.3.0", "@vueuse/shared": "14.3.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw=="], "@vueuse/core": ["@vueuse/core@14.4.0", "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.4.0", "@vueuse/shared": "14.4.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ=="],
"@vueuse/metadata": ["@vueuse/metadata@14.3.0", "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", {}, "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw=="], "@vueuse/metadata": ["@vueuse/metadata@14.4.0", "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz", {}, "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g=="],
"@vueuse/shared": ["@vueuse/shared@14.3.0", "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg=="], "@vueuse/shared": ["@vueuse/shared@14.4.0", "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g=="],
"alien-signals": ["alien-signals@1.0.13", "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="], "alien-signals": ["alien-signals@1.0.13", "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
@@ -433,5 +436,11 @@
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"reka-ui/@vueuse/core": ["@vueuse/core@14.3.0", "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.3.0", "@vueuse/shared": "14.3.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw=="],
"reka-ui/@vueuse/shared": ["@vueuse/shared@14.3.0", "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg=="],
"reka-ui/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@14.3.0", "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", {}, "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw=="],
} }
} }
+33 -16
View File
@@ -8,16 +8,18 @@
"name": "thing", "name": "thing",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@lucide/vue": "^1.25.0", "@lucide/vue": "^1.28.0",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "~2", "@tauri-apps/plugin-autostart": "~2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinyin-pro": "^3.28.2",
"reka-ui": "^2.10.1", "reka-ui": "^2.10.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
@@ -603,9 +605,9 @@
} }
}, },
"node_modules/@lucide/vue": { "node_modules/@lucide/vue": {
"version": "1.25.0", "version": "1.28.0",
"resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.25.0.tgz", "resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.28.0.tgz",
"integrity": "sha512-hkEetV+v48ScIn3uwqwWQ66sI8foeP2q6OMI09GzLFH4SfvBlfe3JHYlMBdBCqFC7WRlhFsndyDn/awRKRc2OQ==", "integrity": "sha512-cO89UyNM/0i2Srdi04nFWeEOQkqzDZlM2ehtWyBhcMpRTJDKpMZPCGAVlmVBgBgr5HaeL5L6FsU8RHsOdwXtKw==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"vue": ">=3.0.1" "vue": ">=3.0.1"
@@ -1455,6 +1457,15 @@
"@tauri-apps/api": "^2.11.0" "@tauri-apps/api": "^2.11.0"
} }
}, },
"node_modules/@tauri-apps/plugin-global-shortcut": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.2.tgz",
"integrity": "sha512-UReHNXrLvpEjylE4jb4oCYiy96uRykPUthoCQCmRXYrd5hs5X9DrW+qOn7GLW57EJN4tdK8bgK5twBTz2NOxzA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-opener": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4", "version": "2.5.4",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
@@ -1671,14 +1682,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueuse/core": { "node_modules/@vueuse/core": {
"version": "14.3.0", "version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz",
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/web-bluetooth": "^0.0.21", "@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.3.0", "@vueuse/metadata": "14.4.0",
"@vueuse/shared": "14.3.0" "@vueuse/shared": "14.4.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
@@ -1688,18 +1699,18 @@
} }
}, },
"node_modules/@vueuse/metadata": { "node_modules/@vueuse/metadata": {
"version": "14.3.0", "version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz",
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/@vueuse/shared": { "node_modules/@vueuse/shared": {
"version": "14.3.0", "version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz",
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
@@ -2280,6 +2291,12 @@
} }
} }
}, },
"node_modules/pinyin-pro": {
"version": "3.28.2",
"resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.28.2.tgz",
"integrity": "sha512-jV38yxXHLfidirMC4hrXasLDozLCSq/4DfX88GnHcSEJ2+GpSedG6I9VOiEXJu6iQ5dbJC/RjmzyMuS5h/wH5A==",
"license": "MIT"
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.19", "version": "8.5.19",
"funding": [ "funding": [
+5 -3
View File
@@ -1,26 +1,28 @@
{ {
"name": "thing", "name": "thing",
"private": true, "private": true,
"version": "0.1.0", "version": "26.8.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "node --test src/modules/quickpanel/engine.test.ts src/lib/calc.test.ts",
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@lucide/vue": "^1.25.0", "@lucide/vue": "^1.28.0",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "~2", "@tauri-apps/plugin-autostart": "~2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinyin-pro": "^3.28.2",
"reka-ui": "^2.10.1", "reka-ui": "^2.10.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
+61
View File
@@ -0,0 +1,61 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
set "SCRIPT=%~dp0release.ps1"
echo ================================================
echo Thing Build Pubilsh
echo 发布目标:https://gitea.atie.fun/LFeng/Thing
echo ================================================
echo.
:: ---------- 版本号 ----------
set "VERSION="
set /p "VERSION=请输入版本号(如 26.8.1,直接回车沿用当前版本): "
set "VER_ARG="
if not "%VERSION%"=="" set "VER_ARG=-Version %VERSION%"
:: ---------- 发布模式 ----------
echo.
echo [1] Build + Pubilsh Gitea
echo [2] Build Only
echo [3] Pubilsh Gitea
set "MODE="
set /p "MODE=请输入数字选择(回车默认 1): "
if "%MODE%"=="" set "MODE=1"
set "EXTRA="
if "%MODE%"=="2" (
set "EXTRA=-SkipPush"
) else if "%MODE%"=="3" (
set "EXTRA=-SkipBuild"
)
:: ---------- Gitea Token(仅完整发布模式需要) ----------
if "%MODE%"=="1" if not defined GITEA_TOKEN (
echo.
echo 未检测到环境变量 GITEA_TOKEN,上传到 Gitea 需要它。
echo 可在此临时输入(仅本次会话生效),留空则自动改为"只构建不上传"
set /p "GITEA_TOKEN=请输入 Gitea Token: "
if "!GITEA_TOKEN!"=="" (
set "EXTRA=-SkipPush"
set "MODE=2"
echo [提示] 已切换为"只构建并整理产物,不上传"
)
)
echo.
echo Startrelease.ps1 %VER_ARG% %EXTRA%
echo ------------------------------------------------
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT%" %VER_ARG% %EXTRA%
set "RESULT=%ERRORLEVEL%"
echo.
if "%RESULT%"=="0" (
echo 执行完成。
) else (
echo 执行出错,请查看上方日志。
)
echo.
pause
+166
View File
@@ -0,0 +1,166 @@
# ============================================================
# Thing 构建发布脚本(目标:自建 Gitea release
#
# 用法:
# .\scripts\release.ps1 -Version 0.2.0 # 同步版本号 + 构建 + 发布
# .\scripts\release.ps1 -Version 0.2.0 -SkipBuild # 复用现有构建产物,直接发布
# .\scripts\release.ps1 -Version 0.2.0 -SkipPush # 只构建+整理产物,不上传
#
# 发布产物(上传到 https://gitea.atie.fun/LFeng/Thing 的 v{Version} release):
# thing_{v}_x64.exe 便携免安装版(无内核)
# thing_{v}_x64.msi 安装版 MSI(去掉 tauri 默认的 _en-US 后缀)
# thing_{v}_x64-setup.exe 安装版 NSIS
# thing-hk_{v}.zip ThingHK 硬件监控内核(mihomo 继续走代理模块内置的 GitHub 下载)
#
# 前提:
# - Gitea token 已配置为环境变量 GITEA_TOKEN-SkipPush 时不需要)
# - 可选 -NotesPath 指定 release notes 文件(Markdown 文本),
# 缺省时使用 scripts/release-notes.md(若存在),否则用简单占位文本
# ============================================================
param(
[string]$Version = '',
[switch]$SkipBuild,
[switch]$SkipPush,
[string]$NotesPath = ''
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
$TargetDir = Join-Path $Root 'src-tauri\target\release'
$BundleDir = Join-Path $TargetDir 'bundle'
$RepoOwner = 'LFeng'
$RepoName = 'Thing'
$ApiBase = 'https://gitea.atie.fun/api/v1'
$Product = 'thing'
function Write-Step([string]$msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan }
# ---------- 0. 版本号 ----------
if ([string]::IsNullOrWhiteSpace($Version)) {
$conf = Get-Content (Join-Path $Root 'src-tauri\tauri.conf.json') -Raw | ConvertFrom-Json
$Version = $conf.version
Write-Step "未指定 -Version,沿用现有版本 $Version"
}
# 校验 X.Y.Z 格式
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
throw "版本号格式错误(应为 X.Y.Z):$Version"
}
Write-Step "发布版本:v$Version"
# ---------- 1. 同步版本号到三处 ----------
$tauriConf = Join-Path $Root 'src-tauri\tauri.conf.json'
$cargoToml = Join-Path $Root 'src-tauri\Cargo.toml'
$pkgJson = Join-Path $Root 'package.json'
$t = Get-Content $tauriConf -Raw
$t = $t -replace '("version"\s*:\s*")[^"]*(")', "`${1}$Version`${2}"
[System.IO.File]::WriteAllText($tauriConf, $t, (New-Object System.Text.UTF8Encoding($false)))
$c = Get-Content $cargoToml -Raw
$c = $c -replace '(?m)^(version\s*=\s*")[^"]*(")', "`${1}$Version`${2}"
[System.IO.File]::WriteAllText($cargoToml, $c, (New-Object System.Text.UTF8Encoding($false)))
$p = Get-Content $pkgJson -Raw
$p = $p -replace '("version"\s*:\s*")[^"]*(")', "`${1}$Version`${2}"
[System.IO.File]::WriteAllText($pkgJson, $p, (New-Object System.Text.UTF8Encoding($false)))
Write-Step "版本号已同步:tauri.conf.json / Cargo.toml / package.json"
# ---------- 2. 构建 ----------
if (-not $SkipBuild) {
Write-Step '开始构建(bun run tauri build...'
Push-Location $Root
try { bun run tauri build }
finally { Pop-Location }
if ($LASTEXITCODE -ne 0) { throw 'tauri build 失败' }
} else {
Write-Step '跳过构建,复用现有产物'
}
# ---------- 3. 整理产物 ----------
# 版本号此时已解析,再确定暂存目录
$StageDir = Join-Path $Root "release_stage\$Version"
New-Item -ItemType Directory -Force -Path $StageDir | Out-Null
$exeSrc = Join-Path $TargetDir "$Product.exe"
$msiSrc = Join-Path $BundleDir "msi\${Product}_${Version}_x64_en-US.msi"
$nsisSrc = Join-Path $BundleDir "nsis\${Product}_${Version}_x64-setup.exe"
$hkSrc = Join-Path $Root 'src-tauri\binaries\ThingHK.exe'
$exeOut = Join-Path $StageDir "${Product}_${Version}_x64.exe"
$msiOut = Join-Path $StageDir "${Product}_${Version}_x64.msi"
$nsisOut = Join-Path $StageDir "${Product}_${Version}_x64-setup.exe"
$hkZip = Join-Path $StageDir "thing-hk_${Version}.zip"
if (Test-Path $exeSrc) { Copy-Item $exeSrc $exeOut } else { Write-Warning "缺少便携版:$exeSrc" }
if (Test-Path $msiSrc) { Copy-Item $msiSrc $msiOut } else { Write-Warning "缺少 MSI(已跳过重命名):$msiSrc" }
if (Test-Path $nsisSrc) { Copy-Item $nsisSrc $nsisOut } else { Write-Warning "缺少 NSIS$nsisSrc" }
if (Test-Path $hkSrc) {
$tmp = Join-Path $env:TEMP "thinghk_$([guid]::NewGuid().ToString('N'))"
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
Copy-Item $hkSrc (Join-Path $tmp 'ThingHK.exe')
Compress-Archive -Path (Join-Path $tmp 'ThingHK.exe') -DestinationPath $hkZip -Force
Remove-Item -Recurse -Force $tmp
Write-Step "ThingHK 内核包:$hkZip"
} else {
Write-Warning "缺少 ThingHK.exe$hkSrc"
}
Write-Step "产物已整理到:$StageDir"
Get-ChildItem $StageDir | Select-Object Name, @{n='Size(MB)';e={[math]::Round($_.Length/1MB,1)}} | Format-Table -AutoSize
# ---------- 4. 上传到 Gitea ----------
if ($SkipPush) {
Write-Step '已跳过上传(-SkipPush'
exit 0
}
$token = $env:GITEA_TOKEN
if ([string]::IsNullOrWhiteSpace($token)) {
throw '未设置环境变量 GITEA_TOKEN,无法发布到 Gitea(或使用 -SkipPush 跳过上传)'
}
$auth = @{ Authorization = "token $token" }
# 4.1 release notes
if (-not [string]::IsNullOrWhiteSpace($NotesPath)) {
$body = Get-Content $NotesPath -Raw
} elseif (Test-Path (Join-Path $PSScriptRoot 'release-notes.md')) {
$body = Get-Content (Join-Path $PSScriptRoot 'release-notes.md') -Raw
} else {
$body = "Thing v$Version"
}
# 4.2 创建 release(已存在同名 tag 则复用)
Write-Step "创建 release v$Version ..."
$releaseUrl = "$ApiBase/repos/$RepoOwner/$RepoName/releases"
$releasePayload = @{
tag_name = "v$Version"
name = "Thing v$Version"
body = $body
draft = $false
prerelease = $false
} | ConvertTo-Json
try {
$release = Invoke-RestMethod -Method Post -Uri $releaseUrl -Headers $auth -ContentType 'application/json' -Body $releasePayload
} catch {
# tag 已存在:尝试用该 tag 查找现有 release,后续资产上传会追加
Write-Warning "创建 release 失败,尝试复用已有 release:$_"
$release = Invoke-RestMethod -Method Get -Uri "$releaseUrl/tags/v$Version" -Headers $auth
}
$releaseId = $release.id
Write-Step "release id=$releaseId"
# 4.3 逐个上传资产
$assets = @($exeOut, $msiOut, $nsisOut, $hkZip) | Where-Object { Test-Path $_ }
foreach ($file in $assets) {
$name = Split-Path $file -Leaf
Write-Step "上传 $name ..."
$assetUrl = "$releaseUrl/$releaseId/assets?name=$([uri]::EscapeDataString($name))"
$resp = Invoke-RestMethod -Method Post -Uri $assetUrl -Headers $auth -ContentType 'application/octet-stream' -InFile $file
Write-Host " -> $($resp.browser_download_url)" -ForegroundColor Green
}
Write-Step "发布完成:https://gitea.atie.fun/$RepoOwner/$RepoName/releases/tag/v$Version"
+200 -2
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@@ -1227,6 +1233,16 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -1306,6 +1322,15 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@@ -2114,6 +2139,26 @@ dependencies = [
"cfb", "cfb",
] ]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@@ -2275,6 +2320,26 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "kqueue"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.13.0",
"libc",
]
[[package]] [[package]]
name = "libappindicator" name = "libappindicator"
version = "0.9.0" version = "0.9.0"
@@ -2454,6 +2519,18 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.2" version = "1.2.2"
@@ -2543,6 +2620,25 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.13.0",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "notify-rust" name = "notify-rust"
version = "4.18.0" version = "4.18.0"
@@ -2929,6 +3025,12 @@ dependencies = [
"windows-link 0.2.1", "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]] [[package]]
name = "pbkdf2" name = "pbkdf2"
version = "0.12.2" version = "0.12.2"
@@ -3980,6 +4082,58 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -4214,6 +4368,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_repr", "serde_repr",
"serialize-to-javascript", "serialize-to-javascript",
"specta",
"swift-rs", "swift-rs",
"tauri-build", "tauri-build",
"tauri-macros", "tauri-macros",
@@ -4487,6 +4642,23 @@ dependencies = [
"wry", "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]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.9.3" version = "2.9.3"
@@ -4571,7 +4743,7 @@ dependencies = [
[[package]] [[package]]
name = "thing" name = "thing"
version = "0.1.0" version = "26.8.1"
dependencies = [ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
@@ -4579,12 +4751,16 @@ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"futures-util", "futures-util",
"image", "image",
"notify",
"raw-window-handle", "raw-window-handle",
"regex",
"reqwest 0.12.28", "reqwest 0.12.28",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"specta",
"specta-typescript",
"sysinfo", "sysinfo",
"tauri", "tauri",
"tauri-build", "tauri-build",
@@ -4594,8 +4770,11 @@ dependencies = [
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-snap-layout", "tauri-plugin-snap-layout",
"tauri-specta",
"tokio", "tokio",
"url", "url",
"walkdir",
"windows 0.52.0",
"windows-sys 0.52.0", "windows-sys 0.52.0",
"winreg 0.52.0", "winreg 0.52.0",
"zip", "zip",
@@ -4704,7 +4883,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio 1.2.2",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
@@ -5413,6 +5592,16 @@ dependencies = [
"windows-version", "windows-version",
] ]
[[package]]
name = "windows"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core 0.52.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.57.0" version = "0.57.0"
@@ -5445,6 +5634,15 @@ dependencies = [
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.57.0" version = "0.57.0"
+19 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "thing" name = "thing"
version = "0.1.0" version = "26.8.1"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -26,7 +26,11 @@ tauri-plugin-global-shortcut = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
regex = "1"
serde_yaml = "0.9" 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" chrono = "0.4"
reqwest = { version = "0.12", features = ["json", "stream"] } reqwest = { version = "0.12", features = ["json", "stream"] }
futures-util = "0.3" futures-util = "0.3"
@@ -39,6 +43,8 @@ sysinfo = "0.32"
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
base64 = "0.22" base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
walkdir = "2"
notify = { version = "6", features = [] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.52" winreg = "0.52"
@@ -56,6 +62,18 @@ windows-sys = { version = "0.52", features = [
"Win32_UI_HiDpi", "Win32_UI_HiDpi",
"Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_KeyboardAndMouse",
"Win32_Graphics_Gdi", "Win32_Graphics_Gdi",
"Win32_Graphics_Dwm",
"Win32_Storage_Xps",
"Win32_Storage_FileSystem",
] }
# Explorer 前台目录检测(IShellWindows COM):仅引入用到的 feature,控制编译体积
windows = { version = "0.52", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_System_Ole",
"Win32_System_Variant",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] } ] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
View File
Binary file not shown.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "quick-panel",
"description": "Capability for quick panel popup window",
"windows": ["quick-panel"],
"permissions": [
"core:default",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-set-theme",
"core:window:allow-set-effects",
"core:window:allow-set-background-color",
"core:event:allow-emit",
"core:event:allow-listen",
"opener:allow-open-url",
"snap-layout:default"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "screenshot",
"description": "Capability for screenshot overlay, editor and pin windows",
"windows": ["screenshot-overlay*", "screenshot-editor-*", "screenshot-pin"],
"permissions": [
"core:default",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"core:window:allow-set-position",
"core:window:allow-set-size",
"core:window:allow-set-resizable",
"core:window:allow-set-always-on-top",
"core:window:allow-set-skip-taskbar",
"core:window:allow-set-decorations",
"core:window:allow-close",
"core:event:allow-emit",
"core:event:allow-listen",
"dialog:default",
"snap-layout:default"
]
}
@@ -16,7 +16,6 @@ const DEFAULT_CONFIG = {
interceptDownload: true, interceptDownload: true,
minSize: 0, minSize: 0,
excludeDomains: [], excludeDomains: [],
showNotifications: true,
// 嗅探开关 // 嗅探开关
sniffEnabled: true, sniffEnabled: true,
// 嗅探的资源类型:只保留视频/音频/图片/压缩包/种子/安装包 // 嗅探的资源类型:只保留视频/音频/图片/压缩包/种子/安装包
@@ -386,28 +385,13 @@ async function handleDownloadCreated(downloadItem) {
const filename = downloadItem.filename || '' const filename = downloadItem.filename || ''
try { try {
const id = await addDownload(url, filename, downloadItem.referrer, '') await addDownload(url, filename, downloadItem.referrer, '')
await notify('已添加到 Thing', `${filename || url}`)
} catch (e) { } catch (e) {
await notify('Thing 添加失败', `${filename || url}\n${e.message}`) // 添加失败:回退到浏览器自带下载,不弹通知
try { await chrome.downloads.download({ url }) } catch { /* ignore */ } try { await chrome.downloads.download({ url }) } catch { /* ignore */ }
} }
} }
// ===== 通知 =====
async function notify(title, message) {
const config = await getConfig()
if (!config.showNotifications) return
try {
await chrome.notifications.create({
type: 'basic',
iconUrl: 'icons/icon-128.png',
title,
message
})
} catch { /* ignore */ }
}
// ===== 右键菜单 ===== // ===== 右键菜单 =====
chrome.runtime.onInstalled.addListener(() => { chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({ chrome.contextMenus.create({
@@ -428,10 +412,7 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
const filename = url.split('/').pop()?.split('?')[0] || '' const filename = url.split('/').pop()?.split('?')[0] || ''
try { try {
await addDownload(url, filename, info.pageUrl, '') await addDownload(url, filename, info.pageUrl, '')
await notify('已添加到 Thing', `${filename || url}`) } catch (e) { /* 忽略:添加失败时不打扰用户 */ }
} catch (e) {
await notify('Thing 添加失败', `${e.message}`)
}
} }
}) })
@@ -11,9 +11,7 @@
"permissions": [ "permissions": [
"downloads", "downloads",
"storage", "storage",
"notifications",
"webRequest", "webRequest",
"webNavigation",
"contextMenus", "contextMenus",
"tabs", "tabs",
"scripting" "scripting"
@@ -87,11 +87,6 @@
<span>启用资源嗅探</span> <span>启用资源嗅探</span>
</label> </label>
<label class="checkbox">
<input type="checkbox" id="showNotifications" />
<span>显示桌面通知</span>
</label>
<label class="field"> <label class="field">
<span>下载最小文件大小(字节,0=全部)</span> <span>下载最小文件大小(字节,0=全部)</span>
<input type="number" id="minSize" min="0" placeholder="0" /> <input type="number" id="minSize" min="0" placeholder="0" />
@@ -12,7 +12,6 @@ const DEFAULT_CONFIG = {
interceptDownload: true, interceptDownload: true,
minSize: 0, minSize: 0,
excludeDomains: [], excludeDomains: [],
showNotifications: true,
sniffEnabled: true, sniffEnabled: true,
sniffTypes: ['video', 'audio', 'image', 'archive', 'torrent', 'installer'], sniffTypes: ['video', 'audio', 'image', 'archive', 'torrent', 'installer'],
sniffMaxItems: 200, sniffMaxItems: 200,
@@ -57,7 +56,6 @@ function fillForm(config) {
$('secret').value = config.secret || '' $('secret').value = config.secret || ''
$('interceptDownload').checked = config.interceptDownload !== false $('interceptDownload').checked = config.interceptDownload !== false
$('sniffEnabled').checked = config.sniffEnabled !== false $('sniffEnabled').checked = config.sniffEnabled !== false
$('showNotifications').checked = config.showNotifications !== false
$('minSize').value = config.minSize || 0 $('minSize').value = config.minSize || 0
$('sniffMinSize').value = config.sniffMinSize ?? DEFAULT_CONFIG.sniffMinSize $('sniffMinSize').value = config.sniffMinSize ?? DEFAULT_CONFIG.sniffMinSize
$('excludeDomains').value = (config.excludeDomains || []).join(',') $('excludeDomains').value = (config.excludeDomains || []).join(',')
@@ -69,7 +67,6 @@ function readForm() {
secret: $('secret').value.trim(), secret: $('secret').value.trim(),
interceptDownload: $('interceptDownload').checked, interceptDownload: $('interceptDownload').checked,
sniffEnabled: $('sniffEnabled').checked, sniffEnabled: $('sniffEnabled').checked,
showNotifications: $('showNotifications').checked,
minSize: parseInt($('minSize').value, 10) || 0, minSize: parseInt($('minSize').value, 10) || 0,
sniffMinSize: parseInt($('sniffMinSize').value, 10) || 0, sniffMinSize: parseInt($('sniffMinSize').value, 10) || 0,
excludeDomains: $('excludeDomains').value excludeDomains: $('excludeDomains').value
+79 -20
View File
@@ -3,13 +3,14 @@
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use base64::Engine as _; use base64::Engine as _;
use serde::Serialize; use serde::Serialize;
use specta::Type;
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use super::manager::{ClipboardManager, ClipboardSettings}; use super::manager::{ClipboardManager, ClipboardSettings};
use super::reader::dib_to_png; use super::reader::dib_to_png;
use super::storage::{ClipboardItem, ClipboardItemDetail}; use super::storage::{ClipboardItem, ClipboardItemDetail};
#[derive(Serialize)] #[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ClipboardStatus { pub struct ClipboardStatus {
pub running: bool, pub running: bool,
@@ -21,7 +22,7 @@ fn img_to_base64(b: &[u8]) -> Option<String> {
} }
/// 历史查询结果(含总数,用于分页) /// 历史查询结果(含总数,用于分页)
#[derive(Serialize)] #[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct HistoryPage { pub struct HistoryPage {
pub items: Vec<ClipboardItem>, pub items: Vec<ClipboardItem>,
@@ -29,74 +30,107 @@ pub struct HistoryPage {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_get_history( pub async fn clipboard_get_history(
limit: Option<i64>, limit: Option<i64>,
offset: Option<i64>, offset: Option<i64>,
kind: Option<String>, kind: Option<String>,
manager: State<'_, ClipboardManager>, manager: State<'_, ClipboardManager>,
) -> Result<HistoryPage, String> { ) -> Result<HistoryPage, String> {
let storage = manager.storage(); let storage = manager.storage().clone();
let kind = kind.unwrap_or_else(|| "all".into()); let kind = kind.unwrap_or_else(|| "all".into());
let limit = limit.unwrap_or(50); let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0); let offset = offset.unwrap_or(0);
// SQLite 查询移出 async runtime 线程
tauri::async_runtime::spawn_blocking(move || {
let items = storage.get_history(limit, offset, &kind); let items = storage.get_history(limit, offset, &kind);
let total = storage.count_kind(&kind); let total = storage.count_kind(&kind);
Ok(HistoryPage { items, total }) HistoryPage { items, total }
})
.await
.map_err(|e| format!("查询任务失败: {}", e))
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_get_pinned( pub async fn clipboard_get_pinned(
manager: State<'_, ClipboardManager>, manager: State<'_, ClipboardManager>,
) -> Result<Vec<ClipboardItem>, String> { ) -> 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] #[tauri::command]
#[specta::specta]
pub async fn clipboard_search( pub async fn clipboard_search(
query: String, query: String,
limit: Option<i64>, limit: Option<i64>,
offset: Option<i64>, offset: Option<i64>,
manager: State<'_, ClipboardManager>, manager: State<'_, ClipboardManager>,
) -> Result<HistoryPage, String> { ) -> Result<HistoryPage, String> {
let storage = manager.storage(); let storage = manager.storage().clone();
let limit = limit.unwrap_or(50); let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0); let offset = offset.unwrap_or(0);
tauri::async_runtime::spawn_blocking(move || {
let items = storage.search(&query, limit, offset); let items = storage.search(&query, limit, offset);
let total = storage.count_search(&query); let total = storage.count_search(&query);
Ok(HistoryPage { items, total }) HistoryPage { items, total }
})
.await
.map_err(|e| format!("查询任务失败: {}", e))
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_get_item( pub async fn clipboard_get_item(
id: i64, id: i64,
manager: State<'_, ClipboardManager>, manager: State<'_, ClipboardManager>,
) -> Result<Option<ClipboardItemDetail>, String> { ) -> 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] #[tauri::command]
#[specta::specta]
pub async fn clipboard_set_pinned( pub async fn clipboard_set_pinned(
id: i64, id: i64,
pinned: bool, pinned: bool,
manager: State<'_, ClipboardManager>, manager: State<'_, ClipboardManager>,
) -> Result<bool, String> { ) -> 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] #[tauri::command]
#[specta::specta]
pub async fn clipboard_delete( pub async fn clipboard_delete(
id: i64, id: i64,
manager: State<'_, ClipboardManager>, manager: State<'_, ClipboardManager>,
) -> Result<bool, String> { ) -> 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] #[tauri::command]
#[specta::specta]
pub async fn clipboard_clear(manager: State<'_, ClipboardManager>) -> Result<bool, String> { 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] #[tauri::command]
#[specta::specta]
pub async fn clipboard_copy_back( pub async fn clipboard_copy_back(
id: i64, id: i64,
manager: State<'_, ClipboardManager>, manager: State<'_, ClipboardManager>,
@@ -105,11 +139,16 @@ pub async fn clipboard_copy_back(
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_count(manager: State<'_, ClipboardManager>) -> Result<i64, String> { 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] #[tauri::command]
#[specta::specta]
pub async fn clipboard_get_settings( pub async fn clipboard_get_settings(
manager: State<'_, ClipboardManager>, manager: State<'_, ClipboardManager>,
) -> Result<ClipboardSettings, String> { ) -> Result<ClipboardSettings, String> {
@@ -117,6 +156,7 @@ pub async fn clipboard_get_settings(
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_save_settings( pub async fn clipboard_save_settings(
settings: ClipboardSettings, settings: ClipboardSettings,
app: AppHandle, app: AppHandle,
@@ -131,30 +171,41 @@ pub async fn clipboard_save_settings(
} else if !settings.enabled && prev_enabled { } else if !settings.enabled && prev_enabled {
manager.stop(); manager.stop();
} }
// 快捷键变化时重新注册 // 快捷键变化时重新注册(共享工具模块,原子化 + 冲突检测)
if settings.shortcut != prev_shortcut { 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);
}
} }
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_status( pub async fn clipboard_status(
manager: State<'_, ClipboardManager>, manager: State<'_, ClipboardManager>,
) -> Result<ClipboardStatus, String> { ) -> Result<ClipboardStatus, String> {
Ok(ClipboardStatus { let storage = manager.storage().clone();
running: manager.is_running(), let running = manager.is_running();
count: manager.storage().count(), let count = tauri::async_runtime::spawn_blocking(move || storage.count())
}) .await
.map_err(|e| format!("查询任务失败: {}", e))?;
Ok(ClipboardStatus { running, count })
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_start(app: AppHandle, manager: State<'_, ClipboardManager>) -> Result<(), String> { pub async fn clipboard_start(app: AppHandle, manager: State<'_, ClipboardManager>) -> Result<(), String> {
manager.start(&app); manager.start(&app);
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_stop(manager: State<'_, ClipboardManager>) -> Result<(), String> { pub async fn clipboard_stop(manager: State<'_, ClipboardManager>) -> Result<(), String> {
manager.stop(); manager.stop();
Ok(()) Ok(())
@@ -162,22 +213,27 @@ pub async fn clipboard_stop(manager: State<'_, ClipboardManager>) -> Result<(),
/// 注册(或切换)快捷弹窗全局快捷键 /// 注册(或切换)快捷弹窗全局快捷键
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_register_shortcut( pub async fn clipboard_register_shortcut(
shortcut: String, shortcut: String,
app: AppHandle, app: AppHandle,
) -> Result<(), String> { ) -> Result<(), String> {
super::popup::register_shortcut(&app, &shortcut) crate::shortcut::register_shortcut(&app, "剪贴板", &shortcut, |a| {
super::popup::show_popup(a)
})
} }
/// 注销快捷弹窗全局快捷键 /// 注销快捷弹窗全局快捷键
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_unregister_shortcut(app: AppHandle) -> Result<(), String> { pub async fn clipboard_unregister_shortcut(app: AppHandle) -> Result<(), String> {
super::popup::unregister_shortcut(&app); crate::shortcut::unregister_shortcut(&app, "剪贴板");
Ok(()) Ok(())
} }
/// 手动触发显示快捷弹窗(供 UI 按钮调用) /// 手动触发显示快捷弹窗(供 UI 按钮调用)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_show_popup(app: AppHandle) -> Result<(), String> { pub async fn clipboard_show_popup(app: AppHandle) -> Result<(), String> {
super::popup::show_popup(&app); super::popup::show_popup(&app);
Ok(()) Ok(())
@@ -185,6 +241,7 @@ pub async fn clipboard_show_popup(app: AppHandle) -> Result<(), String> {
/// 隐藏快捷弹窗 /// 隐藏快捷弹窗
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_hide_popup(app: AppHandle) -> Result<(), String> { pub async fn clipboard_hide_popup(app: AppHandle) -> Result<(), String> {
super::popup::hide_popup(&app); super::popup::hide_popup(&app);
Ok(()) Ok(())
@@ -192,6 +249,7 @@ pub async fn clipboard_hide_popup(app: AppHandle) -> Result<(), String> {
/// 显示已创建的弹窗窗口(前端 onMounted 后调用) /// 显示已创建的弹窗窗口(前端 onMounted 后调用)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_show_window(app: AppHandle) -> Result<(), String> { pub async fn clipboard_show_window(app: AppHandle) -> Result<(), String> {
super::popup::show_window(&app); super::popup::show_window(&app);
Ok(()) Ok(())
@@ -199,6 +257,7 @@ pub async fn clipboard_show_window(app: AppHandle) -> Result<(), String> {
/// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口 /// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn clipboard_paste_to_target(app: AppHandle) -> Result<(), String> { pub async fn clipboard_paste_to_target(app: AppHandle) -> Result<(), String> {
super::popup::paste_to_target(&app); super::popup::paste_to_target(&app);
Ok(()) Ok(())
+22 -10
View File
@@ -7,13 +7,14 @@ use std::sync::{Arc, Mutex};
use std::thread::JoinHandle; use std::thread::JoinHandle;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type;
use super::monitor::start_monitor; use super::monitor::start_monitor;
use super::reader::{write_dib, write_files, write_text}; use super::reader::{write_dib, write_files, write_text};
use super::storage::Storage; use super::storage::Storage;
/// 剪贴板设置(持久化到 clipboard/settings.json /// 剪贴板设置(持久化到 clipboard/settings.json
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
pub struct ClipboardSettings { pub struct ClipboardSettings {
/// 监听是否启用 /// 监听是否启用
@@ -66,7 +67,7 @@ impl ClipboardManager {
let storage = match Storage::new(&clip_dir) { let storage = match Storage::new(&clip_dir) {
Ok(s) => Arc::new(s), Ok(s) => Arc::new(s),
Err(e) => { Err(e) => {
eprintln!("[clipboard] 磁盘存储初始化失败,回退内存: {}", e); crate::logger::log_warn("clipboard", &format!("磁盘存储初始化失败,回退内存: {}", e));
Arc::new(Storage::new_in_memory()) Arc::new(Storage::new_in_memory())
} }
}; };
@@ -86,7 +87,7 @@ impl ClipboardManager {
/// 启动监听(若已运行则跳过) /// 启动监听(若已运行则跳过)
pub fn start(&self, app: &tauri::AppHandle) { 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() { if handle.is_some() {
return; return;
} }
@@ -99,30 +100,41 @@ impl ClipboardManager {
self.monitor_stop.clone(), self.monitor_stop.clone(),
); );
*handle = Some(h); *handle = Some(h);
eprintln!("[clipboard] 监听已启动"); crate::logger::log_info("clipboard", "监听已启动");
} }
/// 停止监听 /// 停止监听
pub fn stop(&self) { pub fn stop(&self) {
self.monitor_stop.store(true, Ordering::SeqCst); self.monitor_stop.store(true, Ordering::SeqCst);
if let Some(h) = self.monitor_handle.lock().unwrap().take() { if let Some(h) = self
// 不阻塞等待;轮询线程最迟 800ms 后退出 .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(); let _ = h.join();
});
} }
eprintln!("[clipboard] 监听已停止"); crate::logger::log_info("clipboard", "监听已停止");
} }
pub fn is_running(&self) -> bool { 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 { 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) { 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); save_settings(&self.settings_path, &s);
} }
+2 -2
View File
@@ -46,7 +46,7 @@ pub fn start_monitor(
continue; continue;
} }
let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = { 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_text,
s.record_image, s.record_image,
@@ -85,7 +85,7 @@ pub fn start_monitor(
}; };
if let Some(_id) = storage.insert_or_touch(item, dedup) { if let Some(_id) = storage.insert_or_touch(item, dedup) {
storage.prune_to_max(max_items); storage.prune_to_max(max_items);
let _ = app.emit("clipboard-changed", ()); let _ = app.emit(crate::constants::events::CLIPBOARD_CHANGED, ());
} }
}) })
} }
+86 -142
View File
@@ -9,64 +9,71 @@
//! 6. 窗口失焦自动隐藏 //! 6. 窗口失焦自动隐藏
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter}; use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
use tauri::window::{Effect, EffectsBuilder}; use tauri::window::{Effect, EffectsBuilder};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
/// 弹窗窗口标签 /// 弹窗窗口标签
pub const POPUP_LABEL: &str = "clipboard-popup"; pub const POPUP_LABEL: &str = "clipboard-popup";
/// 当前注册的快捷键(用于注销旧快捷键) /// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
static CURRENT_SHORTCUT: Mutex<Option<String>> = Mutex::new(None); /// 预创建路径不设置,避免应用启动时弹窗自动弹出。
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+V"、"Ctrl+Shift+V" /// 兜底创建路径下 show_popup 计算出的待显示位置(物理坐标),供 show_window 应用,
/// 失败返回 None /// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)
pub fn parse_shortcut(s: &str) -> Option<Shortcut> { static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None);
s.trim().parse::<Shortcut>().ok()
}
/// 注册全局快捷键。重复调用会先注销旧快捷键 /// 创建弹窗窗口(隐藏状态)并注册失焦监听
/// 传入空字符串则仅注销不注册 /// 位置默认在屏幕外,show_popup 时会重新定位
pub fn register_shortcut(app: &AppHandle, shortcut_str: &str) -> Result<(), String> { /// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。
// 先注销旧快捷键 fn create_popup_window(app: &AppHandle) {
unregister_shortcut(app); let win = match WebviewWindowBuilder::new(
app,
if shortcut_str.trim().is_empty() { POPUP_LABEL,
return Ok(()); WebviewUrl::App("index.html#clipboard-popup".into()),
)
.title("剪贴板")
.inner_size(380.0, 460.0)
.position(-10000.0, -10000.0) // 屏幕外,避免隐藏时一闪
.decorations(false)
.transparent(true)
.shadow(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false)
.focused(false) // 不抢占焦点,避免创建即触发 Focused(false)
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
.build()
{
Ok(w) => w,
Err(e) => {
crate::logger::log_error("clipboard", &format!("创建弹窗失败: {}", e));
return;
} }
};
let shortcut = parse_shortcut(shortcut_str) // 监听窗口失焦:自动隐藏
.ok_or_else(|| format!("无效的快捷键: {}", shortcut_str))?;
let app_handle = app.clone(); let app_handle = app.clone();
app.global_shortcut() let win_handle = win.clone();
.on_shortcut(shortcut, move |_app, _shortcut, event| { win.on_window_event(move |event| {
// 仅在按下时触发(松开不触发) if let tauri::WindowEvent::Focused(false) = event {
if event.state == ShortcutState::Pressed { let _ = win_handle.hide();
show_popup(&app_handle); let _ = app_handle.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
} }
}) });
.map_err(|e| format!("注册快捷键失败: {}", e))?;
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() { crate::logger::log_info("clipboard", "弹窗窗口已预创建(隐藏状态)");
*cur = Some(shortcut_str.to_string());
}
eprintln!("[clipboard] 已注册快捷键: {}", shortcut_str);
Ok(())
} }
/// 注销当前快捷键 /// 应用启动时预创建弹窗窗口(隐藏)。
pub fn unregister_shortcut(app: &AppHandle) { /// 这样首次按快捷键时窗口已存在,直接 show + 定位,避免首次创建时序问题。
if let Ok(cur) = CURRENT_SHORTCUT.lock() { pub fn ensure_popup_window(app: &AppHandle) {
if let Some(ref s) = *cur { if app.get_webview_window(POPUP_LABEL).is_some() {
if let Some(shortcut) = parse_shortcut(s) { return;
let _ = app.global_shortcut().unregister(shortcut);
}
}
}
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
*cur = None;
} }
create_popup_window(app);
} }
/// 在鼠标当前位置显示弹窗。 /// 在鼠标当前位置显示弹窗。
@@ -79,92 +86,66 @@ pub fn show_popup(app: &AppHandle) {
None => return, None => return,
}; };
// 窗口尺寸(逻辑像素 // 窗口尺寸(物理像素,由光标所在屏 DPI 换算
let w = 380.0_f64; let w = 380.0_f64;
let h = 460.0_f64; let h = 460.0_f64;
// 获取屏幕工作区(物理像素,Per-Monitor DPI V2 // 获取光标所在显示器的工作区(物理像素,与 get_cursor_pos 同一坐标系
let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0)); let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx, my)
.unwrap_or((0, 0, 1920, 1040));
// 获取 scale factor,将窗口逻辑尺寸换算为物理像素用于边界裁剪 // 光标所在显示器的 DPI:窗口尺寸需按物理像素放大
let scale = app let dpi = get_dpi_for_point(mx, my).unwrap_or(96);
.get_webview_window(POPUP_LABEL) let scale = dpi as f64 / 96.0;
.as_ref() let w_px = w * scale;
.and_then(|w| w.scale_factor().ok()) let h_px = h * scale;
.or_else(|| {
app.get_webview_window("main")
.and_then(|w| w.scale_factor().ok())
})
.unwrap_or(1.0);
let w_phys = (w * scale) as i32; // 直接以物理坐标 clamping(光标位置 + 工作区均为物理像素,避免混合 DPI 下
let h_phys = (h * scale) as i32; // 手动"物理→逻辑"换算后 Tauri 再按窗口所在屏解释导致的定位偏移)
let x = (mx as f64).max(wa_left as f64).min(wa_right as f64 - w_px);
// 物理坐标 clamping let y = (my as f64).max(wa_top as f64).min(wa_bottom as f64 - h_px);
let x = mx.max(0).min(screen_w as i32 - w_phys);
let y = my.max(0).min(screen_h as i32 - h_phys);
// 窗口已存在:移动 + 显示 + 请求焦点 // 窗口已存在:移动 + 显示 + 请求焦点
if let Some(win) = app.get_webview_window(POPUP_LABEL) { if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
x, x: x as i32,
y, y: y as i32,
})); }));
let _ = win.show(); let _ = win.show();
let _ = win.set_focus(); let _ = win.set_focus();
// 通知前端刷新数据 // 通知前端刷新数据
let _ = app.emit("clipboard-popup-show", ()); let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
return; return;
} }
// 首次创建窗口:先隐藏,等前端挂载完成后再显示 // 兜底:窗口被销毁时重新创建(隐藏,等前端 onMounted 回调 show_window
// 这样确保主题效果(dark class、mica)在窗口可见前已应用 POPUP_PENDING_SHOW.store(true, Ordering::SeqCst);
let win = match WebviewWindowBuilder::new( if let Ok(mut pos) = PENDING_POS.lock() {
app, *pos = Some((x, y));
POPUP_LABEL,
WebviewUrl::App("index.html#clipboard-popup".into()),
)
.title("剪贴板")
.inner_size(w, h)
.position(x as f64 / scale, y as f64 / scale)
.decorations(false)
.transparent(true)
.shadow(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false) // 关键:先隐藏,前端 onMounted 后调用 show_window
.focused(true)
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
.build()
{
Ok(w) => w,
Err(e) => {
eprintln!("[clipboard] 创建弹窗失败: {}", e);
return;
} }
}; create_popup_window(app);
// 监听窗口失焦:自动隐藏
let app_handle = app.clone();
let win_handle = win.clone();
win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
let _ = win_handle.hide();
let _ = app_handle.emit("clipboard-popup-hide", ());
}
});
eprintln!("[clipboard] 弹窗窗口已创建(隐藏状态)");
} }
/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。 /// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。
/// 预创建路径下前端 onMounted 也会调用此函数,但 POPUP_PENDING_SHOW 为 false 时直接跳过,
/// 避免应用启动时弹窗自动弹出。仅 show_popup 兜底创建路径才真正显示。
pub fn show_window(app: &AppHandle) { pub fn show_window(app: &AppHandle) {
if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) {
return;
}
if let Some(win) = app.get_webview_window(POPUP_LABEL) { 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.show();
let _ = win.set_focus(); let _ = win.set_focus();
// 通知前端刷新数据 // 通知前端刷新数据
let _ = app.emit("clipboard-popup-show", ()); let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
} }
} }
@@ -226,43 +207,6 @@ fn simulate_paste() {
// 非 Windows 平台暂不支持自动粘贴 // 非 Windows 平台暂不支持自动粘贴
} }
// ===== Win32 API:获取鼠标位置和工作区 ===== // ===== 屏幕/光标/DPI 工具已迁移至 crate::win32_util(跨模块共享) =====
#[cfg(windows)] use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
mod win_api {
use windows_sys::Win32::Foundation::POINT;
use windows_sys::Win32::UI::WindowsAndMessaging::{GetCursorPos, SystemParametersInfoW, SPI_GETWORKAREA};
/// 获取鼠标位置(屏幕坐标,逻辑像素)
pub fn get_cursor_pos() -> Option<(i32, i32)> {
let mut pt = POINT { x: 0, y: 0 };
unsafe {
if GetCursorPos(&mut pt) != 0 {
Some((pt.x, pt.y))
} else {
None
}
}
}
/// 获取屏幕工作区(排除任务栏,逻辑像素)
pub fn get_work_area() -> Option<(f64, f64)> {
use windows_sys::Win32::Foundation::RECT;
let mut rect = RECT { left: 0, top: 0, right: 0, bottom: 0 };
unsafe {
if SystemParametersInfoW(SPI_GETWORKAREA, 0, &mut rect as *mut _ as *mut _, 0) != 0 {
Some(((rect.right - rect.left) as f64, (rect.bottom - rect.top) as f64))
} else {
None
}
}
}
}
#[cfg(not(windows))]
mod win_api {
pub fn get_cursor_pos() -> Option<(i32, i32)> { None }
pub fn get_work_area() -> Option<(f64, f64)> { None }
}
pub use win_api::{get_cursor_pos, get_work_area};
+20 -3
View File
@@ -232,10 +232,19 @@ pub fn dib_info(dib: &[u8]) -> Option<(u32, u32)> {
Some((width as u32, height.abs() as u32)) Some((width as u32, height.abs() as u32))
} }
/// 将 CF_DIB 字节转换为 PNG 字节(支持 24/32bpp BI_RGB)。 /// 将 CF_DIB 字节转换为 PNG 字节(支持 24/32bpp BI_RGB / BI_BITFIELDS)。
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> { pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
use image::codecs::png::PngEncoder; use image::codecs::png::PngEncoder;
use image::ImageEncoder; use image::ImageEncoder;
// 兜底:数据本身就是 PNG / JPEG(极少数来源直接存放压缩数据)
if dib.len() >= 4 && &dib[0..4] == b"\x89PNG" {
return Some(dib.to_vec());
}
if dib.len() >= 3 && dib[0] == 0xFF && dib[1] == 0xD8 && dib[2] == 0xFF {
return Some(dib.to_vec());
}
if dib.len() < 40 { if dib.len() < 40 {
return None; return None;
} }
@@ -244,7 +253,14 @@ pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
let height_raw = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]); let height_raw = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]);
let bpp = u16::from_le_bytes([dib[14], dib[15]]); let bpp = u16::from_le_bytes([dib[14], dib[15]]);
let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]); let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]);
if width <= 0 || compression != 0 { if width <= 0 {
return None;
}
// 接受 BI_RGB(0) 和 BI_BITFIELDS(3)。
// Windows 截图工具(Win+Shift+S / 截图工具)常用 BI_BITFIELDS 标记 32bpp BGRA
// 像素数据本身未压缩,与 BI_RGB 解码方式一致。
// 拒绝 BI_RLE4/8(1/2) 和 BI_JPEG/PNG(4/5) 等真正压缩格式。
if compression != 0 && compression != 3 {
return None; return None;
} }
if bpp != 24 && bpp != 32 { if bpp != 24 && bpp != 32 {
@@ -269,7 +285,8 @@ pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
rgba[dp] = dib[sp + 2]; // R rgba[dp] = dib[sp + 2]; // R
rgba[dp + 1] = dib[sp + 1]; // G rgba[dp + 1] = dib[sp + 1]; // G
rgba[dp + 2] = dib[sp]; // B rgba[dp + 2] = dib[sp]; // B
rgba[dp + 3] = 255; // A // 32bpp 保留 alpha 通道(截图工具常用);24bpp 不透明
rgba[dp + 3] = if bpp == 32 { dib[sp + 3] } else { 255 };
} }
} }
let mut buf = Vec::new(); let mut buf = Vec::new();
+12 -7
View File
@@ -3,12 +3,13 @@
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。 //! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use specta::Type;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
/// 列表项(不含大字段,用于历史/搜索结果) /// 列表项(不含大字段,用于历史/搜索结果)
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ClipboardItem { pub struct ClipboardItem {
pub id: i64, pub id: i64,
@@ -21,7 +22,7 @@ pub struct ClipboardItem {
} }
/// 详情(含文本内容或图片 base64) /// 详情(含文本内容或图片 base64)
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ClipboardItemDetail { pub struct ClipboardItemDetail {
#[serde(flatten)] #[serde(flatten)]
@@ -206,10 +207,11 @@ impl Storage {
} }
/// 获取详情(含文本/图片预览 base64) /// 获取详情(含文本/图片预览 base64)
/// 先锁内查询数据,释放锁后再执行耗时编码(DIB→PNG + base64),避免长时间占用连接锁
pub fn get_detail(&self, id: i64, image_to_base64: impl Fn(&[u8]) -> Option<String>) -> Option<ClipboardItemDetail> { 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 conn = self.conn.lock().ok()?;
let row = conn conn.query_row(
.query_row(
"SELECT id, kind, preview, size, pinned, pinned_order, created_at, content, blob "SELECT id, kind, preview, size, pinned, pinned_order, created_at, content, blob
FROM clipboard_history WHERE id = ?1", FROM clipboard_history WHERE id = ?1",
params![id], params![id],
@@ -232,8 +234,9 @@ impl Storage {
)) ))
}, },
) )
.ok()?; .ok()?
let (item, content, blob) = row; };
// conn 已在此处释放,以下编码不占用连接锁
let image_base64 = if item.kind == "image" { let image_base64 = if item.kind == "image" {
blob.as_deref().and_then(|b| image_to_base64(b)) blob.as_deref().and_then(|b| image_to_base64(b))
} else { } else {
@@ -342,6 +345,7 @@ impl Storage {
} }
/// 按搜索关键词统计匹配的非固定条目总数 /// 按搜索关键词统计匹配的非固定条目总数
/// 与 `search` 保持一致的匹配字段(preview + content),避免分页总数错误
pub fn count_search(&self, query: &str) -> i64 { pub fn count_search(&self, query: &str) -> i64 {
let conn = match self.conn.lock() { let conn = match self.conn.lock() {
Ok(c) => c, Ok(c) => c,
@@ -349,7 +353,8 @@ impl Storage {
}; };
let pattern = format!("%{}%", query); let pattern = format!("%{}%", query);
conn.query_row( 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], params![pattern],
|r| r.get(0), |r| r.get(0),
) )
+57
View File
@@ -0,0 +1,57 @@
//! 全局常量集中定义。
//! 窗口 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";
#[allow(dead_code)]
pub const SCREENSHOT_PIN: &str = "screenshot-pin";
}
/// 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 QUICKPANEL_EXTRACT_PROGRESS: &str = "quickpanel-extract-progress";
// 监控
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 SCREENSHOT_PIN_SHORTCUT: &str = "screenshot-pin-shortcut";
// 内核安装进度
pub const KERNEL_INSTALL_PROGRESS: &str = "kernel-install-progress";
// 应用更新进度
pub const UPDATE_PROGRESS: &str = "update-progress";
// 进程与下载
pub const PROCESS_STATUS_CHANGED: &str = "process-status-changed";
pub const DOWNLOAD_ADDED: &str = "download-added";
/// 浏览器扩展通过 HTTP API 新增下载(前端需置前主窗口并跳到下载画面)
pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added";
}
+10
View File
@@ -8,12 +8,14 @@ use super::task::{DownloadTask, DownloaderSettings};
/// 获取所有任务 /// 获取所有任务
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTask> { pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTask> {
engine.get_tasks() engine.get_tasks()
} }
/// 检查 URL 重复性并探测文件信息(添加下载前调用) /// 检查 URL 重复性并探测文件信息(添加下载前调用)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn downloader_check_url( pub async fn downloader_check_url(
engine: State<'_, DownloadEngine>, engine: State<'_, DownloadEngine>,
url: String, url: String,
@@ -47,6 +49,7 @@ pub async fn downloader_check_url(
/// 添加下载任务 /// 添加下载任务
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn downloader_add_task( pub async fn downloader_add_task(
engine: State<'_, DownloadEngine>, engine: State<'_, DownloadEngine>,
url: String, url: String,
@@ -60,18 +63,21 @@ pub async fn downloader_add_task(
/// 暂停任务 /// 暂停任务
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn downloader_pause_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> { pub fn downloader_pause_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.pause_task(&id) engine.pause_task(&id)
} }
/// 恢复任务 /// 恢复任务
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> { pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
engine.resume_task(&id) engine.resume_task(&id)
} }
/// 移除任务 /// 移除任务
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn downloader_remove_task( pub fn downloader_remove_task(
engine: State<'_, DownloadEngine>, engine: State<'_, DownloadEngine>,
id: String, id: String,
@@ -82,12 +88,14 @@ pub fn downloader_remove_task(
/// 获取设置 /// 获取设置
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn downloader_get_settings(engine: State<'_, DownloadEngine>) -> DownloaderSettings { pub fn downloader_get_settings(engine: State<'_, DownloadEngine>) -> DownloaderSettings {
engine.get_settings() engine.get_settings()
} }
/// 保存设置 /// 保存设置
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn downloader_save_settings( pub fn downloader_save_settings(
engine: State<'_, DownloadEngine>, engine: State<'_, DownloadEngine>,
settings: DownloaderSettings, settings: DownloaderSettings,
@@ -118,6 +126,7 @@ pub fn downloader_get_extension_info(engine: State<'_, DownloadEngine>) -> serde
/// 用系统资源管理器打开目录 /// 用系统资源管理器打开目录
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn downloader_open_dir(app: AppHandle, path: String) -> Result<(), String> { pub fn downloader_open_dir(app: AppHandle, path: String) -> Result<(), String> {
app.opener() app.opener()
.open_path(path, None::<&str>) .open_path(path, None::<&str>)
@@ -126,6 +135,7 @@ pub fn downloader_open_dir(app: AppHandle, path: String) -> Result<(), String> {
/// 用系统默认浏览器打开 URL /// 用系统默认浏览器打开 URL
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> { pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
app.opener() app.opener()
.open_url(url, None::<&str>) .open_url(url, None::<&str>)
+116 -49
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -10,9 +10,10 @@ use super::http_dl::{HttpDownloader, split_segments};
use super::rate_limit::RateLimiter; use super::rate_limit::RateLimiter;
use super::storage::{EngineState, Storage}; use super::storage::{EngineState, Storage};
use super::task::{DownloadTask, DownloaderSettings, ProbeResult, Segment, TaskStatus}; 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")] #[serde(rename_all = "camelCase")]
pub enum DuplicateKind { 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")] #[serde(rename_all = "camelCase")]
pub struct ExistingTaskInfo { pub struct ExistingTaskInfo {
pub id: String, pub id: String,
@@ -35,7 +36,7 @@ pub struct ExistingTaskInfo {
} }
/// check_url 命令返回的结果 /// check_url 命令返回的结果
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CheckUrlResult { pub struct CheckUrlResult {
/// 探测是否成功 /// 探测是否成功
@@ -77,6 +78,9 @@ pub struct CompletePayload {
/// 活跃下载句柄 /// 活跃下载句柄
struct TaskHandle { struct TaskHandle {
/// 代际号:同一任务每次 start_download 递增。
/// 旧代际任务完成时不删除新任务句柄、不覆盖新任务状态(防 pause→resume 竞态)
gen: u64,
cancel: Arc<AtomicBool>, cancel: Arc<AtomicBool>,
/// 每个分段的已下载字节(与 segments 一一对应) /// 每个分段的已下载字节(与 segments 一一对应)
progress: Vec<Arc<std::sync::atomic::AtomicU64>>, progress: Vec<Arc<std::sync::atomic::AtomicU64>>,
@@ -107,6 +111,8 @@ struct EngineInner {
app_handle: AppHandle, app_handle: AppHandle,
/// 引擎是否已启动 /// 引擎是否已启动
started: AtomicBool, started: AtomicBool,
/// 任务代际计数器(每次 start_download 递增,分配给新句柄)
next_gen: AtomicU64,
/// 持久化节流:上次保存时间 /// 持久化节流:上次保存时间
last_save: Mutex<Instant>, last_save: Mutex<Instant>,
} }
@@ -149,6 +155,7 @@ impl DownloadEngine {
http: HttpDownloader::new(), http: HttpDownloader::new(),
app_handle, app_handle,
started: AtomicBool::new(false), started: AtomicBool::new(false),
next_gen: AtomicU64::new(0),
last_save: Mutex::new(Instant::now()), last_save: Mutex::new(Instant::now()),
}), }),
}; };
@@ -182,7 +189,7 @@ impl DownloadEngine {
headers: &HashMap<String, String>, headers: &HashMap<String, String>,
) -> (Result<ProbeResult, String>, DuplicateKind, Option<ExistingTaskInfo>) { ) -> (Result<ProbeResult, String>, DuplicateKind, Option<ExistingTaskInfo>) {
let probe = self.inner.http.probe(url, headers).await; 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 task_dir = dir.map(|d| d.to_string()).unwrap_or_else(|| settings.download_dir.clone());
let filename = probe.as_ref().ok() let filename = probe.as_ref().ok()
@@ -199,7 +206,7 @@ impl DownloadEngine {
let mut existing: Option<ExistingTaskInfo> = None; 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() { for t in tasks.values() {
// URL 完全相同 // URL 完全相同
if t.url == url { if t.url == url {
@@ -280,7 +287,7 @@ impl DownloadEngine {
// 探测资源信息 // 探测资源信息
let probe = self.inner.http.probe(&url, &headers).await; 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()); 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); tasks.insert(id.clone(), task);
} }
self.persist_now(); self.persist_now();
// 如果探测成功,尝试调度 // 如果探测成功,尝试调度
let should_schedule = { 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) tasks.get(&id).map(|t| t.status == TaskStatus::Queued).unwrap_or(false)
}; };
if should_schedule { if should_schedule {
@@ -362,7 +369,7 @@ impl DownloadEngine {
} }
// 通知前端有新任务加入(扩展通过 HTTP API 添加时,前端需要刷新) // 通知前端有新任务加入(扩展通过 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) Ok(id)
} }
@@ -371,7 +378,7 @@ impl DownloadEngine {
pub fn pause_task(&self, id: &str) -> Result<(), String> { pub fn pause_task(&self, id: &str) -> Result<(), String> {
// 1. 设置取消标志 + 读取进度(锁 handles) // 1. 设置取消标志 + 读取进度(锁 handles)
let progress_values: Vec<u64> = { 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) { if let Some(handle) = handles.get(id) {
handle.cancel.store(true, Ordering::SeqCst); handle.cancel.store(true, Ordering::SeqCst);
handle.progress.iter().map(|p| p.load(Ordering::Relaxed)).collect() handle.progress.iter().map(|p| p.load(Ordering::Relaxed)).collect()
@@ -382,7 +389,7 @@ impl DownloadEngine {
// 2. 更新任务状态 + 同步进度(锁 tasks,不嵌套锁 handles // 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 let Some(task) = tasks.get_mut(id) {
if task.status == TaskStatus::Active || task.status == TaskStatus::Queued { if task.status == TaskStatus::Active || task.status == TaskStatus::Queued {
task.status = TaskStatus::Paused; task.status = TaskStatus::Paused;
@@ -407,7 +414,7 @@ impl DownloadEngine {
/// 恢复任务 /// 恢复任务
pub fn resume_task(&self, id: &str) -> Result<(), String> { 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 let Some(task) = tasks.get_mut(id) {
if task.status != TaskStatus::Paused && task.status != TaskStatus::Error { if task.status != TaskStatus::Paused && task.status != TaskStatus::Error {
return Err("任务不在可恢复状态".to_string()); return Err("任务不在可恢复状态".to_string());
@@ -434,7 +441,7 @@ impl DownloadEngine {
pub fn remove_task(&self, id: &str, delete_files: bool) -> Result<(), String> { pub fn remove_task(&self, id: &str, delete_files: bool) -> Result<(), String> {
// 1. 设置取消标志 + abort join handle(锁 handles // 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) { if let Some(handle) = handles.remove(id) {
handle.cancel.store(true, Ordering::SeqCst); handle.cancel.store(true, Ordering::SeqCst);
if let Ok(mut join) = handle.join.lock() { if let Ok(mut join) = handle.join.lock() {
@@ -447,7 +454,7 @@ impl DownloadEngine {
// 2. 从任务列表移除(锁 tasks) // 2. 从任务列表移除(锁 tasks)
let task = { 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) tasks.remove(id)
}; };
@@ -466,12 +473,12 @@ impl DownloadEngine {
/// 获取所有任务 /// 获取所有任务
pub fn get_tasks(&self) -> Vec<DownloadTask> { 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 { 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); 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; *s = settings;
} }
self.persist_now(); self.persist_now();
@@ -500,7 +507,7 @@ impl DownloadEngine {
pub fn cleanup_on_exit(&self) { 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() { for handle in handles.values() {
handle.cancel.store(true, Ordering::SeqCst); handle.cancel.store(true, Ordering::SeqCst);
} }
@@ -508,7 +515,7 @@ impl DownloadEngine {
// 将 Active 任务标记为 Paused // 将 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() { for task in tasks.values_mut() {
if task.status == TaskStatus::Active { if task.status == TaskStatus::Active {
task.status = TaskStatus::Paused; task.status = TaskStatus::Paused;
@@ -528,10 +535,10 @@ impl DownloadEngine {
/// 调度:如果活跃任务数 < max_concurrent,启动排队任务 /// 调度:如果活跃任务数 < max_concurrent,启动排队任务
fn schedule(&self) { 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 (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 active = tasks.values().filter(|t| t.status == TaskStatus::Active).count();
let mut queued: Vec<_> = tasks let mut queued: Vec<_> = tasks
.values() .values()
@@ -554,7 +561,7 @@ impl DownloadEngine {
/// 启动单个下载任务 /// 启动单个下载任务
fn start_download(&self, id: String) { fn start_download(&self, id: String) {
let task = { 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) { match tasks.get_mut(&id) {
Some(task) if task.status == TaskStatus::Queued => { Some(task) if task.status == TaskStatus::Queued => {
task.status = TaskStatus::Active; 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 cancel = Arc::new(AtomicBool::new(false));
let progress: Vec<Arc<std::sync::atomic::AtomicU64>> = task 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))) .map(|s| Arc::new(std::sync::atomic::AtomicU64::new(s.completed)))
.collect(); .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(), cancel: cancel.clone(),
progress: progress.iter().map(|p| p.clone()).collect(), progress: progress.iter().map(|p| p.clone()).collect(),
join: Mutex::new(None), join: Mutex::new(None),
}; },
self.inner.handles.lock().unwrap().insert(id.clone(), handle); );
}
// 生成下载 future // 生成下载 future
let engine = self.clone(); let engine = self.clone();
@@ -596,25 +615,44 @@ impl DownloadEngine {
let http = self.inner.http.clone(); let http = self.inner.http.clone();
let join = tauri::async_runtime::spawn(async move { let join = tauri::async_runtime::spawn(async move {
let my_gen = gen;
let result = http let result = http
.download(&url, &headers, &segments, &temp_file_path, cancel_clone, &progress_clone, limiter) .download(&url, &headers, &segments, &temp_file_path, cancel_clone, &progress_clone, limiter)
.await; .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, Ok(()) => TaskStatus::Complete,
Err(e) if e == "已取消" => TaskStatus::Paused, Err(e) if e == "已取消" => TaskStatus::Paused,
Err(_) => TaskStatus::Error, Err(_) => TaskStatus::Error,
}; };
let mut final_error: Option<String> = match &result {
Err(e) if e != "已取消" => Some(e.clone()),
_ => None,
};
// 下载成功后,将临时文件重命名为最终文件名 // 下载成功后,将临时文件重命名为最终文件名
if final_status == TaskStatus::Complete { 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) { if let Some(task) = tasks.get_mut(&id_clone) {
for (i, prog) in progress_clone.iter().enumerate() { for (i, prog) in progress_clone.iter().enumerate() {
if let Some(seg) = task.segments.get_mut(i) { if let Some(seg) = task.segments.get_mut(i) {
@@ -624,10 +662,8 @@ impl DownloadEngine {
task.recalc_completed(); task.recalc_completed();
task.speed = 0; task.speed = 0;
task.status = final_status.clone(); task.status = final_status.clone();
if let Err(e) = &result { if let Some(e) = final_error {
if e != "已取消" { task.error = Some(e);
task.error = Some(e.clone());
}
} }
if final_status == TaskStatus::Complete { if final_status == TaskStatus::Complete {
task.completed_size = task.total_size.max(task.completed_size); 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 { if let Some(task) = task {
let _ = engine.inner.app_handle.emit( let _ = engine.inner.app_handle.emit(
"download-complete", "download-complete",
@@ -655,10 +698,11 @@ impl DownloadEngine {
// 持久化 + 调度下一个 // 持久化 + 调度下一个
engine.persist_now(); engine.persist_now();
engine.schedule(); engine.schedule();
}
}); });
// 存储 JoinHandle // 存储 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() { if let Ok(mut join_guard) = h.join.lock() {
*join_guard = Some(join); *join_guard = Some(join);
} }
@@ -670,6 +714,7 @@ impl DownloadEngine {
let progress_monitor = progress; let progress_monitor = progress;
let cancel_monitor = cancel; let cancel_monitor = cancel;
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let my_gen = gen;
// 初始化为当前已下载量,避免恢复下载时首次计算速度异常 // 初始化为当前已下载量,避免恢复下载时首次计算速度异常
let mut last_completed: u64 = progress_monitor let mut last_completed: u64 = progress_monitor
.iter() .iter()
@@ -681,9 +726,17 @@ impl DownloadEngine {
loop { loop {
interval.tick().await; 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 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) tasks.get(&id_monitor).map(|t| t.status == TaskStatus::Active).unwrap_or(false)
}; };
if !is_active || cancel_monitor.load(Ordering::SeqCst) { if !is_active || cancel_monitor.load(Ordering::SeqCst) {
@@ -708,8 +761,8 @@ impl DownloadEngine {
last_time = now; last_time = now;
// 更新任务状态 + 发送进度事件 // 更新任务状态 + 发送进度事件
let total_size = { let (total_size, live_status) = {
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) { if let Some(task) = tasks.get_mut(&id_monitor) {
task.completed_size = completed; task.completed_size = completed;
task.speed = speed; task.speed = speed;
@@ -718,7 +771,10 @@ impl DownloadEngine {
seg.completed = prog.load(Ordering::Relaxed); seg.completed = prog.load(Ordering::Relaxed);
} }
} }
task.total_size // 读取任务实时状态而非硬编码 Active:
// 暂停后监控循环 break 前可能发出的最后一次事件
// 必须携带 Paused,否则前端会把任务状态覆盖回"下载中"
(task.total_size, task.status.clone())
} else { } else {
break; break;
} }
@@ -731,7 +787,7 @@ impl DownloadEngine {
completed_size: completed, completed_size: completed,
total_size, total_size,
speed, speed,
status: TaskStatus::Active, status: live_status,
}, },
); );
} }
@@ -743,7 +799,7 @@ impl DownloadEngine {
/// 节流持久化(至少间隔 3 秒) /// 节流持久化(至少间隔 3 秒)
fn persist_throttled(&self) { fn persist_throttled(&self) {
let should_save = { 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) last.elapsed() >= Duration::from_secs(3)
}; };
if should_save { if should_save {
@@ -751,19 +807,30 @@ impl DownloadEngine {
} }
} }
/// 立即持久化 /// 立即持久化
/// 保存失败(磁盘不可写/rename 失败)时把进行中任务标记为 Error,
/// 防止用户误以为任务已持久化而关闭应用导致数据丢失。
fn persist_now(&self) { fn persist_now(&self) {
let tasks: Vec<DownloadTask> = { 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() 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 { let state = EngineState {
tasks, tasks,
settings, settings,
next_id: 0, // storage.save 会从 id_counter 读取 next_id: 0, // storage.save 会从 id_counter 读取
}; };
self.inner.storage.save(state); if let Err(e) = self.inner.storage.save(state) {
*self.inner.last_save.lock().unwrap() = Instant::now(); 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::rate_limit::RateLimiter;
use super::task::{ProbeResult, Segment}; 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 下载器 /// HTTP/HTTPS 下载器
#[derive(Clone)] #[derive(Clone)]
pub struct HttpDownloader { pub struct HttpDownloader {
@@ -43,7 +49,10 @@ impl HttpDownloader {
req = req.header(k, v); 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) => { Ok(resp) => {
let status = resp.status(); let status = resp.status();
let headers_map = resp.headers().clone(); let headers_map = resp.headers().clone();
@@ -92,9 +101,9 @@ impl HttpDownloader {
for (k, v) in headers { for (k, v) in headers {
head_req = head_req.header(k, v); head_req = head_req.header(k, v);
} }
let resp = head_req let resp = tokio::time::timeout(REQUEST_TIMEOUT, head_req.send())
.send()
.await .await
.map_err(|_| "探测超时(HEAD 30s 内未收到响应头)".to_string())?
.map_err(|e| format!("探测失败(GET 和 HEAD 均失败): {}", e))?; .map_err(|e| format!("探测失败(GET 和 HEAD 均失败): {}", e))?;
let headers_map = resp.headers().clone(); let headers_map = resp.headers().clone();
let total_size = headers_map let total_size = headers_map
@@ -295,15 +304,27 @@ async fn download_segment_with_client(
req = req.header(k, v); req = req.header(k, v);
} }
let resp = req let resp = tokio::time::timeout(REQUEST_TIMEOUT, req.send())
.send()
.await .await
.map_err(|_| "请求超时(30s 内未收到响应头)".to_string())?
.map_err(|e| format!("请求失败: {}", e))?; .map_err(|e| format!("请求失败: {}", e))?;
let status = resp.status(); 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)); 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(); let mut stream = resp.bytes_stream();
@@ -315,8 +336,9 @@ async fn download_segment_with_client(
return Err("已取消".to_string()); return Err("已取消".to_string());
} }
match stream.next().await { // 停滞超时:30s 无数据即中断,确保 cancel 标志能及时被感知(配合代际句柄防并发写)
Some(Ok(chunk)) => { match tokio::time::timeout(READ_STALL_TIMEOUT, stream.next()).await {
Ok(Some(Ok(chunk))) => {
buf.extend_from_slice(&chunk); buf.extend_from_slice(&chunk);
// 接收到数据立即更新进度(避免监控周期内进度无变化导致速度显示为 0) // 接收到数据立即更新进度(避免监控周期内进度无变化导致速度显示为 0)
local_completed += chunk.len() as u64; local_completed += chunk.len() as u64;
@@ -331,10 +353,10 @@ async fn download_segment_with_client(
buf.clear(); buf.clear();
} }
} }
Some(Err(e)) => { Ok(Some(Err(e))) => {
return Err(format!("读取数据失败: {}", e)); return Err(format!("读取数据失败: {}", e));
} }
None => { Ok(None) => {
// 流结束,写入剩余数据 // 流结束,写入剩余数据
if !buf.is_empty() { if !buf.is_empty() {
file.write_all(&buf) file.write_all(&buf)
@@ -346,6 +368,9 @@ async fn download_segment_with_client(
} }
break; break;
} }
Err(_) => {
return Err("读取超时(30s 无数据,已中断下载)".to_string());
}
} }
} }
@@ -436,10 +461,11 @@ fn percent_decode(s: &str) -> String {
/// 将文件大小划分为 N 个分段 /// 将文件大小划分为 N 个分段
pub fn split_segments(total_size: u64, num_connections: u32) -> Vec<Segment> { pub fn split_segments(total_size: u64, num_connections: u32) -> Vec<Segment> {
if total_size == 0 || num_connections == 0 { if total_size == 0 || num_connections == 0 {
// 空文件或未指定连接数:单段覆盖整个文件(end 按 total_size 推导,不能硬编码 0
return vec![Segment { return vec![Segment {
index: 0, index: 0,
start: 0, start: 0,
end: 0, end: total_size.saturating_sub(1),
completed: 0, completed: 0,
}]; }];
} }
@@ -473,3 +499,86 @@ pub fn split_segments(total_size: u64, num_connections: u32) -> Vec<Segment> {
segments 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 前释放) // 尝试在当前窗口消费(用作用域确保 MutexGuard 在 await 前释放)
let over_limit = { let over_limit = {
let now = std::time::Instant::now(); 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); let elapsed = now.duration_since(*start);
// 窗口过期,重置 // 窗口过期,重置
@@ -72,3 +72,64 @@ impl Clone for RateLimiter {
Self::new(self.limit.load(Ordering::Relaxed)) 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));
}
}
+19 -6
View File
@@ -9,6 +9,7 @@ use axum::{
Router, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager};
use super::engine::DownloadEngine; use super::engine::DownloadEngine;
use super::task::DownloadTask; use super::task::DownloadTask;
@@ -45,27 +46,31 @@ struct ErrorResponse {
impl ExtensionServer { impl ExtensionServer {
/// 启动 HTTP API 服务器(绑定到 127.0.0.1:port /// 启动 HTTP API 服务器(绑定到 127.0.0.1:port
pub async fn start(engine: DownloadEngine, port: u16, secret: String) { pub async fn start(engine: DownloadEngine, port: u16, secret: String, app_handle: AppHandle) {
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().expect("无效端口"); let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().expect("无效端口");
let app = Router::new() let app = Router::new()
.route("/health", get(health)) .route("/health", get(health))
.route("/api/downloads", post(create_download).get(list_downloads)) .route("/api/downloads", post(create_download).get(list_downloads))
.route("/api/downloads/:id", axum::routing::delete(remove_download)) .route("/api/downloads/:id", axum::routing::delete(remove_download))
.with_state(AppState { engine, secret }); .with_state(AppState {
engine,
secret,
app_handle,
});
let listener = match tokio::net::TcpListener::bind(&addr).await { let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l, Ok(l) => l,
Err(e) => { Err(e) => {
eprintln!("[download_engine] 扩展 HTTP 服务启动失败 ({}): {}", addr, e); crate::logger::log_error("download", &format!("扩展 HTTP 服务启动失败 ({}): {}", addr, e));
return; 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 { if let Err(e) = axum::serve(listener, app).await {
eprintln!("[download_engine] 扩展 HTTP 服务异常: {}", e); crate::logger::log_error("download", &format!("扩展 HTTP 服务异常: {}", e));
} }
} }
} }
@@ -74,6 +79,7 @@ impl ExtensionServer {
struct AppState { struct AppState {
engine: DownloadEngine, engine: DownloadEngine,
secret: String, secret: String,
app_handle: AppHandle,
} }
/// 鉴权检查:如果配置了 secret,校验 Bearer token /// 鉴权检查:如果配置了 secret,校验 Bearer token
@@ -109,7 +115,14 @@ async fn create_download(
} }
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await { match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await {
Ok(id) => Ok(Json(CreateDownloadResponse { id })), Ok(id) => {
// 浏览器扩展发起下载:置前主窗口并通知前端跳到下载画面(替代原桌面通知)
crate::tray_menu::focus_main_window(&state.app_handle);
let _ = state
.app_handle
.emit(crate::constants::events::DOWNLOAD_EXTENSION_ADDED, ());
Ok(Json(CreateDownloadResponse { id }))
}
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))), Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
} }
} }
+14 -13
View File
@@ -40,7 +40,7 @@ impl Storage {
pub fn new(data_dir: PathBuf) -> Self { pub fn new(data_dir: PathBuf) -> Self {
// 确保数据目录存在(首次启动或目录被删除时自动创建) // 确保数据目录存在(首次启动或目录被删除时自动创建)
if let Err(e) = fs::create_dir_all(&data_dir) { 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 state_path = data_dir.join("engine_state.json");
let existing = Self::load_raw(&state_path); let existing = Self::load_raw(&state_path);
@@ -68,23 +68,24 @@ impl Storage {
Self::load_raw(&self.state_path).unwrap_or_default() Self::load_raw(&self.state_path).unwrap_or_default()
} }
/// 保存状态到磁盘 /// 保存状态到磁盘(原子写:先写 .tmp 再 rename 覆盖,
pub fn save(&self, mut state: EngineState) { /// 避免进程崩溃时产生半写/截断的状态文件导致任务列表丢失)
/// 失败返回 Err,由调用方决定是否将任务置为 Error(防止"看似已保存"的假象)。
pub fn save(&self, mut state: EngineState) -> Result<(), String> {
// 同步 ID 计数器 // 同步 ID 计数器
state.next_id = self.id_counter.load(Ordering::SeqCst); state.next_id = self.id_counter.load(Ordering::SeqCst);
match serde_json::to_string_pretty(&state) { let json = serde_json::to_string_pretty(&state)
Ok(json) => { .map_err(|e| format!("序列化状态失败: {}", e))?;
// 兜底:若父目录被外部删除则在写入前重建 // 兜底:若父目录被外部删除则在写入前重建
if let Some(parent) = self.state_path.parent() { if let Some(parent) = self.state_path.parent() {
let _ = fs::create_dir_all(parent); let _ = fs::create_dir_all(parent);
} }
if let Err(e) = fs::write(&self.state_path, json) { let tmp_path = PathBuf::from(format!("{}.tmp", self.state_path.display()));
eprintln!("[download_engine] 保存状态失败: {}", e); 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);
Err(e) => { format!("替换状态文件失败: {}", e)
eprintln!("[download_engine] 序列化状态失败: {}", e); })?;
} Ok(())
}
} }
} }
+5 -4
View File
@@ -1,8 +1,9 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap; use std::collections::HashMap;
/// 任务状态 /// 任务状态
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum TaskStatus { pub enum TaskStatus {
/// 排队等待(并发数已满) /// 排队等待(并发数已满)
@@ -18,7 +19,7 @@ pub enum TaskStatus {
} }
/// 下载分段(多线程 Range 下载 / 断点续传用) /// 下载分段(多线程 Range 下载 / 断点续传用)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Segment { pub struct Segment {
/// 分段索引 /// 分段索引
@@ -43,7 +44,7 @@ impl Segment {
} }
/// 下载任务 /// 下载任务
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DownloadTask { pub struct DownloadTask {
/// 任务 ID(自增 hex 字符串) /// 任务 ID(自增 hex 字符串)
@@ -98,7 +99,7 @@ impl DownloadTask {
} }
/// 下载设置 /// 下载设置
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DownloaderSettings { pub struct DownloaderSettings {
/// 下载目录 /// 下载目录
+188 -140
View File
@@ -1,7 +1,7 @@
use std::sync::Arc;
use tauri::Manager; use tauri::Manager;
mod clipboard; mod clipboard;
mod constants;
mod download_engine; mod download_engine;
mod logger; mod logger;
mod mihomo_manager; mod mihomo_manager;
@@ -9,17 +9,23 @@ mod monitor_kernel;
mod network_monitor; mod network_monitor;
mod osd_window; mod osd_window;
mod process_manager; mod process_manager;
mod quickpanel;
mod screenshot;
mod setup;
mod shortcut;
mod snap_fix; mod snap_fix;
mod tray_menu; mod tray_menu;
mod updater;
mod win32_util;
use download_engine::{ use download_engine::{
DownloadEngine, ExtensionServer, DownloadEngine,
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task, downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status, downloader_resume_task, downloader_save_settings, downloader_status,
}; };
use logger::{ use logger::{
clear_logs, get_log_info, get_logs, log_message, LogManager, log_clear, log_info_state, log_list, log_message,
}; };
use mihomo_manager::{ use mihomo_manager::{
proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_delete_profile, proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_delete_profile,
@@ -29,19 +35,29 @@ use mihomo_manager::{
proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version, MihomoManager, proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version, MihomoManager,
}; };
use monitor_kernel::{ use monitor_kernel::{
monitor_elevate_self, monitor_get_elevate_on_launch, monitor_get_hardware_config, monitor_elevate_self, monitor_get_auto_start, monitor_get_elevate_on_launch,
monitor_get_snapshot, monitor_get_status, monitor_kernel_info, monitor_set_elevate_on_launch, monitor_get_hardware_config, monitor_get_snapshot, monitor_get_status, monitor_kernel_info,
monitor_set_hardware_config, monitor_start, monitor_start_elevated, monitor_status, monitor_set_auto_start, monitor_set_elevate_on_launch, monitor_set_hardware_config,
monitor_stop, MonitorKernel, 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::{ use osd_window::{
osd_apply_overlay_style, osd_begin_drag, osd_set_click_through, osd_set_topmost, 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, osd_start_drag_watch, osd_start_topmost_watch, osd_stop_watch,
}; };
use process_manager::{ use process_manager::{
get_all_process_status, get_process_status, start_monitoring_thread, start_process, process_all_status, process_start, process_status,
stop_all_processes, stop_process, ProcessManager, 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_delete_cache,
screenshot_disable_transitions, screenshot_enum_windows, screenshot_fullscreen_png,
screenshot_get_editor_image, screenshot_get_fullscreen_bmp, screenshot_load_cache,
screenshot_register_pin_shortcut, screenshot_register_shortcut, screenshot_save_cache,
screenshot_save_png, screenshot_set_editor_image, screenshot_unregister_pin_shortcut,
screenshot_unregister_shortcut, screenshot_window_from_point,
}; };
use clipboard::{ use clipboard::{
ClipboardManager, ClipboardManager,
@@ -51,42 +67,99 @@ use clipboard::{
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start, clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
clipboard_status, clipboard_stop, clipboard_unregister_shortcut, clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
}; };
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_show_window}; use quickpanel::{
quickpanel_apply_rename, quickpanel_batch_extract, quickpanel_build_file_index,
quickpanel_clear_app_icon_cache, quickpanel_delete_file, quickpanel_delete_files,
quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings,
quickpanel_get_special_locations, quickpanel_hide_popup, quickpanel_init_file_index,
quickpanel_list_archives, quickpanel_list_dir, quickpanel_lock_screen, quickpanel_open_file,
quickpanel_open_special, quickpanel_preview_rename, quickpanel_register_shortcut,
quickpanel_reveal_in_explorer, quickpanel_run_custom_command, quickpanel_run_system_command,
quickpanel_save_settings, quickpanel_scan_apps, quickpanel_search_files, quickpanel_show_popup,
quickpanel_show_window, quickpanel_unregister_shortcut, quickpanel_focus_main_window,
};
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
use updater::{app_version, update_check, update_install, update_thinghk};
#[tauri::command] #[tauri::command]
fn greet(name: &str) -> String { fn quit_app(app: tauri::AppHandle) {
format!("Hello, {}! You've been greeted from Rust!", name) // 资源清理统一收敛到 RunEvent::ExitRequested(覆盖所有退出路径),此处仅请求退出
app.exit(0);
} }
#[tauri::command] /// 导出 tauri-specta 生成的 TypeScript 类型与命令绑定(仅 debug 构建,开发时自动刷新)。
fn quit_app( /// 覆盖 proxy / quickpanel / clipboard / download_engine / screenshot 五个模块;
state: tauri::State<'_, ProcessManager>, /// 豁免清单(返回 serde_json::Value 或 tauri::ipc::Response/Requestspecta 无法生成):
mihomo: tauri::State<'_, MihomoManager>, /// proxy_version / proxy_get_proxies / proxy_get_connections / proxy_patch_configs、
engine: tauri::State<'_, DownloadEngine>, /// downloader_status / downloader_get_extension_info、
monitor: tauri::State<'_, MonitorKernel>, /// screenshot_get_fullscreen_bmp(返回 ipc::Response/ screenshot_compose_copy(接收 ipc::Request)。
clipboard: tauri::State<'_, ClipboardManager>, #[cfg(debug_assertions)]
app: tauri::AppHandle, fn export_bindings() {
) { use specta_typescript::Typescript;
// 退出前清理系统代理,避免遗留导致网络问题 use tauri_specta::{Builder, ErrorHandlingMode, collect_commands};
mihomo.cleanup_on_exit();
// 退出前保存下载引擎状态 Builder::<tauri::Wry>::new()
engine.cleanup_on_exit(); // 全局把 u64/i64 映射为 number(项目取值均在 JS 安全整数范围:大小/时间戳/limit)
// 停止提权 Kernel(普通权限由 stop_all 统一清理,提权 Kernel 需通过 /shutdown .dangerously_cast_bigints_to_number()
tauri::async_runtime::block_on(monitor.cleanup_on_exit(&app)); // 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper
// 停止剪贴板监听线程 .error_handling(ErrorHandlingMode::Throw)
clipboard.stop(); .commands(collect_commands![
// 停止所有子进程(同步 kill + 带超时的 wait,确保进程真正终止 // 应用更新(4
state.stop_all(); app_version, update_check, update_install, update_thinghk,
// 通过 app.exit 触发 RunEvent::ExitRequested,统一退出路径 // proxy20
app.exit(0); 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, quickpanel_list_archives, quickpanel_list_dir,
quickpanel_batch_extract, quickpanel_preview_rename, quickpanel_apply_rename,
quickpanel_delete_files, quickpanel_focus_main_window,
// 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,
// screenshot21,豁免 2get_fullscreen_bmp 返回 ipc::Response、compose_copy 接收 ipc::Request
screenshot_disable_transitions, screenshot_register_shortcut,
screenshot_unregister_shortcut, screenshot_register_pin_shortcut,
screenshot_unregister_pin_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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
#[cfg(debug_assertions)]
export_bindings();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_autostart::Builder::new().build()) .plugin(tauri_plugin_autostart::Builder::new().build())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
// 截图快捷键改由 screenshot::shortcut::register_shortcuton_shortcut)按需注册,
// 支持自定义;默认 Ctrl+Alt+A 由前端 store 启动时注册
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin( .plugin(
@@ -96,17 +169,20 @@ pub fn run() {
) )
.manage(ProcessManager::new()) .manage(ProcessManager::new())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
greet,
quit_app, quit_app,
start_process, app_version,
stop_process, update_check,
get_process_status, update_install,
get_all_process_status, update_thinghk,
stop_all_processes, process_start,
process_stop,
process_status,
process_all_status,
process_stop_all,
log_message, log_message,
get_logs, log_list,
clear_logs, log_clear,
get_log_info, log_info_state,
proxy_get_settings, proxy_get_settings,
proxy_save_settings, proxy_save_settings,
proxy_kernel_info, proxy_kernel_info,
@@ -141,9 +217,11 @@ pub fn run() {
monitor_get_snapshot, monitor_get_snapshot,
monitor_get_elevate_on_launch, monitor_get_elevate_on_launch,
monitor_set_elevate_on_launch, monitor_set_elevate_on_launch,
monitor_get_auto_start,
monitor_set_auto_start,
monitor_get_hardware_config, monitor_get_hardware_config,
monitor_set_hardware_config, monitor_set_hardware_config,
network_monitor_status, network_status,
osd_apply_overlay_style, osd_apply_overlay_style,
osd_begin_drag, osd_begin_drag,
osd_set_click_through, osd_set_click_through,
@@ -183,104 +261,64 @@ pub fn run() {
clipboard_show_window, clipboard_show_window,
clipboard_hide_popup, clipboard_hide_popup,
clipboard_paste_to_target, clipboard_paste_to_target,
quickpanel_get_settings,
quickpanel_save_settings,
quickpanel_register_shortcut,
quickpanel_unregister_shortcut,
quickpanel_show_popup,
quickpanel_show_window,
quickpanel_hide_popup,
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_delete_file,
quickpanel_delete_files,
quickpanel_run_custom_command,
quickpanel_run_system_command,
quickpanel_get_special_locations,
quickpanel_open_special,
quickpanel_list_archives,
quickpanel_list_dir,
quickpanel_batch_extract,
quickpanel_preview_rename,
quickpanel_apply_rename,
quickpanel_focus_main_window,
tray_menu_action, tray_menu_action,
tray_menu_hide, tray_menu_hide,
tray_menu_show_window, tray_menu_ready,
snap_fix::fix_snap_background snap_fix::fix_snap_background,
screenshot_capture_fullscreen,
screenshot_get_fullscreen_bmp,
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,
screenshot_register_shortcut,
screenshot_unregister_shortcut,
screenshot_register_pin_shortcut,
screenshot_unregister_pin_shortcut,
screenshot_disable_transitions,
screenshot_compose_copy
]) ])
.setup(|app| { .setup(setup::init)
// 初始化日志系统,日志目录: {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);
}
}
app.manage(clipboard);
// 自定义托盘菜单(代理/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),
}
});
}
Ok(())
})
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
window.hide().ok(); window.hide().ok();
@@ -298,7 +336,17 @@ pub fn run() {
engine.cleanup_on_exit(); engine.cleanup_on_exit();
} }
if let Some(monitor) = app.try_state::<MonitorKernel>() { 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>() { if let Some(clip) = app.try_state::<ClipboardManager>() {
clip.stop(); clip.stop();
+276 -23
View File
@@ -1,8 +1,9 @@
use chrono::Local; use chrono::Local;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions}; use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write}; use std::io::{Read, Seek, SeekFrom, Write};
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
/// 日志级别 /// 日志级别
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -34,11 +35,14 @@ pub struct LogInfo {
} }
/// 日志管理器 —— 负责文件轮转、写入、查询 /// 日志管理器 —— 负责文件轮转、写入、查询
/// 所有写/轮转/读操作通过 write_lock 串行化,防止并发写交错与轮转竞争
#[derive(Clone)]
pub struct LogManager { pub struct LogManager {
log_dir: PathBuf, log_dir: PathBuf,
max_file_size: u64, max_file_size: u64,
max_files: u32, max_files: u32,
base_name: String, base_name: String,
write_lock: Arc<Mutex<()>>,
} }
impl LogManager { impl LogManager {
@@ -48,12 +52,14 @@ impl LogManager {
log_dir, log_dir,
max_file_size: 5 * 1024 * 1024, max_file_size: 5 * 1024 * 1024,
max_files: 5, 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) { 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 timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let level_str = match level { let level_str = match level {
LogLevel::Debug => "DEBUG", LogLevel::Debug => "DEBUG",
@@ -114,32 +120,38 @@ impl LogManager {
} }
/// 读取日志(支持按模块/级别过滤、条数限制) /// 读取日志(支持按模块/级别过滤、条数限制)
/// 从最新文件向旧文件倒序遍历,每个文件从末尾向前读取,达到 limit 即停止,
/// 避免将全部日志读入内存后再排序截断
pub fn get_logs( pub fn get_logs(
&self, &self,
module: Option<&str>, module: Option<&str>,
level: Option<LogLevel>, level: Option<LogLevel>,
limit: Option<usize>, limit: Option<usize>,
) -> Vec<LogEntry> { ) -> 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 entries: Vec<LogEntry> = Vec::new();
// 收集所有日志文件(包括轮转文件 // 收集日志文件(最新在前:thing.log → thing.1.log → …
let mut log_files: Vec<PathBuf> = Vec::new(); let mut paths: Vec<PathBuf> = Vec::new();
let current = self.log_dir.join(format!("{}.log", self.base_name)); let current = self.log_dir.join(format!("{}.log", self.base_name));
if current.exists() { if current.exists() {
log_files.push(current); paths.push(current);
} }
for i in 1..=self.max_files { for i in 1..=self.max_files {
let rotated = self let rotated = self
.log_dir .log_dir
.join(format!("{}.{}.log", self.base_name, i)); .join(format!("{}.{}.log", self.base_name, i));
if rotated.exists() { if rotated.exists() {
log_files.push(rotated); paths.push(rotated);
} }
} }
for path in &log_files { 'outer: for path in &paths {
if let Ok(file) = File::open(path) { // 每文件返回最近 limit 行(从新到旧),收集满即整体停止
for line in BufReader::new(file).lines().flatten() { for line in read_tail_lines(path, limit) {
if let Some(entry) = Self::parse_line(&line) { if let Some(entry) = Self::parse_line(&line) {
if let Some(ref m) = module { if let Some(ref m) = module {
if entry.module != *m { if entry.module != *m {
@@ -152,18 +164,13 @@ impl LogManager {
} }
} }
entries.push(entry); 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 entries
} }
@@ -212,6 +219,7 @@ impl LogManager {
/// 清空所有日志文件 /// 清空所有日志文件
pub fn clear_logs(&self) -> Result<(), String> { 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)); let current = self.log_dir.join(format!("{}.log", self.base_name));
fs::remove_file(&current).map_err(|e| e.to_string())?; fs::remove_file(&current).map_err(|e| e.to_string())?;
for i in 1..=self.max_files { for i in 1..=self.max_files {
@@ -259,6 +267,63 @@ impl LogManager {
// ===== Tauri 命令 ===== // ===== 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] #[tauri::command]
pub fn log_message( pub fn log_message(
state: tauri::State<'_, LogManager>, state: tauri::State<'_, LogManager>,
@@ -278,12 +343,12 @@ pub fn log_message(
} }
#[tauri::command] #[tauri::command]
pub fn get_logs( pub async fn log_list(
state: tauri::State<'_, LogManager>, state: tauri::State<'_, LogManager>,
module: Option<String>, module: Option<String>,
level: Option<String>, level: Option<String>,
limit: Option<usize>, limit: Option<usize>,
) -> Vec<LogEntry> { ) -> Result<Vec<LogEntry>, String> {
let level = level.and_then(|l| match l.as_str() { let level = level.and_then(|l| match l.as_str() {
"debug" => Some(LogLevel::Debug), "debug" => Some(LogLevel::Debug),
"info" => Some(LogLevel::Info), "info" => Some(LogLevel::Info),
@@ -291,15 +356,203 @@ pub fn get_logs(
"error" => Some(LogLevel::Error), "error" => Some(LogLevel::Error),
_ => None, _ => 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] #[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() state.clear_logs()
} }
#[tauri::command] #[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() 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);
}
}
}
}
}
+437
View File
@@ -0,0 +1,437 @@
//! 代理模块(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 system_proxy::get_system_proxy_windows;
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 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 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,
}
+136 -23
View File
@@ -132,6 +132,41 @@ pub fn is_thing_elevated() -> bool {
false false
} }
// ===================== 监控设置持久化 =====================
/// 监控模块设置(存储于 {app_data_dir}/monitor/settings.json
/// 参照代理模块 ProxySettings 的模式,仅包含需要持久化的运行时开关。
#[derive(Serialize, Deserialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct MonitorSettings {
/// 应用启动时自动启动监控内核(默认 false)
#[serde(default)]
pub auto_start: bool,
}
/// 监控设置文件路径: {app_data_dir}/monitor/settings.json
fn settings_path(app_data_dir: &std::path::Path) -> PathBuf {
app_data_dir.join("monitor").join("settings.json")
}
/// 读取监控设置(文件不存在或解析失败时返回默认值)
pub fn load_monitor_settings(app_data_dir: &std::path::Path) -> MonitorSettings {
fs::read_to_string(settings_path(app_data_dir))
.ok()
.and_then(|s| serde_json::from_str::<MonitorSettings>(&s).ok())
.unwrap_or_default()
}
/// 写入监控设置
pub fn save_monitor_settings(app_data_dir: &std::path::Path, settings: &MonitorSettings) -> Result<(), String> {
let path = settings_path(app_data_dir);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
let content = serde_json::to_string_pretty(settings).map_err(|e| format!("序列化设置失败: {}", e))?;
fs::write(path, content).map_err(|e| format!("写入设置失败: {}", e))
}
// ===================== 永久提权标志持久化 ===================== // ===================== 永久提权标志持久化 =====================
/// 永久提权标志文件路径: {app_data_dir}/monitor/elevate.json /// 永久提权标志文件路径: {app_data_dir}/monitor/elevate.json
@@ -175,11 +210,11 @@ pub fn check_and_relaunch_if_needed(app_data_dir: &std::path::Path) -> bool {
Ok(e) => e, Ok(e) => e,
Err(_) => return false, Err(_) => return false,
}; };
eprintln!("[monitor] 检测到永久提权标志,以管理员权限重启 Thing"); crate::logger::log_info("monitor", "检测到永久提权标志,以管理员权限重启 Thing");
match shell_execute_elevated(&exe.to_string_lossy(), "", None) { match shell_execute_elevated(&exe.to_string_lossy(), "", None) {
Ok(()) => true, Ok(()) => true,
Err(e) => { Err(e) => {
eprintln!("[monitor] 永久提权重启失败(用户可能取消了 UAC): {}", e); crate::logger::log_warn("monitor", &format!("永久提权重启失败(用户可能取消了 UAC): {}", e));
false false
} }
} }
@@ -247,6 +282,17 @@ pub struct MonitorKernel {
listener_ids: Arc<Mutex<Vec<tauri::EventId>>>, listener_ids: Arc<Mutex<Vec<tauri::EventId>>>,
/// 是否以管理员权限运行(提权模式下进程不受 ProcessManager 管控,停止走 /shutdown /// 是否以管理员权限运行(提权模式下进程不受 ProcessManager 管控,停止走 /shutdown
elevated: Arc<AtomicBool>, 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 { impl MonitorKernel {
@@ -265,6 +311,7 @@ impl MonitorKernel {
sub_handle: Arc::new(Mutex::new(None)), sub_handle: Arc::new(Mutex::new(None)),
listener_ids: Arc::new(Mutex::new(Vec::new())), listener_ids: Arc::new(Mutex::new(Vec::new())),
elevated: Arc::new(AtomicBool::new(false)), elevated: Arc::new(AtomicBool::new(false)),
subscribing: Arc::new(AtomicBool::new(false)),
} }
} }
@@ -283,6 +330,32 @@ impl MonitorKernel {
write_elevate_flag(&self.root.join("elevate.json"), enabled) write_elevate_flag(&self.root.join("elevate.json"), enabled)
} }
/// 读取监控设置(auto_start 等)
pub fn load_settings(&self) -> MonitorSettings {
load_monitor_settings(&self.root.parent().unwrap_or(&self.root).to_path_buf())
}
/// 写入监控设置
pub fn save_settings(&self, settings: &MonitorSettings) -> Result<(), String> {
save_monitor_settings(&self.root.parent().unwrap_or(&self.root).to_path_buf(), settings)
}
/// 应用启动时检查是否需要自动启动监控内核
pub fn auto_start_on_launch(&self, app: &AppHandle) {
let settings = self.load_settings();
if !settings.auto_start {
return;
}
let monitor = self.clone();
let app_handle = app.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)),
}
});
}
fn cores_dir(&self) -> PathBuf { fn cores_dir(&self) -> PathBuf {
self.root.join("cores") self.root.join("cores")
} }
@@ -381,10 +454,18 @@ impl MonitorKernel {
/// 流程:轮询 /status 等 ready → 订阅 /stream → 解析 SSE 事件 → emit "monitor-data" /// 流程:轮询 /status 等 ready → 订阅 /stream → 解析 SSE 事件 → emit "monitor-data"
/// 写入熔断:SSE 断开后停止 emit,等待外部调用 reconnect 或 process-status-changed 触发重连 /// 写入熔断:SSE 断开后停止 emit,等待外部调用 reconnect 或 process-status-changed 触发重连
pub async fn start_subscription(self: Self, app: AppHandle) { 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 // 1. 轮询等待 Kernel ready(冷启动约 5s
if let Err(e) = self.wait_for_ready(&app).await { if let Err(e) = self.wait_for_ready(&app).await {
eprintln!("[monitor] 等待 Kernel ready 失败,订阅不启动: {}", e); crate::logger::log_warn("monitor", &format!("等待 Kernel ready 失败,订阅不启动: {}", e));
let _ = app.emit("monitor-error", serde_json::json!({ "stage": "ready", "message": e })); let _ = app.emit(crate::constants::events::MONITOR_ERROR, serde_json::json!({ "stage": "ready", "message": e }));
return; return;
} }
@@ -409,7 +490,7 @@ impl MonitorKernel {
match resp.json::<KernelStatus>().await { match resp.json::<KernelStatus>().await {
Ok(s) if s.ready => { Ok(s) if s.ready => {
let _ = app.emit( let _ = app.emit(
"monitor-ready", crate::constants::events::MONITOR_READY,
serde_json::json!({ serde_json::json!({
"isAdmin": s.is_admin, "isAdmin": s.is_admin,
"sensorCount": s.sensor_count, "sensorCount": s.sensor_count,
@@ -426,8 +507,10 @@ impl MonitorKernel {
Err(e) => last_err = e.to_string(), Err(e) => last_err = e.to_string(),
} }
// 通知前端正在加载(前端可显示 "Kernel 启动中..." // 通知前端正在加载(前端可显示 "Kernel 启动中..."
let elapsed = READY_TIMEOUT_MS.saturating_sub(deadline.duration_since(std::time::Instant::now()).as_millis() as u64); // 用 saturating_duration_sincenow 超过 deadline 时返回 0,避免 duration_since panic
let _ = app.emit("monitor-loading", serde_json::json!({ "elapsedMs": elapsed })); 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; tokio::time::sleep(Duration::from_millis(READY_POLL_INTERVAL_MS)).await;
} }
Err(format!("Kernel 在 {}ms 内未就绪: {}", READY_TIMEOUT_MS, last_err)) Err(format!("Kernel 在 {}ms 内未就绪: {}", READY_TIMEOUT_MS, last_err))
@@ -441,24 +524,24 @@ impl MonitorKernel {
match self.subscribe_once(&url, &app).await { match self.subscribe_once(&url, &app).await {
// 正常结束(客户端取消或服务端关闭) // 正常结束(客户端取消或服务端关闭)
Ok(()) => { Ok(()) => {
eprintln!("[monitor] SSE 流正常结束"); crate::logger::log_info("monitor", "SSE 流正常结束");
break; break;
} }
Err(e) => { Err(e) => {
eprintln!("[monitor] SSE 流异常断开: {}3s 后重试", e); crate::logger::log_warn("monitor", &format!("SSE 流异常断开: {}3s 后重试", e));
let _ = app.emit( let _ = app.emit(
"monitor-disconnected", crate::constants::events::MONITOR_DISCONNECTED,
serde_json::json!({ "message": e }), serde_json::json!({ "message": e }),
); );
tokio::time::sleep(Duration::from_secs(3)).await; tokio::time::sleep(Duration::from_secs(3)).await;
// 重连前先确认 Kernel 是否还活着(可能已被 stop) // 重连前先确认 Kernel 是否还活着(可能已被 stop)
if !self.is_kernel_alive().await { if !self.is_kernel_alive().await {
eprintln!("[monitor] Kernel 已停止,退出 SSE 循环"); crate::logger::log_info("monitor", "Kernel 已停止,退出 SSE 循环");
// 提权模式下 Kernel 崩溃/退出后重置 elevated 标志, // 提权模式下 Kernel 崩溃/退出后重置 elevated 标志,
// 否则 monitor_status 会一直认为提权模式但 Kernel 已死,用户无法重启 // 否则 monitor_status 会一直认为提权模式但 Kernel 已死,用户无法重启
if self.is_elevated() { if self.is_elevated() {
self.elevated.store(false, Ordering::SeqCst); self.elevated.store(false, Ordering::SeqCst);
eprintln!("[monitor] 提权 Kernel 已退出,重置 elevated 标志"); crate::logger::log_info("monitor", "提权 Kernel 已退出,重置 elevated 标志");
} }
break; break;
} }
@@ -497,7 +580,7 @@ impl MonitorKernel {
if let Some(json_str) = parse_sse_data(&event_str) { if let Some(json_str) = parse_sse_data(&event_str) {
if let Ok(snap) = serde_json::from_str::<SensorSnapshot>(&json_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 +602,11 @@ impl MonitorKernel {
/// 停止 SSE 订阅(进程由 ProcessManager.stop 负责) /// 停止 SSE 订阅(进程由 ProcessManager.stop 负责)
pub async fn stop_subscription(&self, app: &AppHandle) { pub async fn stop_subscription(&self, app: &AppHandle) {
// 取消 SSE 任务 // 取消 SSE 任务并等待其退出,确保 subscribing 重入守卫复位。
// 否则 stop 后立即 start 时旧任务仍在 Drop,新订阅会被守卫误跳过。
if let Some(handle) = self.sub_handle.lock().await.take() { if let Some(handle) = self.sub_handle.lock().await.take() {
handle.abort(); handle.abort();
let _ = handle.await;
} }
// 取消 process-status-changed 监听 // 取消 process-status-changed 监听
let ids = self.listener_ids.lock().await.drain(..).collect::<Vec<_>>(); let ids = self.listener_ids.lock().await.drain(..).collect::<Vec<_>>();
@@ -552,7 +637,7 @@ impl MonitorKernel {
let this = this.clone(); let this = this.clone();
let app = app_clone.clone(); let app = app_clone.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
eprintln!("[monitor] 检测到 Kernel 重启恢复,重新订阅 SSE"); crate::logger::log_info("monitor", "检测到 Kernel 重启恢复,重新订阅 SSE");
// 重启后需要重新等待 ready(冷启动约 5s) // 重启后需要重新等待 ready(冷启动约 5s)
this.clone().start_subscription(app).await; this.clone().start_subscription(app).await;
}); });
@@ -585,20 +670,23 @@ impl MonitorKernel {
if status.ready { if status.ready {
if thing_elevated { if thing_elevated {
// Thing 是管理员:停止已有 Kernel(无论什么权限),用 ProcessManager 重启以继承权限 // Thing 是管理员:停止已有 Kernel(无论什么权限),用 ProcessManager 重启以继承权限
eprintln!("[monitor] Thing 已提权,重启 ThingHK 以继承管理员权限"); crate::logger::log_info("monitor", "Thing 已提权,重启 ThingHK 以继承管理员权限");
let _ = self.shutdown_kernel().await; let _ = self.shutdown_kernel().await;
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;
} else if status.is_admin { } else if status.is_admin {
// Thing 非管理员,但已有提权 Kernel:直接接管 // Thing 非管理员,但已有提权 Kernel:直接接管
eprintln!("[monitor] 检测到已有提权 Kernel 运行中,直接接管"); crate::logger::log_info("monitor", "检测到已有提权 Kernel 运行中,直接接管");
self.elevated.store(true, Ordering::SeqCst); self.elevated.store(true, Ordering::SeqCst);
let kernel = self.clone(); let kernel = self.clone();
kernel.clone().register_auto_reconnect(app.clone()).await; kernel.clone().register_auto_reconnect(app.clone()).await;
// 已有订阅循环在运行则不覆盖句柄(否则旧任务句柄丢失,stop 无法取消)
if !kernel.subscribing.load(Ordering::SeqCst) {
let app_clone = app.clone(); let app_clone = app.clone();
let handle = tauri::async_runtime::spawn(async move { let handle = tauri::async_runtime::spawn(async move {
kernel.start_subscription(app_clone).await; kernel.start_subscription(app_clone).await;
}); });
*self.sub_handle.lock().await = Some(handle); *self.sub_handle.lock().await = Some(handle);
}
// 提权模式下无 ProcessInfo,返回一个占位的 // 提权模式下无 ProcessInfo,返回一个占位的
return Ok(crate::process_manager::ProcessInfo { return Ok(crate::process_manager::ProcessInfo {
id: PROCESS_ID.into(), id: PROCESS_ID.into(),
@@ -609,7 +697,7 @@ impl MonitorKernel {
}); });
} else { } else {
// Thing 非管理员,已有普通 Kernel:先停止 // Thing 非管理员,已有普通 Kernel:先停止
eprintln!("[monitor] 检测到已有普通权限 Kernel 运行中,先停止再重启"); crate::logger::log_info("monitor", "检测到已有普通权限 Kernel 运行中,先停止再重启");
let _ = self.shutdown_kernel().await; let _ = self.shutdown_kernel().await;
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;
} }
@@ -623,7 +711,7 @@ impl MonitorKernel {
// Thing 是管理员时,ThingHK 继承权限,标记 elevated(仍由 ProcessManager 管理) // Thing 是管理员时,ThingHK 继承权限,标记 elevated(仍由 ProcessManager 管理)
if thing_elevated { if thing_elevated {
self.elevated.store(true, Ordering::SeqCst); self.elevated.store(true, Ordering::SeqCst);
eprintln!("[monitor] ThingHK 已以管理员权限启动(继承自 Thing)"); crate::logger::log_info("monitor", "ThingHK 已以管理员权限启动(继承自 Thing)");
} }
// 用 self 的 clone(共享 Arc<Mutex> 状态)启动订阅, // 用 self 的 clone(共享 Arc<Mutex> 状态)启动订阅,
@@ -631,11 +719,14 @@ impl MonitorKernel {
// stop_subscription 时才能正确清理 listener。 // stop_subscription 时才能正确清理 listener。
let kernel = self.clone(); let kernel = self.clone();
kernel.clone().register_auto_reconnect(app.clone()).await; kernel.clone().register_auto_reconnect(app.clone()).await;
// 已有订阅循环在运行则不覆盖句柄(否则旧任务句柄丢失,stop 无法取消)
if !kernel.subscribing.load(Ordering::SeqCst) {
let app_clone = app.clone(); let app_clone = app.clone();
let handle = tauri::async_runtime::spawn(async move { let handle = tauri::async_runtime::spawn(async move {
kernel.start_subscription(app_clone).await; kernel.start_subscription(app_clone).await;
}); });
*self.sub_handle.lock().await = Some(handle); *self.sub_handle.lock().await = Some(handle);
}
Ok(info) Ok(info)
} }
@@ -703,17 +794,20 @@ impl MonitorKernel {
} }
self.elevated.store(true, Ordering::SeqCst); self.elevated.store(true, Ordering::SeqCst);
eprintln!("[monitor] 提权启动已发起,等待 Kernel ready"); crate::logger::log_info("monitor", "提权启动已发起,等待 Kernel ready");
// 5. 启动 SSE 订阅(提权模式不注册 register_auto_reconnect // 5. 启动 SSE 订阅(提权模式不注册 register_auto_reconnect
// 提权进程不归 ProcessManager 管,process-status-changed 事件不会触发, // 提权进程不归 ProcessManager 管,process-status-changed 事件不会触发,
// 注册了反而可能在其他进程状态变化时误触发 SSE 重连) // 注册了反而可能在其他进程状态变化时误触发 SSE 重连)
let kernel = self.clone(); let kernel = self.clone();
let app_clone = app.clone(); let app_clone = app.clone();
// 已有订阅循环在运行则不覆盖句柄(否则旧任务句柄丢失,stop 无法取消)
if !kernel.subscribing.load(Ordering::SeqCst) {
let handle = tauri::async_runtime::spawn(async move { let handle = tauri::async_runtime::spawn(async move {
kernel.start_subscription(app_clone).await; kernel.start_subscription(app_clone).await;
}); });
*self.sub_handle.lock().await = Some(handle); *self.sub_handle.lock().await = Some(handle);
}
Ok(()) Ok(())
} }
@@ -745,7 +839,7 @@ impl MonitorKernel {
self.stop_subscription(app).await; self.stop_subscription(app).await;
// 仅当 ThingHK 提权但 Thing 未提权时才需要 /shutdownProcessManager 无法 kill 管理员进程) // 仅当 ThingHK 提权但 Thing 未提权时才需要 /shutdownProcessManager 无法 kill 管理员进程)
if self.is_elevated() && !is_thing_elevated() { if self.is_elevated() && !is_thing_elevated() {
eprintln!("[monitor] 退出清理:停止提权 Kernel/shutdown"); crate::logger::log_info("monitor", "退出清理:停止提权 Kernel/shutdown");
let _ = self.shutdown_kernel().await; let _ = self.shutdown_kernel().await;
self.elevated.store(false, Ordering::SeqCst); self.elevated.store(false, Ordering::SeqCst);
} }
@@ -852,13 +946,13 @@ pub async fn monitor_elevate_self(
if is_thing_elevated() { if is_thing_elevated() {
// 已是管理员,只需设置标志,无需重启 // 已是管理员,只需设置标志,无需重启
eprintln!("[monitor] Thing 已是管理员,仅设置永久提权标志"); crate::logger::log_info("monitor", "Thing 已是管理员,仅设置永久提权标志");
return Ok(()); return Ok(());
} }
let exe = std::env::current_exe().map_err(|e| format!("获取当前路径失败: {}", e))?; let exe = std::env::current_exe().map_err(|e| format!("获取当前路径失败: {}", e))?;
let exe_str = exe.to_string_lossy().to_string(); let exe_str = exe.to_string_lossy().to_string();
eprintln!("[monitor] 永久提权:以管理员权限重启 Thing"); crate::logger::log_info("monitor", "永久提权:以管理员权限重启 Thing");
shell_execute_elevated(&exe_str, "", None)?; shell_execute_elevated(&exe_str, "", None)?;
// 退出当前进程(非提权),新的提权进程会接管 // 退出当前进程(非提权),新的提权进程会接管
app.exit(0); app.exit(0);
@@ -917,6 +1011,25 @@ pub fn monitor_set_elevate_on_launch(
state.set_elevate_on_launch(enabled) state.set_elevate_on_launch(enabled)
} }
/// 查询"应用启动时自动启动监控内核"是否已启用
#[tauri::command]
pub fn monitor_get_auto_start(
state: tauri::State<'_, MonitorKernel>,
) -> bool {
state.load_settings().auto_start
}
/// 设置/清除"应用启动时自动启动监控内核"开关
#[tauri::command]
pub fn monitor_set_auto_start(
state: tauri::State<'_, MonitorKernel>,
enabled: bool,
) -> Result<(), String> {
let mut settings = state.load_settings();
settings.auto_start = enabled;
state.save_settings(&settings)
}
/// 查询硬件监控配置(透传 Kernel GET /config/hardware)。 /// 查询硬件监控配置(透传 Kernel GET /config/hardware)。
/// 返回当前配置 + 可用硬件/传感器类型清单,供前端 Dialog 渲染。 /// 返回当前配置 + 可用硬件/传感器类型清单,供前端 Dialog 渲染。
#[tauri::command] #[tauri::command]
+3 -3
View File
@@ -95,9 +95,9 @@ impl NetworkMonitor {
}; };
// emit 给前端(失败忽略:窗口可能未就绪) // 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 命令:获取网速监控是否运行
#[tauri::command] #[tauri::command]
pub fn network_monitor_status( pub fn network_status(
state: tauri::State<'_, NetworkMonitor>, state: tauri::State<'_, NetworkMonitor>,
) -> bool { ) -> bool {
state.running.load(Ordering::SeqCst) state.running.load(Ordering::SeqCst)
+51 -17
View File
@@ -6,8 +6,8 @@
//! - 任务栏覆盖检测:轮询 GetForegroundWindow,检测系统 UI 出现时暂时取消置顶 //! - 任务栏覆盖检测:轮询 GetForegroundWindow,检测系统 UI 出现时暂时取消置顶
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::thread; use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
@@ -15,6 +15,9 @@ use tauri::{AppHandle, Emitter};
static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new(); static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
/// 任务栏覆盖监视线程停止标志 /// 任务栏覆盖监视线程停止标志
static TOPMOST_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> { fn drag_stop() -> &'static Arc<AtomicBool> {
DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true))) 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))) 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)] #[cfg(windows)]
mod win_api { mod win_api {
use tauri::{AppHandle, Manager}; 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) let hwnd = win_api::get_hwnd(&label, &app)
.ok_or_else(|| format!("窗口 {} 不存在", label))?; .ok_or_else(|| format!("窗口 {} 不存在", label))?;
win_api::apply_no_activate(hwnd); win_api::apply_no_activate(hwnd);
eprintln!("[osd] 已应用 NoActivate 样式到窗口 {}", label); crate::logger::log_info("osd", &format!("已应用 NoActivate 样式到窗口 {}", label));
} }
Ok(()) Ok(())
} }
@@ -198,15 +225,14 @@ pub fn osd_apply_overlay_style(label: String, app: AppHandle) -> Result<(), Stri
/// 右键释放后发出 `osd-end-drag` 事件。 /// 右键释放后发出 `osd-end-drag` 事件。
#[tauri::command] #[tauri::command]
pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String> { pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String> {
// 停止旧线程,等待退出 // 停止旧线程,等待退出后再启动新线程(避免新旧线程并存)
drag_stop().store(true, Ordering::SeqCst); stop_drag_thread();
thread::sleep(Duration::from_millis(50));
let stop_flag = drag_stop().clone(); let stop_flag = drag_stop().clone();
stop_flag.store(false, Ordering::SeqCst); stop_flag.store(false, Ordering::SeqCst);
let app_handle = app.clone(); let app_handle = app.clone();
thread::spawn(move || { let handle = thread::spawn(move || {
let mut rbutton_was_down = false; let mut rbutton_was_down = false;
let mut press_start: Option<Instant> = None; let mut press_start: Option<Instant> = None;
let mut drag_emitted = false; 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)) (win_api::get_cursor_pos(), win_api::get_window_rect(hwnd))
{ {
if win_api::point_in_rect(pt, rect) { 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; 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 { } else if !rbutton_down && rbutton_was_down {
// 右键释放 // 右键释放
if drag_emitted { if drag_emitted {
let _ = app_handle.emit("osd-end-drag", ()); let _ = app_handle.emit(crate::constants::events::OSD_END_DRAG, ());
} }
press_start = None; press_start = None;
drag_emitted = false; 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(()) Ok(())
} }
@@ -268,14 +298,14 @@ pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String>
/// 系统 UI 关闭后发出 `osd-system-ui-inactive` 事件恢复置顶。 /// 系统 UI 关闭后发出 `osd-system-ui-inactive` 事件恢复置顶。
#[tauri::command] #[tauri::command]
pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> { 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(); let stop_flag = topmost_stop().clone();
stop_flag.store(false, Ordering::SeqCst); stop_flag.store(false, Ordering::SeqCst);
let app_handle = app.clone(); let app_handle = app.clone();
thread::spawn(move || { let handle = thread::spawn(move || {
let mut last_foreground: isize = 0; let mut last_foreground: isize = 0;
let mut system_ui_active = false; 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 win_api::is_system_ui_class(&class) {
if !system_ui_active { if !system_ui_active {
system_ui_active = true; 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 { } else if system_ui_active {
system_ui_active = false; 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 { } else if system_ui_active {
system_ui_active = false; 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(()) Ok(())
} }
/// 停止所有 OSD 监视线程 /// 停止所有 OSD 监视线程
#[tauri::command] #[tauri::command]
pub fn osd_stop_watch() { pub fn osd_stop_watch() {
drag_stop().store(true, Ordering::SeqCst); stop_drag_thread();
topmost_stop().store(true, Ordering::SeqCst); stop_topmost_thread();
} }
/// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠) /// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠)
+77 -65
View File
@@ -1,4 +1,5 @@
use serde::Serialize; use serde::Serialize;
use specta::Type;
use std::collections::HashMap; use std::collections::HashMap;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::Mutex; use std::sync::Mutex;
@@ -80,7 +81,7 @@ fn get_job_handle() -> Option<winapi::HANDLE> {
unsafe { unsafe {
let h = winapi::CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()); let h = winapi::CreateJobObjectW(std::ptr::null_mut(), std::ptr::null());
if h.is_null() { if h.is_null() {
eprintln!("[ProcessManager] CreateJobObjectW 失败,异常退出时子进程可能残留"); crate::logger::log_error("process", "CreateJobObjectW 失败,异常退出时子进程可能残留");
return 0; return 0;
} }
// 设置 KILL_ON_JOB_CLOSE:主进程退出时自动终止所有子进程 // 设置 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, std::mem::size_of::<winapi::JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
); );
if ok == 0 { if ok == 0 {
eprintln!("[ProcessManager] SetInformationJobObject 失败"); crate::logger::log_error("process", "SetInformationJobObject 失败");
return 0; return 0;
} }
h as usize h as usize
@@ -136,7 +137,7 @@ fn assign_to_job(child: &Child) {
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")] #[serde(rename_all = "lowercase")]
pub enum ProcessStatus { pub enum ProcessStatus {
Running, Running,
@@ -147,7 +148,8 @@ pub enum ProcessStatus {
} }
/// 进程信息(返回给前端) /// 进程信息(返回给前端)
#[derive(Serialize, Clone)] #[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct ProcessInfo { pub struct ProcessInfo {
pub id: String, pub id: String,
pub name: String, pub name: String,
@@ -291,7 +293,7 @@ impl ProcessManager {
Ok(Some(_)) => break, Ok(Some(_)) => break,
Ok(None) => { Ok(None) => {
if std::time::Instant::now() >= deadline { if std::time::Instant::now() >= deadline {
println!("[ProcessManager] 进程 {} 等待退出超时(3s),放弃等待", id); crate::logger::log_warn("process", &format!("进程 {} 等待退出超时(3s),放弃等待", id));
break; break;
} }
std::thread::sleep(Duration::from_millis(50)); std::thread::sleep(Duration::from_millis(50));
@@ -299,7 +301,7 @@ impl ProcessManager {
Err(_) => break, 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> { pub fn check_and_cleanup(&self) -> Vec<ProcessInfo> {
let mut changes = Vec::new(); 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() { let mut processes = match self.processes.lock() {
Ok(p) => p, Ok(p) => p,
Err(_) => return changes, Err(_) => return changes,
@@ -362,7 +370,7 @@ impl ProcessManager {
let ids: Vec<String> = processes.keys().cloned().collect(); let ids: Vec<String> = processes.keys().cloned().collect();
for id in ids { 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() { match entry.child.try_wait() {
Ok(None) => { Ok(None) => {
// 仍在运行,无需处理 // 仍在运行,无需处理
@@ -373,58 +381,10 @@ impl ProcessManager {
&& (entry.max_restarts == 0 && (entry.max_restarts == 0
|| entry.restart_count < entry.max_restarts) || entry.restart_count < entry.max_restarts)
{ {
// 自动重启 // 需要自动重启:从 map 移除,锁外执行
let restart_count = entry.restart_count + 1; if let Some(mut e) = processes.remove(&id) {
let executable = entry.executable.clone(); e.restart_count += 1;
let args = entry.args.clone(); restarts.push((id.clone(), e));
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,
});
}
} }
} else { } 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 changes
@@ -464,7 +476,7 @@ impl ProcessManager {
// ===== Tauri 命令 ===== // ===== Tauri 命令 =====
#[tauri::command] #[tauri::command]
pub fn start_process( pub fn process_start(
state: tauri::State<'_, ProcessManager>, state: tauri::State<'_, ProcessManager>,
params: StartProcessParams, params: StartProcessParams,
) -> Result<ProcessInfo, String> { ) -> Result<ProcessInfo, String> {
@@ -472,7 +484,7 @@ pub fn start_process(
} }
#[tauri::command] #[tauri::command]
pub fn stop_process( pub fn process_stop(
state: tauri::State<'_, ProcessManager>, state: tauri::State<'_, ProcessManager>,
id: String, id: String,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -480,7 +492,7 @@ pub fn stop_process(
} }
#[tauri::command] #[tauri::command]
pub fn get_process_status( pub fn process_status(
state: tauri::State<'_, ProcessManager>, state: tauri::State<'_, ProcessManager>,
id: String, id: String,
) -> Option<ProcessInfo> { ) -> Option<ProcessInfo> {
@@ -488,14 +500,14 @@ pub fn get_process_status(
} }
#[tauri::command] #[tauri::command]
pub fn get_all_process_status( pub fn process_all_status(
state: tauri::State<'_, ProcessManager>, state: tauri::State<'_, ProcessManager>,
) -> Vec<ProcessInfo> { ) -> Vec<ProcessInfo> {
state.get_all_status() state.get_all_status()
} }
#[tauri::command] #[tauri::command]
pub fn stop_all_processes(state: tauri::State<'_, ProcessManager>) { pub fn process_stop_all(state: tauri::State<'_, ProcessManager>) {
state.stop_all() state.stop_all()
} }
@@ -509,7 +521,7 @@ pub fn start_monitoring_thread(app: AppHandle) {
let changes = state.check_and_cleanup(); let changes = state.check_and_cleanup();
for change in changes { for change in changes {
let _ = app.emit("process-status-changed", &change); let _ = app.emit(crate::constants::events::PROCESS_STATUS_CHANGED, &change);
} }
} }
}); });
+344
View File
@@ -0,0 +1,344 @@
//! 快速面板:基于 7-Zipbinaries/7z.exe)的批量文件操作。
//!
//! - 批量解压:逐文件调用 7z 控制台,支持一次传入统一密码(`-p`),
//! 每完成一个文件通过事件推送进度,前端可实时展示。
//! - 批量重命名:`regex` 匹配文件名生成预览,确认后执行 `fs::rename`。
//! - 目录列表:供前端展示当前目录的压缩包 / 文件列表。
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{AppHandle, Emitter, Manager};
use crate::constants::events::QUICKPANEL_EXTRACT_PROGRESS;
/// 7z 可执行文件名(与 7z.dll 同目录,位于 resources/binaries)。
const _7Z_EXE: &str = "binaries/7z.exe";
/// 支持的压缩包扩展名(解压面板中列出)。
const ARCHIVE_EXTS: &[&str] = &[
".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", ".bz2", ".tbz", ".xz", ".txz", ".zst",
".tzst", ".cab", ".lzma",
];
/// 定位 7z 可执行文件(resources/binaries/7z.exe)。
fn locate_7z(app: &AppHandle) -> Option<PathBuf> {
let path = app
.path()
.resolve(_7Z_EXE, tauri::path::BaseDirectory::Resource)
.ok()?;
if path.exists() {
Some(path)
} else {
None
}
}
#[derive(Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ArchiveInfo {
pub name: String,
pub path: String,
pub size: u64,
}
#[derive(Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FileEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub size: u64,
}
#[derive(Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ExtractResult {
pub name: String,
pub path: String,
pub ok: bool,
pub error: String,
}
#[derive(Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ExtractProgress {
pub done: usize,
pub total: usize,
pub current: String,
pub ok: bool,
pub error: String,
}
#[derive(Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RenamePreview {
pub path: String,
pub old_name: String,
pub new_name: String,
pub error: String,
}
#[derive(Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RenameItem {
pub path: String,
pub old_name: String,
pub new_name: String,
}
#[derive(Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RenameResult {
pub old_name: String,
pub new_name: String,
pub ok: bool,
pub error: String,
}
fn is_archive(name: &str) -> bool {
let lower = name.to_lowercase();
ARCHIVE_EXTS.iter().any(|ext| lower.ends_with(ext))
}
/// 列出目录下的压缩包文件(供批量解压面板使用)。
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_list_archives(dir: String) -> Result<Vec<ArchiveInfo>, String> {
tauri::async_runtime::spawn_blocking(move || {
let d = Path::new(&dir);
if !d.is_dir() {
return Err(format!("目录不存在: {dir}"));
}
let mut out = Vec::new();
for entry in std::fs::read_dir(d).map_err(|e| format!("读取目录失败: {e}"))? {
let Ok(entry) = entry else { continue };
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if is_archive(&name) {
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
out.push(ArchiveInfo {
name,
path: path.to_string_lossy().to_string(),
size,
});
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
})
.await
.map_err(|e| format!("读取压缩包失败: {e}"))?
}
/// 列出目录下的全部条目(供批量重命名/删除面板使用,不含子目录递归)。
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_list_dir(dir: String) -> Result<Vec<FileEntry>, String> {
tauri::async_runtime::spawn_blocking(move || {
let d = Path::new(&dir);
if !d.is_dir() {
return Err(format!("目录不存在: {dir}"));
}
let mut out = Vec::new();
for entry in std::fs::read_dir(d).map_err(|e| format!("读取目录失败: {e}"))? {
let Ok(entry) = entry else { continue };
let path = entry.path();
let is_dir = path.is_dir();
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let size = if is_dir {
0
} else {
path.metadata().map(|m| m.len()).unwrap_or(0)
};
out.push(FileEntry {
name,
path: path.to_string_lossy().to_string(),
is_dir,
size,
});
}
out.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(out)
})
.await
.map_err(|e| format!("读取目录失败: {e}"))?
}
fn extract_one(
exe: &Path,
archive: &str,
dest: &str,
password: Option<&str>,
into_subfolder: bool,
) -> Result<(), String> {
let archive_path = Path::new(archive);
let mut cmd = Command::new(exe);
cmd.arg("x").arg(archive_path);
if into_subfolder {
// 解压到「压缩包同名子文件夹」,避免文件散落在当前目录。
let sub = archive_path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "extracted".into());
let sub_dir = Path::new(dest).join(&sub);
std::fs::create_dir_all(&sub_dir).map_err(|e| format!("创建目录 {sub} 失败: {e}"))?;
cmd.arg(format!("-o{}", sub_dir.display()));
} else {
cmd.arg(format!("-o{}", dest));
}
cmd.arg("-y"); // 全自动覆盖确认
if let Some(p) = password.filter(|p| !p.is_empty()) {
cmd.arg(format!("-p{p}"));
}
// 静默普通输出,仅错误进 stderr,逐文件粒度足够时无需 -bsp1 进度。
cmd.arg("-bso0").arg("-bse1").arg("-bsp0");
// 以 7z.exe 所在目录为工作目录,确保同目录的 7z.dll 可被加载。
if let Some(dir) = exe.parent() {
cmd.current_dir(dir);
}
let output = cmd.output().map_err(|e| format!("启动 7-Zip 失败: {e}"))?;
if output.status.success() {
Ok(())
} else {
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
let code = output.status.code().unwrap_or(-1);
Err(if msg.is_empty() {
format!("退出码 {code}")
} else {
msg
})
}
}
/// 批量解压。`files` 为压缩包路径列表,`dest_dir` 为目标目录,
/// `password` 为统一解压密码(可空),`into_subfolder` 是否解压到同名子文件夹。
/// 每完成一个文件通过 `quickpanel-extract-progress` 事件推送进度。
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_batch_extract(
app: AppHandle,
files: Vec<String>,
dest_dir: String,
password: Option<String>,
into_subfolder: bool,
) -> Result<Vec<ExtractResult>, String> {
let exe = locate_7z(&app).ok_or("未找到 7-Zip 组件(binaries/7z.exe),请重新安装")?;
let total = files.len();
let results = tauri::async_runtime::spawn_blocking(move || {
let mut results = Vec::with_capacity(total);
for (idx, file) in files.iter().enumerate() {
let err = extract_one(&exe, file, &dest_dir, password.as_deref(), into_subfolder);
let name = Path::new(file)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| file.clone());
let (ok, error) = match err {
Ok(()) => (true, String::new()),
Err(e) => (false, e),
};
results.push(ExtractResult {
name,
path: file.clone(),
ok,
error: error.clone(),
});
let _ = app.emit(
QUICKPANEL_EXTRACT_PROGRESS,
ExtractProgress {
done: idx + 1,
total,
current: results[idx].name.clone(),
ok,
error,
},
);
}
results
})
.await
.map_err(|e| format!("解压任务异常终止: {e}"))?;
Ok(results)
}
/// 正则批量重命名预览:对每个文件名应用 `pattern → replacement`
/// 仅返回有匹配的文件,`newName` 为替换结果。
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_preview_rename(
files: Vec<String>,
pattern: String,
replacement: String,
) -> Result<Vec<RenamePreview>, String> {
let re = regex::Regex::new(&pattern).map_err(|e| format!("正则表达式无效: {e}"))?;
let mut out = Vec::new();
for file in files {
let name = Path::new(&file)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if !re.is_match(&name) {
continue;
}
let new_name = re.replace_all(&name, replacement.as_str()).to_string();
let error = if new_name.is_empty() || new_name == name {
"名称未变化".to_string()
} else {
String::new()
};
out.push(RenamePreview {
path: file,
old_name: name,
new_name,
error,
});
}
Ok(out)
}
/// 执行重命名。同一目录下若目标已存在则跳过该项。
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_apply_rename(items: Vec<RenameItem>) -> Result<Vec<RenameResult>, String> {
let mut out = Vec::new();
for item in items {
let result = (|| -> Result<(), String> {
if item.new_name.is_empty() {
return Err("新文件名为空".into());
}
if item.new_name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
return Err("文件名包含非法字符".into());
}
let old = Path::new(&item.path);
let parent = old.parent().unwrap_or(Path::new("."));
let new_path = parent.join(&item.new_name);
if new_path.exists() {
return Err("目标已存在".into());
}
std::fs::rename(old, &new_path).map_err(|e| format!("重命名失败: {e}"))?;
Ok(())
})();
out.push(RenameResult {
old_name: item.old_name,
new_name: item.new_name,
ok: result.is_ok(),
error: result.err().unwrap_or_default(),
});
}
Ok(out)
}
+80
View File
@@ -0,0 +1,80 @@
//! 应用扫描:Windows 开始菜单 .lnk + PATH 中的可执行文件。
//!
//! 简化实现:扫描开始菜单目录(系统 + 用户)下的 .lnk 快捷方式,
//! 名称取文件名(去 .lnk 后缀)。PATH 可执行文件扫描可选(避免噪音过多)。
//! 结果不持久化,每次唤起时按需刷新(数据量小,几十毫秒内完成)。
use std::path::PathBuf;
use serde::Serialize;
use walkdir::WalkDir;
use specta::Type;
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AppRecord {
pub name: String,
pub path: String,
}
/// 扫描开始菜单(系统 + 用户)。返回去重后的应用列表。
pub fn scan_apps() -> Vec<AppRecord> {
let mut apps = Vec::new();
let mut seen = std::collections::HashSet::new();
// 开始菜单目录
let mut dirs = Vec::new();
// 系统开始菜单:C:\ProgramData\Microsoft\Windows\Start Menu\Programs
if let Ok(prog_data) = std::env::var("ProgramData") {
dirs.push(
PathBuf::from(prog_data)
.join("Microsoft")
.join("Windows")
.join("Start Menu")
.join("Programs"),
);
}
// 用户开始菜单:%APPDATA%\Microsoft\Windows\Start Menu\Programs
if let Ok(appdata) = std::env::var("APPDATA") {
dirs.push(
PathBuf::from(appdata)
.join("Microsoft")
.join("Windows")
.join("Start Menu")
.join("Programs"),
);
}
for dir in dirs {
if !dir.exists() {
continue;
}
for entry in WalkDir::new(&dir)
.max_depth(5)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let p = entry.path();
if !p.is_file() {
continue;
}
let ext = p.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();
if ext != "lnk" {
continue;
}
let Some(name_os) = p.file_stem() else { continue };
let name = name_os.to_string_lossy().to_string();
let path_str = p.to_string_lossy().to_string();
// 去重:同名应用保留第一个
if seen.insert(name.to_lowercase()) {
apps.push(AppRecord { name, path: path_str });
}
}
}
// 按名称排序
apps.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
apps
}
+443
View File
@@ -0,0 +1,443 @@
//! Tauri 命令:快速面板模块
use tauri::{AppHandle, Manager};
use super::popup::{self, QuickPanelSettings};
use super::{file_index, app_scanner, icon_extractor};
/// 批量删除单个条目的结果。
#[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DeleteResult {
pub name: String,
pub path: String,
pub ok: bool,
pub error: String,
}
/// 读取快速面板设置(快捷键等)
#[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 {
crate::shortcut::register_shortcut(&app, "快速面板", &settings.shortcut, |a| {
popup::show_popup(a)
})?;
// 新快捷键非空时确保弹窗窗口已预创建
if !settings.shortcut.trim().is_empty() {
popup::ensure_window(&app);
}
}
Ok(())
}
/// 注册(或切换)快速面板全局快捷键
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_register_shortcut(
shortcut: String,
app: AppHandle,
) -> Result<(), String> {
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> {
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(())
}
/// 隐藏快速面板
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_hide_popup(app: AppHandle) -> Result<(), String> {
popup::hide_popup(&app);
Ok(())
}
/// 显示已创建的弹窗窗口(前端 onMounted 后调用)
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_show_window(app: AppHandle) -> Result<(), String> {
popup::show_window(&app);
Ok(())
}
/// 显示主窗口并强制置为前台。
/// Tauri 的 set_focus 在 Windows 上受前台锁定限制,主窗口被其他应用遮挡时无法到前台;
/// 改用原生 SetForegroundWindow + BringWindowToTop(模拟 Alt 键重置前台锁定)。
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_focus_main_window(app: AppHandle) -> Result<(), String> {
use crate::constants::windows::MAIN;
if let Some(window) = app.get_webview_window(MAIN) {
window.show().map_err(|e| e.to_string())?;
window.unminimize().map_err(|e| e.to_string())?;
match window.hwnd() {
Ok(hwnd) => crate::win32_util::force_foreground(hwnd.0 as isize),
Err(_) => {
window.set_focus().ok();
}
}
}
Ok(())
}
/// 锁定屏幕(Windows: rundll32 user32.dll,LockWorkStationCREATE_NO_WINDOW 避免黑窗)
#[tauri::command]
#[specta::specta]
pub fn quickpanel_lock_screen() -> Result<(), String> {
#[cfg(windows)]
{
use crate::process_manager::setup_creation_flags;
let mut cmd = std::process::Command::new("rundll32.exe");
cmd.arg("user32.dll,LockWorkStation");
setup_creation_flags(&mut cmd);
cmd.spawn().map_err(|e| format!("锁屏失败: {}", e))?;
}
#[cfg(not(windows))]
{
return Err("当前平台不支持锁屏".into());
}
Ok(())
}
/// 初始化文件索引数据库(应用启动时调用)。
/// 若存在上次构建的索引(last_built_dirs 非空),自动恢复 notify 增量监听,
/// 无需重建即可继续自动同步文件变更。
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_init_file_index(app: AppHandle) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
file_index::init(&app);
let stats = file_index::stats();
if stats.last_built_at > 0 && !stats.last_built_dirs.is_empty() {
file_index::start_watcher(&stats.last_built_dirs);
}
})
.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() {
popup::QuickPanelSettings::default().index_dirs
} else {
settings.index_dirs
};
// 懒加载:首次构建时自动初始化 DB 连接
file_index::ensure_initialized(&app);
// 阻塞操作放到 spawn_blocking
tauri::async_runtime::spawn_blocking(move || file_index::build_index(&dirs))
.await
.map_err(|e| format!("索引任务失败: {}", e))?
}
/// 搜索文件索引(SQLite 查询移出主线程)
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_search_files(
query: String,
limit: Option<i64>,
app: AppHandle,
) -> Result<Vec<file_index::FileRecord>, String> {
// 懒加载:首次搜索时自动初始化 DB 连接
file_index::ensure_initialized(&app);
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 async fn quickpanel_file_index_stats(app: AppHandle) -> file_index::IndexStats {
// 懒加载:查询状态前确保 DB 已初始化(未初始化时 stats 返回全 0)
file_index::ensure_initialized(&app);
file_index::stats()
}
/// 扫描已安装应用(遍历开始菜单/桌面/磁盘,移出主线程)
#[tauri::command]
#[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]
#[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(())
}
/// 在资源管理器中显示文件(选中)
#[tauri::command]
#[specta::specta]
pub fn quickpanel_reveal_in_explorer(path: String) -> Result<(), String> {
#[cfg(windows)]
{
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::HWND;
use windows_sys::Win32::UI::Shell::ShellExecuteW;
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
// 规范化路径分隔符
let normalized = path.replace('/', "\\");
// explorer.exe /select,"path" — 用 ShellExecuteW 直接传参,
// 避免 std::process::Command 的 arg 转义破坏 /select 语法。
// 对 .lnk 文件也能正确选中(explorer 直接选中 .lnk 文件本身)。
let params = format!("/select,\"{}\"", normalized);
let wide_exe: Vec<u16> = OsStr::new("explorer.exe")
.encode_wide()
.chain(std::iter::once(0))
.collect();
let wide_params: Vec<u16> = OsStr::new(&params)
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let hinst = ShellExecuteW(
0 as HWND,
std::ptr::null(),
wide_exe.as_ptr(),
wide_params.as_ptr(),
std::ptr::null(),
SW_SHOWNORMAL,
);
// ShellExecuteW 返回值 <= 32 表示错误
if hinst <= 32 {
return Err(format!("打开资源管理器失败 (code: {})", hinst as i32));
}
}
}
#[cfg(not(windows))]
{
let _ = path;
return Err("当前平台不支持".into());
}
Ok(())
}
/// 用系统默认程序打开文件/文件夹。
/// - 目录: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,
args: Vec<String>,
) -> Result<(), String> {
match kind.as_str() {
"shell" => super::special_locations::open_shell(&target),
"cmd" => super::special_locations::open_cmd(&target, &args),
_ => super::special_locations::open_path(&target),
}
}
/// 删除文件(移到回收站,PowerShell 阻塞等待移出主线程)
#[tauri::command]
#[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 is_dir = p.is_dir();
// 用 PowerShell + Microsoft.VisualBasic 移到回收站
let script = if is_dir {
format!(
"Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory('{}','OnlyErrorDialogs','SendToRecycleBin')",
path.replace('\'', "''")
)
} else {
format!(
"Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile('{}','OnlyErrorDialogs','SendToRecycleBin')",
path.replace('\'', "''")
)
};
let mut cmd = std::process::Command::new("powershell.exe");
cmd.args(["-NoProfile", "-NonInteractive", "-Command", &script]);
setup_creation_flags(&mut cmd);
let output = cmd.output().map_err(|e| format!("删除失败: {}", e))?;
if !output.status.success() {
return Err(format!(
"删除失败: {}",
String::from_utf8_lossy(&output.stderr)
));
}
}
#[cfg(not(windows))]
{
let _ = path;
return Err("当前平台不支持".into());
}
Ok(())
}
/// 批量删除文件/目录。`force=false` 时移动至回收站;`force=true` 时先递归清除
/// 只读属性再永久删除(可绕过只读/部分占用导致的删除失败,但被其他进程真正
/// 锁定的文件仍会失败并返回原因)。
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_delete_files(
paths: Vec<String>,
force: bool,
) -> Result<Vec<DeleteResult>, String> {
tauri::async_runtime::spawn_blocking(move || {
let mut out = Vec::new();
for path in paths {
let name = std::path::Path::new(&path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.clone());
let result = if force {
delete_force_impl(&path)
} else {
delete_file_impl(&path)
};
out.push(DeleteResult {
name,
path: path.clone(),
ok: result.is_ok(),
error: result.err().unwrap_or_default(),
});
}
Ok(out)
})
.await
.map_err(|e| format!("删除任务失败: {e}"))?
}
/// 强行删除:递归清除只读属性后永久删除(不经过回收站)。
fn delete_force_impl(path: &str) -> Result<(), String> {
let p = std::path::Path::new(path);
clear_readonly(p);
if p.is_dir() {
std::fs::remove_dir_all(p).map_err(|e| format!("删除失败: {e}"))
} else {
std::fs::remove_file(p).map_err(|e| format!("删除失败: {e}"))
}
}
/// 递归清除只读属性(只读文件/目录无法直接删除)。
fn clear_readonly(path: &std::path::Path) {
if let Ok(meta) = std::fs::metadata(path) {
if meta.permissions().readonly() {
let mut perms = meta.permissions();
perms.set_readonly(false);
let _ = std::fs::set_permissions(path, perms);
}
if meta.is_dir() {
if let Ok(rd) = std::fs::read_dir(path) {
for entry in rd.flatten() {
clear_readonly(&entry.path());
}
}
}
}
}
/// 运行自定义命令(执行可执行文件 + 参数)
/// .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
.to_lowercase()
.ends_with(".lnk");
let mut cmd = if is_lnk {
// cmd /C start "" "path.lnk" arg1 arg2
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", "", &command]);
c.args(&args);
c
} else {
let mut c = std::process::Command::new(&command);
c.args(&args);
c
};
setup_creation_flags(&mut cmd);
cmd.spawn().map_err(|e| format!("运行命令失败: {}", e))?;
Ok(())
}
/// 运行系统命令(不设置 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);
cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?;
Ok(())
}
+139
View File
@@ -0,0 +1,139 @@
//! 快速面板:检测前台 Explorer 窗口的当前目录。
//!
//! 必须在快捷键回调(`show_popup`)内调用:此时前台窗口仍是资源管理器,
//! 面板尚未取得焦点,`GetForegroundWindow` 拿到的才是 Explorer 主窗口;
//! 若等面板显示后再调用,前台就变成面板自身了。
//!
//! 思路(Listary / PowerToys Run 同款):前台窗口 HWND 匹配 `IShellWindows`
//! 中某个 Shell 窗口 → 取其 `LocationURL`file:///...)→ 转成本地路径。
//!
//! Win11 多选项卡:同一顶层窗口下每个选项卡都是独立的 `IShellWindows` 条目,
//! 共享顶层 HWND。活动选项卡的内容窗口(`ShellTabWindowClass`)在子窗口
//! z-order 顶层,用 `IID_IShellBrowser` 作为 `QueryService` 的 service ID 获取
//! 每个选项卡自己的 `IShellBrowser`(而非 `SID_STopLevelBrowser` 返回的顶层
//! browser),再通过 `GetWindow()` 拿到该选项卡的内容窗口句柄,与活动选项卡
//! 的内容窗口比对,从而定位当前正在浏览的选项卡(`IsWindowVisible` 对所有
//! 选项卡都成立,不可用)。
use std::path::Path;
#[cfg(windows)]
use windows::core::ComInterface;
#[cfg(windows)]
use windows::Win32::Foundation::HWND;
#[cfg(windows)]
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED,
IServiceProvider,
};
#[cfg(windows)]
use windows::Win32::System::Variant::{VARIANT, VT_I4};
#[cfg(windows)]
use windows::Win32::UI::Shell::{
IWebBrowserApp, IShellBrowser, IShellWindows, ShellWindows,
};
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::{
GetClassNameW, GetForegroundWindow, GetWindow, GW_CHILD, GW_HWNDNEXT,
};
/// 检测前台 Explorer 窗口的当前目录。
/// 返回 `None`:前台不是 Explorer / COM 初始化失败 / URL 无法转路径。
#[cfg(windows)]
pub fn detect_explorer_folder() -> Option<String> {
// 首次 COM 初始化失败(例如已在 MTA 线程)时,后续 COM 调用一般仍可用,忽略错误继续。
let _ = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
let fg = unsafe { GetForegroundWindow() };
let result = if fg.0 == 0 {
None
} else {
unsafe { find_folder_for_hwnd(fg) }
};
unsafe { CoUninitialize() };
result
}
#[cfg(windows)]
unsafe fn find_folder_for_hwnd(fg: HWND) -> Option<String> {
let shell: IShellWindows = CoCreateInstance(&ShellWindows, None, CLSCTX_ALL).ok()?;
let count = shell.Count().ok()?;
// Win11 多选项卡:活动选项卡的内容窗口(ShellTabWindowClass)在子窗口 z-order
// 顶层。在 IShellWindows 条目中,用 IShellBrowser::GetWindow() 取到的内容窗口
// HWND 与它比对,即可定位当前正在浏览的选项卡(IsWindowVisible 对所有选项卡
// 都成立,不可用)。
let active_tab = find_active_shell_tab(fg);
for i in 0..count {
// 索引过期的窗口会返回失败,跳过继续即可,不能 `?` 提前结束整个循环。
// 0.52 的 Win32 VARIANT 无 From<i32>,手动构造 VT_I4 变体。
let mut index = VARIANT::default();
{
let value = &mut *index.Anonymous.Anonymous;
value.vt = VT_I4;
value.Anonymous.lVal = i;
}
let Ok(dispatch) = shell.Item(index) else { continue };
let Ok(app) = dispatch.cast::<IWebBrowserApp>() else { continue };
// 只考虑前台顶层窗口对应的条目;同一窗口的多个选项卡条目共享顶层句柄。
let Ok(hwnd) = app.HWND() else { continue };
if HWND(hwnd.0) != fg {
continue;
}
// 有选项卡时,必须匹配活动选项卡的内容窗口;否则退化为任意条目(旧版无选项卡)。
if let Some(active) = active_tab {
let Ok(svc) = app.cast::<IServiceProvider>() else { continue };
let Ok(browser) = svc.QueryService::<IShellBrowser>(&IShellBrowser::IID) else {
continue;
};
let Ok(this_tab) = browser.GetWindow() else { continue };
if this_tab != active {
continue;
}
}
if let Ok(url) = app.LocationURL() {
return url_to_path(&url.to_string());
}
}
None
}
/// 枚举前台窗口的子窗口(z-order 自上而下),返回第一个类名为 `ShellTabWindowClass`
/// 的窗口句柄,即 Win11 资源管理器活动选项卡的内容窗口;无选项卡时返回 `None`。
#[cfg(windows)]
unsafe fn find_active_shell_tab(fg: HWND) -> Option<HWND> {
const CLASS: &str = "ShellTabWindowClass";
let mut hwnd = GetWindow(fg, GW_CHILD);
while hwnd.0 != 0 {
let mut buf = [0u16; 64];
let len = GetClassNameW(hwnd, &mut buf);
if len > 0 {
let name = String::from_utf16_lossy(&buf[..len as usize]);
if name == CLASS {
return Some(hwnd);
}
}
hwnd = GetWindow(hwnd, GW_HWNDNEXT);
}
None
}
/// 把 `file:///C:/xxx`(可能带百分号编码)转成本地路径,仅接受目录。
#[cfg(windows)]
fn url_to_path(url: &str) -> Option<String> {
if !url.starts_with("file:") {
return None;
}
let parsed = url::Url::parse(url).ok()?;
let path = parsed.to_file_path().ok()?;
let path = Path::new(&path);
if path.is_dir() {
Some(path.to_string_lossy().to_string())
} else {
None
}
}
/// 非 Windows 平台占位:保持模块可编译。
#[cfg(not(windows))]
pub fn detect_explorer_folder() -> Option<String> {
None
}
+401
View File
@@ -0,0 +1,401 @@
//! 文件索引:walkdir 遍历 + rusqlite 存储/搜索。
//!
//! schema:
//! files(path TEXT PK, name TEXT, ext TEXT, size INT, mtime INT, is_dir INT)
//! 索引:name LIKE 搜索(name_lower 已预存为小写,避免 lower() 全表扫描)。
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::UNIX_EPOCH;
use rusqlite::{params, Connection};
use serde::Serialize;
use tauri::{AppHandle, Manager};
use walkdir::WalkDir;
use specta::Type;
/// 单个文件记录(返回给前端)
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileRecord {
pub path: String,
pub name: String,
pub ext: String,
pub size: i64,
pub is_dir: bool,
}
/// 索引状态(返回给前端)
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct IndexStats {
pub total: i64,
pub last_built_at: i64,
pub last_built_dirs: Vec<String>,
}
struct Inner {
conn: Mutex<Connection>,
}
static INDEX: std::sync::OnceLock<Mutex<Option<Inner>>> = std::sync::OnceLock::new();
fn index_slot() -> &'static Mutex<Option<Inner>> {
INDEX.get_or_init(|| Mutex::new(None))
}
/// 数据库路径:{app_data_dir}/quickpanel/files.db
fn db_path(app: &AppHandle) -> PathBuf {
app.path()
.app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("quickpanel")
.join("files.db")
}
/// 初始化数据库连接(创建表 + 索引)。若已初始化则跳过。
pub fn init(app: &AppHandle) {
let path = db_path(app);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
let conn = match Connection::open(&path) {
Ok(c) => c,
Err(e) => {
crate::logger::log_error("quickpanel", &format!("文件索引 DB 初始化失败: {}", e));
return;
}
};
conn.busy_timeout(std::time::Duration::from_secs(3)).ok();
let _ = conn.execute_batch(
"CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
name TEXT NOT NULL,
name_lower TEXT NOT NULL,
ext TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
mtime INTEGER NOT NULL DEFAULT 0,
is_dir INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_name_lower ON files(name_lower);
CREATE INDEX IF NOT EXISTS idx_ext ON files(ext);
CREATE TABLE IF NOT EXISTS files_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
);
let mut guard = index_slot().lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(Inner {
conn: Mutex::new(conn),
});
crate::logger::log_info("quickpanel", &format!("文件索引 DB 已就绪: {}", path.display()));
}
/// 懒加载:首次访问文件索引时自动初始化(若尚未初始化)。
/// 避免应用启动时即打开 SQLite 连接,降低启动 IO 开销。
pub fn ensure_initialized(app: &AppHandle) {
let guard = index_slot().lock().unwrap_or_else(|e| e.into_inner());
if guard.is_some() {
return;
}
drop(guard);
init(app);
}
/// 判断索引是否已初始化
fn with_conn<F, R>(f: F) -> Option<R>
where
F: FnOnce(&Connection) -> R,
{
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));
}
}
None
}
/// 遍历指定目录列表建立索引(全量重建)。
/// 返回索引条目数。在 spawn_blocking 中调用。
/// 重建完成后自动启动 notify 监听器做增量更新。
pub fn build_index(dirs: &[String]) -> Result<i64, String> {
// 清空旧数据
let cleared = with_conn(|conn| {
conn.execute("DELETE FROM files", []).ok()
}).unwrap_or(None);
if cleared.is_none() {
return Err("文件索引未初始化".into());
}
let mut count = 0i64;
for dir in dirs {
count += walk_and_index(dir);
}
// 记录构建元信息
let now = now_secs();
let dirs_json = serde_json::to_string(dirs).unwrap_or_default();
let _ = with_conn(|conn| {
conn.execute(
"INSERT OR REPLACE INTO files_meta (key, value) VALUES ('last_built_at', ?1)",
params![now.to_string()],
).ok();
conn.execute(
"INSERT OR REPLACE INTO files_meta (key, value) VALUES ('last_built_dirs', ?1)",
params![dirs_json],
).ok()
});
// 启动/刷新 notify 监听器
start_watcher(dirs);
crate::logger::log_info("quickpanel", &format!("文件索引完成,共 {}", count));
Ok(count)
}
/// 遍历单个目录并写入索引,返回新增条目数
fn walk_and_index(dir: &str) -> i64 {
let path = Path::new(dir);
if !path.exists() {
return 0;
}
let mut count = 0i64;
for entry in WalkDir::new(path)
.max_depth(10)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let p = entry.path();
if upsert_path(p) {
count += 1;
}
}
count
}
/// 将单个路径写入索引(创建/修改)。返回 true 表示已写入。
/// 跳过隐藏文件(. 开头)、不存在的路径。
fn upsert_path(p: &Path) -> bool {
let Some(name_os) = p.file_name() else { return false };
let name = name_os.to_string_lossy().to_string();
if name.starts_with('.') {
return false;
}
let meta = match std::fs::metadata(p) {
Ok(m) => m,
Err(_) => return false,
};
let name_lower = name.to_lowercase();
let ext = p.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let size = meta.len() as i64;
let mtime = meta.modified().ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let is_dir = meta.is_dir() as i32;
let path_str = p.to_string_lossy().to_string();
let _ = with_conn(|conn| {
conn.execute(
"INSERT OR REPLACE INTO files (path, name, name_lower, ext, size, mtime, is_dir)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![path_str, name, name_lower, ext, size, mtime, is_dir],
)
});
true
}
/// 从索引中删除指定路径
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 OR path LIKE ?3",
params![path_str, backslash_prefix, slash_prefix],
)
});
}
/// 搜索文件:name_lower LIKE %query%,按名字长度升序(短名优先)。
pub fn search(query: &str, limit: i64) -> Vec<FileRecord> {
let like = format!("%{}%", query.to_lowercase());
with_conn(|conn| {
let mut stmt = match conn.prepare(
"SELECT path, name, ext, size, is_dir FROM files
WHERE name_lower LIKE ?1
ORDER BY LENGTH(name) ASC, name ASC LIMIT ?2",
) {
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map(params![like, limit], |r| {
Ok(FileRecord {
path: r.get(0)?,
name: r.get(1)?,
ext: r.get(2)?,
size: r.get(3)?,
is_dir: r.get::<_, i32>(4)? != 0,
})
})
.map(|r| r.filter_map(|i| i.ok()).collect())
.unwrap_or_default()
})
.unwrap_or_default()
}
/// 索引状态
pub fn stats() -> IndexStats {
let total = with_conn(|conn| {
conn.query_row("SELECT COUNT(*) FROM files", [], |r| r.get::<_, i64>(0))
.unwrap_or(0)
})
.unwrap_or(0);
let last_built_at = with_conn(|conn| {
// query_row 返回 Result<String, Error>,统一处理失败
let res: rusqlite::Result<String> = conn.query_row(
"SELECT value FROM files_meta WHERE key = 'last_built_at'",
[],
|r| r.get::<_, String>(0),
);
res.ok().and_then(|s| s.parse().ok()).unwrap_or(0)
})
.unwrap_or(0);
let last_built_dirs = with_conn(|conn| {
conn.query_row(
"SELECT value FROM files_meta WHERE key = 'last_built_dirs'",
[],
|r| r.get::<_, String>(0),
)
.ok()
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default()
})
.unwrap_or_default();
IndexStats {
total,
last_built_at,
last_built_dirs,
}
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
// ===== notify 增量监听 =====
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
static WATCHER: std::sync::OnceLock<Mutex<Option<RecommendedWatcher>>> = std::sync::OnceLock::new();
fn watcher_slot() -> &'static Mutex<Option<RecommendedWatcher>> {
WATCHER.get_or_init(|| Mutex::new(None))
}
/// 启动/刷新 notify 监听器。重建索引或修改索引目录后调用。
/// 会先停止旧监听器,再为新目录列表添加递归监听。
pub fn start_watcher(dirs: &[String]) {
// 创建新 watchernotify v6: recommended_watcher 只接受回调,Config 默认)
let mut watcher = match notify::recommended_watcher(
move |res: notify::Result<notify::Event>| {
if let Ok(event) = res {
handle_fs_event(&event);
}
},
) {
Ok(w) => w,
Err(e) => {
crate::logger::log_error("quickpanel", &format!("notify watcher 创建失败: {}", e));
return;
}
};
// 为每个目录添加递归监听
for dir in dirs {
let path = Path::new(dir);
if !path.exists() {
continue;
}
if let Err(e) = watcher.watch(path, RecursiveMode::Recursive) {
crate::logger::log_error("quickpanel", &format!("watch {} 失败: {}", dir, e));
}
}
// 替换旧 watcherdrop 时自动 unwatch
let mut guard = watcher_slot().lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(watcher);
crate::logger::log_info("quickpanel", &format!("notify 监听已启动,监听 {} 个目录", dirs.len()));
}
/// 处理文件系统事件:
/// - 创建/数据或元数据修改 → upsert
/// - 重命名:旧路径(From) → remove,新路径(To) → upsert
/// - 删除 → remove
fn handle_fs_event(event: &notify::Event) {
use notify::event::{ModifyKind, RenameMode};
match event.kind {
// 创建、数据/元数据修改、类型未知 → upsert
EventKind::Create(_)
| EventKind::Modify(ModifyKind::Data(_))
| EventKind::Modify(ModifyKind::Metadata(_))
| EventKind::Modify(ModifyKind::Other)
| EventKind::Modify(ModifyKind::Any) => {
for path in &event.paths {
if path.exists() {
upsert_path(path);
}
}
}
// 重命名旧路径 → 删除旧记录(含目录子项,避免索引残留失效路径)
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
for path in &event.paths {
remove_path(path);
}
}
// 重命名新路径 → 写入新记录
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
for path in &event.paths {
if path.exists() {
upsert_path(path);
}
}
}
// 重命名模式未知:存在则写入,不存在则删除(幂等兜底)
EventKind::Modify(ModifyKind::Name(RenameMode::Any | RenameMode::Both)) => {
for path in &event.paths {
if path.exists() {
upsert_path(path);
} else {
remove_path(path);
}
}
}
// 删除 → remove(含子项)
EventKind::Remove(_) => {
for path in &event.paths {
remove_path(path);
}
}
_ => {
// 忽略访问/其他事件
}
}
}
+351
View File
@@ -0,0 +1,351 @@
//! 应用图标提取:Windows SHGetFileInfo → HICON → RGBA → PNG,带磁盘 + 内存缓存。
//!
//! 流程:
//! 1. 内存缓存命中 → 直接返回 data URL
//! 2. 磁盘缓存命中({app_data_dir}/quickpanel/icons/{hash}.png → 读取并缓存
//! 3. 调用 SHGetFileInfoW 提取 HICON → GetDIBits 取 32bit BGRA → 转 RGBA → PNG
//! 4. 写入磁盘缓存 + 内存缓存,返回 data URL
//!
//! 设计取舍:
//! - 返回 base64 data URL 而非文件路径,避免独立弹窗窗口的 asset 协议配置问题
//! - 磁盘缓存避免重复 Windows API 调用(昂贵),内存缓存避免重复磁盘读取 + 编码
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::Mutex;
use base64::Engine as _;
use tauri::{AppHandle, Manager};
// ===== 内存缓存 =====
/// (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()?.0.get(path).cloned()
}
fn mem_put(path: String, url: String) {
if let Ok(mut guard) = MEM_CACHE.lock() {
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);
}
}
// ===== 磁盘缓存路径 =====
fn cache_dir(app: &AppHandle) -> PathBuf {
app.path()
.app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("quickpanel")
.join("icons")
}
fn path_hash(path: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
// 统一小写 + 正斜杠,避免大小写/分隔符差异导致缓存未命中
let normalized = path.to_lowercase().replace('\\', "/");
let mut hasher = DefaultHasher::new();
normalized.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
// ===== 公共 API =====
/// 获取应用图标 data URL。命中缓存则零开销;未命中则提取 + 编码 + 落盘。
/// 返回 Ok(None) 表示提取失败或不支持的平台。
pub fn get_icon_data_url(app: &AppHandle, path: &str) -> Option<String> {
if path.is_empty() {
return None;
}
// 规范化路径分隔符:混合 / 和 \ 会导致 SHGetFileInfoW 失败
let normalized = path.replace('/', "\\");
let path = normalized.as_str();
// 1. 内存缓存
if let Some(url) = mem_get(path) {
return Some(url);
}
let dir = cache_dir(app);
let hash = path_hash(path);
let cache_path = dir.join(format!("{}.png", hash));
// 2. 磁盘缓存
if cache_path.exists() {
if let Ok(bytes) = std::fs::read(&cache_path) {
let url = png_to_data_url(&bytes);
mem_put(path.to_string(), url.clone());
return Some(url);
}
}
// 3. 提取
let png = extract_icon_png(path)?;
// 4. 落盘(失败不影响返回)
std::fs::create_dir_all(&dir).ok();
std::fs::write(&cache_path, &png).ok();
// 5. 缓存 + 返回
let url = png_to_data_url(&png);
mem_put(path.to_string(), url.clone());
Some(url)
}
/// 清理整个图标磁盘缓存(设置页可调用)
pub fn clear_cache(app: &AppHandle) {
let dir = cache_dir(app);
if dir.exists() {
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).ok();
}
if let Ok(mut guard) = MEM_CACHE.lock() {
*guard = None;
}
}
fn png_to_data_url(png: &[u8]) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(png);
format!("data:image/png;base64,{}", b64)
}
// ===== Windows 图标提取 =====
#[cfg(windows)]
fn extract_icon_png(path: &str) -> Option<Vec<u8>> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::{
SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON,
};
use windows_sys::Win32::UI::WindowsAndMessaging::DestroyIcon;
unsafe {
let wide: Vec<u16> = OsStr::new(path)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut shfi: SHFILEINFOW = std::mem::zeroed();
let _ = SHGetFileInfoW(
wide.as_ptr(),
0,
&mut shfi,
std::mem::size_of::<SHFILEINFOW>() as u32,
SHGFI_ICON | SHGFI_LARGEICON,
);
// hIcon 为 0 表示无图标
if shfi.hIcon == 0 {
return None;
}
let result = hicon_to_png(shfi.hIcon);
let _ = DestroyIcon(shfi.hIcon);
result
}
}
#[cfg(not(windows))]
fn extract_icon_png(_path: &str) -> Option<Vec<u8>> {
None
}
/// HICON → PNG bytes
#[cfg(windows)]
fn hicon_to_png(hicon: windows_sys::Win32::UI::WindowsAndMessaging::HICON) -> Option<Vec<u8>> {
use windows_sys::Win32::Graphics::Gdi::DeleteObject;
use windows_sys::Win32::UI::WindowsAndMessaging::{GetIconInfo, ICONINFO};
unsafe {
let mut icon_info: ICONINFO = std::mem::zeroed();
if GetIconInfo(hicon, &mut icon_info) == 0 {
return None;
}
let has_color = icon_info.hbmColor != 0;
let has_mask = icon_info.hbmMask != 0;
let result: Option<(Vec<u8>, u32, u32)> = if has_color {
// 32-bit BGRA → RGBA
let (mut rgba, w, h) = bitmap_to_rgba32(icon_info.hbmColor)?;
// 检查 alpha 是否全 0(旧式无 alpha 通道图标)
let alpha_any = rgba.chunks_exact(4).any(|c| c[3] != 0);
if !alpha_any {
if has_mask {
// 用 mask 补 alpha(白=透明,黑=不透明)
let _ = apply_mask_alpha(&mut rgba, w, h, icon_info.hbmMask);
} else {
// 无 mask,设为全不透明
for c in rgba.chunks_exact_mut(4) {
c[3] = 255;
}
}
}
Some((rgba, w, h))
} else {
// 无颜色位图:monochrome 图标,罕见且无色,跳过
None
};
// 清理 GDI 对象
if has_color {
let _ = DeleteObject(icon_info.hbmColor);
}
if has_mask {
let _ = DeleteObject(icon_info.hbmMask);
}
let (rgba, w, h) = result?;
encode_png(&rgba, w, h)
}
}
/// 读取 32-bit 位图为 RGBAtop-down),BGRA→RGBA
#[cfg(windows)]
fn bitmap_to_rgba32(
hbm: windows_sys::Win32::Graphics::Gdi::HBITMAP,
) -> Option<(Vec<u8>, u32, u32)> {
use windows_sys::Win32::Foundation::HWND;
use windows_sys::Win32::Graphics::Gdi::{
GetDC, GetDIBits, GetObjectW, BITMAP, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS,
ReleaseDC,
};
unsafe {
// 取尺寸
let mut bmp: BITMAP = std::mem::zeroed();
let got = GetObjectW(
hbm,
std::mem::size_of::<BITMAP>() as i32,
&mut bmp as *mut _ as *mut _,
);
if got == 0 {
return None;
}
let w = bmp.bmWidth as u32;
let h = bmp.bmHeight as u32;
if w == 0 || h == 0 {
return None;
}
// 32-bit top-down DIB
let mut bi: BITMAPINFO = std::mem::zeroed();
bi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
bi.bmiHeader.biWidth = w as i32;
bi.bmiHeader.biHeight = -(h as i32); // 负值 = top-down
bi.bmiHeader.biPlanes = 1;
bi.bmiHeader.biBitCount = 32;
bi.bmiHeader.biCompression = BI_RGB;
let mut pixels = vec![0u8; (w * h * 4) as usize];
let hdc = GetDC(0 as HWND);
if hdc == 0 {
return None;
}
let ret = GetDIBits(
hdc,
hbm,
0,
h,
pixels.as_mut_ptr() as *mut _,
&mut bi,
DIB_RGB_COLORS,
);
let _ = ReleaseDC(0 as HWND, hdc);
if ret == 0 {
return None;
}
// BGRA → RGBA
for chunk in pixels.chunks_exact_mut(4) {
chunk.swap(0, 2);
}
Some((pixels, w, h))
}
}
/// 用 1bpp mask 设置 alphamask 白(1)=透明,黑(0)=不透明
#[cfg(windows)]
fn apply_mask_alpha(
rgba: &mut [u8],
w: u32,
h: u32,
hbm_mask: windows_sys::Win32::Graphics::Gdi::HBITMAP,
) -> Result<(), ()> {
use windows_sys::Win32::Foundation::HWND;
use windows_sys::Win32::Graphics::Gdi::{
GetDC, GetDIBits, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, ReleaseDC,
};
unsafe {
let mut bi: BITMAPINFO = std::mem::zeroed();
bi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
bi.bmiHeader.biWidth = w as i32;
bi.bmiHeader.biHeight = -(h as i32);
bi.bmiHeader.biPlanes = 1;
bi.bmiHeader.biBitCount = 1;
bi.bmiHeader.biCompression = BI_RGB;
// 1bpp,每行 4 字节对齐
let row_bytes = ((w + 31) / 32 * 4) as usize;
let mut mask = vec![0u8; row_bytes * h as usize];
let hdc = GetDC(0 as HWND);
if hdc == 0 {
return Err(());
}
let ret = GetDIBits(
hdc,
hbm_mask,
0,
h,
mask.as_mut_ptr() as *mut _,
&mut bi,
DIB_RGB_COLORS,
);
let _ = ReleaseDC(0 as HWND, hdc);
if ret == 0 {
return Err(());
}
for y in 0..h as usize {
for x in 0..w as usize {
let byte_idx = y * row_bytes + x / 8;
let bit = (mask[byte_idx] >> (7 - (x % 8))) & 1;
let alpha = if bit == 1 { 0 } else { 255 };
rgba[(y * w as usize + x) * 4 + 3] = alpha;
}
}
Ok(())
}
}
/// RGBA → PNG
fn encode_png(rgba: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
use image::{ImageBuffer, RgbaImage};
let img: RgbaImage = ImageBuffer::from_raw(w, h, rgba.to_vec())?;
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.ok()?;
Some(buf.into_inner())
}
+30
View File
@@ -0,0 +1,30 @@
//! 快速面板模块:全局快捷键唤起的多源命令面板。
//!
//! Phase 1:窗口骨架(预创建隐藏窗口 + 快捷键 + 失焦隐藏)
//! Phase 2fuzzy + 拼音引擎,command/calc/web/system Provider
//! Phase 3:文件索引(walkdir + rusqlite)、应用扫描、剪贴板历史复用
pub mod actions;
pub mod app_scanner;
pub mod commands;
pub mod explorer;
pub mod file_index;
pub mod icon_extractor;
pub mod popup;
pub mod special_locations;
pub use actions::{
quickpanel_apply_rename, quickpanel_batch_extract, quickpanel_list_archives,
quickpanel_list_dir, quickpanel_preview_rename,
};
pub use commands::{
quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file,
quickpanel_delete_files, quickpanel_file_index_stats, quickpanel_focus_main_window,
quickpanel_get_app_icon, quickpanel_get_settings, quickpanel_get_special_locations,
quickpanel_hide_popup, quickpanel_init_file_index, quickpanel_lock_screen,
quickpanel_open_file, quickpanel_open_special, quickpanel_register_shortcut,
quickpanel_reveal_in_explorer, quickpanel_run_custom_command, quickpanel_run_system_command,
quickpanel_save_settings, quickpanel_scan_apps, quickpanel_search_files, quickpanel_show_popup,
quickpanel_show_window, quickpanel_unregister_shortcut,
};
pub use popup::{ensure_window, load_settings};
+297
View File
@@ -0,0 +1,297 @@
//! 快速面板弹窗:全局快捷键唤起的多源命令面板。
//!
//! 流程(与剪贴板弹窗同构):
//! 1. 应用启动 → `ensure_window` 预创建隐藏窗口(屏幕外)
//! 2. 全局快捷键按下 → `show_popup` 在鼠标所在显示器中央定位并显示
//! 3. 前端 Vue 挂载完成、主题应用后调用 `quickpanel_show_window` 显示窗口
//! 4. 前端监听 `quickpanel-show` 事件刷新数据/聚焦输入
//! 5. 窗口失焦自动隐藏(保留窗口复用,不销毁)
//!
//! Win32 API(鼠标/显示器/DPI)复用 clipboard::popup 已 pub use 的实现,
//! 避免重复封装。
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use tauri::window::{Effect, EffectsBuilder};
use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
use super::explorer;
use specta::Type;
/// 弹窗窗口标签
pub const POPUP_LABEL: &str = "quick-panel";
/// 窗口尺寸(逻辑像素)
const WIN_W: f64 = 600.0;
const WIN_H: f64 = 420.0;
/// `quickpanel-show` 事件负载:携带快捷键按下时检测到的 Explorer 当前目录,
/// 前端据此渲染"当前目录"文件操作分组。
#[derive(Clone, Serialize)]
pub struct QuickPanelShowPayload {
pub dir: Option<String>,
}
/// 检测前台 Explorer 目录并连同 show 事件一起下发。
/// 必须在快捷键回调内调用:此时前台窗口仍是 Explorer,面板尚未抢焦点。
fn emit_show(app: &AppHandle) {
let dir = explorer::detect_explorer_folder();
crate::logger::log_info("quickpanel", &format!("show_popup: explorer_dir={:?}", dir));
let _ = app.emit(
crate::constants::events::QUICKPANEL_SHOW,
QuickPanelShowPayload { dir },
);
}
/// 标志: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, Type)]
#[serde(rename_all = "camelCase")]
pub struct CustomCommand {
pub id: String,
pub title: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
/// 快速面板设置
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct QuickPanelSettings {
/// 全局快捷键(如 "Alt+Space"),空字符串表示不注册。
#[serde(default = "default_shortcut")]
pub shortcut: String,
/// 唤起位置:center(鼠标所在显示器中央)| cursor(鼠标位置)
#[serde(default = "default_popup_position")]
pub popup_position: String,
/// 默认搜索引擎:google | bing | baidu
#[serde(default = "default_search_engine")]
pub search_engine: String,
/// 文件索引目录列表(空列表表示使用默认:桌面/文档/下载)
#[serde(default = "default_index_dirs")]
pub index_dirs: Vec<String>,
/// 自定义命令列表
#[serde(default)]
pub custom_commands: Vec<CustomCommand>,
}
fn default_shortcut() -> String {
"Alt+Space".to_string()
}
fn default_popup_position() -> String {
"center".to_string()
}
fn default_search_engine() -> String {
"bing".to_string()
}
fn default_index_dirs() -> Vec<String> {
// 桌面/文档/下载目录(延迟到实际使用时解析,避免启动时失败)
let mut dirs = Vec::new();
if let Some(d) = dirs::desktop_dir() {
dirs.push(d.to_string_lossy().to_string());
}
if let Some(d) = dirs::document_dir() {
dirs.push(d.to_string_lossy().to_string());
}
if let Some(d) = dirs::download_dir() {
dirs.push(d.to_string_lossy().to_string());
}
dirs
}
impl Default for QuickPanelSettings {
fn default() -> Self {
Self {
shortcut: default_shortcut(),
popup_position: default_popup_position(),
search_engine: default_search_engine(),
index_dirs: default_index_dirs(),
custom_commands: Vec::new(),
}
}
}
/// 设置文件路径:{app_data_dir}/quickpanel/settings.json
fn settings_path(app: &AppHandle) -> PathBuf {
app.path()
.app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("quickpanel")
.join("settings.json")
}
/// 读取设置,文件不存在或解析失败返回默认值
pub fn load_settings(app: &AppHandle) -> QuickPanelSettings {
let path = settings_path(app);
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
/// 保存设置到磁盘
pub fn save_settings(app: &AppHandle, settings: &QuickPanelSettings) -> Result<(), String> {
let path = settings_path(app);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建设置目录失败: {}", e))?;
}
let json = serde_json::to_string_pretty(settings).map_err(|e| format!("序列化设置失败: {}", e))?;
std::fs::write(&path, json).map_err(|e| format!("写入设置文件失败: {}", e))
}
/// 创建弹窗窗口(隐藏状态)并注册失焦监听。
/// 位置默认在屏幕外,show_popup 时会重新定位到鼠标所在显示器中央。
/// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。
fn create_popup_window(app: &AppHandle) {
let win = match WebviewWindowBuilder::new(
app,
POPUP_LABEL,
WebviewUrl::App("index.html#quick-panel".into()),
)
.title("快速面板")
.inner_size(WIN_W, WIN_H)
.position(-10000.0, -10000.0) // 屏幕外,避免隐藏时一闪
.decorations(false)
.transparent(true)
.shadow(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false)
.focused(false) // 不抢占焦点,避免创建即触发 Focused(false)
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
.build()
{
Ok(w) => w,
Err(e) => {
crate::logger::log_error("quickpanel", &format!("创建弹窗失败: {}", e));
return;
}
};
// 监听窗口失焦:自动隐藏
let app_handle = app.clone();
let win_handle = win.clone();
win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
let _ = win_handle.hide();
let _ = app_handle.emit(crate::constants::events::QUICKPANEL_HIDE, ());
}
});
crate::logger::log_info("quickpanel", "弹窗窗口已预创建(隐藏状态)");
}
/// 应用启动时预创建弹窗窗口(隐藏)。
/// 这样首次按快捷键时窗口已存在,直接 show + 定位,避免首次创建时序问题。
pub fn ensure_window(app: &AppHandle) {
if app.get_webview_window(POPUP_LABEL).is_some() {
return;
}
create_popup_window(app);
}
/// 在指定位置显示弹窗。
/// popup_position = "cursor" 时在鼠标位置附近显示,否则在鼠标所在显示器中央显示。
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
pub fn show_popup(app: &AppHandle) {
let settings = load_settings(app);
let cursor_mode = settings.popup_position == "cursor";
// 获取鼠标位置(物理像素)
let (mx, my) = match get_cursor_pos() {
Some(p) => p,
None => return,
};
// 获取光标所在显示器的工作区(物理像素,与 get_cursor_pos 同一坐标系)
let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx, my)
.unwrap_or((0, 0, 1920, 1040));
// 光标所在显示器的 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;
// 直接以物理坐标计算(光标 + 工作区均为物理像素,避免混合 DPI 下换算偏移)
let (x, y) = if cursor_mode {
// 鼠标位置模式:以鼠标为基准偏移,clamp 到工作区内
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 - 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) {
// 必须先于窗口显示/聚焦检测 Explorer 目录:show/set_focus 会立即抢走前台焦点,
// 之后调用 GetForegroundWindow 拿到的就是面板自身了。
emit_show(app);
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
x: x as i32,
y: y as i32,
}));
let _ = win.show();
let _ = win.set_focus();
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);
}
/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。
/// 预创建路径下前端 onMounted 也会调用此函数,但 POPUP_PENDING_SHOW 为 false 时直接跳过,
/// 避免应用启动时弹窗自动弹出。仅 show_popup 兜底创建路径才真正显示。
pub fn show_window(app: &AppHandle) {
if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) {
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,
}));
}
// 同样先检测 Explorer 目录再显示,避免面板抢焦点导致检测失败。
emit_show(app);
let _ = win.show();
let _ = win.set_focus();
}
}
/// 隐藏弹窗(不销毁,保留复用)
pub fn hide_popup(app: &AppHandle) {
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.hide();
}
}
@@ -0,0 +1,234 @@
//! 快捷位置:Windows 常用系统位置/工具。
//!
//! 提供两类能力:
//! - `get_special_locations`:返回预定义列表(hosts、回收站、此电脑、用户目录、系统管理工具),
//! 路径在 Rust 侧解析(环境变量 / dirs crate),前端只负责展示与搜索。
//! - 打开逻辑:目录走 explorer.exe(比 ShellExecute "open" 目录更可靠),
//! 文件走 ShellExecuteWopen + openas fallback),shell 位置与系统工具走 ShellExecuteW。
use std::path::PathBuf;
use serde::Serialize;
use specta::Type;
/// 快捷位置条目
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SpecialLocation {
pub id: String,
pub title: String,
pub subtitle: String,
pub keywords: Vec<String>,
/// file: 真实文件/文件夹路径;shell: explorer 打开的 shell 路径;cmd: 可执行命令
pub kind: String,
pub target: String,
pub args: Vec<String>,
}
fn sl(
id: &str,
title: &str,
subtitle: &str,
keywords: &[&str],
kind: &str,
target: &str,
args: &[&str],
) -> SpecialLocation {
SpecialLocation {
id: id.to_string(),
title: title.to_string(),
subtitle: subtitle.to_string(),
keywords: keywords.iter().map(|s| s.to_string()).collect(),
kind: kind.to_string(),
target: target.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
}
}
/// Windows 系统目录(%SystemRoot%
fn system_root() -> String {
std::env::var("SystemRoot")
.or_else(|_| std::env::var("windir"))
.unwrap_or_else(|_| "C:\\Windows".to_string())
}
fn dir_str(d: Option<PathBuf>) -> Option<String> {
d.map(|p| p.to_string_lossy().to_string())
}
/// 返回全部快捷位置(路径已在 Rust 侧解析,避免前端硬编码)
pub fn get_special_locations() -> Vec<SpecialLocation> {
let mut list = Vec::new();
// ===== shell 位置(explorer 打开,CLSID 格式) =====
list.push(sl(
"sp-recycle-bin",
"回收站",
"Windows 回收站",
&["recycle", "bin", "trash", "回收站", "垃圾桶", "垃圾箱"],
"shell",
"::{645FF040-5081-101B-9F08-00AA002F954E}",
&[],
));
list.push(sl(
"sp-this-pc",
"此电脑",
"我的电脑",
&["此电脑", "我的电脑", "computer", "pc", "thispc"],
"shell",
"::{20D04FE0-3AEA-1069-A2D8-08002B30309D}",
&[],
));
list.push(sl(
"sp-network",
"网络",
"网络位置",
&["网络", "network", "网上邻居"],
"shell",
"::{208D2C60-3AEA-1069-A2D7-08002B30309D}",
&[],
));
// ===== 常用文件 =====
let hosts = format!("{}\\System32\\drivers\\etc\\hosts", system_root());
list.push(sl(
"sp-hosts",
"hosts 文件",
&hosts,
&["hosts", "host", "主机文件", "域名映射"],
"file",
&hosts,
&[],
));
// ===== 用户目录(真实路径) =====
let user_dirs: &[(&str, &str, &[&str], Option<PathBuf>)] = &[
("sp-documents", "我的文档", &["文档", "我的文档", "documents", "docs"], dirs::document_dir()),
("sp-desktop", "桌面", &["桌面", "desktop"], dirs::desktop_dir()),
("sp-downloads", "下载", &["下载", "download", "downloads"], dirs::download_dir()),
("sp-pictures", "图片", &["图片", "照片", "pictures", "photos"], dirs::picture_dir()),
("sp-music", "音乐", &["音乐", "music"], dirs::audio_dir()),
("sp-videos", "视频", &["视频", "videos"], dirs::video_dir()),
];
for (id, title, keywords, path) in user_dirs {
if let Some(p) = dir_str(path.clone()) {
list.push(sl(id, title, &p, keywords, "file", &p, &[]));
}
}
// ===== 系统管理工具(ShellExecuteW 直接启动 .msc/.cpl/.exe =====
let tools: &[(&str, &str, &[&str], &str)] = &[
("sp-devmgmt", "设备管理器", &["设备管理器", "设备", "devmgmt", "device"], "devmgmt.msc"),
("sp-diskmgmt", "磁盘管理", &["磁盘管理", "磁盘", "diskmgmt", "disk"], "diskmgmt.msc"),
("sp-compmgmt", "计算机管理", &["计算机管理", "compmgmt"], "compmgmt.msc"),
("sp-services", "服务", &["服务", "services", "service"], "services.msc"),
("sp-perfmon", "性能监视器", &["性能监视器", "性能", "perfmon"], "perfmon.msc"),
("sp-msinfo", "系统信息", &["系统信息", "msinfo", "systeminfo"], "msinfo32.exe"),
("sp-ncpa", "网络连接", &["网络连接", "ncpa", "网卡"], "ncpa.cpl"),
("sp-appwiz", "程序和功能", &["程序和功能", "卸载", "appwiz", "uninstall"], "appwiz.cpl"),
("sp-powercfg", "电源选项", &["电源选项", "电源", "powercfg", "power"], "powercfg.cpl"),
("sp-osk", "屏幕键盘", &["屏幕键盘", "键盘", "osk", "virtual keyboard"], "osk.exe"),
("sp-magnify", "放大镜", &["放大镜", "magnify", "magnifier"], "magnify.exe"),
];
for (id, title, keywords, exe) in tools {
list.push(sl(id, title, exe, keywords, "cmd", exe, &[]));
}
list.push(sl(
"sp-envvar",
"环境变量",
"rundll32 sysdm.cpl,EditEnvironmentVariables",
&["环境变量", "环境", "env", "environment"],
"cmd",
"rundll32.exe",
&["sysdm.cpl,EditEnvironmentVariables"],
));
list
}
// ===== 打开逻辑 =====
/// ShellExecuteW 调用(verb 可指定,如 "open" / "openas"
#[cfg(windows)]
fn shell_execute_verb(exe: &str, params: &str, verb: &str) -> Result<(), String> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::HWND;
use windows_sys::Win32::UI::Shell::ShellExecuteW;
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
let wide_exe: Vec<u16> = OsStr::new(exe)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let wide_params: Vec<u16> = OsStr::new(params)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let wide_verb: Vec<u16> = OsStr::new(verb)
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let hinst = ShellExecuteW(
0 as HWND,
wide_verb.as_ptr(),
wide_exe.as_ptr(),
wide_params.as_ptr(),
std::ptr::null(),
SW_SHOWNORMAL,
);
// ShellExecuteW 返回值 <= 32 表示错误
if hinst <= 32 {
return Err(format!("打开失败 (code: {})", hinst as i32));
}
}
Ok(())
}
#[cfg(not(windows))]
fn shell_execute_verb(_exe: &str, _params: &str, _verb: &str) -> Result<(), String> {
Err("当前平台不支持".into())
}
/// 打开文件/文件夹路径:
/// - 目录:explorer.exe 直接打开(比 ShellExecute "open" 目录更可靠,修复索引目录点击不生效问题)
/// - 文件:ShellExecuteW open,无关联程序时 fallback 到「打开方式」对话框
pub fn open_path(path: &str) -> Result<(), String> {
let p = std::path::Path::new(path);
#[cfg(windows)]
{
if p.is_dir() {
use crate::process_manager::setup_creation_flags;
let mut cmd = std::process::Command::new("explorer.exe");
cmd.arg(path);
setup_creation_flags(&mut cmd);
return cmd
.spawn()
.map(|_| ())
.map_err(|e| format!("打开文件夹失败: {}", e));
}
let params = String::new();
if shell_execute_verb(path, &params, "open").is_err() {
return shell_execute_verb(path, &params, "openas");
}
Ok(())
}
#[cfg(not(windows))]
{
let _ = p;
Err("当前平台不支持".into())
}
}
/// 打开 shell 位置(explorer shell: 或 ::{CLSID}
pub fn open_shell(target: &str) -> Result<(), String> {
shell_execute_verb("explorer.exe", target, "open")
}
/// 打开系统工具(.msc / .cpl / .exe,参数空格连接)
pub fn open_cmd(exe: &str, args: &[String]) -> Result<(), String> {
let params = args.join(" ");
shell_execute_verb(exe, &params, "open")
}
+778
View File
@@ -0,0 +1,778 @@
//! Windows 屏幕捕获核心(仅 Windows 编译)
//!
//! 实现:
//! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素
//! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口)
//! - 窗口拾取:EnumWindows 按 Z 序命中测试(排除本进程窗口,避免命中覆盖层自身)
//! - 顶层窗口枚举:EnumWindows
//! - 像素 → PNG / CF_DIB 转换
//!
//! 设计说明:
//! - 不使用 Windows Graphics Capture:避免引入 windows crateWinRT)增大体积,
//! 且 WGC 默认绘制黄色捕获边框,截图工具不可接受。
//! - 一次性捕获使用 BitBlt/PrintWindow,延迟低、无用户提示。
//! - 32bpp 捕获后强制 alpha=255BitBlt 取出的 alpha 通道未定义(常为 0),
//! 直接编码 PNG 会得到全透明图,故强制不透明。
use std::sync::Mutex;
use windows_sys::Win32::Foundation::{BOOL, HWND, POINT, RECT};
use windows_sys::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits,
PatBlt, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BLACKNESS, DIB_RGB_COLORS,
RGBQUAD, SRCCOPY,
};
use windows_sys::Win32::Storage::Xps::PrintWindow;
use windows_sys::Win32::System::DataExchange::{
CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData,
};
use windows_sys::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE};
use windows_sys::Win32::System::Ole::CF_DIB;
use windows_sys::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetCursorPos, GetSystemMetrics, GetWindowLongPtrW, GetWindowRect,
GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, GWL_EXSTYLE,
WS_EX_TOOLWINDOW,
};
use super::{CaptureData, ScreenRect, WindowInfo};
/// 捕获结果(PNG 字节 + 原始 BGRA 像素,像素用于剪贴板 DIB 构造,避免重复解码)
pub struct CapturedImage {
pub width: i32,
pub height: i32,
pub png: Vec<u8>,
/// 32bpp BGRAtop-down(与 GetDIBits negative height 一致)
pub bgra: Vec<u8>,
}
impl From<RECT> for ScreenRect {
fn from(r: RECT) -> Self {
Self {
x: r.left,
y: r.top,
width: r.right - r.left,
height: r.bottom - r.top,
}
}
}
/// 全屏捕获静态存储(供覆盖层选区后裁剪取用)
static FULLSCREEN_CAPTURE: Mutex<Option<CapturedImage>> = Mutex::new(None);
const SM_XVIRTUALSCREEN: i32 = 76;
const SM_YVIRTUALSCREEN: i32 = 77;
const SM_CXVIRTUALSCREEN: i32 = 78;
const SM_CYVIRTUALSCREEN: i32 = 79;
const PW_RENDERFULLCONTENT: u32 = 0x00000002;
const BI_RGB: u32 = 0;
/// 将 BGRA top-down 像素编码为 BMP(强制 alpha=255
///
/// 用于覆盖层/编辑器快速显示:相比 PNG 编码几乎零 CPU 开销,
/// ChromiumWebView2)原生支持 top-down 32bpp BMP。
fn bgra_to_bmp(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> {
if width <= 0 || height <= 0 {
return Err("无效尺寸".into());
}
let w = width as usize;
let h = height as usize;
let data_size = w * h * 4;
let file_size = 14 + 40 + data_size;
let mut bmp = Vec::with_capacity(file_size);
// BITMAPFILEHEADER
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&(file_size as u32).to_le_bytes());
bmp.extend_from_slice(&0u16.to_le_bytes()); // reserved
bmp.extend_from_slice(&0u16.to_le_bytes()); // reserved
bmp.extend_from_slice(&54u32.to_le_bytes()); // 像素数据偏移
// BITMAPINFOHEADER
bmp.extend_from_slice(&40u32.to_le_bytes()); // header size
bmp.extend_from_slice(&(width as i32).to_le_bytes());
bmp.extend_from_slice(&(-(height as i32)).to_le_bytes()); // 负值 = top-down
bmp.extend_from_slice(&1u16.to_le_bytes()); // planes
bmp.extend_from_slice(&32u16.to_le_bytes()); // bpp
bmp.extend_from_slice(&0u32.to_le_bytes()); // BI_RGB
bmp.extend_from_slice(&(data_size as u32).to_le_bytes());
bmp.extend_from_slice(&0u32.to_le_bytes()); // x ppm
bmp.extend_from_slice(&0u32.to_le_bytes()); // y ppm
bmp.extend_from_slice(&0u32.to_le_bytes()); // colors used
bmp.extend_from_slice(&0u32.to_le_bytes()); // important colors
// 像素数据:memcpy 一次性拷贝,再原地修正 alpha(避免 to_vec 中间分配)
let pixel_start = bmp.len();
bmp.extend_from_slice(bgra);
for i in (pixel_start + 3..bmp.len()).step_by(4) {
bmp[i] = 255;
}
Ok(bmp)
}
/// 将 BGRA top-down 像素编码为 PNG(强制 alpha=255
///
/// 使用 Fast 压缩 + 无过滤:历史缩略图/自动保存不需要最优压缩比,
/// 大幅降低"点击完成 → 关闭窗口"的编码延迟。
fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> {
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::ImageEncoder;
if width <= 0 || height <= 0 {
return Err("无效尺寸".into());
}
let pixel_count = (width as usize) * (height as usize);
let mut rgba = vec![0u8; pixel_count * 4];
// chunks_exact 迭代器写法:4 字节对齐连续读取,编译器更容易自动向量化
for (src, dst) in bgra.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
dst[0] = src[2]; // R
dst[1] = src[1]; // G
dst[2] = src[0]; // B
dst[3] = 255; // A 强制不透明
}
let mut buf = Vec::with_capacity(pixel_count * 4 / 3);
PngEncoder::new_with_quality(
&mut buf,
CompressionType::Fast,
FilterType::NoFilter,
)
.write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8)
.map_err(|e| format!("PNG 编码失败: {}", e))?;
Ok(buf)
}
/// 从 HBITMAP 提取 32bpp BGRA top-down 像素
unsafe fn extract_pixels(
hdc_mem: isize,
hbm: isize,
width: i32,
height: i32,
) -> Result<Vec<u8>, String> {
let bi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: width,
biHeight: -height, // 负值 = top-down
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: 0,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
},
bmiColors: [RGBQUAD {
rgbBlue: 0,
rgbGreen: 0,
rgbRed: 0,
rgbReserved: 0,
}],
};
let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4];
let got = GetDIBits(
hdc_mem,
hbm,
0,
height as u32,
pixels.as_mut_ptr() as *mut _,
&bi as *const _ as *mut _,
DIB_RGB_COLORS,
);
if got == 0 {
return Err("GetDIBits 失败".into());
}
Ok(pixels)
}
/// 捕获整个虚拟屏(所有显示器拼接为一张图)
pub fn capture_virtual_screen() -> Result<CapturedImage, String> {
unsafe {
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
let y = GetSystemMetrics(SM_YVIRTUALSCREEN);
let w = GetSystemMetrics(SM_CXVIRTUALSCREEN);
let h = GetSystemMetrics(SM_CYVIRTUALSCREEN);
if w <= 0 || h <= 0 {
return Err("无法获取虚拟屏尺寸".into());
}
let hdc_screen = GetDC(0);
if hdc_screen == 0 {
return Err("GetDC 失败".into());
}
let hdc_mem = CreateCompatibleDC(hdc_screen);
if hdc_mem == 0 {
ReleaseDC(0, hdc_screen);
return Err("CreateCompatibleDC 失败".into());
}
let hbm = CreateCompatibleBitmap(hdc_screen, w, h);
if hbm == 0 {
DeleteDC(hdc_mem);
ReleaseDC(0, hdc_screen);
return Err("CreateCompatibleBitmap 失败".into());
}
let old = SelectObject(hdc_mem, hbm);
let ok = BitBlt(hdc_mem, 0, 0, w, h, hdc_screen, x, y, SRCCOPY);
let result = if ok == 0 {
Err("BitBlt 失败".into())
} else {
extract_pixels(hdc_mem, hbm, w, h).map(|bgra| CapturedImage {
width: w,
height: h,
png: Vec::new(), // 全屏捕获不做 PNG 编码,显示/裁剪走 raw BMP
bgra,
})
};
SelectObject(hdc_mem, old);
DeleteObject(hbm);
DeleteDC(hdc_mem);
ReleaseDC(0, hdc_screen);
result
}
}
/// 捕获指定窗口(PrintWindow + PW_RENDERFULLCONTENT,覆盖硬件加速窗口)
pub fn capture_window(hwnd: isize) -> Result<CapturedImage, String> {
unsafe {
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) == 0 {
return Err("GetWindowRect 失败".into());
}
let w = rect.right - rect.left;
let h = rect.bottom - rect.top;
if w <= 0 || h <= 0 {
return Err("窗口尺寸无效".into());
}
let hdc_screen = GetDC(0);
if hdc_screen == 0 {
return Err("GetDC 失败".into());
}
let hdc_mem = CreateCompatibleDC(hdc_screen);
let hbm = CreateCompatibleBitmap(hdc_screen, w, h);
let old = SelectObject(hdc_mem, hbm);
// 先用黑色填充(PrintWindow 对部分窗口不绘制透明区域)
PatBlt(hdc_mem, 0, 0, w, h, BLACKNESS);
let ok = PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT);
let result = if ok == 0 {
Err("PrintWindow 失败(可能窗口无响应或权限不足)".into())
} else {
extract_pixels(hdc_mem, hbm, w, h).and_then(|bgra| {
bgra_to_png(&bgra, w, h).map(|png| CapturedImage {
width: w,
height: h,
png,
bgra,
})
})
};
SelectObject(hdc_mem, old);
DeleteObject(hbm);
DeleteDC(hdc_mem);
ReleaseDC(0, hdc_screen);
result
}
}
/// 获取指定屏幕坐标下的顶层窗口(窗口拾取)
///
/// 入参 x/y 为物理屏幕坐标(前端需按显示器 scaleFactor 从逻辑坐标换算)。
///
/// 不能直接用 WindowFromPoint:覆盖层是 alwaysOnTop 全屏窗口,会命中覆盖层自身。
/// 改为 EnumWindows 按 Z 序(顶→底)枚举顶层窗口做命中测试,并排除本进程
/// (覆盖层/主窗口/编辑器)的窗口,从而取到覆盖层下面的目标窗口。
pub fn window_from_point(x: i32, y: i32) -> Option<WindowInfo> {
struct PickContext {
my_pid: u32,
pt: POINT,
found: Option<WindowInfo>,
}
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
unsafe {
let ctx = &mut *(lparam as *mut PickContext);
// 跳过本进程窗口(覆盖层 / 主窗口 / 编辑器等)
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, &mut pid);
if pid == ctx.my_pid {
return 1;
}
if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 {
return 1;
}
// 跳过工具窗口(如本应用 OSD / 托盘菜单)
let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
if ex & (WS_EX_TOOLWINDOW as isize) != 0 {
return 1;
}
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) == 0 {
return 1;
}
// 命中测试(物理坐标),Z 序最顶层的第一个命中即为目标
let pt = ctx.pt;
if pt.x >= rect.left && pt.x < rect.right && pt.y >= rect.top && pt.y < rect.bottom {
ctx.found = Some(WindowInfo {
hwnd,
title: get_window_title(hwnd),
rect: ScreenRect::from(rect),
visual_rect: extended_frame_bounds(hwnd),
});
return 0; // 停止枚举
}
}
1
}
let mut ctx = PickContext {
my_pid: std::process::id(),
pt: POINT { x, y },
found: None,
};
unsafe {
EnumWindows(Some(enum_proc), &mut ctx as *mut _ as isize);
}
ctx.found
}
/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口)
pub fn cursor_pos() -> Result<(i32, i32), String> {
unsafe {
let mut pt: POINT = std::mem::zeroed();
if GetCursorPos(&mut pt) == 0 {
return Err("GetCursorPos 失败".into());
}
Ok((pt.x, pt.y))
}
}
/// DWM 扩展边框矩形(视觉边界)
///
/// GetWindowRect 对最大化窗口包含屏幕外的隐形缩放边框(各向 7~8px),
/// 导致窗口高亮框比实际窗口大一圈;DWMWA_EXTENDED_FRAME_BOUNDS 返回可视边界。
fn extended_frame_bounds(hwnd: HWND) -> Option<ScreenRect> {
use windows_sys::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS};
let mut r: RECT = unsafe { std::mem::zeroed() };
let hr = unsafe {
DwmGetWindowAttribute(
hwnd,
DWMWA_EXTENDED_FRAME_BOUNDS as u32,
&mut r as *mut _ as *mut core::ffi::c_void,
std::mem::size_of::<RECT>() as u32,
)
};
if hr == 0 {
Some(ScreenRect::from(r))
} else {
None
}
}
/// 禁用指定窗口的显示/隐藏过渡动画
///
/// Windows 默认对 show/hide 播放"缩放+淡入淡出"动画,截图覆盖层会出现
/// "从小变大/从大变小"的过渡;DWMWA_TRANSITIONS_FORCEDISABLED 对该窗口永久关闭过渡。
pub fn disable_window_transitions(hwnd: isize) -> Result<(), String> {
use windows_sys::Win32::Graphics::Dwm::DwmSetWindowAttribute;
const DWMWA_TRANSITIONS_FORCEDISABLED: u32 = 3;
let mut disabled: BOOL = 1;
let hr = unsafe {
DwmSetWindowAttribute(
hwnd as HWND,
DWMWA_TRANSITIONS_FORCEDISABLED,
&mut disabled as *mut _ as *mut core::ffi::c_void,
std::mem::size_of::<BOOL>() as u32,
)
};
if hr == 0 {
Ok(())
} else {
Err(format!("DwmSetWindowAttribute 失败: {}", hr))
}
}
/// 枚举所有可见、有标题的顶层窗口(供窗口列表选择)
pub fn enum_visible_windows() -> Vec<WindowInfo> {
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
unsafe {
let vec = &mut *(lparam as *mut Vec<WindowInfo>);
if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 {
return 1;
}
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) == 0 {
return 1;
}
let srect = ScreenRect::from(rect);
if srect.width < 20 || srect.height < 20 {
return 1;
}
// 跳过工具窗口(本应用 OSD / 托盘菜单 / 截图覆盖层)
let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
if ex & (WS_EX_TOOLWINDOW as isize) != 0 {
return 1;
}
let title = get_window_title(hwnd);
if title.is_empty() {
return 1;
}
vec.push(WindowInfo {
hwnd,
title,
rect: srect,
visual_rect: extended_frame_bounds(hwnd),
});
}
1
}
let mut out: Vec<WindowInfo> = Vec::new();
unsafe {
EnumWindows(Some(enum_proc), &mut out as *mut _ as isize);
}
out
}
unsafe fn get_window_title(hwnd: HWND) -> String {
let len = GetWindowTextLengthW(hwnd);
if len <= 0 {
return String::new();
}
let mut buf = vec![0u16; (len as usize) + 1];
let got = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
if got <= 0 {
return String::new();
}
String::from_utf16_lossy(&buf[..got as usize])
}
/// 裁剪已存储的全屏捕获(按物理像素坐标)
pub fn crop_stored(x: i32, y: i32, w: i32, h: i32) -> Result<CaptureData, String> {
let guard = FULLSCREEN_CAPTURE.lock().map_err(|e| e.to_string())?;
let img = guard.as_ref().ok_or("无已存储的全屏捕获")?;
if x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > img.width || y + h > img.height {
return Err("裁剪区域越界".into());
}
let sw = img.width as usize;
let sub = crop_bgra(&img.bgra, sw, x, y, w, h)?;
let png = bgra_to_png(&sub, w, h)?;
Ok(CaptureData {
png_base64: base64_encode(&png),
width: w,
height: h,
})
}
/// 裁剪已存储的全屏捕获并直接写入剪贴板(一次 IPC 完成"裁剪+复制"
///
/// 相比前端 crop_stored → copy_image 两次大 base64 往返:
/// 直接从原始 BGRA 构造成 bottom-up DIB,省去 PNG 解码,显著降低"点击完成"延迟。
pub fn crop_copy_stored(x: i32, y: i32, w: i32, h: i32) -> Result<CaptureData, String> {
let guard = FULLSCREEN_CAPTURE.lock().map_err(|e| e.to_string())?;
let img = guard.as_ref().ok_or("无已存储的全屏捕获")?;
if x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > img.width || y + h > img.height {
return Err("裁剪区域越界".into());
}
let sw = img.width as usize;
let sub = crop_bgra(&img.bgra, sw, x, y, w, h)?;
let dib = bgra_to_dib(&sub, w, h);
write_dib_to_clipboard(&dib)?;
let png = bgra_to_png(&sub, w, h)?;
Ok(CaptureData {
png_base64: base64_encode(&png),
width: w,
height: h,
})
}
/// 将 BGRA top-down 像素构造为 CF_DIBbottom-upalpha 强制 255
fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec<u8> {
let header_size = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
let w = width as usize;
let h = height as usize;
let row_size = w * 4;
let pixel_size = row_size * h;
let mut dib = Vec::with_capacity(header_size as usize + pixel_size);
let header = BITMAPINFOHEADER {
biSize: header_size,
biWidth: width,
biHeight: height, // 正值 = bottom-up
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: (pixel_size) as u32,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
};
let raw = unsafe {
std::slice::from_raw_parts(
&header as *const _ as *const u8,
std::mem::size_of::<BITMAPINFOHEADER>(),
)
};
dib.extend_from_slice(raw);
// 预分配像素区,按行 memcpy 拷贝(top-down → bottom-up 行序翻转),再原地修正 alpha
let pixel_start = dib.len();
dib.resize(dib.len() + pixel_size, 0);
for y in 0..h {
let src = y * row_size;
let dst = (h - 1 - y) * row_size;
dib[pixel_start + dst..pixel_start + dst + row_size]
.copy_from_slice(&bgra[src..src + row_size]);
}
for i in (pixel_start + 3..pixel_start + pixel_size).step_by(4) {
dib[i] = 255;
}
dib
}
fn crop_bgra(
src: &[u8],
src_width: usize,
x: i32,
y: i32,
w: i32,
h: i32,
) -> Result<Vec<u8>, String> {
let mut out = vec![0u8; (w as usize) * (h as usize) * 4];
for row in 0..(h as usize) {
let src_off = ((y as usize) + row) * src_width * 4 + (x as usize) * 4;
let dst_off = row * (w as usize) * 4;
out[dst_off..dst_off + (w as usize) * 4]
.copy_from_slice(&src[src_off..src_off + (w as usize) * 4]);
}
Ok(out)
}
/// 存储全屏捕获(覆盖层显示/裁剪取用),返回是否成功
pub fn store_fullscreen(img: CapturedImage) -> Result<(), String> {
FULLSCREEN_CAPTURE
.lock()
.map(|mut g| *g = Some(img))
.map_err(|e| e.to_string())
}
/// 取出全屏捕获的 BMP 字节用于显示(不移除,裁剪仍依赖原始像素)
pub fn fullscreen_bmp() -> Result<Vec<u8>, String> {
let guard = FULLSCREEN_CAPTURE.lock().map_err(|e| e.to_string())?;
let img = guard.as_ref().ok_or("无已存储的全屏捕获")?;
bgra_to_bmp(&img.bgra, img.width, img.height)
}
/// 全屏捕获编码为 PNG base64 并移除(全屏截图直接进编辑器时用)
pub fn fullscreen_png() -> Result<CaptureData, String> {
let mut g = FULLSCREEN_CAPTURE.lock().map_err(|e| e.to_string())?;
let img = g.take().ok_or("无已存储的全屏捕获")?;
let png = bgra_to_png(&img.bgra, img.width, img.height)?;
Ok(CaptureData {
png_base64: base64_encode(&png),
width: img.width,
height: img.height,
})
}
/// 清除全屏捕获(覆盖层关闭/取消时释放内存)
pub fn clear_fullscreen() {
if let Ok(mut g) = FULLSCREEN_CAPTURE.lock() {
*g = None;
}
}
// ===== 输出:剪贴板 / 文件 =====
/// 将 PNG base64 解码为像素,构造 CF_DIB 并写入剪贴板
pub fn copy_png_to_clipboard(png_base64: &str) -> Result<(), String> {
let png = base64_decode(png_base64).ok_or("base64 解码失败")?;
let img = image::load_from_memory(&png)
.map_err(|e| format!("PNG 解码失败: {}", e))?
.to_rgba8();
let (w, h) = (img.width() as i32, img.height() as i32);
let dib = rgba_to_dib(&img, w, h);
write_dib_to_clipboard(&dib)
}
/// raw RGBA 像素 → CF_DIB(直接从连续像素切片构造,无需 image crate 解码)
///
/// 用于有标注导出路径:前端 canvas.getImageData() 直传 raw RGBA
/// 省去 toDataURL(PNG 编码+base64) → Rust base64 解码 → PNG 解码 的三次往返。
fn rgba_raw_to_dib(rgba: &[u8], width: i32, height: i32) -> Vec<u8> {
let header_size = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
let w = width as usize;
let h = height as usize;
let row_size = w * 4;
let pixel_size = row_size * h;
let mut dib = Vec::with_capacity(header_size as usize + pixel_size);
let header = BITMAPINFOHEADER {
biSize: header_size,
biWidth: width,
biHeight: height, // 正值 = bottom-up
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: (pixel_size) as u32,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
};
let raw = unsafe {
std::slice::from_raw_parts(
&header as *const _ as *const u8,
std::mem::size_of::<BITMAPINFOHEADER>(),
)
};
dib.extend_from_slice(raw);
let pixel_start = dib.len();
dib.resize(dib.len() + pixel_size, 0);
// RGBA top-down → BGRA bottom-upalpha 强制 255
for y in 0..h {
let src_off = (h - 1 - y) * row_size;
let dst_off = y * row_size;
for x in 0..w {
let si = src_off + x * 4;
let di = pixel_start + dst_off + x * 4;
dib[di] = rgba[si + 2]; // B
dib[di + 1] = rgba[si + 1]; // G
dib[di + 2] = rgba[si]; // R
dib[di + 3] = 255; // A
}
}
dib
}
/// raw RGBA → PNG(直接从连续像素切片编码,跳过 image crate 的 RgbaImage 包装)
fn rgba_raw_to_png(rgba: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> {
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::ImageEncoder;
if width <= 0 || height <= 0 {
return Err("无效尺寸".into());
}
let mut buf = Vec::with_capacity(rgba.len() / 3);
PngEncoder::new_with_quality(
&mut buf,
CompressionType::Fast,
FilterType::NoFilter,
)
.write_image(rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8)
.map_err(|e| format!("PNG 编码失败: {}", e))?;
Ok(buf)
}
/// 有标注导出:raw RGBA → 剪贴板 DIB + PNG base64(一次调用完成复制+编码)
///
/// 替代原 composeBase64 + screenshot_copy_image 两步:
/// 省去前端 toDataURL(PNG 编码+base64) + Rust base64 解码 + PNG 解码,
/// 直接 raw RGBA 传输 → DIB 写剪贴板 + PNG 编码返回。
pub fn compose_copy_rgba(rgba: &[u8], width: i32, height: i32) -> Result<CaptureData, String> {
let expected = (width as usize) * (height as usize) * 4;
if rgba.len() < expected {
return Err(format!("像素数据不足: {} < {}", rgba.len(), expected));
}
let dib = rgba_raw_to_dib(rgba, width, height);
write_dib_to_clipboard(&dib)?;
let png = rgba_raw_to_png(rgba, width, height)?;
Ok(CaptureData {
png_base64: base64_encode(&png),
width,
height,
})
}
/// RGBA → CF_DIBBITMAPINFOHEADER + BGRA bottom-upalpha 强制 255
fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec<u8> {
let header_size = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
let w = width as usize;
let h = height as usize;
let row_size = w * 4;
let pixel_size = row_size * h;
let mut dib = Vec::with_capacity(header_size as usize + pixel_size);
let header = BITMAPINFOHEADER {
biSize: header_size,
biWidth: width,
biHeight: height, // 正值 = bottom-up
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: (pixel_size) as u32,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
};
let raw = unsafe {
std::slice::from_raw_parts(
&header as *const _ as *const u8,
std::mem::size_of::<BITMAPINFOHEADER>(),
)
};
dib.extend_from_slice(raw);
// 预分配像素区,用 as_raw() 连续切片替代逐像素 get_pixel
let pixel_start = dib.len();
dib.resize(dib.len() + pixel_size, 0);
let src = rgba.as_raw(); // &[u8] RGBA top-down
for y in 0..h {
// bottom-up: DIB 第一行 = 图像最后一行
let src_off = (h - 1 - y) * row_size;
let dst_off = y * row_size;
for x in 0..w {
let si = src_off + x * 4;
let di = pixel_start + dst_off + x * 4;
dib[di] = src[si + 2]; // B
dib[di + 1] = src[si + 1]; // G
dib[di + 2] = src[si]; // R
dib[di + 3] = 255; // A
}
}
dib
}
fn write_dib_to_clipboard(dib: &[u8]) -> Result<(), String> {
unsafe {
if OpenClipboard(0) == 0 {
return Err("OpenClipboard 失败".into());
}
let r = (|| {
if EmptyClipboard() == 0 {
return Err("EmptyClipboard 失败".to_string());
}
let hglob = GlobalAlloc(GMEM_MOVEABLE, dib.len());
if hglob.is_null() {
return Err("GlobalAlloc 失败".to_string());
}
let ptr = GlobalLock(hglob) as *mut u8;
if ptr.is_null() {
return Err("GlobalLock 失败".to_string());
}
std::ptr::copy_nonoverlapping(dib.as_ptr(), ptr, dib.len());
GlobalUnlock(hglob);
if SetClipboardData(CF_DIB as u32, hglob as isize) == 0 {
return Err("SetClipboardData 失败".to_string());
}
Ok(())
})();
CloseClipboard();
r
}
}
/// 将 PNG base64 写入文件
pub fn save_png_to_file(png_base64: &str, path: &str) -> Result<(), String> {
let png = base64_decode(png_base64).ok_or("base64 解码失败")?;
std::fs::write(path, &png).map_err(|e| format!("写入文件失败: {}", e))
}
pub fn base64_encode(b: &[u8]) -> String {
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
STANDARD.encode(b)
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
STANDARD.decode(s).ok()
}
+448
View File
@@ -0,0 +1,448 @@
//! Tauri 命令:截图模块
//!
//! 命令清单:
//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态(不做 PNG 编码)
//! - screenshot_get_fullscreen_bmp:取出全屏捕获的 BMP 原始字节(raw IPC,覆盖层显示用,不移除)
//! - screenshot_fullscreen_png:全屏捕获编码 PNG base64 并清除(全屏截图进编辑器用)
//! - screenshot_clear_fullscreen:清除静态全屏捕获(覆盖层关闭时)
//! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获
//! - screenshot_window_from_point:拾取指定屏幕坐标下的顶层窗口
//! - screenshot_enum_windows:枚举可见顶层窗口
//! - screenshot_capture_window:按 hwnd 捕获指定窗口
//! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递
//! - screenshot_copy_image:写入剪贴板(CF_DIB
//! - screenshot_save_png:写入文件
//! - screenshot_disable_transitions:禁用窗口显示/隐藏过渡动画(消除覆盖层缩放动画)
use super::{CaptureData, WindowInfo};
use tauri::{Emitter, Manager};
/// 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画
#[tauri::command]
#[specta::specta]
pub async fn screenshot_disable_transitions(
app: tauri::AppHandle,
label: String,
) -> Result<(), String> {
#[cfg(windows)]
{
use raw_window_handle::HasWindowHandle;
let win = app
.get_webview_window(&label)
.ok_or_else(|| format!("窗口不存在: {}", label))?;
let handle = win
.window_handle()
.map_err(|e| format!("获取窗口句柄失败: {}", e))?;
match handle.as_raw() {
raw_window_handle::RawWindowHandle::Win32(h) => {
super::capture::disable_window_transitions(h.hwnd.get() as isize)
}
_ => Ok(()),
}
}
#[cfg(not(windows))]
{
let _ = (app, label);
Ok(())
}
}
/// 注册(或切换)截图全局快捷键。传入空字符串则禁用快捷键。
#[tauri::command]
#[specta::specta]
pub async fn screenshot_register_shortcut(
app: tauri::AppHandle,
shortcut: String,
) -> Result<(), String> {
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> {
crate::shortcut::unregister_shortcut(&app, "截图");
Ok(())
}
/// 注册(或切换)贴图全局快捷键。传入空字符串则禁用快捷键。
/// 按下时 emit 'screenshot-pin-shortcut',由前端切换贴图窗口显示/隐藏。
#[tauri::command]
#[specta::specta]
pub async fn screenshot_register_pin_shortcut(
app: tauri::AppHandle,
shortcut: String,
) -> Result<(), String> {
crate::shortcut::register_shortcut(&app, "贴图", &shortcut, |a| {
let _ = a.emit(crate::constants::events::SCREENSHOT_PIN_SHORTCUT, ());
})
}
/// 注销贴图全局快捷键
#[tauri::command]
#[specta::specta]
pub async fn screenshot_unregister_pin_shortcut(app: tauri::AppHandle) -> Result<(), String> {
crate::shortcut::unregister_shortcut(&app, "贴图");
Ok(())
}
/// 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码
#[tauri::command]
#[specta::specta]
pub async fn screenshot_capture_fullscreen() -> Result<(), String> {
#[cfg(windows)]
{
// 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度
tauri::async_runtime::spawn_blocking(|| {
let img = super::capture::capture_virtual_screen()?;
super::capture::store_fullscreen(img)
})
.await
.map_err(|e| format!("捕获任务失败: {}", e))?
}
#[cfg(not(windows))]
{
Err("截图仅支持 Windows".into())
}
}
/// 取出全屏捕获的 BMP 原始字节(raw IPC → 前端 ArrayBuffer),不移除
#[tauri::command]
pub async fn screenshot_get_fullscreen_bmp() -> Result<tauri::ipc::Response, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(|| {
let bmp = super::capture::fullscreen_bmp()?;
Ok(tauri::ipc::Response::new(bmp))
})
.await
.map_err(|e| format!("读取捕获失败: {}", e))?
}
#[cfg(not(windows))]
{
Err("截图仅支持 Windows".into())
}
}
/// 全屏捕获编码为 PNG base64 并清除(全屏截图直接进编辑器)
#[tauri::command]
#[specta::specta]
pub async fn screenshot_fullscreen_png() -> Result<CaptureData, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(|| super::capture::fullscreen_png())
.await
.map_err(|e| format!("编码任务失败: {}", e))?
}
#[cfg(not(windows))]
{
Err("截图仅支持 Windows".into())
}
}
/// 清除静态全屏捕获(覆盖层关闭/取消时释放内存)
#[tauri::command]
#[specta::specta]
pub async fn screenshot_clear_fullscreen() -> Result<(), String> {
#[cfg(windows)]
{
super::capture::clear_fullscreen();
Ok(())
}
#[cfg(not(windows))]
{
Ok(())
}
}
/// 按物理像素坐标裁剪已存储的全屏捕获
#[tauri::command]
#[specta::specta]
pub async fn screenshot_crop_stored(
x: i32,
y: i32,
w: i32,
h: i32,
) -> Result<CaptureData, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
super::capture::crop_stored(x, y, w, h)
})
.await
.map_err(|e| format!("裁剪任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = (x, y, w, h);
Err("截图仅支持 Windows".into())
}
}
/// 裁剪已存储的全屏捕获并直接写入剪贴板(一次 IPC 完成"裁剪+复制"
#[tauri::command]
#[specta::specta]
pub async fn screenshot_crop_copy_stored(
x: i32,
y: i32,
w: i32,
h: i32,
) -> Result<CaptureData, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
super::capture::crop_copy_stored(x, y, w, h)
})
.await
.map_err(|e| format!("裁剪复制任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = (x, y, w, h);
Err("截图仅支持 Windows".into())
}
}
/// 拾取指定物理屏幕坐标下的顶层窗口
#[tauri::command]
#[specta::specta]
pub async fn screenshot_window_from_point(
x: i32,
y: i32,
) -> Result<Option<WindowInfo>, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
Ok(super::capture::window_from_point(x, y))
})
.await
.map_err(|e| format!("查询失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = (x, y);
Ok(None)
}
}
/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口)
#[tauri::command]
#[specta::specta]
pub async fn screenshot_cursor_pos() -> Result<(i32, i32), String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(super::capture::cursor_pos)
.await
.map_err(|e| format!("查询任务失败: {}", e))?
}
#[cfg(not(windows))]
{
Err("截图仅支持 Windows".into())
}
}
/// 枚举所有可见顶层窗口
#[tauri::command]
#[specta::specta]
pub async fn screenshot_enum_windows() -> Result<Vec<WindowInfo>, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(|| super::capture::enum_visible_windows())
.await
.map_err(|e| format!("枚举失败: {}", e))
}
#[cfg(not(windows))]
{
Ok(vec![])
}
}
/// 按 hwnd 捕获指定窗口
#[tauri::command]
#[specta::specta]
pub async fn screenshot_capture_window(hwnd: isize) -> Result<CaptureData, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
let img = super::capture::capture_window(hwnd)?;
Ok(super::CaptureData {
png_base64: super::capture::base64_encode(&img.png),
width: img.width,
height: img.height,
})
})
.await
.map_err(|e| format!("捕获任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = hwnd;
Err("截图仅支持 Windows".into())
}
}
/// 存入编辑器图片(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(())
}
/// 取出编辑器图片(编辑器窗口加载时调用,取出即清除)
#[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)]
{
tauri::async_runtime::spawn_blocking(move || {
super::capture::copy_png_to_clipboard(&png_base64)
})
.await
.map_err(|e| format!("剪贴板任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = png_base64;
Err("剪贴板仅支持 Windows".into())
}
}
/// 有标注导出:接收 raw RGBA(前端 canvas.getImageData 直传),一次完成 剪贴板+PNG base64。
///
/// 省去前端 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<'_>,
) -> Result<super::CaptureData, String> {
#[cfg(windows)]
{
let body = match request.body() {
tauri::ipc::InvokeBody::Raw(data) => data.clone(),
_ => return Err("需要 raw bodyArrayBuffer".into()),
};
if body.len() < 8 {
return Err("数据不足:缺少尺寸头".into());
}
let width = i32::from_le_bytes([body[0], body[1], body[2], body[3]]);
let height = i32::from_le_bytes([body[4], body[5], body[6], body[7]]);
let rgba = body[8..].to_vec();
tauri::async_runtime::spawn_blocking(move || {
super::capture::compose_copy_rgba(&rgba, width, height)
})
.await
.map_err(|e| format!("合成复制任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = request;
Err("截图仅支持 Windows".into())
}
}
/// 将 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)]
{
super::capture::save_png_to_file(&png_base64, &path)
}
#[cfg(not(windows))]
{
let _ = (png_base64, path);
Err("文件写入仅支持 Windows".to_string())
}
})
.await
.map_err(|e| format!("保存任务失败: {}", e))?
}
// ===== 截图历史缓存:完整 PNG 落盘到应用数据目录(持久化,随历史保留数量清理),内存只保留缩略图 =====
/// 历史根目录(app_data_dir/screenshot/history
fn history_cache_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app
.path()
.app_data_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))?
}
+56
View File
@@ -0,0 +1,56 @@
//! 截图模块
//!
//! 跨平台数据结构定义 + Windows 捕获实现(capture.rs)。
//! 捕获引擎选型见 capture.rs 顶部说明:BitBlt + PrintWindow,不使用 WGC。
use std::sync::Mutex;
use specta::Type;
#[cfg(windows)]
pub mod capture;
pub mod commands;
/// 前端可见的捕获数据
#[derive(serde::Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CaptureData {
pub png_base64: String,
pub width: i32,
pub height: i32,
}
/// 窗口信息(窗口拾取 / 枚举)
#[derive(serde::Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WindowInfo {
pub hwnd: isize,
pub title: String,
pub rect: ScreenRect,
/// DWM 扩展边框矩形(视觉边界,去掉最大化窗口的隐形缩放边框),命中测试用 rect,高亮用 visual_rect
pub visual_rect: Option<ScreenRect>,
}
#[derive(serde::Serialize, Clone, Copy, Type)]
#[serde(rename_all = "camelCase")]
pub struct ScreenRect {
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
}
/// 编辑器图片静态存储(覆盖层裁剪后存入 → 编辑器窗口加载取出)
static EDITOR_IMAGE: Mutex<Option<String>> = Mutex::new(None);
/// 存储编辑器图片(base64 PNG
pub fn set_editor_image(png_base64: String) {
if let Ok(mut g) = EDITOR_IMAGE.lock() {
*g = Some(png_base64);
}
}
/// 取出并清除编辑器图片
pub fn take_editor_image() -> Option<String> {
EDITOR_IMAGE.lock().ok()?.take()
}
+136
View File
@@ -0,0 +1,136 @@
//! 应用启动初始化 —— 从 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();
let server_app = app.handle().clone();
tauri::async_runtime::spawn(async move {
ExtensionServer::start(server_engine, server_port, server_secret, server_app).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 钩子注销快捷键。
// 文件索引 DB 连接改为懒加载(首次搜索/构建时由 commands 中的 ensure_initialized 触发),
// 避免应用启动时即打开 SQLite 连接,降低启动 IO 开销。
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::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>() {
monitor.auto_start_on_launch(app.handle());
}
// 截图快捷键由前端 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] #[tauri::command]
pub async fn fix_snap_background(app: AppHandle) -> Result<(), String> { pub async fn fix_snap_background(app: AppHandle) -> Result<(), String> {
let main_window = app let main_window = app
.get_webview_window("main") .get_webview_window(crate::constants::windows::MAIN)
.ok_or("main window not found")?; .ok_or("main window not found")?;
let hwnd_isize: isize = { let hwnd_isize: isize = {
+163 -140
View File
@@ -20,10 +20,12 @@ use tauri::{
/// 记录窗口最后显示时间,用于失焦防抖(避免显示瞬间因焦点未稳定而被立即隐藏) /// 记录窗口最后显示时间,用于失焦防抖(避免显示瞬间因焦点未稳定而被立即隐藏)
static LAST_SHOW_TIME: Mutex<Option<Instant>> = Mutex::new(None); static LAST_SHOW_TIME: Mutex<Option<Instant>> = Mutex::new(None);
use crate::clipboard::popup::{get_cursor_pos, get_work_area}; /// 保存最近一次右键时计算出的定位参数(物理坐标),供 `tray_menu_ready` 使用
use crate::clipboard::ClipboardManager; /// (x, cursor_y, screen_top, screen_bottom, scale)
use crate::download_engine::DownloadEngine; static LAST_MENU_LAYOUT: Mutex<Option<(f64, f64, f64, f64, f64)>> = Mutex::new(None);
use crate::mihomo_manager::MihomoManager;
use crate::win32_util::{get_work_area, get_monitor_bounds_at_point, get_dpi_for_point};
use crate::mihomo_manager::{MihomoManager, is_pseudo_node};
use crate::monitor_kernel::MonitorKernel; use crate::monitor_kernel::MonitorKernel;
use crate::process_manager::{ProcessManager, ProcessStatus}; use crate::process_manager::{ProcessManager, ProcessStatus};
@@ -44,43 +46,13 @@ pub struct ProxyNodeInfo {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TrayMenuState { pub struct TrayMenuState {
pub proxy_running: bool, pub proxy_running: bool,
pub system_proxy: bool,
pub monitor_running: bool, pub monitor_running: bool,
pub proxy_group: Option<String>, pub proxy_group: Option<String>,
pub proxy_nodes: Vec<ProxyNodeInfo>, pub proxy_nodes: Vec<ProxyNodeInfo>,
pub proxy_current: Option<String>, 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 { fn is_proxy_running(app: &AppHandle) -> bool {
@@ -176,9 +148,29 @@ async fn fetch_proxy_nodes(app: &AppHandle) -> Option<(String, Vec<(String, Opti
// ===== 获取菜单状态 ===== // ===== 获取菜单状态 =====
pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState { /// 基础状态(不含代理节点)。托盘菜单显示不应受 mihomo API 慢/卡死影响,
/// 因此先秒发基础状态让菜单立即出现,代理节点随后异步补充。
fn get_base_tray_state(app: &AppHandle) -> TrayMenuState {
let proxy_running = is_proxy_running(app); let proxy_running = is_proxy_running(app);
let monitor_running = is_monitor_running(app); let monitor_running = is_monitor_running(app);
// 读取 Windows 注册表中的真实系统代理状态(不依赖 settings.json 缓存)
let system_proxy = crate::mihomo_manager::get_system_proxy_windows();
TrayMenuState {
proxy_running,
system_proxy,
monitor_running,
proxy_group: None,
proxy_nodes: vec![],
proxy_current: None,
}
}
/// 完整状态(含代理节点)。可能因调用 mihomo /proxies 而耗时(最长 10s)。
async fn get_full_tray_state(app: &AppHandle) -> TrayMenuState {
let proxy_running = is_proxy_running(app);
let monitor_running = is_monitor_running(app);
// 读取 Windows 注册表中的真实系统代理状态(不依赖 settings.json 缓存)
let system_proxy = crate::mihomo_manager::get_system_proxy_windows();
let (proxy_group, proxy_nodes, proxy_current) = if proxy_running { let (proxy_group, proxy_nodes, proxy_current) = if proxy_running {
match fetch_proxy_nodes(app).await { match fetch_proxy_nodes(app).await {
@@ -201,6 +193,7 @@ pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState {
TrayMenuState { TrayMenuState {
proxy_running, proxy_running,
system_proxy,
monitor_running, monitor_running,
proxy_group, proxy_group,
proxy_nodes, proxy_nodes,
@@ -208,10 +201,15 @@ pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState {
} }
} }
pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState {
get_full_tray_state(app).await
}
// ===== 显示/隐藏托盘菜单窗口 ===== // ===== 显示/隐藏托盘菜单窗口 =====
/// 托盘菜单窗口尺寸(逻辑像素) /// 托盘菜单窗口尺寸(逻辑像素)
const MENU_W: f64 = 260.0; /// 宽度需与前端 TrayMenu.vue 的 MENU_WIDTH 保持一致,避免 resize 时右对齐错位
const MENU_W: f64 = 220.0;
const MENU_H: f64 = 420.0; const MENU_H: f64 = 420.0;
/// 预创建隐藏的托盘菜单窗口(在应用启动时调用)。 /// 预创建隐藏的托盘菜单窗口(在应用启动时调用)。
@@ -247,7 +245,7 @@ pub fn precreate_tray_menu_window(app: &AppHandle) {
{ {
Ok(w) => w, Ok(w) => w,
Err(e) => { Err(e) => {
eprintln!("[tray-menu] 预创建菜单窗口失败: {}", e); crate::logger::log_error("tray", &format!("预创建菜单窗口失败: {}", e));
return; return;
} }
}; };
@@ -257,7 +255,7 @@ pub fn precreate_tray_menu_window(app: &AppHandle) {
win.on_window_event(move |event| { win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event { if let tauri::WindowEvent::Focused(false) = event {
let should_hide = { 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 { match *t {
Some(time) => time.elapsed() > Duration::from_millis(300), Some(time) => time.elapsed() > Duration::from_millis(300),
None => true, None => true,
@@ -269,72 +267,63 @@ pub fn precreate_tray_menu_window(app: &AppHandle) {
} }
}); });
eprintln!("[tray-menu] 菜单窗口已预创建(隐藏渲染)"); crate::logger::log_info("tray", "菜单窗口已预创建(隐藏渲染)");
} }
/// 在鼠标当前位置显示托盘菜单窗口。 /// 右键托盘时调用:计算定位参数、发送状态给前端,但不立即显示窗口。
/// 窗口不存在则创建并显示,已存在则移动到鼠标位置并显示 /// 窗口等待前端测量内容高度后调用 `tray_menu_ready` 才显示,确保底部对齐鼠标点击位置
pub fn show_tray_menu(app: &AppHandle) { ///
let (mx, my) = match get_cursor_pos() { /// `cursor_pos`: 事件报告的鼠标物理坐标。
Some(p) => p, pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64)) {
None => return, let (mx, my) = (cursor_pos.0, cursor_pos.1);
};
// GetCursorPos 在 Per-Monitor DPI V2 下返回物理像素,直接用作 PhysicalPosition // 获取光标所在显示器的完整边界(含任务栏,物理像素)。
let win = app.get_webview_window(TRAY_MENU_LABEL); // 托盘图标位于任务栏上,菜单需在鼠标位置弹出,因此用屏幕边界而非工作区做 clamp,
let scale = win.as_ref() // 否则会被工作区底部(任务栏顶部)截断,导致菜单整体被推到任务栏上方。
.and_then(|w| w.scale_factor().ok()) let (scr_left, scr_top, scr_right, scr_bottom) = get_monitor_bounds_at_point(mx as i32, my as i32)
.unwrap_or(1.0); .unwrap_or((0, 0, 1920, 1080));
let w_phys = (MENU_W * scale) as i32; // 光标所在显示器的 DPI:菜单宽度按物理像素换算
let h_phys = (MENU_H * scale) as i32; 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;
// 托盘位于屏幕右下角,菜单始终出现在鼠标左上方 // 水平:菜单左边缘对齐鼠标 X(向右延伸),超出屏幕右边界则左移(物理坐标)
let x = (mx - w_phys).max(0); let x = mx.max(scr_left as f64).min(scr_right as f64 - menu_w_px);
let y = (my - h_phys - 8).max(0);
// 保存布局参数(全部物理坐标 + scale,供 tray_menu_ready 换算前端上报的逻辑高度)
{
let mut layout = LAST_MENU_LAYOUT.lock().unwrap_or_else(|e| e.into_inner());
*layout = Some((x, my, scr_top as f64, scr_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()); *t = Some(Instant::now());
} }
// 窗口存在(预创建或之前显示过):移动 + 显示 + 发送状态 // 确保窗口存在(兖底)
if let Some(win) = win { if app.get_webview_window(TRAY_MENU_LABEL).is_none() {
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
let _ = win.show();
let _ = win.set_focus();
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);
});
return;
}
// 兜底:窗口未预创建(理论上不会走到,因为 create_tray_menu 已预创建)
precreate_tray_menu_window(app); precreate_tray_menu_window(app);
if let Some(win) = app.get_webview_window(TRAY_MENU_LABEL) {
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
let _ = win.show();
let _ = win.set_focus();
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);
});
} }
}
/// 预创建窗口完成后由前端调用(仅触发状态推送,不显示窗口——预创建模式下窗口保持隐藏)。 // 发送状态给前端(前端测量内容高度后调用 tray_menu_ready 显示窗口)。
pub fn show_window(app: &AppHandle) { // 代理节点拉取默认最长 10s;这里用 1.5s 短超时兜底,避免 mihomo API 卡死时
if app.get_webview_window(TRAY_MENU_LABEL).is_some() { // 菜单迟迟不出现(超时则退回基础状态,节点区留空,后续 refresh 可补)。
let app_clone = app.clone(); let app_clone = app.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = get_tray_menu_state(&app_clone).await; let state = match tokio::time::timeout(
let _ = app_clone.emit("tray-menu-show", state); Duration::from_millis(1500),
get_full_tray_state(&app_clone),
)
.await
{
Ok(s) => s,
Err(_) => get_base_tray_state(&app_clone),
};
let _ = app_clone.emit(crate::constants::events::TRAY_MENU_SHOW, state);
}); });
}
} }
/// 隐藏托盘菜单窗口(不销毁,保留复用) /// 隐藏托盘菜单窗口(不销毁,保留复用)
@@ -347,7 +336,23 @@ pub fn hide_tray_menu(app: &AppHandle) {
/// 刷新菜单状态并发送给前端 /// 刷新菜单状态并发送给前端
async fn refresh_and_emit_state(app: &AppHandle) { async fn refresh_and_emit_state(app: &AppHandle) {
let state = get_tray_menu_state(app).await; 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 的 set_focus 在 Windows 上受前台锁定限制,主窗口被其他应用遮挡时无法到前台;
/// 改用原生 SetForegroundWindow + BringWindowToTop(模拟 Alt 键重置前台锁定)。
pub fn focus_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window(crate::constants::windows::MAIN) {
let _ = window.show();
let _ = window.unminimize();
match window.hwnd() {
Ok(hwnd) => crate::win32_util::force_foreground(hwnd.0 as isize),
Err(_) => {
window.set_focus().ok();
}
}
}
} }
// ===== Tauri 命令 ===== // ===== Tauri 命令 =====
@@ -362,13 +367,13 @@ pub async fn tray_menu_action(
match action.as_str() { match action.as_str() {
"proxy_enable" => { "proxy_enable" => {
if let Err(e) = enable_proxy(&app).await { if let Err(e) = enable_proxy(&app).await {
eprintln!("[tray] 开启代理失败: {}", e); crate::logger::log_error("tray", &format!("开启代理失败: {}", e));
send_notification(&app, "代理启动失败", &e); send_notification(&app, "代理启动失败", &e);
} }
} }
"proxy_disable" => { "proxy_disable" => {
if let Err(e) = disable_proxy(&app).await { if let Err(e) = disable_proxy(&app).await {
eprintln!("[tray] 关闭代理失败: {}", e); crate::logger::log_error("tray", &format!("关闭代理失败: {}", e));
send_notification(&app, "代理关闭失败", &e); send_notification(&app, "代理关闭失败", &e);
} }
} }
@@ -386,29 +391,38 @@ pub async fn tray_menu_action(
} }
} }
} }
"system_proxy_toggle" => {
// 根据注册表当前真实状态切换(不依赖 settings.json 缓存,避免与主界面不同步)
let mihomo = app.state::<MihomoManager>();
if crate::mihomo_manager::get_system_proxy_windows() {
if let Err(e) = mihomo.disable_system_proxy() {
crate::logger::log_error("tray", &format!("关闭系统代理失败: {}", e));
send_notification(&app, "系统代理关闭失败", &e);
}
} else {
if let Err(e) = mihomo.enable_system_proxy() {
crate::logger::log_error("tray", &format!("开启系统代理失败: {}", e));
send_notification(&app, "系统代理开启失败", &e);
}
}
}
"osd_toggle" => { "osd_toggle" => {
let _ = app.emit("tray:toggle-osd", ()); let _ = app.emit(crate::constants::events::TRAY_TOGGLE_OSD, ());
} }
"kernel_restart" => { "kernel_restart" => {
if let Err(e) = restart_kernel(&app).await { 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); send_notification(&app, "Kernel 重启失败", &e);
} }
} }
"download_new" => { "download_new" => {
if let Some(window) = app.get_webview_window("main") { focus_main_window(&app);
window.show().ok(); let _ = app.emit(crate::constants::events::TRAY_NEW_DOWNLOAD, ());
window.set_focus().ok();
}
let _ = app.emit("tray:new-download", ());
hide_tray_menu(&app); hide_tray_menu(&app);
} }
"settings" => { "settings" => {
if let Some(window) = app.get_webview_window("main") { focus_main_window(&app);
window.show().ok(); let _ = app.emit(crate::constants::events::TRAY_OPEN_SETTINGS, ());
window.set_focus().ok();
}
let _ = app.emit("tray:open-settings", ());
hide_tray_menu(&app); hide_tray_menu(&app);
} }
"quit" => { "quit" => {
@@ -433,10 +447,42 @@ pub async fn tray_menu_hide(app: AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
/// 显示已创建的菜单窗口(前端 onMounted 后调用) /// 前端测量内容高度后调用:调整窗口大小、精确定位、然后显示。
/// 这样窗口从一开始就是正确尺寸和位置,不会出现间隙。
#[tauri::command] #[tauri::command]
pub async fn tray_menu_show_window(app: AppHandle) -> Result<(), String> { pub async fn tray_menu_ready(content_height: f64, app: AppHandle) -> Result<(), String> {
show_window(&app); let win = app.get_webview_window(TRAY_MENU_LABEL)
.ok_or("tray-menu window not found")?;
let (x, cursor_y, scr_top, scr_bottom, scale) = {
let layout = LAST_MENU_LAYOUT.lock().unwrap_or_else(|e| e.into_inner());
layout.unwrap_or((0.0, 1040.0, 0.0, 1080.0, 1.0))
};
// 将内容高度限制在合理范围内(前端上报为逻辑像素)
let h = content_height.max(100.0).min(520.0);
// 调整窗口尺寸(物理像素,与物理坐标定位保持一致,避免混合 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,
}));
// 垂直:菜单底部对齐鼠标点击位置(向上弹出,物理坐标)。
// 用屏幕边界而非工作区 clamp,使菜单贴近/覆盖任务栏上的鼠标位置,
// 而不是被工作区底部(任务栏顶部)截断后整体出现在任务栏上方。
let y = (cursor_y - win_h_px).max(scr_top).min(scr_bottom - win_h_px);
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();
Ok(()) Ok(())
} }
@@ -589,7 +635,7 @@ async fn select_proxy_node(app: &AppHandle, group: &str, name: &str) {
send_notification(app, "节点已切换", &format!("{}\n延迟: {}", name, delay_text)); send_notification(app, "节点已切换", &format!("{}\n延迟: {}", name, delay_text));
} }
Err(e) => { Err(e) => {
eprintln!("[tray] 切换节点失败: {}", e); crate::logger::log_error("tray", &format!("切换节点失败: {}", e));
send_notification(app, "切换节点失败", &e); send_notification(app, "切换节点失败", &e);
} }
} }
@@ -641,34 +687,12 @@ fn send_notification(app: &AppHandle, title: &str, message: &str) {
.show(); .show();
} }
// ===== 退出清理 ===== // ===== 退出入口 =====
/// 托盘菜单"退出"调用。
/// 资源清理统一收敛到 RunEvent::ExitRequested(覆盖所有退出路径),此处仅请求退出,
/// 避免清理逻辑双份执行(monitor 的 cleanup 涉及子进程/网络,重复执行有竞态风险)。
pub fn quit_cleanup(app: &AppHandle) { 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); app.exit(0);
} }
@@ -687,18 +711,17 @@ pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> {
button: MouseButton::Left, button: MouseButton::Left,
.. ..
} => { } => {
// 左键:显示主窗口 // 左键:显示主窗口(强制置前,绕过前台锁定)
if let Some(window) = app.get_webview_window("main") { focus_main_window(&app);
window.show().ok();
window.set_focus().ok();
}
} }
TrayIconEvent::Click { TrayIconEvent::Click {
button: MouseButton::Right, button: MouseButton::Right,
position,
.. ..
} => { } => {
// 右键:显示自定义菜单窗口 // 右键:显示自定义菜单窗口(使用事件中的鼠标坐标)
show_tray_menu(&app); let cursor = (position.x, position.y);
show_tray_menu(&app, cursor);
} }
_ => {} _ => {}
} }
+457
View File
@@ -0,0 +1,457 @@
//! 应用自更新模块。
//! 更新源为自建 Gitea`https://gitea.atie.fun/LFeng/Thing` 的 release 资产。
//! - 便携版(无 unins000.exe 且不在 Program Files):下载新 thing.exe → update.bat 覆盖重启
//! - 安装版(NSIS):下载新 setup.exe → 提权静默安装 /S
//! - ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖 {app_data}/monitor/cores/ThingHK.exe
//! mihomo 内核更新继续复用代理模块已有的 GitHub 下载机制,不在此模块处理。
use futures_util::StreamExt;
use serde::Serialize;
use specta::Type;
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Emitter, Manager};
use crate::constants::events::UPDATE_PROGRESS;
/// 发布仓库(Gitea
const GITEA_REPO: &str = "LFeng/Thing";
const GITEA_BASE: &str = "https://gitea.atie.fun";
/// release 中的一个资产
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAsset {
pub name: String,
pub size: u64,
pub browser_download_url: String,
}
/// 检查更新的结果
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheckResult {
pub current_version: String,
pub latest_version: String,
pub has_update: bool,
/// portable | installed
pub install_type: String,
pub release_name: String,
pub release_body: String,
pub assets: Vec<UpdateAsset>,
}
/// 更新进度事件载荷(与内核安装进度同构,独立事件便于 UI 区分)
#[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProgress {
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,
}
// ---------- 版本比较 ----------
/// 解析 vX.Y.Z 为数字元组用于比较;解析失败返回 (0,0,0)
fn parse_version(v: &str) -> (u32, u32, u32) {
let s = v.trim().trim_start_matches('v');
let mut parts = s.split('.');
let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts
.next()
.map(|p| p.chars().take_while(|c| c.is_ascii_digit()).collect::<String>())
.and_then(|p| p.parse().ok())
.unwrap_or(0);
(major, minor, patch)
}
fn version_gt(a: &str, b: &str) -> bool {
parse_version(a) > parse_version(b)
}
// ---------- Gitea API ----------
struct LatestRelease {
tag_name: String,
name: String,
body: String,
assets: Vec<UpdateAsset>,
}
async fn fetch_latest_release() -> Result<LatestRelease, String> {
let url = format!("{}/api/v1/repos/{}/releases/latest", GITEA_BASE, GITEA_REPO);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
let resp = client
.get(&url)
.header("User-Agent", "thing-app")
.send()
.await
.map_err(|e| format!("请求 Gitea API 失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Gitea API 返回 HTTP {}", resp.status()));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("解析 Gitea 响应失败: {}", e))?;
let mut assets = Vec::new();
if let Some(list) = json.get("assets").and_then(|v| v.as_array()) {
for a in list {
if let (Some(name), Some(url)) = (
a.get("name").and_then(|v| v.as_str()),
a.get("browser_download_url").and_then(|v| v.as_str()),
) {
assets.push(UpdateAsset {
name: name.to_string(),
size: a.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
browser_download_url: url.to_string(),
});
}
}
}
Ok(LatestRelease {
tag_name: json.get("tag_name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
name: json.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
body: json.get("body").and_then(|v| v.as_str()).unwrap_or("").to_string(),
assets,
})
}
// ---------- 下载 / 解压 ----------
/// 下载文件到 dest,期间通过 UPDATE_PROGRESS 事件上报进度
async fn download_with_progress(app: &AppHandle, url: &str, dest: &Path) -> Result<(), String> {
let client = reqwest::Client::new();
let resp = 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;
let percent = match total {
Some(t) if t > 0 => ((downloaded as f64 / t as f64) * 100.0) as u8,
_ => 0,
};
if percent >= last_percent + 1 {
last_percent = percent;
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "downloading".into(),
percent,
downloaded_bytes: downloaded,
total_bytes: total,
message: format!(
"已下载 {:.2} MB / {:.2} MB",
downloaded as f64 / 1024.0 / 1024.0,
total.unwrap_or(0) as f64 / 1024.0 / 1024.0
),
},
);
}
}
file.flush().map_err(|e| format!("flush 失败: {}", e))?;
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "downloaded".into(),
percent: 100,
downloaded_bytes: downloaded,
total_bytes: total,
message: "下载完成".into(),
},
);
Ok(())
}
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
fn extract_zip(zip_path: &Path, dest: &Path) -> 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(())
}
// ---------- 安装类型 / ShellExecute ----------
/// 判断当前是便携版还是安装版。
/// NSIS 安装会在程序目录生成 unins000.exeMSI 通常安装到 Program Files。
fn is_installed_version() -> bool {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
if dir.join("unins000.exe").exists() {
return true;
}
let p = dir.to_string_lossy().to_lowercase();
if p.contains("program files") {
return true;
}
}
}
false
}
/// 通过 ShellExecuteW 启动程序/文档(绕过 Job Object,脱离主进程生命周期)
fn shell_execute(verb: &str, file: &Path, params: &str, show: i32) -> Result<(), String> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::ShellExecuteW;
let file_w: Vec<u16> = file.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
let verb_w: Vec<u16> = verb.encode_utf16().chain(std::iter::once(0)).collect();
let params_w: Vec<u16> = params.encode_utf16().chain(std::iter::once(0)).collect();
let res = unsafe {
ShellExecuteW(
0 as isize,
verb_w.as_ptr(),
file_w.as_ptr(),
params_w.as_ptr(),
std::ptr::null(),
show,
)
};
if (res as isize) <= 32 {
return Err(format!("ShellExecuteW 失败 (code={})", res));
}
Ok(())
}
#[cfg(not(windows))]
{
let _ = (verb, file, params, show);
Err("仅支持 Windows".into())
}
}
/// 便携版:写 update.bat 等待主进程退出 → 覆盖 exe → 重新启动
fn apply_portable_update(new_exe: &Path) -> Result<(), String> {
let cur_exe = std::env::current_exe().map_err(|e| format!("获取当前程序路径失败: {}", e))?;
let cur_dir = cur_exe.parent().ok_or("无法确定程序目录".to_string())?;
let bat_path = cur_dir.join("update.bat");
let script = format!(
"@echo off\r\n\
:wait\r\n\
tasklist /FI \"IMAGENAME eq thing.exe\" 2>nul | findstr /i \"thing.exe\" >nul\r\n\
if not errorlevel 1 (\r\n\
ping -n 2 127.0.0.1 >nul\r\n\
goto wait\r\n\
)\r\n\
copy /y \"{new}\" \"{cur}\" >nul\r\n\
if errorlevel 1 exit /b 1\r\n\
start \"\" \"{cur}\"\r\n\
del \"{new}\" >nul 2>nul\r\n\
del \"%~f0\" >nul 2>nul\r\n",
new = new_exe.display(),
cur = cur_exe.display()
);
fs::write(&bat_path, script).map_err(|e| format!("写入更新脚本失败: {}", e))?;
// 用 cmd /c 启动 bat 并隐藏窗口;ShellExecute 启动的进程不属于本进程 Job,
// 主进程退出后 update.bat 仍能继续执行
let windir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
let cmd_exe = Path::new(&windir).join("System32").join("cmd.exe");
shell_execute("open", &cmd_exe, &format!("/c \"{}\"", bat_path.display()), 0)
}
// ---------- 命令 ----------
/// 获取当前应用版本
#[tauri::command]
#[specta::specta]
pub fn app_version(app: AppHandle) -> String {
app.package_info().version.to_string()
}
/// 检查 Gitea 最新 release,返回版本对比与可用资产
#[tauri::command]
#[specta::specta]
pub async fn update_check(app: AppHandle) -> Result<UpdateCheckResult, String> {
let latest = fetch_latest_release().await?;
let latest_version = latest.tag_name.trim_start_matches('v').to_string();
let current_version = app.package_info().version.to_string();
let has_update = version_gt(&latest_version, &current_version);
Ok(UpdateCheckResult {
current_version,
latest_version,
has_update,
install_type: if is_installed_version() { "installed".into() } else { "portable".into() },
release_name: latest.name,
release_body: latest.body,
assets: latest.assets,
})
}
/// 更新应用本体。
/// 便携版:下载 thing_{v}_x64.exe → update.bat 覆盖重启;
/// 安装版:下载 thing_{v}_x64-setup.exe → 提权静默安装 /S。
/// 下载进度通过 UPDATE_PROGRESS 事件上报,调用方返回前会触发应用退出。
#[tauri::command]
#[specta::specta]
pub async fn update_install(app: AppHandle) -> Result<(), String> {
let latest = fetch_latest_release().await?;
let installed = is_installed_version();
let (target_name, target_url) = if installed {
latest
.assets
.iter()
.find(|a| a.name.ends_with("-setup.exe"))
.map(|a| (a.name.clone(), a.browser_download_url.clone()))
.ok_or("未在 release 中找到安装包 (setup.exe)".to_string())?
} else {
latest
.assets
.iter()
.find(|a| a.name.ends_with(".exe") && !a.name.contains("setup"))
.map(|a| (a.name.clone(), a.browser_download_url.clone()))
.ok_or("未在 release 中找到便携版程序 (thing.exe)".to_string())?
};
let temp_dir = std::env::temp_dir().join("thing-update");
fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?;
let dest = temp_dir.join(&target_name);
download_with_progress(&app, &target_url, &dest).await?;
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "applying".into(),
percent: 100,
downloaded_bytes: 0,
total_bytes: None,
message: if installed { "正在启动安装程序...".into() } else { "正在替换程序文件...".into() },
},
);
if installed {
// 提权静默安装 /S;UAC 确认期间主进程已退出,安装器可正常覆盖
shell_execute("runas", &dest, "/S", 0)?;
} else {
apply_portable_update(&dest)?;
}
// 延迟退出,确保 ShellExecute 已拉起子进程
app.exit(0);
Ok(())
}
/// 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件
#[tauri::command]
#[specta::specta]
pub async fn update_thinghk(app: AppHandle) -> Result<(), String> {
let latest = fetch_latest_release().await?;
let asset = latest
.assets
.iter()
.find(|a| a.name.starts_with("thing-hk_") && a.name.ends_with(".zip"))
.ok_or("未在 release 中找到 ThingHK 内核包".to_string())?;
// 停止监控内核(含提权模式的 /shutdown 兜底由前端先停模块),避免 exe 被占用
if let Some(monitor) = app.try_state::<crate::monitor_kernel::MonitorKernel>() {
monitor.stop_subscription(&app).await;
}
if let Some(pm) = app.try_state::<crate::process_manager::ProcessManager>() {
let _ = pm.stop("monitor");
}
// 等待进程退出释放文件句柄
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
let temp_dir = std::env::temp_dir().join("thing-update");
fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?;
let zip_path = temp_dir.join(&asset.name);
download_with_progress(&app, &asset.browser_download_url, &zip_path).await?;
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "extracting".into(),
percent: 95,
downloaded_bytes: 0,
total_bytes: None,
message: "正在解压内核...".into(),
},
);
let extract_dir = temp_dir.join("thinghk_extract");
let _ = fs::remove_dir_all(&extract_dir);
extract_zip(&zip_path, &extract_dir)?;
// 在解压目录中查找 ThingHK.exe
let exe_path = find_thinghk_exe(&extract_dir).ok_or("内核包中未找到 ThingHK.exe".to_string())?;
let app_data = app
.path()
.app_data_dir()
.map_err(|e| format!("获取数据目录失败: {}", e))?;
let cores_dir = app_data.join("monitor").join("cores");
fs::create_dir_all(&cores_dir).map_err(|e| format!("创建内核目录失败: {}", e))?;
fs::copy(&exe_path, cores_dir.join("ThingHK.exe"))
.map_err(|e| format!("覆盖内核文件失败(请确认监控模块已停止): {}", e))?;
// 清理临时文件
let _ = fs::remove_file(&zip_path);
let _ = fs::remove_dir_all(&extract_dir);
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "done".into(),
percent: 100,
downloaded_bytes: 0,
total_bytes: None,
message: "ThingHK 内核更新完成".into(),
},
);
Ok(())
}
fn find_thinghk_exe(dir: &Path) -> Option<PathBuf> {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(found) = find_thinghk_exe(&path) {
return Some(found);
}
} else if path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.eq_ignore_ascii_case("ThingHK.exe"))
.unwrap_or(false)
{
return Some(path);
}
}
}
None
}
+144
View File
@@ -0,0 +1,144 @@
//! 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
}
}
}
/// 获取指定点所在显示器的完整边界(含任务栏),返回 (left, top, right, bottom) 物理像素。
/// 与 get_work_area_at_point 不同,这里用 rcMonitor 而非 rcWork
/// 用于需要在鼠标位置弹出、允许贴近/覆盖任务栏的场景(如托盘菜单)。
#[cfg(windows)]
pub fn get_monitor_bounds_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.rcMonitor;
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 前台锁定限制)。
/// Tauri 的 set_focus 内部调用 SetForegroundWindow,受前台锁定(foreground lock)限制:
/// 本进程不拥有前台时调用会被系统忽略,导致已打开但被遮挡的窗口无法到前台。
/// 先模拟 Alt 键释放以重置前台锁定,再 SetForegroundWindow + BringWindowToTop。
#[cfg(windows)]
pub fn force_foreground(hwnd: isize) {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
keybd_event, KEYEVENTF_KEYUP, VK_MENU,
};
use windows_sys::Win32::UI::WindowsAndMessaging::{BringWindowToTop, SetForegroundWindow};
unsafe {
keybd_event(VK_MENU as u8, 0, KEYEVENTF_KEYUP, 0);
let _ = SetForegroundWindow(hwnd);
let _ = BringWindowToTop(hwnd);
}
}
// ===== 非 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 }
#[cfg(not(windows))]
pub fn get_monitor_bounds_at_point(_x: i32, _y: i32) -> Option<(i32, i32, i32, i32)> { None }
#[cfg(not(windows))]
pub fn force_foreground(_hwnd: isize) {}
+9 -4
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "thing", "productName": "thing",
"version": "0.1.0", "version": "26.8.1",
"identifier": "thing.lfeng.me", "identifier": "thing.lfeng.me",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -12,7 +12,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "thing", "title": "Thing",
"width": 1000, "width": 1000,
"height": 700, "height": 700,
"decorations": false, "decorations": false,
@@ -24,7 +24,7 @@
} }
], ],
"security": { "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": { "bundle": {
@@ -37,6 +37,11 @@
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
], ],
"resources": ["binaries/*", "resources/thing-extension/**/*"] "resources": ["binaries/*", "resources/thing-extension/**/*"],
"windows": {
"nsis": {
"compression": "lzma"
}
}
} }
} }
+82 -26
View File
@@ -1,17 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, shallowRef, computed, watch, type Component } from 'vue' import { ref, onMounted, onUnmounted, shallowRef, computed, watch, type Component } from 'vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import TitleBar from '@/components/layout/TitleBar.vue' import TitleBar from '@/components/layout/TitleBar.vue'
import Sidebar from '@/components/layout/Sidebar.vue' import Sidebar from '@/components/layout/Sidebar.vue'
import ModuleContainer from '@/components/layout/ModuleContainer.vue' import ModuleContainer from '@/components/layout/ModuleContainer.vue'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import { useAppStore } from '@/stores/appStore' import { useAppStore } from '@/stores/appStore'
import { useScreenshotStore } from '@/stores/screenshotStore'
import { useQuickPanelStore } from '@/stores/quickpanelStore'
import { useMonitorStore } from '@/stores/monitorStore'
import { useProcessStore } from '@/stores/processStore'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry' import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module' import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload, pendingOpenSettings } from '@/lib/trayEvents' import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { commands } from '@/lib/bindings'
const appStore = useAppStore() const appStore = useAppStore()
const screenshotStore = useScreenshotStore()
const quickpanelStore = useQuickPanelStore()
const monitorStore = useMonitorStore()
const processStore = useProcessStore()
/** 侧边栏 / 标题栏需要的模块信息(id + name + icon */ /** 侧边栏 / 标题栏需要的模块信息(id + name + icon */
interface NavModule { interface NavModule {
@@ -28,18 +39,13 @@ const toNavModule = (meta: ModuleMeta): NavModule => ({
}) })
/** 上次激活的模块 IDlocalStorage 持久化) */ /** 上次激活的模块 IDlocalStorage 持久化) */
const LAST_MODULE_KEY = 'thing_last_module' const LAST_MODULE_KEY = STORAGE_KEYS.lastModule
const activeModule = ref('') const activeModule = ref('')
const activeComponent = shallowRef<Component | null>(null) const activeComponent = shallowRef<Component | null>(null)
/** 预加载的监控模块组件(用于隐藏预渲染,确保 OSD 在启动时创建) */ /** 模块组件加载中(异步 import 未完成)标志,避免切换期间仍显示上一个模块内容 */
const monitorComponent = shallowRef<Component | null>(null) const moduleLoading = ref(false)
/** 监控模块是否已启用 */
const monitorEnabled = computed(() =>
appStore.modules.find(m => m.id === 'monitor')?.enabled ?? false
)
/** 当前可显示的模块(内置模块 + 已启用的用户模块),按用户排序显示 */ /** 当前可显示的模块(内置模块 + 已启用的用户模块),按用户排序显示 */
const availableModules = computed<NavModule[]>(() => { const availableModules = computed<NavModule[]>(() => {
@@ -61,9 +67,19 @@ const availableModules = computed<NavModule[]>(() => {
}) })
}) })
/** 模块加载请求序号:快速切换模块时丢弃过期加载结果,避免旧组件覆盖新组件 */
let moduleLoadSeq = 0
const loadModule = async (moduleId: string) => { const loadModule = async (moduleId: string) => {
const seq = ++moduleLoadSeq
// 立即清空旧组件并进入加载态,避免异步 import 期间仍渲染上一个模块内容
// (否则 ModuleContainer 以 :key="activeModule" 重挂载旧组件,用户误以为切换失败)
activeComponent.value = null
moduleLoading.value = true
const component = await moduleRegistry.loadComponent(moduleId) const component = await moduleRegistry.loadComponent(moduleId)
// 过期请求(期间用户又切换了模块)直接丢弃,不覆盖 activeComponent 也不触发钩子
if (seq !== moduleLoadSeq) return
activeComponent.value = component activeComponent.value = component
moduleLoading.value = false
// 调用模块的 onActivate 生命周期钩子 // 调用模块的 onActivate 生命周期钩子
const config = moduleRegistry.getConfig(moduleId) const config = moduleRegistry.getConfig(moduleId)
@@ -103,6 +119,8 @@ watch(() => appStore.enabledModules.length, () => {
activeModule.value = fallback activeModule.value = fallback
loadModule(fallback) loadModule(fallback)
} }
// 模块启用/禁用变化时重新同步快速面板命令缓存
quickpanelStore.syncCommands()
}) })
/** 计算启动时应打开的默认模块:优先上次记忆,其次排序第一个 */ /** 计算启动时应打开的默认模块:优先上次记忆,其次排序第一个 */
@@ -127,21 +145,48 @@ const resolveDefaultModule = (): string => {
onMounted(async () => { onMounted(async () => {
await appStore.init().catch(e => console.error('App init error:', e)) await appStore.init().catch(e => console.error('App init error:', e))
// 预加载监控模块组件,用于隐藏预渲染 // 初始化监控 store:订阅后端 monitor-data / monitor-network 等事件,
// 这样即使启动时默认模块不是监控,MonitorModule 的 onMounted 也会执行 // 使 OSD 窗口在应用启动后即可接收数据流,不依赖用户手动打开监控模块。
// 从而在应用启动时自动创建 OSD 窗口(如果 OSD 配置已开启) // init() 幂等:MonitorModule 挂载时再次调用不会重复订阅。
if (monitorEnabled.value) { monitorStore.init()
monitorComponent.value = await moduleRegistry.loadComponent('monitor') // 显式初始化 OSD
} // 使 OSD 窗口在应用启动时创建(若配置已开启),且不依赖监控模块挂载/卸载
monitorStore.initOsd()
const defaultModule = resolveDefaultModule() const defaultModule = resolveDefaultModule()
activeModule.value = defaultModule activeModule.value = defaultModule
loadModule(defaultModule) loadModule(defaultModule)
// 监听托盘菜单事件 // 快速面板:同步命令缓存与设置到 localStorage,供独立窗口读取
// tray:toggle-osd 由 MonitorModule 直接监听(预渲染实例始终挂载) quickpanelStore.syncCommands()
quickpanelStore.syncSettings()
// 初始化文件索引 DB 并恢复增量监听(上次构建过索引时自动恢复,不重建)
commands.quickpanelInitFileIndex().catch(e => console.error('文件索引初始化失败:', e))
// 监听快速面板执行命令事件:显示主窗口 + 切换模块
trayUnlisteners.push( trayUnlisteners.push(
await listen('tray:new-download', () => { await listen<{ moduleId: string }>('quickpanel-execute-command', async (e) => {
// Rust 端强制置前(绕过 Windows 前台锁定,主窗口被遮挡时也能到前台)
try {
await commands.quickpanelFocusMainWindow()
} catch {
// 回退:前端 show + setFocus
const win = getCurrentWindow()
try {
await win.show()
await win.unminimize()
await win.setFocus()
} catch {
/* 忽略窗口操作失败 */
}
}
handleSearch(e.payload.moduleId)
})
)
// 监听托盘菜单事件
// tray:toggle-osd 由 monitorStore.initOsd() 注册的监听处理(与监控模块生命周期解耦)
trayUnlisteners.push(
await listen(EVENTS.trayNewDownload, () => {
// 设置标志位,DownloaderModule 挂载后消费 // 设置标志位,DownloaderModule 挂载后消费
pendingNewDownload.value = true pendingNewDownload.value = true
// 切换到下载模块(如果未启用则切换到设置) // 切换到下载模块(如果未启用则切换到设置)
@@ -151,18 +196,34 @@ onMounted(async () => {
} }
}) })
) )
// 浏览器扩展新增下载:直接切到下载模块的任务列表页(主窗口已由 Rust 端置前)
trayUnlisteners.push( trayUnlisteners.push(
await listen('tray:open-settings', () => { await listen(EVENTS.downloadExtensionAdded, () => {
pendingOpenSettings.value = true const enabledIds = appStore.enabledModules.map(m => m.id)
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) {
pendingShowDownloadTasks.value = true
handleModuleChange('downloader')
}
})
)
trayUnlisteners.push(
await listen(EVENTS.trayOpenSettings, () => {
handleModuleChange('settings') handleModuleChange('settings')
}) })
) )
// 截图导出监听(应用级常驻,确保任何来源的截图都记录到历史)
screenshotStore.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
}) })
const trayUnlisteners: UnlistenFn[] = [] const trayUnlisteners: UnlistenFn[] = []
onUnmounted(() => { onUnmounted(() => {
trayUnlisteners.forEach(fn => fn()) trayUnlisteners.forEach(fn => fn())
screenshotStore.destroyExportListener()
// 释放 OSD 事件监听与配置 watcher
monitorStore.disposeOsd()
// 释放进程状态事件监听
processStore.destroyListener()
}) })
</script> </script>
@@ -176,14 +237,9 @@ onUnmounted(() => {
:active-module="activeModule" :active-module="activeModule"
@change="handleModuleChange" @change="handleModuleChange"
/> />
<ModuleContainer :active-component="activeComponent" :active-module="activeModule" /> <ModuleContainer :active-component="activeComponent" :active-module="activeModule" :loading="moduleLoading" />
</div> </div>
</div> </div>
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" /> <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> </TooltipProvider>
</template> </template>
+43 -2
View File
@@ -1,19 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Component } from 'vue' import type { Component } from 'vue'
import { ref, watch } from 'vue'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
defineProps<{ const props = defineProps<{
activeComponent: Component | null activeComponent: Component | null
activeModule: string activeModule: string
loading: boolean
}>() }>()
const containerRef = ref<HTMLElement | null>(null)
// 切换模块时重置主滚动区位置,避免新模块沿用上一个模块的滚动距离
watch(
() => props.activeModule,
() => {
const viewport = containerRef.value?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]'
)
if (viewport) viewport.scrollTop = 0
}
)
</script> </script>
<template> <template>
<main <main
ref="containerRef"
class="flex-1" class="flex-1"
:style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }" :style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }"
> >
<ScrollArea class="h-full w-full"> <ScrollArea data-main-scroll class="h-full w-full">
<Transition name="fade-slide" mode="out-in"> <Transition name="fade-slide" mode="out-in">
<div <div
v-if="activeComponent" v-if="activeComponent"
@@ -22,6 +39,30 @@ defineProps<{
> >
<component :is="activeComponent" /> <component :is="activeComponent" />
</div> </div>
<div
v-else-if="loading"
key="loading"
class="h-full w-full p-6"
>
<!-- 模块加载骨架撑起画面避免空白闪屏 -->
<div class="h-full max-w-5xl mx-auto space-y-5">
<div class="flex items-center gap-3">
<Skeleton class="h-8 w-40" />
<Skeleton class="h-6 w-24 ml-auto" />
</div>
<div class="grid gap-4 md:grid-cols-2">
<div v-for="n in 4" :key="n" class="rounded-lg border p-5 space-y-4">
<div class="flex items-center justify-between">
<Skeleton class="h-5 w-32" />
<Skeleton class="h-5 w-16" />
</div>
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-5/6" />
<Skeleton class="h-4 w-2/3" />
</div>
</div>
</div>
</div>
<div <div
v-else v-else
key="empty" key="empty"
+6 -6
View File
@@ -31,14 +31,14 @@ const displayModules = computed(() => {
<Button <Button
:variant="activeModule === module.id ? 'default' : 'ghost'" :variant="activeModule === module.id ? 'default' : 'ghost'"
size="icon" size="icon"
class="h-10 w-10 rounded-lg transition-[background-color,color,box-shadow] duration-300" class="size-10 rounded-lg transition-[background-color,color,box-shadow] duration-300"
:class="{ :class="{
'bg-primary text-primary-foreground shadow-md': activeModule === module.id, 'bg-primary text-primary-foreground shadow-md': activeModule === module.id,
'hover:bg-secondary/50': activeModule !== module.id 'hover:bg-secondary/50': activeModule !== module.id
}" }"
@click="emit('change', module.id)" @click="activeModule !== module.id && emit('change', module.id)"
> >
<component :is="getModuleIcon(module.icon)" class="h-5 w-5" /> <component :is="getModuleIcon(module.icon)" class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" class="w-fit"> <TooltipContent side="right" class="w-fit">
@@ -55,14 +55,14 @@ const displayModules = computed(() => {
<Button <Button
:variant="activeModule === 'settings' ? 'default' : 'ghost'" :variant="activeModule === 'settings' ? 'default' : 'ghost'"
size="icon" size="icon"
class="h-10 w-10 rounded-lg transition-[background-color,color,box-shadow] duration-300" class="size-10 rounded-lg transition-[background-color,color,box-shadow] duration-300"
:class="{ :class="{
'bg-primary text-primary-foreground shadow-md': activeModule === 'settings', 'bg-primary text-primary-foreground shadow-md': activeModule === 'settings',
'hover:bg-secondary/50': activeModule !== 'settings' 'hover:bg-secondary/50': activeModule !== 'settings'
}" }"
@click="emit('change', 'settings')" @click="activeModule !== 'settings' && emit('change', 'settings')"
> >
<Settings class="h-5 w-5" /> <Settings class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" class="w-fit"> <TooltipContent side="right" class="w-fit">
+122 -15
View File
@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { Search, Settings, ChevronRight } from '@lucide/vue' import { Search, Settings, ChevronRight, ArrowUp, Check, Loader2 } from '@lucide/vue'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import { useSearchStore, type SearchItem } from '@/stores/searchStore' import { useSearchStore, type SearchItem } from '@/stores/searchStore'
import { useModuleTabsStore } from '@/stores/moduleTabsStore' import { useModuleTabsStore } from '@/stores/moduleTabsStore'
// 复用快速面板匹配引擎(支持拼音/子序列模糊匹配)
import { getTextForms, bestScore } from '@/modules/quickpanel/engine'
const tabsStore = useModuleTabsStore() const tabsStore = useModuleTabsStore()
@@ -23,10 +25,12 @@ const searchStore = useSearchStore()
const filteredModules = computed(() => { const filteredModules = computed(() => {
if (!searchQuery.value.trim()) return [] if (!searchQuery.value.trim()) return []
const query = searchQuery.value.toLowerCase() const query = searchQuery.value.trim()
return props.modules.filter(m => return props.modules
m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query) .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(() => { const searchResults = computed(() => {
@@ -38,6 +42,11 @@ const hasSearchContent = computed(() => {
return searchQuery.value.trim().length > 0 return searchQuery.value.trim().length > 0
}) })
/** 模块 id → 名称 查找表(用于搜索结果中标注所属模块) */
const moduleNameById = computed(() => {
return new Map(props.modules.map(m => [m.id, m.name]))
})
const handleSearchSelect = (moduleId: string) => { const handleSearchSelect = (moduleId: string) => {
emit('search', moduleId) emit('search', moduleId)
searchQuery.value = '' searchQuery.value = ''
@@ -46,6 +55,10 @@ const handleSearchSelect = (moduleId: string) => {
const handleSettingSelect = (item: SearchItem) => { const handleSettingSelect = (item: SearchItem) => {
emit('search', item.moduleId) emit('search', item.moduleId)
// 记录待跳转 tab:模块挂载后由 useModuleTabs 自动切换(模块已挂载时同样生效)
if (item.tab) {
tabsStore.setPendingTab(item.moduleId, item.tab)
}
if (item.action) { if (item.action) {
item.action() item.action()
} }
@@ -68,6 +81,7 @@ const minimize = async () => {
// 窗口最大化状态:切换最大化/还原图标 // 窗口最大化状态:切换最大化/还原图标
const isMaximized = ref(false) const isMaximized = ref(false)
let unlistenMaximize: (() => void) | null = null let unlistenMaximize: (() => void) | null = null
let unlistenFocus: (() => void) | null = null
const maximize = async () => { const maximize = async () => {
await tauriWindow?.toggleMaximize() await tauriWindow?.toggleMaximize()
@@ -118,7 +132,8 @@ const close = async () => {
} }
if (tauriWindow) { if (tauriWindow) {
tauriWindow.onFocusChanged(({ payload: focused }) => { // 保存 unlistenonUnmounted 时释放(onFocusChanged 返回 Promise<UnlistenFn>
void tauriWindow.onFocusChanged(({ payload: focused }) => {
if (focused) { if (focused) {
hoverSuppressed.value = true hoverSuppressed.value = true
if (document.activeElement instanceof HTMLElement) { if (document.activeElement instanceof HTMLElement) {
@@ -131,12 +146,56 @@ if (tauriWindow) {
} else { } else {
hoverSuppressed.value = true hoverSuppressed.value = true
} }
}).then(fn => {
unlistenFocus = fn
}) })
} }
// 初始时窗口已显示,恢复 hover // 初始时窗口已显示,恢复 hover
restoreHover(300) restoreHover(300)
// ===== 回到顶部按钮 =====
const showScrollTop = ref(false)
let scrollViewport: HTMLElement | null = null
let rafId: number | null = null
const handleMainScroll = () => {
if (!scrollViewport) return
showScrollTop.value = scrollViewport.scrollTop > 200
}
const scrollToTop = () => {
if (!scrollViewport) return
const start = scrollViewport.scrollTop
if (start === 0) return
// 物理变速滚动:cubic ease-out,先快后慢
const duration = 500
const startTime = performance.now()
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3)
if (rafId !== null) cancelAnimationFrame(rafId)
const animate = (now: number) => {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
scrollViewport!.scrollTop = start * (1 - easeOutCubic(progress))
if (progress < 1) {
rafId = requestAnimationFrame(animate)
} else {
rafId = null
}
}
rafId = requestAnimationFrame(animate)
}
const initScrollListener = () => {
scrollViewport = document.querySelector<HTMLElement>(
'[data-main-scroll] [data-reka-scroll-area-viewport]'
)
if (scrollViewport) {
scrollViewport.addEventListener('scroll', handleMainScroll, { passive: true })
handleMainScroll()
}
}
onMounted(async () => { onMounted(async () => {
if (tauriWindow) { if (tauriWindow) {
try { try {
@@ -152,12 +211,17 @@ onMounted(async () => {
invoke('fix_snap_background').catch(() => {}) invoke('fix_snap_background').catch(() => {})
}, 1000) }, 1000)
} }
// 等待模块容器渲染后绑定滚动监听
nextTick(initScrollListener)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('mousemove', handleFirstMouseMove) window.removeEventListener('mousemove', handleFirstMouseMove)
if (restoreHoverTimer) clearTimeout(restoreHoverTimer) if (restoreHoverTimer) clearTimeout(restoreHoverTimer)
if (unlistenMaximize) unlistenMaximize() if (unlistenMaximize) unlistenMaximize()
if (unlistenFocus) unlistenFocus()
if (scrollViewport) scrollViewport.removeEventListener('scroll', handleMainScroll)
if (rafId !== null) cancelAnimationFrame(rafId)
}) })
const handleBlur = () => { const handleBlur = () => {
@@ -178,11 +242,12 @@ const handleBlur = () => {
> >
<span class="font-semibold text-sm">Thing</span> <span class="font-semibold text-sm">Thing</span>
<!-- 浮动标签切换器模块内 TabsList 滚出可视区时显示 --> <!-- 浮动标签切换器 + 回到顶部按钮 -->
<div class="flex items-center gap-1 ml-2 pointer-events-auto">
<Transition name="floating-tabs"> <Transition name="floating-tabs">
<div <div
v-if="tabsStore.floatingVisible && tabsStore.tabs.length > 0" v-if="tabsStore.floatingVisible && tabsStore.tabs.length > 0"
class="flex items-center gap-0.5 ml-2 pointer-events-auto" class="flex items-center gap-0.5"
> >
<button <button
v-for="tab in tabsStore.tabs" v-for="tab in tabsStore.tabs"
@@ -200,11 +265,36 @@ const handleBlur = () => {
</button> </button>
</div> </div>
</Transition> </Transition>
<Transition name="floating-tabs">
<button
v-if="showScrollTop"
class="flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
@click="scrollToTop"
@mousedown.stop
>
<ArrowUp class="h-3.5 w-3.5" />
</button>
</Transition>
</div>
<div class="flex-1"></div> <div class="flex-1"></div>
<!-- 保存设置按钮当前模块在设置 tab 且注册了保存函数时显示从右向左弹出 -->
<Transition name="save-btn">
<button
v-if="tabsStore.saveVisible"
class="flex items-center gap-1.5 h-7 px-3 mr-1 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-60 disabled:pointer-events-none transition-colors pointer-events-auto"
:disabled="tabsStore.saving"
@click="tabsStore.runSave"
@mousedown.stop
>
<Loader2 v-if="tabsStore.saving" class="size-3.5 animate-spin" />
<Check v-else class="size-3.5" />
保存设置
</button>
</Transition>
<div class="relative max-w-xs mr-3 pointer-events-auto"> <div class="relative max-w-xs mr-3 pointer-events-auto">
<Search <Search
class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground"
/> />
<Input <Input
v-model="searchQuery" v-model="searchQuery"
@@ -242,14 +332,19 @@ const handleBlur = () => {
class="w-full px-3 py-2 text-left text-sm hover:bg-accent transition-colors flex items-center gap-2" class="w-full px-3 py-2 text-left text-sm hover:bg-accent transition-colors flex items-center gap-2"
@click="handleSettingSelect(item)" @click="handleSettingSelect(item)"
> >
<Settings class="h-4 w-4 text-muted-foreground" /> <Settings class="size-4 text-muted-foreground shrink-0" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="truncate">{{ item.title }}</span> <span class="truncate">{{ item.title }}</span>
<span class="shrink-0 text-[10px] text-muted-foreground/70 px-1 py-px rounded bg-muted">
{{ moduleNameById.get(item.moduleId) || item.moduleId }}
</span>
</div>
<span v-if="item.description" class="block text-xs text-muted-foreground truncate"> <span v-if="item.description" class="block text-xs text-muted-foreground truncate">
{{ item.description }} {{ item.description }}
</span> </span>
</div> </div>
<ChevronRight class="h-4 w-4 text-muted-foreground" /> <ChevronRight class="size-4 text-muted-foreground shrink-0" />
</button> </button>
</template> </template>
@@ -264,7 +359,7 @@ const handleBlur = () => {
<div class="flex items-center pointer-events-auto" :class="{ 'hover-suppressed': hoverSuppressed }"> <div class="flex items-center pointer-events-auto" :class="{ 'hover-suppressed': hoverSuppressed }">
<button <button
class="h-10 w-10 flex items-center justify-center hover:bg-foreground/5 transition-colors rounded-sm" class="size-10 flex items-center justify-center hover:bg-foreground/5 transition-colors rounded-sm"
@click="minimize" @click="minimize"
@mousedown.stop @mousedown.stop
> >
@@ -275,7 +370,7 @@ const handleBlur = () => {
</button> </button>
<button <button
id="titlebar-maximize" id="titlebar-maximize"
class="h-10 w-10 flex items-center justify-center transition-colors rounded-sm titlebar-maximize-btn" class="size-10 flex items-center justify-center transition-colors rounded-sm titlebar-maximize-btn"
@click="maximize" @click="maximize"
@mousedown.stop @mousedown.stop
> >
@@ -292,7 +387,7 @@ const handleBlur = () => {
</svg> </svg>
</button> </button>
<button <button
class="h-10 w-10 flex items-center justify-center hover:bg-red-500 hover:text-white text-foreground transition-colors rounded-sm" class="size-10 flex items-center justify-center hover:bg-red-500 hover:text-white text-foreground transition-colors rounded-sm"
@click="close" @click="close"
@mousedown.stop @mousedown.stop
> >
@@ -310,6 +405,7 @@ const handleBlur = () => {
.hover-suppressed button:hover, .hover-suppressed button:hover,
.hover-suppressed button.is-hovered { .hover-suppressed button.is-hovered {
background-color: transparent !important; background-color: transparent !important;
color: var(--foreground) !important;
} }
/* 最大化按钮:snap-layout 插件的原生子窗口遮挡了指针事件, /* 最大化按钮:snap-layout 插件的原生子窗口遮挡了指针事件,
@@ -328,4 +424,15 @@ const handleBlur = () => {
opacity: 0; opacity: 0;
transform: translateX(-12px); transform: translateX(-12px);
} }
/* 保存设置按钮进出动画:淡入 + 从右侧滑入(从右向左弹出) */
.save-btn-enter-active,
.save-btn-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.save-btn-enter-from,
.save-btn-leave-to {
opacity: 0;
transform: translateX(12px);
}
</style> </style>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty"
:class="cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12',
props.class,
)"
>
<slot />
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty-content"
:class="cn(
'flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm',
props.class,
)"
>
<slot />
</div>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p
data-slot="empty-description"
:class="cn(
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
$attrs.class ?? '',
)"
>
<slot />
</p>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty-header"
:class="cn(
'flex max-w-sm flex-col items-center gap-2 text-center',
props.class,
)"
>
<slot />
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { EmptyMediaVariants } from "."
import { cn } from "@/lib/utils"
import { emptyMediaVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
variant?: EmptyMediaVariants["variant"]
}>()
</script>
<template>
<div
data-slot="empty-icon"
:data-variant="variant"
:class="cn(emptyMediaVariants({ variant }), props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty-title"
:class="cn('text-lg font-medium tracking-tight', props.class)"
>
<slot />
</div>
</template>
+26
View File
@@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Empty } from "./Empty.vue"
export { default as EmptyContent } from "./EmptyContent.vue"
export { default as EmptyDescription } from "./EmptyDescription.vue"
export { default as EmptyHeader } from "./EmptyHeader.vue"
export { default as EmptyMedia } from "./EmptyMedia.vue"
export { default as EmptyTitle } from "./EmptyTitle.vue"
export const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type EmptyMediaVariants = VariantProps<typeof emptyMediaVariants>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="skeleton"
:class="cn('animate-pulse rounded-md bg-muted', props.class)"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Skeleton } from "./Skeleton.vue"
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { SliderRootEmits, SliderRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SliderRange, SliderRoot, SliderThumb, SliderTrack, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SliderRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<SliderRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SliderRoot
v-slot="{ modelValue }"
data-slot="slider"
:class="cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
props.class,
)"
v-bind="forwarded"
>
<SliderTrack
data-slot="slider-track"
class="bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
>
<SliderRange
data-slot="slider-range"
class="bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
/>
</SliderTrack>
<SliderThumb
v-for="(_, key) in modelValue"
:key="key"
data-slot="slider-thumb"
class="bg-white border-primary ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
</SliderRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Slider } from "./Slider.vue"
+1 -1
View File
@@ -24,7 +24,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
data-slot="switch" data-slot="switch"
v-bind="forwarded" v-bind="forwarded"
:class="cn( :class="cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50', 'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer hover:data-[state=checked]:bg-primary/90 hover:data-[state=unchecked]:bg-input/70 active:scale-95',
props.class, props.class,
)" )"
> >
+1 -1
View File
@@ -24,7 +24,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<TooltipContent <TooltipContent
data-slot="tooltip-content" data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }" v-bind="{ ...forwarded, ...$attrs }"
:class="cn('bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance', props.class)" :class="cn('bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit max-w-[calc(100vw-2rem)] rounded-md px-3 py-1.5 text-xs', props.class)"
> >
<slot /> <slot />
+604
View File
@@ -0,0 +1,604 @@
// 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 = {
/** 获取当前应用版本 */
appVersion: () => __TAURI_INVOKE<string>("app_version"),
/** 检查 Gitea 最新 release,返回版本对比与可用资产 */
updateCheck: () => __TAURI_INVOKE<UpdateCheckResult>("update_check"),
/**
* 更新应用本体。
* 便携版:下载 thing_{v}_x64.exe → update.bat 覆盖重启;
* 安装版:下载 thing_{v}_x64-setup.exe → 提权静默安装 /S。
* 下载进度通过 UPDATE_PROGRESS 事件上报,调用方返回前会触发应用退出。
*/
updateInstall: () => __TAURI_INVOKE<null>("update_install"),
/** 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件 */
updateThinghk: () => __TAURI_INVOKE<null>("update_thinghk"),
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"),
/**
* 初始化文件索引数据库(应用启动时调用)。
* 若存在上次构建的索引(last_built_dirs 非空),自动恢复 notify 增量监听,
* 无需重建即可继续自动同步文件变更。
*/
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 等显示自身窗口)
* 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
*/
quickpanelRunSystemCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_system_command", { command, args }),
/** 列出目录下的压缩包文件(供批量解压面板使用)。 */
quickpanelListArchives: (dir: string) => __TAURI_INVOKE<ArchiveInfo[]>("quickpanel_list_archives", { dir }),
/** 列出目录下的全部条目(供批量重命名/删除面板使用,不含子目录递归)。 */
quickpanelListDir: (dir: string) => __TAURI_INVOKE<FileEntry[]>("quickpanel_list_dir", { dir }),
/**
* 批量解压。`files` 为压缩包路径列表,`dest_dir` 为目标目录,
* `password` 为统一解压密码(可空),`into_subfolder` 是否解压到同名子文件夹。
* 每完成一个文件通过 `quickpanel-extract-progress` 事件推送进度。
*/
quickpanelBatchExtract: (files: string[], destDir: string, password: string | null, intoSubfolder: boolean) => __TAURI_INVOKE<ExtractResult[]>("quickpanel_batch_extract", { files, destDir, password, intoSubfolder }),
/**
* 正则批量重命名预览:对每个文件名应用 `pattern → replacement`
* 仅返回有匹配的文件,`newName` 为替换结果。
*/
quickpanelPreviewRename: (files: string[], pattern: string, replacement: string) => __TAURI_INVOKE<RenamePreview[]>("quickpanel_preview_rename", { files, pattern, replacement }),
/** 执行重命名。同一目录下若目标已存在则跳过该项。 */
quickpanelApplyRename: (items: RenameItem[]) => __TAURI_INVOKE<RenameResult[]>("quickpanel_apply_rename", { items }),
/**
* 批量删除文件/目录。`force=false` 时移动至回收站;`force=true` 时先递归清除
* 只读属性再永久删除(可绕过只读/部分占用导致的删除失败,但被其他进程真正
* 锁定的文件仍会失败并返回原因)。
*/
quickpanelDeleteFiles: (paths: string[], force: boolean) => __TAURI_INVOKE<DeleteResult[]>("quickpanel_delete_files", { paths, force }),
/**
* 显示主窗口并强制置为前台。
* Tauri 的 set_focus 在 Windows 上受前台锁定限制,主窗口被其他应用遮挡时无法到前台;
* 改用原生 SetForegroundWindow + BringWindowToTop(模拟 Alt 键重置前台锁定)。
*/
quickpanelFocusMainWindow: () => __TAURI_INVOKE<null>("quickpanel_focus_main_window"),
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"),
/**
* 注册(或切换)贴图全局快捷键。传入空字符串则禁用快捷键。
* 按下时 emit 'screenshot-pin-shortcut',由前端切换贴图窗口显示/隐藏。
*/
screenshotRegisterPinShortcut: (shortcut: string) => __TAURI_INVOKE<null>("screenshot_register_pin_shortcut", { shortcut }),
/** 注销贴图全局快捷键 */
screenshotUnregisterPinShortcut: () => __TAURI_INVOKE<null>("screenshot_unregister_pin_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 ArchiveInfo = {
name: string,
path: string,
size: number,
};
/** 前端可见的捕获数据 */
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 DeleteResult = {
name: string,
path: string,
ok: boolean,
error: 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 ExtractResult = {
name: string,
path: string,
ok: boolean,
error: string,
};
export type FileEntry = {
name: string,
path: string,
isDir: boolean,
size: number,
};
/** 单个文件记录(返回给前端) */
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 RenameItem = {
path: string,
oldName: string,
newName: string,
};
export type RenamePreview = {
path: string,
oldName: string,
newName: string,
error: string,
};
export type RenameResult = {
oldName: string,
newName: string,
ok: boolean,
error: string,
};
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";
/** release 中的一个资产 */
export type UpdateAsset = {
name: string,
size: number,
browserDownloadUrl: string,
};
/** 检查更新的结果 */
export type UpdateCheckResult = {
currentVersion: string,
latestVersion: string,
hasUpdate: boolean,
/** portable | installed */
installType: string,
releaseName: string,
releaseBody: string,
assets: UpdateAsset[],
};
/** 窗口信息(窗口拾取 / 枚举) */
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
}
+84
View File
@@ -0,0 +1,84 @@
/**
* 全局常量集中定义。
* 窗口 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',
screenshotPin: 'screenshot-pin',
} 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',
quickpanelExtractProgress: 'quickpanel-extract-progress',
// 截图
screenshotBegin: 'screenshot-begin',
screenshotOverlayReady: 'screenshot-overlay-ready',
screenshotShortcut: 'screenshot-shortcut',
screenshotPinShortcut: 'screenshot-pin-shortcut',
screenshotPinReady: 'screenshot-pin-ready',
screenshotPinShow: 'screenshot-pin-show',
screenshotExported: 'screenshot-exported',
// 内核安装进度
kernelInstallProgress: 'kernel-install-progress',
// 应用更新进度
updateProgress: 'update-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',
/** 浏览器扩展通过 HTTP API 新增下载(置前主窗口并跳到下载画面) */
downloadExtensionAdded: 'download-extension-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',
quickpanelPwdHistory: 'thing_quickpanel_pwd_history',
quickpanelPwdFavs: 'thing_quickpanel_pwd_favs',
quickpanelRenameMatchHistory: 'thing_quickpanel_rename_match_history',
quickpanelRenameMatchFavs: 'thing_quickpanel_rename_match_favs',
quickpanelRenameReplaceHistory: 'thing_quickpanel_rename_replace_history',
quickpanelRenameReplaceFavs: 'thing_quickpanel_rename_replace_favs',
quickpanelDeleteFilterHistory: 'thing_quickpanel_delete_filter_history',
quickpanelDeleteFilterFavs: 'thing_quickpanel_delete_filter_favs',
currencyRates: 'thing_quickpanel_currency_rates',
monitorOsdConfig: 'thing_monitor_osd_config',
screenshotHistory: 'thing_screenshot_history',
screenshotPinIndex: 'thing_screenshot_pin_index',
} as const
+3 -3
View File
@@ -86,19 +86,19 @@ export async function getLogs(
level?: LogLevel, level?: LogLevel,
limit?: number, limit?: number,
): Promise<LogEntry[]> { ): Promise<LogEntry[]> {
return invoke('get_logs', { module, level, limit }) return invoke('log_list', { module, level, limit })
} }
/** /**
* 清空所有日志文件。 * 清空所有日志文件。
*/ */
export async function clearLogs(): Promise<void> { export async function clearLogs(): Promise<void> {
return invoke('clear_logs') return invoke('log_clear')
} }
/** /**
* 获取日志系统信息(目录、文件列表、空间占用)。 * 获取日志系统信息(目录、文件列表、空间占用)。
*/ */
export async function getLogInfo(): Promise<LogInfo> { export async function getLogInfo(): Promise<LogInfo> {
return invoke('get_log_info') return invoke('log_info_state')
} }
+2 -2
View File
@@ -11,5 +11,5 @@ import { ref } from 'vue'
/** 待打开新建下载对话框(由托盘"新建下载"触发) */ /** 待打开新建下载对话框(由托盘"新建下载"触发) */
export const pendingNewDownload = ref(false) export const pendingNewDownload = ref(false)
/** 待切换到设置模块(由托盘"常规设置"触发 */ /** 待切换到下载任务列表页(由浏览器扩展新增下载触发,不弹对话框直接看任务 */
export const pendingOpenSettings = ref(false) export const pendingShowDownloadTasks = ref(false)
@@ -12,12 +12,15 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
* ```ts * ```ts
* // 模块 <script setup> 顶部 * // 模块 <script setup> 顶部
* const activeTab = ref('overview') * const activeTab = ref('overview')
* const tabsListRef = useModuleTabs(activeTab, [ * const tabsListRef = useModuleTabs('proxy', activeTab, [
* { value: 'overview', label: '概览' }, * { value: 'overview', label: '概览' },
* { value: 'settings', label: '设置' } * { value: 'settings', label: '设置' }
* ]) * ])
* ``` * ```
* *
* id
* moduleTabsStore tabpendingTab
*
* ```vue * ```vue
* <!-- 模板中给 TabsList 包一层带 ref 的 div --> * <!-- 模板中给 TabsList 包一层带 ref 的 div -->
* <div ref="tabsListRef"> * <div ref="tabsListRef">
@@ -29,7 +32,8 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
* 1. onMounted moduleTabsStoreTitleBar * 1. onMounted moduleTabsStoreTitleBar
* 2. IntersectionObserver TabsList rootMargin TitleBar * 2. IntersectionObserver TabsList rootMargin TitleBar
* 3. watch activeTab store.activeTab * 3. watch activeTab store.activeTab
* 4. onUnmounted observer * 4. tab
* 5. onUnmounted observer
* *
* ## * ##
* - TitleBar 40px (h-10)composable 44px * - TitleBar 40px (h-10)composable 44px
@@ -37,6 +41,7 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
* - composable onUnmounted * - composable onUnmounted
*/ */
export function useModuleTabs( export function useModuleTabs(
moduleId: string,
activeTab: Ref<string>, activeTab: Ref<string>,
tabs: ModuleTab[] tabs: ModuleTab[]
): Ref<HTMLElement | null> { ): Ref<HTMLElement | null> {
@@ -45,6 +50,15 @@ export function useModuleTabs(
let observer: IntersectionObserver | null = null let observer: IntersectionObserver | null = null
/** 应用待跳转 tab(若属于当前模块的 tab 列表) */
const applyPendingTab = () => {
const pending = tabsStore.consumePendingTab(moduleId)
if (pending && tabs.some(t => t.value === pending)) {
activeTab.value = pending
tabsStore.setActiveTab(pending)
}
}
const setupObserver = () => { const setupObserver = () => {
const el = tabsListRef.value const el = tabsListRef.value
if (!el || observer) return if (!el || observer) return
@@ -75,10 +89,19 @@ export function useModuleTabs(
} }
}) })
// 模块已挂载时(搜索结果选中同一模块),pendingTab 变化 → 直接切换 tab
watch(() => tabsStore.pendingTab, (p) => {
if (p?.moduleId === moduleId) {
applyPendingTab()
}
})
onMounted(async () => { onMounted(async () => {
tabsStore.registerTabs(tabs, activeTab.value) tabsStore.registerTabs(tabs, activeTab.value)
await nextTick() await nextTick()
setupObserver() setupObserver()
// 搜索导航跳转:模块刚挂载,消费待跳转 tab
applyPendingTab()
}) })
onUnmounted(() => { onUnmounted(() => {
+41 -19
View File
@@ -1,4 +1,4 @@
import { createApp } from 'vue' import { createApp, type Component } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import './style.css' import './style.css'
import 'vue-sonner/style.css' import 'vue-sonner/style.css'
@@ -6,6 +6,9 @@ import 'vue-sonner/style.css'
import { createLogger } from './lib/logger' import { createLogger } from './lib/logger'
const logger = createLogger('main') const logger = createLogger('main')
// 禁用 WebView 默认右键菜单(桌面应用体验,主窗口与独立窗口共用)
document.addEventListener('contextmenu', (e) => e.preventDefault())
// 全局未捕获异常日志 // 全局未捕获异常日志
window.addEventListener('error', (event) => { window.addEventListener('error', (event) => {
logger.error(`全局JS错误: ${event.message} @ ${event.filename}:${event.lineno}`) logger.error(`全局JS错误: ${event.message} @ ${event.filename}:${event.lineno}`)
@@ -15,26 +18,32 @@ window.addEventListener('unhandledrejection', (event) => {
logger.error(`未处理的Promise拒绝: ${event.reason}`) logger.error(`未处理的Promise拒绝: ${event.reason}`)
}) })
// ===== OSD 窗口模式检测 ===== // ===== 独立窗口模式 =====
// 通过 URL hash 识别:#osd-overlay(悬浮窗)/ #clipboard-popup(剪贴板快捷弹窗)/ #tray-menu(托盘菜单) // 通过 URL hash 识别独立窗口#osd-overlay / #clipboard-popup / #quick-panel / #tray-menu / #screenshot-overlay / #screenshot-editor
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块 // 这些窗口是精简的独立 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')],
['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')],
]
const winHash = window.location.hash const winHash = window.location.hash
if (winHash === '#osd-overlay') {
logger.info(`OSD 窗口启动: ${winHash}`) // #screenshot-overlay 带窗口号参数(多屏),按前缀匹配;其余精确匹配
void import('./modules/monitor/OsdWindow.vue').then(({ default: OsdWindow }) => { const matched = standaloneWindowApps.find(([hash]) =>
const app = createApp(OsdWindow) hash === '#screenshot-overlay' ? winHash.startsWith(hash) : winHash === hash
app.mount('#app') )
})
} else if (winHash === '#clipboard-popup') { if (matched) {
logger.info(`剪贴板弹窗窗口启动: ${winHash}`) const [, label, loader] = matched
void import('./modules/clipboard/ClipboardPopup.vue').then(({ default: ClipboardPopup }) => { logger.info(`${label}窗口启动: ${winHash}`)
const app = createApp(ClipboardPopup) void loader().then(({ default: Comp }) => {
app.mount('#app') const app = createApp(Comp)
})
} else if (winHash === '#tray-menu') {
logger.info(`托盘菜单窗口启动: ${winHash}`)
void import('./modules/tray/TrayMenu.vue').then(({ default: TrayMenu }) => {
const app = createApp(TrayMenu)
app.mount('#app') app.mount('#app')
}) })
} else { } else {
@@ -71,6 +80,19 @@ if (winHash === '#osd-overlay') {
void import('./stores/processStore').then(({ useProcessStore }) => { void import('./stores/processStore').then(({ useProcessStore }) => {
useProcessStore().initListener().catch(e => console.error('Process listener init error:', e)) useProcessStore().initListener().catch(e => console.error('Process listener init error:', e))
}) })
// 截图模块:加载设置 + 恢复持久化历史 + 预创建常驻覆盖层窗口 + 监听导出事件(历史/自动保存)+ 注册并监听全局快捷键(截图/贴图)
void import('./stores/screenshotStore').then(({ useScreenshotStore }) => {
const screenshotStore = useScreenshotStore()
screenshotStore.loadSettings()
screenshotStore.loadHistory()
screenshotStore.initOverlay().catch(e => console.error('Screenshot overlay init error:', e))
screenshotStore.initExportListener().catch(e => console.error('Screenshot export listener init error:', e))
screenshotStore.initShortcutListener().catch(e => console.error('Screenshot shortcut listener init error:', e))
screenshotStore.initShortcutRegistration().catch(e => console.error('Screenshot shortcut registration init error:', e))
screenshotStore.initPinShortcutListener().catch(e => console.error('Screenshot pin shortcut listener init error:', e))
screenshotStore.initPinShortcutRegistration().catch(e => console.error('Screenshot pin shortcut registration init error:', e))
})
}) })
} }
+247 -64
View File
@@ -1,14 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { import {
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon, ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Check, Keyboard, FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Keyboard,
ChevronLeft, ChevronRight,
} from '@lucide/vue' } from '@lucide/vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useClipboardStore, type ClipboardItem, type ClipboardKind } from '@/stores/clipboardStore' 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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -22,11 +23,19 @@ import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
import { Skeleton } from '@/components/ui/skeleton'
import {
Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle,
} from '@/components/ui/empty'
const store = useClipboardStore() const store = useClipboardStore()
const activeTab = ref('history') const activeTab = ref('history')
const tabsListRef = useModuleTabs(activeTab, [ const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs('clipboard', activeTab, [
{ value: 'history', label: '历史' }, { value: 'history', label: '历史' },
{ value: 'pinned', label: '固定' }, { value: 'pinned', label: '固定' },
{ value: 'settings', label: '设置' }, { value: 'settings', label: '设置' },
@@ -66,8 +75,14 @@ const gotoPage = async (p: number) => {
// 详情弹窗 // 详情弹窗
const detailOpen = ref(false) const detailOpen = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const detail = ref<ReturnType<typeof Object> & { content?: string | null; imageBase64?: string | null; kind?: string } | null>(null) const detail = ref<ClipboardItemDetail | null>(null)
const openDetail = async (item: ClipboardItem) => { const openDetail = async (item: ClipboardItem) => {
// reka-ui Dialog 打开时会把当前活动元素记为 triggerElement,关闭时对其无 preventScroll 地 focus
// 导致历史列表的 ScrollAreaViewport(tabindex=0) 被聚焦并滚回顶部。打开前 blur,避免记录滚动容器。
const active = document.activeElement
if (active instanceof HTMLElement) {
active.blur()
}
detailOpen.value = true detailOpen.value = true
detailLoading.value = true detailLoading.value = true
detail.value = null detail.value = null
@@ -76,6 +91,15 @@ const openDetail = async (item: ClipboardItem) => {
detailLoading.value = false detailLoading.value = false
} }
/** 根据 base64 前缀判断图片 MIME 类型(dib_to_png 可能直接透传 PNG/JPEG */
const imageSrc = computed(() => {
const b64 = detail.value?.imageBase64
if (!b64) return ''
// PNG base64 以 iVBORw0KGgo 开头,JPEG 以 /9j/ 开头
const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png'
return `data:${mime};base64,${b64}`
})
// 清空确认 // 清空确认
const clearOpen = ref(false) const clearOpen = ref(false)
@@ -102,6 +126,9 @@ const handleSaveSettings = async () => {
} }
} }
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
tabsStore.registerSave(handleSaveSettings)
const handleShortcutChange = async () => { const handleShortcutChange = async () => {
// 快捷键变化立即保存并注册(不等点击"保存设置") // 快捷键变化立即保存并注册(不等点击"保存设置")
try { try {
@@ -112,6 +139,86 @@ const handleShortcutChange = async () => {
} }
} }
// ===== 快捷键录入器 =====
const recording = ref(false)
const recorderRef = ref<HTMLDivElement | null>(null)
/** 把存储的快捷键字符串格式化为展示形式:alt+v → Alt + V */
function displayShortcut(s: string): string {
if (!s) return ''
return s
.split('+')
.map(p => {
const t = p.trim()
if (!t) return ''
if (t.length === 1) return t.toUpperCase()
return t.charAt(0).toUpperCase() + t.slice(1)
})
.join(' + ')
}
/** 把键盘事件转为 Tauri 快捷键字符串(小写,+ 分隔) */
function eventToShortcut(e: KeyboardEvent): string | null {
const mods: string[] = []
if (e.ctrlKey) mods.push('ctrl')
if (e.altKey) mods.push('alt')
if (e.shiftKey) mods.push('shift')
if (e.metaKey) mods.push('super')
let main = ''
const code = e.code || ''
if (/^Key[A-Z]$/.test(code)) main = code.slice(3).toLowerCase()
else if (/^Digit[0-9]$/.test(code)) main = code.slice(5)
else if (/^F([1-9]|1[0-2])$/.test(code)) main = code.toLowerCase()
else if (code === 'Space') main = 'space'
else if (code === 'PrintScreen') main = 'printscreen'
else if (code.startsWith('Numpad')) main = code.slice(6).toLowerCase()
else return null
// 必须至少一个修饰键(功能键 F1-F12 / PrintScreen 例外)
const isFunctionKey = /^f([1-9]|1[0-2])$/.test(main) || main === 'printscreen'
if (mods.length === 0 && !isFunctionKey) return null
return [...mods, main].join('+')
}
function onRecordKey(e: KeyboardEvent) {
if (!recording.value) return
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
recording.value = false
return
}
// 仅修饰键按下时不结束(等待主键)
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return
const combo = eventToShortcut(e)
if (!combo) {
toast.warning('不支持的按键组合,请使用字母/数字/功能键 + 修饰键')
return
}
recording.value = false
void commitShortcut(combo)
}
async function commitShortcut(combo: string) {
form.value.shortcut = combo
await handleShortcutChange()
}
async function startRecord() {
recording.value = true
await nextTick()
recorderRef.value?.focus()
}
watch(recording, (on) => {
if (on) window.addEventListener('keydown', onRecordKey, true)
else window.removeEventListener('keydown', onRecordKey, true)
})
async function clearShortcut() {
form.value.shortcut = ''
await handleShortcutChange()
}
const handleTestPopup = async () => { const handleTestPopup = async () => {
try { try {
await store.showPopup() await store.showPopup()
@@ -152,14 +259,14 @@ const handleClear = async () => {
toast.success('已清空历史') toast.success('已清空历史')
} }
// 显示辅助 // 显示辅助kind 来自 bindings 生成的 string,按字符串比较)
const kindIcon = (k: ClipboardKind) => { const kindIcon = (k: string) => {
if (k === 'text') return FileText if (k === 'text') return FileText
if (k === 'image') return ImageIcon if (k === 'image') return ImageIcon
return Files return Files
} }
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件') const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) => const kindBadgeClass = (k: string) =>
k === 'text' k === 'text'
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400' ? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: k === 'image' : k === 'image'
@@ -185,13 +292,22 @@ const historyList = computed(() => store.history)
const pinnedList = computed(() => store.pinned) const pinnedList = computed(() => store.pinned)
onMounted(async () => { onMounted(async () => {
await store.init() // 首次进入:异步加载并显示骨架屏。再次进入时 store 已缓存历史/固定/设置,
await Promise.all([loadPage(), store.refreshPinned()]) // 直接即时渲染缓存数据,后台并行静默刷新,避免每次切换都出现骨架屏/空态闪烁。
const isFirst = !store.initialized
if (isFirst) store.loading = true
try {
await Promise.all([store.init(), loadPage(), store.refreshPinned()])
store.initialized = true
form.value = { ...store.settings } form.value = { ...store.settings }
} finally {
store.loading = false
}
}) })
onUnmounted(() => { onUnmounted(() => {
store.dispose() store.dispose()
window.removeEventListener('keydown', onRecordKey, true)
}) })
</script> </script>
@@ -210,7 +326,7 @@ onUnmounted(() => {
<TabsContent value="history" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate"> <TabsContent value="history" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate">
<div class="flex items-center gap-2 mb-3 shrink-0"> <div class="flex items-center gap-2 mb-3 shrink-0">
<div class="relative flex-1 max-w-sm"> <div class="relative flex-1 max-w-sm">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input v-model="searchQuery" placeholder="搜索历史..." class="pl-8" /> <Input v-model="searchQuery" placeholder="搜索历史..." class="pl-8" />
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@@ -226,31 +342,86 @@ onUnmounted(() => {
</div> </div>
<div class="flex-1" /> <div class="flex-1" />
<Badge variant="secondary"> <Badge variant="secondary">
<Loader2 v-if="store.status.running" class="h-3 w-3 mr-1 animate-spin" />
{{ store.historyTotal }} {{ store.historyTotal }}
</Badge> </Badge>
<Button variant="outline" size="sm" @click="clearOpen = true" :disabled="!historyList.length"> <Button variant="outline" size="sm" @click="clearOpen = true" :disabled="!historyList.length">
<Eraser class="h-4 w-4 mr-1" />清空 <Eraser class="size-4 mr-1" />清空
</Button> </Button>
</div> </div>
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1"> <!-- 分页 + 快捷键提示置于列表上方分页靠左快捷弹窗提示靠右 -->
<div <div class="flex items-center justify-between pb-2 shrink-0">
v-if="!historyList.length" <template v-if="totalPages > 1">
class="flex flex-col items-center justify-center h-full text-muted-foreground" <Pagination
v-slot="{ page }"
:page="currentPage"
:total="store.historyTotal"
:items-per-page="PAGE_SIZE"
:sibling-count="1"
show-edges
@update:page="gotoPage"
class="justify-start"
> >
<ClipboardList class="h-12 w-12 mb-3 opacity-40" /> <PaginationContent v-slot="{ items }" class="gap-1">
<p class="text-sm">暂无历史记录复制内容后将自动收录</p> <template v-for="(item, index) in items" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
size="icon"
class="size-7 text-xs"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else class="size-7" />
</template>
</PaginationContent>
</Pagination>
</template>
<div v-else />
<span class="text-xs text-muted-foreground shrink-0">
快捷弹窗<span v-if="form.shortcut" class="font-medium text-foreground">{{ displayShortcut(form.shortcut) }}</span><span v-else>未设置</span>
</span>
</div> </div>
<ScrollArea class="flex-1 min-h-0">
<div class="space-y-1.5 pr-2">
<!-- 加载骨架数据异步返回前撑起画面避免误显示"暂无历史" -->
<div v-if="store.loading" class="space-y-1.5">
<div
v-for="n in 8" :key="n"
class="flex items-center gap-3 rounded-lg border p-3"
>
<Skeleton class="size-4 shrink-0" />
<div class="flex-1 space-y-2">
<Skeleton class="h-3.5 w-3/4" />
<Skeleton class="h-2.5 w-1/4" />
</div>
</div>
</div>
<Empty
v-else-if="!historyList.length"
class="py-12"
>
<EmptyHeader>
<EmptyMedia variant="icon">
<ClipboardList class="size-6" />
</EmptyMedia>
<EmptyTitle>暂无历史记录</EmptyTitle>
</EmptyHeader>
<EmptyContent>
<EmptyDescription>复制内容后将自动收录图片与文件也会被记录</EmptyDescription>
</EmptyContent>
</Empty>
<Card <Card
v-for="item in historyList" v-for="item in historyList"
:key="item.id" :key="item.id"
class="group hover:shadow-md transition-shadow py-0" class="group hover:shadow-md transition-shadow py-0"
> >
<CardContent class="flex items-center gap-3 px-3 py-2"> <CardContent class="flex items-center gap-3 px-3 py-2">
<component :is="kindIcon(item.kind)" class="h-4 w-4 text-muted-foreground shrink-0" /> <component :is="kindIcon(item.kind)" class="size-4 text-muted-foreground shrink-0" />
<div class="flex-1 min-w-0 cursor-pointer" @click="openDetail(item)"> <div class="flex-1 min-w-0 cursor-pointer" @click="openDetail(item)">
<p class="text-sm break-all line-clamp-1" :title="item.preview">{{ item.preview }}</p> <p class="text-sm break-all line-clamp-1">{{ item.preview }}</p>
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap"> <div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
<Badge variant="outline" :class="['px-1.5 py-0 text-[10px] border', kindBadgeClass(item.kind)]">{{ kindLabel(item.kind) }}</Badge> <Badge variant="outline" :class="['px-1.5 py-0 text-[10px] border', kindBadgeClass(item.kind)]">{{ kindLabel(item.kind) }}</Badge>
<span>{{ formatTime(item.createdAt) }}</span> <span>{{ formatTime(item.createdAt) }}</span>
@@ -258,48 +429,41 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition"> <div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition">
<Button variant="ghost" size="icon" class="h-7 w-7" title="复制" @click.stop="handleCopy(item)"> <Button variant="ghost" size="icon" class="size-7" title="复制" @click.stop="handleCopy(item)">
<Copy class="h-3.5 w-3.5" /> <Copy class="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" class="h-7 w-7" :title="item.pinned ? '取消固定' : '固定'" @click.stop="handlePin(item)"> <Button variant="ghost" size="icon" class="size-7" :title="item.pinned ? '取消固定' : '固定'" @click.stop="handlePin(item)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" /> <component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" class="h-7 w-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)"> <Button variant="ghost" size="icon" class="size-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)">
<Trash2 class="h-3.5 w-3.5" /> <Trash2 class="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</ScrollArea>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 pt-3 shrink-0">
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
<ChevronLeft class="h-4 w-4" />
</Button>
<span class="text-xs text-muted-foreground tabular-nums">{{ currentPage }} / {{ totalPages }}</span>
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</TabsContent> </TabsContent>
<!-- 固定 --> <!-- 固定 -->
<TabsContent value="pinned" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate"> <TabsContent value="pinned" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate">
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1"> <div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1">
<div <Empty v-if="!pinnedList.length" class="h-full py-8">
v-if="!pinnedList.length" <EmptyHeader>
class="flex flex-col items-center justify-center h-full text-muted-foreground" <EmptyMedia variant="icon">
> <Pin class="size-6" />
<Pin class="h-12 w-12 mb-3 opacity-40" /> </EmptyMedia>
<p class="text-sm">暂无固定条目</p> <EmptyTitle>暂无固定条目</EmptyTitle>
<p class="text-xs mt-1">鼠标悬停历史条目点击图钉按钮即可固定</p> </EmptyHeader>
</div> <EmptyContent>
<EmptyDescription>鼠标悬停历史条目点击图钉按钮即可固定</EmptyDescription>
</EmptyContent>
</Empty>
<Card v-for="item in pinnedList" :key="item.id" class="group hover:shadow-md transition-shadow py-0"> <Card v-for="item in pinnedList" :key="item.id" class="group hover:shadow-md transition-shadow py-0">
<CardContent class="flex items-center gap-3 px-3 py-2"> <CardContent class="flex items-center gap-3 px-3 py-2">
<component :is="kindIcon(item.kind)" class="h-4 w-4 text-primary shrink-0" /> <component :is="kindIcon(item.kind)" class="size-4 text-primary shrink-0" />
<div class="flex-1 min-w-0 cursor-pointer" @click="openDetail(item)"> <div class="flex-1 min-w-0 cursor-pointer" @click="openDetail(item)">
<p class="text-sm break-all line-clamp-1" :title="item.preview">{{ item.preview }}</p> <p class="text-sm break-all line-clamp-1">{{ item.preview }}</p>
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap"> <div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
<Badge variant="outline" :class="['px-1.5 py-0 text-[10px] border', kindBadgeClass(item.kind)]">{{ kindLabel(item.kind) }}</Badge> <Badge variant="outline" :class="['px-1.5 py-0 text-[10px] border', kindBadgeClass(item.kind)]">{{ kindLabel(item.kind) }}</Badge>
<span>{{ formatTime(item.createdAt) }}</span> <span>{{ formatTime(item.createdAt) }}</span>
@@ -307,13 +471,13 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition"> <div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition">
<Button variant="ghost" size="icon" class="h-7 w-7" title="复制" @click.stop="handleCopy(item)"> <Button variant="ghost" size="icon" class="size-7" title="复制" @click.stop="handleCopy(item)">
<Copy class="h-3.5 w-3.5" /> <Copy class="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" class="h-7 w-7" title="取消固定" @click.stop="handlePin(item)"> <Button variant="ghost" size="icon" class="size-7" title="取消固定" @click.stop="handlePin(item)">
<PinOff class="h-3.5 w-3.5" /> <PinOff class="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" class="h-7 w-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)"> <Button variant="ghost" size="icon" class="size-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)">
<Trash2 class="h-3.5 w-3.5" /> <Trash2 class="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -325,11 +489,10 @@ onUnmounted(() => {
<!-- 设置 --> <!-- 设置 -->
<TabsContent value="settings" class="flex-1 min-h-0 overflow-y-auto mt-4 tab-animate"> <TabsContent value="settings" class="flex-1 min-h-0 overflow-y-auto mt-4 tab-animate">
<div class="max-w-xl space-y-4"> <div class="max-w-xl space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center">
<h3 class="text-base font-medium flex items-center gap-2"> <h3 class="text-base font-medium flex items-center gap-2">
<SettingsIcon class="h-4 w-4" />基本设置 <SettingsIcon class="size-4" />基本设置
</h3> </h3>
<Button @click="handleSaveSettings"><Check class="h-4 w-4 mr-1" />保存设置</Button>
</div> </div>
<Card> <Card>
@@ -397,18 +560,35 @@ onUnmounted(() => {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Label class="flex items-center gap-2"> <Label class="flex items-center gap-2">
<Keyboard class="h-4 w-4" />快捷弹窗快捷键 <Keyboard class="size-4" />快捷弹窗快捷键
</Label> </Label>
<p class="text-xs text-muted-foreground mt-1">全局快捷键触发鼠标位置历史弹窗留空禁用</p> <p class="text-xs text-muted-foreground mt-1">全局快捷键触发鼠标位置历史弹窗留空禁用</p>
</div> </div>
<Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button> <Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button>
</div> </div>
<Input <div class="flex items-center gap-2 flex-wrap">
v-model="form.shortcut" <div
placeholder="如 Alt+V / Ctrl+Shift+V" ref="recorderRef"
@change="handleShortcutChange" class="hotkey-recorder"
/> :class="{ recording }"
<p class="text-xs text-muted-foreground">格式修饰键+主键 Ctrl+CAlt+VShift+F1修改后自动生效</p> tabindex="0"
@click="startRecord"
>
<template v-if="recording">按下快捷键Esc 取消</template>
<template v-else-if="form.shortcut">
{{ displayShortcut(form.shortcut) }}
</template>
<template v-else>未设置点击录入</template>
</div>
<Button
v-if="form.shortcut"
variant="ghost"
size="sm"
class="h-8 text-muted-foreground"
@click="clearShortcut"
>清除</Button>
</div>
<p class="text-xs text-muted-foreground">点击方框录入快捷键需至少一个修饰键 + 字母/数字/功能键默认 Alt+V</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -424,15 +604,18 @@ onUnmounted(() => {
</DialogHeader> </DialogHeader>
<div class="flex-1 min-h-0 overflow-auto"> <div class="flex-1 min-h-0 overflow-auto">
<div v-if="detailLoading" class="flex items-center justify-center py-12"> <div v-if="detailLoading" class="flex items-center justify-center py-12">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 class="size-6 animate-spin text-muted-foreground" />
</div> </div>
<template v-else-if="detail"> <template v-else-if="detail">
<img <img
v-if="detail.kind === 'image' && detail.imageBase64" v-if="detail.kind === 'image' && imageSrc"
:src="`data:image/png;base64,${detail.imageBase64}`" :src="imageSrc"
class="max-w-full max-h-[60vh] mx-auto rounded" class="max-w-full max-h-[60vh] mx-auto rounded"
alt="剪贴板图片" alt="剪贴板图片"
/> />
<p v-else-if="detail.kind === 'image'" class="text-sm text-muted-foreground text-center py-8">
图片预览不可用
</p>
<pre v-else-if="detail.kind === 'text'" class="text-sm whitespace-pre-wrap break-all font-mono bg-muted/50 p-3 rounded">{{ detail.content }}</pre> <pre v-else-if="detail.kind === 'text'" class="text-sm whitespace-pre-wrap break-all font-mono bg-muted/50 p-3 rounded">{{ detail.content }}</pre>
<ul v-else-if="detail.kind === 'files'" class="space-y-1 text-sm"> <ul v-else-if="detail.kind === 'files'" class="space-y-1 text-sm">
<li <li
+213 -198
View File
@@ -1,30 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' 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 { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { Effect, EffectState } from '@tauri-apps/api/window' import { Effect, EffectState } from '@tauri-apps/api/window'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import { import {
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon, ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Loader2, ChevronLeft, ChevronRight, FileText, Files,
} from '@lucide/vue' } from '@lucide/vue'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
import { Skeleton } from '@/components/ui/skeleton'
// ===== 与 Rust 端对应的数据结构(camelCase ===== // ===== 与 Rust 端对应的数据结构(bindings 提供,camelCase =====
type ClipboardKind = 'text' | 'image' | 'files' // kind 为 bindings 生成的 string,前端按字符串比较即可
interface ClipboardItem { import type { ClipboardItem, HistoryPage } from '@/lib/bindings'
id: number
kind: ClipboardKind
preview: string
size: number
pinned: boolean
pinnedOrder: number | null
createdAt: number
}
interface HistoryPage {
items: ClipboardItem[]
total: number
}
// ===== 状态 ===== // ===== 状态 =====
const items = ref<ClipboardItem[]>([]) const items = ref<ClipboardItem[]>([])
@@ -41,27 +36,35 @@ let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE))) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
// ===== 数据加载 ===== // ===== 数据加载 =====
/** 加载请求序号:翻页/搜索快速操作时丢弃过期请求结果,避免旧请求覆盖新结果 */
let loadSeq = 0
async function loadData() { async function loadData() {
const seq = ++loadSeq
loading.value = true loading.value = true
try { try {
const q = searchQuery.value.trim() const q = searchQuery.value.trim()
const offset = (currentPage.value - 1) * PAGE_SIZE const offset = (currentPage.value - 1) * PAGE_SIZE
let res: HistoryPage let res: HistoryPage
if (q) { if (q) {
res = await invoke<HistoryPage>('clipboard_search', { query: q, limit: PAGE_SIZE, offset }) res = await commands.clipboardSearch(q, PAGE_SIZE, offset)
} else { } 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 items.value = res.items
total.value = res.total total.value = res.total
selectedIndex.value = 0 selectedIndex.value = 0
} catch (e) { } catch (e) {
if (seq !== loadSeq) return
console.error('[clipboard-popup] 加载失败:', e) console.error('[clipboard-popup] 加载失败:', e)
} finally { } finally {
loading.value = false // 仅最新请求可结束 loading,避免旧请求提前清除新请求的加载态
if (seq === loadSeq) loading.value = false
} }
if (seq === loadSeq) {
await nextTick() await nextTick()
scrollSelectedIntoView() scrollSelectedIntoView()
}
} }
async function gotoPage(p: number) { async function gotoPage(p: number) {
@@ -80,9 +83,9 @@ watch(searchQuery, () => {
/// 选中条目 → 写回剪贴板 → 隐藏窗口 → 模拟 Ctrl+V 粘贴到原窗口 /// 选中条目 → 写回剪贴板 → 隐藏窗口 → 模拟 Ctrl+V 粘贴到原窗口
async function selectAndPaste(item: ClipboardItem) { async function selectAndPaste(item: ClipboardItem) {
try { try {
await invoke('clipboard_copy_back', { id: item.id }) await commands.clipboardCopyBack(item.id)
// paste_to_target 会先隐藏窗口,再延迟模拟 Ctrl+V // paste_to_target 会先隐藏窗口,再延迟模拟 Ctrl+V
await invoke('clipboard_paste_to_target') await commands.clipboardPasteToTarget()
} catch (e) { } catch (e) {
console.error('[clipboard-popup] 粘贴失败:', e) console.error('[clipboard-popup] 粘贴失败:', e)
// 失败时至少隐藏窗口 // 失败时至少隐藏窗口
@@ -93,7 +96,7 @@ async function selectAndPaste(item: ClipboardItem) {
async function togglePin(item: ClipboardItem, ev: Event) { async function togglePin(item: ClipboardItem, ev: Event) {
ev.stopPropagation() ev.stopPropagation()
try { try {
await invoke('clipboard_set_pinned', { id: item.id, pinned: !item.pinned }) await commands.clipboardSetPinned(item.id, !item.pinned)
await loadData() await loadData()
} catch (e) { } catch (e) {
console.error('[clipboard-popup] 固定失败:', e) console.error('[clipboard-popup] 固定失败:', e)
@@ -103,7 +106,7 @@ async function togglePin(item: ClipboardItem, ev: Event) {
async function deleteItem(item: ClipboardItem, ev: Event) { async function deleteItem(item: ClipboardItem, ev: Event) {
ev.stopPropagation() ev.stopPropagation()
try { try {
await invoke('clipboard_delete', { id: item.id }) await commands.clipboardDelete(item.id)
items.value = items.value.filter((i) => i.id !== item.id) items.value = items.value.filter((i) => i.id !== item.id)
} catch (e) { } catch (e) {
console.error('[clipboard-popup] 删除失败:', e) console.error('[clipboard-popup] 删除失败:', e)
@@ -112,7 +115,7 @@ async function deleteItem(item: ClipboardItem, ev: Event) {
async function hideWindow() { async function hideWindow() {
try { try {
await invoke('clipboard_hide_popup') await commands.clipboardHidePopup()
} catch { } catch {
/* 忽略 */ /* 忽略 */
} }
@@ -123,10 +126,14 @@ function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1) selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
cancelHoverTimer()
previewVisible.value = false
scrollSelectedIntoView() scrollSelectedIntoView()
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0) selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
cancelHoverTimer()
previewVisible.value = false
scrollSelectedIntoView() scrollSelectedIntoView()
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
@@ -145,14 +152,14 @@ function scrollSelectedIntoView() {
}) })
} }
// ===== 显示辅助 ===== // ===== 显示辅助kind 为 bindings 生成的 string,按字符串比较) =====
const kindIcon = (k: ClipboardKind) => { const kindIcon = (k: string) => {
if (k === 'text') return FileText if (k === 'text') return FileText
if (k === 'image') return ImageIcon if (k === 'image') return ImageIcon
return Files return Files
} }
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件') const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) => const kindBadgeClass = (k: string) =>
k === 'text' k === 'text'
? 'badge-text' ? 'badge-text'
: k === 'image' : k === 'image'
@@ -169,11 +176,67 @@ const formatTime = (ms: number) => {
const hasItems = computed(() => items.value.length > 0) const hasItems = computed(() => items.value.length > 0)
// ===== 图片悬停预览(悬停 100ms 后显示缩略图) =====
const previewSrc = ref('')
const previewVisible = ref(false)
let hoverTimer: ReturnType<typeof setTimeout> | null = null
// 缓存已加载的图片 id → dataUrl,避免重复请求
const imageCache = new Map<number, string>()
/** 根据 base64 前缀判断 MIME 类型 */
function buildImageDataUrl(b64: string): string {
const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png'
return `data:${mime};base64,${b64}`
}
async function onItemHover(idx: number, item: ClipboardItem) {
selectedIndex.value = idx
// 仅图片类型触发预览
if (item.kind !== 'image') {
cancelHoverTimer()
previewVisible.value = false
return
}
// 先取消之前的定时器和预览
cancelHoverTimer()
// 100ms 后加载并显示(快速响应悬停意图)
hoverTimer = setTimeout(async () => {
try {
let src = imageCache.get(item.id)
if (!src) {
const detail = await commands.clipboardGetItem(item.id)
if (detail?.imageBase64) {
src = buildImageDataUrl(detail.imageBase64)
imageCache.set(item.id, src)
}
}
if (src) {
previewSrc.value = src
previewVisible.value = true
}
} catch {
/* 忽略加载失败 */
}
}, 100)
}
function cancelHoverTimer() {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = null
}
}
function onItemLeave() {
cancelHoverTimer()
previewVisible.value = false
}
// ===== 主题应用(与主应用同步) ===== // ===== 主题应用(与主应用同步) =====
/** 从 localStorage 读取主应用的主题设置 */ /** 从 localStorage 读取主应用的主题设置 */
function readMainTheme(): { theme: string; effect: string } { function readMainTheme(): { theme: string; effect: string } {
try { try {
const raw = localStorage.getItem('thing_app_settings') const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) { if (raw) {
const s = JSON.parse(raw) const s = JSON.parse(raw)
return { return {
@@ -266,11 +329,15 @@ onMounted(async () => {
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange)) unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// 监听弹窗显示事件:每次显示时重新同步主题 + 刷新数据 // 监听弹窗显示事件:每次显示时重新同步主题 + 刷新数据
unlistenFns.push(await listen('clipboard-popup-show', async () => { unlistenFns.push(await listen(EVENTS.clipboardPopupShow, async () => {
// 主应用可能切换了主题,每次显示前重新应用 // 主应用可能切换了主题,每次显示前重新应用
await applyTheme() await applyTheme()
searchQuery.value = '' searchQuery.value = ''
currentPage.value = 1 currentPage.value = 1
// 重置预览状态,清空缓存避免历史图片占用内存
cancelHoverTimer()
previewVisible.value = false
imageCache.clear()
await loadData() await loadData()
await nextTick() await nextTick()
searchInputRef.value?.focus() searchInputRef.value?.focus()
@@ -283,81 +350,119 @@ onMounted(async () => {
// 主题和数据都就绪后,调用 Rust 端显示窗口 // 主题和数据都就绪后,调用 Rust 端显示窗口
try { try {
await invoke('clipboard_show_window') await commands.clipboardShowWindow()
} catch { } catch {
/* 忽略 */ /* 忽略 */
} }
}) })
onUnmounted(() => { onUnmounted(() => {
cancelHoverTimer()
unlistenFns.forEach((fn) => fn()) unlistenFns.forEach((fn) => fn())
}) })
</script> </script>
<template> <template>
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown"> <div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
<!-- 搜索栏 --> <!-- 搜索栏与剪切板主页统一样式 -->
<div class="popup-header shrink-0"> <div class="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<div class="search-wrap"> <div class="relative flex-1 max-w-sm">
<Search class="h-4 w-4 text-muted-foreground" /> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<input <Input
ref="searchInputRef" ref="searchInputRef"
v-model="searchQuery" v-model="searchQuery"
placeholder="搜索剪贴板历史..." placeholder="搜索剪贴板历史..."
class="search-input" class="pl-8 text-sm"
spellcheck="false" spellcheck="false"
/> />
</div> </div>
<span class="popup-count">{{ total }} </span> <span class="text-xs text-muted-foreground whitespace-nowrap">{{ total }} </span>
</div> </div>
<!-- 图片悬停预览浮层 -->
<Transition name="popup-preview">
<div v-if="previewVisible && previewSrc" class="popup-preview">
<img :src="previewSrc" alt="预览" />
</div>
</Transition>
<!-- 列表 --> <!-- 列表 -->
<ScrollArea class="popup-list flex-1 min-h-0"> <ScrollArea class="popup-list flex-1 min-h-0">
<div v-if="loading && !hasItems" class="popup-empty"> <div class="space-y-1.5 p-2">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" /> <div v-if="loading && !hasItems" class="space-y-1.5">
<div
v-for="n in 8"
:key="n"
class="flex items-start gap-3 rounded-lg border px-3 py-2"
>
<Skeleton class="size-4 shrink-0 mt-0.5" />
<div class="flex-1 space-y-1.5">
<Skeleton class="h-3.5 w-3/4" />
<Skeleton class="h-2.5 w-1/4" />
</div> </div>
<div v-else-if="!hasItems" class="popup-empty"> </div>
<ClipboardList class="h-10 w-10 mb-2 opacity-40" /> </div>
<p class="text-sm text-muted-foreground"> <div v-else-if="!hasItems" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<ClipboardList class="size-10 mb-2 opacity-40" />
<p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无历史记录' }} {{ searchQuery ? '无匹配结果' : '暂无历史记录' }}
</p> </p>
</div> </div>
<div <div
v-for="(item, idx) in items" v-for="(item, idx) in items"
:key="item.id" :key="item.id"
class="popup-item" class="popup-item group"
:class="{ 'popup-item-selected': idx === selectedIndex }" :class="{ 'popup-item-selected': idx === selectedIndex }"
@click="selectAndPaste(item)" @click="selectAndPaste(item)"
@mouseenter="selectedIndex = idx" @mouseenter="onItemHover(idx, item)"
@mouseleave="onItemLeave"
> >
<component :is="kindIcon(item.kind)" class="popup-item-icon" /> <component :is="kindIcon(item.kind)" class="size-4 text-muted-foreground shrink-0 mt-0.5" />
<div class="popup-item-body"> <div class="flex-1 min-w-0">
<p class="popup-item-preview">{{ item.preview }}</p> <p class="text-sm break-all line-clamp-1">{{ item.preview }}</p>
<div class="popup-item-meta"> <div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
<span class="popup-item-kind" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span> <span class="popup-item-kind px-1.5 py-0 text-[10px] border rounded-sm" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
<span>{{ formatTime(item.createdAt) }}</span> <span>{{ formatTime(item.createdAt) }}</span>
</div> </div>
</div> </div>
<div class="popup-item-actions"> <div class="popup-item-actions shrink-0">
<button class="popup-action-btn" title="固定" @click="togglePin(item, $event)"> <button class="popup-action-btn size-7" :title="item.pinned ? '取消固定' : '固定'" @click="togglePin(item, $event)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" /> <component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</button> </button>
<button class="popup-action-btn hover:text-destructive" title="删除" @click="deleteItem(item, $event)"> <button class="popup-action-btn size-7 hover:text-destructive" title="删除" @click="deleteItem(item, $event)">
<Trash2 class="h-3.5 w-3.5" /> <Trash2 class="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>
</div>
</ScrollArea> </ScrollArea>
<!-- 分页 --> <!-- 分页与剪切板历史统一样式 -->
<div v-if="totalPages > 1" class="popup-pagination"> <div v-if="totalPages > 1" class="flex items-center justify-center gap-1 px-2 py-1 border-t border-border">
<button class="popup-page-btn" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)"> <Pagination
<ChevronLeft class="h-3.5 w-3.5" /> v-slot="{ page }"
</button> :page="currentPage"
<span class="popup-page-info">{{ currentPage }} / {{ totalPages }}</span> :total="total"
<button class="popup-page-btn" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)"> :items-per-page="PAGE_SIZE"
<ChevronRight class="h-3.5 w-3.5" /> :sibling-count="1"
</button> show-edges
@update:page="gotoPage"
>
<PaginationContent v-slot="{ items: pageItems }" class="gap-1">
<template v-for="(item, index) in pageItems" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
size="icon"
class="size-7 text-xs"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else class="size-7" />
</template>
</PaginationContent>
</Pagination>
</div> </div>
<!-- 底部提示 --> <!-- 底部提示 -->
@@ -377,49 +482,12 @@ onUnmounted(() => {
color: var(--foreground); color: var(--foreground);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
} position: relative;
/* 搜索栏 */
.popup-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.search-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
background: var(--input);
border-radius: 6px;
padding: 6px 10px;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--foreground);
font-size: 13px;
}
.search-input::placeholder {
color: var(--muted-foreground);
}
.popup-count {
font-size: 11px;
color: var(--muted-foreground);
white-space: nowrap;
} }
/* 列表 */ /* 列表 */
.popup-list { .popup-list {
padding: 4px; padding: 0;
} }
/* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */ /* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */
@@ -427,66 +495,26 @@ onUnmounted(() => {
min-height: 100%; min-height: 100%;
} }
/* 条目卡片样式(与主界面 Card 统一) */
.popup-item { .popup-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 12px;
padding: 8px 10px; padding: 8px 12px;
border-radius: 6px; border-radius: var(--radius);
cursor: pointer; cursor: pointer;
/* 预留边框空间,避免 hover/选中时布局抖动 */ border: 1px solid var(--border);
border: 1px solid transparent; background: var(--card);
/* 统一 hover 和 selected 为同一高亮效果,避免错乱 */ transition: box-shadow 0.2s, background-color 0.1s;
transition: background-color 0.1s, border-color 0.1s; }
/* hover 使用 shadow(同主界面 hover:shadow-md),键盘选中保留轻微高亮 */
.popup-item:hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
} }
/* 鼠标悬停和键盘选中统一使用 accent 高亮 + 反色边框增强提醒 */
.popup-item:hover,
.popup-item-selected { .popup-item-selected {
background: var(--accent); background: var(--accent);
border-color: var(--foreground);
}
.popup-item-icon {
width: 18px;
height: 18px;
margin-top: 1px;
color: var(--muted-foreground);
flex-shrink: 0;
}
.popup-item-body {
flex: 1;
min-width: 0;
}
.popup-item-preview {
font-size: 13px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
}
.popup-item-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 3px;
font-size: 11px;
color: var(--muted-foreground);
}
.popup-item-kind {
background: var(--secondary);
color: var(--secondary-foreground);
padding: 0 5px;
border-radius: 3px;
font-size: 10px;
border: 1px solid transparent;
} }
/* 类型 badge 配色(与主界面一致) */ /* 类型 badge 配色(与主界面一致) */
@@ -522,7 +550,6 @@ onUnmounted(() => {
gap: 2px; gap: 2px;
opacity: 0; opacity: 0;
transition: opacity 0.1s; transition: opacity 0.1s;
flex-shrink: 0;
} }
/* 选中项和悬停项都显示操作按钮 */ /* 选中项和悬停项都显示操作按钮 */
@@ -535,27 +562,20 @@ onUnmounted(() => {
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--muted-foreground);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--muted-foreground);
transition: background-color 0.1s, color 0.1s;
} }
.popup-action-btn:hover { .popup-action-btn:hover {
background: var(--muted); background: var(--muted);
color: var(--foreground);
} }
/* 空状态 */ /* 空状态(使用 Tailwind 类,无需额外 CSS */
.popup-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: var(--muted-foreground);
}
/* 底部 */ /* 底部 */
.popup-footer { .popup-footer {
@@ -568,42 +588,37 @@ onUnmounted(() => {
color: var(--muted-foreground); color: var(--muted-foreground);
} }
/* 分页 */ /* 图片悬停预览浮层:固定在弹窗右上角,不遮挡列表操作 */
.popup-pagination { .popup-preview {
display: flex; position: absolute;
align-items: center; top: 50px;
justify-content: center; right: 10px;
gap: 8px; z-index: 100;
padding: 4px 12px; max-width: 180px;
border-top: 1px solid var(--border); max-height: 180px;
} border-radius: 6px;
overflow: hidden;
.popup-page-btn {
background: transparent;
border: 1px solid var(--border); border: 1px solid var(--border);
cursor: pointer; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
padding: 3px; background: var(--popover, var(--background));
border-radius: 4px; pointer-events: none;
color: var(--foreground);
display: flex;
align-items: center;
} }
.popup-page-btn:hover:not(:disabled) { .popup-preview img {
background: var(--accent); display: block;
max-width: 100%;
max-height: 180px;
object-fit: contain;
} }
.popup-page-btn:disabled { /* 预览浮层淡入淡出 */
opacity: 0.4; .popup-preview-enter-active,
cursor: not-allowed; .popup-preview-leave-active {
transition: opacity 0.15s ease;
} }
.popup-preview-enter-from,
.popup-page-info { .popup-preview-leave-to {
font-size: 11px; opacity: 0;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
min-width: 50px;
text-align: center;
} }
.popup-footer kbd { .popup-footer kbd {
+38 -1
View File
@@ -5,7 +5,44 @@ const searchItems: SearchIndexItem[] = [
{ {
title: '剪贴板历史', title: '剪贴板历史',
description: '查看和管理剪贴板记录', description: '查看和管理剪贴板记录',
keywords: ['剪贴板', '复制', '粘贴', 'clipboard', 'copy', 'paste'] keywords: ['剪贴板', '复制', '粘贴', 'clipboard', 'copy', 'paste'],
tab: 'history'
},
{
title: '固定记录',
description: '查看固定的剪贴板条目',
keywords: ['固定', '收藏', 'pin', '置顶'],
tab: 'pinned'
},
{
title: '剪贴板设置',
description: '历史数量、图片收录与快捷弹窗快捷键',
keywords: ['设置', 'setting', '选项', '配置'],
tab: 'settings'
},
{
title: '快捷弹窗快捷键',
description: '配置全局快捷键唤起剪贴板弹窗',
keywords: ['快捷键', '热键', 'shortcut', 'hotkey', '弹窗', 'popup'],
tab: 'settings'
},
{
title: '最大历史条数',
description: '设置剪贴板历史记录数量上限',
keywords: ['历史', '数量', '上限', '条数', 'max', 'limit'],
tab: 'settings'
},
{
title: '图片大小上限',
description: '设置收录图片的大小上限 (KB)',
keywords: ['图片', '大小', '上限', 'image', 'kb', '体积'],
tab: 'settings'
},
{
title: '记录图片',
description: '是否收录复制/截图的图片',
keywords: ['图片', '截图', '收录', 'image', 'capture'],
tab: 'settings'
} }
] ]
+131 -128
View File
@@ -9,12 +9,14 @@ import {
} from '@lucide/vue' } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { invoke } from '@tauri-apps/api/core'
import { open as openDialog } from '@tauri-apps/plugin-dialog' 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 { 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 { createLogger } from '@/lib/logger'
import { pendingNewDownload } from '@/lib/trayEvents' import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -42,7 +44,8 @@ const logger = createLogger('downloader')
// ===== Tab ===== // ===== Tab =====
const activeTab = ref('tasks') const activeTab = ref('tasks')
const tabsListRef = useModuleTabs(activeTab, [ const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs('downloader', activeTab, [
{ value: 'tasks', label: '下载任务' }, { value: 'tasks', label: '下载任务' },
{ value: 'settings', label: '设置' }, { value: 'settings', label: '设置' },
{ value: 'extension', label: '浏览器扩展' } { value: 'extension', label: '浏览器扩展' }
@@ -216,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, 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) => { const handleShowDetail = (task: DownloadTask) => {
detailDialogState.value = { open: true, task } detailDialogState.value = { open: true, taskId: task.id }
} }
const handleCopyText = async (text: string, label: string) => { const handleCopyText = async (text: string, label: string) => {
@@ -483,6 +494,9 @@ const handleSaveSettings = async (): Promise<boolean> => {
} }
} }
// storeTitleBar
tabsStore.registerSave(handleSaveSettings)
// Dialog // Dialog
const handleDialogSave = async () => { const handleDialogSave = async () => {
const ok = await handleSaveSettings() const ok = await handleSaveSettings()
@@ -493,7 +507,7 @@ const handleDialogSave = async () => {
const EXTENSION_STORE_URL = 'https://chromewebstore.google.com/' const EXTENSION_STORE_URL = 'https://chromewebstore.google.com/'
const handleInstallExtensionOnline = async () => { const handleInstallExtensionOnline = async () => {
try { try {
await invoke('downloader_open_url', { url: EXTENSION_STORE_URL }) await commands.downloaderOpenUrl(EXTENSION_STORE_URL)
} catch (e) { } catch (e) {
try { try {
await navigator.clipboard.writeText(EXTENSION_STORE_URL) await navigator.clipboard.writeText(EXTENSION_STORE_URL)
@@ -522,6 +536,11 @@ onMounted(async () => {
pendingNewDownload.value = false pendingNewDownload.value = false
addDialogOpen.value = true addDialogOpen.value = true
} }
//
if (pendingShowDownloadTasks.value) {
pendingShowDownloadTasks.value = false
activeTab.value = 'tasks'
}
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -532,6 +551,13 @@ onUnmounted(() => {
const allTasks = computed<DownloadTask[]>(() => store.tasks) 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[]>(() => { const filteredByStatus = computed<DownloadTask[]>(() => {
if (statusFilter.value === 'all') return allTasks.value if (statusFilter.value === 'all') return allTasks.value
@@ -595,15 +621,15 @@ const toggleSortOrder = () => {
<div ref="tabsListRef"> <div ref="tabsListRef">
<TabsList class="grid w-full grid-cols-3 max-w-md !bg-transparent !p-0 !shadow-none"> <TabsList class="grid w-full grid-cols-3 max-w-md !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="tasks" class="gap-1.5"> <TabsTrigger value="tasks" class="gap-1.5">
<ListChecks class="h-4 w-4" /> <ListChecks class="size-4" />
下载任务 下载任务
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"> <TabsTrigger value="settings" class="gap-1.5">
<SettingsIcon class="h-4 w-4" /> <SettingsIcon class="size-4" />
设置 设置
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="extension" class="gap-1.5"> <TabsTrigger value="extension" class="gap-1.5">
<Puzzle class="h-4 w-4" /> <Puzzle class="size-4" />
浏览器扩展 浏览器扩展
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -629,20 +655,20 @@ const toggleSortOrder = () => {
<Separator orientation="vertical" class="h-5" /> <Separator orientation="vertical" class="h-5" />
<div v-if="running" class="flex items-center gap-2 text-xs"> <div v-if="running" class="flex items-center gap-2 text-xs">
<Badge variant="secondary" class="gap-1"> <Badge variant="secondary" class="gap-1">
<Download class="h-3 w-3" /> <Download class="size-3" />
下载中 {{ allTasks.filter(t => t.status === 'active').length }} 下载中 {{ statusCounts.active }}
</Badge> </Badge>
<Badge variant="secondary" class="gap-1"> <Badge variant="secondary" class="gap-1">
<Clock class="h-3 w-3" /> <Clock class="size-3" />
等待 {{ allTasks.filter(t => t.status === 'queued').length }} 等待 {{ statusCounts.queued }}
</Badge> </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" /> <Pause class="size-3" />
已暂停 {{ allTasks.filter(t => t.status === 'paused').length }} 已暂停 {{ statusCounts.paused }}
</Badge> </Badge>
<Badge variant="secondary" class="gap-1"> <Badge variant="secondary" class="gap-1">
<Check class="h-3 w-3" /> <Check class="size-3" />
已完成 {{ allTasks.filter(t => t.status === 'complete').length }} 已完成 {{ statusCounts.complete }}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -652,7 +678,7 @@ const toggleSortOrder = () => {
variant="ghost" variant="ghost"
@click="store.refreshTasks()" @click="store.refreshTasks()"
> >
<RefreshCw class="h-4 w-4" /> <RefreshCw class="size-4" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -662,20 +688,15 @@ const toggleSortOrder = () => {
<!-- 工具栏筛选 / 排序 / 搜索 --> <!-- 工具栏筛选 / 排序 / 搜索 -->
<div class="shrink-0 flex items-center gap-2 flex-wrap"> <div class="shrink-0 flex items-center gap-2 flex-wrap">
<!-- 新建下载按钮 --> <!-- 新建下载按钮 -->
<Tooltip>
<TooltipTrigger as-child>
<Button <Button
size="sm" size="sm"
class="h-8 gap-1.5" class="h-8 gap-1.5"
:disabled="addingTask" :disabled="addingTask"
@click="addDialogOpen = true" @click="addDialogOpen = true"
> >
<Plus class="h-4 w-4" /> <Plus class="size-4" />
新建下载 新建下载
</Button> </Button>
</TooltipTrigger>
<TooltipContent>添加下载链接</TooltipContent>
</Tooltip>
<!-- 状态筛选 --> <!-- 状态筛选 -->
<Select v-model="statusFilter"> <Select v-model="statusFilter">
@@ -715,11 +736,11 @@ const toggleSortOrder = () => {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
class="h-8 w-8 p-0 bg-transparent" class="size-8 p-0 bg-transparent"
@click="toggleSortOrder" @click="toggleSortOrder"
> >
<ArrowUp v-if="sortOrder === 'asc'" class="h-4 w-4" /> <ArrowUp v-if="sortOrder === 'asc'" class="size-4" />
<ArrowDown v-else class="h-4 w-4" /> <ArrowDown v-else class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{{ sortOrder === 'asc' ? '升序(点击切换降序)' : '降序(点击切换升序)' }}</TooltipContent> <TooltipContent>{{ sortOrder === 'asc' ? '升序(点击切换降序)' : '降序(点击切换升序)' }}</TooltipContent>
@@ -727,7 +748,7 @@ const toggleSortOrder = () => {
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="relative flex-1 min-w-[180px] max-w-xs"> <div class="relative flex-1 min-w-[180px] max-w-xs">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search class="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
v-model="searchText" v-model="searchText"
placeholder="搜索文件名..." placeholder="搜索文件名..."
@@ -735,20 +756,15 @@ const toggleSortOrder = () => {
/> />
</div> </div>
<Tooltip>
<TooltipTrigger as-child>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
class="h-8 gap-1.5 bg-transparent" class="h-8 gap-1.5 bg-transparent"
@click="downloadSettingsOpen = true" @click="downloadSettingsOpen = true"
> >
<SettingsIcon class="h-4 w-4" /> <SettingsIcon class="size-4" />
下载设置 下载设置
</Button> </Button>
</TooltipTrigger>
<TooltipContent>下载设置</TooltipContent>
</Tooltip>
<span class="text-xs text-muted-foreground ml-auto"> <span class="text-xs text-muted-foreground ml-auto">
{{ sortedTasks.length }} 个任务 {{ sortedTasks.length }} 个任务
@@ -758,10 +774,10 @@ const toggleSortOrder = () => {
<!-- 任务列表 --> <!-- 任务列表 -->
<ScrollArea class="flex-1 min-h-0"> <ScrollArea class="flex-1 min-h-0">
<div v-if="pagedTasks.length === 0" key="empty" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10"> <div v-if="pagedTasks.length === 0" key="empty" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
<CheckCircle2 class="h-16 w-16 opacity-30" /> <CheckCircle2 class="size-16 opacity-30" />
<p>暂无符合条件的任务</p> <p>暂无符合条件的任务</p>
<Button size="sm" class="mt-2" @click="addDialogOpen = true"> <Button size="sm" class="mt-2" @click="addDialogOpen = true">
<Plus class="h-4 w-4" /> <Plus class="size-4" />
新建下载 新建下载
</Button> </Button>
</div> </div>
@@ -771,19 +787,14 @@ const toggleSortOrder = () => {
<div class="flex items-start justify-between gap-3 mb-3"> <div class="flex items-start justify-between gap-3 mb-3">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<Tooltip>
<TooltipTrigger as-child>
<span class="font-medium truncate cursor-default"> <span class="font-medium truncate cursor-default">
{{ getFileName(task) }} {{ getFileName(task) }}
</span> </span>
</TooltipTrigger>
<TooltipContent>{{ getFileName(task) }}</TooltipContent>
</Tooltip>
<Badge <Badge
:variant="getTaskStatusBadge(task).variant" :variant="getTaskStatusBadge(task).variant"
class="shrink-0" class="shrink-0"
> >
<component :is="getTaskStatusBadge(task).icon" class="h-3 w-3" /> <component :is="getTaskStatusBadge(task).icon" class="size-3" />
{{ getTaskStatusBadge(task).text }} {{ getTaskStatusBadge(task).text }}
</Badge> </Badge>
</div> </div>
@@ -792,7 +803,7 @@ const toggleSortOrder = () => {
{{ formatSize(task.completedSize) }} / {{ formatSize(task.totalSize) }} {{ formatSize(task.completedSize) }} / {{ formatSize(task.totalSize) }}
</span> </span>
<span v-if="task.status === 'active'" class="flex items-center gap-1 text-green-600 dark:text-green-400"> <span v-if="task.status === 'active'" class="flex items-center gap-1 text-green-600 dark:text-green-400">
<Download class="h-3 w-3" /> <Download class="size-3" />
{{ formatSpeed(task.speed) }} {{ formatSpeed(task.speed) }}
</span> </span>
<Badge v-if="task.status === 'active' && task.segments.length > 1" variant="outline" class="gap-1 py-0 px-1.5 text-[10px]"> <Badge v-if="task.status === 'active' && task.segments.length > 1" variant="outline" class="gap-1 py-0 px-1.5 text-[10px]">
@@ -805,7 +816,7 @@ const toggleSortOrder = () => {
{{ truncatePath(task.dir) }} {{ truncatePath(task.dir) }}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent class="max-w-[400px] break-all">{{ task.dir }}</TooltipContent> <TooltipContent class="max-w-[480px] break-words">{{ task.dir }}</TooltipContent>
</Tooltip> </Tooltip>
<span v-if="task.error" class="text-destructive"> <span v-if="task.error" class="text-destructive">
{{ task.error }} {{ task.error }}
@@ -818,10 +829,10 @@ const toggleSortOrder = () => {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
class="h-8 w-8" class="size-8"
@click="handlePause(task.id)" @click="handlePause(task.id)"
> >
<Pause class="h-4 w-4" /> <Pause class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{{ task.status === 'queued' ? '取消排队' : '暂停' }}</TooltipContent> <TooltipContent>{{ task.status === 'queued' ? '取消排队' : '暂停' }}</TooltipContent>
@@ -831,10 +842,10 @@ const toggleSortOrder = () => {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
class="h-8 w-8" class="size-8"
@click="handleResume(task.id)" @click="handleResume(task.id)"
> >
<Play class="h-4 w-4" /> <Play class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>继续</TooltipContent> <TooltipContent>继续</TooltipContent>
@@ -844,10 +855,10 @@ const toggleSortOrder = () => {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
class="h-8 w-8" class="size-8"
@click="handleShowDetail(task)" @click="handleShowDetail(task)"
> >
<Info class="h-4 w-4" /> <Info class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>详细信息</TooltipContent> <TooltipContent>详细信息</TooltipContent>
@@ -857,10 +868,10 @@ const toggleSortOrder = () => {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
class="h-8 w-8" class="size-8"
@click="handleOpenDir(task)" @click="handleOpenDir(task)"
> >
<FolderOpen class="h-4 w-4" /> <FolderOpen class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>打开目录</TooltipContent> <TooltipContent>打开目录</TooltipContent>
@@ -870,10 +881,10 @@ const toggleSortOrder = () => {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
class="h-8 w-8 text-destructive hover:text-destructive" class="size-8 text-destructive hover:text-destructive"
@click="handleRemove(task)" @click="handleRemove(task)"
> >
<Trash2 class="h-4 w-4" /> <Trash2 class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>删除</TooltipContent> <TooltipContent>删除</TooltipContent>
@@ -898,12 +909,12 @@ const toggleSortOrder = () => {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
class="h-8 w-8 p-0" class="size-8 p-0"
:disabled="currentPage <= 1" :disabled="currentPage <= 1"
@click="currentPage = 1" @click="currentPage = 1"
> >
<ChevronLeft class="h-4 w-4" /> <ChevronLeft class="size-4" />
<ChevronLeft class="h-4 w-4 -ml-2" /> <ChevronLeft class="size-4 -ml-2" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>第一页</TooltipContent> <TooltipContent>第一页</TooltipContent>
@@ -913,11 +924,11 @@ const toggleSortOrder = () => {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
class="h-8 w-8 p-0" class="size-8 p-0"
:disabled="currentPage <= 1" :disabled="currentPage <= 1"
@click="currentPage--" @click="currentPage--"
> >
<ChevronLeft class="h-4 w-4" /> <ChevronLeft class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>上一页</TooltipContent> <TooltipContent>上一页</TooltipContent>
@@ -930,11 +941,11 @@ const toggleSortOrder = () => {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
class="h-8 w-8 p-0" class="size-8 p-0"
:disabled="currentPage >= totalPages" :disabled="currentPage >= totalPages"
@click="currentPage++" @click="currentPage++"
> >
<ChevronRight class="h-4 w-4" /> <ChevronRight class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>下一页</TooltipContent> <TooltipContent>下一页</TooltipContent>
@@ -944,12 +955,12 @@ const toggleSortOrder = () => {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
class="h-8 w-8 p-0" class="size-8 p-0"
:disabled="currentPage >= totalPages" :disabled="currentPage >= totalPages"
@click="currentPage = totalPages" @click="currentPage = totalPages"
> >
<ChevronRight class="h-4 w-4" /> <ChevronRight class="size-4" />
<ChevronRight class="h-4 w-4 -ml-2" /> <ChevronRight class="size-4 -ml-2" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>最后一页</TooltipContent> <TooltipContent>最后一页</TooltipContent>
@@ -963,19 +974,11 @@ const toggleSortOrder = () => {
<TabsContent value="settings" class="flex-1 mt-4 min-h-0 tab-animate"> <TabsContent value="settings" class="flex-1 mt-4 min-h-0 tab-animate">
<ScrollArea class="h-full pr-3"> <ScrollArea class="h-full pr-3">
<div class="flex flex-col gap-4 pb-4 max-w-2xl"> <div class="flex flex-col gap-4 pb-4 max-w-2xl">
<!-- 顶部保存按钮 -->
<div class="flex justify-end">
<Button @click="handleSaveSettings">
<Check class="h-4 w-4" />
保存设置
</Button>
</div>
<!-- 下载设置 --> <!-- 下载设置 -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
<FolderOpen class="h-4 w-4 text-primary" /> <FolderOpen class="size-4 text-primary" />
下载设置 下载设置
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -991,7 +994,7 @@ const toggleSortOrder = () => {
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button variant="outline" size="icon" @click="handleSelectSettingsDir"> <Button variant="outline" size="icon" @click="handleSelectSettingsDir">
<FolderOpen class="h-4 w-4" /> <FolderOpen class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>选择目录</TooltipContent> <TooltipContent>选择目录</TooltipContent>
@@ -1066,7 +1069,7 @@ const toggleSortOrder = () => {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
<Zap class="h-4 w-4 text-primary" /> <Zap class="size-4 text-primary" />
扩展 API 扩展 API
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -1108,7 +1111,7 @@ const toggleSortOrder = () => {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
<Puzzle class="h-4 w-4 text-primary" /> <Puzzle class="size-4 text-primary" />
Thing Extension Thing Extension
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -1129,7 +1132,7 @@ const toggleSortOrder = () => {
{{ store.extensionInfo.url }} {{ store.extensionInfo.url }}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent class="max-w-[400px] break-all">{{ store.extensionInfo.url }}</TooltipContent> <TooltipContent class="max-w-[480px] break-words">{{ store.extensionInfo.url }}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Tooltip> <Tooltip>
@@ -1137,10 +1140,10 @@ const toggleSortOrder = () => {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
class="h-8 w-8 shrink-0" class="size-8 shrink-0"
@click="handleCopy(store.extensionInfo.url, 'API 地址')" @click="handleCopy(store.extensionInfo.url, 'API 地址')"
> >
<Copy class="h-4 w-4" /> <Copy class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>复制 API 地址</TooltipContent> <TooltipContent>复制 API 地址</TooltipContent>
@@ -1160,11 +1163,11 @@ const toggleSortOrder = () => {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
class="h-8 w-8" class="size-8"
@click="showSecret = !showSecret" @click="showSecret = !showSecret"
> >
<Eye v-if="!showSecret" class="h-4 w-4" /> <Eye v-if="!showSecret" class="size-4" />
<EyeOff v-else class="h-4 w-4" /> <EyeOff v-else class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{{ showSecret ? '隐藏' : '显示' }}</TooltipContent> <TooltipContent>{{ showSecret ? '隐藏' : '显示' }}</TooltipContent>
@@ -1174,10 +1177,10 @@ const toggleSortOrder = () => {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
class="h-8 w-8" class="size-8"
@click="handleCopy(store.extensionInfo.secret, '密钥')" @click="handleCopy(store.extensionInfo.secret, '密钥')"
> >
<Copy class="h-4 w-4" /> <Copy class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>复制密钥</TooltipContent> <TooltipContent>复制密钥</TooltipContent>
@@ -1192,7 +1195,7 @@ const toggleSortOrder = () => {
<Label class="text-xs text-muted-foreground">安装扩展</Label> <Label class="text-xs text-muted-foreground">安装扩展</Label>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Button variant="outline" @click="handleInstallExtensionOnline"> <Button variant="outline" @click="handleInstallExtensionOnline">
<ExternalLink class="h-4 w-4" /> <ExternalLink class="size-4" />
在线安装 在线安装
</Button> </Button>
</div> </div>
@@ -1222,7 +1225,7 @@ const toggleSortOrder = () => {
<DialogContent class="max-w-lg"> <DialogContent class="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle class="flex items-center gap-2"> <DialogTitle class="flex items-center gap-2">
<SettingsIcon class="h-4 w-4 text-primary" /> <SettingsIcon class="size-4 text-primary" />
下载设置 下载设置
</DialogTitle> </DialogTitle>
<DialogDescription class="text-xs"> <DialogDescription class="text-xs">
@@ -1243,7 +1246,7 @@ const toggleSortOrder = () => {
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button variant="outline" size="icon" @click="handleSelectSettingsDir"> <Button variant="outline" size="icon" @click="handleSelectSettingsDir">
<FolderOpen class="h-4 w-4" /> <FolderOpen class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>选择目录</TooltipContent> <TooltipContent>选择目录</TooltipContent>
@@ -1315,7 +1318,7 @@ const toggleSortOrder = () => {
<Button variant="outline">取消</Button> <Button variant="outline">取消</Button>
</DialogClose> </DialogClose>
<Button @click="handleDialogSave"> <Button @click="handleDialogSave">
<Check class="h-4 w-4" /> <Check class="size-4" />
保存 保存
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -1327,7 +1330,7 @@ const toggleSortOrder = () => {
<DialogContent class="max-w-lg"> <DialogContent class="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle class="flex items-center gap-2"> <DialogTitle class="flex items-center gap-2">
<Link2 class="h-4 w-4 text-primary" /> <Link2 class="size-4 text-primary" />
新建下载 新建下载
</DialogTitle> </DialogTitle>
<DialogDescription class="text-xs"> <DialogDescription class="text-xs">
@@ -1358,7 +1361,7 @@ const toggleSortOrder = () => {
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button variant="outline" size="icon" @click="handleSelectDir"> <Button variant="outline" size="icon" @click="handleSelectDir">
<FolderOpen class="h-4 w-4" /> <FolderOpen class="size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>选择目录</TooltipContent> <TooltipContent>选择目录</TooltipContent>
@@ -1376,8 +1379,8 @@ const toggleSortOrder = () => {
<Button variant="outline">取消</Button> <Button variant="outline">取消</Button>
</DialogClose> </DialogClose>
<Button :disabled="addingTask || !addUriText.trim()" @click="handleAddDownload"> <Button :disabled="addingTask || !addUriText.trim()" @click="handleAddDownload">
<Loader2 v-if="addingTask" class="h-4 w-4 animate-spin" /> <Loader2 v-if="addingTask" class="size-4 animate-spin" />
<Plus v-else class="h-4 w-4" /> <Plus v-else class="size-4" />
添加下载 添加下载
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -1389,7 +1392,7 @@ const toggleSortOrder = () => {
<AlertDialogContent class="max-w-md"> <AlertDialogContent class="max-w-md">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle class="flex items-center gap-2"> <AlertDialogTitle class="flex items-center gap-2">
<AlertCircle class="h-4 w-4 text-amber-500" /> <AlertCircle class="size-4 text-amber-500" />
下载重复提醒 下载重复提醒
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription class="text-xs"> <AlertDialogDescription class="text-xs">
@@ -1410,7 +1413,7 @@ const toggleSortOrder = () => {
<AlertDialogCancel @click="onDuplicateCancel">取消全部</AlertDialogCancel> <AlertDialogCancel @click="onDuplicateCancel">取消全部</AlertDialogCancel>
<Button variant="outline" @click="onDuplicateSkip">跳过此链接</Button> <Button variant="outline" @click="onDuplicateSkip">跳过此链接</Button>
<AlertDialogAction @click="onDuplicateConfirm"> <AlertDialogAction @click="onDuplicateConfirm">
<Download class="h-4 w-4" /> <Download class="size-4" />
仍然下载 仍然下载
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@@ -1456,25 +1459,25 @@ const toggleSortOrder = () => {
<DialogContent class="max-w-xl max-h-[80vh]"> <DialogContent class="max-w-xl max-h-[80vh]">
<DialogHeader> <DialogHeader>
<DialogTitle class="flex items-center gap-2"> <DialogTitle class="flex items-center gap-2">
<Info class="h-4 w-4 text-primary" /> <Info class="size-4 text-primary" />
任务详情 任务详情
</DialogTitle> </DialogTitle>
<DialogDescription class="text-xs"> <DialogDescription class="text-xs">
任务 ID{{ detailDialogState.task?.id }} 任务 ID{{ detailTask?.id }}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ScrollArea class="max-h-[55vh] pr-3"> <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 items-start justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0 flex-1"> <div class="flex flex-col gap-0.5 min-w-0 flex-1">
<span class="text-xs text-muted-foreground">文件名</span> <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> </div>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <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="size-7 shrink-0" @click="handleCopyText(detailTask.filename, '文件名')">
<Copy class="h-3.5 w-3.5" /> <Copy class="h-3.5 w-3.5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -1487,9 +1490,9 @@ const toggleSortOrder = () => {
<!-- 状态 --> <!-- 状态 -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">状态</span> <span class="text-xs text-muted-foreground">状态</span>
<Badge :variant="getTaskStatusBadge(detailDialogState.task).variant" class="gap-1"> <Badge :variant="getTaskStatusBadge(detailTask).variant" class="gap-1">
<component :is="getTaskStatusBadge(detailDialogState.task).icon" class="h-3 w-3" /> <component :is="getTaskStatusBadge(detailTask).icon" class="size-3" />
{{ getTaskStatusBadge(detailDialogState.task).text }} {{ getTaskStatusBadge(detailTask).text }}
</Badge> </Badge>
</div> </div>
@@ -1497,11 +1500,11 @@ const toggleSortOrder = () => {
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0 flex-1"> <div class="flex flex-col gap-0.5 min-w-0 flex-1">
<span class="text-xs text-muted-foreground">下载链接</span> <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> </div>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <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="size-7 shrink-0" @click="handleCopyText(detailTask.url, '下载链接')">
<Copy class="h-3.5 w-3.5" /> <Copy class="h-3.5 w-3.5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -1517,15 +1520,15 @@ const toggleSortOrder = () => {
<span class="text-xs text-muted-foreground">保存位置</span> <span class="text-xs text-muted-foreground">保存位置</span>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <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> </TooltipTrigger>
<TooltipContent class="max-w-[400px] break-all">{{ detailDialogState.task.dir }}</TooltipContent> <TooltipContent class="max-w-[480px] break-words">{{ detailTask.dir }}</TooltipContent>
</Tooltip> </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> </div>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <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="size-7 shrink-0" @click="handleCopyText(detailTask.dir + '\\' + detailTask.filename, '完整路径')">
<Copy class="h-3.5 w-3.5" /> <Copy class="h-3.5 w-3.5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -1539,19 +1542,19 @@ const toggleSortOrder = () => {
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">文件总大小</span> <span class="text-xs text-muted-foreground">文件总大小</span>
<span>{{ formatSize(detailDialogState.task.totalSize) }}</span> <span>{{ formatSize(detailTask.totalSize) }}</span>
</div> </div>
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">已下载</span> <span class="text-xs text-muted-foreground">已下载</span>
<span>{{ formatSize(detailDialogState.task.completedSize) }}</span> <span>{{ formatSize(detailTask.completedSize) }}</span>
</div> </div>
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">下载进度</span> <span class="text-xs text-muted-foreground">下载进度</span>
<span>{{ getProgress(detailDialogState.task) }}%</span> <span>{{ getProgress(detailTask) }}%</span>
</div> </div>
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">当前速度</span> <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> <span v-else>-</span>
</div> </div>
</div> </div>
@@ -1562,41 +1565,41 @@ const toggleSortOrder = () => {
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">断点续传</span> <span class="text-xs text-muted-foreground">断点续传</span>
<span>{{ detailDialogState.task.supportsResume ? '支持' : '不支持' }}</span> <span>{{ detailTask.supportsResume ? '支持' : '不支持' }}</span>
</div> </div>
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">连接数 / 分片数</span> <span class="text-xs text-muted-foreground">连接数 / 分片数</span>
<span>{{ detailDialogState.task.segments.length }}</span> <span>{{ detailTask.segments.length }}</span>
</div> </div>
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">创建时间</span> <span class="text-xs text-muted-foreground">创建时间</span>
<span>{{ formatTime(detailDialogState.task.createdAt) }}</span> <span>{{ formatTime(detailTask.createdAt) }}</span>
</div> </div>
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">剩余时间</span> <span class="text-xs text-muted-foreground">剩余时间</span>
<span v-if="detailDialogState.task.status === 'active' && detailDialogState.task.speed > 0"> <span v-if="detailTask.status === 'active' && detailTask.speed > 0">
{{ formatEta(getEta(detailDialogState.task)) }} {{ formatEta(getEta(detailTask)) }}
</span> </span>
<span v-else>-</span> <span v-else>-</span>
</div> </div>
</div> </div>
<!-- 错误信息 --> <!-- 错误信息 -->
<template v-if="detailDialogState.task.error"> <template v-if="detailTask.error">
<Separator /> <Separator />
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">错误信息</span> <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> </div>
</template> </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 /> <Separator />
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">自定义请求头</span> <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 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="text-muted-foreground shrink-0">{{ key }}:</span>
<span class="break-all">{{ value }}</span> <span class="break-all">{{ value }}</span>
</div> </div>
@@ -1605,12 +1608,12 @@ const toggleSortOrder = () => {
</template> </template>
<!-- 分段详情 --> <!-- 分段详情 -->
<template v-if="detailDialogState.task.segments.length > 1"> <template v-if="detailTask.segments.length > 1">
<Separator /> <Separator />
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="text-xs text-muted-foreground">分段详情</span> <span class="text-xs text-muted-foreground">分段详情</span>
<div class="flex flex-col gap-1.5"> <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> <span class="w-8 text-muted-foreground shrink-0">#{{ i }}</span>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<Progress :model-value="segmentProgress(seg)" class="h-1.5" /> <Progress :model-value="segmentProgress(seg)" class="h-1.5" />
@@ -1629,8 +1632,8 @@ const toggleSortOrder = () => {
<DialogClose as-child> <DialogClose as-child>
<Button variant="outline">关闭</Button> <Button variant="outline">关闭</Button>
</DialogClose> </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" /> <FolderOpen class="size-4" />
打开目录 打开目录
</Button> </Button>
</DialogFooter> </DialogFooter>
+26 -4
View File
@@ -5,22 +5,44 @@ const searchItems: SearchIndexItem[] = [
{ {
title: '下载任务', title: '下载任务',
description: '查看与管理下载任务', description: '查看与管理下载任务',
keywords: ['下载', 'download', '任务', 'task'] keywords: ['下载', 'download', '任务', 'task'],
tab: 'tasks'
}, },
{ {
title: '添加下载', title: '添加下载',
description: '添加 HTTP/HTTPS 直链下载', description: '添加 HTTP/HTTPS 直链下载',
keywords: ['添加', '链接', 'url', 'add', '新建'] keywords: ['添加', '链接', 'url', 'add', '新建'],
tab: 'tasks'
}, },
{ {
title: '下载设置', title: '下载设置',
description: '配置下载目录、并发数与速度限制', description: '配置下载目录、并发数与速度限制',
keywords: ['设置', 'setting', '速度', '目录', '并发'] keywords: ['设置', 'setting', '速度', '目录', '并发'],
tab: 'settings'
},
{
title: '下载目录',
description: '设置任务默认保存目录',
keywords: ['目录', '保存', '路径', 'dir', 'folder', '下载位置'],
tab: 'settings'
},
{
title: '并发下载数',
description: '设置同时下载的任务数量上限',
keywords: ['并发', '数量', 'concurrent', '线程'],
tab: 'settings'
},
{
title: '速度限制',
description: '设置全局下载/上传限速',
keywords: ['限速', '速度', '速率', 'rate', 'limit', '带宽'],
tab: 'settings'
}, },
{ {
title: '浏览器扩展', title: '浏览器扩展',
description: '安装 Thing Extension 接管浏览器下载', description: '安装 Thing Extension 接管浏览器下载',
keywords: ['扩展', 'extension', '浏览器', 'chrome', 'edge'] keywords: ['扩展', 'extension', '浏览器', 'chrome', 'edge'],
tab: 'extension'
} }
] ]
-24
View File
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { Search } from '@lucide/vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
</script>
<template>
<div class="h-full p-6 overflow-y-auto">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Search class="h-5 w-5 text-primary" />
文件搜索模块
</CardTitle>
</CardHeader>
<CardContent>
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
<Search class="h-16 w-16 mb-4 opacity-50" />
<p>文件搜索功能开发中...</p>
<p class="text-sm mt-2">支持快速搜索弹窗文件搜索应用程序搜索等功能</p>
</div>
</CardContent>
</Card>
</div>
</template>
-22
View File
@@ -1,22 +0,0 @@
import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex'
const searchItems: SearchIndexItem[] = [
{
title: '文件搜索',
description: '搜索本地文件',
keywords: ['文件', '搜索', 'finder', 'search', 'file']
}
]
export const moduleConfig: ModuleConfig = {
id: 'finder',
name: '文件搜索',
icon: 'finder',
description: '快速文件搜索、拼音模糊匹配',
category: 'tool',
defaultEnabled: true,
loader: () => import('./FinderModule.vue'),
searchItems,
order: 60
}

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