下载模块 Init

This commit is contained in:
zhongluofeng
2026-07-20 18:28:34 +08:00
parent e84958e0fc
commit 548b022426
37 changed files with 4795 additions and 65 deletions
+132
View File
@@ -490,3 +490,135 @@ const installKernel = async () => {
TitleBar 的关闭按钮实际是 `hide()` 到托盘。webview 快速隐藏时浏览器 `mouseleave` 可能不触发,导致从托盘恢复后按钮仍显示 hover 高亮。 TitleBar 的关闭按钮实际是 `hide()` 到托盘。webview 快速隐藏时浏览器 `mouseleave` 可能不触发,导致从托盘恢复后按钮仍显示 hover 高亮。
**修复**`hide()` 前调用 `document.activeElement.blur()`,并监听 `onFocusChanged` 在窗口重新获得焦点时再 blur 一次。新模块若有类似的"隐藏窗口"操作(如全局快捷键隐藏),同样需要 blur。 **修复**`hide()` 前调用 `document.activeElement.blur()`,并监听 `onFocusChanged` 在窗口重新获得焦点时再 blur 一次。新模块若有类似的"隐藏窗口"操作(如全局快捷键隐藏),同样需要 blur。
### 前端:浮动标签切换器(TabsList 滚动遮挡时在 TitleBar 显示)
模块详情页内容滚动时,顶部 `TabsList` 会被 `TitleBar` 遮挡,导致用户必须滚回顶部才能切换 Tab。已抽取通用 composable `src/lib/useModuleTabs.ts` 自动处理。
**接入方式**(任何使用 Tabs 的模块都可用,代理/下载器模块已接入):
```ts
// 模块 <script setup> 顶部
import { useModuleTabs } from '@/lib/useModuleTabs'
const activeTab = ref('overview')
const tabsListRef = useModuleTabs(activeTab, [
{ value: 'overview', label: '概览' },
{ value: 'settings', label: '设置' }
])
```
```vue
<!-- 模板中给 TabsList 包一层带 ref div -->
<Tabs v-model="activeTab">
<div ref="tabsListRef">
<TabsList>...</TabsList>
</div>
<TabsContent .../>
</Tabs>
```
**工作原理**
- composable 内部在 `onMounted` 时注册标签到 `moduleTabsStore``onUnmounted` 时注销
-`IntersectionObserver``rootMargin: '-44px 0px 0px 0px'` 裁剪 TitleBar 高度)监听 TabsList 可见性
- 滚动遮挡时 `TitleBar` 中"Thing"标题右侧自动显示浮动切换按钮(带淡入+左滑动画)
- 双向 watch 同步本地 `activeTab` 与 store,用户点击 TitleBar 浮动按钮也能切换模块内 Tab
**约束**TitleBar 高度固定 40pxh-10),composable 已用 44px 裁剪(含 4px 缓冲);store 是单例,一个模块同一时间只能注册一组标签。
### 前端:内核路径用 %APPDATA% 简化显示
内核路径较长(如 `C:\Users\xxx\AppData\Roaming\thing.lfeng.me\proxy\cores\mihomo.exe`),展示时用 `%APPDATA%` 替换前缀,并提供复制完整路径 / 在文件夹中显示两个按钮。代理和下载器模块的内核管理区块已采用此模式。
```ts
import { appDataDir } from '@tauri-apps/api/path'
const appDataPath = ref('')
onMounted(async () => {
try { appDataPath.value = await appDataDir() } catch {}
})
const pathDisplay = computed(() => {
const p = store.kernel?.path
if (!p) return ''
if (appDataPath.value && p.toLowerCase().startsWith(appDataPath.value.toLowerCase())) {
return '%APPDATA%' + p.slice(appDataPath.value.length)
}
return p
})
```
`<title>` 放完整路径,显示文本用 `pathDisplay`,复制时复制完整路径。
### 前端:任务历史 localStorage 持久化(下载器模块模式)
子进程未启动时仍想展示历史数据(如下载任务列表),可在 store 中用 localStorage 保存最近一次的任务快照:
- 每次全量刷新后调用 `persistHistory()` 保存(合并去重,按状态优先级排序,限制条数如 200)
- `onMounted` 时无论子进程是否运行都先调用 `loadHistory()` 填充到 `stoppedTasks`
- 子进程启动后实时数据会覆盖历史快照
此模式适用于任何"子进程不运行时也要展示历史"的场景。
### 前端:轻量设置 Dialog(替代独立 Tab)
模块设置项较多时,传统做法是单独开一个"设置"Tab。但任务页工具栏需要快速调整少量核心设置(如下载目录、并发数),切到设置 Tab 再切回来体验割裂。
**模式**:在任务工具栏放一个"下载设置"按钮,点击弹出 Dialog,包含核心设置项(与设置页共用 `store.settings`),保存时调用 `handleSaveSettings`(重启子进程应用配置)并自动关闭弹窗。完整设置仍保留在"设置"Tab。
适用场景:需要在任务页快速调整的少量高频设置;若设置项不多,可完全用 Dialog 替代设置 Tab。
### 后端:opener 插件 scope 限制与绕过
`tauri-plugin-opener``opener:allow-open-path` 权限默认 **无 scope**IPC `open_path` 命令会拒绝任何路径并报错 `Not allowed to open path`
**解决方案**:在 Rust 端写一个自定义命令,用 `OpenerExt::opener().open_path()` 直接调用(绕过 IPC scope 检查):
```rust
use tauri_plugin_opener::OpenerExt;
#[tauri::command]
pub fn my_open_dir(app: AppHandle, path: String) -> Result<(), String> {
let p = std::path::Path::new(&path);
if !p.exists() {
return Err(format!("路径不存在: {}", path));
}
app.opener().open_path(path, None::<&str>).map_err(|e| e.to_string())
}
```
`lib.rs``invoke_handler` 中注册,前端通过 `invoke('my_open_dir', { path })` 调用。下载器模块的"打开下载目录"已采用此方案。
### 前端:剩余时间(ETA)可读化
下载卡片直接显示 `(total-completed)/speed` 会得到 `17.217666215634114 B` 这种难懂的字节数。应格式化为时间:
```ts
const formatEta = (seconds: number): string => {
if (!isFinite(seconds) || seconds <= 0) return ''
if (seconds < 60) return `剩余 ${Math.ceil(seconds)}`
if (seconds < 3600) {
const m = Math.floor(seconds / 60)
const s = Math.ceil(seconds % 60)
return `剩余 ${m}${s}`
}
const h = Math.floor(seconds / 3600)
const m = Math.ceil((seconds % 3600) / 60)
return `剩余 ${h} 小时 ${m}`
}
```
`getEta(task) = (totalLength - completedLength) / downloadSpeed`,speed≤0 时返回 0(不显示)。
### 前端:状态统计用 Badge 替代纯文本
下载任务工具栏的速度/活跃/等待/已完成数量,以及代理概览页的内核状态/版本,从纯文本改为 `Badge variant="secondary"` 或带颜色的 `Badge`(绿色已安装/红色未安装),视觉更醒目。新模块的状态展示建议统一用 Badge。
### 后端:应用退出清理必须覆盖所有退出路径
`lib.rs` 中应用退出有两种触发路径,**都要**调用 `cleanup_on_exit` + `stop_all`
1. `quit_app` Tauri 命令(前端 `appWindow.destroy()` 触发)
2. 系统托盘的退出菜单项
漏掉任何一个都会导致子进程残留(如 aria2 继续占用端口、mihomo 系统代理未清除)。新增管理子进程的模块时,检查这两处是否都调用了清理逻辑。
+7 -3
View File
@@ -76,7 +76,7 @@ Thing/
### 第二阶段:核心模块开发 ### 第二阶段:核心模块开发
#### 🌐 代理管理 #### 🌐 代理管理
- [x] 内置 mihomo (Clash.Meta) 内核集成 - [x] 下载/内置 mihomo (Clash.Meta) 内核集成
- [x] 系统代理切换 - [x] 系统代理切换
- [x] 规则配置管理 - [x] 规则配置管理
- [x] 延迟测速 - [x] 延迟测速
@@ -97,16 +97,20 @@ Thing/
- [ ] 图片编辑工具 - [ ] 图片编辑工具
#### 📊 硬件监控 #### 📊 硬件监控
- [ ] 任务栏快捷显示
- [ ] CPU 使用率监控 - [ ] CPU 使用率监控
- [ ] GPU 使用率监控 - [ ] GPU 使用率监控
- [ ] 内存使用率监控 - [ ] 内存使用率监控
- [ ] 硬盘温度及使用率 - [ ] 硬盘温度及使用率
- [ ] 传感器数据可视化 - [ ] 传感器数据可视化
- [ ] 网速监控
#### ⬇️ 下载器 #### ⬇️ 下载器
- [ ] 下载/内置 aria2 内核集成
- [ ] 接管浏览器下载,创建edge插件(Thing Extension)?
- [ ] HTTP 下载支持 - [ ] HTTP 下载支持
- [ ] BT/磁力链接支持aria2 - [ ] BT/磁力链接支持
- [ ] 下载任务管理 - [ ] 下载任务管理(历史)
- [ ] 速度限制 - [ ] 速度限制
- [ ] 断点续传 - [ ] 断点续传
+5 -2
View File
@@ -5,10 +5,11 @@
"": { "": {
"name": "thing", "name": "thing",
"dependencies": { "dependencies": {
"@lucide/vue": "^1.24.0", "@lucide/vue": "^1.25.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-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -115,7 +116,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.24.0", "https://registry.npmmirror.com/@lucide/vue/-/vue-1.24.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-5bNPX0G2YEWdUlBYk7pE8SgDg/f1mkIFpJ9vtE44pW/cwRz7Ioc0tOTESoVJAPvxIELSmYekX+XXIJMjsswNIg=="], "@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=="],
"@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=="],
@@ -231,6 +232,8 @@
"@tauri-apps/plugin-autostart": ["@tauri-apps/plugin-autostart@2.5.1", "https://registry.npmmirror.com/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w=="], "@tauri-apps/plugin-autostart": ["@tauri-apps/plugin-autostart@2.5.1", "https://registry.npmmirror.com/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w=="],
"@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-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=="],
+10
View File
@@ -12,6 +12,7 @@
"@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-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -1445,6 +1446,15 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
"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",
+1
View File
@@ -14,6 +14,7 @@
"@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-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+165 -1
View File
@@ -823,6 +823,15 @@ dependencies = [
"dirs-sys 0.3.7", "dirs-sys 0.3.7",
] ]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
@@ -843,6 +852,18 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "dirs-sys" name = "dirs-sys"
version = "0.5.0" version = "0.5.0"
@@ -2465,6 +2486,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -3088,6 +3110,30 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3933,6 +3979,48 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.4" version = "2.5.4"
@@ -4082,6 +4170,7 @@ name = "thing"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs 5.0.1",
"futures-util", "futures-util",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
@@ -4090,6 +4179,7 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-autostart", "tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-opener", "tauri-plugin-opener",
"tokio", "tokio",
"windows-sys 0.52.0", "windows-sys 0.52.0",
@@ -5081,6 +5171,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -5129,13 +5228,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.1.0" version = "0.1.0"
@@ -5172,6 +5288,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -5190,6 +5312,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@@ -5208,12 +5336,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@@ -5232,6 +5372,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@@ -5250,6 +5396,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@@ -5268,6 +5420,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -5286,6 +5444,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.40" version = "0.5.40"
+3 -1
View File
@@ -20,14 +20,16 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["tray-icon"] } tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
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"
tokio = { version = "1", features = ["io-util"] } tokio = { version = "1", features = ["io-util", "time"] }
zip = "2" zip = "2"
dirs = "5"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.52" winreg = "0.52"
+2
View File
@@ -7,6 +7,8 @@
"core:default", "core:default",
"opener:default", "opener:default",
"opener:allow-reveal-item-in-dir", "opener:allow-reveal-item-in-dir",
"opener:allow-open-path",
"dialog:default",
"core:window:allow-minimize", "core:window:allow-minimize",
"core:window:allow-maximize", "core:window:allow-maximize",
"core:window:allow-close", "core:window:allow-close",
@@ -0,0 +1,192 @@
/**
* Thing Extension - 后台 Service Worker
*
* 职责:
* 1. 接管浏览器下载,转发到 aria2
* 2. 与本应用(可选)通过 JSON-RPC 通信
* 3. 提供右键菜单"使用 aria2 下载链接"
* 4. 预留资源嗅探能力(webRequest 监听)
*
* 注:Service Worker 是短生命周期的,配置需持久化到 chrome.storage
*/
// ===== 默认配置 =====
const DEFAULT_CONFIG = {
rpcUrl: 'http://127.0.0.1:6800/jsonrpc',
rpcSecret: '',
// 是否拦截浏览器原生下载
interceptDownload: true,
// 文件大小阈值(字节),超过才转 aria2。0 = 全部转
minSize: 0,
// 排除的域名(这些域名的下载走浏览器原生)
excludeDomains: [],
// 是否显示桌面通知
showNotifications: true
}
// ===== 配置读取 =====
async function getConfig() {
const stored = await chrome.storage.local.get('config')
return { ...DEFAULT_CONFIG, ...(stored.config || {}) }
}
async function saveConfig(config) {
await chrome.storage.local.set({ config })
}
// ===== aria2 JSON-RPC 调用 =====
async function aria2Call(method, params = []) {
const config = await getConfig()
const rpcParams = []
if (config.rpcSecret) {
rpcParams.push(`token:${config.rpcSecret}`)
}
rpcParams.push(...params)
const resp = await fetch(config.rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: Date.now().toString(),
method,
params: rpcParams
})
})
if (!resp.ok) {
throw new Error(`aria2 RPC HTTP ${resp.status}`)
}
const data = await resp.json()
if (data.error) {
throw new Error(`aria2 RPC error: ${data.error.message} (${data.error.code})`)
}
return data.result
}
/**
* 添加 URI 下载任务到 aria2
* @returns {string} gid
*/
async function aria2AddUri(url, filename, referer, cookies) {
const options = {}
if (filename) options.out = filename
if (referer) options.referer = referer
if (cookies) options.header = [`Cookie: ${cookies}`]
// User-Agent 用浏览器默认值更兼容
options['user-agent'] = navigator.userAgent
return aria2Call('aria2.addUri', [[url], options])
}
// ===== 下载拦截 =====
async function shouldIntercept(downloadItem) {
const config = await getConfig()
if (!config.interceptDownload) return false
// 大小阈值
if (config.minSize > 0 && downloadItem.fileSize > 0 && downloadItem.fileSize < config.minSize) {
return false
}
// 域名排除
try {
const url = new URL(downloadItem.finalUrl || downloadItem.url)
if (config.excludeDomains.some(d => url.hostname.includes(d))) {
return false
}
} catch { /* ignore */ }
return true
}
async function handleDownloadCreated(downloadItem) {
if (!await shouldIntercept(downloadItem)) return
// 立即取消浏览器原生下载
try {
await chrome.downloads.cancel(downloadItem.id)
await chrome.downloads.erase({ id: downloadItem.id })
} catch { /* ignore */ }
const url = downloadItem.finalUrl || downloadItem.url
const filename = downloadItem.filename || ''
try {
const gid = await aria2AddUri(url, filename, downloadItem.referrer, '')
await notify('已添加到 aria2', `${filename || url}\nGID: ${gid}`)
} catch (e) {
await notify('aria2 添加失败', `${filename || url}\n${e.message}`)
// 失败时把 URL 重新交给浏览器下载
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.contextMenus.create({
id: 'thing-download-link',
title: '使用 aria2 下载此链接',
contexts: ['link']
})
chrome.contextMenus.create({
id: 'thing-download-page',
title: '使用 aria2 下载当前页面资源',
contexts: ['page']
})
})
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === 'thing-download-link') {
const url = info.linkUrl
const filename = url.split('/').pop()?.split('?')[0] || ''
try {
const gid = await aria2AddUri(url, filename, info.pageUrl, '')
await notify('已添加到 aria2', `${filename || url}\nGID: ${gid}`)
} catch (e) {
await notify('aria2 添加失败', `${e.message}`)
}
}
})
// ===== 下载事件监听 =====
chrome.downloads.onCreated.addListener(handleDownloadCreated)
// ===== 资源嗅探(预留,仅日志,不拦截)=====
// 后续可启用:监听页面媒体资源,提供"嗅探到的资源"列表
chrome.webRequest.onBeforeRequest.addListener(
(details) => {
// 预留:识别视频/音频流等可下载资源
// 当前不处理,仅保留权限和入口
return undefined
},
{ urls: ['<all_urls>'] },
[]
)
// ===== 来自 popup 的消息 =====
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === 'getConfig') {
getConfig().then(sendResponse)
return true
}
if (msg.type === 'saveConfig') {
saveConfig(msg.config).then(() => sendResponse({ ok: true })).catch(e => sendResponse({ ok: false, error: e.message }))
return true
}
if (msg.type === 'testConnection') {
aria2Call('aria2.getVersion', [])
.then(res => sendResponse({ ok: true, version: res.version }))
.catch(e => sendResponse({ ok: false, error: e.message }))
return true
}
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

@@ -0,0 +1,41 @@
{
"manifest_version": 3,
"name": "Thing Extension",
"version": "0.1.0",
"description": "接管浏览器下载,将任务发送到 aria2 内核。支持后续资源嗅探能力扩展。",
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"permissions": [
"downloads",
"storage",
"notifications",
"webRequest",
"webNavigation",
"contextMenus"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"default_title": "Thing Extension"
},
"options_ui": {
"page": "popup.html",
"open_in_tab": false
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
}
}
@@ -0,0 +1,138 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 340px;
font-family: system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
font-size: 13px;
color: #1f2937;
background: #ffffff;
}
.container {
padding: 14px;
}
header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.logo {
width: 32px;
height: 32px;
border-radius: 6px;
}
.title h1 {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.subtitle {
font-size: 11px;
color: #6b7280;
}
.status {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
background: #f3f4f6;
margin-bottom: 12px;
font-size: 12px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9ca3af;
}
.dot.ok {
background: #10b981;
}
.dot.fail {
background: #ef4444;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.field span {
font-size: 11px;
color: #4b5563;
font-weight: 500;
}
.field input {
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
font-family: 'JetBrains Mono', 'Consolas', monospace;
}
.field input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.checkbox {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
cursor: pointer;
user-select: none;
}
.checkbox input {
cursor: pointer;
}
.actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.actions button {
flex: 1;
padding: 7px 10px;
border: 1px solid #d1d5db;
background: #ffffff;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.actions button:hover {
background: #f9fafb;
}
.actions button.primary {
background: #111827;
color: #ffffff;
border-color: #111827;
}
.actions button.primary:hover {
background: #1f2937;
}
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Thing Extension</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div class="container">
<header>
<img src="icons/icon-48.png" alt="Thing" class="logo" />
<div class="title">
<h1>Thing Extension</h1>
<span class="subtitle">aria2 下载接管</span>
</div>
</header>
<section class="status" id="statusBox">
<span class="dot" id="statusDot"></span>
<span id="statusText">检测中...</span>
</section>
<form id="configForm">
<label class="field">
<span>RPC 地址</span>
<input type="text" id="rpcUrl" placeholder="http://127.0.0.1:6800/jsonrpc" />
</label>
<label class="field">
<span>RPC 密钥</span>
<input type="password" id="rpcSecret" placeholder="未设置时留空" />
</label>
<label class="checkbox">
<input type="checkbox" id="interceptDownload" />
<span>接管浏览器下载</span>
</label>
<label class="checkbox">
<input type="checkbox" id="showNotifications" />
<span>显示桌面通知</span>
</label>
<label class="field">
<span>最小文件大小(字节,0=全部)</span>
<input type="number" id="minSize" min="0" placeholder="0" />
</label>
<label class="field">
<span>排除域名(逗号分隔)</span>
<input type="text" id="excludeDomains" placeholder="例如:example.com,another.com" />
</label>
<div class="actions">
<button type="button" id="testBtn">测试连接</button>
<button type="submit" class="primary">保存</button>
</div>
</form>
</div>
<script src="popup.js"></script>
</body>
</html>
@@ -0,0 +1,96 @@
/**
* Thing Extension - Popup 脚本
* 配置 RPC 地址、密钥等参数,存储到 chrome.storage.local
*/
const $ = (id) => document.getElementById(id)
const DEFAULT_CONFIG = {
rpcUrl: 'http://127.0.0.1:6800/jsonrpc',
rpcSecret: '',
interceptDownload: true,
minSize: 0,
excludeDomains: [],
showNotifications: true
}
function setStatus(state, text) {
const dot = $('statusDot')
const txt = $('statusText')
dot.className = 'dot ' + (state === 'ok' ? 'ok' : state === 'fail' ? 'fail' : '')
txt.textContent = text
}
async function loadConfig() {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'getConfig' }, (config) => {
resolve(config || { ...DEFAULT_CONFIG })
})
})
}
function fillForm(config) {
$('rpcUrl').value = config.rpcUrl || DEFAULT_CONFIG.rpcUrl
$('rpcSecret').value = config.rpcSecret || ''
$('interceptDownload').checked = config.interceptDownload !== false
$('showNotifications').checked = config.showNotifications !== false
$('minSize').value = config.minSize || 0
$('excludeDomains').value = (config.excludeDomains || []).join(',')
}
function readForm() {
return {
rpcUrl: $('rpcUrl').value.trim() || DEFAULT_CONFIG.rpcUrl,
rpcSecret: $('rpcSecret').value.trim(),
interceptDownload: $('interceptDownload').checked,
showNotifications: $('showNotifications').checked,
minSize: parseInt($('minSize').value, 10) || 0,
excludeDomains: $('excludeDomains').value
.split(',')
.map(s => s.trim())
.filter(Boolean)
}
}
document.getElementById('configForm').addEventListener('submit', async (e) => {
e.preventDefault()
const config = readForm()
chrome.runtime.sendMessage({ type: 'saveConfig', config }, (res) => {
if (res && res.ok) {
setStatus('', '已保存')
setTimeout(() => window.close(), 500)
} else {
setStatus('fail', '保存失败:' + (res?.error || '未知错误'))
}
})
})
$('testBtn').addEventListener('click', () => {
setStatus('', '测试中...')
// 先保存当前表单值,再测试
const config = readForm()
chrome.runtime.sendMessage({ type: 'saveConfig', config }, () => {
chrome.runtime.sendMessage({ type: 'testConnection' }, (res) => {
if (res && res.ok) {
setStatus('ok', `已连接 · aria2 ${res.version}`)
} else {
setStatus('fail', '连接失败:' + (res?.error || '未知错误'))
}
})
})
})
// 初始化
;(async () => {
const config = await loadConfig()
fillForm(config)
// 自动测试一次连接
setStatus('', '检测中...')
chrome.runtime.sendMessage({ type: 'testConnection' }, (res) => {
if (res && res.ok) {
setStatus('ok', `已连接 · aria2 ${res.version}`)
} else {
setStatus('fail', '未连接')
}
})
})()
File diff suppressed because it is too large Load Diff
+54 -3
View File
@@ -1,9 +1,18 @@
use tauri::Manager; use tauri::Manager;
mod aria2_manager;
mod logger; mod logger;
mod mihomo_manager; mod mihomo_manager;
mod process_manager; mod process_manager;
use aria2_manager::{
Aria2Manager, downloader_add_uri, downloader_change_global_option, downloader_check_kernel_update,
downloader_get_active, downloader_get_global_stat, downloader_get_rpc_info, downloader_get_settings,
downloader_get_status, downloader_get_stopped, downloader_get_waiting, downloader_install_kernel,
downloader_kernel_info, downloader_open_dir, downloader_open_url, downloader_pause, downloader_remove, downloader_restart, downloader_save_settings,
downloader_start, downloader_status, downloader_stop, downloader_unpause, downloader_update_kernel,
downloader_version,
};
use logger::{ use logger::{
clear_logs, get_log_info, get_logs, log_message, LogManager, clear_logs, get_log_info, get_logs, log_message, LogManager,
}; };
@@ -28,9 +37,12 @@ fn greet(name: &str) -> String {
fn quit_app( fn quit_app(
state: tauri::State<'_, ProcessManager>, state: tauri::State<'_, ProcessManager>,
mihomo: tauri::State<'_, MihomoManager>, mihomo: tauri::State<'_, MihomoManager>,
aria2: tauri::State<'_, Aria2Manager>,
) { ) {
// 退出前清理系统代理,避免遗留导致网络问题 // 退出前清理系统代理,避免遗留导致网络问题
mihomo.cleanup_on_exit(); mihomo.cleanup_on_exit();
// 退出前让 aria2 优雅关闭(保存 session
aria2.cleanup_on_exit();
// 停止所有子进程 // 停止所有子进程
state.stop_all(); state.stop_all();
std::process::exit(0); std::process::exit(0);
@@ -41,6 +53,7 @@ pub fn run() {
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())
.manage(ProcessManager::new()) .manage(ProcessManager::new())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
greet, greet,
@@ -77,7 +90,31 @@ pub fn run() {
proxy_activate_profile, proxy_activate_profile,
proxy_set_system_proxy, proxy_set_system_proxy,
proxy_clear_system_proxy, proxy_clear_system_proxy,
proxy_get_system_proxy proxy_get_system_proxy,
downloader_get_settings,
downloader_save_settings,
downloader_kernel_info,
downloader_check_kernel_update,
downloader_update_kernel,
downloader_install_kernel,
downloader_status,
downloader_start,
downloader_stop,
downloader_restart,
downloader_version,
downloader_get_global_stat,
downloader_get_active,
downloader_get_waiting,
downloader_get_stopped,
downloader_get_status,
downloader_add_uri,
downloader_pause,
downloader_unpause,
downloader_remove,
downloader_change_global_option,
downloader_get_rpc_info,
downloader_open_dir,
downloader_open_url
]) ])
.setup(|app| { .setup(|app| {
// 初始化日志系统,日志目录: {app_data_dir}/logs/ // 初始化日志系统,日志目录: {app_data_dir}/logs/
@@ -93,9 +130,13 @@ pub fn run() {
.path() .path()
.app_data_dir() .app_data_dir()
.unwrap_or_else(|_| std::path::PathBuf::from(".")); .unwrap_or_else(|_| std::path::PathBuf::from("."));
let mihomo = MihomoManager::new(app_data_dir); let mihomo = MihomoManager::new(app_data_dir.clone());
app.manage(mihomo); app.manage(mihomo);
// 初始化 Aria2Manager,数据目录: {app_data_dir}/downloader/
let aria2 = Aria2Manager::new(app_data_dir);
app.manage(aria2);
let open = tauri::menu::MenuItem::with_id(app, "open", "设置", true, None::<&str>)?; let open = tauri::menu::MenuItem::with_id(app, "open", "设置", true, None::<&str>)?;
let quit = tauri::menu::MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; let quit = tauri::menu::MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?; let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?;
@@ -112,10 +153,13 @@ pub fn run() {
} }
} }
"quit" => { "quit" => {
// 退出前清理系统代理 + 停止所有子进程 // 退出前清理系统代理 + 优雅关闭 aria2 + 停止所有子进程
if let Some(mihomo) = app.try_state::<MihomoManager>() { if let Some(mihomo) = app.try_state::<MihomoManager>() {
mihomo.cleanup_on_exit(); mihomo.cleanup_on_exit();
} }
if let Some(aria2) = app.try_state::<Aria2Manager>() {
aria2.cleanup_on_exit();
}
if let Some(pm) = app.try_state::<ProcessManager>() { if let Some(pm) = app.try_state::<ProcessManager>() {
pm.stop_all(); pm.stop_all();
} }
@@ -148,6 +192,13 @@ pub fn run() {
} }
} }
// 应用启动时自动启动 aria2(如果用户在设置中开启了自动启动)
if let Some(aria2) = app.try_state::<Aria2Manager>() {
if let Some(pm) = app.try_state::<ProcessManager>() {
aria2.auto_start_on_launch(app.handle(), &pm);
}
}
Ok(()) Ok(())
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
+1 -1
View File
@@ -37,6 +37,6 @@
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
], ],
"resources": ["binaries/*"] "resources": ["binaries/*", "resources/thing-extension/**/*"]
} }
} }
+38
View File
@@ -4,6 +4,9 @@ import { Search, Minus, Square, X, Settings, ChevronRight } from '@lucide/vue'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
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'
const tabsStore = useModuleTabsStore()
const props = defineProps<{ const props = defineProps<{
modules: Array<{ id: string; name: string; icon: string }> modules: Array<{ id: string; name: string; icon: string }>
@@ -119,6 +122,30 @@ const handleBlur = () => {
data-tauri-drag-region data-tauri-drag-region
> >
<span class="font-semibold text-sm">Thing</span> <span class="font-semibold text-sm">Thing</span>
<!-- 浮动标签切换器模块内 TabsList 滚出可视区时显示 -->
<Transition name="floating-tabs">
<div
v-if="tabsStore.floatingVisible && tabsStore.tabs.length > 0"
class="flex items-center gap-0.5 ml-2 pointer-events-auto"
>
<button
v-for="tab in tabsStore.tabs"
:key="tab.value"
class="px-2.5 py-1 text-xs font-medium rounded-md transition-colors"
:class="
tabsStore.activeTab === tab.value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
"
@click="tabsStore.setActiveTab(tab.value)"
@mousedown.stop
>
{{ tab.label }}
</button>
</div>
</Transition>
<div class="flex-1"></div> <div class="flex-1"></div>
<div class="relative max-w-xs mr-3 pointer-events-auto"> <div class="relative max-w-xs mr-3 pointer-events-auto">
<Search <Search
@@ -211,4 +238,15 @@ const handleBlur = () => {
.hover-suppressed button:hover { .hover-suppressed button:hover {
background-color: transparent !important; background-color: transparent !important;
} }
/* 浮动标签切换器进出动画:淡入 + 从左侧滑入 */
.floating-tabs-enter-active,
.floating-tabs-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.floating-tabs-enter-from,
.floating-tabs-leave-to {
opacity: 0;
transform: translateX(-12px);
}
</style> </style>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PaginationRootEmits, PaginationRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<PaginationRootProps & {
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<PaginationRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PaginationRoot
v-slot="slotProps"
data-slot="pagination"
v-bind="forwarded"
:class="cn('mx-auto flex w-full justify-center', props.class)"
>
<slot v-bind="slotProps" />
</PaginationRoot>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { PaginationListProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationList } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<PaginationListProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<PaginationList
v-slot="slotProps"
data-slot="pagination-content"
v-bind="delegatedProps"
:class="cn('flex flex-row items-center gap-1', props.class)"
>
<slot v-bind="slotProps" />
</PaginationList>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { PaginationEllipsisProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { MoreHorizontal } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationEllipsis } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<PaginationEllipsis
data-slot="pagination-ellipsis"
v-bind="delegatedProps"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
<span class="sr-only">More pages</span>
</slot>
</PaginationEllipsis>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationFirstProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '@/components/ui/button'
import { ChevronLeftIcon } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationFirst, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationFirstProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationFirst
data-slot="pagination-first"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">First</span>
</slot>
</PaginationFirst>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { PaginationListItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '@/components/ui/button'
import { reactiveOmit } from "@vueuse/core"
import { PaginationListItem } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationListItemProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
isActive?: boolean
}>(), {
size: "icon",
})
const delegatedProps = reactiveOmit(props, "class", "size", "isActive")
</script>
<template>
<PaginationListItem
data-slot="pagination-item"
v-bind="delegatedProps"
:class="cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
props.class)"
>
<slot />
</PaginationListItem>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationLastProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '@/components/ui/button'
import { ChevronRightIcon } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationLast, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationLastProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationLast
data-slot="pagination-last"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Last</span>
<ChevronRightIcon />
</slot>
</PaginationLast>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationNextProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '@/components/ui/button'
import { ChevronRightIcon } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationNext, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationNextProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationNext
data-slot="pagination-next"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Next</span>
<ChevronRightIcon />
</slot>
</PaginationNext>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationPrevProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '@/components/ui/button'
import { ChevronLeftIcon } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationPrev, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationPrevProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationPrev
data-slot="pagination-previous"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">Previous</span>
</slot>
</PaginationPrev>
</template>
+8
View File
@@ -0,0 +1,8 @@
export { default as Pagination } from "./Pagination.vue"
export { default as PaginationContent } from "./PaginationContent.vue"
export { default as PaginationEllipsis } from "./PaginationEllipsis.vue"
export { default as PaginationFirst } from "./PaginationFirst.vue"
export { default as PaginationItem } from "./PaginationItem.vue"
export { default as PaginationLast } from "./PaginationLast.vue"
export { default as PaginationNext } from "./PaginationNext.vue"
export { default as PaginationPrevious } from "./PaginationPrevious.vue"
+1 -1
View File
@@ -16,7 +16,7 @@ const forwardedProps = useForwardProps(delegatedProps)
<TabsTrigger <TabsTrigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
:class="cn( :class="cn(
'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-3 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', 'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,background-color,box-shadow] duration-200 ease-out focus-visible:ring-3 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class, props.class,
)" )"
v-bind="forwardedProps" v-bind="forwardedProps"
+93
View File
@@ -0,0 +1,93 @@
import { nextTick, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
/**
* 模块标签栏通用 composable。
*
* 让任何模块的 TabsList 在滚动被 TitleBar 遮挡时,
* 自动在 TitleBar 中显示一组浮动切换按钮。
*
* ## 使用方式
*
* ```ts
* // 模块 <script setup> 顶部
* const activeTab = ref('overview')
* const tabsListRef = useModuleTabs(activeTab, [
* { value: 'overview', label: '概览' },
* { value: 'settings', label: '设置' }
* ])
* ```
*
* ```vue
* <!-- 模板中给 TabsList 包一层带 ref 的 div -->
* <div ref="tabsListRef">
* <TabsList>...</TabsList>
* </div>
* ```
*
* ## 工作原理
* 1. onMounted 时注册标签到 moduleTabsStoreTitleBar 据此渲染浮动切换器
* 2. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
* 3. 双向 watch 同步本地 activeTab 与 store.activeTab
* 4. onUnmounted 时清理 observer 并注销标签
*
* ## 约束
* - TitleBar 高度固定为 40px (h-10)composable 内部已用 44px 裁剪(含缓冲)
* - 一个模块同一时间只能注册一组标签(store 是单例)
* - 模块卸载时务必让 composable 的 onUnmounted 执行(已自动处理,无需手动调用)
*/
export function useModuleTabs(
activeTab: Ref<string>,
tabs: ModuleTab[]
): Ref<HTMLElement | null> {
const tabsStore = useModuleTabsStore()
const tabsListRef = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
const setupObserver = () => {
const el = tabsListRef.value
if (!el || observer) return
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
tabsStore.setFloatingVisible(!entry.isIntersecting)
}
},
{
// root=null 表示视口;顶部裁剪 44pxTitleBar 高度 40px + 4px 缓冲)
rootMargin: '-44px 0px 0px 0px',
threshold: 0
}
)
observer.observe(el)
}
// 本地 activeTab → store(用户点击模块内 TabsTrigger 时同步)
watch(activeTab, (val) => {
tabsStore.setActiveTab(val)
})
// store activeTab → 本地(用户点击 TitleBar 浮动切换器时同步)
watch(() => tabsStore.activeTab, (val) => {
if (val && val !== activeTab.value) {
activeTab.value = val
}
})
onMounted(async () => {
tabsStore.registerTabs(tabs, activeTab.value)
await nextTick()
setupObserver()
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
observer = null
}
tabsStore.unregisterTabs()
})
return tabsListRef
}
File diff suppressed because it is too large Load Diff
+43 -5
View File
@@ -1,11 +1,27 @@
import type { ModuleConfig } from '@/types/module' import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex' import type { SearchIndexItem } from '@/stores/searchIndex'
import { invoke } from '@tauri-apps/api/core'
const searchItems: SearchIndexItem[] = [ const searchItems: SearchIndexItem[] = [
{ {
title: '下载管理', title: '下载任务',
description: '管理下载任务', description: '查看与管理下载任务',
keywords: ['下载', 'download', '文件', 'file'] keywords: ['下载', 'download', '任务', 'task', 'aria2']
},
{
title: '添加下载',
description: '添加 HTTP/HTTPS 直链下载',
keywords: ['添加', '链接', 'url', 'add', '新建']
},
{
title: '下载设置',
description: '配置下载目录、速度限制与 RPC',
keywords: ['设置', 'setting', 'rpc', '速度', '端口', '目录']
},
{
title: '浏览器扩展',
description: '安装 Thing Extension 接管浏览器下载',
keywords: ['扩展', 'extension', '浏览器', 'chrome', 'edge']
} }
] ]
@@ -13,18 +29,40 @@ export const moduleConfig: ModuleConfig = {
id: 'downloader', id: 'downloader',
name: '下载器', name: '下载器',
icon: 'downloader', icon: 'downloader',
description: 'HTTP下载、BT/磁力链接支持', description: '基于 aria2 的多线程 HTTP 下载管理',
category: 'network', category: 'network',
defaultEnabled: true, defaultEnabled: true,
loader: () => import('./DownloaderModule.vue'), loader: () => import('./DownloaderModule.vue'),
searchItems, searchItems,
// 进程由 Aria2Manager 通过 ProcessManager 统一管理(id='downloader'),
// executable/args 在运行时由后端确定,此处仅声明 hasProcess 以便禁用时自动停止。
process: { process: {
name: 'aria2c', name: 'aria2c',
executable: '', executable: '',
args: ['--enable-rpc', '--rpc-listen-port=6800'],
autoStart: false, autoStart: false,
restartOnCrash: true, restartOnCrash: true,
maxRestarts: 3 maxRestarts: 3
}, },
lifecycle: {
// 启用模块时若用户开启了"自动启动",则随模块启用而运行 aria2
onEnable: async () => {
try {
const s = await invoke<{ autoStart?: boolean }>('downloader_get_settings')
if (s.autoStart) {
await invoke('downloader_start')
}
} catch {
/* 忽略:可能内核未安装 */
}
},
// 禁用模块时停止 aria2 进程(cleanup_on_exit 会在应用退出时调用)
onDisable: async () => {
try {
await invoke('downloader_stop')
} catch {
/* 忽略:可能进程未运行 */
}
}
},
order: 50 order: 50
} }
+23 -25
View File
@@ -10,6 +10,7 @@ import { invoke } from '@tauri-apps/api/core'
import { appDataDir } from '@tauri-apps/api/path' import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener' import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore' import { useProxyStore, type ProxyNode } from '@/stores/proxyStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { createLogger } from '@/lib/logger' import { createLogger } from '@/lib/logger'
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'
@@ -68,6 +69,13 @@ const onConfirmOpenChange = (open: boolean) => {
} }
const activeTab = ref('overview') const activeTab = ref('overview')
// 浮动标签切换器:注册到 TitleBar,滚动遮挡时自动显示
const tabsListRef = useModuleTabs(activeTab, [
{ value: 'overview', label: '概览' },
{ value: 'proxies', label: '节点' },
{ value: 'profiles', label: '订阅' },
{ value: 'settings', label: '设置' }
])
const starting = ref(false) const starting = ref(false)
const stopping = ref(false) const stopping = ref(false)
const restarting = ref(false) const restarting = ref(false)
@@ -613,10 +621,10 @@ const formatMB = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB`
// 镜像源选择:'__direct' = GitHub 直连(value 不能用空串,reka-ui SelectItem 禁止空 value // 镜像源选择:'__direct' = GitHub 直连(value 不能用空串,reka-ui SelectItem 禁止空 value
// | 'ghproxy.net' 等预设 key | '__custom' = 自定义 // | 'ghproxy.net' 等预设 key | '__custom' = 自定义
const MIRROR_PRESETS = [ const MIRROR_PRESETS = [
{ label: 'GitHub 直连', value: '__direct', hint: '能访问 GitHub,速度最快' }, { label: 'GitHub 直连', value: '__direct', hint: '能访问 GitHub 时选择,最稳定' },
{ label: 'gh-proxy.com', value: 'https://gh-proxy.com/', hint: '最推荐公益镜像' },
{ label: 'ghproxy.net', value: 'https://ghproxy.net/', hint: '老牌公益镜像' }, { label: 'ghproxy.net', value: 'https://ghproxy.net/', hint: '老牌公益镜像' },
{ label: 'gh-proxy.com', value: 'https://gh-proxy.com/', hint: '公益镜像' }, { label: 'ghfast.top', value: 'https://ghfast.top/', hint: '较新镜像,备用' },
{ label: 'ghfast.top', value: 'https://ghfast.top/', hint: '较新镜像' },
{ label: '自定义', value: '__custom', hint: '手动输入镜像站前缀' }, { label: '自定义', value: '__custom', hint: '手动输入镜像站前缀' },
] as const ] as const
@@ -843,12 +851,14 @@ const saveSettingsForm = async () => {
<template> <template>
<div class="h-full p-6"> <div class="h-full p-6">
<Tabs v-model="activeTab" class="h-full flex flex-col"> <Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef">
<TabsList class="grid w-full grid-cols-4 max-w-md !bg-transparent !p-0 !shadow-none"> <TabsList class="grid w-full grid-cols-4 max-w-md !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger> <TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger>
<TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger> <TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger>
<TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger> <TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger> <TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
</TabsList> </TabsList>
</div>
<!-- 概览 --> <!-- 概览 -->
<TabsContent value="overview" class="flex-1 mt-4 tab-animate"> <TabsContent value="overview" class="flex-1 mt-4 tab-animate">
@@ -873,16 +883,18 @@ const saveSettingsForm = async () => {
<CardContent class="space-y-3 text-sm"> <CardContent class="space-y-3 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-muted-foreground">状态</span> <span class="text-muted-foreground">状态</span>
<span v-if="store.kernel?.exists" class="flex items-center gap-1 text-emerald-500"> <Badge v-if="store.kernel?.exists" variant="default" class="gap-1 bg-emerald-500 hover:bg-emerald-500">
<Check class="size-3.5" />已安装 <Check class="size-3" />已安装
</span> </Badge>
<span v-else class="flex items-center gap-1 text-red-500"> <Badge v-else variant="destructive" class="gap-1">
<AlertCircle class="size-3.5" />未安装 <AlertCircle class="size-3" />未安装
</span> </Badge>
</div> </div>
<div v-if="store.kernel?.exists" class="flex items-center justify-between"> <div v-if="store.kernel?.exists" class="flex items-center justify-between">
<span class="text-muted-foreground">当前版本</span> <span class="text-muted-foreground">当前版本</span>
<span class="font-mono text-xs" :title="store.kernel?.version ?? ''">{{ versionShort }}</span> <Badge variant="secondary" class="font-mono text-xs" :title="store.kernel?.version ?? ''">
{{ versionShort }}
</Badge>
</div> </div>
<div v-if="kernelUpdateInfo" class="flex items-center justify-between"> <div v-if="kernelUpdateInfo" class="flex items-center justify-between">
<span class="text-muted-foreground">最新版本</span> <span class="text-muted-foreground">最新版本</span>
@@ -1479,19 +1491,5 @@ const saveSettingsForm = async () => {
</template> </template>
<style scoped> <style scoped>
/* Tab 内容切换动画 */ /* Tab 内容切换动画已移至 src/style.css 全局 .tab-animate 类,所有模块共用 */
.tab-animate {
animation: tabFadeSlide 0.2s ease-out;
}
@keyframes tabFadeSlide {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style> </style>
+403
View File
@@ -0,0 +1,403 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { createLogger } from '@/lib/logger'
const logger = createLogger('downloader')
// ===== 与 Rust 端对应的数据结构(camelCase =====
export interface DownloaderSettings {
rpcPort: number
rpcSecret: string
downloadDir: string
maxConcurrent: number
maxConnectionPerServer: number
split: number
continueDownload: boolean
autoStart: boolean
speedLimit: number
kernelMirrors: string[]
}
/** 内核安装进度事件载荷,对应 Rust 端 InstallProgress */
export interface InstallProgress {
/** downloading | extracting | replacing | done | error */
stage: string
percent: number
downloadedBytes: number
totalBytes: number | null
message: string
}
export interface KernelInfo {
path: string
exists: boolean
version: string | null
}
export interface KernelUpdateInfo {
currentVersion: string | null
latestVersion: string
downloadUrl: string
hasUpdate: boolean
}
export interface DownloaderStatus {
running: boolean
pid: number | null
restartCount: number
}
export interface RpcInfo {
rpcUrl: string
rpcSecret: string
extensionPath: string | null
}
/** aria2 任务文件信息 */
export interface Aria2File {
path: string
length: number
completedLength: number
selected: boolean
}
/** aria2 任务状态(tellActive/tellWaiting/tellStopped 返回项) */
export interface Aria2Task {
gid: string
status: 'active' | 'waiting' | 'paused' | 'complete' | 'removed' | 'error' | string
totalLength: string
completedLength: string
downloadSpeed: string
uploadSpeed: string
connections: string
dir: string
files?: Aria2File[]
bittorrent?: { info?: { name?: string } } | null
errorCode?: string
errorMessage?: string
}
/** 全局统计 */
export interface GlobalStat {
downloadSpeed: string
uploadSpeed: string
numActive: string
numWaiting: string
numStopped: string
numStoppedTotal: string
}
export interface Aria2Version {
version: string
enabledFeatures?: string[]
}
export const useDownloaderStore = defineStore('downloader', () => {
const kernel = ref<KernelInfo | null>(null)
const status = ref<DownloaderStatus>({ running: false, pid: null, restartCount: 0 })
const version = ref<string>('')
const settings = ref<DownloaderSettings | null>(null)
const rpcInfo = ref<RpcInfo | null>(null)
const activeTasks = ref<Aria2Task[]>([])
const waitingTasks = ref<Aria2Task[]>([])
const stoppedTasks = ref<Aria2Task[]>([])
const globalStat = ref<GlobalStat | null>(null)
// ===== 任务历史持久化(localStorage =====
// 即使 aria2 未启动,也能展示最近一次的任务快照
const HISTORY_KEY = 'thing.downloader.taskHistory'
const HISTORY_MAX = 200 // 最多保留 200 条历史记录
/** 将当前任务快照保存到 localStorage(合并 active+waiting+stopped,按 gid 去重) */
const persistHistory = () => {
try {
const map = new Map<string, Aria2Task>()
// 先读已有历史,作为基底
const raw = localStorage.getItem(HISTORY_KEY)
if (raw) {
const existing: Aria2Task[] = JSON.parse(raw)
for (const t of existing) map.set(t.gid, t)
}
// 用最新任务覆盖(active/waiting/stopped 都是最新的)
for (const t of activeTasks.value) map.set(t.gid, t)
for (const t of waitingTasks.value) map.set(t.gid, t)
for (const t of stoppedTasks.value) map.set(t.gid, t)
// 限制条数:优先保留 stopped(已完成/错误),其次 waiting,最后 active
const all = Array.from(map.values())
const priority = { complete: 0, error: 0, removed: 1, active: 2, waiting: 2, paused: 2 } as Record<string, number>
all.sort((a, b) => (priority[a.status] ?? 3) - (priority[b.status] ?? 3))
const trimmed = all.slice(0, HISTORY_MAX)
localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed))
} catch (e) {
logger.error('保存任务历史失败: ' + e)
}
}
/** 从 localStorage 加载任务历史,填充到 stoppedTasks(作为历史展示) */
const loadHistory = () => {
try {
const raw = localStorage.getItem(HISTORY_KEY)
if (!raw) return
const history: Aria2Task[] = JSON.parse(raw)
if (!Array.isArray(history)) return
// 仅在没有实时任务时填充(避免覆盖实时数据)
if (stoppedTasks.value.length === 0) {
stoppedTasks.value = history
}
} catch (e) {
logger.error('加载任务历史失败: ' + e)
}
}
// ===== 内核安装进度 =====
const installing = ref(false)
const installProgress = ref<InstallProgress | null>(null)
let progressUnlisten: UnlistenFn | null = null
/** 内核信息(同时尝试从 resource 提取到 cores/ */
const refreshKernel = async () => {
try {
kernel.value = await invoke<KernelInfo>('downloader_kernel_info')
} catch (e) {
logger.error('获取内核信息失败: ' + e)
}
return kernel.value
}
/** 刷新进程状态 */
const refreshStatus = async () => {
try {
status.value = await invoke<DownloaderStatus>('downloader_status')
} catch (e) {
logger.error('获取进程状态失败: ' + e)
}
return status.value
}
const start = async () => {
await invoke('downloader_start')
await refreshStatus()
}
const stop = async () => {
await invoke('downloader_stop')
await refreshStatus()
}
const restart = async () => {
await invoke('downloader_restart')
await refreshStatus()
}
/** 等待 aria2 RPC 就绪(轮询 version 接口,最多等 10 秒) */
const waitForApi = async (timeoutMs = 10000): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
await invoke<Aria2Version>('downloader_version')
return true
} catch {
await new Promise((r) => setTimeout(r, 500))
}
}
return false
}
/** 获取 aria2 版本(仅运行时可用) */
const refreshVersion = async () => {
try {
const v = await invoke<Aria2Version>('downloader_version')
version.value = v.version
} catch {
version.value = ''
}
}
// ---------- 任务列表 ----------
const refreshActive = async () => {
try {
const res = await invoke<Aria2Task[]>('downloader_get_active')
activeTasks.value = res ?? []
} catch (e) {
logger.error('获取活跃任务失败: ' + e)
}
return activeTasks.value
}
const refreshWaiting = async () => {
try {
const res = await invoke<Aria2Task[]>('downloader_get_waiting')
waitingTasks.value = res ?? []
} catch (e) {
logger.error('获取等待任务失败: ' + e)
}
return waitingTasks.value
}
const refreshStopped = async () => {
try {
const res = await invoke<Aria2Task[]>('downloader_get_stopped')
stoppedTasks.value = res ?? []
} catch (e) {
logger.error('获取已完成任务失败: ' + e)
}
return stoppedTasks.value
}
/** 刷新全部任务(active + waiting + stopped */
const refreshAllTasks = async () => {
await Promise.all([refreshActive(), refreshWaiting(), refreshStopped()])
// 刷新后持久化历史快照
persistHistory()
}
const refreshGlobalStat = async () => {
try {
globalStat.value = await invoke<GlobalStat>('downloader_get_global_stat')
} catch (e) {
logger.error('获取全局统计失败: ' + e)
}
return globalStat.value
}
// ---------- 任务操作 ----------
const addUri = async (uris: string[], options?: Record<string, unknown>) => {
const opts = options ? (JSON.parse(JSON.stringify(options)) as unknown) : undefined
return await invoke<string>('downloader_add_uri', { uris, options: opts })
}
const pauseTask = async (gid: string) => {
await invoke('downloader_pause', { gid })
}
const unpauseTask = async (gid: string) => {
await invoke('downloader_unpause', { gid })
}
const removeTask = async (gid: string) => {
await invoke('downloader_remove', { gid })
}
const changeGlobalOption = async (options: Record<string, string>) => {
await invoke('downloader_change_global_option', { options })
}
// ---------- 设置 ----------
const loadSettings = async () => {
settings.value = await invoke<DownloaderSettings>('downloader_get_settings')
return settings.value
}
const saveSettings = async (s: DownloaderSettings) => {
await invoke('downloader_save_settings', { settings: s })
settings.value = s
}
// ---------- RPC 信息 ----------
const loadRpcInfo = async () => {
rpcInfo.value = await invoke<RpcInfo>('downloader_get_rpc_info')
return rpcInfo.value
}
// ---------- 内核更新 / 安装 ----------
const checkKernelUpdate = async (): Promise<KernelUpdateInfo> => {
return await invoke<KernelUpdateInfo>('downloader_check_kernel_update')
}
const updateKernel = async (mirrorPrefix: string = '') => {
await invoke('downloader_update_kernel', { mirrorPrefix })
await refreshKernel()
}
/**
* 首次安装内核:调用后端 install_kernel,监听 downloader-kernel-install-progress 事件更新进度
* @param mirrorPrefix 镜像源前缀(空串=GitHub 直连)
*/
const installKernel = async (mirrorPrefix: string = ''): Promise<void> => {
if (installing.value) return
installing.value = true
installProgress.value = {
stage: 'downloading',
percent: 0,
downloadedBytes: 0,
totalBytes: null,
message: '准备开始下载...'
}
if (!progressUnlisten) {
progressUnlisten = await listen<InstallProgress>('downloader-kernel-install-progress', (e) => {
installProgress.value = e.payload
})
}
try {
await invoke('downloader_install_kernel', { mirrorPrefix })
await refreshKernel()
} catch (e) {
logger.error('内核安装失败: ' + e)
throw e
} finally {
installing.value = false
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
}
}
const clearInstallProgress = () => {
installProgress.value = null
}
/** 用系统资源管理器打开目录(Rust 端绕过 opener scope 限制) */
const openDir = (path: string) => invoke<void>('downloader_open_dir', { path })
return {
// state
kernel,
status,
version,
settings,
rpcInfo,
activeTasks,
waitingTasks,
stoppedTasks,
globalStat,
installing,
installProgress,
// kernel & process
refreshKernel,
refreshStatus,
start,
stop,
restart,
waitForApi,
refreshVersion,
// tasks
refreshActive,
refreshWaiting,
refreshStopped,
refreshAllTasks,
refreshGlobalStat,
addUri,
pauseTask,
unpauseTask,
removeTask,
changeGlobalOption,
// settings
loadSettings,
saveSettings,
// rpc info
loadRpcInfo,
// kernel update / install
checkKernelUpdate,
updateKernel,
installKernel,
clearInstallProgress,
openDir,
loadHistory,
persistHistory
}
})
+75
View File
@@ -0,0 +1,75 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
/**
* 模块标签栏跨组件状态。
*
* 用于在模块详情页的 TabsList 因滚动消失时,
* 在顶部 TitleBar 中显示一组浮动切换按钮。
*
* 工作流程:
* 1. 模块(如 DownloaderModuleonMounted 时调用 registerTabs(),传入标签定义和当前值
* 2. 模块用 IntersectionObserver 或 scroll 监听检测 TabsList 可见性,调用 setFloatingVisible()
* 3. 模块通过 watch 将本地 activeTab 同步到 store
* 4. TitleBar 读取 store 的 tabs / activeTab / floatingVisible 渲染浮动切换器
* 5. 用户点击浮动切换器时调用 setActiveTab(),模块监听 store.activeTab 变化更新本地值
* 6. 模块 onUnmounted 时调用 unregisterTabs() 清理状态
*/
export interface ModuleTab {
value: string
label: string
}
export const useModuleTabsStore = defineStore('moduleTabs', () => {
/** 当前模块注册的标签列表(空表示无模块注册,TitleBar 不渲染) */
const tabs = ref<ModuleTab[]>([])
/** 当前活跃标签值 */
const activeTab = ref<string>('')
/** 是否显示浮动切换器(TabsList 滚出可视区时为 true */
const floatingVisible = ref<boolean>(false)
/** 模块注册标签(onMounted 时调用) */
const registerTabs = (tabList: ModuleTab[], current: string) => {
tabs.value = tabList
activeTab.value = current
floatingVisible.value = false
}
/** 模块注销标签(onUnmounted 时调用) */
const unregisterTabs = () => {
tabs.value = []
activeTab.value = ''
floatingVisible.value = false
}
/** 设置浮动切换器可见性 */
const setFloatingVisible = (visible: boolean) => {
// 仅在有注册标签时才允许显示
if (!visible) {
floatingVisible.value = false
return
}
if (tabs.value.length > 0) {
floatingVisible.value = true
}
}
/**
* 设置活跃标签(双向同步用)。
* - 模块本地 activeTab 变化时调用此方法同步到 store
* - TitleBar 点击时也调用此方法,模块通过 watch 感知变化
*/
const setActiveTab = (value: string) => {
activeTab.value = value
}
return {
tabs,
activeTab,
floatingVisible,
registerTabs,
unregisterTabs,
setFloatingVisible,
setActiveTab
}
})
+16
View File
@@ -183,3 +183,19 @@
left: 100%; left: 100%;
} }
} }
/* 模块 Tab 内容切换动画(全局,所有模块通用) */
.tab-animate {
animation: tabFadeSlide 0.35s cubic-bezier(0.22, 0.61, 0.36, 1);
}
@keyframes tabFadeSlide {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}