Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69db7118a9 | ||
|
|
ffa410b399 | ||
|
|
d702ed0d31 | ||
|
|
27ad5d89a5 | ||
|
|
6b9f71da08 | ||
|
|
28e0c4664a | ||
|
|
d21649c60e | ||
|
|
4dd60f42a1 | ||
|
|
0a19b4b38a | ||
|
|
7d49a7395f | ||
|
|
d0705b1ffe | ||
|
|
6c7897bf47 | ||
|
|
2f20161010 | ||
|
|
d09599fa95 | ||
|
|
e09b0567d6 | ||
|
|
e66c53e66d | ||
|
|
4f0f9e08b7 | ||
|
|
c7578a2e6b | ||
|
|
126f8896b6 | ||
|
|
5656193b5c | ||
|
|
d85f7b2ad0 | ||
|
|
a745df0880 | ||
|
|
c913379e2b | ||
|
|
f51a7f894e | ||
|
|
89e5b7bed5 | ||
|
|
66575c6166 |
@@ -7,6 +7,12 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# 发布暂存目录
|
||||
release_stage/
|
||||
|
||||
# musicdl 手动测试产物(search_results.pkl 等)
|
||||
musicdl_outputs/
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# 2026-09-12
|
||||
|
||||
## 设置页图标修复 + WebDAV 凭据加密存储 / 配置后隐藏
|
||||
|
||||
- **图标不显示根因**:设置页模板用了 `HardDriveDownload` 但从未 import——`vue-tsc` 不报错(未在 script 中引用、模板解析为未知组件静默失败),**运行时图标直接消失**。以后设置页新增图标必须同步加到 lucide import 列表。
|
||||
- **WebDAV 凭据加密**:新增 Rust 命令 `webdav_get_secret` / `webdav_save_secret`(`commands.rs`,非 specta,走 raw invoke;已注册 mod.rs 导出 + lib.rs generate_handler)。Windows 用 **keyring v3(windows-native,凭据管理器,DPAPI 保护)**,service="Thing" user="webdav-credentials",blob 为 `{"username","password"}` JSON;NoEntry 视为未配置。
|
||||
- **feiniuStore**:`WEBDAV_KEY` localStorage 现在只存 `{url, dir}`;`patchWebdav` 检测 `username/password` 字段时调 `webdav_save_secret`;`init()` 从凭据管理器加载凭据,并做一次性迁移(localStorage 有旧明文凭据 → 写入凭据管理器 + 立即改写 LS 去掉明文;用 `hasStoredSecret` 标志避免误覆盖)。`davConfig()` 仍从内存取明文传给 webdav_* 命令,链路不变。
|
||||
- **设置页 UI**:`webdavEditing` ref 控制——`webdavReady && !webdavEditing` 时显示摘要卡(服务地址/目标目录 + 「账号/密码已保存(系统凭据管理器加密存储)」),带「修改配置」「测试连接」;编辑态完整表单(密码框 type=password 本身掩码)+「收起」。
|
||||
- 校验:cargo check(新增 keyring 3.6.3)/ vue-tsc / vite build 全 0。
|
||||
|
||||
## 音乐模块全链路检查 + 排序/设置页/任务页调整
|
||||
|
||||
- **模块排序**:`music/index.ts` order 55→15(代理=10 之后、整体第二);`appStore.ts` loadSettings 增加一次性迁移(`musicOrderMigrated` 标记随设置持久化,声明在 store setup 内的非响应式 let),已保存过 moduleOrder 的用户下次启动自动把 music 移到 proxy 后,之后尊重手动拖动。
|
||||
- **隐藏 bug 修复(MusicModule.vue)**:
|
||||
1. `downloadToFeiniu` 早退分支(Rust 引擎/无下载源)未清理 `feiniuUploadingKeys` → 行按钮 spinner 永久卡死;
|
||||
2. `resolvePendingUploads` 只处理 done,临时「下载到飞牛」任务 error/cancelled/interrupted 或被手动移除时 spinner 同样卡死 → 增加失败/移除清理分支(注意:临时任务的 tempTaskIds.add 发生在任务进入 tasks 之后,watch 触发时序依赖后续状态变化再触发一次);
|
||||
3. `feiniu.uploading` 时不再叠加触发 `uploadRecentToFeiniu`。
|
||||
- **下载任务 tabs**:去掉标题上的进行中计数(连同 `activeTaskCount` computed,noUnusedLocals 会拦未用变量);任务卡片容器 `py-4`→`pb-4`,贴近 tabs。
|
||||
- **设置页 UI 重做**:section+Separator 改为 Card/CardHeader/CardTitle/CardContent 卡片式(max-w-2xl、gap-4、图标+text-base 标题,与 DownloaderModule 设置页一致);「播放条常驻在所有模块底部」等过时文案改为「标题栏播放控制」。
|
||||
- 全链路审查结论:旧审查的 P0(曲库搜索失效/重启后播放键失效/双音频引擎)均已在现码修复;musicdl 事件幂等、任务持久化签名 watch、歌词二分+邻近缓存等设计良好。
|
||||
- 校验:`vue-tsc --noEmit` 0 错误,`vite build` 通过。
|
||||
|
||||
## 「下载到飞牛」改为同链路落地即传(用户实测 URL 直传失败)
|
||||
|
||||
用户确认:WebDAV 测试连接 OK、下载+自动上传 OK,但发现音乐行的「下载并上传」失败(旧的 URL 拉流直传机制)。按要求改为与「下载完成后自动上传」完全同链路:
|
||||
|
||||
- `downloadToFeiniu`:直接 `store.startDownload([song])`(musicdl 引擎,含引擎侧解析/代理/音质),不再手动 resolve + 拼 headers;记 `tempTaskStarts`(taskId→创建时刻,扫描下界)。
|
||||
- 任务 done → `finishTempUpload`:`uploadRecentToFeiniu(0, { since, deleteLocal: true })` 上传 → `feiniu_delete_local` 删本地音频及同名 lrc/封面 → `store.removeTask` 移除记录。**失败时保留本地文件与任务记录**供排查重试。
|
||||
- `uploadRecentToFeiniu` 扩展 opts `{ since?, deleteLocal? }`;默认行为(自动上传)不变、不删本地。
|
||||
- 后端新增 `feiniu_delete_local`(音频/歌词/封面扩展名白名单 + 拒绝目录);**删除** `webdav_upload_from_url` 命令与 webdav.rs `upload_from_url`(机制废弃)。
|
||||
- musicdl 引擎守卫保留:Rust 引擎任务在「下载器」模块,无法回传。
|
||||
- 校验:tsc / vite / cargo 全 0。
|
||||
|
||||
## 文件服务 → WebDAV(飞牛传输改版,最终形态)
|
||||
|
||||
fnOS 文件服务(WS + RSA/AES 登录)经三轮协议修复仍无法在用户 NAS 上稳定登录,且会话易失(重启要重登)。用户确认局域网场景改用标准文件协议,整体替换为 **WebDAV**(fnOS 系统设置 → 文件服务 → WebDAV,HTTP 5005/HTTPS 5006,Basic 认证,文件夹需在可见范围+团队文件夹要勾"允许协议挂载")。
|
||||
|
||||
- **删除**:`fnos.rs`(WS 客户端)、`Feiniu.fnos_sessions`、`feiniu_fnos_*` 与 `feiniu_upload_from_url` 命令、ConnectionsSettings 云朵登录弹窗。
|
||||
- **新增**:`src-tauri/src/music/feiniu/webdav.rs`——无状态:`test`(PROPFIND Depth 0)、`upload_file`(128KB 分块流式 PUT,unfold 转 Stream 免 tokio-util)、`upload_from_url`(GET 拉流→直接 PUT,本地不落地,**不再要求 Content-Length**)、`delete`;上传前逐级 MKCOL;URL 逐段百分号编码;401/403/404/409 可读错误。
|
||||
- **命令**:`webdav_test` / `webdav_upload` / `webdav_upload_from_url` / `webdav_delete`(config 由前端每次传入,serde+specta 结构 `WebDavConfig`)。
|
||||
- **前端**:`feiniuStore.webdav = {url, username, password, dir}` 持久化 `thing.music.feiniu.webdav`(密码明文 localStorage,LAN 工具可接受);`webdavReady` computed 四项齐备;`patchWebdav` / `testWebdav`;曲库目标目录语义改为 **WebDAV 根下相对路径**。设置页「存储与上传」为配置表单 + 测试按钮 + fnOS 侧设置折叠提示;TrackItem/发现音乐「下载到飞牛」门控全部改 `webdavReady`。
|
||||
- 校验:vue-tsc / vite build / cargo check 全 0。
|
||||
|
||||
## fnOS 登录协议修复(第三轮:"fnOS 登录未返回 token")
|
||||
|
||||
第二轮修复后登录请求**有响应了**(不再超时),但 rid 命中的回包里没有 token。对照 pyfnos 发现关键行为:**登录最终成功包可能不按 reqid 关联**——pyfnos 的 `_is_final_login_success` 是按内容特征(`result=="succ" && token && secret`)识别任意消息,而非按 reqid 匹配。rid 命中的那条很可能只是中间应答,带 token 的包以独立推送到达,被旧 reader 直接丢弃。
|
||||
|
||||
修复(`fnos.rs`):
|
||||
- 新增 `login_waiter` 通道;reader 对每条消息先做内容识别,命中最终成功包就投递给登录流程,reqid 匹配照旧(并容忍服务端把 reqid 回显成数字,`resp_rid` 辅助函数)。
|
||||
- `login()` 双通道等待:rid 应答里直接有 token → 用之;是中间应答 → 最多再等 8s 登录推送;send_and_wait 超时 → 再宽限 5s 等推送。
|
||||
- 识别两步验证挑战(`isBindTwofaSecret` / `isTwofaEnforced` / `accessToken`),明确报"账号开启了两步验证"而不是干等。
|
||||
- 诊断兜底:若始终等不到 token,报错里带上服务端实际回包的前 300 字符,用户可直接贴回继续定位。
|
||||
- 断开时同时唤醒登录等待者(Null)。
|
||||
|
||||
`cargo check` 通过。协议参考:pyfnos `client.py`(响应扁平结构:`result/token/secret/longToken/reqid` 顶层)。
|
||||
|
||||
## fnOS 登录协议修复(第二轮:"fnOS 请求超时(encrypted)")
|
||||
|
||||
上一轮修完 WS 并发缺陷后,错误变成"请求超时(encrypted)"——说明握手和 `util.crypto.getRSAPub` 都通了,**卡在登录这一条**。根因:**加密登录体里缺字段**。
|
||||
|
||||
已对照两个参考实现逐字核对并修复:
|
||||
- **FNOSP/fnnas-api** `sdk/encryption.py` 的 `login_encrypt()` 示例(原开发注释里引用的就是它)
|
||||
- **Timandes/pyfnos** `fnos/client.py` 的 `_encrypt_login_data()`
|
||||
|
||||
### 正确的加密登录体(必须逐字段对齐)
|
||||
```json
|
||||
{"reqid":"...","user":"...","password":"...","deviceType":"Browser",
|
||||
"deviceName":"...","stay":false,"req":"user.login","si":"..."}
|
||||
```
|
||||
要点:
|
||||
- **`req: "user.login"` 必须在加密体内** —— 服务端靠它路由;缺了就静默不回包(=超时)。
|
||||
- **`reqid` 必须在加密体内**,且与 `pending` 等待者登记的 key 一致;外层 `encrypted` **不带** reqid。
|
||||
- **没有 `did` 字段**(FNOSP 示例没有;pyfnos 有但非必需,为减少变量已去掉)。
|
||||
- `stay: false`。
|
||||
- 加密方式:AES-256-CBC(key=32 字节随机 ASCII 串, iv=16 字节随机) + PKCS7,RSA 只加密那个 32 字符 key(PKCS1v1.5),全部 base64。
|
||||
|
||||
### 顺带核对过、确认我们原本就正确的部分
|
||||
- 签名:`hmac_sha256_b64(sign_key, json) + json`(签名前置);`sign_key` = 登录响应 `secret` → AES 解密 → base64(明文) → **base64 解码后的字节**。
|
||||
- **不签名白名单**:`encrypted` / `util.getSI` / `util.crypto.getRSAPub`(已按参考实现补上 `UNSIGNED_REQ`)。
|
||||
- 后续请求不携带 token 字段,服务端靠 WS 连接 + 签名识别会话;HTTP 上传走 `Trim-Token` 头。
|
||||
- reqid 格式宽松(各实现 18~28 位不等),我们现有的 `0000000000000000<hex毫秒>` 服务端接受(getRSAPub 已验证)。
|
||||
|
||||
### 其他改动
|
||||
- `request()` 拆成 `send_and_wait(message, rid, label)` + `request()`,登录走前者以便用加密体内的 reqid 关联。
|
||||
- 错误处理补齐 `result=="fail"` 分支并带出 `msg`/`errmsg`(密码错误等现在能看到具体原因)。
|
||||
- 新增**应用层心跳**:fnOS 网关会关闭长时间无消息的连接(参考实现默认 10s 一次 `{"req":"ping"}`)。做成"发请求前按需补发"(>20s 才发),避免常驻任务的生命周期问题——多文件批量上传时 WS 不会被闲置断开。
|
||||
- 报错里的 label 更可读(如 `user.login(encrypted)`)。
|
||||
|
||||
校验:`cargo check` exit 0。**仍未对真实 NAS 验证**,需要用户实测。
|
||||
|
||||
---
|
||||
|
||||
## 修复 fnOS 文件服务登录("输入密码没反应,很久后报发送请求失败")
|
||||
|
||||
### 根因(`src-tauri/src/music/feiniu/fnos.rs`)
|
||||
1. **WS 读写共用一个 `tokio::sync::Mutex`,且 reader 在持锁状态下 `await stream.next()`** —— `request()` 要拿同一把锁才能发送。第一次 `request()` 里 `ensure_reader()` 先 spawn 了 reader,reader 抢到锁后就在等一条永远不会发出的消息;发送方要么永久阻塞(超时也覆盖不到锁获取),要么在 reader 已退出后发送才报错 —— 这正是"等很久 → 发送请求失败"的来源。
|
||||
2. **握手没带 `Origin`**。浏览器发起的 WS 必带 `Origin`,Rust 客户端默认不带;fnOS 会据此判断来源,缺了它会"握手成功但立刻被对端关闭"。这是"连上了却什么都没发生"的典型原因。
|
||||
3. **没有回 Pong**。tungstenite 的 Stream/Sink API 不会自动回 Pong,服务端 ping 得不到响应会判定死连接。
|
||||
4. 连接/请求均**没有超时覆盖**(`timeout` 只包了 `rx`,不含锁获取与握手),主机不可达时会干等系统 TCP 重试。
|
||||
|
||||
### 修复
|
||||
- 读写分离:`futures_util::StreamExt::split(ws)` → writer 任务独占写半边(mpsc 队列),reader 独占读半边。发送不再与"等响应"争锁。
|
||||
- 握手请求改为构造 `Request` 并注入 `Origin`(由 ws url 反推 `http://host:port`)与浏览器 UA。
|
||||
- reader 显式处理 `Ping → Pong`,并记录断开原因到 `closed: Arc<Mutex<Option<String>>>`。
|
||||
- `connect_async` 加 12s 超时;`request` 的 15s 超时改为覆盖整个等待,并在入口先检查 `closed`,断开时立刻带原因报错。
|
||||
- 所有报错都带上尝试的 ws 地址或断开原因,例如 `连接 fnOS 文件服务失败:ws://ip:port/websocket?type=main → ...`。
|
||||
- 前端:`ConnectionsSettings` 的 fnOS 弹窗加 `fnosConnecting`(按钮显示"连接中…")与弹窗内联错误块(错误里含地址/状态码,留在弹窗里可复制),并显示"当前地址"供核对。
|
||||
- `feiniuStore.fnosLogin(connectionId, ...)` 改为显式接收 connectionId 并校验它等于 activeId —— 上传命令按 `settings.feiniu_active_id` 找会话,登录非激活连接会存成永远用不上的会话。
|
||||
|
||||
**注意**:以上都只做了 `cargo check` + 前端构建校验,没有对真实飞牛 NAS 验证过协议细节(`Origin` 校验、`file.checkUpload`/`Trim-Path` 约定可能随 fnOS 版本不同)。如果仍失败,报错文案已包含具体地址与失败阶段。
|
||||
|
||||
---
|
||||
|
||||
## 发现音乐:分组头去白底 + 新增「下载到飞牛」一条龙 + 飞牛上传链路修复
|
||||
|
||||
### 分组头
|
||||
`MusicModule.vue` 里源分组头原来是 `sticky top-0 z-10 bg-background/95 backdrop-blur`,在浅色背景下会形成一条白色横条。已去掉 sticky 与底色,改为普通的 `border-b border-border/60 px-2` 分组标签(px-2 与行对齐)。
|
||||
|
||||
### 新增 Rust 命令 `feiniu_upload_from_url`(下载 + 上传一条龙,本地不留文件)
|
||||
链路:前端 `feiniuStore.uploadUrlToFeiniu()` → `invoke('feiniu_upload_from_url')` → `Feiniu::fnos_upload_from_url()` → `fnos::upload_stream()`。
|
||||
|
||||
- `fnos.rs` 新增泛型 `upload_stream<S, E>()`:`Part::stream_with_length(Body::wrap_stream(stream), size)`,配套 `upload_client(proxy)`(`proxy` 为空则 `no_proxy()`)。
|
||||
- `upload_file()` 重写为**流式**(`stream::unfold` + `tokio::fs::File` 分块 128KB)。原来用 `std::fs::read` 把整个文件读进内存,100MB 无损会直接吃 100MB RAM。用 unfold 是为了不引入 `tokio-util` 依赖。
|
||||
- `FnOsSession::connect` 增加 scheme 校验:`https://` 直接返回可读错误(`ws_url` 只把 `http://` 换成 `ws://`,https 会变成非法 WS scheme 并抛出难懂的 tungstenite 错误)。
|
||||
- `guess_mime(&Path)` 改为 `mime_of(&str)`(现在需要按 NAS 上的文件名推断)。
|
||||
- **依赖音源返回 `Content-Length`**(fnOS `checkUpload` 需要精确 size),缺失时返回可读错误,而不是先把整首缓冲进内存。
|
||||
- 前端 `downloadToFeiniu()`:懒解析歌曲先 `resolveSong(source, index, quality)` 拿真实链接(quality 取默认下载音质),合并 `defaultDownloadHeaders` + `defaultDownloadCookies` 为 Cookie 头,并**过滤非 ASCII 头**(reqwest 遇到非法头值会 panic,不能把外部数据直接塞进去)。
|
||||
|
||||
### 飞牛上传链路审查结论(重要)
|
||||
1. **fnOS 会话只存在内存里**(`Feiniu.fnos_sessions`),应用重启即失效 → 设置页显示的「未连接」是准确的,需要重新点云朵图标登录。已在设置页与登录弹窗里明确写出"仅本次运行有效"。
|
||||
2. **仅支持 `http://` 局域网直连**,https/frp 无法上传(协议限制),已给出明确报错。
|
||||
3. `feiniu_scan_local` 扫描 `savedir` + `feiniu_local_dirs`,因此下载到本地目录的文件能被"上传最近下载"找到;但该功能是**按 mtime 10 分钟窗口**筛选,与具体下载任务无关联——这是它不可靠的根因(这就是为什么要新增 URL 直传)。
|
||||
4. `feiniu_fnos_upload` 用 `settings.feiniu_active_id` 找连接,`fnos_login` 用传入的 connection_id;只要激活连接一致就没问题。
|
||||
|
||||
### 校验(本机 bash 不可用,PowerShell + 绝对路径)
|
||||
- `cargo check`(`src-tauri`,`%USERPROFILE%\.cargo\bin\cargo.exe`)→ exit 0,仅 3 个既有 warning
|
||||
- `vue-tsc --noEmit` → exit 0;`vite build` → exit 0
|
||||
|
||||
---
|
||||
|
||||
## 曲库左栏:去掉灰底容器、数量列对齐、行高统一
|
||||
|
||||
用户反馈截图问题(灰底 + 文字竖向不齐)后的修正,`MyMusicLibrary.vue`:
|
||||
- **去掉 `bg-muted` 容器**:来源项不再包在灰色圆角块里。选中态保留 `bg-background font-medium shadow-sm`,并**补 `ring-1 ring-border`**——否则白底上的白色胶囊只有 5% 透明度的阴影,几乎看不见(顶部 Tabs 那个"可见边框"其实是 `:focus-visible` 的 ring,不是常态样式,不能照抄)。
|
||||
- **数量列始终渲染**:原来 `v-if="nav.count"` 会在 0 时隐藏整列,造成右侧数字列参差。改为无歌曲时显示 `0`,并加 `min-w-[3ch] text-right tabular-nums` 让个位数/三位数右边缘对齐。
|
||||
- **行高统一为固定值**:来源项 `h-8`、歌单子项 `h-7`,配合 `items-center`,不再依赖 `py-1.5` + 行高推算,彻底消除逐行细微的垂直错位。
|
||||
- 未选中项 hover 从 `hover:bg-accent/50` 统一为 `hover:bg-accent/60`(无容器后需要更明显的悬停反馈)。
|
||||
- 歌单子项的选中态也同步改成同一套胶囊样式,保持层级内一致。
|
||||
|
||||
---
|
||||
|
||||
## 曲库左栏收窄 + 选中态对齐顶部 Tabs + 切换动画
|
||||
|
||||
`MyMusicLibrary.vue`:
|
||||
- 左栏宽度 `w-[210px]` → `w-[168px]`("飞牛曲库 → 数量"之间的大段空白因此明显收窄)。内边距 `p-2.5` → `p-2`,字号 13px → 保留。
|
||||
- 三个来源(飞牛曲库/本地曲库/我的歌单)从裸列表改为 **`rounded-lg bg-muted p-1` 容器 + 选中项 `bg-background font-medium shadow-sm rounded-md`**,与顶部 `TabsList`/`TabsTrigger` 的默认选中态一致。
|
||||
- 歌单子树加 `border-l` 缩进表示层级;展开/收起加了 `pl-list` Transition(opacity + translateY(-6px),0.18s)。
|
||||
- **标签截断修复**:`<span class="truncate">` 在 flex 行里不会真正截断(flex item 默认 `min-width:auto`)。改成 `min-w-0 flex-1 truncate` + 右侧计数 `shrink-0`,并去掉 `ml-auto`。以后左栏加条目记得照这个写。
|
||||
- 右栏内容切换动画:给 `<section>` 加 `:key="view"` + 全局 `tab-animate`,靠重挂载触发动画(与模块级 TabsContent 的做法一致,无需自造 Transition)。
|
||||
|
||||
---
|
||||
|
||||
## 歌单新增「编辑音乐」编辑器
|
||||
|
||||
- 新组件 `src/modules/music/components/PlaylistEditorDialog.vue`(props `open` / `playlistId`,emit `update:open`)。
|
||||
- 双 Tab:**全部音乐**(来源筛选 全部/飞牛/本地 → 勾选加入;已在歌单的项置灰并标「已加入」,不可重复勾选)、**已在歌单**(勾选批量移出,勾选框用 destructive 色)。
|
||||
- 含搜索(歌名/歌手/专辑)、全选当前列表、增量渲染每批 120 + 「显示更多」、飞牛曲库「继续加载」、本地曲库未扫描时的内联「扫描本地」按钮。
|
||||
- 勾选框**不用** `Checkbox` 组件:整行是 `<button>`,视觉勾选框是纯 span(避免 label/按钮与 Checkbox 双重触发导致 net-zero toggle)。若以后要加交互型复选框,注意这个坑。
|
||||
- 关闭/打开时 `resetState()`,切 Tab 与切来源时清空选择(避免"看不见的项仍被选中"导致数量对不上)。
|
||||
- 入口:`MyMusicLibrary.vue` 歌单详情工具栏的「编辑音乐」按钮,以及歌单为空时 `Empty` 里的按钮。
|
||||
- 依赖 store 既有 API:`addToPlaylist`(内部已按 `guid+source` 去重)、`removeItemsFromPlaylist(id, keys)`(key 形如 `${source}:${guid}`)。
|
||||
|
||||
---
|
||||
|
||||
## 音乐模块播放器入口改版 + tabs 统一(在同日重构之后)
|
||||
|
||||
> 本节修订上面「结构变更」里的播放器结论,以本节为准。
|
||||
|
||||
- **删除底部播放条**:`GlobalPlayerBar.vue` 已删除,`App.vue` 不再渲染播放条。不要再新增底部坞站播放条。
|
||||
- **新增标题栏音乐栏** `src/components/layout/TitleBarMusic.vue`,插在 `TitleBar.vue` 里**搜索输入框的左侧**(在保存设置按钮与搜索框之间),外层包 `pointer-events-auto`,触发按钮带 `@mousedown.stop`(标题栏是 Tauri 拖拽区,不加会导致点击被拖拽吞掉)。
|
||||
- 未播放过(`store.nowPlaying` 为空)→ 只显示唱片图标,隐藏歌名。
|
||||
- 有曲目 → 图标替换为封面(`rounded-full` + `overflow-hidden`),`store.playing` 为真时用 CSS `animation-play-state` 旋转(8s/圈,暂停保留角度)。
|
||||
- 点击弹出方形控制窗(`Popover`,`w-[300px]`,约 300×340):封面(点击进大屏)、曲目信息、`ScrubBar` 进度、循环/上下首/播放/队列、音量 `ScrubBar`、歌词/队列入口。
|
||||
- `feiniu.playError` 的 toast 消费点从 GlobalPlayerBar 移到了这里——**删播放条时容易漏掉这个 watcher**,导致播放失败静默。
|
||||
- **音乐模块 tabs 回归统一写法**:改回 `<Tabs><div ref="tabsListRef" class="shrink-0"><TabsList class="grid w-full max-w-md grid-cols-4 !bg-transparent !p-0 !shadow-none">…<TabsTrigger class="gap-1.5"><Icon class="size-3.5"/>标签</TabsTrigger>` + `<TabsContent class="mt-4 min-h-0 flex-1 tab-animate">`;模块根节点恢复 `h-full p-6 overflow-hidden flex flex-col`。`SegmentedNav` 不再被 MusicModule 使用,仅 `NowPlayingDialog` 的歌词/队列切换仍在用。
|
||||
- 发现音乐 / 任务 / 设置三块内部的横向内边距改为 `0`(由模块根 `p-6` 统一提供,避免左右不对称)。
|
||||
|
||||
---
|
||||
|
||||
## 音乐模块重构(审查之后落地实施)
|
||||
|
||||
### 结构变更(重要,改这块前先读)
|
||||
- ~~**播放器已提升到全局层**:`GlobalPlayerBar.vue` + `NowPlayingDialog.vue`~~ → 已被上一节取代;`NowPlayingDialog.vue`(大屏,含歌词/队列)保留,由标题栏方形控制窗打开。
|
||||
- **新增通用组件**:`src/components/common/ScrubBar.vue`(提交式 seek 进度条,含缓冲层/时间气泡/键盘支持)、`SegmentedNav.vue`(分段导航,现由 NowPlayingDialog 使用)。
|
||||
- **音乐模块导航**:模块级 `Tabs`(曲库/发现音乐/下载任务/设置);「曲库」内部是 `MyMusicLibrary` 的「左侧来源树 + 右侧列表」两栏,来源树含飞牛曲库/本地曲库/我的歌单(歌单列表在树内展开)。
|
||||
- `TrackItem.vue` 重写为 44px 紧凑行(网格列 `28px 36px 1fr 74px 28px`,有专辑时多一列);序号列悬停原位切换播放按钮,播放中显示 `.music-eq` 跳动均衡器(该 CSS 已加到 `style.css` 全局,NowPlayingDialog 复用)。
|
||||
- 所有列表表头/行网格用**字面量** Tailwind 类或内联 `gridTemplateColumns`(Tailwind v4 不识别运行时拼接的类名)。
|
||||
|
||||
### feiniuStore 关键 API 变化
|
||||
- `PlayableItem.source` 增加 `'preview'`,另加 `coverUrl` / `url` / `ext` 字段。
|
||||
- 新增 `nowPlaying`(试听优先于队列当前项)、`playPreview` / `stopPreview`、`playNext` / `addToQueue` / `removeFromQueue` / `clearQueue`、`loadMoreTracks`(分页 200/页)、`searchKeyword`(曲库搜索,之前是孤立局部变量导致搜索无效)、`playError` / `clearPlayError`、`muted` / `toggleMute`、`setPlayMode`、`bufferedPercent`、`nowPlayingTab`。
|
||||
- `playItem(item, context?)` 第二参数是播放上下文;不传则退化为单曲播放。
|
||||
- `init()` 幂等;`setCacheMode`/`clearCache` 不再中断播放;shuffle 用「待播池 + 历史栈」,自动切歌也随机;`currentLine()` 改为缓存索引 + 二分。
|
||||
- 已移除 `queueVisible` / `lyricVisible`(改用 `nowPlayingOpen` + `nowPlayingTab`)。
|
||||
- `musicStore.startDownload` 返回值改为 `{ engine, skipped, taskId? }`。
|
||||
|
||||
### 校验方式(本机 bash 工具不可用,用 PowerShell + node 直接跑)
|
||||
```
|
||||
node node_modules/vue-tsc/bin/vue-tsc.js --noEmit -p tsconfig.json
|
||||
node node_modules/vite/bin/vite.js build
|
||||
```
|
||||
两者在本次重构后均为 exit 0。注意:PowerShell 工具不返回 stdout,需 `| Out-File` 后再 Read;`Remove-Item` 被沙箱拦截(静默失败),删文件要用 `node -e "fs.unlinkSync(...)"`。
|
||||
|
||||
排障经验:`feiniuStore` 任何「返回对象里引用了不存在的变量」(如漏掉 `clearQueue` 函数体)都会让 TS 对整个 store 的类型推断失败,进而让所有 `store.xxx` 变成 `any`,报出一堆看似无关的 `TS7006 implicitly has an 'any' type`。遇到成片 implicit any,先检查 store 自身的错误。
|
||||
|
||||
### 报告
|
||||
`MUSIC_MODULE_REVIEW.md` 已追加「§9 重构落地记录」,逐条对应 30 项改动与「仍未做」清单。
|
||||
|
||||
---
|
||||
|
||||
## 音乐模块全链路审查(数据请求 / 播放控制 / 状态管理 / UI)
|
||||
|
||||
产出:
|
||||
- `MUSIC_MODULE_REVIEW.md` —— 完整审查报告(P0/P1 bug + 性能瓶颈 + 25 条界面缺陷 + 重设计方案 + 分阶段落地路径)
|
||||
- `music-redesign-mockup.html` —— 「当前实现 / 建议方案」可切换的界面改版对照稿(浅色主题)
|
||||
|
||||
### 关键结论(后续如要动这块,先看这些)
|
||||
|
||||
**架构共识(不要改错方向)**
|
||||
- 搜索/解析/下载 → Python 桥接 musicdl;播放 → Rust 本地流代理 `127.0.0.1:<port>/feiniu/stream`(`src-tauri/src/music/feiniu/proxy.rs`),**真流式透传 Range,seek 依赖它,别改成前端直连 NAS**。
|
||||
- 播放内核在 `src/stores/feiniuStore.ts`(不是 musicStore);`musicStore` 只管搜索/下载任务。
|
||||
- `PlayerBar.vue` / `PlayerFull.vue` 用的是 `useFeiniuStore`,不是 musicStore。
|
||||
|
||||
**P0(确认可复现)**
|
||||
1. 飞牛曲库搜索框完全无效:`MyMusicLibrary.vue:21` 的 `keywordInput` 从未写入 `feiniuStore.keyword`,而 `loadTracks` 读的是 store 的 `keyword`(全仓无赋值点)。
|
||||
2. 重启后点播放键无反应:`restoreQueue()` 只恢复队列不设 `audio.src`,`toggle()` 里 `play()` 被空 catch 吞掉。
|
||||
3. 双音频引擎可同时发声:`MusicModule.vue:1413` 的试听 `<audio>` 与 `feiniuStore` 的 `new Audio()` 互不感知。
|
||||
|
||||
**最值得优先做的一件事**
|
||||
把 `PlayerBar` + `PlayerFull` 从 `MusicModule.vue` 提升到 `App.vue` 全局层。原因:`App.vue` 的模块容器是 `:key="activeModule"` 重挂载,切到其他模块时播放条整体卸载,但音频继续播 → 用户失去所有控制入口。
|
||||
|
||||
**其它已确认的坑**
|
||||
- shuffle 模式下自动切歌是顺序的(`next(manual)` 的随机分支带 `manual` 条件)。
|
||||
- `setCacheMode('stream')` / `clearCache()` 都会 `clearPlayback()`,改设置即中断播放。
|
||||
- `CacheSettings.vue` 的 `cacheEnabled` 开关从不回填 store 的 `cacheMode`,状态不同步;`cacheMax` 是死代码(后端字段是 `feiniu_cache_max_gb`)。
|
||||
- `feiniuStore.init()` 被 `MyMusicLibrary`/`CacheSettings` 重复调用,内含 `restoreQueue()` 会覆盖内存队列,应加幂等标志。
|
||||
- `musicStore` 任务持久化把每首歌的 `rawSearch` 一起写 localStorage,大歌单会爆 5MB 配额且静默失败。
|
||||
- `playItem` 未命中队列时替换整个队列(单曲播放语义),歌单里的 TrackItem 还没有双击且播放键会被 disabled → 正在播的歌无法暂停。
|
||||
- rust 下载引擎下 `doDownload` 仍跳「下载任务」tab,但该 tab 是空的(任务在下载器模块)。
|
||||
- 拖动进度条即写 `currentTime` → 流式下反复中断 Range 请求,应改「拖动预览 + 松手提交」。
|
||||
|
||||
**界面主线(详见报告 §5)**
|
||||
播放条未贴底且离开模块即消失;模块左右内边距不对称(左 28 / 右 40 含滚动条);三套切换控件(模块 Tabs / 自绘 segmented / 裸文字);音质徽标 5 色抢戏;列表是「卡片堆」而非紧凑行;`Empty` 组件被裸 children 误用(gap-6 + md:p-12 + border-dashed 无 border)。改版方向:左侧来源树 + 紧凑表格列表 + 全宽坞站播放条 + 可折叠「正在播放」右栏。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 项目长期记忆(Thing / F:\AtieProject\Thing)
|
||||
|
||||
Tauri + Vue 3 + Pinia + Tailwind v4 + reka-ui(shadcn-vue) 的桌面工具箱,按模块组织(`src/modules/<id>/`)。
|
||||
|
||||
## 架构约定
|
||||
|
||||
- **模块挂载**:`App.vue` 的 `ModuleContainer` 用 `:key="activeModule"` 重挂载,**模块不 keep-alive**。因此任何「需要跨模块常驻」的 UI 都必须挂在 `App.vue` 层,不能放在模块内部。
|
||||
- **音乐播放器**:内核在 `src/stores/feiniuStore.ts`(不是 musicStore);UI 入口在**标题栏** `src/components/layout/TitleBarMusic.vue`(搜索框左侧:唱片图标 + 歌名,点击弹出方形控制窗,播放中图标换封面并旋转),大屏 `src/components/layout/NowPlayingDialog.vue`(歌词/队列)由控制窗的入口打开,由 `App.vue` 渲染。`musicStore` 只负责搜索/歌单解析/下载任务。
|
||||
- 历史沿革:`PlayerBar/PlayerFull`(模块内)→ `GlobalPlayerBar`(App 全局坞站)→ 现为 `TitleBarMusic`(标题栏)。前者们均已删除,不要再新增底部播放条。
|
||||
- **模块顶部 tabs 统一写法**:`<Tabs v-model>` → `<div ref="tabsListRef" class="shrink-0">` → `<TabsList class="grid w-full max-w-md grid-cols-N !bg-transparent !p-0 !shadow-none">` → `TabsTrigger`(带 `class="gap-1.5"` + 图标) → `TabsContent class="mt-4 min-h-0 flex-1 ... tab-animate"`;模块根节点统一 `h-full p-6 overflow-hidden flex flex-col`。
|
||||
- 注意:顶部 Tabs 上那个"可见边框"是 `:focus-visible` 的 ring,并非常态样式。别处若要复刻选中态且背景也是白色,需要自己补 `ring-1 ring-border`,否则白色胶囊只靠 `shadow-sm`(5% 透明度)在白底上几乎不可见。
|
||||
- **列表/导航行的对齐**:行高用固定值(`h-8` / `h-7`)+ `items-center`,不要靠 `py-x` 推算;右侧数字列始终渲染(空时显示 `0`)并加 `min-w-[3ch] text-right tabular-nums`,否则数字列会参差。
|
||||
- 音乐播放走 Rust 本地流代理 `127.0.0.1:<port>/feiniu/stream`,**真流式透传 Range**(`src-tauri/src/music/feiniu/proxy.rs`),seek 依赖它,不要改成前端直连 NAS。
|
||||
- 模块生命周期钩子:`src/modules/<id>/index.ts` 的 `lifecycle.onActivate/onDeactivate/onEnable/onDisable`。
|
||||
- 模块内分段导航统一用 `src/components/common/SegmentedNav.vue`;进度/音量统一用 `src/components/common/ScrubBar.vue`(拖动只预览、松手才提交 seek)。
|
||||
|
||||
## 工程约定
|
||||
|
||||
- **Tailwind v4**:不识别运行时拼接的类名。动态网格用内联 `gridTemplateColumns`,或把完整字面量类名写在模板里。
|
||||
- **tsconfig 开启 `noUnusedLocals` / `noUnusedParameters`**,未使用的 import 会导致构建失败。
|
||||
- 主题 token 在 `src/style.css`(`--primary` / `--muted` / `--background` 等)。**禁止硬编码** `bg-white` / `border-white/10`,否则暗色主题会失效。
|
||||
- 全局可复用动画类写在 `src/style.css`(如 `.music-eq`、`.tab-animate`)。
|
||||
- 构建校验:`npm run build`(= `vue-tsc --noEmit && vite build`)。
|
||||
|
||||
## 本机环境注意事项
|
||||
|
||||
- Bash 工具在本机不可用(PATH 未初始化,`ls`/`find` 等均 not found)。用 PowerShell + 显式绝对路径的 node 执行命令。
|
||||
- PowerShell 工具**不返回 stdout**:需 `| Out-File` 写文件后再用 Read 读取。
|
||||
- `Remove-Item` 被沙箱静默拦截;删除文件用 `node -e "require('fs').unlinkSync('绝对路径')"`。
|
||||
- 受管 node:`C:\Users\Administrator\.workbuddy\binaries\node\versions\22.22.2-3\node.exe`。
|
||||
- 排障经验:Pinia setup store 里若返回对象引用了未定义变量,TS 会让整个 store 退化成 `any`,进而报出成片无关的 `TS7006 implicitly has an 'any' type`。先查 store 自身的错误。
|
||||
+7
-7
@@ -315,11 +315,11 @@ const status = processStore.getProcessStatus('proxy')
|
||||
|
||||
| 命令 | 参数 | 返回值 |
|
||||
|------|------|--------|
|
||||
| `start_process` | `StartProcessParams` | `ProcessInfo` |
|
||||
| `stop_process` | `id: String` | `()` |
|
||||
| `get_process_status` | `id: String` | `Option<ProcessInfo>` |
|
||||
| `get_all_process_status` | - | `Vec<ProcessInfo>` |
|
||||
| `stop_all_processes` | - | `()` |
|
||||
| `process_start` | `StartProcessParams` | `ProcessInfo` |
|
||||
| `process_stop` | `id: String` | `()` |
|
||||
| `process_status` | `id: String` | `Option<ProcessInfo>` |
|
||||
| `process_all_status` | - | `Vec<ProcessInfo>` |
|
||||
| `process_stop_all` | - | `()` |
|
||||
|
||||
### 事件
|
||||
|
||||
@@ -388,8 +388,8 @@ rusqlite = { version = "0.30", features = ["bundled"] }
|
||||
|
||||
### Rust 调试
|
||||
|
||||
- 使用 `println!()` 输出到终端
|
||||
- 使用 `dbg!()` 宏调试变量
|
||||
- 使用 `crate::logger::log_info / log_warn / log_error`(或 `log_line`)写入统一日志文件,日志页面可过滤级别
|
||||
- 使用 `dbg!()` 宏调试变量(仅临时,提交前删除)
|
||||
- 使用 Visual Studio Code 的 Rust 调试插件
|
||||
|
||||
### 构建问题排查
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
# 模块开发指南
|
||||
|
||||
本文档详细说明 Thing 应用的模块系统架构,帮助开发者快速理解并创建新模块。
|
||||
|
||||
## 架构概述
|
||||
|
||||
Thing 采用**统一模块注册机制**,每个模块通过 `index.ts` 自描述其全部配置(名称、图标、组件、搜索项、进程配置、生命周期钩子等),由中央注册表 `moduleRegistry` 统一管理。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ main.ts │
|
||||
│ import './modules' (触发注册) │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ src/modules/index.ts │
|
||||
│ 聚合入口:导入所有模块配置并注册 │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ src/modules/registry.ts │
|
||||
│ ModuleRegistry 单例:注册 / 查询 / 组件懒加载 │
|
||||
└──────┬───────────────┬───────────────┬──────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
App.vue Sidebar.vue GeneralSettings
|
||||
(组件加载) (侧边栏导航) (模块开关管理)
|
||||
```
|
||||
|
||||
## 核心文件说明
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/types/module.ts` | 模块配置类型定义 |
|
||||
| `src/modules/registry.ts` | 注册表单例:注册、查询、组件懒加载缓存 |
|
||||
| `src/modules/index.ts` | 聚合入口:导入并注册所有模块 |
|
||||
| `src/modules/icons.ts` | 模块图标映射表 |
|
||||
| `src/modules/<name>/index.ts` | 各模块的配置声明 |
|
||||
| `src/modules/<name>/*.vue` | 模块前端组件 |
|
||||
|
||||
## 模块配置类型
|
||||
|
||||
```typescript
|
||||
// src/types/module.ts
|
||||
|
||||
interface ModuleConfig {
|
||||
/** 模块唯一标识(如 'proxy'、'clipboard') */
|
||||
id: string
|
||||
/** 显示名称 */
|
||||
name: string
|
||||
/** 图标标识(对应 icons.ts 中的 key) */
|
||||
icon: string
|
||||
/** 模块描述(显示在设置界面的模块管理中) */
|
||||
description: string
|
||||
/** 模块分类 */
|
||||
category: 'network' | 'tool' | 'system' | 'media'
|
||||
/** 默认是否启用(默认 true) */
|
||||
defaultEnabled?: boolean
|
||||
/** 是否为内置模块(不可禁用,如设置模块) */
|
||||
builtin?: boolean
|
||||
/** 懒加载组件的 loader 函数(推荐) */
|
||||
loader?: () => Promise<{ default: Component }>
|
||||
/** 直接组件引用(内置模块可用,无需懒加载) */
|
||||
component?: Component
|
||||
/** 全局搜索项 */
|
||||
searchItems?: SearchIndexItem[]
|
||||
/** 进程配置(需要管理子进程的模块填写) */
|
||||
process?: ModuleProcessConfig
|
||||
/** 生命周期钩子 */
|
||||
lifecycle?: ModuleLifecycle
|
||||
/** 排序权重(数值越小越靠前,默认 100) */
|
||||
order?: number
|
||||
}
|
||||
```
|
||||
|
||||
## 创建新模块
|
||||
|
||||
### 第 1 步:创建模块目录
|
||||
|
||||
```
|
||||
src/modules/my-module/
|
||||
├── index.ts # 模块配置
|
||||
└── MyModule.vue # 前端组件
|
||||
```
|
||||
|
||||
### 第 2 步:编写模块配置
|
||||
|
||||
```typescript
|
||||
// src/modules/my-module/index.ts
|
||||
import type { ModuleConfig } from '@/types/module'
|
||||
|
||||
export const moduleConfig: ModuleConfig = {
|
||||
id: 'my-module',
|
||||
name: '我的模块',
|
||||
icon: 'my-module', // 需在 icons.ts 中添加映射
|
||||
description: '模块功能描述',
|
||||
category: 'tool',
|
||||
defaultEnabled: true,
|
||||
loader: () => import('./MyModule.vue'),
|
||||
searchItems: [
|
||||
{
|
||||
title: '功能名称',
|
||||
description: '功能描述',
|
||||
keywords: ['关键词1', '关键词2', 'keyword']
|
||||
}
|
||||
],
|
||||
order: 70
|
||||
}
|
||||
```
|
||||
|
||||
### 第 3 步:注册模块
|
||||
|
||||
在 `src/modules/index.ts` 中添加导入:
|
||||
|
||||
```typescript
|
||||
import { moduleConfig as myModule } from './my-module'
|
||||
|
||||
const allModules: ModuleConfig[] = [
|
||||
// ... 已有模块
|
||||
myModule
|
||||
]
|
||||
|
||||
moduleRegistry.registerAll(allModules)
|
||||
```
|
||||
|
||||
### 第 4 步:添加图标映射
|
||||
|
||||
在 `src/modules/icons.ts` 中添加:
|
||||
|
||||
```typescript
|
||||
import { Wrench } from '@lucide/vue' // 选择合适的图标
|
||||
|
||||
export const moduleIconMap: Record<string, Component> = {
|
||||
// ... 已有映射
|
||||
'my-module': Wrench
|
||||
}
|
||||
```
|
||||
|
||||
### 第 5 步(可选):添加 Rust 后端命令
|
||||
|
||||
如果模块需要 Rust 后端支持,在 `src-tauri/src/` 中创建命令模块,并在 `lib.rs` 的 `invoke_handler` 中注册。
|
||||
|
||||
## 进程管理
|
||||
|
||||
需要管理外部子进程的模块(如 mihomo)通过 `ModuleProcessConfig` 声明进程配置。下载器模块使用进程内自建下载引擎,不涉及外部子进程管理。
|
||||
|
||||
### 配置示例
|
||||
|
||||
```typescript
|
||||
// src/modules/proxy/index.ts
|
||||
export const moduleConfig: ModuleConfig = {
|
||||
// ...
|
||||
process: {
|
||||
name: 'mihomo', // 进程标识
|
||||
executable: '', // 可执行文件路径(运行时确定)
|
||||
args: ['-f', 'config.yaml'], // 启动参数
|
||||
autoStart: false, // 模块启用时是否自动启动
|
||||
restartOnCrash: true, // 崩溃后自动重启
|
||||
maxRestarts: 3 // 最大重启次数(0 = 不限制)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 前端 API
|
||||
|
||||
```typescript
|
||||
import { useProcessStore } from '@/stores/processStore'
|
||||
|
||||
const processStore = useProcessStore()
|
||||
|
||||
// 通过模块 ID 启动进程(自动读取模块配置)
|
||||
await processStore.startByModule('proxy')
|
||||
|
||||
// 停止进程
|
||||
await processStore.stopByModule('proxy')
|
||||
|
||||
// 获取进程状态(从缓存读取)
|
||||
const status = processStore.getProcessStatus('proxy')
|
||||
// status.status: 'running' | 'stopped' | 'crashed' | 'starting'
|
||||
|
||||
// 刷新所有进程状态
|
||||
await processStore.refreshAll()
|
||||
```
|
||||
|
||||
### 自动化行为
|
||||
|
||||
模块启用/禁用时,`appStore.toggleModule` 会自动处理:
|
||||
|
||||
| 操作 | 禁用模块 | 启用模块 |
|
||||
|------|----------|----------|
|
||||
| 进程 | 停止运行中的进程 | 若 `autoStart` 为 true,启动进程 |
|
||||
| 搜索项 | 移除该模块的搜索项 | 恢复该模块的搜索项 |
|
||||
| 组件缓存 | 清除组件缓存,释放内存 | 下次访问时重新懒加载 |
|
||||
| 生命周期 | 调用 `onDisable` 钩子 | 调用 `onEnable` 钩子 |
|
||||
| 侧边栏 | 从侧边栏隐藏 | 在侧边栏显示 |
|
||||
| Toast 通知 | 显示"已禁用 {模块名}" | 显示"已启用 {模块名}" |
|
||||
|
||||
## 生命周期钩子
|
||||
|
||||
```typescript
|
||||
interface ModuleLifecycle {
|
||||
/** 模块首次加载时调用 */
|
||||
onInit?: () => void | Promise<void>
|
||||
/** 模块组件挂载时调用(切换到该模块) */
|
||||
onActivate?: () => void | Promise<void>
|
||||
/** 模块组件卸载时调用(切换离开该模块) */
|
||||
onDeactivate?: () => void | Promise<void>
|
||||
/** 模块被禁用时调用 */
|
||||
onDisable?: () => void | Promise<void>
|
||||
/** 模块被启用时调用 */
|
||||
onEnable?: () => void | Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
使用示例:
|
||||
|
||||
```typescript
|
||||
export const moduleConfig: ModuleConfig = {
|
||||
// ...
|
||||
lifecycle: {
|
||||
onEnable: async () => {
|
||||
console.log('模块已启用')
|
||||
// 初始化资源、建立连接等
|
||||
},
|
||||
onDisable: async () => {
|
||||
console.log('模块已禁用')
|
||||
// 释放资源、关闭连接等
|
||||
},
|
||||
onActivate: () => {
|
||||
console.log('用户切换到本模块')
|
||||
// 开始实时数据更新等
|
||||
},
|
||||
onDeactivate: () => {
|
||||
console.log('用户离开本模块')
|
||||
// 暂停实时更新以节省资源
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 全局搜索
|
||||
|
||||
模块通过 `searchItems` 声明可被全局搜索的功能项。用户在标题栏搜索框输入关键词时,匹配的搜索项会显示在结果列表中。
|
||||
|
||||
```typescript
|
||||
searchItems: [
|
||||
{
|
||||
title: '代理设置', // 显示标题
|
||||
description: '配置网络代理', // 显示描述
|
||||
keywords: ['代理', 'proxy', '网络'] // 匹配关键词
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
如果搜索项需要执行特定操作(如跳转到子页面、触发命令),在模块组件挂载时通过 `searchStore.registerAction` 注册:
|
||||
|
||||
```typescript
|
||||
import { useSearchStore } from '@/stores/searchStore'
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
|
||||
onMounted(() => {
|
||||
// 注册第 0 个搜索项的 action
|
||||
searchStore.registerAction('my-module', 0, () => {
|
||||
// 跳转到特定子页面或执行操作
|
||||
activeTab.value = 'settings'
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## 模块分类
|
||||
|
||||
| 分类 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `network` | 网络相关 | 代理、下载器、网络工具 |
|
||||
| `tool` | 实用工具 | 剪贴板、文件搜索、文本处理 |
|
||||
| `system` | 系统相关 | 硬件监控、系统设置 |
|
||||
| `media` | 媒体相关 | 截图、录屏、图片处理 |
|
||||
|
||||
分类目前用于元信息标记,未来可用于设置界面的分组展示。
|
||||
|
||||
## 模块禁用机制详解
|
||||
|
||||
当用户在设置页面切换模块开关时:
|
||||
|
||||
```
|
||||
用户点击开关
|
||||
│
|
||||
▼
|
||||
appStore.toggleModule(moduleId, enabled)
|
||||
│
|
||||
├─ 禁用时:
|
||||
│ ├─ searchStore.unregisterModule(moduleId) // 移除搜索项
|
||||
│ ├─ processStore.stopByModule(moduleId) // 停止进程
|
||||
│ ├─ config.lifecycle?.onDisable?.() // 生命周期钩子
|
||||
│ ├─ moduleRegistry.clearComponentCache(id) // 清除组件缓存
|
||||
│ ├─ modules[id].enabled = false // 更新状态
|
||||
│ └─ toast.success('已禁用 {模块名}') // 通知用户
|
||||
│
|
||||
└─ 启用时:
|
||||
├─ modules[id].enabled = true // 更新状态
|
||||
├─ config.lifecycle?.onEnable?.() // 生命周期钩子
|
||||
├─ searchStore.registerItem(...) // 恢复搜索项
|
||||
├─ processStore.startByModule(moduleId) // 启动进程(若 autoStart)
|
||||
└─ toast.success('已启用 {模块名}') // 通知用户
|
||||
```
|
||||
|
||||
禁用后的效果:
|
||||
- 模块从侧边栏导航中隐藏
|
||||
- 模块的搜索项从全局搜索中移除
|
||||
- 模块的后台进程被停止
|
||||
- 模块的组件缓存被清除,释放内存
|
||||
- 如果当前正在查看被禁用的模块,自动切换到第一个可用模块
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **模块 ID 必须唯一**:重复注册会被忽略并输出警告
|
||||
2. **图标必须映射**:模块配置中的 `icon` 字符串必须在 `icons.ts` 中有对应映射,否则回退到 Settings 图标
|
||||
3. **懒加载优先**:使用 `loader` 而非 `component`,避免首屏加载所有模块代码
|
||||
4. **避免循环依赖**:模块的 `index.ts` 只导出配置,不导入其他模块的 store
|
||||
5. **进程配置的 executable**:通常留空,由模块组件在运行时根据用户设置确定实际路径
|
||||
6. **内置模块**:设置 `builtin: true` 的模块不可被用户禁用,开关处于禁用状态
|
||||
|
||||
## 跨模块开发经验(代理模块沉淀)
|
||||
|
||||
以下要点来自代理模块开发,对后续涉及子进程管理、外部 API 交互、shadcn-vue 组件使用的模块同样适用。
|
||||
|
||||
### Tauri 命令与主线程
|
||||
|
||||
- **同步命令(`pub fn`)会阻塞主线程**:Tauri 的同步命令在主线程执行,其内部的 `std::thread::sleep`、磁盘 I/O、网络请求会阻塞所有 async 命令的调度。涉及等待/阻塞操作的命令必须声明为 `pub async fn`,并用 `tauri::async_runtime::spawn_blocking(|| { std::thread::sleep(...) }).await` 将阻塞操作放到线程池。
|
||||
- **Windows 端口释放有延迟**:`child.kill()` + `child.wait()` 后 TCP 端口不会立即可用,需等待约 800ms 再重新绑定。重启类命令应预留此延迟。
|
||||
- **`Mutex` 持锁期间禁止 sleep**:`ProcessManager::check_and_cleanup` 等持锁函数中不要执行长时间 sleep,否则会阻塞所有需要该锁的命令(如状态查询)。应先释放锁再 sleep,或移出临界区。
|
||||
- **进程监控线程**:`start_monitoring_thread` 每 3 秒检查一次进程状态,崩溃时自动重启(可配置 `maxRestarts`)。前端通过监听 `process-status-changed` 事件更新 UI。
|
||||
|
||||
### 外部 API 交互
|
||||
|
||||
- **API 就绪轮询**:子进程 spawn 后 API 不会立即可用(需初始化配置、加载 geo 文件等)。前端应在请求前轮询健康检查接口(如 `/version`),500ms 间隔、10s 超时。
|
||||
- **缓存配置避免频繁读盘**:后端 Manager 每次方法调用都从磁盘读 settings 会拖慢批量操作。建议在 Manager 内维护内存缓存,`save_settings` 时同步更新。
|
||||
- **并发请求限流**:批量测速等场景不要一次性 `Promise.all` 全部请求,应分批(如每批 20 个),避免压垮子进程。
|
||||
|
||||
### shadcn-vue / reka-ui 注意事项
|
||||
|
||||
- **Select 禁止空字符串 value**:`<SelectItem value="">` 会触发警告并失效。使用哨兵值(如 `__default__`、`__all__`)替代空字符串,在 `@update:model-value` 回调中转回空值。
|
||||
- **Select 双击问题**:reka-ui Select 的 DismissableLayer + closeAutoFocus 会导致连续点击两个 Select 时第一次点击仅关闭上一个、需第二次点击才打开下一个。当前未完美解决,建议同一界面避免放置过多相邻 Select。
|
||||
- **Switch 使用 `model-value` / `update:model-value`**:reka-ui v2+ 的 Switch 不再使用 `checked` / `update:checked`。
|
||||
- **Sonner toast 不可见**:通常是缺少 `vue-sonner/lib/index.css` 导入,而非 z-index 问题。确保在入口处导入该 CSS。
|
||||
- **AlertDialog 替代 confirm()**:原生 `confirm()` 在 Tauri WebView 中样式不一致,使用 shadcn-vue AlertDialog 封装 Promise 化的 `showConfirm()` 函数,支持 destructive 样式。
|
||||
|
||||
### 数据一致性与自愈
|
||||
|
||||
- **配置文件与磁盘 reconcile**:settings.json 中的列表(如订阅 profiles)可能与磁盘文件不同步(用户手动删除、反序列化失败被 default 覆盖)。每次 `load_settings` 时应扫描磁盘补全缺失条目,并在 `currentProfile` 为 null 时自动指向第一个。
|
||||
- **操作顺序防重复**:导入文件后写入 settings 时,若先写文件再 load_settings(含 reconcile 扫盘),reconcile 会扫到新文件添加一次,随后 push 又添加一次。应先 load_settings → 写文件 → push(加去重保护)。
|
||||
- **子进程崩溃循环防护**:子进程因依赖文件损坏(如 geo 数据库)崩溃时,`restart_on_crash` 会反复重启。需配置备用下载源(如 jsdelivr 镜像)避免无代理时 GitHub 超时。
|
||||
|
||||
### UI 细节
|
||||
|
||||
- **瀑布流布局避免卡片等高撑开**:使用 `columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid` 替代 `grid`,让卡片按内容高度自然排列。
|
||||
- **模块禁用清理系统状态**:模块 `onDisable` 钩子应清理系统级副作用(如系统代理注册表项),避免模块停用后遗留导致系统异常。
|
||||
- **应用退出清理**:在 `lib.rs` 的 `quit_app` 命令和托盘退出事件中都要调用 `cleanup_on_exit` + `stop_all`,确保任何退出路径都清理干净。
|
||||
|
||||
## 近期重要变更(影响其他模块开发)
|
||||
|
||||
以下是最近几次改动中确立的约定和模式,开发新模块时需遵循。
|
||||
|
||||
### 后端:子进程启动必须隐藏控制台窗口
|
||||
|
||||
`process_manager.rs` 暴露了公共函数 `setup_creation_flags(cmd: &mut Command)`,Windows 上会设置 `CREATE_NO_WINDOW` 标志。
|
||||
|
||||
**所有用 `std::process::Command` 启动外部程序的地方都必须调用它**,否则会弹出黑色控制台窗口(即使程序是后台运行)。包括:
|
||||
|
||||
- `ProcessManager::start()` 启动 mihomo 等内核
|
||||
- `ProcessManager::check_and_cleanup()` 崩溃重启
|
||||
- 任何调用 `mihomo.exe -v` 等查询版本的场景
|
||||
|
||||
跨平台:非 Windows 平台该函数为空实现,无需条件编译。
|
||||
|
||||
```rust
|
||||
use crate::process_manager::setup_creation_flags;
|
||||
|
||||
let mut cmd = std::process::Command::new(&path);
|
||||
cmd.arg("-v");
|
||||
setup_creation_flags(&mut cmd); // 必须调用
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null()).stdin(Stdio::null());
|
||||
```
|
||||
|
||||
### 后端:长时间 wait() 不要阻塞主线程
|
||||
|
||||
`ProcessManager::stop()` 中 `child.kill()` 是快速的,但 `child.wait()` 可能阻塞几十到几百毫秒。**wait() 已移到后台线程**:
|
||||
|
||||
```rust
|
||||
thread::spawn(move || {
|
||||
let _ = entry.child.wait();
|
||||
});
|
||||
```
|
||||
|
||||
新模块若有类似的"终止外部进程"逻辑,也应遵循此模式,避免 Tauri 命令阻塞导致前端卡顿。
|
||||
|
||||
### 后端:内核/二进制下载用流式 + 事件推送
|
||||
|
||||
`mihomo_manager.rs` 的 `install_kernel` 确立了"下载二进制资源"的标准模式,未来若有其他模块需要下载外部内核时应复用:
|
||||
|
||||
- **流式下载**:`reqwest::Response::bytes_stream()` + `futures_util::StreamExt`,避免大文件一次性读入内存
|
||||
- **进度事件**:通过 `app.emit("xxx-install-progress", progress)` 推送,事件载荷结构参考 `InstallProgress`
|
||||
- **事件节流**:仅在百分比变化 ≥1% 时 emit,避免事件轰炸
|
||||
- **zip 解压**:用 `zip` crate(纯 Rust),不要用 PowerShell `Expand-Archive`(有执行策略问题)
|
||||
- **文件名匹配**:解压后用 `find_exe_in_dir` 查找 `.exe`(zip 内文件名可能含版本号,不是固定名字),找到后重命名为标准名
|
||||
|
||||
### 后端:GitHub API rate limit 规避
|
||||
|
||||
`check_kernel_update` 采用 **API 优先 + 重定向回退** 策略:
|
||||
|
||||
1. 先调 `api.github.com/.../releases/latest`(能拿完整资产列表,命名变化时更健壮)
|
||||
2. 失败(403 rate limit / 网络错误)时回退到访问 `github.com/.../releases/latest`,从 302 重定向的最终 URL 提取版本号,按稳定命名规则构造下载 URL
|
||||
|
||||
**新模块若需要查 GitHub 最新版本,应复用此模式**,不要直接调 API(未认证 60次/小时/IP 极易超限)。
|
||||
|
||||
### 后端:资产命名规则适配
|
||||
|
||||
mihomo v1.19+ 改了 Windows 资产命名,按 CPU 微架构分级:
|
||||
|
||||
- 旧:`mihomo-windows-amd64-vX.X.X.zip`(已废弃)
|
||||
- 新:`mihomo-windows-amd64-v3-vX.X.X.zip`(v1/v2/v3 对应 CPU level)
|
||||
|
||||
`fetch_latest_via_api` 中的匹配优先级:v3 标准 > v3-go124 > v3-go123 > v3 其他 > v2 > v1 > 旧命名。若未来其他内核也有类似分级,参考此优先级策略。
|
||||
|
||||
### 前端:Transition 内的 v-if/v-else 必须加 key
|
||||
|
||||
**这是 dev 模式的坑**。`<Transition mode="out-in">` 内的 `v-if`/`v-else` 分支如果缺 `:key`,Vue 3.5.x dev 模式下会触发 `__vnode` 写入竞态,导致:
|
||||
|
||||
- 控制台报错 `Cannot set properties of null (setting '__vnode')`
|
||||
- vnode 树损坏,所有事件派发失效(按钮点击没反应)
|
||||
- **build 模式不报错**(生产构建剥除了 `__vnode` instrumentation),容易漏掉
|
||||
|
||||
**约定**:`<Transition>` 内所有分支(v-if/v-else-if/v-else)都必须加 `:key`,即使是原生 div 也要加。
|
||||
|
||||
```vue
|
||||
<Transition name="fade" mode="out-in">
|
||||
<ComponentA v-if="cond" key="a" />
|
||||
<div v-else key="empty">占位</div>
|
||||
</Transition>
|
||||
```
|
||||
|
||||
`ModuleContainer.vue` 和 `ProxyModule.vue` 的 Progress 区块已修复,新模块开发时注意。
|
||||
|
||||
### 前端:模块内 Tabs 顶部固定模式
|
||||
|
||||
模块根容器用 `h-full overflow-hidden flex flex-col`,Tabs 用 `flex-1 min-h-0 flex flex-col`,TabsList 加 `shrink-0`,TabsContent 加 `flex-1 min-h-0 overflow-y-auto`:
|
||||
|
||||
```vue
|
||||
<div class="h-full p-6 overflow-hidden flex flex-col">
|
||||
<Tabs v-model="tab" class="flex-1 min-h-0 flex flex-col">
|
||||
<TabsList class="shrink-0">...</TabsList>
|
||||
<TabsContent value="x" class="flex-1 min-h-0 overflow-y-auto">...</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
```
|
||||
|
||||
这样 TabsList 固定在顶部,只有 TabsContent 滚动。`min-h-0` 是 flex 子元素 overflow 生效的关键,不能省。
|
||||
|
||||
### 前端:Tauri 事件监听需在 store 中管理生命周期
|
||||
|
||||
`proxyStore.ts` 的 `installKernel` 确立了模式:
|
||||
|
||||
- 监听在方法调用时注册,`finally` 块中取消
|
||||
- 用模块级变量保存 `UnlistenFn`,避免重复注册
|
||||
- 错误事件由后端保证 emit(前端不重复弹 toast,统一由 watch 处理)
|
||||
|
||||
```typescript
|
||||
let progressUnlisten: UnlistenFn | null = null
|
||||
const installKernel = async () => {
|
||||
if (!progressUnlisten) {
|
||||
progressUnlisten = await listen<Progress>('xxx-progress', (e) => {
|
||||
progress.value = e.payload
|
||||
})
|
||||
}
|
||||
try {
|
||||
await invoke('xxx_command')
|
||||
} finally {
|
||||
if (progressUnlisten) {
|
||||
progressUnlisten()
|
||||
progressUnlisten = null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 前端:窗口隐藏前 blur 焦点
|
||||
|
||||
TitleBar 的关闭按钮实际是 `hide()` 到托盘。webview 快速隐藏时浏览器 `mouseleave` 可能不触发,导致从托盘恢复后按钮仍显示 hover 高亮。
|
||||
|
||||
**修复**:`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 高度固定 40px(h-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`,复制时复制完整路径。
|
||||
|
||||
### 前端:任务历史持久化(下载器模块模式)
|
||||
|
||||
下载引擎在进程内运行,状态通过 `engine_state.json` 持久化到磁盘。前端 store 的做法:
|
||||
|
||||
- `onMounted` 时调用 `store.init()`,一次性加载 status/settings/extensionInfo/tasks
|
||||
- 通过 Tauri 事件 `download-progress`、`download-complete`、`download-added` 实时更新任务状态
|
||||
- 引擎在 `Storage::new` 时自动创建数据目录,`save` 时兜底重建父目录,避免路径不存在错误
|
||||
|
||||
此模式适用于任何"引擎在进程内运行、状态需持久化"的场景。
|
||||
|
||||
### 前端:轻量设置 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. 系统托盘的退出菜单项
|
||||
|
||||
漏掉任何一个都会导致子进程残留(如 mihomo 继续占用端口、系统代理未清除)。新增管理子进程的模块时,检查这两处是否都调用了清理逻辑。
|
||||
@@ -0,0 +1,465 @@
|
||||
# 音乐模块全链路审查报告
|
||||
|
||||
> 审查范围:数据请求(搜索/歌单解析/下载/解析真实链接)、播放控制(试听、播放器内核、队列、歌词、缓存)、状态管理(musicStore / feiniuStore)、UI 交互与视觉。
|
||||
> 覆盖文件(前端 11 个 + Rust 4 个):
|
||||
> `src/stores/musicStore.ts`、`src/stores/feiniuStore.ts`、`src/modules/music/MusicModule.vue`、`components/PlayerBar.vue`、`components/PlayerFull.vue`、`components/MyMusicLibrary.vue`、`components/TrackItem.vue`、`components/settings/ConnectionsSettings.vue`、`components/settings/CacheSettings.vue`、`SourcePicker.vue`、`sources.ts`、`index.ts`、`src/App.vue`、`src/components/layout/ModuleContainer.vue`、`src-tauri/src/music/{mod,commands}.rs`、`src-tauri/src/music/feiniu/{mod,proxy}.rs`
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论摘要
|
||||
|
||||
整体架构是清晰的:**搜索/下载走 Python 桥接(musicdl)**,**播放走飞牛 NAS 本地流代理(Rust axum,支持 Range 透传)**,两个 store 职责分离,流代理与缓存设计是对的。问题集中在三类:
|
||||
|
||||
| 类别 | 数量 | 代表问题 |
|
||||
|---|---|---|
|
||||
| P0 功能性失效 | 3 | 飞牛曲库搜索框完全无效;重启后播放键无响应;两个音频引擎可同时发声 |
|
||||
| P1 逻辑/状态缺陷 | 12 | shuffle 自动切歌不随机;缓存开关状态不同步;队列被单曲播放重置;任务持久化写入 `rawSearch` 撑爆 localStorage |
|
||||
| P2 性能瓶颈 | 9 | 拖动进度条即发 Range 请求;歌词/队列无虚拟滚动;全屏模糊背景;每行 2 个 Tooltip |
|
||||
| P3 界面与交互缺陷 | 25 | 播放条未贴底且离开模块即消失;左右内边距不对称;三套切换控件;徽标 5 色抢戏;列表变卡片堆 |
|
||||
|
||||
**最需要优先决策的一件事**:把 `PlayerBar` / `PlayerFull` 从 `MusicModule.vue` 提升到 `App.vue` 全局层。当前它们挂载在音乐模块内部,而模块容器是 `:key="activeModule"` 重挂载(`App.vue:36-41`)——**切到任何其他模块,播放条和"正在播放"大屏整体卸载,但音频仍在后台播放,用户完全失去控制入口**。这是当前体验上最严重的问题。
|
||||
|
||||
---
|
||||
|
||||
## 1. P0 功能性失效(必须修)
|
||||
|
||||
### 1.1 飞牛曲库搜索框完全无效
|
||||
**现象**:输入关键词、回车,曲库列表纹丝不动。
|
||||
|
||||
**根因**:`MyMusicLibrary.vue:21` 定义了本地 `keywordInput`,`onSearchInput`(:24-29)只做了防抖后调用 `store.loadTracks(1)`,而 `loadTracks` 读的是 **store 里的 `keyword`**(`feiniuStore.ts:381`)。检查全仓:`store.keyword` **从未被任何 UI 赋值**。
|
||||
|
||||
```ts
|
||||
// feiniuStore.ts:375-382
|
||||
const data = await invoke('feiniu_list_tracks', {
|
||||
page: p, size: 50,
|
||||
keyword: keyword.value.trim() || null // ← keyword 永远是 ''
|
||||
})
|
||||
```
|
||||
|
||||
**修复**:把输入直接接到 store 字段,去掉中间层。
|
||||
|
||||
```vue
|
||||
<!-- MyMusicLibrary.vue -->
|
||||
<Input v-model="store.keyword" placeholder="搜索飞牛曲库(歌名 / 歌手)"
|
||||
@input="onSearchInput" @keydown.enter="onSearchInput" />
|
||||
```
|
||||
```ts
|
||||
// onSearchInput 里补一句:清空关键词时立即回全量
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => store.loadTracks(1).catch(e => toast.error(String(e))), 400)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 应用重启后,播放键点了没反应
|
||||
**现象**:重启应用,底部播放条显示着上次的歌,点播放键没任何反应,也没有提示。
|
||||
|
||||
**根因**:`restoreQueue()`(`feiniuStore.ts:644-655`)只恢复了 `queue` / `queueIndex`,**没有给 audio 设置 `src`**。此时点击播放走 `toggle()`(:564-568):
|
||||
|
||||
```ts
|
||||
function toggle() {
|
||||
const a = ensureAudio()
|
||||
if (a.paused) a.play().catch(() => {}) // ← 无 src,play() 直接 reject,被静默吞掉
|
||||
else a.pause()
|
||||
}
|
||||
```
|
||||
|
||||
`ensureAudio()` 新建的 `Audio()` 没有 `src`,`play()` 抛 `NotSupportedError`,被空 catch 吞掉。点「上一首/下一首」反而能用(走 `playCurrent()` 会设 src),所以表现为"只有播放键坏了"。
|
||||
|
||||
**修复**:`toggle()` 检测无 src 时先走一次 `playCurrent()`;同时给失败加可见提示。
|
||||
|
||||
```ts
|
||||
function toggle() {
|
||||
const a = ensureAudio()
|
||||
if (!a.src) { // 恢复队列后首次播放
|
||||
if (current.value) playCurrent()
|
||||
return
|
||||
}
|
||||
if (a.paused) a.play().catch(err => { playing.value = false; logger.error(`播放失败: ${err}`) })
|
||||
else a.pause()
|
||||
}
|
||||
```
|
||||
另外建议 `startPlayback` 的 catch 里落一条 `toast.error('播放失败:' + err.message)`,当前 `:558-561` 是先 catch 再静默,用户拿不到任何信息。
|
||||
|
||||
---
|
||||
|
||||
### 1.3 两个独立音频引擎,可同时发声
|
||||
**现象**:在「发现音乐」点某首歌试听,再回到播放器播另一首 → 两首歌同时响;或者试听中点播放器播放,两路声音叠加。
|
||||
|
||||
**根因**:存在两个互不知晓的 `<audio>`:
|
||||
- `MusicModule.vue:1413` —— 试听用的模板 `<audio ref="audioEl">`(`togglePlay` :372-406 驱动)
|
||||
- `feiniuStore.ts:109-126` —— 播放器内核 `new Audio()`
|
||||
|
||||
二者没有互斥,`playingKey` 与 `store.playing` 彼此独立。附带问题:试听音频在**切换 tab / 卸载模块时不会停止**(模板 audio 被移除,引用置空,媒体仍可能继续播到自然结束)。
|
||||
|
||||
**修复(推荐)**:废弃模块内试听 audio,试听直接走同一内核——`playQueue([{...}], 0)`,source 用 `'preview'`,播完即止。若短期不想动内核,至少在 `togglePlay` 之前 `feiniu.clearPlayback()`,并在 `onBeforeUnmount` 里 `audioEl.value?.pause()`。
|
||||
|
||||
---
|
||||
|
||||
## 2. P1 逻辑与状态缺陷
|
||||
|
||||
| # | 问题 | 位置 | 说明与改法 |
|
||||
|---|---|---|---|
|
||||
| 2.1 | **shuffle 自动切歌不随机** | `feiniuStore.ts:570-586` | `next(manual)` 的随机分支带 `manual` 条件,`onEnded → next(false)` 在 shuffle 下走的是顺序 `++`。用户开了随机,一首播完却是下一首。改:把随机逻辑与 `manual` 解耦,`shuffle` 时无论手动/自动都随机(并记录已播集合避免短期重复)。 |
|
||||
| 2.2 | **prev 在 shuffle 下也是顺序** | `feiniuStore.ts:598-607` | 同上;随机模式应回退到历史栈。 |
|
||||
| 2.3 | **切缓存模式/清缓存会中断播放** | `feiniuStore.ts:266-280` | `setCacheMode('stream')` 和 `clearCache()` 都调 `clearPlayback()`;而 `CacheSettings.setEnabled(false)` 又会触发 `setCacheMode('stream')`。改设置就把歌停了,属于误伤。应只在必要(如清掉正在播的那首缓存)时停止,或改为"下一首生效"。 |
|
||||
| 2.4 | **缓存开关与真实模式不同步** | `CacheSettings.vue:13-23,32-35` | `cacheEnabled` 初始恒为 `false`,`onMounted` 只读了 `cacheMode` 没回填开关;若历史已是 cache 模式,打开设置页看到的是"关",但实际在缓存。另外 `cacheMax = ref(5)` 是死代码(从未使用,后端对应字段是 `feiniu_cache_max_gb`),`cacheStatus` 在 onMounted 也没刷新。 |
|
||||
| 2.5 | **单曲播放会重置整个队列** | `feiniuStore.ts:500-511` | `playItem` 未命中队列时 `queue.value = [{...item}]`——用户从 2000 首的曲库点播放,队列被替换成 1 首,播完就停。播放器语义应是"以所在列表为上下文播放"。改:`playItem(item, contextList?)`,命中则跳转,未命中则以 `contextList` 建队列。 |
|
||||
| 2.6 | **歌单内的曲目无法暂停 / 无法整单播放** | `MyMusicLibrary.vue:228-233` | 歌单里的 `TrackItem` 既没有 `@dblclick`,播放按钮又被 `TrackItem.vue:71` 的 `:disabled="store.playing && store.current?.guid === item.guid"` 禁用——正在播这首时按钮变灰,**没有任何方式暂停**。且点击播放会走 2.5 的"单曲替换队列"。 |
|
||||
| 2.7 | **`init()` 被重复调用,会覆盖内存中的队列** | `feiniuStore.ts:129-157` | `MyMusicLibrary.onMounted`(:64-69)与 `CacheSettings.onMounted`(:16-20)都调 `store.init()`;`init` 内含 `restoreQueue()`,会在用户切到设置页时用 localStorage 的旧值覆盖当前队列/索引。应加 `inited` 标志位做幂等,或把 `restoreQueue` 只放在应用启动路径。 |
|
||||
| 2.8 | **任务持久化把 `rawSearch` 一起写进 localStorage** | `musicStore.ts:79-93,228-234,439` | `songsData: songs` 保留了每首歌的 `rawSearch`(上游 API 原始响应)。一个 500 首歌单任务 → 序列化后极易超过 5MB 配额,`persistTasks` 的 catch 会静默失败,历史记录随机丢失。改:持久化前剥离 `rawSearch` / `defaultDownloadHeaders`,只留重下所需字段。 |
|
||||
| 2.9 | **`pendingUploadOnDone` 泄漏,造成误上传** | `MusicModule.vue:60,74-82,483-500` | 该标志在下发下载请求时置 `true`,只有"检测到有任务 done"才清 `false`。若下载报错/被取消、或用户在选择音质弹窗点了取消,标志会残留,之后**任意一个无关任务完成**都会触发一次上传。`autoUpload` 打开时则是每个任务完成都重复全量上传一次。建议改为按 `taskId` 记录"待上传任务集合",完成时精确消费。 |
|
||||
| 2.10 | **Rust 引擎下载后跳到空的任务页** | `MusicModule.vue:460` | `doDownload` 无条件 `activeTab.value = 'tasks'`,但 `downloadEngine === 'rust'` 时任务被交给「下载器」模块(`musicStore.ts:391-430` 不创建本模块任务),本模块任务列表是空的,用户看到"暂无下载任务"。且此时 `downloadTarget === 'feiniu'` 的自动上传链路也永远不会触发。应在 rust 引擎下改为提示"已交给下载器模块"并跳转到对应模块。 |
|
||||
| 2.11 | **`duration` 未知时拖动进度条无效** | `feiniuStore.ts:609-613`(配合 `PlayerBar.vue:18-20`) | `onProgress` 用 `store.duration` 换算秒数;若流没有返回 `Content-Length`,`loadedmetadata` 后 `duration` 为 `NaN`,`a.currentTime = NaN` 被忽略,进度条要么不动要么跳回 0。应在 `seek` 内做 `if (!isFinite(a.duration) || a.duration <= 0) return` 并给出"当前音频不支持跳转"的提示。 |
|
||||
| 2.12 | **`nowPlayingOpen` 状态跨模块残留** | `feiniuStore.ts:105` + `PlayerFull.vue:43` | 大屏开着时切走模块,Dialog 随模块卸载消失,但 `nowPlayingOpen` 仍是 `true`;回到音乐模块会突兀地自动弹出。建议改为由组件本地 `ref` 承载,或在 `onMounted` 时重置。 |
|
||||
|
||||
其它可在后续清理的小项:`doneCount` 在重复 `done` 事件下会虚增(`musicStore.ts:320`);`redownloadTask` 用的是**当前默认音质**而非原任务音质(`musicStore.ts:523`);音质弹窗取消后 `selectedKeys` 已被清空(`MusicModule.vue:505-509`);`installRuntime` 的进度监听清理逻辑正确,但 `installError` 与最后一条进度事件之间缺少"失败即清进度"的处理(注释已提到,代码未做)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 性能瓶颈与优化点
|
||||
|
||||
### 3.1 拖动进度条 = 连续发起 Range 请求(最该改的一项)
|
||||
`PlayerBar.vue:104-110` / `PlayerFull.vue:75-80` 把 Slider 的 `update:model-value` 直接接到 `store.seek()`,而 `seek` 立刻写 `a.currentTime`。飞牛流代理是**真流式透传 Range**(`proxy.rs:131-133`),所以拖动一次会触发几十次"中断旧请求 → 发新 Range 请求":
|
||||
|
||||
- 占用带宽、NAS 侧反复建连
|
||||
- 拖动过程中出现爆音/卡顿
|
||||
- 拖到底后又从新位置重新缓冲
|
||||
|
||||
**推荐**:拖动期间只更新本地"预览位置",`pointerup` / `change` 时提交 `seek`。
|
||||
|
||||
```ts
|
||||
// 父组件维护 dragging + previewValue
|
||||
const dragging = ref(false)
|
||||
const preview = ref(0)
|
||||
function onProgressInput(v?: number[]) { dragging.value = true; preview.value = v?.[0] ?? 0 }
|
||||
function onProgressCommit(v?: number[]) {
|
||||
dragging.value = false
|
||||
store.seek(((v?.[0] ?? 0) / 100) * store.duration)
|
||||
}
|
||||
```
|
||||
```vue
|
||||
<Slider :model-value="[dragging ? preview : store.progress]" @update:model-value="onProgressInput" @value-commit="onProgressCommit" />
|
||||
```
|
||||
另外 `:step="0.5"`(200 档)对 4 分钟歌 ≈ 1.2s 粒度尚可,但对播客/长音频(1 小时以上)单档 ≈ 18s,建议改为按 `duration` 动态计算 step(如 `100 / duration`)。
|
||||
|
||||
### 3.2 进度时间轴以 4Hz 驱动整棵组件树
|
||||
`timeupdate` 约每 250ms 一次写 `position.value`,凡是读它的组件都会重渲染。当前影响面:
|
||||
- `PlayerBar` + `PlayerFull` **同时挂载**(大屏打开时)→ 双份重渲染
|
||||
- `PlayerFull` 的 `currentLine()`(`feiniuStore.ts:710-717`)是**线性扫描**,每次重算 O(歌词行数)。改:缓存上次命中的索引,只在 `position > 下一行时间` 时 `idx++`(歌词已排序,单调推进),或二分查找。
|
||||
- `PlayerFull.vue:33-39` 的 `watchEffect` 每次行切换都 `querySelectorAll('[data-line]')` 全量查询后再 `scrollIntoView({behavior:'smooth'})`。改:用 `ref` 数组或在 v-for 里收集 DOM,仅对目标行滚动;并给滚动加 `prefers-reduced-motion` 降级。
|
||||
|
||||
```ts
|
||||
let lastLineIdx = -1
|
||||
function currentLine(): number {
|
||||
const lines = lyricLines.value
|
||||
if (lastLineIdx >= 0 && lastLineIdx < lines.length
|
||||
&& (lastLineIdx + 1 >= lines.length || position.value < lines[lastLineIdx + 1].t)) {
|
||||
while (lastLineIdx > 0 && position.value < lines[lastLineIdx].t) lastLineIdx--
|
||||
while (lastLineIdx + 1 < lines.length && lines[lastLineIdx + 1].t <= position.value) lastLineIdx++
|
||||
return lastLineIdx
|
||||
}
|
||||
lastLineIdx = -1
|
||||
for (let k = 0; k < lines.length; k++) if (lines[k].t <= position.value) lastLineIdx = k; else break
|
||||
return lastLineIdx
|
||||
}
|
||||
```
|
||||
同时建议歌词/队列上**虚拟滚动**:LRC 常有 80~200 行,长播放列表可上千行,当前是全量 DOM。
|
||||
|
||||
### 3.3 大屏背景的整图高斯模糊
|
||||
`PlayerFull.vue:50-52`:`<img class="h-full w-full scale-110 object-cover blur-2xl opacity-30">` 铺满整个弹窗(`min-h-[70vh]`,实际常超 900×700)。一张全尺寸图叠加 `blur-2xl` + `scale-110`,在打开期间持续占用合成/光栅资源,低端机上会明显掉帧(拖进度条时最明显)。
|
||||
|
||||
**推荐**:改为一次性低分辨率方案——用同图 `size=96` 缩略图 + CSS 渐变遮罩,或直接用取色(后端返回主色)生成柔和纯色/线性渐变背景。收益远大于视觉损失。
|
||||
|
||||
### 3.4 搜索结果行每行 2 个 Tooltip 组件
|
||||
`MusicModule.vue:928-965`:每首歌行包了 2 个 `Tooltip`(reka-ui 组件 + 上下文)。首批 80 行 = 160 个 Tooltip 实例,滚动加载到 500 行 = 1000 个。挂载/更新成本高,且 Tooltip 会随列表 diff 频繁重建。
|
||||
|
||||
**推荐**:列表行用原生 `title` 属性承载提示(无需组件);只对"需要富文本提示"的 `+N` 音质档位保留一个 Tooltip。
|
||||
|
||||
### 3.5 其它
|
||||
- **下载任务聚合计算**:`taskAggDone/taskAggTotal`(`MusicModule.vue:547-548`)每次渲染都遍历全部 `songs`。一个 500 首歌的任务,每来一次进度事件就重算 500×2。改:在 `handleDownloadEvent` 里维护 `task.doneBytes/totalBytes` 增量字段。
|
||||
- **`displayItems` / `groupKeys` / `groupAllSelected`**:模板中对每个源分组头调用 `groupAllSelected`,内部 `filteredResults.filter(...)` 是 O(n);结果集大时是 O(n × 分组数)。改:用一次 `computed` 预生成 `Map<source, keys[]>`。
|
||||
- **飞牛曲库无分页**:`loadTracks` 固定 `size: 50`,`page/total` 暴露了却没有 UI。曲库 > 50 首时后面的歌**完全访问不到**(`playAll` 也只能播前 50 首)。应加无限滚动或分页。
|
||||
- **本地扫描无上限**:`scanLocal` 一次性返回全部条目,超大音乐目录下会长时间占住主线程做映射与渲染。
|
||||
- **缓存模式首播延迟**:`feiniu_cache_fetch`(`commands.rs:421-429` → `cache_fetch`)是"**下载完整文件后返回路径**",即 `cache` 模式下首次播放要等整首下完;而 `loadingPlay` 这个状态在 UI 上**从未被使用**(`PlayerBar`/`PlayerFull` 都没读),用户看到的是"点了没反应"。要么改用边下边播的双源策略,要么至少把 `loadingPlay` 接上 spinner。
|
||||
|
||||
---
|
||||
|
||||
## 4. 交互体验问题(按操作路径)
|
||||
|
||||
**播放控制**
|
||||
1. 播放/暂停按钮 `disabled` 时无视觉降级(`PlayerBar.vue:76-84`),看起来可点却没反应。
|
||||
2. 音量图标(`PlayerBar.vue:117`)不可点击,没有"一键静音";音量条只有 80px,长按/滚轮调节都没有。
|
||||
3. 切歌没有 crossfade,也没有间奏/淡出,切歌瞬间是硬切。
|
||||
4. 播放模式按钮图标随状态变(Repeat/Repeat1/Shuffle),初看像三个不同功能,且没有文字提示(只有 Tooltip)。
|
||||
5. 队列入口用 `ListMusic` 图标 + 数字角标,但点击是**平铺开关**(`store.queueVisible = !store.queueVisible`),而队列实际渲染在 `PlayerFull` 的右栏里——在大屏没打开时点这个图标看不到任何反馈(因为它只切了右栏的 tab,而弹窗没开)。
|
||||
6. 上一首/下一首没有"按住快进/快退"(老播放器的常见预期)。
|
||||
|
||||
**进度条**
|
||||
7. 没有 hover 时间气泡、没有缓冲进度层、没有"拖动时显示预览时间"。当前拖动时只能看两侧的小字。
|
||||
8. 轨道 6px + 16px 白色圆点滑块,`Slider.vue:40` 硬编码 `bg-white`,在浅色主题下白滑块白描边几乎隐形。
|
||||
|
||||
**列表切换**
|
||||
9. 发现音乐页切到「我的音乐」再切回,搜索关键词和结果**全部丢失**(模块内 tab 用 `Tabs` 非 keep-alive,`flatResults` 被重算但 store 里 `results` 还在——实际是因为 `MusicModule` 未卸载所以结果还在,但 `visibleCount` 会被 `watch(filteredResults)` 重置,滚动位置丢失)。
|
||||
10. 列表里双击行不会播放(只有点小图标才播),不符合音乐软件的肌肉记忆。
|
||||
11. 键盘:无空格播放/暂停、无 ↑↓ 选行、无 Enter 播放;所有列表行都不可聚焦。
|
||||
12. 多选操作只有"下载所选",缺"加入歌单""加入队列""下一首播放"。
|
||||
|
||||
---
|
||||
|
||||
## 5. 界面缺陷清单(具体位置 + 改法)
|
||||
|
||||
### 5.1 结构与布局
|
||||
|
||||
| # | 缺陷 | 位置 | 改法 |
|
||||
|---|---|---|---|
|
||||
| L1 | **播放条没贴底**:被模块根 `p-6` 包住,`border-t` 只在左右各缩进 24px 的宽度上出现,视觉上像一张"飘着的卡片"而非底部坞站 | `MusicModule.vue:688,1416` | 把 `PlayerBar` 提升到 `App.vue`,作为 `flex-col` 壳层的最后一行(全宽、`border-t`、`h-[72px]`);音乐模块内容区 `pb-[72px]` 让位 |
|
||||
| L2 | **左右内边距不对称**:模块左 24px + `px-1`(4px) = 28px;右 24px + `pr-3`(12px) + `px-1` = 40px,右侧被滚动条再吃一层,卡片右边距明显窄于左边 | `MusicModule.vue:688,706-707,987-988` | 统一成 `px-6`,滚动区改成 `[&>[data-slot=scroll-area-viewport]]:pr-6` 或让 ScrollArea 用 `scrollbar-gutter: stable`,避免内边距被滚动条挤压 |
|
||||
| L3 | **三套切换控件**:模块级 `TabsList`(半透明覆盖)、`MyMusicLibrary` 自绘分段按钮(:89-114)、`PlayerFull` 的裸文字切换(:113-129)——三种视觉语言表达同一件事 | 三处 | 统一为一种 segmented 控件(建议用现有 `Tabs`/`TabsList`,或统一样式的 `ButtonGroup`),并用同一套"选中=主色底/浅底 + 中粗字"的规则 |
|
||||
| L4 | **`Empty` 组件误用**:直接塞裸 children,继承 `gap-6`(24px) 与 `md:p-12`(48px),图标与文案间距过大且垂直被撑高;`border-dashed` 没有宽度声明,虚线框实际不显示 | `MusicModule.vue:801-809, 977-980`、`MyMusicLibrary.vue:76-83,135-140` | 用组件既定的子结构:`EmptyMedia / EmptyTitle / EmptyDescription`;或给容器加 `border` + `border-dashed`、`gap-3`、`p-8` |
|
||||
| L5 | **横向 Tab 与子 Tab 层级混乱**:顶层 4 个 tab 里,「我的音乐」内部又横排 3 个子 tab,「设置」内部又竖排 5 张卡;导航成本高 | `MusicModule.vue:691-696`、`MyMusicLibrary.vue:88-114` | 见 §6 的"左侧来源树 + 右侧主区"结构,把两级 tab 压成一级 |
|
||||
| L6 | **首屏被低价值信息占据**:「下载到 本地/飞牛」占一整个卡片行(`MusicModule.vue:709-726`),说明文字是 12px 灰字却放在最显眼位置;真正的搜索框被挤到第二行 | `MusicModule.vue:708-794` | 把"下载目标"收进搜索工具栏右侧的 `Select`(与源选择器同排),或放进设置页;首行只留 搜索输入 + 源选择 + 搜索按钮 |
|
||||
| L7 | **歌单详情页底部孤零零一个"移除最后一首"按钮** | `MyMusicLibrary.vue:234-243` | 语义错误,删掉。改为:行 hover 显示「⋯」菜单(从歌单移除 / 上移 / 下移),或支持多选后批量移除 |
|
||||
| L8 | **歌单左栏无滚动**:`grid-cols-[220px_1fr]`,左栏是普通 `div`,歌单多了会撑高整页 | `MyMusicLibrary.vue:189-205` | 左栏用 `ScrollArea` 并固定高度(`h-[calc(100vh-260px)]`),或整体改三栏布局 |
|
||||
| L9 | **设置页信息密度过低 + 语义重复**:「飞牛音乐连接」管连接,「上传到飞牛曲库」又要求用户回头去前一张卡点云朵登录;5 张卡片纵向拉得很长 | `MusicModule.vue:1123-1371` | 合并为「连接与存储」一张卡:连接列表 + NAS 文件服务登录 + 曲库目录 + 自动上传;「播放缓存」并入「播放」分组;「环境检查」做成折叠的"诊断"面板 |
|
||||
|
||||
### 5.2 视觉与色彩
|
||||
|
||||
| # | 缺陷 | 位置 | 改法 |
|
||||
|---|---|---|---|
|
||||
| V1 | **音质徽标 5 色抢戏**:Hi-Res=琥珀、无损=紫、320K=天蓝、192K=橙、128K=锌灰,一行里 2 个高饱和徽标 + 1 个 `+N`,视觉噪音压过歌名 | `MusicModule.vue:336-355, 879-905` | 降为**两档**:无损/高解析用实心浅色徽标(一个色),其余用 `outline` 中性色;颜色不承担"档位"信息,档位靠文字。或改为歌名后的一个小图标 + Tooltip 详情 |
|
||||
| V2 | **列表变成卡片堆**:每行 `border` + `rounded-lg` + `p-2.5` + `space-y-2`,80 行 = 80 张卡,行间 8px 空隙,扫描效率低 | `MusicModule.vue:845-859` | 改紧凑单行:行高 44~48px、无独立边框、`border-b border-border/60` 或干脆无分隔、hover `bg-accent/60`;表头一行(`# / 标题 / 专辑 / 时长 / 操作`) |
|
||||
| V3 | **源分组头与大卡片行不匹配**:分组头只是 12px 小字,紧贴一堆 56px 卡片 | `MusicModule.vue:848-857` | 分组头改 `sticky top-0 z-10 bg-background/95 backdrop-blur` + 底部分隔线,字号 13px medium;或改用左侧来源 Tab 过滤,取消组内嵌头 |
|
||||
| V4 | **当前播放项用"选中态"表达**:`border-primary bg-primary/10` 在浅色主题下是"近黑边框 + 10% 黑底",像复选框选中而非"正在播放" | `TrackItem.vue:53-54` | 改为:左侧 3px 主色竖条 + 歌名主色 + 一个 12px 的跳动均衡器图标;hover 与 active 语义分离 |
|
||||
| V5 | **`PlayerFull` 硬编码白色边框**:`border-l border-white/10`,浅色主题下完全不可见 | `PlayerFull.vue:112` | 改用 `border-border` |
|
||||
| V6 | **`PlayerFull` 背景被"洗白"**:整图 `opacity-30` + 白→白渐变,浅色下糊成一片;同时是持续 GPU 开销 | `PlayerFull.vue:50-53` | 改为缩略图小尺寸模糊,或纯色/双色线性渐变(从封面取色);浅色主题下用极浅的封面色调,深色主题下用暗化封面色 |
|
||||
| V7 | **滑块基本不可见**:`Slider.vue:40` 硬编码 `bg-white`,16px 白色滑块 + 白色描边叠在浅底上;轨道仅 6px | `Slider.vue:36-41` | 滑块改 `bg-background` + 主色描边(或直接用 `bg-primary`);静息轨道 4px、hover/拖动时平滑增到 8px;加 `transition-[height]` |
|
||||
| V8 | **`PlayerFull` 歌词行区分度不足**:非当前行 `text-muted-foreground/60`,当前行仅 `font-medium text-foreground`,没有位置指示 | `PlayerFull.vue:136-145` | 当前行放大到 17~18px + `text-foreground` + 左侧或下方主色指示;两侧行逐级降透明度(60/40/25)形成景深;点击区域扩大到整行并加 hover 背景 |
|
||||
| V9 | **`PlayerFull` 进度时间只在下方一行小字**,且控制按钮组与进度条分属两个块,视觉不聚合 | `PlayerFull.vue:74-108` | 进度条与控制键合并为一块:`[时间] ──●──── [时间]` 上、`[模式][上一首][播放][下一首][队列]` 下的居中控制簇 |
|
||||
| V10 | **「词」按钮用单个汉字**当歌词开关 | `PlayerBar.vue:125-132` | 换成 `Lyrics` 图标(`FileText`/`MicVocal`),与两侧图标风格一致 |
|
||||
| V11 | **下载任务标题行过载**:状态标签 + 歌名 + 歌手 + 音质标签 + 已下载/总量 + 最多 4 个图标按钮挤在一行;且 `Card` 被 `gap-0 py-0` + `CardContent py-2` 覆盖,内边距与模块内其它卡片不一致 | `MusicModule.vue:995-1060` | 拆两行:第一行 `[状态] 歌名 · 歌手 ……… [音质] [⋯菜单]`;第二行进度条 + 大小 + "打开目录/取消/重下"收进 `⋯` 菜单。`Card` 统一 `p-0` + 内部 `p-3` |
|
||||
| V12 | **多曲目任务行进度条只有 56px**(`w-14`),几乎读不出进度;错误信息被截断到 112px | `MusicModule.vue:1086-1097` | 进度条改占满剩余宽度(去掉固定宽度,放歌名下方一行);错误信息用 Tooltip 或点击展开 |
|
||||
| V13 | **`TrackItem` 悬停才出现的播放按钮占 40px 固定宽度**,未悬停时标题左侧留出空白列,触屏/键盘用户无法触发 | `TrackItem.vue:68-75` | 改为:封面左下角 24px 半透明播放浮层(hover 显现),或整行 hover 时标题左侧淡入小图标;并支持双击整行播放 |
|
||||
| V14 | **飞牛曲库行/本地曲库行/歌单行的"来源"标识不统一**:本地用行内小标签「本地」,飞牛没有标识,歌单混排时无法区分 | `TrackItem.vue:81` | 统一在行尾或封面角标显示来源(NAS 云图标 / 本机图标),并在歌单详情里按来源分组或加筛选 |
|
||||
| V15 | **搜索历史下拉不适合键盘操作**:`@focusout` 关闭 + `@mousedown.prevent` 的组合同样让 Tab 导航无法进入历史项 | `MusicModule.vue:738-760` | 改用 `Combobox`/`Popover` + `Command` 组件(仓库已有 `components/ui/combobox`),支持 ↑↓ 选择、Enter 确认、Esc 关闭 |
|
||||
| V16 | **`+N` 音质档位靠 Tooltip**,触屏不可达;`visibleTiers` 固定展示 2 个,长歌名下把歌名挤窄 | `MusicModule.vue:888-904` | 行内只留 1 个"最高音质"徽标,全部档位收进右侧 `⋯` 菜单或点击弹出 popover |
|
||||
| V17 | **`PlayerBar` 缺少播放态反馈**:`loadingPlay` 未接 UI;NAS 慢时点播放毫无反应 | `PlayerBar.vue:76-84` | 播放键上叠加 spinner(`loadingPlay` 时),或封面加旋转/脉冲 |
|
||||
| V18 | **`PlayerBar` 三段留白失衡**:左侧信息 `max-w-44`,中间控制区 `flex-1` 里塞了模式/上下首/播放/队列 + 进度条,右侧音量+词+最大化;窄窗口下中间进度条被压到很短 | `PlayerBar.vue:34-141` | 采用标准三段:左 `w-[240px]` 固定、中 `flex-1 min-w-0`(控制 + 进度)、右 `w-[200px]`(音量/词/大屏);窄容器下用 `container query` 渐进隐藏音量条与歌词按钮 |
|
||||
| V19 | **`TabsList` 同时带 `grid w-full max-w-md` 与组件默认 `inline-flex w-fit`**,并靠 `!p-0 !bg-transparent !shadow-none` 强行覆盖 | `MusicModule.vue:691` | 不要用覆盖式改写;把"模块级分段导航"抽成一个 `SegmentedNav` 组件(`grid grid-cols-N`、`bg-muted p-0.5`、激活项 `bg-background shadow-sm`) |
|
||||
| V20 | **封面圆角/阴影规格不统一**:列表 40px `rounded-md`、播放条 44px `rounded-md shadow`、大屏 256px `rounded-xl shadow-2xl` | 多处 | 定规格:列表 6px 无阴影、播放条 6px 无阴影、大屏 12px + `ring-1 ring-border/60` 无重阴影 |
|
||||
| V21 | **下载任务空态文案指向错误**:「去"搜索"页选择歌曲下载」,但 tab 已改名为「发现音乐」 | `MusicModule.vue:991` | 改为"去「发现音乐」页选择歌曲下载" |
|
||||
| V22 | **`PlayerFull` 未提供音量、队列收起、下一首信息**;队列项没有播放态指示(只有 `bg-primary/10`) | `PlayerFull.vue:147-163` | 补:右上角音量滑条;队列项用序号↔均衡器切换表示当前项,hover 显示"下一首播放/移除" |
|
||||
| V23 | **`ConnectionsSettings` 图标按钮语义不明**:`Power` 既表示"激活"又表示"登出",两个含义相同的图标相邻 | `ConnectionsSettings.vue:157-171` | 激活用 `CircleCheck`/`RadioButton`,登出用 `LogOut`,删除保留 `Trash2`;并为所有 icon-only 按钮补 `Tooltip` + `aria-label` |
|
||||
| V24 | **歌单/曲库的「播放全部」用文字 `▶ 播放全部`**,与其他按钮的图标风格不统一 | `MyMusicLibrary.vue:115-117,214-216` | 统一为图标 + 文字:`<Play class="size-4"/> 播放全部` |
|
||||
| V25 | **`MusicModule` 里两处搜索框文案不一致**("输入歌名/歌手/专辑关键词" vs "粘贴歌单链接")且歌单解析行的 Input 靠 `border-0 bg-transparent` 融进容器,缺少"这是一行输入"的暗示 | `MusicModule.vue:730-782` | 歌单解析改为折叠的"粘贴歌单链接"次级入口(默认收起),避免与主搜索竞争注意力 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 重设计方案
|
||||
|
||||
### 6.1 参考基准
|
||||
- **Spotify / Apple Music**:左侧来源树 + 中间列表 + 底部全宽播放条 + 右侧可折叠"正在播放"。
|
||||
- **网易云音乐 / QQ 音乐**:紧凑表格行(`# / 标题 / 歌手 / 专辑 / 时长`)、行内 hover 操作、双击播放、底部播放条 + 播放列表面板。
|
||||
- **foobar2000 / MusicBee**:信息密度高、可排序表头、键盘优先。
|
||||
本项目的定位是"NAS 曲库 + 本地曲库 + 多平台搜索下载"的**工具型播放器**,建议取 **Spotify 的骨架 + 网易云的列表密度**,避免做成消费级流媒体的强视觉风格。
|
||||
|
||||
### 6.2 目标信息架构(三级 tab → 一级导航)
|
||||
|
||||
```
|
||||
音乐
|
||||
├── 曲库
|
||||
│ ├── 飞牛曲库 (NAS 曲目,分页/无限滚动 + 搜索 + 排序)
|
||||
│ ├── 本地曲库 (扫描目录,按文件夹浏览)
|
||||
│ └── 我的歌单 (歌单列表 → 歌单详情,可拖拽排序)
|
||||
├── 发现音乐 (多平台搜索 + 歌单解析 + 下载)
|
||||
├── 下载任务 (进行中 / 已完成 分组)
|
||||
└── 设置 (连接与存储 / 播放 / 下载 / 诊断,用左侧锚点或分段)
|
||||
```
|
||||
把「我的音乐」内部的 3 个子 tab 提升为导航的一级子项(来源树),「设置」内部的 5 张卡片改为分组锚点。
|
||||
|
||||
### 6.3 布局结构(目标)
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ TitleBar(含模块浮动切换器) │
|
||||
├──────────┬──────────────┬─────────────────────────────────────────────┤
|
||||
│ Sidebar │ 来源树 │ 内容主区 │
|
||||
│ (模块) │ 240px │ ┌ 固定工具栏 ────────────────────────────┐ │
|
||||
│ │ │ │ 搜索 来源筛 音质筛 排序 多选操作 │ │
|
||||
│ │ 曲库 │ ├ 表头 (# 标题 歌手 专辑 时长 操作) ──────┤ │
|
||||
│ │ · 飞牛曲库 │ │ 44px 紧凑行,hover 显示操作 │ │
|
||||
│ │ · 本地曲库 │ │ 播放中:主色竖条 + 跳动的均衡器 │ │
|
||||
│ │ · 我的歌单 │ │ ... │ │
|
||||
│ │ 发现音乐 │ └────────────────────────────────────────┘ │
|
||||
│ │ 下载任务 │ │
|
||||
│ │ 设置 │ (可选右栏 320px:正在播放/歌词/队列) │
|
||||
├──────────┴──────────────┴─────────────────────────────────────────────┤
|
||||
│ MiniPlayer 72px(全宽坞站:左曲目 中控制+进度 右音量/歌词/大屏) │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
要点:
|
||||
1. **MiniPlayer 提升到 `App.vue`**,全宽贴底、不参与任何模块内边距;音乐模块内容区 `pb-[72px]`。这样在任何模块都能控制播放。
|
||||
2. **`PlayerFull` 也提升到 `App.vue`**(全局 Dialog/Overlay),解决跨模块残留问题。
|
||||
3. **去掉"模块级横向 Tab"**,音乐模块内部用来源树;`useModuleTabs` 仍可保留,用于把来源树的第一级注册给 TitleBar 浮动切换器(保持现有能力)。
|
||||
4. **右栏"正在播放"可折叠**:窄窗口默认收起,宽窗口(≥1280px)默认展开,用 `container query` 或 `useMediaQuery` 决定。
|
||||
|
||||
### 6.4 视觉规范(建议写入项目 Design Token)
|
||||
|
||||
| 项目 | 规范 |
|
||||
|---|---|
|
||||
| 主色使用 | 只用于:主操作按钮、播放中指示、"正在播放"高亮。**不用于**徽标分类、分组头 |
|
||||
| 中性色阶 | 文本 `foreground` / 次文本 `muted-foreground` / 三级文本 `muted-foreground/70`;分隔线 `border/60`;hover 面 `accent/60` |
|
||||
| 圆角 | 行/输入 6px(`radius-md`)、卡片 10px(`radius-lg`)、大图 12px、头像/封面圆形仅用于单曲大图 |
|
||||
| 阴影 | 列表与播放条**不用阴影**,靠 `border` 分层;只有 Dialog/Popover 用 `shadow-lg` |
|
||||
| 行高 | 列表行 44px(紧凑)/ 48px(标准);表头 32px |
|
||||
| 字号 | 歌名 14px/500,歌手·专辑 12px/400,时长时间数字 12px `tabular-nums` |
|
||||
| 徽标 | 一类语义一个样式:状态用实心浅底,属性(音质/格式)用 `outline` 中性色 + 文字;不超过 2 个 |
|
||||
| 动效 | 仅 `opacity` / `transform`,120~180ms `ease-out`;进度条高度过渡 150ms;尊重 `prefers-reduced-motion` |
|
||||
| 明暗主题 | **禁止硬编码** `bg-white` / `border-white/10` / `text-white`;一律走 token |
|
||||
| 空状态 | 统一 `EmptyMedia(32px 图标) → EmptyTitle(14px) → EmptyDescription(12px)`,`gap-3`、`p-10`、无边框 |
|
||||
|
||||
### 6.5 关键组件规格
|
||||
|
||||
**MiniPlayer**
|
||||
```
|
||||
高度 72px | 全宽 | border-t | bg-background/85 backdrop-blur
|
||||
左 240px :封面 48px(6px 圆角) + 歌名 14px/500 截断 + 歌手 12px 截断 + ♥
|
||||
中 flex-1:① [模式][上一首][播放 40px 圆形主色][下一首][队列] 居中
|
||||
② [时间 00:00] ──进度条(hover 4→8px,含缓冲层)── [总时长] 居中 max-w-[560px]
|
||||
右 200px :音量图标(可点静音) + 音量条 96px | 歌词 | 大屏 | (窄屏只留大屏)
|
||||
播放中:封面右侧叠加 3 条均衡器动效;`loadingPlay` 时播放键显示 spinner
|
||||
```
|
||||
|
||||
**列表行(TrackItem 重写)**
|
||||
```
|
||||
[#/均衡器 24px] [封面 40px] [标题 14px/500 + 歌手 12px] [专辑 12px 可隐藏] [时长 12px tabular] [⋯ hover 显示]
|
||||
高度 44px | 无边框 | border-b border-border/50 | hover:bg-accent/60 | 当前项:左侧 3px 主色条 + 标题主色
|
||||
双击整行播放;⋯ 菜单:下一首播放 / 加入队列 / 加入歌单 / 查看文件位置 / 下载
|
||||
```
|
||||
|
||||
**进度条交互**
|
||||
```
|
||||
拖动中:只更新预览位置 + 显示时间气泡;轨道加粗;不写 currentTime
|
||||
释放时:一次性 seek;若 duration 未知则禁用拖动并提示"该音频不支持跳转"
|
||||
缓冲层:在轨道里用 bg-muted-foreground/25 画已缓冲区间(audio.buffered)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 落地路径(按性价比排序)
|
||||
|
||||
| 阶段 | 内容 | 影响面 | 预估 |
|
||||
|---|---|---|---|
|
||||
| **S1 修 Bug(不做设计变更)** | 1.1 搜索框绑定 store.keyword;1.2 `toggle()` 无 src 兜底;1.3 试听与播放器互斥(或合并内核);2.1/2.2 shuffle 自动切歌;2.4 缓存开关回填;2.7 `init` 幂等;2.8 持久化剥离 `rawSearch` | 仅改逻辑,风险低 | 半天 |
|
||||
| **S2 播放器骨架调整** | 3.1 拖动提交式 seek;3.2 歌词行索引缓存;3.3 大屏背景降级;4.1 播放键 loading 态;L1/V18 MiniPlayer 全宽贴底;V5/V7 主题 token 修正 | 播放条 + 大屏 | 1~2 天 |
|
||||
| **S3 列表与信息架构** | V2 紧凑列表;V1 徽标降噪;V3 分组头;V13/V4 TrackItem 重写;L6 首屏工具栏重构;2.5 上下文队列;2.6 歌单播放/暂停 | 发现音乐 + 曲库 | 3~4 天 |
|
||||
| **S4 结构性重构** | §6.2/6.3 左侧来源树 + 全局播放条;PlayerBar/PlayerFull 提升到 App.vue;右栏正在播放;设置页分组 | 模块外壳 | 1 周 |
|
||||
| **S5 体验补全** | 键盘快捷键、虚拟滚动、分页/无限滚动、拖拽排序歌单、音质档位 popover、缓冲层、桌面歌词 | — | 按需 |
|
||||
|
||||
**建议先做 S1 + S2**:S1 全是低风险的逻辑修复,S2 集中在两个组件文件里,能立刻解决"卡顿 + 没有控制感 + 拖动难用"的主观感受;S4 的结构调整等到前面稳定后再动,避免一次改动面过大。
|
||||
|
||||
---
|
||||
|
||||
## 8. 附:值得保留的设计
|
||||
|
||||
- 流代理走 `127.0.0.1` 动态端口 + `Cookie` 注入 + **Range 透传**(`proxy.rs`)——这是正确做法,seek 能真正生效,不要改成前端直连。
|
||||
- 桥接进程按需拉起、禁用模块时停止(`music/index.ts:34-45`)——避免了常驻 Python 进程。
|
||||
- `tasksSignature` 轻量签名代替深度 watch(`musicStore.ts:257-266`)——思路正确(只是 payload 需要瘦身)。
|
||||
- 搜索结果**懒解析**(搜索阶段拿档位、播放/下载前才解析真实链接)+ `rawSearch` 支持隔夜链接重解析(`musicStore.ts:355-370,458`)——设计合理。
|
||||
- 发现音乐列表的**增量渲染 + IntersectionObserver 哨兵**(`MusicModule.vue:265-305`)——方向正确,可平滑迁移为新列表的基础。
|
||||
|
||||
---
|
||||
|
||||
## 9. 重构落地记录(本次已实施)
|
||||
|
||||
### 9.1 新增文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `src/components/layout/TitleBarMusic.vue` | 标题栏音乐栏:唱片图标 + 歌名,点击弹出**方形播放控制窗** |
|
||||
| `src/modules/music/components/PlaylistEditorDialog.vue` | 歌单音乐编辑器:从全部音乐勾选加入 / 批量移出 |
|
||||
| `src/components/layout/NowPlayingDialog.vue` | 「正在播放」大屏(封面 + 控制 + 歌词 / 队列),由方形控制窗的「歌词 / 队列」入口打开 |
|
||||
| `src/components/common/ScrubBar.vue` | 通用拖拽进度条:**拖动只预览、松手才 seek**,含缓冲层与时间气泡 |
|
||||
| `src/components/common/SegmentedNav.vue` | 分段导航(现由 `NowPlayingDialog` 的「歌词 / 队列」切换使用) |
|
||||
|
||||
`PlayerBar.vue` / `PlayerFull.vue` 已删除;`GlobalPlayerBar.vue` 曾短暂作为全局坞站播放条存在,现已被标题栏音乐栏取代并删除。
|
||||
|
||||
**播放器入口最终形态**:标题栏搜索框左侧的音乐栏(`TitleBarMusic`)——未播放过时只显示唱片图标;有曲目后图标替换为封面并随播放旋转,右侧显示截断歌名。点击弹出约 300×340 的方形控制窗(封面 / 曲目信息 / 进度 / 循环·上下首·播放·队列 / 音量 / 歌词·队列入口),点击封面或「歌词 / 队列」进入大屏。
|
||||
|
||||
### 9.2 修复的缺陷
|
||||
|
||||
**P0**
|
||||
1. 飞牛曲库搜索框:直接 `v-model="store.searchKeyword"` 并新增 `loadTracks(page, append)` + `loadMoreTracks()`,搜索真正生效且支持分页续拉。
|
||||
2. 重启后播放键失效:`toggle()` 在 `audio` 无 src 时先按当前条目装载(试听或队列当前曲),失败原因经 `playError` 冒泡成 toast。
|
||||
3. 双音频引擎:删除模块内试听 `<audio>`,试听改走 `feiniu.playPreview()`,与队列播放**共用同一个 audio 实例**,天然互斥。
|
||||
|
||||
**P1**
|
||||
4. shuffle 自动切歌:`next()` 不再区分手动/自动,随机模式下都从「待播池」抽取;新增切歌历史栈,`prev()` 在随机模式下真正回退。播放模式持久化。
|
||||
5. 缓存模式:`setCacheMode` / `clearCache` 不再调用 `clearPlayback`,改设置不中断播放。
|
||||
6. 缓存开关状态:`CacheSettings` 的开关改为 `cacheMode` 的双向 computed,删除死代码 `cacheMax`,补 `ensureCacheStatus` 刷新。
|
||||
7. `init()` 幂等化(`inited` 标志),不会再覆盖内存队列。
|
||||
8. 任务持久化:超过 1.5MB 时自动剥离 `rawSearch` / `defaultDownloadHeaders` 后重试。
|
||||
9. 上传触发:`pendingUploadOnDone` 布尔标志改为 `uploadOnDoneTaskIds` 任务集合 + `uploadedTaskIds` 去重表,每个任务最多触发一次;启动时的历史任务不触发重传。
|
||||
10. Rust 引擎下载:`startDownload` 返回 `{ engine, skipped, taskId }`;Rust 引擎不再跳到空的任务页,改为提示到「下载器」查看;「下载到飞牛 + Rust 引擎」组合直接给出可读的错误提示并阻止。
|
||||
11. 队列语义:`playItem(item, context?)` 支持以所在列表为播放上下文,解决「从大曲库点一首就只剩一首」;新增 `playNext` / `addToQueue` / `removeFromQueue`。
|
||||
12. `done` 事件幂等,`doneCount` 不再虚增;`redownloadTask` 沿用原任务音质。
|
||||
|
||||
**性能**
|
||||
13. 提交式 seek(`ScrubBar`),拖动不再触发几十次 Range 请求中断重建。
|
||||
14. 歌词行索引由 O(n) 全量扫描改为「命中缓存微调 + 未命中二分」,并缓存上次索引。
|
||||
15. 大屏背景从「整图 `blur-2xl scale-110`」改为主题色径向渐变,去掉持续 GPU 开销。
|
||||
16. 列表行去掉 reka Tooltip(改用原生 `title` + 单个 Popover 展示音质档位),500 行不再产生 1000 个组件实例。
|
||||
17. 歌词滚动用函数 ref 收集节点,不再每次 `querySelectorAll`。
|
||||
18. 播放队列增量渲染(每批 200 行)。
|
||||
19. 曲库分页 200/页 + 滚动续拉;任务明细进度条由固定 56px 改为占满剩余宽度。
|
||||
|
||||
**交互与视觉**
|
||||
20. 播放入口改到**标题栏**:移除模块内播放条与全局坞站播放条,在自定义标题栏搜索框左侧新增音乐栏(唱片图标 + 歌名,未播放过时隐藏歌名);点击弹出方形控制窗;播放中图标替换为封面并旋转。
|
||||
21. 音乐模块顶部 tabs 改回与其余模块完全一致的写法:`<Tabs><div ref="tabsListRef"><TabsList class="grid w-full max-w-md grid-cols-4 !bg-transparent !p-0 !shadow-none">` + `TabsTrigger` + `TabsContent class="mt-4 min-h-0 flex-1 ... tab-animate"`,根节点恢复为 `h-full p-6 overflow-hidden flex flex-col`。
|
||||
22. 三级 tab 压成「模块 tab → 曲库内左侧来源树」两级。左栏由 `w-[210px]` 收窄到 `w-[168px]`,来源项的选中态对齐顶部 Tabs(`rounded-lg bg-muted` 容器 + `bg-background shadow-sm` 胶囊),歌单子树用 `border-l` 缩进 + 展开动画;右栏内容切换加 `tab-animate`(`:key="view"` 重挂载触发),不再生硬。
|
||||
23. 首屏工具栏:搜索框回到首行,下载目标 / 来源筛选 / 音质筛选收到筛选条,歌单解析收进 Popover。
|
||||
24. 列表改 44px 紧凑行:无独立边框、hover 高亮、双击整行播放、序号列悬停原位切换为播放按钮(不再出现 40px 空白列);播放中行用 `music-eq` 跳动均衡器 + 主色文字。
|
||||
25. 音质徽标由 5 色降为单枚中性 outline 徽标 + Popover 详情。
|
||||
26. `Empty` 全部改为组件既定结构(`EmptyMedia` / `EmptyTitle` / `EmptyDescription`)。
|
||||
27. 歌单:删除语义奇怪的「移除最后一首」,改为行内「⋯」菜单的支持上移 / 下移 / 移除;新增重命名;左侧歌单列表可独立滚动。
|
||||
28. **歌单页新增「编辑音乐」**(`components/PlaylistEditorDialog.vue`):双 Tab 编辑器——「全部音乐」按来源(全部 / 飞牛 / 本地)勾选加入,已在歌单的曲目置灰标记「已加入」不可重复勾选;「已在歌单」批量勾选移出。含搜索、全选当前列表、增量渲染(每批 120)、飞牛曲库续拉、本地未扫描时的内联扫描入口。
|
||||
29. 源分组头去掉 `sticky` 与白色底色(原 `bg-background/95 backdrop-blur` 会在浅色背景上形成白色横条)。
|
||||
30. **发现音乐每行新增「下载到飞牛」**(试听 / 下载右侧):走新增的 Rust 命令 `feiniu_upload_from_url`,由后端边拉音源边传给 NAS,**本地不落地文件**;懒解析歌曲会先按默认音质解析真实链接,并合并 `defaultDownloadHeaders` + `defaultDownloadCookies`(过滤非 ASCII 头,避免 reqwest panic)。
|
||||
31. 设置页从「5 张卡片」改为「连接 / 存储与上传 / 播放 / 搜索与下载 / 环境与诊断(折叠)」分组,并把「NAS 文件服务登录」并入存储分组。
|
||||
32. 主题一致性:删除 `border-white/10`、`bg-white` 等硬编码;滑块、均衡器、进度条全部走 `--primary` / `--muted` / `--background` token。
|
||||
33. 连接管理图标语义修正(激活用 `CircleCheck`、登出用 `LogOut`),全部 icon-only 按钮补齐 `title` + `aria-label`。
|
||||
34. 搜索历史下拉改为 `focusin/focusout` 容器判定,Tab 键可以进入历史项。
|
||||
|
||||
### 9.2.1 飞牛上传链路:fnOS 文件服务 → WebDAV(最终形态)
|
||||
|
||||
> 演进:multipart over WS 的 fnOS 文件服务存在会话易失、握手/登录协议联调困难等问题(三轮修复:
|
||||
> WS 读写分离与 Origin → 加密登录体补 `req`/`reqid` → 登录结果双通道等待),最终整体替换为 **WebDAV**。
|
||||
|
||||
| 结论 | 说明 |
|
||||
|---|---|
|
||||
| **协议选型** | fnOS 提供的 SMB / WebDAV / FTP / NFS 中选 **WebDAV**:纯 HTTP 语义、reqwest 即可实现、Basic 认证随请求携带**无会话**(重启免登录);SMB 的 Rust 客户端生态差、FTP 明文且被动模式易出问题、NFS 无 Windows 客户端方案 |
|
||||
| 后端实现 | `src-tauri/src/music/feiniu/webdav.rs`(`fnos.rs` 已删除):`test`(PROPFIND Depth 0)/ `upload_file`(本地文件 128KB 分块流式 PUT)/ `delete`;上传前逐级 MKCOL 补目录 |
|
||||
| 「下载到飞牛」机制 | **与自动上传同链路**:正常走 musicdl 下载(引擎侧解析/代理/音质)→ 任务完成后扫描下载目录(下界=任务创建时刻)→ WebDAV 上传 → 删除本地音频及同名歌词/封面(`feiniu_delete_local`,扩展名白名单)→ 移除任务记录。失败时保留本地文件与任务记录供排查。早期的「URL 拉流直传」(`webdav_upload_from_url`)因可靠性不足已移除 |
|
||||
| 前端配置 | `feiniuStore.webdav = { url, username, password, dir }` 持久化 localStorage(`thing.music.feiniu.webdav`);`webdavReady` 四项齐备才放行上传;设置页「存储与上传」分组内配置 + 「测试连接」 |
|
||||
| fnOS 侧设置 | 系统设置 → 文件服务 → WebDAV 开启(HTTP 5005 / HTTPS 5006);目标文件夹在「可见文件夹范围」内;团队文件夹需勾选「允许通过文件共享协议挂载」;账号需读写权限(已写成设置页折叠提示) |
|
||||
| 路径语义 | 目标目录是 **WebDAV 根下的相对路径**(不是 NAS 内部绝对路径),URL 逐段百分号编码 |
|
||||
| 错误可读化 | 401 认证失败 / 403 无权限 / 404 目录不存在或不在可见范围 / 409 父目录不存在,均带上下文 |
|
||||
|
||||
### 9.3 校验
|
||||
|
||||
```
|
||||
vue-tsc --noEmit → exit 0(0 error)
|
||||
vite build → exit 0(11.5s)
|
||||
cargo check → exit 0
|
||||
```
|
||||
|
||||
### 9.4 仍未做的部分(建议后续)
|
||||
|
||||
- 键盘全局快捷键(空格播放/暂停、↑↓ 选行)——需接入项目既有快捷键体系,避免与输入框冲突。
|
||||
- 列表虚拟滚动(当前为增量渲染,千级以上仍有优化空间)。
|
||||
- 歌单拖拽排序(`vue-draggable-plus` 已在依赖中,可直接接入)。
|
||||
- 本地文件的时长 / 内嵌封面解析(当前时长列退化为文件大小)。
|
||||
- 缓存模式改为「边下边播」的双源策略(当前 `cache` 模式首播仍需等整首下完)。
|
||||
|
||||
@@ -23,15 +23,15 @@ Thing/
|
||||
│ ├── components/ # 通用组件
|
||||
│ │ ├── layout/ # 布局组件(TitleBar、Sidebar、ModuleContainer)
|
||||
│ │ └── ui/ # shadcn-vue UI 组件
|
||||
│ ├── lib/ # 工具库(logger、useModuleTabs、utils)
|
||||
│ ├── lib/ # 工具库(logger、use-module-tabs、calc、constants)
|
||||
│ ├── modules/ # 功能模块(每个模块含 index.ts 配置 + Vue 组件)
|
||||
│ │ ├── proxy/ # 代理管理模块
|
||||
│ │ ├── clipboard/ # 剪贴板增强模块
|
||||
│ │ ├── screenshot/ # 截图模块
|
||||
│ │ ├── monitor/ # 硬件监控模块
|
||||
│ │ ├── downloader/ # 下载器模块
|
||||
│ │ ├── finder/ # 文件搜索模块
|
||||
│ │ ├── general/ # 通用设置模块
|
||||
│ │ ├── quickpanel/ # 快速面板模块(含 providers/ 搜索提供器目录)
|
||||
│ │ ├── settings/ # 通用设置模块
|
||||
│ │ ├── icons.ts # 模块图标映射
|
||||
│ │ ├── index.ts # 模块聚合入口
|
||||
│ │ └── registry.ts # 模块注册表单例
|
||||
@@ -46,12 +46,22 @@ Thing/
|
||||
│ ├── icons/ # 应用图标
|
||||
│ ├── resources/ # 打包资源(浏览器扩展等)
|
||||
│ ├── src/
|
||||
│ │ ├── download_engine/ # 下载引擎(进程内自建,含 engine/storage/http_dl/server 等)
|
||||
│ │ ├── logger.rs # 日志系统
|
||||
│ │ ├── mihomo_manager.rs # mihomo 内核管理(代理模块后端)
|
||||
│ │ ├── clipboard/ # 剪贴板模块(reader/monitor/storage/popup)
|
||||
│ │ ├── download_engine/ # 下载引擎(进程内自建,含 engine/storage/http_dl 等)
|
||||
│ │ ├── mihomo_manager/ # mihomo 内核管理(mod/kernel/profiles/system_proxy/commands 等)
|
||||
│ │ ├── quickpanel/ # 快速面板后端(commands/file_index/icon_extractor)
|
||||
│ │ ├── screenshot/ # 截图后端(capture/commands)
|
||||
│ │ ├── constants.rs # 双端常量(窗口 label / 事件名)
|
||||
│ │ ├── logger.rs # 日志系统(进程级全局日志器)
|
||||
│ │ ├── monitor_kernel.rs # 硬件监控内核(SSE 订阅)
|
||||
│ │ ├── network_monitor.rs # 网速监控
|
||||
│ │ ├── osd_window.rs # OSD 悬浮窗
|
||||
│ │ ├── process_manager.rs # 子进程统一管理
|
||||
│ │ ├── setup.rs # 应用初始化
|
||||
│ │ ├── tray_menu.rs # 系统托盘
|
||||
│ │ ├── win32_util.rs # Win32 屏幕/DPI 工具
|
||||
│ │ ├── main.rs # Rust 入口
|
||||
│ │ └── lib.rs # Rust 库入口(命令注册、应用初始化)
|
||||
│ │ └── lib.rs # Rust 库入口(命令注册、事件循环)
|
||||
│ ├── Cargo.toml # Rust 依赖配置
|
||||
│ └── tauri.conf.json # Tauri 配置
|
||||
├── package.json # 前端依赖配置
|
||||
@@ -81,18 +91,16 @@ Thing/
|
||||
- [x] 订阅管理
|
||||
|
||||
#### 📋 剪贴板增强
|
||||
- [ ] 剪贴板历史记录(排序,搜索)
|
||||
- [ ] 鼠标位置快携键历史弹窗
|
||||
- [ ] 固定常用条目(快捷输入)
|
||||
- [ ] 多格式预览(文本、图片、文件)
|
||||
- [ ] SQLite 存储
|
||||
- [x] 剪贴板历史记录(排序,搜索)
|
||||
- [x] 鼠标位置快携键历史弹窗
|
||||
- [x] 固定常用条目(快捷输入)
|
||||
- [x] 多格式预览(文本、图片、文件)
|
||||
- [x] SQLite 存储
|
||||
|
||||
#### 📸 截图
|
||||
- [ ] 区域截图
|
||||
- [ ] 窗口截图
|
||||
- [ ] 全屏截图
|
||||
- [ ] 滚动截图
|
||||
- [ ] 图片编辑工具
|
||||
- [x] 截图及相关
|
||||
- [ ] 滚动截图(后续支持)
|
||||
- [x] 贴图
|
||||
|
||||
#### 📊 硬件监控
|
||||
- [x] 悬浮窗显示
|
||||
@@ -103,25 +111,24 @@ Thing/
|
||||
- [x] 自建进程内下载引擎(多线程 HTTP/HTTPS,无需外部内核)
|
||||
- [x] 接管浏览器下载,浏览器扩展(Thing Extension)
|
||||
- [x] HTTP 下载支持
|
||||
- [ ] BT/磁力链接支持(后续支持)
|
||||
- [x] BT/磁力链接支持(后续支持)
|
||||
- [x] 下载任务管理(历史)
|
||||
- [x] 速度限制
|
||||
- [x] 断点续传
|
||||
|
||||
#### 🔍 文件 / 程序搜索
|
||||
- [ ] 快速搜索弹窗(快捷键触发)
|
||||
- [ ] 文件搜索(Everything SDK)
|
||||
- [ ] 应用程序搜索
|
||||
- [ ] 拼音模糊匹配
|
||||
- [ ] 正则表达式支持
|
||||
#### 🔍 快速面板
|
||||
- [x] 全局弹窗(快捷键触发)
|
||||
- [x] 全局搜索:拼音模糊匹配,正则表达式支持。支持:程序,文件,文件夹,设置项,最近使用,历史命令
|
||||
- [x] 快捷命令(例:shutdown, hosts)
|
||||
- [x] 计算器 / 单位换算(数学表达式、单位/货币换算;时间转换、进制转换后续支持)
|
||||
|
||||
### 第三阶段:优化与完善
|
||||
|
||||
- [ ] 性能优化
|
||||
- [ ] 错误处理与日志完善
|
||||
- [ ] 用户体验优化
|
||||
- [ ] 自动更新机制
|
||||
- [ ] 打包发布
|
||||
- [x] 性能优化(见 MODULE_REVIEW.md)
|
||||
- [x] 错误处理与日志完善(全局日志器、异常兜底)
|
||||
- [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节)
|
||||
- [x] 自动更新机制
|
||||
- [x] 打包发布
|
||||
|
||||
## 模块管理架构
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ internal sealed class KernelStatus
|
||||
{
|
||||
public bool Ready { get; set; }
|
||||
public bool IsAdmin { get; set; }
|
||||
/// <summary>PawnIO 驱动是否已安装(ring0 传感器读取依赖它或 WinRing0,缺失时温度/频率通常无法读取)</summary>
|
||||
public bool PawnIoInstalled { get; set; }
|
||||
public double UptimeMs { get; set; }
|
||||
public int GroupCount { get; set; }
|
||||
public int SensorCount { get; set; }
|
||||
|
||||
+45
-11
@@ -24,6 +24,9 @@ internal sealed class HardwareManager : IDisposable
|
||||
private readonly bool _isAdmin;
|
||||
private readonly double _coldStartMs;
|
||||
private readonly Stopwatch _startupSw;
|
||||
// 传感器 ID 缓存:避免每秒为每个传感器重复拼接字符串(减少 GC 压力)
|
||||
private readonly Dictionary<ISensor, string> _sensorIdCache = new();
|
||||
private bool _coldStartSent;
|
||||
private bool _ready;
|
||||
private bool _closed;
|
||||
private HardwareConfig _config;
|
||||
@@ -84,7 +87,7 @@ internal sealed class HardwareManager : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// 全量 Update 所有硬件。
|
||||
/// 由 SamplingScheduler 按通道分频调用。
|
||||
/// 仅用于构造函数首轮填充,运行期由调度器分频调用 UpdateFastOnly/UpdateSlowOnly。
|
||||
/// </summary>
|
||||
public void UpdateAll()
|
||||
{
|
||||
@@ -94,9 +97,24 @@ internal sealed class HardwareManager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅 Update 快通道硬件(CPU/GPU/Memory/Network 等)。
|
||||
/// 由调度器快通道 tick 高频调用,避免 SMART 等重查询拖慢采样节奏。
|
||||
/// </summary>
|
||||
public void UpdateFastOnly()
|
||||
{
|
||||
foreach (var hw in _visitor.AllHardware)
|
||||
{
|
||||
if (!IsSlowHardware(hw.HardwareType))
|
||||
{
|
||||
try { hw.Update(); } catch { /* 单个硬件 Update 失败不影响整体 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅 Update 慢通道硬件(Storage/PSU/Battery 等)。
|
||||
/// 快通道硬件(CPU/GPU/Memory/Network)由调度器更高频调用 UpdateAll。
|
||||
/// 快通道硬件(CPU/GPU/Memory/Network)由调度器更高频调用 UpdateFastOnly。
|
||||
/// </summary>
|
||||
public void UpdateSlowOnly()
|
||||
{
|
||||
@@ -123,10 +141,11 @@ internal sealed class HardwareManager : IDisposable
|
||||
Ready = _ready,
|
||||
};
|
||||
|
||||
// 首个快照带上冷启动耗时,后续为 0
|
||||
if (_coldStartMs > 0 && snap.Timestamp > 0)
|
||||
// 首个快照带上冷启动耗时,后续为 0(修复:此前每个快照都携带 ColdStartMs)
|
||||
if (_coldStartMs > 0 && !_coldStartSent)
|
||||
{
|
||||
snap.ColdStartMs = Math.Round(_coldStartMs, 1);
|
||||
_coldStartSent = true;
|
||||
}
|
||||
|
||||
// 重新遍历以读取最新传感器值(visitor 缓存的是 hardware 引用,sensor 值实时)
|
||||
@@ -156,7 +175,7 @@ internal sealed class HardwareManager : IDisposable
|
||||
|
||||
g.Sensors.Add(new SensorEntry
|
||||
{
|
||||
Id = $"{groupId}/{hw.Name}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant(),
|
||||
Id = GetSensorId(s, groupId, hw.Name),
|
||||
Name = s.Name,
|
||||
Type = sensorType,
|
||||
Value = s.Value,
|
||||
@@ -169,6 +188,21 @@ internal sealed class HardwareManager : IDisposable
|
||||
return snap;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取传感器稳定 ID(带缓存)。
|
||||
/// ID 格式:{groupId}/{hwName}/{sensorType}/{sensorName},空格转下划线、小写化。
|
||||
/// 缓存命中直接返回,未命中(新传感器)计算后入缓存。
|
||||
/// </summary>
|
||||
private string GetSensorId(ISensor s, string groupId, string hwName)
|
||||
{
|
||||
if (!_sensorIdCache.TryGetValue(s, out var id))
|
||||
{
|
||||
id = $"{groupId}/{hwName}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant();
|
||||
_sensorIdCache[s] = id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为已启用但 LHB 未枚举到的硬件类型预创建空分组。
|
||||
/// 场景:用户在设置中勾选了主板/电池/电源等,但 LHB 在当前权限或机型下检测不到对应硬件,
|
||||
@@ -243,7 +277,7 @@ internal sealed class HardwareManager : IDisposable
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static bool IsRunningAsAdmin()
|
||||
internal static bool IsRunningAsAdmin()
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -290,14 +324,14 @@ internal sealed class SnapshotVisitor : IVisitor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 采样调度器:按快/慢通道分频驱动 HardwareManager.UpdateAll。
|
||||
/// 采样调度器:按快/慢通道分频驱动 HardwareManager 的分层 Update。
|
||||
/// 使用 Channel 向 SSE 推送层广播快照(解耦:调度器不关心有几个订阅者)。
|
||||
///
|
||||
/// 调度策略:
|
||||
/// - 快通道 tick:UpdateAll(含慢通道硬件,因 UpdateAll 成本主要在 SMART,已通过慢通道分频减少调用频率)
|
||||
/// 实际优化:快通道 tick 只 Update 快通道硬件(UpdateFastOnly),慢通道单独按慢节奏 Update
|
||||
/// - 快通道 tick:UpdateFastOnly(仅 CPU/GPU/Memory/Network 等轻量硬件),
|
||||
/// 避免 SMART 等重查询每秒执行拖慢采样节奏
|
||||
/// - 慢通道 tick:UpdateSlowOnly(仅 Storage/PSU/Motherboard 等)
|
||||
/// - 每个 tick 结束后构建快照并广播
|
||||
/// - 快通道每个 tick 结束后构建快照并广播(慢通道更新后的值随下一帧带出)
|
||||
/// </summary>
|
||||
internal sealed class SamplingScheduler : IDisposable
|
||||
{
|
||||
@@ -352,7 +386,7 @@ internal sealed class SamplingScheduler : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
_hw.UpdateAll();
|
||||
_hw.UpdateFastOnly();
|
||||
var snap = _hw.BuildSnapshot();
|
||||
_cache.Update(snap);
|
||||
_broadcast.Writer.TryWrite(snap);
|
||||
|
||||
@@ -44,6 +44,7 @@ internal static class HttpEndpoints
|
||||
{
|
||||
Ready = hw?.Ready ?? false,
|
||||
IsAdmin = hw?.IsAdmin ?? false,
|
||||
PawnIoInstalled = PawnIoSupport.IsServiceInstalled(),
|
||||
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
|
||||
GroupCount = snap?.Groups.Count ?? 0,
|
||||
SensorCount = kernel.Scheduler.Cache.SensorCount,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ThingHK;
|
||||
|
||||
/// <summary>
|
||||
/// PawnIO 驱动支持:检测 + 静默安装。
|
||||
///
|
||||
/// 背景:LHM 读取 CPU 温度/频率等 ring0 数据依赖内核驱动,回退用的 WinRing0 被
|
||||
/// 微软"易受攻击的驱动程序阻止列表"和部分杀软(如火绒)拦截,导致传感器缺失。
|
||||
/// PawnIO 是正规签名的替代驱动(不在阻止列表、兼容 HVCI/安全启动),
|
||||
/// LHM 0.9.5+ 检测到已安装时优先使用,无需任何代码开关。
|
||||
///
|
||||
/// 安装器约定:PawnIO_setup.exe 与 ThingHK.exe 同目录
|
||||
/// (由 Tauri 侧 prepare_kernel 从资源目录随内核一起复制到 {app_data}/monitor/cores/)。
|
||||
///
|
||||
/// 静默参数:-install -silent(官方 CLI 参数,见 namazso/PawnIO.Setup)。
|
||||
/// 退出码:0=成功;3010=成功但需重启(ERROR_SUCCESS_REBOOT_REQUIRED)。
|
||||
///
|
||||
/// 策略:仅在内核已提权时安装。两种提权模式(Thing 提权继承 / 仅提权 ThingHK)
|
||||
/// 都只有一次 UAC,内核拿到权限后自行静默安装,避免二次弹窗。
|
||||
/// serve 模式调用;scan 诊断模式不安装,保持被动。
|
||||
/// </summary>
|
||||
internal static class PawnIoSupport
|
||||
{
|
||||
/// <summary>驱动服务注册表键:存在即认为已安装</summary>
|
||||
private const string ServiceKeyName = @"SYSTEM\CurrentControlSet\Services\PawnIO";
|
||||
|
||||
private const string SetupFileName = "PawnIO_setup.exe";
|
||||
|
||||
/// <summary>3010 = ERROR_SUCCESS_REBOOT_REQUIRED(安装成功但需重启生效)</summary>
|
||||
private const int ExitCodeRebootRequired = 3010;
|
||||
|
||||
/// <summary>驱动安装通常数秒内完成,留足余量防止卡死启动流程</summary>
|
||||
private const int InstallTimeoutMs = 90_000;
|
||||
|
||||
/// <summary>检测 PawnIO 驱动服务是否已注册</summary>
|
||||
public static bool IsServiceInstalled()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(ServiceKeyName);
|
||||
return key != null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保 PawnIO 就绪:已安装直接返回;未安装且当前已提权时静默安装。
|
||||
/// 返回描述性结果(写入 stderr 日志 + /status 诊断)。
|
||||
/// </summary>
|
||||
public static string EnsureInstalled()
|
||||
{
|
||||
if (IsServiceInstalled())
|
||||
return "already-installed";
|
||||
|
||||
if (!HardwareManager.IsRunningAsAdmin())
|
||||
return "skipped: not elevated (温度/频率等传感器需要提权运行)";
|
||||
|
||||
string setupPath = Path.Combine(AppContext.BaseDirectory, SetupFileName);
|
||||
if (!File.Exists(setupPath))
|
||||
return $"skipped: {SetupFileName} 未找到(应随内核一起部署,见 prepare_kernel)";
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = setupPath,
|
||||
Arguments = "-install -silent",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
if (process == null)
|
||||
return "failed: Process.Start 返回 null";
|
||||
|
||||
if (!process.WaitForExit(InstallTimeoutMs))
|
||||
{
|
||||
try { process.Kill(); } catch { /* 超时后进程可能已自行退出 */ }
|
||||
return "failed: 安装超时";
|
||||
}
|
||||
|
||||
int code = process.ExitCode;
|
||||
if (code == ExitCodeRebootRequired)
|
||||
return "installed: 需重启后生效";
|
||||
|
||||
if (code != 0)
|
||||
return $"failed: 安装器退出码 {code}";
|
||||
|
||||
return IsServiceInstalled()
|
||||
? "installed"
|
||||
: "failed: 安装器返回 0 但服务未注册";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,11 @@ internal static class Program
|
||||
|
||||
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} config={configPath ?? "(默认)"} fast={fastMs}ms slow={slowMs}ms");
|
||||
|
||||
// PawnIO:ring0 传感器读取的首选驱动(未安装且已提权时静默安装,
|
||||
// 避开 WinRing0 被系统阻止列表/杀软拦截导致的温度/频率缺失)
|
||||
string pawnIoResult = PawnIoSupport.EnsureInstalled();
|
||||
Console.Error.WriteLine($"[ThingHK] PawnIO: {pawnIoResult}");
|
||||
|
||||
using var kernel = new KernelHost();
|
||||
await kernel.StartAsync(configPath, fastMs, slowMs);
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.7-pre716" />
|
||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.7-pre729" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
"": {
|
||||
"name": "thing",
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.25.0",
|
||||
"@lucide/vue": "^1.28.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-autostart": "~2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"pinia": "^3.0.4",
|
||||
"pinyin-pro": "^3.28.2",
|
||||
"reka-ui": "^2.10.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
@@ -116,7 +118,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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -234,6 +236,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-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=="],
|
||||
|
||||
"@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 +284,11 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -378,6 +382,8 @@
|
||||
|
||||
"pinia": ["pinia@3.0.4", "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="],
|
||||
|
||||
"pinyin-pro": ["pinyin-pro@3.29.4", "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.29.4.tgz", {}, "sha512-SPXpDT2cHEy+d26V1RXYMlVzXN42hotFAak1fzyWPi4o2dKXb61UqD4pzxDJHwk6gbv8vQ6EfErd+hYX0Qhzug=="],
|
||||
|
||||
"postcss": ["postcss@8.5.19", "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
|
||||
|
||||
"reka-ui": ["reka-ui@2.10.1", "https://registry.npmmirror.com/reka-ui/-/reka-ui-2.10.1.tgz", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.5", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-drcOQ4rQtDYAcGCsyQBqQg8QQ+H3B+zDaMJU0h8KPEPMa7g9BHu3zcOi4OB39XJSWizceFoNO0Z9tctSGLOXqg=="],
|
||||
@@ -433,5 +439,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/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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>音乐模块 · 界面改版对照稿</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root{
|
||||
--bg:#f4f4f5; --panel:#ffffff; --panel-2:#fafafa; --line:#e7e7e9; --line-2:#f0f0f1;
|
||||
--fg:#18181b; --fg-2:#71717a; --fg-3:#a1a1aa;
|
||||
--primary:#18181b; --primary-fg:#fafafa; --accent:#f4f4f5;
|
||||
--ok:#0f7b4f; --warn:#9a3412; --err:#b91c1c;
|
||||
}
|
||||
body{
|
||||
font-family: system-ui,-apple-system,"Segoe UI","Microsoft YaHei","PingFang SC",sans-serif;
|
||||
background:var(--bg); color:var(--fg); font-size:13px; line-height:1.55; padding:28px 20px 60px;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.wrap{ max-width:1180px; margin:0 auto; }
|
||||
h1{ font-size:19px; font-weight:600; letter-spacing:-.01em; }
|
||||
.sub{ color:var(--fg-2); font-size:12.5px; margin-top:4px; }
|
||||
.toolbar{ display:flex; align-items:center; gap:10px; margin:18px 0 14px; flex-wrap:wrap; }
|
||||
.seg{ display:inline-flex; background:#e9e9eb; border-radius:8px; padding:3px; gap:2px; }
|
||||
.seg button{
|
||||
border:0; background:transparent; font:inherit; font-size:12.5px; color:var(--fg-2);
|
||||
padding:6px 14px; border-radius:6px; cursor:pointer; transition:.15s;
|
||||
}
|
||||
.seg button.on{ background:#fff; color:var(--fg); font-weight:500; box-shadow:0 1px 2px rgba(0,0,0,.08); }
|
||||
.hint{ color:var(--fg-3); font-size:12px; }
|
||||
|
||||
/* ---------- window frame ---------- */
|
||||
.win{
|
||||
background:var(--panel); border:1px solid var(--line); border-radius:11px; overflow:hidden;
|
||||
box-shadow:0 8px 28px rgba(0,0,0,.07); display:flex; flex-direction:column; height:640px;
|
||||
}
|
||||
.titlebar{
|
||||
height:38px; flex:0 0 38px; display:flex; align-items:center; gap:8px; padding:0 12px;
|
||||
background:var(--panel-2); border-bottom:1px solid var(--line);
|
||||
}
|
||||
.dots{ display:flex; gap:5px; }
|
||||
.dots i{ width:9px; height:9px; border-radius:50%; background:#dcdce0; display:block; }
|
||||
.tb-title{ font-size:12px; color:var(--fg-2); margin-left:6px; }
|
||||
.tb-tabs{ margin-left:auto; display:flex; gap:4px; }
|
||||
.tb-tabs span{ font-size:11px; color:var(--fg-2); padding:3px 9px; border-radius:5px; }
|
||||
.tb-tabs span.on{ background:#e9e9eb; color:var(--fg); }
|
||||
.body{ flex:1; display:flex; min-height:0; }
|
||||
|
||||
/* ---------- left module rail ---------- */
|
||||
.rail{
|
||||
width:52px; flex:0 0 52px; border-right:1px solid var(--line); background:var(--panel-2);
|
||||
display:flex; flex-direction:column; align-items:center; gap:4px; padding:10px 0;
|
||||
}
|
||||
.rail i{
|
||||
width:32px; height:32px; border-radius:8px; background:#ececed; display:grid; place-items:center;
|
||||
font-size:12px; color:var(--fg-2); font-style:normal;
|
||||
}
|
||||
.rail i.on{ background:var(--primary); color:#fff; }
|
||||
|
||||
/* ---------- source tree ---------- */
|
||||
.tree{ width:186px; flex:0 0 186px; border-right:1px solid var(--line); padding:12px 10px; background:var(--panel); }
|
||||
.tree h4{ font-size:11px; color:var(--fg-3); font-weight:500; padding:6px 8px 4px; letter-spacing:.03em; }
|
||||
.tree a{
|
||||
display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:6px;
|
||||
color:var(--fg-2); text-decoration:none; font-size:12.5px; margin-bottom:1px;
|
||||
}
|
||||
.tree a.on{ background:#ececed; color:var(--fg); font-weight:500; }
|
||||
.tree a b{ margin-left:auto; font-weight:400; font-size:11px; color:var(--fg-3); }
|
||||
|
||||
/* ---------- main ---------- */
|
||||
.main{ flex:1; min-width:0; display:flex; flex-direction:column; background:var(--panel); }
|
||||
.main-head{ padding:12px 16px 10px; border-bottom:1px solid var(--line-2); display:flex; align-items:center; gap:8px; }
|
||||
.field{
|
||||
height:30px; border:1px solid var(--line); border-radius:7px; background:#fff;
|
||||
display:flex; align-items:center; padding:0 10px; color:var(--fg-3); font-size:12.5px; flex:1; max-width:330px;
|
||||
}
|
||||
.btn{ height:30px; padding:0 12px; border-radius:7px; border:1px solid var(--line); background:#fff; color:var(--fg); font-size:12.5px; display:inline-flex; align-items:center; gap:5px; }
|
||||
.btn.dark{ background:var(--primary); color:#fff; border-color:var(--primary); }
|
||||
.main-body{ flex:1; min-height:0; overflow:hidden; }
|
||||
.thead{
|
||||
display:grid; grid-template-columns:34px 44px minmax(0,3fr) minmax(0,2fr) 52px 34px;
|
||||
gap:10px; align-items:center; padding:6px 16px; border-bottom:1px solid var(--line-2);
|
||||
font-size:11px; color:var(--fg-3);
|
||||
}
|
||||
.row{
|
||||
display:grid; grid-template-columns:34px 44px minmax(0,3fr) minmax(0,2fr) 52px 34px;
|
||||
gap:10px; align-items:center; padding:5px 16px; height:44px; border-bottom:1px solid var(--line-2);
|
||||
}
|
||||
.row:hover{ background:var(--accent); }
|
||||
.row.on{ background:#f4f4f5; }
|
||||
.row.on .t1{ color:var(--fg); font-weight:500; }
|
||||
.idx{ font-size:11.5px; color:var(--fg-3); text-align:right; }
|
||||
.eq{ display:flex; align-items:flex-end; gap:2px; height:12px; }
|
||||
.eq i{ width:2.5px; background:var(--primary); border-radius:1px; display:block; }
|
||||
.eq i:nth-child(1){ height:5px } .eq i:nth-child(2){ height:11px } .eq i:nth-child(3){ height:8px }
|
||||
.cov{ width:36px; height:36px; border-radius:6px; background:#e9e9eb; }
|
||||
.t1{ font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.t2{ font-size:11.5px; color:var(--fg-2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.tag{ font-size:10px; padding:1px 6px; border-radius:4px; border:1px solid var(--line); color:var(--fg-2); white-space:nowrap; }
|
||||
.tag.solid{ background:#ececed; border-color:transparent; }
|
||||
.dur{ font-size:11.5px; color:var(--fg-3); font-variant-numeric:tabular-nums; text-align:right; }
|
||||
.more{ color:var(--fg-3); text-align:center; opacity:0; }
|
||||
.row:hover .more{ opacity:1; }
|
||||
|
||||
/* ---------- right now playing ---------- */
|
||||
.np{ width:250px; flex:0 0 250px; border-left:1px solid var(--line); padding:16px; display:flex; flex-direction:column; gap:12px; background:var(--panel); }
|
||||
.np-cover{ width:100%; aspect-ratio:1; border-radius:10px; background:linear-gradient(150deg,#e8e8ea,#d6d6da); }
|
||||
.np h3{ font-size:14px; font-weight:500; }
|
||||
.np p{ font-size:12px; color:var(--fg-2); }
|
||||
.lyric{ display:flex; flex-direction:column; gap:7px; margin-top:4px; }
|
||||
.lyric span{ font-size:12.5px; color:var(--fg-3); }
|
||||
.lyric span.on{ font-size:14px; color:var(--fg); font-weight:500; }
|
||||
|
||||
/* ---------- player bar ---------- */
|
||||
.player{
|
||||
flex:0 0 72px; height:72px; border-top:1px solid var(--line); background:rgba(255,255,255,.92);
|
||||
backdrop-filter:blur(12px); display:flex; align-items:center; gap:14px; padding:0 16px;
|
||||
}
|
||||
.pl-left{ width:236px; flex:0 0 236px; display:flex; align-items:center; gap:10px; min-width:0; }
|
||||
.pl-left .cov{ width:44px; height:44px; border-radius:6px; }
|
||||
.pl-mid{ flex:1; min-width:0; display:flex; flex-direction:column; align-items:center; gap:4px; }
|
||||
.pl-ctrl{ display:flex; align-items:center; gap:16px; color:var(--fg-2); }
|
||||
.pl-play{ width:34px; height:34px; border-radius:50%; background:var(--primary); color:#fff; display:grid; place-items:center; font-size:11px; }
|
||||
.pl-seek{ width:100%; max-width:520px; display:flex; align-items:center; gap:8px; }
|
||||
.pl-seek .time{ font-size:10.5px; color:var(--fg-3); font-variant-numeric:tabular-nums; width:34px; }
|
||||
.track{ flex:1; height:4px; border-radius:2px; background:#e4e4e7; position:relative; }
|
||||
.track .buf{ position:absolute; inset:0 auto 0 0; width:34%; background:#d4d4d8; border-radius:2px; }
|
||||
.track .fill{ position:absolute; inset:0 auto 0 0; width:22%; background:var(--primary); border-radius:2px; }
|
||||
.track .knob{ position:absolute; left:22%; top:50%; width:10px; height:10px; margin:-5px 0 0 -5px; border-radius:50%; background:var(--primary); }
|
||||
.pl-right{ width:196px; flex:0 0 196px; display:flex; align-items:center; justify-content:flex-end; gap:10px; color:var(--fg-2); }
|
||||
.vol{ width:76px; height:4px; border-radius:2px; background:#e4e4e7; position:relative; }
|
||||
.vol i{ position:absolute; inset:0 auto 0 0; width:70%; background:var(--primary); border-radius:2px; display:block; }
|
||||
|
||||
/* ---------- before view ---------- */
|
||||
.before-pad{ padding:22px 22px 0; flex:1; min-height:0; display:flex; flex-direction:column; gap:14px; overflow:hidden; }
|
||||
.b-tabs{ display:grid; grid-template-columns:repeat(4,1fr); max-width:430px; background:#ececed; border-radius:8px; padding:3px; font-size:12.5px; text-align:center; }
|
||||
.b-tabs span{ padding:5px 0; border-radius:6px; color:var(--fg-2); }
|
||||
.b-tabs span.on{ background:#fff; color:var(--fg); font-weight:500; }
|
||||
.b-card{ border:1px solid var(--line); border-radius:9px; padding:9px 12px; display:flex; align-items:center; gap:10px; font-size:12.5px; }
|
||||
.b-card.muted{ background:#fafafa; }
|
||||
.b-row{ border:1px solid var(--line); border-radius:9px; padding:9px 11px; display:flex; align-items:center; gap:11px; margin-bottom:7px; }
|
||||
.b-sub{ display:inline-flex; gap:3px; background:#ececed; border-radius:7px; padding:3px; font-size:12px; }
|
||||
.b-sub span{ padding:4px 11px; border-radius:5px; color:var(--fg-2); }
|
||||
.b-sub span.on{ background:var(--primary); color:#fff; }
|
||||
.b-player{ border-top:1px solid var(--line); margin:0 22px; padding:10px 0; display:flex; align-items:center; gap:12px; }
|
||||
.mark{
|
||||
width:17px; height:17px; border-radius:50%; background:#b91c1c; color:#fff; font-size:10.5px;
|
||||
display:inline-grid; place-items:center; flex:0 0 17px; font-weight:500;
|
||||
}
|
||||
.mark.g{ background:#0f7b4f; }
|
||||
.mark.amber{ background:#b45309; }
|
||||
|
||||
/* ---------- legend ---------- */
|
||||
.legend{ margin-top:20px; display:grid; grid-template-columns:repeat(auto-fit,minmax(310px,1fr)); gap:10px; }
|
||||
.li{ background:#fff; border:1px solid var(--line); border-radius:9px; padding:11px 13px; display:flex; gap:10px; }
|
||||
.li b{ display:block; font-size:12.5px; font-weight:500; margin-bottom:2px; }
|
||||
.li p{ font-size:12px; color:var(--fg-2); }
|
||||
.hide{ display:none !important; }
|
||||
.note{ font-size:11.5px; color:var(--fg-3); margin-top:6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>音乐模块 · 界面改版对照稿</h1>
|
||||
<p class="sub">左边是当前实现的实际问题,右边是按建议方案重排后的目标形态。改版要点见下方图例。</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="seg" id="seg">
|
||||
<button data-v="before" class="on">当前实现</button>
|
||||
<button data-v="after">建议方案</button>
|
||||
</div>
|
||||
<span class="hint" id="hint">红/橙标记 = 缺陷位置</span>
|
||||
</div>
|
||||
|
||||
<!-- ================= BEFORE ================= -->
|
||||
<div class="win" id="view-before">
|
||||
<div class="titlebar">
|
||||
<div class="dots"><i></i><i></i><i></i></div>
|
||||
<span class="tb-title">Thing</span>
|
||||
<div class="tb-tabs"><span class="on">我的音乐</span><span>发现音乐</span><span>下载任务</span><span>设置</span></div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="rail">
|
||||
<i class="on">音</i><i>载</i><i>监</i><i>代</i><i>设</i>
|
||||
</div>
|
||||
<div class="before-pad">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div class="b-tabs">
|
||||
<span class="on">我的音乐</span><span>发现音乐</span><span>下载任务</span><span>设置</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span class="b-sub"><span class="on">飞牛曲库</span><span>本地曲库</span><span>我的歌单</span></span>
|
||||
<span class="hint">← 第二套切换控件,与上一行风格不同</span>
|
||||
<span class="mark" style="margin-left:auto">3</span>
|
||||
</div>
|
||||
|
||||
<div class="b-card">
|
||||
<span class="hint">下载到</span>
|
||||
<span class="tag">本地目录 ▾</span>
|
||||
<span class="hint">下载到本地目录(musicdl)</span>
|
||||
<span class="mark" style="margin-left:auto">1</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<div class="field" style="max-width:none">输入歌名 / 歌手 / 专辑关键词</div>
|
||||
<span class="tag">已选 3 个源 ▾</span>
|
||||
<span class="btn dark">搜索</span>
|
||||
</div>
|
||||
|
||||
<div class="b-card muted">
|
||||
<span class="hint">粘贴歌单链接(网易云 / QQ 音乐等)</span>
|
||||
<span class="btn" style="margin-left:auto">解析歌单</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:2px">
|
||||
<div class="b-row">
|
||||
<span class="tag">☐</span><div class="cov" style="width:38px;height:38px"></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="t1">海阔天空 <span class="tag" style="color:#7c3aed">无损 flac 32.4 MB</span> <span class="tag" style="color:#0369a1">320K mp3</span> <span class="tag">+2</span></div>
|
||||
<div class="t2">Beyond · 乐与怒</div>
|
||||
</div>
|
||||
<span class="tag">05:21</span><span class="tag">▷</span><span class="tag">⤓</span>
|
||||
<span class="mark">2</span><span class="mark">5</span>
|
||||
</div>
|
||||
<div class="b-row">
|
||||
<span class="tag">☐</span><div class="cov" style="width:38px;height:38px"></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="t1">光辉岁月 <span class="tag" style="color:#b45309">Hi-Res flac 96.1 MB</span> <span class="tag" style="color:#a16207">192K mp3</span></div>
|
||||
<div class="t2">Beyond · 命运派对</div>
|
||||
</div>
|
||||
<span class="tag">04:58</span><span class="tag">▷</span><span class="tag">⤓</span>
|
||||
<span class="mark">2</span>
|
||||
</div>
|
||||
<div class="b-row">
|
||||
<span class="tag">☐</span><div class="cov" style="width:38px;height:38px"></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="t1">喜欢你</div><div class="t2">Beyond · 秘密警察</div>
|
||||
</div>
|
||||
<span class="tag">04:35</span><span class="tag">▷</span><span class="tag">⤓</span>
|
||||
<span class="mark">6</span>
|
||||
</div>
|
||||
<span class="note">每行都是独立圆角卡片 + 8px 间距;一行内 6~8 个视觉元素,彩色徽标抢过歌名。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b-player">
|
||||
<div class="cov" style="width:44px;height:44px;border-radius:6px"></div>
|
||||
<div style="min-width:0;max-width:150px">
|
||||
<div class="t1">未播放</div><div class="t2">选择一首歌曲开始播放</div>
|
||||
</div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px">
|
||||
<div class="pl-ctrl" style="font-size:13px"><span>⟳</span><span>⏮</span><span class="pl-play">▶</span><span>⏭</span><span>☰</span></div>
|
||||
<div class="pl-seek" style="max-width:360px">
|
||||
<span class="time">--:--</span>
|
||||
<div class="track"><div class="fill" style="width:0"></div><div class="knob" style="left:0"></div></div>
|
||||
<span class="time">--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-right"><span>🔊</span><div class="vol"><i></i></div><span>词</span><span>⤢</span></div>
|
||||
<span class="mark" style="position:absolute">4</span>
|
||||
</div>
|
||||
<div style="position:relative">
|
||||
<span class="mark" style="position:absolute;right:14px;bottom:44px">7</span>
|
||||
<span class="mark" style="position:absolute;left:14px;bottom:60px">8</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= AFTER ================= -->
|
||||
<div class="win hide" id="view-after">
|
||||
<div class="titlebar">
|
||||
<div class="dots"><i></i><i></i><i></i></div>
|
||||
<span class="tb-title">Thing</span>
|
||||
<div class="tb-tabs"><span class="on">曲库</span><span>发现音乐</span><span>下载任务</span><span>设置</span></div>
|
||||
<span class="mark g" style="margin-left:8px">3</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="rail">
|
||||
<i class="on">音</i><i>载</i><i>监</i><i>代</i><i>设</i>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<h4>曲库</h4>
|
||||
<a class="on">飞牛曲库 <b>1.2k</b></a>
|
||||
<a>本地曲库 <b>318</b></a>
|
||||
<a>我的歌单 <b>7</b></a>
|
||||
<h4>其他</h4>
|
||||
<a>发现音乐</a>
|
||||
<a>下载任务 <b>2</b></a>
|
||||
<a>设置</a>
|
||||
<span class="mark g" style="margin:10px 0 0 8px">3</span>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="main-head">
|
||||
<div class="field">搜索歌名 / 歌手 / 专辑</div>
|
||||
<span class="tag">全部来源 ▾</span>
|
||||
<span class="tag">全部音质 ▾</span>
|
||||
<span class="btn" style="margin-left:auto">排序 ▾</span>
|
||||
<span class="btn">扫描本地</span>
|
||||
<span class="btn dark">▶ 播放全部</span>
|
||||
<span class="mark g" style="margin-left:6px">1</span>
|
||||
</div>
|
||||
<div class="main-body">
|
||||
<div class="thead">
|
||||
<span style="text-align:right">#</span><span></span><span>标题</span><span>专辑</span><span style="text-align:right">时长</span><span></span>
|
||||
</div>
|
||||
<div class="row on">
|
||||
<span class="idx"><span class="eq"><i></i><i></i><i></i></span></span>
|
||||
<div class="cov"></div>
|
||||
<div style="min-width:0">
|
||||
<div class="t1">海阔天空</div>
|
||||
<div class="t2">Beyond · 1993</div>
|
||||
</div>
|
||||
<div class="t2">乐与怒</div>
|
||||
<span class="dur">05:21</span>
|
||||
<span class="more">⋯</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="idx">2</span>
|
||||
<div class="cov"></div>
|
||||
<div style="min-width:0"><div class="t1">光辉岁月</div><div class="t2">Beyond</div></div>
|
||||
<div class="t2">命运派对</div>
|
||||
<span class="dur">04:58</span>
|
||||
<span class="more">⋯</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="idx">3</span>
|
||||
<div class="cov"></div>
|
||||
<div style="min-width:0"><div class="t1">喜欢你</div><div class="t2">Beyond</div></div>
|
||||
<div class="t2">秘密警察</div>
|
||||
<span class="dur">04:35</span>
|
||||
<span class="more">⋯</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="idx">4</span>
|
||||
<div class="cov"></div>
|
||||
<div style="min-width:0"><div class="t1">冷雨夜</div><div class="t2">Beyond</div></div>
|
||||
<div class="t2">现代舞台</div>
|
||||
<span class="dur">04:12</span>
|
||||
<span class="more">⋯</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="idx">5</span>
|
||||
<div class="cov"></div>
|
||||
<div style="min-width:0"><div class="t1">真的爱你</div><div class="t2">Beyond</div></div>
|
||||
<div class="t2">Beyond IV</div>
|
||||
<span class="dur">04:41</span>
|
||||
<span class="more">⋯</span>
|
||||
</div>
|
||||
<div style="padding:8px 16px" class="note">
|
||||
行高 44px、无独立边框、hover 高亮、双击整行播放;播放中行用主色竖条 + 均衡器表示,不再用“选中框”。
|
||||
<span class="mark g">2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="np">
|
||||
<div class="np-cover"></div>
|
||||
<div>
|
||||
<h3>海阔天空</h3>
|
||||
<p>Beyond · 乐与怒</p>
|
||||
</div>
|
||||
<div class="track" style="height:4px"><div class="fill" style="width:38%"></div><div class="knob" style="left:38%"></div></div>
|
||||
<div class="lyric">
|
||||
<span>今天我 寒夜里看雪飘过</span>
|
||||
<span>怀着冷却了的心窝漂远方</span>
|
||||
<span class="on">风雨里追赶 雾里分不清影踪</span>
|
||||
<span>天空海阔你与我</span>
|
||||
<span>可会变(谁没在变)</span>
|
||||
</div>
|
||||
<span class="note">右栏“正在播放”可折叠(窄窗口自动收起)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player">
|
||||
<div class="pl-left">
|
||||
<div class="cov"></div>
|
||||
<div style="min-width:0">
|
||||
<div class="t1">海阔天空</div>
|
||||
<div class="t2">Beyond · 乐与怒</div>
|
||||
</div>
|
||||
<span class="mark g" style="margin-left:auto">4</span>
|
||||
</div>
|
||||
<div class="pl-mid">
|
||||
<div class="pl-ctrl"><span>🔀</span><span>⏮</span><span class="pl-play">❚❚</span><span>⏭</span><span>☰</span></div>
|
||||
<div class="pl-seek">
|
||||
<span class="time">01:12</span>
|
||||
<div class="track"><div class="buf"></div><div class="fill"></div><div class="knob"></div></div>
|
||||
<span class="time">05:21</span>
|
||||
</div>
|
||||
<span class="note" style="margin:0">拖动只更新预览,松手才 seek;轨道含缓冲层,hover 加粗到 8px</span>
|
||||
</div>
|
||||
<div class="pl-right">
|
||||
<span class="mark g">5</span>
|
||||
<span>🔊</span><div class="vol"><i></i></div><span>詞</span><span>⤢</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend" id="legend">
|
||||
<div class="li"><span class="mark">1</span><div><b>首屏信息层级</b><p>“下载到 本地/飞牛”不再独占一行卡片,收进工具栏;搜索框回到首行黄金位置。</p></div></div>
|
||||
<div class="li"><span class="mark">2</span><div><b>列表密度与徽标降噪</b><p>卡片堆 → 44px 紧凑表格行;音质徽标从 5 色降为 1 类中性 outline,档位详情收进 ⋯ 菜单。</p></div></div>
|
||||
<div class="li"><span class="mark">3</span><div><b>两级横向 Tab → 左侧来源树</b><p>模块级 Tab + 子 Tab 合并为一条来源树,导航层级从三层降到两层。</p></div></div>
|
||||
<div class="li"><span class="mark g">4</span><div><b>播放条全宽贴底</b><p>MiniPlayer 提升到 App 全局层:任何模块都能控制播放;离开音乐模块不再丢失控制入口。</p></div></div>
|
||||
<div class="li"><span class="mark g">5</span><div><b>进度与音量交互</b><p>提交式 seek + 缓冲进度层;音量图标可点静音;滑块配色改走主题 token(原为硬编码纯白)。</p></div></div>
|
||||
<div class="li"><span class="mark">6</span><div><b>来源与来源标识统一</b><p>本地/NAS/歌单混排时用统一图标标识来源,不再靠行内小标签。</p></div></div>
|
||||
<div class="li"><span class="mark">7</span><div><b>内边距不对称</b><p>模块左 28px / 右 40px(含滚动条),卡片右边距明显偏窄;统一为 24px 并保留滚动条槽位。</p></div></div>
|
||||
<div class="li"><span class="mark">8</span><div><b>播放条未贴底</b><p>被模块 p-6 包住,分隔线左右各缩进 24px,读作“漂浮卡片”而非底部坞站。</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var seg = document.getElementById('seg');
|
||||
var before = document.getElementById('view-before');
|
||||
var after = document.getElementById('view-after');
|
||||
var hint = document.getElementById('hint');
|
||||
seg.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button');
|
||||
if (!b) return;
|
||||
var v = b.getAttribute('data-v');
|
||||
seg.querySelectorAll('button').forEach(function (x) { x.classList.toggle('on', x === b); });
|
||||
before.classList.toggle('hide', v !== 'before');
|
||||
after.classList.toggle('hide', v !== 'after');
|
||||
hint.textContent = v === 'before' ? '红/橙标记 = 缺陷位置' : '绿色标记 = 改版要点';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+33
-16
@@ -8,16 +8,18 @@
|
||||
"name": "thing",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.25.0",
|
||||
"@lucide/vue": "^1.28.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-autostart": "~2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"pinia": "^3.0.4",
|
||||
"pinyin-pro": "^3.28.2",
|
||||
"reka-ui": "^2.10.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
@@ -603,9 +605,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@lucide/vue": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.25.0.tgz",
|
||||
"integrity": "sha512-hkEetV+v48ScIn3uwqwWQ66sI8foeP2q6OMI09GzLFH4SfvBlfe3JHYlMBdBCqFC7WRlhFsndyDn/awRKRc2OQ==",
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.28.0.tgz",
|
||||
"integrity": "sha512-cO89UyNM/0i2Srdi04nFWeEOQkqzDZlM2ehtWyBhcMpRTJDKpMZPCGAVlmVBgBgr5HaeL5L6FsU8RHsOdwXtKw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
@@ -1455,6 +1457,15 @@
|
||||
"@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": {
|
||||
"version": "2.5.4",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -1671,14 +1682,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz",
|
||||
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz",
|
||||
"integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.3.0",
|
||||
"@vueuse/shared": "14.3.0"
|
||||
"@vueuse/metadata": "14.4.0",
|
||||
"@vueuse/shared": "14.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
@@ -1688,18 +1699,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz",
|
||||
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz",
|
||||
"integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz",
|
||||
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz",
|
||||
"integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"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": {
|
||||
"version": "8.5.19",
|
||||
"funding": [
|
||||
|
||||
+5
-3
@@ -1,26 +1,28 @@
|
||||
{
|
||||
"name": "thing",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "26.9.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --test src/modules/quickpanel/engine.test.ts src/lib/calc.test.ts",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.25.0",
|
||||
"@lucide/vue": "^1.28.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-autostart": "~2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"pinia": "^3.0.4",
|
||||
"pinyin-pro": "^3.28.2",
|
||||
"reka-ui": "^2.10.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import sys, importlib.util
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
from musicdl.modules.sources.qq import QQMusicClient
|
||||
from musicdl.modules.sources.netease import NeteaseMusicClient
|
||||
from musicdl.modules.sources.kugou import KugouMusicClient
|
||||
from musicdl.modules.utils.qqutils import SongFileType
|
||||
# find MUSIC_QUALITIES in source modules
|
||||
for mod in (QQMusicClient, NeteaseMusicClient, KugouMusicClient):
|
||||
src = mod.__module__
|
||||
m = getattr(mod, 'source', '?')
|
||||
names = [x for x in mod.__init__.__globals__.keys() if 'QUALIT' in x.upper()]
|
||||
print("SRC", m, "quality-names-in-globals:", names)
|
||||
for n in names:
|
||||
v = mod.__init__.__globals__[n]
|
||||
print(" ", n, "=", v)
|
||||
print("QQ SORTED_QUALITIES:", SongFileType.SORTED_QUALITIES.value)
|
||||
@@ -0,0 +1,21 @@
|
||||
import sys, json, importlib.util
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
spec = importlib.util.spec_from_file_location("bridge", r"d:\Atie\Gitea\Thing\src-tauri\src\music\bridge.py")
|
||||
b = importlib.util.module_from_spec(spec); spec.loader.exec_module(b)
|
||||
client = b.get_client(["QQMusicClient"])
|
||||
res = client.search("海阔天空")
|
||||
s = res["QQMusicClient"][0]
|
||||
rs = (s.raw_data or {}).get("search")
|
||||
src = b._get_resolve_client("QQMusicClient")
|
||||
print("raw song:", s.song_name, "| id", s.identifier)
|
||||
# official alone, lossless_quality_is_sufficient=False, no cap
|
||||
from musicdl.modules.utils.data import SongInfo
|
||||
official = src._parsewithofficialapiv1
|
||||
r2 = official(search_result=rs, song_info_flac=SongInfo(source="QQMusicClient"), lossless_quality_is_sufficient=False)
|
||||
print("official-only uncapped -> ext=", getattr(r2,"ext",None), "valid=", getattr(r2,"with_valid_download_url",None))
|
||||
for target in ("", "最高", "无损", "320K", "128K"):
|
||||
r = b._resolve_song(src, rs, target)
|
||||
print("target=%r ->" % target, "ext=", getattr(r,"ext",None), "bitrate=", getattr(r,"bitrate",None), "valid=", getattr(r,"with_valid_download_url",None))
|
||||
# verify constant restored
|
||||
import musicdl.modules.utils.qqutils as qq
|
||||
print("restored SORTED len:", len(qq.SongFileType.SORTED_QUALITIES.value))
|
||||
@@ -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 Start:release.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
|
||||
@@ -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"
|
||||
Generated
+1812
-26
File diff suppressed because it is too large
Load Diff
+52
-2
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "clipboard-preview",
|
||||
"description": "Capability for clipboard independent preview window",
|
||||
"windows": ["clipboard-preview"],
|
||||
"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-listen",
|
||||
"core:event:allow-emit",
|
||||
"snap-layout:default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "download-window",
|
||||
"description": "Capability for the per-download one-time window",
|
||||
"windows": ["download-window-*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-set-skip-taskbar",
|
||||
"core:window:allow-set-theme",
|
||||
"core:window:allow-set-effects",
|
||||
"core:window:allow-set-background-color",
|
||||
"core:window:allow-close",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-listen",
|
||||
"snap-layout:default"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "screenshot",
|
||||
"description": "Capability for screenshot overlay, editor, pin and scroll-control windows",
|
||||
"windows": ["screenshot-overlay*", "screenshot-editor*", "screenshot-pin", "screenshot-scroll-*"],
|
||||
"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",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"dialog:default",
|
||||
"snap-layout:default"
|
||||
]
|
||||
}
|
||||
@@ -16,7 +16,6 @@ const DEFAULT_CONFIG = {
|
||||
interceptDownload: true,
|
||||
minSize: 0,
|
||||
excludeDomains: [],
|
||||
showNotifications: true,
|
||||
// 嗅探开关
|
||||
sniffEnabled: true,
|
||||
// 嗅探的资源类型:只保留视频/音频/图片/压缩包/种子/安装包
|
||||
@@ -359,6 +358,11 @@ async function addDownload(url, filename, referer, cookies, headers) {
|
||||
}
|
||||
|
||||
// ===== 下载拦截 =====
|
||||
// 处理中的 URL(防止同一 URL 并发/重入,也避免与引擎去重检查竞态)
|
||||
const processingUrls = new Set()
|
||||
// 我们自己用 chrome.downloads.download 回退创建的下载 URL(短时间内跳过,防止再次被拦截形成死循环)
|
||||
const fallbackUrls = new Map() // url -> 过期时间戳
|
||||
|
||||
async function shouldIntercept(downloadItem) {
|
||||
const config = await getConfig()
|
||||
if (!config.interceptDownload) return false
|
||||
@@ -374,42 +378,94 @@ async function shouldIntercept(downloadItem) {
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleDownloadCreated(downloadItem) {
|
||||
if (!await shouldIntercept(downloadItem)) return
|
||||
|
||||
/**
|
||||
* 判断是否为"历史下载"(安装插件之前就已存在、随后被浏览器恢复的旧下载)
|
||||
* 这类下载一律不接管,交给浏览器原生处理,实现"只接管以后的下载,历史都不管"
|
||||
*/
|
||||
async function isHistoricalDownload(item) {
|
||||
// 1) canResume=true 表示已存在有效的部分文件,说明浏览器在恢复旧下载
|
||||
if (item.canResume) return true
|
||||
// 2) 开始时间早于插件首次安装时间(浏览器重启后恢复的旧下载会保留原来的开始时间)
|
||||
try {
|
||||
await chrome.downloads.cancel(downloadItem.id)
|
||||
await chrome.downloads.erase({ id: downloadItem.id })
|
||||
const start = item.startTime ? new Date(item.startTime).getTime() : 0
|
||||
if (start > 0) {
|
||||
const stored = await chrome.storage.local.get('installTime')
|
||||
const installTime = stored.installTime || 0
|
||||
if (installTime > 0 && start < installTime) return true
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return false
|
||||
}
|
||||
|
||||
const url = downloadItem.finalUrl || downloadItem.url
|
||||
const filename = downloadItem.filename || ''
|
||||
|
||||
/**
|
||||
* 查询引擎是否已有同 URL 的非终态任务(活跃/排队/暂停)
|
||||
* 用于防止同一下载被重复转发、重复下载
|
||||
*/
|
||||
async function hasExistingTask(url) {
|
||||
try {
|
||||
const id = await addDownload(url, filename, downloadItem.referrer, '')
|
||||
await notify('已添加到 Thing', `${filename || url}`)
|
||||
} catch (e) {
|
||||
await notify('Thing 添加失败', `${filename || url}\n${e.message}`)
|
||||
try { await chrome.downloads.download({ url }) } catch { /* ignore */ }
|
||||
const tasks = await apiRequest('/api/downloads')
|
||||
return tasks.some(t => {
|
||||
const s = t.status
|
||||
if (s === 'complete' || s === 'error') return false
|
||||
return t.url === url
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 通知 =====
|
||||
async function notify(title, message) {
|
||||
const config = await getConfig()
|
||||
if (!config.showNotifications) return
|
||||
async function handleDownloadCreated(downloadItem) {
|
||||
const url = downloadItem.finalUrl || downloadItem.url
|
||||
|
||||
// 回退下载:我们自己用 chrome.downloads.download 创建的,直接跳过,避免无限循环
|
||||
const fbExp = fallbackUrls.get(url)
|
||||
if (fbExp && Date.now() < fbExp) {
|
||||
fallbackUrls.delete(url)
|
||||
return
|
||||
}
|
||||
|
||||
// 历史下载(安装前的旧下载被浏览器恢复)一律不接管
|
||||
if (await isHistoricalDownload(downloadItem)) return
|
||||
|
||||
if (!await shouldIntercept(downloadItem)) return
|
||||
|
||||
// 同一 URL 已在处理中,跳过(防并发/防重复转发)
|
||||
if (processingUrls.has(url)) return
|
||||
|
||||
// 引擎不可达时不接管(保留浏览器原生下载),避免取消后下载无处可去
|
||||
let connected = false
|
||||
try { connected = await testConnection() } catch { connected = false }
|
||||
if (!connected) return
|
||||
|
||||
// 引擎已有同 URL 的非终态任务,不重复转发
|
||||
if (await hasExistingTask(url)) return
|
||||
|
||||
processingUrls.add(url)
|
||||
try {
|
||||
await chrome.notifications.create({
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon-128.png',
|
||||
title,
|
||||
message
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
await chrome.downloads.cancel(downloadItem.id)
|
||||
await chrome.downloads.erase({ id: downloadItem.id })
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const filename = downloadItem.filename || ''
|
||||
try {
|
||||
await addDownload(url, filename, downloadItem.referrer, '')
|
||||
} catch (e) {
|
||||
// 添加失败:回退为浏览器自带下载,并标记该 URL 短时间内跳过,防止再次被拦截形成死循环
|
||||
fallbackUrls.set(url, Date.now() + 3000)
|
||||
try { await chrome.downloads.download({ url }) } catch { /* ignore */ }
|
||||
}
|
||||
} finally {
|
||||
processingUrls.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 右键菜单 =====
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
// ===== 右键菜单 & 安装标记 =====
|
||||
chrome.runtime.onInstalled.addListener(async (details) => {
|
||||
// 记录首次安装时间:用于区分"安装前的历史下载"(被浏览器恢复的旧下载)与"安装后的新下载"
|
||||
if (details.reason === 'install') {
|
||||
await chrome.storage.local.set({ installTime: Date.now() })
|
||||
}
|
||||
chrome.contextMenus.create({
|
||||
id: 'thing-download-link',
|
||||
title: '使用 Thing 下载此链接',
|
||||
@@ -428,10 +484,7 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
const filename = url.split('/').pop()?.split('?')[0] || ''
|
||||
try {
|
||||
await addDownload(url, filename, info.pageUrl, '')
|
||||
await notify('已添加到 Thing', `${filename || url}`)
|
||||
} catch (e) {
|
||||
await notify('Thing 添加失败', `${e.message}`)
|
||||
}
|
||||
} catch (e) { /* 忽略:添加失败时不打扰用户 */ }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Thing Extension",
|
||||
"version": "0.2.0",
|
||||
"version": "0.30",
|
||||
"description": "发送浏览器下载到 Thing 下载引擎,嗅探网页资源。",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
@@ -11,9 +11,7 @@
|
||||
"permissions": [
|
||||
"downloads",
|
||||
"storage",
|
||||
"notifications",
|
||||
"webRequest",
|
||||
"webNavigation",
|
||||
"contextMenus",
|
||||
"tabs",
|
||||
"scripting"
|
||||
|
||||
@@ -87,11 +87,6 @@
|
||||
<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" />
|
||||
|
||||
@@ -12,7 +12,6 @@ const DEFAULT_CONFIG = {
|
||||
interceptDownload: true,
|
||||
minSize: 0,
|
||||
excludeDomains: [],
|
||||
showNotifications: true,
|
||||
sniffEnabled: true,
|
||||
sniffTypes: ['video', 'audio', 'image', 'archive', 'torrent', 'installer'],
|
||||
sniffMaxItems: 200,
|
||||
@@ -57,7 +56,6 @@ function fillForm(config) {
|
||||
$('secret').value = config.secret || ''
|
||||
$('interceptDownload').checked = config.interceptDownload !== false
|
||||
$('sniffEnabled').checked = config.sniffEnabled !== false
|
||||
$('showNotifications').checked = config.showNotifications !== false
|
||||
$('minSize').value = config.minSize || 0
|
||||
$('sniffMinSize').value = config.sniffMinSize ?? DEFAULT_CONFIG.sniffMinSize
|
||||
$('excludeDomains').value = (config.excludeDomains || []).join(',')
|
||||
@@ -69,7 +67,6 @@ function readForm() {
|
||||
secret: $('secret').value.trim(),
|
||||
interceptDownload: $('interceptDownload').checked,
|
||||
sniffEnabled: $('sniffEnabled').checked,
|
||||
showNotifications: $('showNotifications').checked,
|
||||
minSize: parseInt($('minSize').value, 10) || 0,
|
||||
sniffMinSize: parseInt($('sniffMinSize').value, 10) || 0,
|
||||
excludeDomains: $('excludeDomains').value
|
||||
|
||||
Binary file not shown.
@@ -3,13 +3,14 @@
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine as _;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::manager::{ClipboardManager, ClipboardSettings};
|
||||
use super::reader::dib_to_png;
|
||||
use super::reader::{dib_to_png, dib_to_thumbnail};
|
||||
use super::storage::{ClipboardItem, ClipboardItemDetail};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClipboardStatus {
|
||||
pub running: bool,
|
||||
@@ -21,7 +22,7 @@ fn img_to_base64(b: &[u8]) -> Option<String> {
|
||||
}
|
||||
|
||||
/// 历史查询结果(含总数,用于分页)
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HistoryPage {
|
||||
pub items: Vec<ClipboardItem>,
|
||||
@@ -29,74 +30,122 @@ pub struct HistoryPage {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_get_history(
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
kind: Option<String>,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<HistoryPage, String> {
|
||||
let storage = manager.storage();
|
||||
let storage = manager.storage().clone();
|
||||
let kind = kind.unwrap_or_else(|| "all".into());
|
||||
let limit = limit.unwrap_or(50);
|
||||
let offset = offset.unwrap_or(0);
|
||||
let items = storage.get_history(limit, offset, &kind);
|
||||
let total = storage.count_kind(&kind);
|
||||
Ok(HistoryPage { items, total })
|
||||
// SQLite 查询移出 async runtime 线程
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let items = storage.get_history(limit, offset, &kind);
|
||||
let total = storage.count_kind(&kind);
|
||||
HistoryPage { items, total }
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("查询任务失败: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_get_pinned(
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<Vec<ClipboardItem>, String> {
|
||||
Ok(manager.storage().get_pinned())
|
||||
let storage = manager.storage().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || storage.get_pinned())
|
||||
.await
|
||||
.map_err(|e| format!("查询任务失败: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_search(
|
||||
query: String,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<HistoryPage, String> {
|
||||
let storage = manager.storage();
|
||||
let storage = manager.storage().clone();
|
||||
let limit = limit.unwrap_or(50);
|
||||
let offset = offset.unwrap_or(0);
|
||||
let items = storage.search(&query, limit, offset);
|
||||
let total = storage.count_search(&query);
|
||||
Ok(HistoryPage { items, total })
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let items = storage.search(&query, limit, offset);
|
||||
let total = storage.count_search(&query);
|
||||
HistoryPage { items, total }
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("查询任务失败: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_get_item(
|
||||
id: i64,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<Option<ClipboardItemDetail>, String> {
|
||||
Ok(manager.storage().get_detail(id, img_to_base64))
|
||||
let storage = manager.storage().clone();
|
||||
// get_detail 内部:锁内取数 + 锁外 base64 编码,整体移出主线程
|
||||
tauri::async_runtime::spawn_blocking(move || storage.get_detail(id, img_to_base64))
|
||||
.await
|
||||
.map_err(|e| format!("查询任务失败: {}", e))
|
||||
}
|
||||
|
||||
/// 获取图片缩略图 PNG base64(弹窗悬停预览用,避免加载全尺寸图片)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_get_thumb(
|
||||
id: i64,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let storage = manager.storage().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
storage.get_thumb(id, |dib| dib_to_thumbnail(dib, 256))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("查询任务失败: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_set_pinned(
|
||||
id: i64,
|
||||
pinned: bool,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<bool, String> {
|
||||
Ok(manager.storage().set_pinned(id, pinned))
|
||||
let storage = manager.storage().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || storage.set_pinned(id, pinned))
|
||||
.await
|
||||
.map_err(|e| format!("操作任务失败: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_delete(
|
||||
id: i64,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<bool, String> {
|
||||
Ok(manager.storage().delete(id))
|
||||
let storage = manager.storage().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || storage.delete(id))
|
||||
.await
|
||||
.map_err(|e| format!("删除任务失败: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_clear(manager: State<'_, ClipboardManager>) -> Result<bool, String> {
|
||||
Ok(manager.storage().clear_non_pinned())
|
||||
let storage = manager.storage().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || storage.clear_non_pinned())
|
||||
.await
|
||||
.map_err(|e| format!("清空任务失败: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_copy_back(
|
||||
id: i64,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
@@ -105,11 +154,16 @@ pub async fn clipboard_copy_back(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_count(manager: State<'_, ClipboardManager>) -> Result<i64, String> {
|
||||
Ok(manager.storage().count())
|
||||
let storage = manager.storage().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || storage.count())
|
||||
.await
|
||||
.map_err(|e| format!("查询任务失败: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_get_settings(
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<ClipboardSettings, String> {
|
||||
@@ -117,44 +171,75 @@ pub async fn clipboard_get_settings(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_save_settings(
|
||||
settings: ClipboardSettings,
|
||||
app: AppHandle,
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<(), String> {
|
||||
let prev_enabled = manager.get_settings().enabled;
|
||||
let prev_shortcut = manager.get_settings().shortcut.clone();
|
||||
let prev = manager.get_settings();
|
||||
let prev_enabled = prev.enabled;
|
||||
let prev_shortcut = prev.shortcut.clone();
|
||||
|
||||
// 快捷键变化且新值非空:先注册(原子化 + 冲突检测),成功后才保存设置。
|
||||
// 注册失败时恢复旧快捷键并中止保存,避免设置被写入无法生效的组合键、旧键丢失。
|
||||
if settings.shortcut != prev_shortcut && !settings.shortcut.trim().is_empty() {
|
||||
if let Err(e) = crate::shortcut::register_shortcut(&app, "剪贴板", &settings.shortcut, |a| {
|
||||
super::popup::show_popup(a)
|
||||
}) {
|
||||
// register_shortcut 内部已注销旧快捷键,失败时需重新注册旧键以恢复
|
||||
if !prev_shortcut.trim().is_empty() {
|
||||
let _ = crate::shortcut::register_shortcut(&app, "剪贴板", &prev_shortcut, |a| {
|
||||
super::popup::show_popup(a)
|
||||
});
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
// 新快捷键非空时确保弹窗/预览窗口已预创建
|
||||
super::popup::ensure_popup_window(&app);
|
||||
super::popup::ensure_preview_window(&app);
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
manager.save_settings(settings.clone());
|
||||
|
||||
// 监听开关变化时联动启停
|
||||
if settings.enabled && !prev_enabled {
|
||||
manager.start(&app);
|
||||
} else if !settings.enabled && prev_enabled {
|
||||
manager.stop();
|
||||
}
|
||||
// 快捷键变化时重新注册
|
||||
if settings.shortcut != prev_shortcut {
|
||||
super::popup::register_shortcut(&app, &settings.shortcut)?;
|
||||
|
||||
// 快捷键改为空字符串(禁用):注销旧快捷键
|
||||
if settings.shortcut.trim().is_empty() && settings.shortcut != prev_shortcut {
|
||||
crate::shortcut::unregister_shortcut(&app, "剪贴板");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_status(
|
||||
manager: State<'_, ClipboardManager>,
|
||||
) -> Result<ClipboardStatus, String> {
|
||||
Ok(ClipboardStatus {
|
||||
running: manager.is_running(),
|
||||
count: manager.storage().count(),
|
||||
})
|
||||
let storage = manager.storage().clone();
|
||||
let running = manager.is_running();
|
||||
let count = tauri::async_runtime::spawn_blocking(move || storage.count())
|
||||
.await
|
||||
.map_err(|e| format!("查询任务失败: {}", e))?;
|
||||
Ok(ClipboardStatus { running, count })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_start(app: AppHandle, manager: State<'_, ClipboardManager>) -> Result<(), String> {
|
||||
manager.start(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_stop(manager: State<'_, ClipboardManager>) -> Result<(), String> {
|
||||
manager.stop();
|
||||
Ok(())
|
||||
@@ -162,22 +247,27 @@ pub async fn clipboard_stop(manager: State<'_, ClipboardManager>) -> Result<(),
|
||||
|
||||
/// 注册(或切换)快捷弹窗全局快捷键
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_register_shortcut(
|
||||
shortcut: String,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
super::popup::register_shortcut(&app, &shortcut)
|
||||
crate::shortcut::register_shortcut(&app, "剪贴板", &shortcut, |a| {
|
||||
super::popup::show_popup(a)
|
||||
})
|
||||
}
|
||||
|
||||
/// 注销快捷弹窗全局快捷键
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_unregister_shortcut(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::unregister_shortcut(&app);
|
||||
crate::shortcut::unregister_shortcut(&app, "剪贴板");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 手动触发显示快捷弹窗(供 UI 按钮调用)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_show_popup(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::show_popup(&app);
|
||||
Ok(())
|
||||
@@ -185,6 +275,7 @@ pub async fn clipboard_show_popup(app: AppHandle) -> Result<(), String> {
|
||||
|
||||
/// 隐藏快捷弹窗
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_hide_popup(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::hide_popup(&app);
|
||||
Ok(())
|
||||
@@ -192,6 +283,7 @@ pub async fn clipboard_hide_popup(app: AppHandle) -> Result<(), String> {
|
||||
|
||||
/// 显示已创建的弹窗窗口(前端 onMounted 后调用)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_show_window(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::show_window(&app);
|
||||
Ok(())
|
||||
@@ -199,7 +291,57 @@ pub async fn clipboard_show_window(app: AppHandle) -> Result<(), String> {
|
||||
|
||||
/// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_paste_to_target(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::paste_to_target(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 在弹窗旁显示独立预览窗口(悬停/键盘选中时调用)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_show_preview(app: AppHandle, id: i64) -> Result<(), String> {
|
||||
super::popup::show_preview(&app, id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 隐藏独立预览窗口
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_hide_preview(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::hide_preview(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按内容自适应调整预览窗大小(逻辑像素)。前端加载内容(文本测高、图片按宽高比)后调用,
|
||||
/// 窗口贴合内容消除留白;后端按弹窗所在屏工作区钳制并重新对齐弹窗。
|
||||
/// allow_flip:初始落位为 true(优先侧放不下可换侧);放大/还原为 false(保持原侧)。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_resize_preview(
|
||||
app: AppHandle,
|
||||
width: f64,
|
||||
height: f64,
|
||||
allow_flip: bool,
|
||||
) -> Result<(), String> {
|
||||
super::popup::resize_preview(&app, width, height, allow_flip);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 显示已就绪的预览窗口。前端完成内容加载与 resize 后调用,窗口以最终尺寸出现,
|
||||
/// 消除"先以上次尺寸(可能是放大态大窗)显示再缩回"的闪烁。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_reveal_preview(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::reveal_preview(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 预览窗交互锁定:前端预览窗收到 mousedown(放大/缩小、复制、选择文本)时调用。
|
||||
/// 此后弹窗+预览不因失焦/鼠标离开而关闭,仅当点击外部或弹窗重新聚焦时退出锁定。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_preview_interacted(app: AppHandle) -> Result<(), String> {
|
||||
super::popup::preview_interacted(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7,13 +7,15 @@ use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
use super::monitor::start_monitor;
|
||||
use super::reader::{write_dib, write_files, write_text};
|
||||
use super::storage::Storage;
|
||||
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
|
||||
|
||||
/// 剪贴板设置(持久化到 clipboard/settings.json)
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct ClipboardSettings {
|
||||
/// 监听是否启用
|
||||
@@ -54,7 +56,8 @@ impl Default for ClipboardSettings {
|
||||
pub struct ClipboardManager {
|
||||
storage: Arc<Storage>,
|
||||
settings: Arc<Mutex<ClipboardSettings>>,
|
||||
suppress: Arc<AtomicBool>,
|
||||
/// 本应用 copy_back 写入后的剪贴板序列号,用于跳过自身写入产生的记录
|
||||
suppress: Arc<Mutex<Option<u32>>>,
|
||||
monitor_stop: Arc<AtomicBool>,
|
||||
monitor_handle: Mutex<Option<JoinHandle<()>>>,
|
||||
settings_path: PathBuf,
|
||||
@@ -66,13 +69,13 @@ impl ClipboardManager {
|
||||
let storage = match Storage::new(&clip_dir) {
|
||||
Ok(s) => Arc::new(s),
|
||||
Err(e) => {
|
||||
eprintln!("[clipboard] 磁盘存储初始化失败,回退内存: {}", e);
|
||||
crate::logger::log_warn("clipboard", &format!("磁盘存储初始化失败,回退内存: {}", e));
|
||||
Arc::new(Storage::new_in_memory())
|
||||
}
|
||||
};
|
||||
let settings_path = clip_dir.join("settings.json");
|
||||
let settings = Arc::new(Mutex::new(load_settings(&settings_path)));
|
||||
let suppress = Arc::new(AtomicBool::new(false));
|
||||
let suppress = Arc::new(Mutex::new(None));
|
||||
let monitor_stop = Arc::new(AtomicBool::new(true));
|
||||
Self {
|
||||
storage,
|
||||
@@ -86,7 +89,7 @@ impl ClipboardManager {
|
||||
|
||||
/// 启动监听(若已运行则跳过)
|
||||
pub fn start(&self, app: &tauri::AppHandle) {
|
||||
let mut handle = self.monitor_handle.lock().unwrap();
|
||||
let mut handle = self.monitor_handle.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if handle.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -99,41 +102,51 @@ impl ClipboardManager {
|
||||
self.monitor_stop.clone(),
|
||||
);
|
||||
*handle = Some(h);
|
||||
eprintln!("[clipboard] 监听已启动");
|
||||
crate::logger::log_info("clipboard", "监听已启动");
|
||||
}
|
||||
|
||||
/// 停止监听
|
||||
pub fn stop(&self) {
|
||||
self.monitor_stop.store(true, Ordering::SeqCst);
|
||||
if let Some(h) = self.monitor_handle.lock().unwrap().take() {
|
||||
// 不阻塞等待;轮询线程最迟 800ms 后退出
|
||||
let _ = h.join();
|
||||
if let Some(h) = self
|
||||
.monitor_handle
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take()
|
||||
{
|
||||
// 后台等待监听线程退出(最迟 800ms),不阻塞调用者。
|
||||
// 剪贴板 stop 命令跑在 async runtime 线程上,直接 join 会卡住 tokio worker。
|
||||
std::thread::spawn(move || {
|
||||
let _ = h.join();
|
||||
});
|
||||
}
|
||||
eprintln!("[clipboard] 监听已停止");
|
||||
crate::logger::log_info("clipboard", "监听已停止");
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.monitor_handle.lock().unwrap().is_some()
|
||||
self.monitor_handle
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> ClipboardSettings {
|
||||
self.settings.lock().unwrap().clone()
|
||||
self.settings.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
pub fn save_settings(&self, s: ClipboardSettings) {
|
||||
{
|
||||
*self.settings.lock().unwrap() = s.clone();
|
||||
*self.settings.lock().unwrap_or_else(|e| e.into_inner()) = s.clone();
|
||||
}
|
||||
save_settings(&self.settings_path, &s);
|
||||
}
|
||||
|
||||
/// 将某条历史写回剪贴板。写回前置 suppress 标志以避免再次记录。
|
||||
/// 将某条历史写回剪贴板。写回成功后记录剪贴板序列号,供监听跳过自身写入。
|
||||
pub fn copy_back(&self, id: i64) -> Result<(), String> {
|
||||
let (kind, content, blob) = self
|
||||
.storage
|
||||
.get_raw_for_copy(id)
|
||||
.ok_or_else(|| "条目不存在".to_string())?;
|
||||
self.suppress.store(true, Ordering::SeqCst);
|
||||
let ok = match kind.as_str() {
|
||||
"text" => content.as_deref().map(write_text).unwrap_or(false),
|
||||
"image" => blob.as_deref().map(write_dib).unwrap_or(false),
|
||||
@@ -151,10 +164,12 @@ impl ClipboardManager {
|
||||
_ => false,
|
||||
};
|
||||
if ok {
|
||||
// 绑定到写入完成后的剪贴板序列号:仅跳过本次写入产生的记录,
|
||||
// 用户后续复制(序列号不同)不会被误吞。
|
||||
let seq = unsafe { GetClipboardSequenceNumber() };
|
||||
*self.suppress.lock().unwrap_or_else(|e| e.into_inner()) = Some(seq);
|
||||
Ok(())
|
||||
} else {
|
||||
// 写入失败也清除 suppress,避免误吞下次复制
|
||||
self.suppress.store(false, Ordering::SeqCst);
|
||||
Err("写回剪贴板失败".into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@ pub mod storage;
|
||||
|
||||
pub use commands::{
|
||||
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
|
||||
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup,
|
||||
clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search,
|
||||
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
|
||||
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_get_thumb,
|
||||
clipboard_hide_popup, clipboard_hide_preview, clipboard_paste_to_target,
|
||||
clipboard_preview_interacted, clipboard_register_shortcut,
|
||||
clipboard_reveal_preview,
|
||||
clipboard_save_settings, clipboard_search, clipboard_set_pinned, clipboard_resize_preview,
|
||||
clipboard_show_popup, clipboard_show_preview, clipboard_show_window, clipboard_start,
|
||||
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
|
||||
};
|
||||
pub use manager::ClipboardManager;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 剪贴板监听线程:基于 GetClipboardSequenceNumber 轮询
|
||||
//!
|
||||
//! 选用轮询而非 AddClipboardFormatListener 消息窗口:实现更简单、无需消息循环,
|
||||
//! 800ms 间隔对剪贴板场景延迟可接受,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。
|
||||
//! 250ms 间隔兼顾响应速度与开销,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -15,18 +15,19 @@ use super::storage::{NewItem, Storage};
|
||||
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
|
||||
|
||||
/// 启动监听线程,返回 JoinHandle。
|
||||
/// `suppress` 记录本应用 copy_back 写入后的剪贴板序列号,用于跳过自身写入产生的记录。
|
||||
pub fn start_monitor(
|
||||
storage: Arc<Storage>,
|
||||
app: AppHandle,
|
||||
settings: Arc<Mutex<super::manager::ClipboardSettings>>,
|
||||
suppress: Arc<AtomicBool>,
|
||||
suppress: Arc<Mutex<Option<u32>>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> thread::JoinHandle<()> {
|
||||
thread::spawn(move || loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(800));
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
@@ -40,13 +41,16 @@ pub fn start_monitor(
|
||||
if seq == last {
|
||||
continue;
|
||||
}
|
||||
// 序列号变化,处理一次
|
||||
if suppress.swap(false, Ordering::SeqCst) {
|
||||
// 由本应用 copy_back 触发,跳过记录
|
||||
continue;
|
||||
// 序列号变化:仅当变化来自本应用 copy_back(序列号精确匹配)时跳过,
|
||||
// 避免旧布尔标志在用户后续复制时被误吞。
|
||||
if let Ok(mut s) = suppress.lock() {
|
||||
if *s == Some(seq) {
|
||||
*s = None;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = {
|
||||
let s = settings.lock().unwrap();
|
||||
let s = settings.lock().unwrap_or_else(|e| e.into_inner());
|
||||
(
|
||||
s.record_text,
|
||||
s.record_image,
|
||||
@@ -85,7 +89,7 @@ pub fn start_monitor(
|
||||
};
|
||||
if let Some(_id) = storage.insert_or_touch(item, dedup) {
|
||||
storage.prune_to_max(max_items);
|
||||
let _ = app.emit("clipboard-changed", ());
|
||||
let _ = app.emit(crate::constants::events::CLIPBOARD_CHANGED, ());
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -99,6 +103,7 @@ fn build_text_item(t: &str) -> NewItem {
|
||||
kind: "text".into(),
|
||||
content: Some(t.to_string()),
|
||||
blob: None,
|
||||
thumb: None,
|
||||
preview: make_preview(t, 200),
|
||||
size: t.len() as i64,
|
||||
hash: hash_str(t),
|
||||
@@ -110,6 +115,7 @@ fn build_image_item(dib: &[u8], w: u32, h: u32) -> NewItem {
|
||||
kind: "image".into(),
|
||||
content: None,
|
||||
blob: Some(dib.to_vec()),
|
||||
thumb: super::reader::dib_to_thumbnail(dib, 256),
|
||||
preview: format!("图片 {}×{}", w, h),
|
||||
size: dib.len() as i64,
|
||||
hash: hash_bytes(dib),
|
||||
@@ -132,6 +138,7 @@ fn build_files_item(files: &[String]) -> NewItem {
|
||||
kind: "files".into(),
|
||||
content: Some(content),
|
||||
blob: None,
|
||||
thumb: None,
|
||||
preview,
|
||||
size: files.iter().map(|f| f.len()).sum::<usize>() as i64,
|
||||
hash,
|
||||
|
||||
+606
-136
@@ -9,64 +9,158 @@
|
||||
//! 6. 窗口失焦自动隐藏
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
|
||||
use tauri::window::{Effect, EffectsBuilder};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
|
||||
/// 弹窗窗口标签
|
||||
pub const POPUP_LABEL: &str = "clipboard-popup";
|
||||
|
||||
/// 当前注册的快捷键(用于注销旧快捷键)
|
||||
static CURRENT_SHORTCUT: Mutex<Option<String>> = Mutex::new(None);
|
||||
/// 预览窗口标签(悬停/键盘选中条目时在弹窗旁显示内容)
|
||||
pub const PREVIEW_LABEL: &str = "clipboard-preview";
|
||||
|
||||
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+V"、"Ctrl+Shift+V")
|
||||
/// 失败返回 None。
|
||||
pub fn parse_shortcut(s: &str) -> Option<Shortcut> {
|
||||
s.trim().parse::<Shortcut>().ok()
|
||||
/// 预览窗口初始逻辑尺寸(后续由前端按内容自适应调整,见 resize_preview)
|
||||
const PREVIEW_W: f64 = 340.0;
|
||||
const PREVIEW_H: f64 = 300.0;
|
||||
|
||||
/// 预览窗口最小逻辑尺寸(防止内容过小时窗口退化)
|
||||
const PREVIEW_MIN_W: f64 = 240.0;
|
||||
const PREVIEW_MIN_H: f64 = 160.0;
|
||||
|
||||
/// 弹窗与预览窗之间的可视间距(逻辑像素,左右对称)
|
||||
const PREVIEW_GAP: f64 = 12.0;
|
||||
|
||||
/// 标志: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);
|
||||
|
||||
/// 弹窗当前是否可见(同步单一事实来源)。
|
||||
/// show_preview 用它代替 popup.is_visible():后者在 hide 异步在途时可能返回过期 true,
|
||||
/// 导致"弹窗已隐藏但残留的悬停/键盘请求重新弹出孤立预览窗"。该标志在隐藏路径
|
||||
/// 同步置 false,任何在途请求都会直接拒绝。
|
||||
static POPUP_VISIBLE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 预览窗当前是否可见(同步标志,与 POPUP_VISIBLE 同理:预览窗以原生
|
||||
/// SW_SHOWNOACTIVATE 显示,Tauri 的 is_visible() 可能与其真实状态不同步,
|
||||
/// 故用该标志代替 is_visible 判断预览交互状态)。
|
||||
static PREVIEW_VISIBLE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 弹窗因预览交互(光标位于预览窗内)而推迟隐藏的标记:
|
||||
/// 点击预览窗(选择/复制文本、放大图片)时,弹窗会收到 Focused(false),但用户正在
|
||||
/// 预览窗内交互,此时不应隐藏弹窗。置该标记后保持弹窗显示,待用户离开预览窗
|
||||
/// (hide_preview)时再连同弹窗一起隐藏,避免"点击预览即关闭弹窗"导致无法交互。
|
||||
static POPUP_DEFER_HIDE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 失焦隐藏的宽限期:WebView2 透明窗口在 show 后激活期间焦点可能短暂弹跳
|
||||
/// (与快速面板同因),导致 Focused(false) 紧跟在 show 之后触发并立即隐藏刚显示的弹窗。
|
||||
const SHOW_GRACE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// 最近一次 show 的时间,用于失焦宽限期判断。
|
||||
static LAST_SHOWN: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
|
||||
/// 标记"已发起显示",并记录时间供失焦宽限期使用。
|
||||
fn mark_shown() {
|
||||
if let Ok(mut t) = LAST_SHOWN.lock() {
|
||||
*t = Some(Instant::now());
|
||||
}
|
||||
POPUP_VISIBLE.store(true, Ordering::SeqCst);
|
||||
// 重新显示弹窗时复位推迟隐藏标记(上一轮预览交互的遗留状态不应影响本轮)
|
||||
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 注册全局快捷键。重复调用会先注销旧快捷键。
|
||||
/// 传入空字符串则仅注销不注册。
|
||||
pub fn register_shortcut(app: &AppHandle, shortcut_str: &str) -> Result<(), String> {
|
||||
// 先注销旧快捷键
|
||||
unregister_shortcut(app);
|
||||
|
||||
if shortcut_str.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let shortcut = parse_shortcut(shortcut_str)
|
||||
.ok_or_else(|| format!("无效的快捷键: {}", shortcut_str))?;
|
||||
|
||||
let app_handle = app.clone();
|
||||
app.global_shortcut()
|
||||
.on_shortcut(shortcut, move |_app, _shortcut, event| {
|
||||
// 仅在按下时触发(松开不触发)
|
||||
if event.state == ShortcutState::Pressed {
|
||||
show_popup(&app_handle);
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("注册快捷键失败: {}", e))?;
|
||||
|
||||
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
|
||||
*cur = Some(shortcut_str.to_string());
|
||||
}
|
||||
eprintln!("[clipboard] 已注册快捷键: {}", shortcut_str);
|
||||
Ok(())
|
||||
/// 判断距上次 show 是否仍在宽限期内(是则忽略失焦自动隐藏)。
|
||||
fn within_show_grace() -> bool {
|
||||
LAST_SHOWN
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|t| *t)
|
||||
.map(|t| t.elapsed() < SHOW_GRACE)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 注销当前快捷键
|
||||
pub fn unregister_shortcut(app: &AppHandle) {
|
||||
if let Ok(cur) = CURRENT_SHORTCUT.lock() {
|
||||
if let Some(ref s) = *cur {
|
||||
if let Some(shortcut) = parse_shortcut(s) {
|
||||
let _ = app.global_shortcut().unregister(shortcut);
|
||||
}
|
||||
/// 创建弹窗窗口(隐藏状态)并注册失焦监听。
|
||||
/// 位置默认在屏幕外,show_popup 时会重新定位。
|
||||
/// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。
|
||||
fn create_popup_window(app: &AppHandle) {
|
||||
let win = match WebviewWindowBuilder::new(
|
||||
app,
|
||||
POPUP_LABEL,
|
||||
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 app_handle = app.clone();
|
||||
let win_handle = win.clone();
|
||||
win.on_window_event(move |event| {
|
||||
match event {
|
||||
tauri::WindowEvent::Focused(false) => {
|
||||
// 失焦宽限期:show 后激活期间的焦点弹跳不隐藏弹窗,避免弹窗刚显示就被隐藏
|
||||
if within_show_grace() {
|
||||
return;
|
||||
}
|
||||
// 交互锁定模式(点击过预览窗):弹窗与预览保持显示,仅由看护线程在
|
||||
// "点击外部"时关闭;此处的失焦是点击预览窗(NoActivate)所致,不隐藏。
|
||||
if POPUP_INTERACTED.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
// 鼠标当前位于预览窗或弹窗内(用户正在交互:选择/复制文本、放大图片、或
|
||||
// 已移回弹窗准备继续浏览):弹窗失焦是点击预览窗或焦点反弹所致,不应隐藏弹窗。
|
||||
// 置推迟标记保持弹窗显示,待用户离开整个区域(hide_preview / 再次失焦)再隐藏。
|
||||
if is_cursor_in_preview(&app_handle) || is_cursor_in_popup(&app_handle) {
|
||||
POPUP_DEFER_HIDE.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
// 先同步标记弹窗不可见,再隐藏窗口/预览:异步 hide 在途时,残留的
|
||||
// show_preview 请求会因该标志为 false 而拒绝,预览窗不会孤立残留。
|
||||
POPUP_VISIBLE.store(false, Ordering::SeqCst);
|
||||
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
|
||||
let _ = win_handle.hide();
|
||||
hide_preview(&app_handle);
|
||||
let _ = app_handle.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
|
||||
}
|
||||
tauri::WindowEvent::Focused(true) => {
|
||||
// 弹窗重新获得焦点(用户点击/移回弹窗):取消推迟隐藏状态
|
||||
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
crate::logger::log_info("clipboard", "弹窗窗口已预创建(隐藏状态)");
|
||||
}
|
||||
|
||||
/// 应用启动时预创建弹窗窗口(隐藏)。
|
||||
/// 这样首次按快捷键时窗口已存在,直接 show + 定位,避免首次创建时序问题。
|
||||
pub fn ensure_popup_window(app: &AppHandle) {
|
||||
if app.get_webview_window(POPUP_LABEL).is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
|
||||
*cur = None;
|
||||
}
|
||||
create_popup_window(app);
|
||||
}
|
||||
|
||||
/// 在鼠标当前位置显示弹窗。
|
||||
@@ -79,100 +173,85 @@ pub fn show_popup(app: &AppHandle) {
|
||||
None => return,
|
||||
};
|
||||
|
||||
// 窗口尺寸(逻辑像素)
|
||||
// 窗口尺寸(物理像素,由光标所在屏 DPI 换算)
|
||||
let w = 380.0_f64;
|
||||
let h = 460.0_f64;
|
||||
|
||||
// 获取屏幕工作区(物理像素,Per-Monitor DPI V2)
|
||||
let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0));
|
||||
// 获取光标所在显示器的工作区(物理像素,与 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));
|
||||
|
||||
// 获取 scale factor,将窗口逻辑尺寸换算为物理像素用于边界裁剪
|
||||
let scale = app
|
||||
.get_webview_window(POPUP_LABEL)
|
||||
.as_ref()
|
||||
.and_then(|w| w.scale_factor().ok())
|
||||
.or_else(|| {
|
||||
app.get_webview_window("main")
|
||||
.and_then(|w| w.scale_factor().ok())
|
||||
})
|
||||
.unwrap_or(1.0);
|
||||
// 光标所在显示器的 DPI:窗口尺寸需按物理像素放大
|
||||
let dpi = get_dpi_for_point(mx, my).unwrap_or(96);
|
||||
let scale = dpi as f64 / 96.0;
|
||||
let w_px = w * scale;
|
||||
let h_px = h * scale;
|
||||
|
||||
let w_phys = (w * scale) as i32;
|
||||
let h_phys = (h * scale) as i32;
|
||||
|
||||
// 物理坐标 clamping
|
||||
let x = mx.max(0).min(screen_w as i32 - w_phys);
|
||||
let y = my.max(0).min(screen_h as i32 - h_phys);
|
||||
// 直接以物理坐标 clamping(光标位置 + 工作区均为物理像素,避免混合 DPI 下
|
||||
// 手动"物理→逻辑"换算后 Tauri 再按窗口所在屏解释导致的定位偏移)
|
||||
let x = (mx as f64).max(wa_left as f64).min(wa_right as f64 - w_px);
|
||||
let y = (my as f64).max(wa_top as f64).min(wa_bottom as f64 - h_px);
|
||||
|
||||
// 窗口已存在:移动 + 显示 + 请求焦点
|
||||
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
|
||||
x,
|
||||
y,
|
||||
x: x as i32,
|
||||
y: y as i32,
|
||||
}));
|
||||
let _ = win.show();
|
||||
mark_shown();
|
||||
let _ = win.set_focus();
|
||||
// 通知前端刷新数据
|
||||
let _ = app.emit("clipboard-popup-show", ());
|
||||
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
|
||||
return;
|
||||
}
|
||||
|
||||
// 首次创建窗口:先隐藏,等前端挂载完成后再显示
|
||||
// 这样确保主题效果(dark class、mica)在窗口可见前已应用
|
||||
let win = match WebviewWindowBuilder::new(
|
||||
app,
|
||||
POPUP_LABEL,
|
||||
WebviewUrl::App("index.html#clipboard-popup".into()),
|
||||
)
|
||||
.title("剪贴板")
|
||||
.inner_size(w, h)
|
||||
.position(x 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;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听窗口失焦:自动隐藏
|
||||
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 回调 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,
|
||||
}));
|
||||
}
|
||||
let _ = win.show();
|
||||
mark_shown();
|
||||
let _ = win.set_focus();
|
||||
// 通知前端刷新数据
|
||||
let _ = app.emit("clipboard-popup-show", ());
|
||||
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏弹窗(不销毁,保留复用)
|
||||
/// 隐藏弹窗(不销毁,保留复用),并通知前端清理悬停定时器/预览。
|
||||
pub fn hide_popup(app: &AppHandle) {
|
||||
// 复位推迟隐藏标记(先于 hide_preview,避免 hide_preview 重入 hide_popup 形成递归)
|
||||
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
|
||||
// 复位交互锁定:弹窗关闭后下次呼出恢复"失焦即隐藏"的常规模式
|
||||
POPUP_INTERACTED.store(false, Ordering::SeqCst);
|
||||
// 同步标记弹窗不可见,拒绝此后在途的 show_preview 请求
|
||||
POPUP_VISIBLE.store(false, Ordering::SeqCst);
|
||||
hide_preview(app);
|
||||
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||
let _ = win.hide();
|
||||
}
|
||||
// 通知前端:取消悬停定时器并隐藏预览,避免弹窗隐藏后残留定时器重新弹出预览窗
|
||||
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
|
||||
}
|
||||
|
||||
/// 隐藏弹窗后延迟模拟 Ctrl+V 粘贴到之前聚焦的窗口。
|
||||
@@ -226,43 +305,434 @@ fn simulate_paste() {
|
||||
// 非 Windows 平台暂不支持自动粘贴
|
||||
}
|
||||
|
||||
// ===== Win32 API:获取鼠标位置和工作区 =====
|
||||
// ===== 独立预览窗口 =====
|
||||
|
||||
#[cfg(windows)]
|
||||
mod win_api {
|
||||
use windows_sys::Win32::Foundation::POINT;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{GetCursorPos, SystemParametersInfoW, SPI_GETWORKAREA};
|
||||
/// 创建预览窗口(隐藏状态),应用 NoActivate 样式避免抢焦点。
|
||||
fn create_preview_window(app: &AppHandle) {
|
||||
let win = match WebviewWindowBuilder::new(
|
||||
app,
|
||||
PREVIEW_LABEL,
|
||||
WebviewUrl::App("index.html#clipboard-preview".into()),
|
||||
)
|
||||
.title("预览")
|
||||
.inner_size(PREVIEW_W, PREVIEW_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) // 不抢占焦点
|
||||
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
|
||||
.build()
|
||||
{
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
crate::logger::log_error("clipboard", &format!("创建预览窗失败: {}", e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/// 获取鼠标位置(屏幕坐标,逻辑像素)
|
||||
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
|
||||
// NoActivate:预览窗可交互但不激活,弹窗保持前台(键盘导航/粘贴不受影响)
|
||||
// 圆角:NoActivate 悬浮窗系统不自动圆角,显式指定 DWMWCP_ROUND 与弹窗外观统一
|
||||
#[cfg(windows)]
|
||||
if let Ok(hwnd) = win.hwnd() {
|
||||
crate::win32_util::apply_no_activate(hwnd.0 as isize);
|
||||
crate::win32_util::apply_rounded_corners(hwnd.0 as isize);
|
||||
}
|
||||
|
||||
crate::logger::log_info("clipboard", "预览窗口已预创建(隐藏状态)");
|
||||
}
|
||||
|
||||
/// 应用启动时预创建预览窗口(隐藏),随弹窗一起就绪。
|
||||
pub fn ensure_preview_window(app: &AppHandle) {
|
||||
if app.get_webview_window(PREVIEW_LABEL).is_some() {
|
||||
return;
|
||||
}
|
||||
create_preview_window(app);
|
||||
}
|
||||
|
||||
/// 预览当前所在侧(default=右,left=左),供放大/还原重定位沿用同侧。
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum PreviewSide {
|
||||
Right,
|
||||
Left,
|
||||
}
|
||||
|
||||
static PREVIEW_SIDE: Mutex<Option<PreviewSide>> = Mutex::new(None);
|
||||
|
||||
/// 最近一次显示预览时的鼠标 Y 锚点(物理像素)。预览窗垂直中线对齐该位置
|
||||
/// (贴近鼠标所悬停的条目),resize(切换条目/放大还原)时沿用,保持中线稳定不跳变。
|
||||
static PREVIEW_ANCHOR_Y: Mutex<Option<i32>> = Mutex::new(None);
|
||||
|
||||
/// 弹窗离开看护线程是否已在运行(防重入,见 spawn_popup_leave_watch)。
|
||||
static POPUP_LEAVE_WATCH: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 交互锁定模式:用户点击过预览窗(放大/缩小、复制文本等)后置位。
|
||||
/// 该模式下弹窗与预览不因失焦/鼠标离开而关闭,仅当点击弹窗+预览之外的
|
||||
/// 区域(看护线程检测鼠标按下沿)或弹窗重新聚焦(恢复正常模式)时才复位。
|
||||
static POPUP_INTERACTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 重新定位预览窗口(物理像素)。preferred 指定优先放置侧,返回实际落位侧。
|
||||
/// anchor_y 为鼠标 Y 锚点:预览窗垂直中线对齐该位置(贴近悬停条目),
|
||||
/// 上下 clamp 到工作区;None 时退回弹窗顶部对齐。
|
||||
///
|
||||
/// 无边框+阴影窗口(tao 实现)的外框矩形在客户区四周各留一条不可见边框
|
||||
/// (SM_CXSIZEFRAME+SM_CXPADDEDBORDER,典型 8px):outer_position/outer_size 均含该边框,
|
||||
/// 而可视内容仅占客户区。若直接用外框尺寸计算右侧落位,右侧可视间距会比左侧多出
|
||||
/// 两侧边框之和(约 16px)。这里以"可视边缘"对齐:frame = (外框宽 - 客户区宽) / 2,
|
||||
/// 左右两侧使用同一 GAP,保证两侧可视间距一致。
|
||||
fn reposition_preview(
|
||||
popup: &tauri::WebviewWindow,
|
||||
win: &tauri::WebviewWindow,
|
||||
vw: i32,
|
||||
vh: i32,
|
||||
preferred: PreviewSide,
|
||||
anchor_y: Option<i32>,
|
||||
allow_flip: bool,
|
||||
) -> PreviewSide {
|
||||
// 弹窗当前物理位置与尺寸(外框,含不可见边框)
|
||||
let Ok(pos) = popup.outer_position() else { return preferred };
|
||||
let Ok(osz) = popup.outer_size() else { return preferred };
|
||||
let Ok(isz) = popup.inner_size() else { return preferred };
|
||||
let (px, py) = (pos.x, pos.y);
|
||||
let pw = osz.width as i32;
|
||||
// 不可见边框宽度(外框与客户区之差的一半;无阴影窗口为 0)
|
||||
let frame = ((osz.width.saturating_sub(isz.width)) / 2) as i32;
|
||||
|
||||
// 弹窗所在显示器工作区
|
||||
let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(px, py)
|
||||
.unwrap_or((0, 0, 1920, 1040));
|
||||
|
||||
let gap = (PREVIEW_GAP * popup.scale_factor().unwrap_or(1.0)).round() as i32;
|
||||
|
||||
// 可视边缘对齐:
|
||||
// 右侧落位 = 弹窗可视右缘(px+pw-frame) + GAP - 预览窗自身左边框(frame)
|
||||
// 左侧落位 = 弹窗可视左缘(px+frame) - GAP - 预览可视宽(vw) - 预览窗左边框(frame)
|
||||
// (frame 两两抵消,与旧公式一致)
|
||||
let right_x = px + pw - 2 * frame + gap;
|
||||
let left_x = px - gap - vw;
|
||||
// 可视区域(含预览窗自身边框偏移)不越工作区
|
||||
let fits_right = right_x + frame + vw <= wa_right;
|
||||
let fits_left = left_x + frame >= wa_left;
|
||||
let mut x = match preferred {
|
||||
PreviewSide::Right => right_x,
|
||||
PreviewSide::Left => left_x,
|
||||
};
|
||||
// 仅初始落位(allow_flip=true)允许换侧:优先侧放不下时切到另一侧。
|
||||
// 内容尺寸变化(放大/还原,allow_flip=false)不允许换侧:窗口跳到另一侧会使
|
||||
// 鼠标瞬间落在窗外,误触发 mouseleave 而关闭预览/弹窗;此时保持原侧并靠
|
||||
// 工作区 clamp(大图可能覆盖弹窗边缘,属合理取舍,鼠标仍在预览窗内可交互)。
|
||||
if allow_flip {
|
||||
match preferred {
|
||||
PreviewSide::Right => {
|
||||
if !fits_right {
|
||||
x = left_x;
|
||||
}
|
||||
}
|
||||
PreviewSide::Left => {
|
||||
if !fits_left {
|
||||
x = right_x;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
x = (x + frame).max(wa_left).min(wa_right - vw) - frame;
|
||||
// 仅初始落位(allow_flip)按最终位置更新所在侧(换侧时位置在弹窗另一侧,判定正确);
|
||||
// 放大/还原(不允许换侧)时即使被工作区 clamp 覆盖到弹窗上方,语义上仍属原侧,
|
||||
// 必须沿用 preferred——否则 clamp 后按位置误判为另一侧并存档,缩小后会跳侧。
|
||||
let side = if allow_flip {
|
||||
if x + vw <= px { PreviewSide::Left } else { PreviewSide::Right }
|
||||
} else {
|
||||
preferred
|
||||
};
|
||||
// 垂直定位:预览窗中线对齐鼠标 Y 锚点(贴近悬停条目),上下 clamp 到工作区;
|
||||
// 无锚点时退回弹窗顶部对齐
|
||||
let y = match anchor_y {
|
||||
Some(ay) => (ay - vh / 2).max(wa_top).min(wa_bottom - vh),
|
||||
None => py.max(wa_top).min(wa_bottom - vh),
|
||||
};
|
||||
|
||||
/// 获取屏幕工作区(排除任务栏,逻辑像素)
|
||||
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
|
||||
}
|
||||
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
|
||||
if let Ok(mut s) = PREVIEW_SIDE.lock() {
|
||||
*s = Some(side);
|
||||
}
|
||||
side
|
||||
}
|
||||
|
||||
/// 通知预览前端加载指定条目。窗口此时保持隐藏:前端测得内容尺寸后先调用
|
||||
/// resize_preview(隐藏状态下完成尺寸/位置调整)再 reveal_preview 显示,
|
||||
/// 避免"先以上次尺寸(可能是放大态大窗)显示再缩回"的闪烁。
|
||||
pub fn show_preview(app: &AppHandle, id: i64) {
|
||||
// 弹窗已隐藏(或隐藏中)时拒绝显示预览:同步标志消除 is_visible() 的异步时序竞态,
|
||||
// 保证"弹窗失焦消失后预览窗不会孤立残留"。
|
||||
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
if app.get_webview_window(PREVIEW_LABEL).is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(popup) = app.get_webview_window(POPUP_LABEL) else {
|
||||
return;
|
||||
};
|
||||
// 弹窗不可见时不显示预览:避免弹窗隐藏后仍在途的悬停请求重新弹出孤立的预览窗
|
||||
if !popup.is_visible().unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录鼠标 Y 锚点:预览窗中线对齐鼠标所悬停的条目位置;resize 时沿用
|
||||
if let Some((_, y)) = get_cursor_pos() {
|
||||
if let Ok(mut a) = PREVIEW_ANCHOR_Y.lock() {
|
||||
*a = Some(y);
|
||||
}
|
||||
}
|
||||
|
||||
// 通知前端加载条目内容(前端完成后自行 resize + reveal)
|
||||
let _ = app.emit(crate::constants::events::CLIPBOARD_PREVIEW_SHOW, id);
|
||||
}
|
||||
|
||||
/// 显示已就绪的预览窗口(前端完成内容加载与 resize 后调用)。
|
||||
/// 显示前重查弹窗可见性,消除内容加载期间弹窗已隐藏的竞态。
|
||||
pub fn reveal_preview(app: &AppHandle) {
|
||||
// 弹窗已隐藏:在途请求作废
|
||||
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
// 已可见则跳过(放大/还原触发的重复 reveal)
|
||||
if PREVIEW_VISIBLE.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 同步标记预览窗可见(以原生方式显示,Tauri 的 is_visible 可能不同步)
|
||||
PREVIEW_VISIBLE.store(true, Ordering::SeqCst);
|
||||
// 不激活显示(SW_SHOWNOACTIVATE),弹窗保持前台
|
||||
#[cfg(windows)]
|
||||
if let Ok(hwnd) = win.hwnd() {
|
||||
crate::win32_util::show_no_activate(hwnd.0 as isize);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = win.show();
|
||||
|
||||
// 显示期间弹窗可能已被隐藏(失焦/关闭):立即收回预览。
|
||||
// 关闭初次检查与真正 show 之间的竞态——失焦处理器先置 POPUP_VISIBLE=false
|
||||
// 再隐藏,这里 show 之后重查一次,若已被隐藏则收回预览,避免孤立残留。
|
||||
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||
PREVIEW_VISIBLE.store(false, Ordering::SeqCst);
|
||||
let _ = win.hide();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod win_api {
|
||||
pub fn get_cursor_pos() -> Option<(i32, i32)> { None }
|
||||
pub fn get_work_area() -> Option<(f64, f64)> { None }
|
||||
/// 按内容自适应调整预览窗大小(逻辑像素),位置沿用当前所在侧并重新对齐弹窗。
|
||||
/// 前端加载/切换内容(文本测高、图片按宽高比)后调用,窗口贴合内容消除留白。
|
||||
/// allow_flip:初始落位为 true(优先侧放不下可换侧);放大/还原为 false
|
||||
/// (保持原侧靠工作区 clamp,避免窗口跳侧使鼠标落在窗外误触发隐藏)。
|
||||
pub fn resize_preview(app: &AppHandle, width: f64, height: f64, allow_flip: bool) {
|
||||
// 弹窗不可见时忽略(无对齐基准)
|
||||
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
|
||||
return;
|
||||
};
|
||||
let Some(popup) = app.get_webview_window(POPUP_LABEL) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let scale = popup.scale_factor().unwrap_or(1.0);
|
||||
// 以弹窗所在屏工作区为上限钳制(逻辑像素),并保证不小于最小可视尺寸
|
||||
let (wa_left, wa_top, wa_right, wa_bottom) = popup
|
||||
.outer_position()
|
||||
.ok()
|
||||
.and_then(|p| get_work_area_at_point(p.x, p.y))
|
||||
.unwrap_or((0, 0, 1920, 1040));
|
||||
let max_w = ((wa_right - wa_left).max(0) as f64 / scale).floor().max(PREVIEW_MIN_W);
|
||||
let max_h = ((wa_bottom - wa_top).max(0) as f64 / scale).floor().max(PREVIEW_MIN_H);
|
||||
let w = width.clamp(PREVIEW_MIN_W, max_w);
|
||||
let h = height.clamp(PREVIEW_MIN_H, max_h);
|
||||
let vw = (w * scale).round() as i32;
|
||||
let vh = (h * scale).round() as i32;
|
||||
|
||||
let side = PREVIEW_SIDE.lock().ok().and_then(|s| *s).unwrap_or(PreviewSide::Right);
|
||||
// 沿用最近一次的鼠标 Y 锚点:高度变化时中线保持对齐悬停条目,不产生跳变
|
||||
let anchor_y = PREVIEW_ANCHOR_Y.lock().ok().and_then(|a| *a);
|
||||
reposition_preview(&popup, &win, vw, vh, side, anchor_y, allow_flip);
|
||||
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize { width: vw as u32, height: vh as u32 }));
|
||||
}
|
||||
|
||||
pub use win_api::{get_cursor_pos, get_work_area};
|
||||
/// 交互锁定模式入口:前端预览窗收到 mousedown(放大/缩小、复制、选择文本)时调用。
|
||||
/// 置位锁定标志并启动看护线程:此后弹窗+预览不因失焦/鼠标离开而关闭,
|
||||
/// 仅当点击弹窗+预览之外的区域或弹窗重新聚焦时退出锁定。
|
||||
pub fn preview_interacted(app: &AppHandle) {
|
||||
POPUP_INTERACTED.store(true, Ordering::SeqCst);
|
||||
// 交互锁定取代推迟隐藏模式:清除可能残留的 DEFER 标记,
|
||||
// 避免后续取消钉住时 hide_preview 走 DEFER 分支误关弹窗
|
||||
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
|
||||
spawn_popup_leave_watch(app.clone());
|
||||
}
|
||||
|
||||
/// 隐藏预览窗口(保留复用),并通知前端清空内容。
|
||||
pub fn hide_preview(app: &AppHandle) {
|
||||
// 交互锁定模式:弹窗仍显示时预览保持(生命周期由看护线程管理,点击外部才
|
||||
// 随弹窗一起关闭),忽略常规隐藏请求(条目离开、键盘切换等)。
|
||||
if POPUP_INTERACTED.load(Ordering::SeqCst) && POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
POPUP_INTERACTED.store(false, Ordering::SeqCst);
|
||||
// 若此前因预览交互推迟了弹窗隐藏(弹窗已失焦但保持显示),现在用户离开预览窗,
|
||||
// 需判断去向:光标已回到弹窗内(继续浏览弹窗)→ 仅隐藏预览、保持弹窗显示;
|
||||
// 光标在弹窗之外(离开整个区域)→ 连同弹窗一起隐藏。
|
||||
// (hide_popup 会先复位该标记,故此处 swap(false) 后调用不会递归重入)
|
||||
if POPUP_DEFER_HIDE.swap(false, Ordering::SeqCst) {
|
||||
if !is_cursor_in_popup(app) {
|
||||
hide_popup(app);
|
||||
return;
|
||||
}
|
||||
// 光标已回到弹窗内。若弹窗仍持有焦点(曾点击弹窗重新激活),仅隐藏预览即可,
|
||||
// 后续失焦仍走 Focused(false) 正常关闭;若弹窗已失焦(点击预览窗所致),
|
||||
// 它不会再收到 Focused(false) 事件——光标一旦离开弹窗区域,弹窗将永远
|
||||
// 无法关闭(无焦点残留窗口)。启动看护线程兜底关闭。
|
||||
let focused = app
|
||||
.get_webview_window(POPUP_LABEL)
|
||||
.map(|w| w.is_focused().unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if !focused {
|
||||
spawn_popup_leave_watch(app.clone());
|
||||
}
|
||||
}
|
||||
PREVIEW_VISIBLE.store(false, Ordering::SeqCst);
|
||||
if let Some(win) = app.get_webview_window(PREVIEW_LABEL) {
|
||||
let _ = win.hide();
|
||||
// 预览窗以原生 SW_SHOWNOACTIVATE 方式显示,Tauri 内部可见性状态可能与其
|
||||
// 不同步;补一次原生 SW_HIDE,确保任何路径下都被可靠隐藏。
|
||||
#[cfg(windows)]
|
||||
if let Ok(hwnd) = win.hwnd() {
|
||||
crate::win32_util::hide_window(hwnd.0 as isize);
|
||||
}
|
||||
}
|
||||
let _ = app.emit(crate::constants::events::CLIPBOARD_PREVIEW_HIDE, ());
|
||||
}
|
||||
|
||||
/// 弹窗失焦但保持显示时的看护线程(防重入单例)。
|
||||
///
|
||||
/// 两种工作模式(每轮动态读取 POPUP_INTERACTED,可中途切换):
|
||||
/// - 常规模式(未点击过预览窗):光标连续 ~300ms 既不在弹窗也不在预览窗内
|
||||
/// (连续计数防"穿越弹窗-预览间隙"时的瞬时离开误判)→ 关闭弹窗;
|
||||
/// - 交互锁定模式(点击过预览窗的放大/缩小/复制等):弹窗已失焦不会再收到
|
||||
/// Focused(false),检测"鼠标按下沿且按下位置在弹窗+预览之外"→ 点击外部,关闭弹窗。
|
||||
///
|
||||
/// 公共退出条件:弹窗已隐藏/销毁(其他路径关闭);弹窗重新获得焦点(用户点击
|
||||
/// 弹窗,恢复正常失焦关闭路径,并复位交互锁定);超时兜底防线程泄漏
|
||||
/// (常规 30s;交互锁定为事件驱动,放宽至 10min)。
|
||||
fn spawn_popup_leave_watch(app: AppHandle) {
|
||||
// 防重入:已有看护在运行则跳过
|
||||
if POPUP_LEAVE_WATCH.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
let start = Instant::now();
|
||||
let mut outside = 0u32;
|
||||
let mut was_down = false;
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
// 弹窗已隐藏或被销毁:看护结束
|
||||
if !POPUP_VISIBLE.load(Ordering::SeqCst)
|
||||
|| app.get_webview_window(POPUP_LABEL).is_none()
|
||||
{
|
||||
break;
|
||||
}
|
||||
// 弹窗重新获得焦点(用户点击弹窗):恢复正常失焦关闭路径,复位交互锁定
|
||||
if app
|
||||
.get_webview_window(POPUP_LABEL)
|
||||
.map(|w| w.is_focused().unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
POPUP_INTERACTED.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
let interacted = POPUP_INTERACTED.load(Ordering::SeqCst);
|
||||
// 超长兜底:避免异常路径下看护线程无限轮询
|
||||
let timeout = if interacted {
|
||||
Duration::from_secs(600)
|
||||
} else {
|
||||
Duration::from_secs(30)
|
||||
};
|
||||
if start.elapsed() > timeout {
|
||||
hide_popup(&app);
|
||||
break;
|
||||
}
|
||||
let cursor_in = is_cursor_in_popup(&app) || is_cursor_in_preview(&app);
|
||||
if interacted {
|
||||
// 交互锁定:左键按下沿发生在弹窗+预览之外 → 点击外部,关闭弹窗+预览。
|
||||
// 按下沿判定(上一轮未按下、本轮按下)配合 30ms 轮询,可捕获常规点击;
|
||||
// 在弹窗/预览内按下后拖出再松开不算外部点击(以按下位置为准)。
|
||||
let down = crate::win32_util::is_left_button_down();
|
||||
if down && !was_down && !cursor_in {
|
||||
hide_popup(&app);
|
||||
break;
|
||||
}
|
||||
was_down = down;
|
||||
} else if cursor_in {
|
||||
outside = 0;
|
||||
} else {
|
||||
outside += 1;
|
||||
// 连续 ~300ms 不在弹窗/预览区域(排除穿越间隙的瞬时状态)才关闭
|
||||
if outside >= 10 {
|
||||
hide_popup(&app);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
POPUP_LEAVE_WATCH.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// 鼠标当前是否位于弹窗矩形内(弹窗可见且光标在其窗口范围内)。
|
||||
/// 用于 hide_preview 判断"用户是回到弹窗继续浏览,还是离开整个区域"。
|
||||
fn is_cursor_in_popup(app: &AppHandle) -> bool {
|
||||
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
let Some(win) = app.get_webview_window(POPUP_LABEL) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(pos) = win.outer_position() else { return false };
|
||||
let Ok(size) = win.outer_size() else { return false };
|
||||
let Some((cx, cy)) = crate::win32_util::get_cursor_pos() else {
|
||||
return false;
|
||||
};
|
||||
let x = pos.x;
|
||||
let y = pos.y;
|
||||
let w = size.width as i32;
|
||||
let h = size.height as i32;
|
||||
cx >= x && cx <= x + w && cy >= y && cy <= y + h
|
||||
}
|
||||
|
||||
/// 鼠标当前是否位于预览窗矩形内(预览窗可见且光标在其窗口范围内)。
|
||||
/// 用于弹窗失焦时判断是否因预览交互所致,决定是否推迟隐藏弹窗。
|
||||
fn is_cursor_in_preview(app: &AppHandle) -> bool {
|
||||
if !PREVIEW_VISIBLE.load(Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(pos) = win.outer_position() else { return false };
|
||||
let Ok(size) = win.outer_size() else { return false };
|
||||
let Some((cx, cy)) = crate::win32_util::get_cursor_pos() else {
|
||||
return false;
|
||||
};
|
||||
let x = pos.x;
|
||||
let y = pos.y;
|
||||
let w = size.width as i32;
|
||||
let h = size.height as i32;
|
||||
cx >= x && cx <= x + w && cy >= y && cy <= y + h
|
||||
}
|
||||
|
||||
// ===== 屏幕/光标/DPI 工具已迁移至 crate::win32_util(跨模块共享) =====
|
||||
|
||||
use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
|
||||
|
||||
@@ -232,10 +232,19 @@ pub fn dib_info(dib: &[u8]) -> Option<(u32, 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>> {
|
||||
use image::codecs::png::PngEncoder;
|
||||
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 {
|
||||
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 bpp = u16::from_le_bytes([dib[14], dib[15]]);
|
||||
let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]);
|
||||
if width <= 0 || compression != 0 {
|
||||
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;
|
||||
}
|
||||
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 + 1] = dib[sp + 1]; // G
|
||||
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();
|
||||
@@ -278,3 +295,29 @@ pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
|
||||
.ok()?;
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// 生成缩略图 PNG(最长边不超过 max_dim 像素)。
|
||||
/// 小图直接复用 `dib_to_png` 结果;大图降采样后重新编码,供弹窗悬停预览使用。
|
||||
pub fn dib_to_thumbnail(dib: &[u8], max_dim: u32) -> Option<Vec<u8>> {
|
||||
use image::codecs::png::PngEncoder;
|
||||
use image::GenericImageView;
|
||||
use image::ImageEncoder;
|
||||
|
||||
let png = dib_to_png(dib)?;
|
||||
let img = image::load_from_memory(&png).ok()?;
|
||||
let (w, h) = img.dimensions();
|
||||
if w.max(h) <= max_dim {
|
||||
return Some(png); // 小图直接使用,避免重复编码
|
||||
}
|
||||
// 让 image 库在 max_dim×max_dim 边界内自动等比缩放,避免手算 nw/nh 与库内部
|
||||
// resize_dimensions 的取整不一致(如手算 256x10 而库实际输出 250x10),
|
||||
// 否则 write_image 的缓冲区长度断言会失败。
|
||||
let resized = img.resize(max_dim, max_dim, image::imageops::FilterType::Triangle);
|
||||
let (rw, rh) = resized.dimensions();
|
||||
let rgba = resized.to_rgba8();
|
||||
let mut buf = Vec::new();
|
||||
let enc = PngEncoder::new(&mut buf);
|
||||
enc.write_image(rgba.as_raw(), rw, rh, image::ExtendedColorType::Rgba8)
|
||||
.ok()?;
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
//!
|
||||
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
|
||||
|
||||
use base64::Engine as _;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use specta::Type;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// 列表项(不含大字段,用于历史/搜索结果)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[derive(Debug, Clone, serde::Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClipboardItem {
|
||||
pub id: i64,
|
||||
@@ -21,7 +23,7 @@ pub struct ClipboardItem {
|
||||
}
|
||||
|
||||
/// 详情(含文本内容或图片 base64)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[derive(Debug, Clone, serde::Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClipboardItemDetail {
|
||||
#[serde(flatten)]
|
||||
@@ -37,6 +39,8 @@ pub struct NewItem {
|
||||
pub kind: String,
|
||||
pub content: Option<String>,
|
||||
pub blob: Option<Vec<u8>>,
|
||||
/// 图片缩略图 PNG(仅 image 类型,供弹窗悬停预览)
|
||||
pub thumb: Option<Vec<u8>>,
|
||||
pub preview: String,
|
||||
pub size: i64,
|
||||
pub hash: String,
|
||||
@@ -87,6 +91,19 @@ impl Storage {
|
||||
CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_history(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_history(kind);",
|
||||
);
|
||||
// 迁移:老库无 thumb 列(图片缩略图,供弹窗悬停预览),补列
|
||||
let has_thumb = conn
|
||||
.prepare("PRAGMA table_info(clipboard_history)")
|
||||
.ok()
|
||||
.map(|mut stmt| {
|
||||
stmt.query_map([], |r| r.get::<_, String>(1))
|
||||
.map(|rows| rows.filter_map(|c| c.ok()).any(|c| c == "thumb"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !has_thumb {
|
||||
let _ = conn.execute("ALTER TABLE clipboard_history ADD COLUMN thumb BLOB", []);
|
||||
}
|
||||
}
|
||||
|
||||
/// 插入新条目;若 dedup 为 true 且 hash 已存在则仅更新 created_at,返回条目 id。
|
||||
@@ -119,12 +136,13 @@ impl Storage {
|
||||
let now = now_ms();
|
||||
let res = conn.execute(
|
||||
"INSERT INTO clipboard_history
|
||||
(kind, content, blob, preview, size, hash, pinned, pinned_order, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)",
|
||||
(kind, content, blob, thumb, preview, size, hash, pinned, pinned_order, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, NULL, ?8)",
|
||||
params![
|
||||
item.kind,
|
||||
item.content,
|
||||
item.blob.as_deref(),
|
||||
item.thumb.as_deref(),
|
||||
item.preview,
|
||||
item.size,
|
||||
item.hash,
|
||||
@@ -206,10 +224,11 @@ impl Storage {
|
||||
}
|
||||
|
||||
/// 获取详情(含文本/图片预览 base64)
|
||||
/// 先锁内查询数据,释放锁后再执行耗时编码(DIB→PNG + base64),避免长时间占用连接锁
|
||||
pub fn get_detail(&self, id: i64, image_to_base64: impl Fn(&[u8]) -> Option<String>) -> Option<ClipboardItemDetail> {
|
||||
let conn = self.conn.lock().ok()?;
|
||||
let row = conn
|
||||
.query_row(
|
||||
let (item, content, blob) = {
|
||||
let conn = self.conn.lock().ok()?;
|
||||
conn.query_row(
|
||||
"SELECT id, kind, preview, size, pinned, pinned_order, created_at, content, blob
|
||||
FROM clipboard_history WHERE id = ?1",
|
||||
params![id],
|
||||
@@ -232,8 +251,9 @@ impl Storage {
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok()?;
|
||||
let (item, content, blob) = row;
|
||||
.ok()?
|
||||
};
|
||||
// conn 已在此处释放,以下编码不占用连接锁
|
||||
let image_base64 = if item.kind == "image" {
|
||||
blob.as_deref().and_then(|b| image_to_base64(b))
|
||||
} else {
|
||||
@@ -246,6 +266,26 @@ impl Storage {
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取图片缩略图 PNG base64(弹窗悬停预览用)。
|
||||
/// 老数据无缩略图时回退为 `thumb_from_dib` 现场生成。
|
||||
pub fn get_thumb(&self, id: i64, thumb_from_dib: impl Fn(&[u8]) -> Option<Vec<u8>>) -> Option<String> {
|
||||
let (thumb, blob) = {
|
||||
let conn = self.conn.lock().ok()?;
|
||||
conn.query_row(
|
||||
"SELECT thumb, blob FROM clipboard_history WHERE id = ?1 AND kind = 'image'",
|
||||
params![id],
|
||||
|r| Ok((r.get::<_, Option<Vec<u8>>>(0)?, r.get::<_, Option<Vec<u8>>>(1)?)),
|
||||
)
|
||||
.ok()?
|
||||
};
|
||||
// conn 已在此处释放
|
||||
let png = match thumb {
|
||||
Some(t) if !t.is_empty() => Some(t),
|
||||
_ => blob.as_deref().and_then(|b| thumb_from_dib(b)),
|
||||
};
|
||||
png.map(|p| base64::engine::general_purpose::STANDARD.encode(&p))
|
||||
}
|
||||
|
||||
/// 获取原始字段供 copy_back 写回(避免 base64 转换开销)
|
||||
pub fn get_raw_for_copy(&self, id: i64) -> Option<(String, Option<String>, Option<Vec<u8>>)> {
|
||||
let conn = self.conn.lock().ok()?;
|
||||
@@ -342,6 +382,7 @@ impl Storage {
|
||||
}
|
||||
|
||||
/// 按搜索关键词统计匹配的非固定条目总数
|
||||
/// 与 `search` 保持一致的匹配字段(preview + content),避免分页总数错误
|
||||
pub fn count_search(&self, query: &str) -> i64 {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
@@ -349,7 +390,8 @@ impl Storage {
|
||||
};
|
||||
let pattern = format!("%{}%", query);
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND preview LIKE ?1",
|
||||
"SELECT COUNT(*) FROM clipboard_history
|
||||
WHERE pinned = 0 AND (preview LIKE ?1 OR content LIKE ?1)",
|
||||
params![pattern],
|
||||
|r| r.get(0),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! 全局常量集中定义。
|
||||
//! 窗口 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";
|
||||
#[allow(dead_code)]
|
||||
pub const SCREENSHOT_SCROLL: &str = "screenshot-scroll";
|
||||
}
|
||||
|
||||
/// 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 CLIPBOARD_PREVIEW_SHOW: &str = "clipboard-preview-show";
|
||||
pub const CLIPBOARD_PREVIEW_HIDE: &str = "clipboard-preview-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 QUICKPANEL_INDEX_UPDATED: &str = "quickpanel-index-updated";
|
||||
// 监控
|
||||
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_GAME_ACTIVE: &str = "osd-game-active";
|
||||
pub const OSD_GAME_INACTIVE: &str = "osd-game-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 SCREENSHOT_EXPORTED: &str = "screenshot-exported";
|
||||
/// 滚动截图会话:实时进度 { width, height, auto }
|
||||
pub const SCROLL_PROGRESS: &str = "screenshot-scroll-progress";
|
||||
/// 滚动截图会话:完成并导出(负载与 SCREENSHOT_EXPORTED 相同)
|
||||
pub const SCROLL_COMPLETE: &str = "screenshot-scroll-complete";
|
||||
/// 滚动截图会话:已取消(无负载)
|
||||
pub const SCROLL_CANCELLED: &str = "screenshot-scroll-cancelled";
|
||||
// 内核安装进度
|
||||
pub const KERNEL_INSTALL_PROGRESS: &str = "kernel-install-progress";
|
||||
// 音乐模块:Python 便携运行时安装进度
|
||||
pub const MUSIC_RUNTIME_INSTALL_PROGRESS: &str = "music-runtime-install-progress";
|
||||
// 音乐模块:下载任务事件(桥接事件行 → 前端,负载见 bridge.py _emit_event)
|
||||
pub const MUSIC_DOWNLOAD_EVENT: &str = "music-download-event";
|
||||
// 后端自动切换节点完成(前端据以刷新节点列表并提示)
|
||||
pub const PROXY_AUTO_SWITCH: &str = "proxy-auto-switch";
|
||||
// 应用更新进度
|
||||
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_REMOVED: &str = "download-removed";
|
||||
/// 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口)
|
||||
pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added";
|
||||
}
|
||||
@@ -1,19 +1,36 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use super::engine::{CheckUrlResult, DownloadEngine};
|
||||
use super::task::{DownloadTask, DownloaderSettings};
|
||||
use super::torrent::TorrentInfo;
|
||||
|
||||
/// 获取所有任务
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTask> {
|
||||
engine.get_tasks()
|
||||
}
|
||||
|
||||
/// 解析磁力链 / .torrent 文件,返回种子信息(名称 / infohash / 文件列表),供前端做文件勾选
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn downloader_inspect(engine: State<'_, DownloadEngine>, input: String) -> Result<TorrentInfo, String> {
|
||||
engine.inspect(&input).await
|
||||
}
|
||||
|
||||
/// 磁力任务:元数据解析成功后,用户勾选文件并确认开始下载
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn downloader_select_bt_files(engine: State<'_, DownloadEngine>, id: String, only_files: Vec<u32>) -> Result<(), String> {
|
||||
engine.select_bt_files(&id, only_files).await
|
||||
}
|
||||
|
||||
/// 检查 URL 重复性并探测文件信息(添加下载前调用)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn downloader_check_url(
|
||||
engine: State<'_, DownloadEngine>,
|
||||
url: String,
|
||||
@@ -47,6 +64,7 @@ pub async fn downloader_check_url(
|
||||
|
||||
/// 添加下载任务
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn downloader_add_task(
|
||||
engine: State<'_, DownloadEngine>,
|
||||
url: String,
|
||||
@@ -54,24 +72,42 @@ pub async fn downloader_add_task(
|
||||
dir: Option<String>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
auto_rename: Option<bool>,
|
||||
only_files: Option<Vec<u32>>,
|
||||
) -> Result<String, String> {
|
||||
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false)).await
|
||||
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false), only_files).await
|
||||
}
|
||||
|
||||
/// 暂停任务
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn downloader_pause_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
|
||||
engine.pause_task(&id)
|
||||
}
|
||||
|
||||
/// 恢复任务
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
|
||||
engine.resume_task(&id)
|
||||
}
|
||||
|
||||
/// 取消任务(置为已取消,清空进度并删除下载文件,但保留记录)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn downloader_cancel_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
|
||||
engine.cancel_task(&id)
|
||||
}
|
||||
|
||||
/// 重新下载已取消/出错的任务
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn downloader_redownload(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
|
||||
engine.redownload(&id).await
|
||||
}
|
||||
|
||||
/// 移除任务
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn downloader_remove_task(
|
||||
engine: State<'_, DownloadEngine>,
|
||||
id: String,
|
||||
@@ -82,12 +118,14 @@ pub fn downloader_remove_task(
|
||||
|
||||
/// 获取设置
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn downloader_get_settings(engine: State<'_, DownloadEngine>) -> DownloaderSettings {
|
||||
engine.get_settings()
|
||||
}
|
||||
|
||||
/// 保存设置
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn downloader_save_settings(
|
||||
engine: State<'_, DownloadEngine>,
|
||||
settings: DownloaderSettings,
|
||||
@@ -118,6 +156,7 @@ pub fn downloader_get_extension_info(engine: State<'_, DownloadEngine>) -> serde
|
||||
|
||||
/// 用系统资源管理器打开目录
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn downloader_open_dir(app: AppHandle, path: String) -> Result<(), String> {
|
||||
app.opener()
|
||||
.open_path(path, None::<&str>)
|
||||
@@ -126,8 +165,30 @@ pub fn downloader_open_dir(app: AppHandle, path: String) -> Result<(), String> {
|
||||
|
||||
/// 用系统默认浏览器打开 URL
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
|
||||
app.opener()
|
||||
.open_url(url, None::<&str>)
|
||||
.map_err(|e| format!("打开链接失败: {}", e))
|
||||
}
|
||||
|
||||
/// 将指定 label 的下载窗口显示并强制置为前台。
|
||||
/// Tauri 的 set_focus 在 Windows 上受前台锁定限制(尤其下载窗口由后台进程创建、
|
||||
/// 或创建到非主显示器时更明显),改用原生 SetForegroundWindow + BringWindowToTop
|
||||
/// (模拟 Alt 键重置前台锁定),保证开始/完成下载时窗口能正确定位到前台。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn downloader_focus_window(app: AppHandle, label: String) -> Result<(), String> {
|
||||
let Some(window) = app.get_webview_window(&label) else {
|
||||
return Ok(()); // 窗口已关闭则忽略
|
||||
};
|
||||
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(())
|
||||
}
|
||||
|
||||
+1449
-173
File diff suppressed because it is too large
Load Diff
@@ -1,41 +1,131 @@
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Proxy;
|
||||
use reqwest::Client;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::SeekFrom;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use super::rate_limit::RateLimiter;
|
||||
use super::task::{ProbeResult, Segment};
|
||||
|
||||
/// 请求超时:连接 + 响应头必须在 30s 内就绪(分段请求若无超时,
|
||||
/// 服务器挂死时任务将永久卡在 Active,pause→resume 会出现新旧任务并发写同一临时文件)
|
||||
const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
/// 分块读取停滞超时:30s 内无任何数据视为连接挂死,主动中断(配合取消标志及时退出)
|
||||
const READ_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// HTTP/HTTPS 下载器
|
||||
#[derive(Clone)]
|
||||
pub struct HttpDownloader {
|
||||
client: Client,
|
||||
/// 直连客户端:强制禁用代理(no_proxy),用于降级重试与控制端探测
|
||||
direct_client: Client,
|
||||
/// mihomo 显式代理客户端缓存(与代理地址一一对应):走 http://127.0.0.1:{mixed_port}。
|
||||
/// 地址未配置(mihomo 未运行)时为 no_proxy 直连。Arc 共享同一状态,任意 clone 统一生效。
|
||||
mihomo: Arc<Mutex<(Option<String>, Client)>>,
|
||||
}
|
||||
|
||||
impl HttpDownloader {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
let direct_client = Client::builder()
|
||||
// 强制直连:即使系统代理已开启,下载也不经过系统代理
|
||||
.no_proxy()
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
Self { client }
|
||||
Self {
|
||||
direct_client: direct_client.clone(),
|
||||
mihomo: Arc::new(Mutex::new((None, direct_client))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置 mihomo 显式代理客户端。
|
||||
/// - `Some("http://127.0.0.1:{port}")`:走本地 mihomo 的 mixed 端口;
|
||||
/// - `None`:回退直连(不走系统代理)。
|
||||
/// 地址未变化时复用缓存,不重复重建,避免丢失连接复用。
|
||||
pub fn configure_mihomo_proxy(&self, proxy_url: Option<String>) {
|
||||
let mut guard = self.mihomo.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if guard.0 == proxy_url {
|
||||
return;
|
||||
}
|
||||
let client = match &proxy_url {
|
||||
Some(url) => {
|
||||
let mut cb = Client::builder();
|
||||
if let Ok(p) = Proxy::all(url.clone()) {
|
||||
cb = cb.proxy(p);
|
||||
}
|
||||
cb.build().unwrap_or_else(|_| Client::new())
|
||||
}
|
||||
None => Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
};
|
||||
*guard = (proxy_url, client);
|
||||
}
|
||||
|
||||
/// 探测 mihomo 外部控制端是否在线(用于判断代理模块是否真正运行)。
|
||||
/// 走直连客户端,避免探测本身依赖代理。
|
||||
pub async fn probe_controller(&self, controller: &str) -> bool {
|
||||
let url = format!("http://{}/version", controller);
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
self.direct_client.get(&url).send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(r)) => r.status().is_success(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 use_proxy 选择客户端(克隆句柄,Client 内部共享连接池)
|
||||
fn client(&self, use_proxy: bool) -> Client {
|
||||
if use_proxy {
|
||||
self.mihomo
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.1
|
||||
.clone()
|
||||
} else {
|
||||
self.direct_client.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测下载资源信息(大小、是否支持 Range、文件名)
|
||||
/// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD
|
||||
/// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD。
|
||||
/// 代理降级:use_proxy=true 时先走系统代理,失败则回退 no_proxy 直连重试一次
|
||||
pub async fn probe(
|
||||
&self,
|
||||
url: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
use_proxy: bool,
|
||||
) -> Result<ProbeResult, String> {
|
||||
let first = self.client(use_proxy);
|
||||
match self.probe_with_client(&first, url, headers).await {
|
||||
Ok(r) => return Ok(r),
|
||||
Err(e) if use_proxy => {
|
||||
let direct = self.client(false);
|
||||
self.probe_with_client(&direct, url, headers)
|
||||
.await
|
||||
.map_err(|e2| format!("代理探测失败({}),直连重试也失败({})", e, e2))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// 用指定 client 执行探测(GET Range → 回退 HEAD),供代理降级复用
|
||||
async fn probe_with_client(
|
||||
&self,
|
||||
client: &Client,
|
||||
url: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
) -> Result<ProbeResult, String> {
|
||||
// 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小)
|
||||
let mut req = self
|
||||
.client
|
||||
let mut req = client
|
||||
.get(url)
|
||||
.header("Range", "bytes=0-0")
|
||||
.header("User-Agent", "Thing-Download-Engine/1.0");
|
||||
@@ -43,7 +133,10 @@ impl HttpDownloader {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
|
||||
match req.send().await {
|
||||
match tokio::time::timeout(REQUEST_TIMEOUT, req.send())
|
||||
.await
|
||||
.map_err(|_| "探测超时(30s 内未收到响应头)".to_string())?
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let headers_map = resp.headers().clone();
|
||||
@@ -88,13 +181,13 @@ impl HttpDownloader {
|
||||
}
|
||||
Err(_) => {
|
||||
// GET 失败,尝试 HEAD 作为回退
|
||||
let mut head_req = self.client.head(url);
|
||||
let mut head_req = client.head(url);
|
||||
for (k, v) in headers {
|
||||
head_req = head_req.header(k, v);
|
||||
}
|
||||
let resp = head_req
|
||||
.send()
|
||||
let resp = tokio::time::timeout(REQUEST_TIMEOUT, head_req.send())
|
||||
.await
|
||||
.map_err(|_| "探测超时(HEAD 30s 内未收到响应头)".to_string())?
|
||||
.map_err(|e| format!("探测失败(GET 和 HEAD 均失败): {}", e))?;
|
||||
let headers_map = resp.headers().clone();
|
||||
let total_size = headers_map
|
||||
@@ -123,6 +216,7 @@ impl HttpDownloader {
|
||||
/// - `cancel`: 取消标志
|
||||
/// - `progress`: 每个分段的已下载字节(AtomicU64,与 segments 一一对应)
|
||||
/// - `limiter`: 全局限速器
|
||||
/// - `use_proxy`: 是否使用系统代理(false=强制直连)
|
||||
pub async fn download(
|
||||
&self,
|
||||
url: &str,
|
||||
@@ -132,6 +226,37 @@ impl HttpDownloader {
|
||||
cancel: Arc<AtomicBool>,
|
||||
progress: &[Arc<AtomicU64>],
|
||||
limiter: Arc<RateLimiter>,
|
||||
use_proxy: bool,
|
||||
) -> Result<(), String> {
|
||||
// 代理降级:仅当 use_proxy=true 才有"走代理→失败回退直连"的意义。
|
||||
// use_proxy=false 直接用直连客户端,无需回退。
|
||||
// 注意:用户主动暂停/取消(返回"已取消")必须原样透传,不能触发代理回退,
|
||||
// 否则会把"已取消"包装成"代理失败",导致引擎将其误判为错误而非暂停。
|
||||
let first = self.client(use_proxy);
|
||||
match self.download_with_client(&first, url, headers, segments, file_path, cancel.clone(), progress, &limiter).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if use_proxy && e != "已取消" => {
|
||||
// 回退直连重试(不继承 use_proxy,保证用 no_proxy 客户端)
|
||||
let direct = self.client(false);
|
||||
self.download_with_client(&direct, url, headers, segments, file_path, cancel, progress, &limiter)
|
||||
.await
|
||||
.map_err(|e2| format!("代理下载失败({}),直连重试也失败({})", e, e2))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// 用指定 client 执行下载(支持单线程与多线程分段),供代理降级复用
|
||||
async fn download_with_client(
|
||||
&self,
|
||||
client: &Client,
|
||||
url: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
segments: &[Segment],
|
||||
file_path: &Path,
|
||||
cancel: Arc<AtomicBool>,
|
||||
progress: &[Arc<AtomicU64>],
|
||||
limiter: &Arc<RateLimiter>,
|
||||
) -> Result<(), String> {
|
||||
let total_size = segments.iter().map(|s| s.len()).sum();
|
||||
|
||||
@@ -158,7 +283,7 @@ impl HttpDownloader {
|
||||
// 单线程下载(不支持 Range 或文件太小)
|
||||
let seg = &segments[0];
|
||||
let prog = &progress[0];
|
||||
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter)
|
||||
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter.clone(), client)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -178,7 +303,7 @@ impl HttpDownloader {
|
||||
.unwrap_or_default();
|
||||
let limiter = limiter.clone();
|
||||
let file_path = file_path.to_path_buf();
|
||||
let client = self.client.clone();
|
||||
let client = client.clone();
|
||||
|
||||
join_set.spawn(async move {
|
||||
download_segment_with_client(
|
||||
@@ -232,9 +357,10 @@ impl HttpDownloader {
|
||||
cancel: Arc<AtomicBool>,
|
||||
progress: Arc<AtomicU64>,
|
||||
limiter: Arc<RateLimiter>,
|
||||
client: &Client,
|
||||
) -> Result<(), String> {
|
||||
download_segment_with_client(
|
||||
&self.client,
|
||||
client,
|
||||
url,
|
||||
headers,
|
||||
seg,
|
||||
@@ -295,14 +421,26 @@ async fn download_segment_with_client(
|
||||
req = req.header(k, v);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
let resp = tokio::time::timeout(REQUEST_TIMEOUT, req.send())
|
||||
.await
|
||||
.map_err(|_| "请求超时(30s 内未收到响应头)".to_string())?
|
||||
.map_err(|e| format!("请求失败: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() && status.as_u16() != 206 {
|
||||
return Err(format!("服务器返回 HTTP {}", status));
|
||||
if unknown_size {
|
||||
// 未知大小:未发送 Range 头,接受任意 2xx
|
||||
if !status.is_success() {
|
||||
return Err(format!("服务器返回 HTTP {}", status));
|
||||
}
|
||||
} else {
|
||||
// 已发送 Range 头:必须返回 206。若服务器忽略 Range 返回 200 全文,
|
||||
// 按 range_start 偏移写入会错位 → 静默损坏文件;此处直接中断。
|
||||
if status.as_u16() != 206 {
|
||||
return Err(format!(
|
||||
"服务器未按分段请求响应(期望 206,实际 {}),已中断以避免文件损坏",
|
||||
status
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 流式读取并写入文件
|
||||
@@ -315,8 +453,9 @@ async fn download_segment_with_client(
|
||||
return Err("已取消".to_string());
|
||||
}
|
||||
|
||||
match stream.next().await {
|
||||
Some(Ok(chunk)) => {
|
||||
// 停滞超时:30s 无数据即中断,确保 cancel 标志能及时被感知(配合代际句柄防并发写)
|
||||
match tokio::time::timeout(READ_STALL_TIMEOUT, stream.next()).await {
|
||||
Ok(Some(Ok(chunk))) => {
|
||||
buf.extend_from_slice(&chunk);
|
||||
// 接收到数据立即更新进度(避免监控周期内进度无变化导致速度显示为 0)
|
||||
local_completed += chunk.len() as u64;
|
||||
@@ -331,10 +470,10 @@ async fn download_segment_with_client(
|
||||
buf.clear();
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
Ok(Some(Err(e))) => {
|
||||
return Err(format!("读取数据失败: {}", e));
|
||||
}
|
||||
None => {
|
||||
Ok(None) => {
|
||||
// 流结束,写入剩余数据
|
||||
if !buf.is_empty() {
|
||||
file.write_all(&buf)
|
||||
@@ -344,8 +483,20 @@ async fn download_segment_with_client(
|
||||
limiter.consume(buf.len() as u64).await;
|
||||
buf.clear();
|
||||
}
|
||||
// 校验:已知大小的分段若流提前结束(收到的字节数不足分段长度),
|
||||
// 说明服务器提前断开或返回不完整内容,不能标记为完成,否则文件会被截断
|
||||
if !unknown_size && local_completed < seg.len() {
|
||||
return Err(format!(
|
||||
"文件不完整:已接收 {} / {} 字节,服务器提前结束连接",
|
||||
local_completed,
|
||||
seg.len()
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
return Err("读取超时(30s 无数据,已中断下载)".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,10 +587,11 @@ fn percent_decode(s: &str) -> String {
|
||||
/// 将文件大小划分为 N 个分段
|
||||
pub fn split_segments(total_size: u64, num_connections: u32) -> Vec<Segment> {
|
||||
if total_size == 0 || num_connections == 0 {
|
||||
// 空文件或未指定连接数:单段覆盖整个文件(end 按 total_size 推导,不能硬编码 0)
|
||||
return vec![Segment {
|
||||
index: 0,
|
||||
start: 0,
|
||||
end: 0,
|
||||
end: total_size.saturating_sub(1),
|
||||
completed: 0,
|
||||
}];
|
||||
}
|
||||
@@ -473,3 +625,86 @@ pub fn split_segments(total_size: u64, num_connections: u32) -> Vec<Segment> {
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod split_segments_tests {
|
||||
use super::*;
|
||||
|
||||
/// 分段必须完整覆盖 [0, total_size),且相互连续无重叠
|
||||
fn assert_contiguous(segments: &[Segment], total_size: u64) {
|
||||
assert!(!segments.is_empty());
|
||||
let mut prev_end: i64 = -1;
|
||||
for seg in segments {
|
||||
assert_eq!(seg.start as i64, prev_end + 1, "分段不连续");
|
||||
assert!(seg.end >= seg.start, "分段 start > end");
|
||||
prev_end = seg.end as i64;
|
||||
}
|
||||
assert_eq!(
|
||||
segments.last().unwrap().end,
|
||||
total_size - 1,
|
||||
"末段未覆盖文件末尾"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_size_returns_single_segment() {
|
||||
let segs = split_segments(0, 4);
|
||||
assert_eq!(segs.len(), 1);
|
||||
assert_eq!(segs[0].start, 0);
|
||||
assert_eq!(segs[0].end, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_connections_covers_whole_file() {
|
||||
let segs = split_segments(1024, 0);
|
||||
assert_eq!(segs.len(), 1);
|
||||
assert_eq!(segs[0].start, 0);
|
||||
assert_eq!(segs[0].end, 1023);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divides_evenly_with_contiguous_coverage() {
|
||||
// 10MB / 4 连接 → 4 段完整覆盖
|
||||
let total = 10 * 1024 * 1024;
|
||||
let segs = split_segments(total, 4);
|
||||
assert_eq!(segs.len(), 4);
|
||||
assert_contiguous(&segs, total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_connections_by_min_segment_size() {
|
||||
// 2MB 文件请求 8 连接 → 受 1MB 最小分段限制,实际 ≤ 2 段
|
||||
let total = 2 * 1024 * 1024;
|
||||
let segs = split_segments(total, 8);
|
||||
assert!(segs.len() <= 2, "连接数未按最小分段收敛: {}", segs.len());
|
||||
assert_contiguous(&segs, total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_requested_connection_count() {
|
||||
// 大文件按请求连接数切分
|
||||
let total = 100 * 1024 * 1024;
|
||||
let segs = split_segments(total, 3);
|
||||
assert_eq!(segs.len(), 3);
|
||||
assert_contiguous(&segs, total);
|
||||
// 每段大小均匀
|
||||
for seg in &segs {
|
||||
let seg_len = seg.end - seg.start + 1;
|
||||
assert!(
|
||||
seg_len >= total / 3,
|
||||
"分段大小不均: {} 段只有 {} 字节",
|
||||
seg.index,
|
||||
seg_len
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_file_single_segment() {
|
||||
// 小于 1MB 的文件始终单段
|
||||
let total = 100;
|
||||
let segs = split_segments(total, 4);
|
||||
assert_eq!(segs.len(), 1);
|
||||
assert_contiguous(&segs, total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ pub mod rate_limit;
|
||||
pub mod server;
|
||||
pub mod storage;
|
||||
pub mod task;
|
||||
pub mod torrent;
|
||||
|
||||
pub use commands::{
|
||||
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_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files,
|
||||
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
|
||||
downloader_resume_task, downloader_save_settings, downloader_status,
|
||||
};
|
||||
pub use engine::DownloadEngine;
|
||||
|
||||
@@ -38,7 +38,7 @@ impl RateLimiter {
|
||||
// 尝试在当前窗口消费(用作用域确保 MutexGuard 在 await 前释放)
|
||||
let over_limit = {
|
||||
let now = std::time::Instant::now();
|
||||
let mut start = self.window_start.lock().unwrap();
|
||||
let mut start = self.window_start.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let elapsed = now.duration_since(*start);
|
||||
|
||||
// 窗口过期,重置
|
||||
@@ -72,3 +72,64 @@ impl Clone for RateLimiter {
|
||||
Self::new(self.limit.load(Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rate_limiter_tests {
|
||||
use super::*;
|
||||
use std::time::Instant;
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_limit_never_blocks() {
|
||||
let limiter = RateLimiter::new(0);
|
||||
let start = Instant::now();
|
||||
limiter.consume(1024 * 1024).await;
|
||||
limiter.consume(u64::MAX).await;
|
||||
assert!(start.elapsed() < Duration::from_millis(50));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn under_limit_returns_immediately() {
|
||||
let limiter = RateLimiter::new(100_000); // 100KB/s
|
||||
let start = Instant::now();
|
||||
limiter.consume(1024).await;
|
||||
assert!(start.elapsed() < Duration::from_millis(50));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exceeding_limit_waits_proportionally() {
|
||||
// 限速 200 B/s:先消耗 100 未超限,再消耗 150 → 超限 50 → 等待约 250ms
|
||||
let limiter = RateLimiter::new(200);
|
||||
limiter.consume(100).await;
|
||||
let start = Instant::now();
|
||||
limiter.consume(150).await;
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(200),
|
||||
"等待时间不足: {:?}",
|
||||
elapsed
|
||||
);
|
||||
assert!(elapsed < Duration::from_millis(1100));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn window_resets_after_one_second() {
|
||||
// 限速 100 B/s:第一窗口耗尽后,1.1s 窗口重置,再消耗 100 不应阻塞
|
||||
let limiter = RateLimiter::new(100);
|
||||
limiter.consume(100).await;
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
let start = Instant::now();
|
||||
limiter.consume(100).await;
|
||||
assert!(start.elapsed() < Duration::from_millis(100));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_limit_takes_effect_dynamically() {
|
||||
let limiter = RateLimiter::new(0);
|
||||
limiter.consume(1024).await; // 不限速
|
||||
limiter.set_limit(100);
|
||||
limiter.consume(100).await;
|
||||
let start = Instant::now();
|
||||
limiter.consume(100).await; // 累计 200 > 100 → 等待 1000ms
|
||||
assert!(start.elapsed() >= Duration::from_millis(900));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
@@ -9,13 +11,25 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::engine::DownloadEngine;
|
||||
use super::task::DownloadTask;
|
||||
use super::task::{DownloadTask, TaskStatus};
|
||||
|
||||
/// 扩展 HTTP API 服务器
|
||||
pub struct ExtensionServer;
|
||||
|
||||
/// 最近处理过的下载 URL(短窗口去重):记录对应任务 id 与创建时刻,
|
||||
/// 用于拦截"探测期间并发 POST"与"任务已结束但扩展重试"造成的重复创建
|
||||
#[derive(Clone)]
|
||||
struct RecentEntry {
|
||||
id: String,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
/// 同 URL 去重窗口:窗口内重复 POST 复用既有任务 id
|
||||
const DEDUP_WINDOW: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
ok: bool,
|
||||
@@ -45,27 +59,32 @@ struct ErrorResponse {
|
||||
|
||||
impl ExtensionServer {
|
||||
/// 启动 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 app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/api/downloads", post(create_download).get(list_downloads))
|
||||
.route("/api/downloads/:id", axum::routing::delete(remove_download))
|
||||
.with_state(AppState { engine, secret });
|
||||
.with_state(AppState {
|
||||
engine,
|
||||
secret,
|
||||
app_handle,
|
||||
recent: Arc::new(Mutex::new(HashMap::new())),
|
||||
});
|
||||
|
||||
let listener = match tokio::net::TcpListener::bind(&addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("[download_engine] 扩展 HTTP 服务启动失败 ({}): {}", addr, e);
|
||||
crate::logger::log_error("download", &format!("扩展 HTTP 服务启动失败 ({}): {}", addr, e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("[download_engine] 扩展 HTTP 服务已启动: http://{}", addr);
|
||||
crate::logger::log_info("download", &format!("扩展 HTTP 服务已启动: http://{}", addr));
|
||||
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
eprintln!("[download_engine] 扩展 HTTP 服务异常: {}", e);
|
||||
crate::logger::log_error("download", &format!("扩展 HTTP 服务异常: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +93,9 @@ impl ExtensionServer {
|
||||
struct AppState {
|
||||
engine: DownloadEngine,
|
||||
secret: String,
|
||||
app_handle: AppHandle,
|
||||
/// 最近创建的 URL→任务 id(短窗口去重)
|
||||
recent: Arc<Mutex<HashMap<String, RecentEntry>>>,
|
||||
}
|
||||
|
||||
/// 鉴权检查:如果配置了 secret,校验 Bearer token
|
||||
@@ -108,8 +130,48 @@ async fn create_download(
|
||||
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
|
||||
}
|
||||
|
||||
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await {
|
||||
Ok(id) => Ok(Json(CreateDownloadResponse { id })),
|
||||
// 1. 检查短窗口缓存:同 URL 30s 内已有创建记录,直接返回(拦截浏览器/扩展重试和并发 POST)
|
||||
{
|
||||
let mut recent = state.recent.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(entry) = recent.get(&req.url) {
|
||||
if entry.at.elapsed() < DEDUP_WINDOW {
|
||||
// 30 秒内重复请求 → 返回已创建任务 id,不重复新建
|
||||
return Ok(Json(CreateDownloadResponse { id: entry.id.clone() }));
|
||||
} else {
|
||||
// 窗口过期 → 删除旧记录继续检查引擎层去重
|
||||
recent.remove(&req.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 引擎层去重:同 URL 已有非终态任务(活跃/排队/暂停)时直接返回既有任务,
|
||||
// 避免浏览器重复转发同一下载造成重复下载
|
||||
if let Some(existing) = state.engine.get_tasks().into_iter().find(|t| {
|
||||
matches!(t.status, TaskStatus::Active | TaskStatus::Queued | TaskStatus::Paused) && t.url == req.url
|
||||
}) {
|
||||
// 添加到短窗口缓存以便拦截重试
|
||||
let mut recent = state.recent.lock().unwrap_or_else(|e| e.into_inner());
|
||||
recent.insert(req.url.clone(), RecentEntry { id: existing.id.clone(), at: Instant::now() });
|
||||
return Ok(Json(CreateDownloadResponse { id: existing.id }));
|
||||
}
|
||||
|
||||
// add_task 会 move 掉 req 的字段,先取出 url 供去重缓存使用
|
||||
let task_url = req.url.clone();
|
||||
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true, None).await {
|
||||
Ok(id) => {
|
||||
// 记录到短窗口缓存:拦截后续同一 URL 的重复转发/重试
|
||||
let url = task_url.clone();
|
||||
let mut recent = state.recent.lock().unwrap_or_else(|e| e.into_inner());
|
||||
recent.insert(url, RecentEntry { id: id.clone(), at: Instant::now() });
|
||||
drop(recent);
|
||||
// 浏览器扩展发起下载:不再置前主窗口,改为带 task id 通知前端,
|
||||
// 由前端为该任务创建一个专属的一次性下载窗口(不打断主界面)
|
||||
let _ = state.app_handle.emit(
|
||||
crate::constants::events::DOWNLOAD_EXTENSION_ADDED,
|
||||
serde_json::json!({ "id": id }),
|
||||
);
|
||||
Ok(Json(CreateDownloadResponse { id }))
|
||||
}
|
||||
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
|
||||
}
|
||||
}
|
||||
@@ -133,7 +195,11 @@ async fn remove_download(
|
||||
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
|
||||
}
|
||||
match state.engine.remove_task(&id, false) {
|
||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
||||
Ok(()) => {
|
||||
// 通知前端刷新任务列表(扩展删除时前端无从感知,否则列表残留已删除任务)
|
||||
let _ = state.app_handle.emit(crate::constants::events::DOWNLOAD_REMOVED, serde_json::json!({ "id": id }));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
Err(e) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: e }))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ impl Storage {
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
// 确保数据目录存在(首次启动或目录被删除时自动创建)
|
||||
if let Err(e) = fs::create_dir_all(&data_dir) {
|
||||
eprintln!("[download_engine] 创建数据目录失败: {} ({})", data_dir.display(), e);
|
||||
crate::logger::log_error("download", &format!("创建数据目录失败: {} ({})", data_dir.display(), e));
|
||||
}
|
||||
let state_path = data_dir.join("engine_state.json");
|
||||
let existing = Self::load_raw(&state_path);
|
||||
@@ -68,23 +68,24 @@ impl Storage {
|
||||
Self::load_raw(&self.state_path).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 保存状态到磁盘
|
||||
pub fn save(&self, mut state: EngineState) {
|
||||
/// 保存状态到磁盘(原子写:先写 .tmp 再 rename 覆盖,
|
||||
/// 避免进程崩溃时产生半写/截断的状态文件导致任务列表丢失)
|
||||
/// 失败返回 Err,由调用方决定是否将任务置为 Error(防止"看似已保存"的假象)。
|
||||
pub fn save(&self, mut state: EngineState) -> Result<(), String> {
|
||||
// 同步 ID 计数器
|
||||
state.next_id = self.id_counter.load(Ordering::SeqCst);
|
||||
match serde_json::to_string_pretty(&state) {
|
||||
Ok(json) => {
|
||||
// 兜底:若父目录被外部删除则在写入前重建
|
||||
if let Some(parent) = self.state_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
if let Err(e) = fs::write(&self.state_path, json) {
|
||||
eprintln!("[download_engine] 保存状态失败: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[download_engine] 序列化状态失败: {}", e);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&state)
|
||||
.map_err(|e| format!("序列化状态失败: {}", e))?;
|
||||
// 兜底:若父目录被外部删除则在写入前重建
|
||||
if let Some(parent) = self.state_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp_path = PathBuf::from(format!("{}.tmp", self.state_path.display()));
|
||||
fs::write(&tmp_path, json).map_err(|e| format!("保存状态失败: {}", e))?;
|
||||
fs::rename(&tmp_path, &self.state_path).map_err(|e| {
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
format!("替换状态文件失败: {}", e)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 任务下载协议类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TaskProtocol {
|
||||
/// HTTP/HTTPS 直链
|
||||
#[default]
|
||||
Http,
|
||||
/// BitTorrent(磁力链 / .torrent 文件)
|
||||
BitTorrent,
|
||||
}
|
||||
|
||||
/// 任务状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TaskStatus {
|
||||
/// 排队等待(并发数已满)
|
||||
@@ -15,10 +27,12 @@ pub enum TaskStatus {
|
||||
Complete,
|
||||
/// 错误
|
||||
Error,
|
||||
/// 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载)
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// 下载分段(多线程 Range 下载 / 断点续传用)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Segment {
|
||||
/// 分段索引
|
||||
@@ -36,24 +50,57 @@ impl Segment {
|
||||
pub fn len(&self) -> u64 {
|
||||
self.end.saturating_sub(self.start) + 1
|
||||
}
|
||||
/// 是否为未知大小哨兵段(start=0, end=0,表示不支持 Range 或未探测到大小)
|
||||
pub fn is_unknown_size(&self) -> bool {
|
||||
self.start == 0 && self.end == 0
|
||||
}
|
||||
/// 是否已完成
|
||||
pub fn is_done(&self) -> bool {
|
||||
// 未知大小段无法用长度判断是否完成,由流结束(Ok(None))判定;
|
||||
// 若按 len()=1 判断,暂停/恢复后 completed>=1 会误判为已完成,导致文件被截断
|
||||
if self.is_unknown_size() {
|
||||
return false;
|
||||
}
|
||||
self.completed >= self.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BtFileInfo {
|
||||
/// 文件在种子内的索引
|
||||
pub index: u32,
|
||||
/// 相对种子根目录的路径(如 "sub/file.mkv")
|
||||
pub path: String,
|
||||
/// 文件大小(字节)
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// 下载任务
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadTask {
|
||||
/// 任务 ID(自增 hex 字符串)
|
||||
pub id: String,
|
||||
/// 下载地址
|
||||
/// 下载地址(HTTP URL 或磁力链接)
|
||||
pub url: String,
|
||||
/// 文件名
|
||||
/// 文件名(HTTP:目标文件名;BT:种子名称)
|
||||
pub filename: String,
|
||||
/// 保存目录(绝对路径)
|
||||
pub dir: String,
|
||||
/// 协议类型
|
||||
#[serde(default)]
|
||||
pub protocol: TaskProtocol,
|
||||
/// BT 种子 infohash(协议=BitTorrent 时存在)
|
||||
#[serde(default)]
|
||||
pub info_hash: Option<String>,
|
||||
/// BT 种子内文件列表(协议=BitTorrent 时存在)
|
||||
#[serde(default)]
|
||||
pub bt_files: Vec<BtFileInfo>,
|
||||
/// BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过)
|
||||
#[serde(default)]
|
||||
pub bt_metadata_ready: bool,
|
||||
/// 状态
|
||||
pub status: TaskStatus,
|
||||
/// 文件总大小(字节),0=未知
|
||||
@@ -98,7 +145,7 @@ impl DownloadTask {
|
||||
}
|
||||
|
||||
/// 下载设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloaderSettings {
|
||||
/// 下载目录
|
||||
@@ -128,6 +175,21 @@ pub struct DownloaderSettings {
|
||||
/// 添加下载前检查重复(URL 或文件名重复时询问)
|
||||
#[serde(default = "default_true")]
|
||||
pub check_duplicate: bool,
|
||||
/// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连
|
||||
#[serde(default = "default_true")]
|
||||
pub use_proxy: bool,
|
||||
/// BitTorrent 上传限速 KB/s(0=不限)
|
||||
#[serde(default)]
|
||||
pub bt_upload_limit_kb: u64,
|
||||
/// BitTorrent 下载完成后是否继续做种上传(false=下载完即停止上传)
|
||||
#[serde(default)]
|
||||
pub bt_seed_after_download: bool,
|
||||
/// BitTorrent 监听端口(0=自动选择)
|
||||
#[serde(default)]
|
||||
pub bt_listen_port: u16,
|
||||
/// BitTorrent 使用代理下载:开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时降级直连
|
||||
#[serde(default)]
|
||||
pub bt_use_proxy: bool,
|
||||
}
|
||||
|
||||
fn default_max_concurrent() -> u32 {
|
||||
@@ -166,6 +228,11 @@ impl Default for DownloaderSettings {
|
||||
extension_secret: String::new(),
|
||||
delete_files_on_remove: false,
|
||||
check_duplicate: true,
|
||||
use_proxy: true,
|
||||
bt_upload_limit_kb: 0,
|
||||
bt_seed_after_download: false,
|
||||
bt_listen_port: 0,
|
||||
bt_use_proxy: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
// 会话字段专用 tokio 异步互斥(与 std 互斥区分开):
|
||||
// 创建 Session 是 async 操作,必须持锁跨 await,否则"双重检查"在 await 期间失效,
|
||||
// 并发首次添加 BT 任务可能各建一个全局会话,产生孤儿会话与跨会话无效句柄。
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use librqbit::api::TorrentIdOrHash;
|
||||
use librqbit::{AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, Session};
|
||||
|
||||
use super::task::BtFileInfo;
|
||||
|
||||
/// 磁力元数据解析超时(秒):依赖 DHT/tracker 拉取,过短易误报,过长体验差
|
||||
pub(crate) const INSPECT_TIMEOUT_SECS: u64 = 60;
|
||||
|
||||
/// 每日 tracker 同步源(XIU2/TrackersListCollection,官方新域名 cf.trackerslist.com)。
|
||||
/// 注意:GitHub 仓库 raw 路径(raw.githubusercontent.com/...trackers_best.txt)已随项目
|
||||
/// 迁移失效(404),不要再用 GitHub 代理镜像。以下为官方 Cloudflare 分发地址,
|
||||
/// 国内直连通常可达,按顺序尝试、首个成功即用。
|
||||
const TRACKER_SYNC_SOURCES: &[&str] = &[
|
||||
"https://cf.trackerslist.com/best.txt",
|
||||
"https://trackerslist.com/best.txt",
|
||||
"https://cf.trackerslist.com/all.txt",
|
||||
];
|
||||
/// 动态 tracker 持久化文件与同步元数据
|
||||
const TRACKERS_FILE: &str = "bt_trackers.txt";
|
||||
const TRACKERS_META_FILE: &str = "bt_trackers_meta.json";
|
||||
|
||||
/// 常用公共 tracker:磁力链接本身可能只带很少 tracker,追加这些可提升解析成功率。
|
||||
/// 混合 UDP/HTTP(S)/WebSocket,覆盖 UDP 被屏蔽但 HTTP 可用的网络环境。
|
||||
pub const PUBLIC_TRACKERS: &[&str] = &[
|
||||
// UDP
|
||||
"udp://tracker.opentrackr.org:1337/announce",
|
||||
"udp://open.tracker.cl:1337/announce",
|
||||
"udp://tracker.openbittorrent.com:6969/announce",
|
||||
"udp://tracker.torrent.eu.org:451/announce",
|
||||
"udp://open.stealth.si:80/announce",
|
||||
"udp://exodus.desync.com:6969/announce",
|
||||
"udp://tracker.tiny-vps.com:6969/announce",
|
||||
"udp://open.demonii.com:1337/announce",
|
||||
"udp://tracker.moeking.me:6969/announce",
|
||||
"udp://ipv4.tracker.harry.lu:80/announce",
|
||||
"udp://explodie.org:6969/announce",
|
||||
"udp://tracker.birkenfeld.ru:7496/announce",
|
||||
"udp://tracker.pomf.se:80/announce",
|
||||
"udp://tracker.tamersunion.org:6969/announce",
|
||||
"udp://retracker.lanta-net.ru:2710/announce",
|
||||
// HTTP(S)
|
||||
"http://tracker.opentrackr.org:1337/announce",
|
||||
"http://tracker.openbittorrent.com:80/announce",
|
||||
"http://tracker1.itzmx.com:8080/announce",
|
||||
"http://tracker4.itzmx.com:2710/announce",
|
||||
"https://tracker.gbitt.info:443/announce",
|
||||
"https://tracker.nanoha.org:443/announce",
|
||||
"http://tracker.bt4g.com:2095/announce",
|
||||
"http://tracker.gbitt.info:80/announce",
|
||||
];
|
||||
|
||||
/// 种子信息(inspect 解析结果,供命令返回给前端做文件勾选)
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TorrentInfo {
|
||||
/// 种子名称
|
||||
pub name: String,
|
||||
/// infohash(hex 小写字符串)
|
||||
pub info_hash: String,
|
||||
/// 种子内全部文件总大小(字节)
|
||||
pub total_size: u64,
|
||||
/// 种子内文件列表
|
||||
pub files: Vec<BtFileInfo>,
|
||||
}
|
||||
|
||||
/// BitTorrent 下载器:封装 librqbit 全局会话(多任务共享 DHT / 监听端口 / tracker 缓存)。
|
||||
/// 会话懒创建:只有真正添加 BT 任务时才初始化,避免引擎启动即拉起 BT 内核。
|
||||
#[derive(Clone)]
|
||||
pub struct TorrentDownloader {
|
||||
/// 全局会话(懒创建;tokio 异步互斥保证并发首次添加只创建一个会话)
|
||||
session: Arc<AsyncMutex<Option<Arc<Session>>>>,
|
||||
/// 会话默认输出目录(每个任务用 AddTorrentOptions.output_folder 覆盖)
|
||||
base_dir: PathBuf,
|
||||
/// 上传限速 bytes/s(0=不限),per-torrent 应用
|
||||
upload_limit_bps: Arc<std::sync::atomic::AtomicU64>,
|
||||
/// 监听端口(0=自动)
|
||||
listen_port: Arc<std::sync::atomic::AtomicU16>,
|
||||
/// SOCKS5 代理地址(None=直连;来自代理模块,不可用时降级直连)
|
||||
proxy_addr: Arc<Mutex<Option<String>>>,
|
||||
/// 动态公共 tracker(每日从外部列表同步,叠加到内置列表)
|
||||
dynamic_trackers: Arc<RwLock<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl TorrentDownloader {
|
||||
pub fn new(base_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
session: Arc::new(AsyncMutex::new(None)),
|
||||
base_dir,
|
||||
upload_limit_bps: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
listen_port: Arc::new(std::sync::atomic::AtomicU16::new(0)),
|
||||
proxy_addr: Arc::new(Mutex::new(None)),
|
||||
dynamic_trackers: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新 BT 专属设置(上传限速 KB/s、监听端口、SOCKS5 代理地址)— 会话创建时生效
|
||||
pub fn set_settings(&self, upload_limit_kb: u64, listen_port: u16, proxy_addr: Option<String>) {
|
||||
self.upload_limit_bps
|
||||
.store(upload_limit_kb.saturating_mul(1024), std::sync::atomic::Ordering::SeqCst);
|
||||
self.listen_port
|
||||
.store(listen_port, std::sync::atomic::Ordering::SeqCst);
|
||||
*self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()) = proxy_addr;
|
||||
}
|
||||
|
||||
/// 设置动态 tracker(合并去重)
|
||||
pub fn add_dynamic_trackers(&self, list: Vec<String>) {
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut g = self.dynamic_trackers.write().unwrap_or_else(|e| e.into_inner());
|
||||
for t in list {
|
||||
let t = t.trim().to_string();
|
||||
if !t.is_empty() && !g.contains(&t) {
|
||||
g.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前动态 tracker 数量
|
||||
pub fn dynamic_count(&self) -> usize {
|
||||
self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).len()
|
||||
}
|
||||
|
||||
/// 合并内置 + 动态 tracker(去重),供 inspect / add_async 共用
|
||||
fn build_trackers(&self) -> Vec<String> {
|
||||
let dyn_trackers = self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let mut trackers: Vec<String> = PUBLIC_TRACKERS.iter().map(|s| s.to_string()).collect();
|
||||
for t in dyn_trackers {
|
||||
if !trackers.contains(&t) {
|
||||
trackers.push(t);
|
||||
}
|
||||
}
|
||||
trackers
|
||||
}
|
||||
|
||||
/// 每日同步动态 tracker:读取本地缓存 → 判断今天是否已同步 → 未同步则拉取更新。
|
||||
/// 失败静默降级:保留上次成功的列表。
|
||||
pub async fn sync_dynamic_trackers(&self, data_dir: &Path) {
|
||||
fn parse_list(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.map(|l| l.trim())
|
||||
.filter(|l| l.starts_with("http://") || l.starts_with("https://") || l.starts_with("udp://") || l.starts_with("ws://") || l.starts_with("wss://"))
|
||||
.map(|l| l.to_string())
|
||||
.collect()
|
||||
}
|
||||
let trackers_path = data_dir.join(TRACKERS_FILE);
|
||||
let meta_path = data_dir.join(TRACKERS_META_FILE);
|
||||
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||
|
||||
// 1. 先加载上次成功保存的列表到内存(覆盖"重启后内存被清空")
|
||||
if let Ok(s) = std::fs::read_to_string(&trackers_path) {
|
||||
self.add_dynamic_trackers(parse_list(&s));
|
||||
}
|
||||
|
||||
// 2. 今天已同步过则不再下载
|
||||
if let Ok(meta) = std::fs::read_to_string(&meta_path) {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&meta) {
|
||||
if v.get("date").and_then(|d| d.as_str()) == Some(&today) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 依次尝试各镜像源(记录每个源的失败原因,便于判断是 DNS 还是连接超时)
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.ok();
|
||||
for url in TRACKER_SYNC_SOURCES {
|
||||
let Some(c) = &client else { break };
|
||||
match c.get(*url).send().await {
|
||||
Err(e) => {
|
||||
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源不可达 {}: {}", url, e));
|
||||
continue;
|
||||
}
|
||||
Ok(resp) => {
|
||||
let text = match resp.text().await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源读取失败 {}: {}", url, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let list = parse_list(&text);
|
||||
if list.is_empty() {
|
||||
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源返回空列表 {}", url));
|
||||
continue;
|
||||
}
|
||||
// 保存列表与同步元数据
|
||||
let _ = std::fs::write(&trackers_path, format!("{}\n", list.join("\n")));
|
||||
let _ = std::fs::write(&meta_path, serde_json::json!({ "date": today }).to_string());
|
||||
self.add_dynamic_trackers(list);
|
||||
crate::logger::log_info("download", &format!("已同步动态 tracker(共 {} 条)", self.dynamic_count()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::logger::log_warn("download", "动态 tracker 全部同步源不可达,使用上次成功列表或内置列表(不影响磁力解析,公共 tracker 仍会生效)");
|
||||
}
|
||||
|
||||
/// 会话级公共 tracker(HashSet<Url>,供 SessionOptions.trackers 使用)。
|
||||
/// 注意:librqbit 对磁力链接只采用 magnet URL 自带的 tr= 参数,完全忽略
|
||||
/// AddTorrentOptions.trackers(见 librqbit session.rs 的 magnet 分支),导致
|
||||
/// 纯磁力(无自带 tr)解析元数据时 "trackers list is empty"。而会话级
|
||||
/// SessionOptions.trackers 会在 make_peer_rx 中合并进每个种子(含磁力),
|
||||
/// 因此必须放到这里才能让磁力链接真正带上公共 tracker。
|
||||
fn session_trackers(&self) -> std::collections::HashSet<url::Url> {
|
||||
self.build_trackers()
|
||||
.into_iter()
|
||||
.filter_map(|t| url::Url::parse(&t).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 获取或创建全局会话。
|
||||
/// 持 tokio 互斥锁覆盖整个构造过程:并发调用在此串行化,
|
||||
/// 先进入者创建并写入,后续者锁内二次检查直接复用(避免多会话竞态)。
|
||||
/// 开启代理时先尝试用代理初始化;失败则降级为直连(记录下来供 add 重试判断)。
|
||||
async fn get_session(&self) -> Result<Arc<Session>, String> {
|
||||
let mut guard = self.session.lock().await;
|
||||
if let Some(s) = guard.as_ref() {
|
||||
return Ok(s.clone());
|
||||
}
|
||||
let _ = std::fs::create_dir_all(&self.base_dir);
|
||||
let port = self.listen_port.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let proxy = self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let connect_opt = proxy.as_ref().map(|p| librqbit::ConnectionOptions { proxy_url: Some(p.clone()), ..Default::default() });
|
||||
let had_proxy = connect_opt.is_some();
|
||||
|
||||
// 构造带代理(若启用)+ 监听端口 + 会话级公共 tracker 的会话选项
|
||||
let mut opts = lib_session_options();
|
||||
opts.trackers = self.session_trackers();
|
||||
if connect_opt.is_some() {
|
||||
opts.connect = connect_opt;
|
||||
}
|
||||
if port > 0 {
|
||||
if let Ok(addr) = format!("0.0.0.0:{}", port).parse::<std::net::SocketAddr>() {
|
||||
opts.listen = Some(librqbit::ListenerOptions {
|
||||
listen_addr: addr,
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
crate::logger::log_error("download", &format!("BT 监听端口 {} 无效,使用自动端口", port));
|
||||
}
|
||||
}
|
||||
// 尝试创建会话:先按配置(可能带代理),失败且有代理则降级直连重试
|
||||
let s = match Session::new_with_opts(self.base_dir.clone(), opts).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if had_proxy {
|
||||
crate::logger::log_error("download", &format!("BT 代理会话初始化失败({}),降级为直连", e));
|
||||
let mut m = lib_session_options();
|
||||
m.trackers = self.session_trackers();
|
||||
if port > 0 {
|
||||
if let Ok(addr) = format!("0.0.0.0:{}", port).parse::<std::net::SocketAddr>() {
|
||||
m.listen = Some(librqbit::ListenerOptions { listen_addr: addr, ..Default::default() });
|
||||
}
|
||||
}
|
||||
Session::new_with_opts(self.base_dir.clone(), m).await.map_err(|e2| format!("初始化 BitTorrent 会话失败: {}", e2))?
|
||||
} else {
|
||||
return Err(format!("初始化 BitTorrent 会话失败: {}", e));
|
||||
}
|
||||
}
|
||||
};
|
||||
*guard = Some(s.clone());
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// 解析磁力链 / 本地 .torrent 文件,返回种子信息(不开始下载)。
|
||||
/// list_only 模式:仅获取元数据,不加入会话,因此不影响后续真正添加。
|
||||
/// 磁力元数据依赖 DHT/tracker 拉取,无超时会永久等待,故加超时。
|
||||
pub async fn inspect(&self, input: &str) -> Result<TorrentInfo, String> {
|
||||
let session = self.get_session().await?;
|
||||
let trackers = self.build_trackers();
|
||||
// 磁力:需从网络拉取元数据,可能较慢(取决于种子热度与网络连通性);
|
||||
// 本地 .torrent / http(s) .torrent URL:元数据在文件内,from_cli_argument 会读取/下载并解析
|
||||
let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?;
|
||||
let add_fut = session.add_torrent(
|
||||
add,
|
||||
Some(AddTorrentOptions {
|
||||
list_only: true,
|
||||
trackers: Some(trackers),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let resp = tokio::time::timeout(std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS), add_fut)
|
||||
.await
|
||||
.map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())?
|
||||
.map_err(|e| format!("解析种子失败: {}", e))?;
|
||||
|
||||
let AddTorrentResponse::ListOnly(lo) = resp else {
|
||||
return Err("该链接未返回有效的种子元数据".to_string());
|
||||
};
|
||||
|
||||
let info = lo.info.info();
|
||||
let name = info
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|b| String::from_utf8_lossy(b.as_ref()).into_owned())
|
||||
.or_else(|| lo.info.name().map(|c| c.to_string()))
|
||||
.unwrap_or_else(|| "未命名种子".to_string());
|
||||
|
||||
let mut files = Vec::new();
|
||||
let mut total_size = 0u64;
|
||||
if let Some(fs) = &info.files {
|
||||
for (i, f) in fs.iter().enumerate() {
|
||||
let path = f
|
||||
.path
|
||||
.iter()
|
||||
.map(|p| String::from_utf8_lossy(p.as_ref()).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
files.push(BtFileInfo {
|
||||
index: i as u32,
|
||||
path,
|
||||
size: f.length,
|
||||
});
|
||||
total_size += f.length;
|
||||
}
|
||||
} else if let Some(len) = info.length {
|
||||
// 单文件种子
|
||||
files.push(BtFileInfo {
|
||||
index: 0,
|
||||
path: name.clone(),
|
||||
size: len,
|
||||
});
|
||||
total_size = len;
|
||||
}
|
||||
|
||||
Ok(TorrentInfo {
|
||||
name,
|
||||
info_hash: hex_encode(&lo.info_hash.0),
|
||||
total_size,
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
/// 异步添加种子:立即返回句柄(不等待元数据),由调用方后台解析。
|
||||
/// 附加公共 tracker 提升磁力元数据解析成功率;上传限速 per-torrent 应用。
|
||||
pub async fn add_async(
|
||||
&self,
|
||||
input: &str,
|
||||
output_dir: &str,
|
||||
) -> Result<(usize, Arc<ManagedTorrent>), String> {
|
||||
let session = self.get_session().await?;
|
||||
std::fs::create_dir_all(output_dir).map_err(|e| format!("创建下载目录失败: {}", e))?;
|
||||
let up = self.upload_limit_bps.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let upload_bps = std::num::NonZeroU32::new(up.min(u32::MAX as u64) as u32);
|
||||
let trackers = self.build_trackers();
|
||||
let opts = AddTorrentOptions {
|
||||
paused: true,
|
||||
output_folder: Some(output_dir.to_string()),
|
||||
overwrite: true,
|
||||
trackers: Some(trackers),
|
||||
ratelimits: librqbit::limits::LimitsConfig {
|
||||
upload_bps,
|
||||
download_bps: None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?;
|
||||
let resp = session
|
||||
.add_torrent(add, Some(opts))
|
||||
.await
|
||||
.map_err(|e| format!("添加种子失败: {}", e))?;
|
||||
match resp {
|
||||
AddTorrentResponse::Added(id, handle) => Ok((id, handle)),
|
||||
AddTorrentResponse::AlreadyManaged(id, handle) => Ok((id, handle)),
|
||||
_ => Err("种子已存在或元数据无效".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 等待种子元数据初始化就绪(磁力需要从网络拉取,带超时)
|
||||
pub async fn wait_initialized(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS),
|
||||
handle.wait_until_initialized(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())?
|
||||
.map_err(|e| format!("获取种子元数据失败: {}", e))
|
||||
}
|
||||
|
||||
/// 设置要下载的种子文件子集(用户文件勾选;传全部索引则全选)
|
||||
pub async fn set_only_files(&self, handle: &Arc<ManagedTorrent>, files: &[u32]) -> Result<(), String> {
|
||||
let session = self.get_session().await?;
|
||||
let set: std::collections::HashSet<usize> = files.iter().map(|&i| i as usize).collect();
|
||||
session
|
||||
.update_only_files(handle, &set)
|
||||
.await
|
||||
.map_err(|e| format!("设置下载文件失败: {}", e))
|
||||
}
|
||||
|
||||
/// 读取句柄当前进度:返回 (已下载字节, 总字节, 每文件已下载字节)
|
||||
/// `file_progress` 与种子文件一一对应,供前端详情页展示单文件进度。
|
||||
pub fn progress_full(handle: &Arc<ManagedTorrent>) -> (u64, u64, Vec<u64>) {
|
||||
let s = handle.stats();
|
||||
(s.progress_bytes, s.total_bytes, s.file_progress.clone())
|
||||
}
|
||||
|
||||
/// 暂停种子(下载中的连接会停止,已下载片段保留,可继续)
|
||||
pub async fn pause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
|
||||
let session = self.get_session().await?;
|
||||
session
|
||||
.pause(handle)
|
||||
.await
|
||||
.map_err(|e| format!("暂停种子失败: {}", e))
|
||||
}
|
||||
|
||||
/// 继续种子(幂等):恢复已暂停的种子。
|
||||
/// 若种子本已处于 Live(例如 select_bt_files 的 set_only_files 内部已恢复过、
|
||||
/// 或暂停期间被其他逻辑恢复),直接视为成功,避免 librqbit 报 "torrent is already live"
|
||||
/// 而导致 pause→resume 链路被误判为失败。
|
||||
pub async fn unpause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
|
||||
let session = self.get_session().await?;
|
||||
match handle.stats().state {
|
||||
librqbit::TorrentStatsState::Live => Ok(()),
|
||||
librqbit::TorrentStatsState::Error => Err("种子处于错误状态,无法继续".to_string()),
|
||||
librqbit::TorrentStatsState::Paused
|
||||
| librqbit::TorrentStatsState::Initializing { .. } => session
|
||||
.unpause(handle)
|
||||
.await
|
||||
.map_err(|e| format!("继续种子失败: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除种子(info_hash 为 hex 字符串;delete_files 是否同时删除已下载文件)
|
||||
pub async fn delete(&self, info_hash: &str, delete_files: bool) -> Result<(), String> {
|
||||
let session = self.get_session().await?;
|
||||
let id = TorrentIdOrHash::parse(info_hash)
|
||||
.map_err(|_| format!("无效的 infohash: {}", info_hash))?;
|
||||
session
|
||||
.delete(id, delete_files)
|
||||
.await
|
||||
.map_err(|e| format!("删除种子失败: {}", e))
|
||||
}
|
||||
|
||||
/// 读取句柄当前下载进度 (已下载字节, 总字节)
|
||||
pub fn progress(handle: &Arc<ManagedTorrent>) -> (u64, u64) {
|
||||
let s = handle.stats();
|
||||
(s.progress_bytes, s.total_bytes)
|
||||
}
|
||||
|
||||
/// 停止全局会话(应用退出时调用)
|
||||
pub async fn stop(&self) {
|
||||
let session = {
|
||||
let mut guard = self.session.lock().await;
|
||||
guard.take()
|
||||
};
|
||||
if let Some(s) = session {
|
||||
let _ = s.stop().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字节数组转小写十六进制字符串(librqbit 的 Id 未实现 Display)
|
||||
pub(crate) fn hex_encode(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for &b in bytes {
|
||||
out.push(HEX[(b >> 4) as usize] as char);
|
||||
out.push(HEX[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 默认会话选项(后续如需新增 DHT/缓存等可在此统一配置)
|
||||
fn lib_session_options() -> librqbit::SessionOptions {
|
||||
librqbit::SessionOptions::default()
|
||||
}
|
||||
+307
-157
@@ -1,92 +1,196 @@
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
|
||||
mod clipboard;
|
||||
mod constants;
|
||||
mod download_engine;
|
||||
mod logger;
|
||||
mod mihomo_manager;
|
||||
mod monitor_kernel;
|
||||
mod music;
|
||||
mod network_monitor;
|
||||
mod osd_window;
|
||||
mod process_manager;
|
||||
mod quickpanel;
|
||||
mod screenshot;
|
||||
mod setup;
|
||||
mod shortcut;
|
||||
mod snap_fix;
|
||||
mod tray_menu;
|
||||
mod updater;
|
||||
mod win32_util;
|
||||
|
||||
use download_engine::{
|
||||
DownloadEngine, ExtensionServer,
|
||||
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,
|
||||
DownloadEngine,
|
||||
downloader_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files,
|
||||
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
|
||||
downloader_resume_task, downloader_save_settings, downloader_status,
|
||||
};
|
||||
use logger::{
|
||||
clear_logs, get_log_info, get_logs, log_message, LogManager,
|
||||
log_clear, log_info_state, log_list, log_message,
|
||||
};
|
||||
use mihomo_manager::{
|
||||
proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_delete_profile,
|
||||
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, MihomoManager,
|
||||
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
||||
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy,
|
||||
proxy_import_profile, 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_traffic,
|
||||
proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager,
|
||||
};
|
||||
use monitor_kernel::{
|
||||
monitor_elevate_self, monitor_get_elevate_on_launch, monitor_get_hardware_config,
|
||||
monitor_get_snapshot, monitor_get_status, monitor_kernel_info, monitor_set_elevate_on_launch,
|
||||
monitor_set_hardware_config, monitor_start, monitor_start_elevated, monitor_status,
|
||||
monitor_stop, MonitorKernel,
|
||||
monitor_elevate_self, monitor_get_auto_start, monitor_get_elevate_on_launch,
|
||||
monitor_get_hardware_config, monitor_get_snapshot, monitor_get_status, monitor_kernel_info,
|
||||
monitor_repair_pawnio, monitor_set_auto_start, monitor_set_elevate_on_launch,
|
||||
monitor_set_hardware_config, monitor_start, monitor_start_elevated, monitor_status, monitor_stop,
|
||||
MonitorKernel,
|
||||
};
|
||||
use network_monitor::{network_monitor_status, NetworkMonitor};
|
||||
use music::{
|
||||
feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status,
|
||||
feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve, feiniu_get_config,
|
||||
feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout, feiniu_lyric,
|
||||
feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, feiniu_test_connection,
|
||||
music_cancel_runtime_install, music_download, music_download_cancel, music_env_status,
|
||||
music_get_settings, music_get_sources, music_install_runtime, music_parse_playlist, music_ping,
|
||||
music_resolve, music_save_settings, music_search, music_stop_bridge, webdav_delete,
|
||||
webdav_get_secret, webdav_save_secret, webdav_test, webdav_upload, MusicManager,
|
||||
};
|
||||
use network_monitor::network_status;
|
||||
use osd_window::{
|
||||
osd_apply_overlay_style, osd_begin_drag, osd_set_click_through, osd_set_topmost,
|
||||
osd_start_drag_watch, osd_start_topmost_watch, osd_stop_watch,
|
||||
osd_apply_overlay_style, osd_begin_drag, osd_set_bounds, osd_set_click_through,
|
||||
osd_set_topmost, osd_start_drag_watch, osd_start_game_watch, osd_start_topmost_watch,
|
||||
osd_stop_watch,
|
||||
};
|
||||
use process_manager::{
|
||||
get_all_process_status, get_process_status, start_monitoring_thread, start_process,
|
||||
stop_all_processes, stop_process, ProcessManager,
|
||||
process_all_status, process_start, process_status,
|
||||
process_stop, process_stop_all, ProcessManager,
|
||||
};
|
||||
use screenshot::commands::{
|
||||
screenshot_capture_fullscreen, screenshot_capture_window, screenshot_clear_fullscreen,
|
||||
screenshot_compose_copy, screenshot_compose_png, 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_fullscreen_bmp, screenshot_load_cache,
|
||||
screenshot_load_cache_raw, screenshot_pick_list, screenshot_register_pin_shortcut,
|
||||
screenshot_register_shortcut,
|
||||
screenshot_save_cache, screenshot_save_png, screenshot_scroll_capture,
|
||||
screenshot_scroll_cancel, screenshot_scroll_finish, screenshot_scroll_start,
|
||||
screenshot_set_scroll_hole, screenshot_show_overlay, screenshot_take_editor_image_raw,
|
||||
screenshot_unregister_pin_shortcut, screenshot_unregister_shortcut,
|
||||
};
|
||||
use clipboard::{
|
||||
ClipboardManager,
|
||||
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
|
||||
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup,
|
||||
clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search,
|
||||
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
|
||||
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_get_thumb,
|
||||
clipboard_hide_popup, clipboard_hide_preview, clipboard_paste_to_target,
|
||||
clipboard_preview_interacted, clipboard_register_shortcut,
|
||||
clipboard_reveal_preview,
|
||||
clipboard_save_settings, clipboard_search, clipboard_set_pinned, clipboard_resize_preview,
|
||||
clipboard_show_popup, clipboard_show_preview, clipboard_show_window, clipboard_start,
|
||||
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_apply, update_thinghk_cancel,
|
||||
update_thinghk_confirm, ThinghkUpdateState,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
fn quit_app(app: tauri::AppHandle) {
|
||||
// 资源清理统一收敛到 RunEvent::ExitRequested(覆盖所有退出路径),此处仅请求退出
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn quit_app(
|
||||
state: tauri::State<'_, ProcessManager>,
|
||||
mihomo: tauri::State<'_, MihomoManager>,
|
||||
engine: tauri::State<'_, DownloadEngine>,
|
||||
monitor: tauri::State<'_, MonitorKernel>,
|
||||
clipboard: tauri::State<'_, ClipboardManager>,
|
||||
app: tauri::AppHandle,
|
||||
) {
|
||||
// 退出前清理系统代理,避免遗留导致网络问题
|
||||
mihomo.cleanup_on_exit();
|
||||
// 退出前保存下载引擎状态
|
||||
engine.cleanup_on_exit();
|
||||
// 停止提权 Kernel(普通权限由 stop_all 统一清理,提权 Kernel 需通过 /shutdown)
|
||||
tauri::async_runtime::block_on(monitor.cleanup_on_exit(&app));
|
||||
// 停止剪贴板监听线程
|
||||
clipboard.stop();
|
||||
// 停止所有子进程(同步 kill + 带超时的 wait,确保进程真正终止)
|
||||
state.stop_all();
|
||||
// 通过 app.exit 触发 RunEvent::ExitRequested,统一退出路径
|
||||
app.exit(0);
|
||||
/// 导出 tauri-specta 生成的 TypeScript 类型与命令绑定(仅 debug 构建,开发时自动刷新)。
|
||||
/// 覆盖 proxy / quickpanel / clipboard / download_engine / screenshot 五个模块;
|
||||
/// 豁免清单(返回 serde_json::Value 或 tauri::ipc::Response/Request,specta 无法生成):
|
||||
/// proxy_version / proxy_get_proxies / proxy_get_connections / proxy_patch_configs、
|
||||
/// downloader_status / downloader_get_extension_info、
|
||||
/// screenshot_get_fullscreen_bmp(返回 ipc::Response)/ screenshot_compose_copy(接收 ipc::Request)。
|
||||
#[cfg(debug_assertions)]
|
||||
fn export_bindings() {
|
||||
use specta_typescript::Typescript;
|
||||
use tauri_specta::{Builder, ErrorHandlingMode, collect_commands};
|
||||
|
||||
Builder::<tauri::Wry>::new()
|
||||
// 全局把 u64/i64 映射为 number(项目取值均在 JS 安全整数范围:大小/时间戳/limit)
|
||||
.dangerously_cast_bigints_to_number()
|
||||
// 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper
|
||||
.error_handling(ErrorHandlingMode::Throw)
|
||||
.commands(collect_commands![
|
||||
// 应用更新(6)
|
||||
app_version, update_check, update_install, update_thinghk_apply,
|
||||
update_thinghk_confirm, update_thinghk_cancel,
|
||||
// proxy(20)
|
||||
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
||||
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings,
|
||||
proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info,
|
||||
proxy_restart, proxy_save_settings,
|
||||
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_traffic,
|
||||
proxy_test_delay, proxy_update_profile,
|
||||
// quickpanel(22)
|
||||
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,
|
||||
// clipboard(23)
|
||||
clipboard_get_history, clipboard_get_pinned, clipboard_search, clipboard_get_item,
|
||||
clipboard_get_thumb, 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, clipboard_show_preview,
|
||||
clipboard_hide_preview, clipboard_resize_preview, clipboard_reveal_preview,
|
||||
clipboard_preview_interacted,
|
||||
// download_engine(10,豁免 2)
|
||||
downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task,
|
||||
downloader_resume_task, downloader_cancel_task, downloader_redownload, downloader_remove_task, downloader_get_settings,
|
||||
downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_focus_window, downloader_inspect, downloader_select_bt_files,
|
||||
// music(6,豁免 3:music_ping / music_get_sources / music_search 返回 serde_json::Value)
|
||||
music_env_status, music_install_runtime, music_cancel_runtime_install,
|
||||
music_stop_bridge, music_get_settings, music_save_settings,
|
||||
// screenshot(23,豁免 3:get_fullscreen_bmp / take_editor_image_raw 返回 ipc::Response、
|
||||
// compose_copy / compose_png 接收 ipc::Request)
|
||||
screenshot_disable_transitions, screenshot_show_overlay, 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_pick_list, screenshot_cursor_pos,
|
||||
screenshot_enum_windows, screenshot_capture_window, screenshot_scroll_capture,
|
||||
screenshot_scroll_cancel, screenshot_scroll_finish, screenshot_scroll_start,
|
||||
screenshot_set_scroll_hole,
|
||||
screenshot_copy_image, screenshot_save_png,
|
||||
screenshot_save_cache, screenshot_load_cache, screenshot_delete_cache,
|
||||
])
|
||||
.export(Typescript::default(), "../src/lib/bindings.ts")
|
||||
.expect("failed to export bindings");
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
#[cfg(debug_assertions)]
|
||||
export_bindings();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_autostart::Builder::new().build())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
// 截图快捷键改由 screenshot::shortcut::register_shortcut(on_shortcut)按需注册,
|
||||
// 支持自定义;默认 Ctrl+Alt+A 由前端 store 启动时注册
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(
|
||||
@@ -95,27 +199,35 @@ pub fn run() {
|
||||
.build()
|
||||
)
|
||||
.manage(ProcessManager::new())
|
||||
.manage(ThinghkUpdateState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
quit_app,
|
||||
start_process,
|
||||
stop_process,
|
||||
get_process_status,
|
||||
get_all_process_status,
|
||||
stop_all_processes,
|
||||
app_version,
|
||||
update_check,
|
||||
update_install,
|
||||
update_thinghk_apply,
|
||||
update_thinghk_confirm,
|
||||
update_thinghk_cancel,
|
||||
process_start,
|
||||
process_stop,
|
||||
process_status,
|
||||
process_all_status,
|
||||
process_stop_all,
|
||||
log_message,
|
||||
get_logs,
|
||||
clear_logs,
|
||||
get_log_info,
|
||||
log_list,
|
||||
log_clear,
|
||||
log_info_state,
|
||||
proxy_get_settings,
|
||||
proxy_save_settings,
|
||||
proxy_kernel_info,
|
||||
proxy_check_kernel_update,
|
||||
proxy_update_kernel,
|
||||
proxy_install_kernel,
|
||||
proxy_apply_kernel_update,
|
||||
proxy_cancel_kernel_install,
|
||||
proxy_confirm_install,
|
||||
proxy_status,
|
||||
proxy_start,
|
||||
proxy_stop,
|
||||
proxy_traffic,
|
||||
proxy_restart,
|
||||
proxy_version,
|
||||
proxy_get_proxies,
|
||||
@@ -138,24 +250,66 @@ pub fn run() {
|
||||
monitor_elevate_self,
|
||||
monitor_stop,
|
||||
monitor_get_status,
|
||||
monitor_repair_pawnio,
|
||||
monitor_get_snapshot,
|
||||
monitor_get_elevate_on_launch,
|
||||
monitor_set_elevate_on_launch,
|
||||
monitor_get_auto_start,
|
||||
monitor_set_auto_start,
|
||||
monitor_get_hardware_config,
|
||||
monitor_set_hardware_config,
|
||||
network_monitor_status,
|
||||
music_env_status,
|
||||
music_install_runtime,
|
||||
music_cancel_runtime_install,
|
||||
music_ping,
|
||||
music_stop_bridge,
|
||||
music_get_sources,
|
||||
music_search,
|
||||
music_parse_playlist,
|
||||
music_resolve,
|
||||
music_get_settings,
|
||||
music_save_settings,
|
||||
music_download,
|
||||
music_download_cancel,
|
||||
feiniu_list_connections,
|
||||
feiniu_save_connection,
|
||||
feiniu_delete_connection,
|
||||
feiniu_activate_connection,
|
||||
feiniu_test_connection,
|
||||
feiniu_login,
|
||||
feiniu_logout,
|
||||
feiniu_get_config,
|
||||
feiniu_list_tracks,
|
||||
feiniu_lyric,
|
||||
feiniu_media_prefix,
|
||||
feiniu_scan_local,
|
||||
feiniu_cache_status,
|
||||
feiniu_cache_clear,
|
||||
feiniu_cache_fetch,
|
||||
feiniu_fnconnect_resolve,
|
||||
webdav_test,
|
||||
webdav_upload,
|
||||
webdav_delete,
|
||||
webdav_get_secret,
|
||||
webdav_save_secret,
|
||||
feiniu_delete_local,
|
||||
network_status,
|
||||
osd_apply_overlay_style,
|
||||
osd_begin_drag,
|
||||
osd_set_bounds,
|
||||
osd_set_click_through,
|
||||
osd_set_topmost,
|
||||
osd_start_drag_watch,
|
||||
osd_start_topmost_watch,
|
||||
osd_start_game_watch,
|
||||
osd_stop_watch,
|
||||
downloader_get_tasks,
|
||||
downloader_add_task,
|
||||
downloader_check_url,
|
||||
downloader_pause_task,
|
||||
downloader_resume_task,
|
||||
downloader_cancel_task,
|
||||
downloader_redownload,
|
||||
downloader_remove_task,
|
||||
downloader_get_settings,
|
||||
downloader_save_settings,
|
||||
@@ -163,10 +317,14 @@ pub fn run() {
|
||||
downloader_get_extension_info,
|
||||
downloader_open_dir,
|
||||
downloader_open_url,
|
||||
downloader_focus_window,
|
||||
downloader_inspect,
|
||||
downloader_select_bt_files,
|
||||
clipboard_get_history,
|
||||
clipboard_get_pinned,
|
||||
clipboard_search,
|
||||
clipboard_get_item,
|
||||
clipboard_get_thumb,
|
||||
clipboard_set_pinned,
|
||||
clipboard_delete,
|
||||
clipboard_clear,
|
||||
@@ -183,108 +341,84 @@ pub fn run() {
|
||||
clipboard_show_window,
|
||||
clipboard_hide_popup,
|
||||
clipboard_paste_to_target,
|
||||
clipboard_show_preview,
|
||||
clipboard_hide_preview,
|
||||
clipboard_resize_preview,
|
||||
clipboard_reveal_preview,
|
||||
clipboard_preview_interacted,
|
||||
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_hide,
|
||||
tray_menu_show_window,
|
||||
snap_fix::fix_snap_background
|
||||
tray_menu_ready,
|
||||
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_pick_list,
|
||||
screenshot_show_overlay,
|
||||
screenshot_cursor_pos,
|
||||
screenshot_enum_windows,
|
||||
screenshot_capture_window,
|
||||
screenshot_scroll_capture,
|
||||
screenshot_scroll_cancel,
|
||||
screenshot_scroll_finish,
|
||||
screenshot_scroll_start,
|
||||
screenshot_set_scroll_hole,
|
||||
screenshot_take_editor_image_raw,
|
||||
screenshot_copy_image,
|
||||
screenshot_save_png,
|
||||
screenshot_save_cache,
|
||||
screenshot_load_cache,
|
||||
screenshot_load_cache_raw,
|
||||
screenshot_delete_cache,
|
||||
screenshot_register_shortcut,
|
||||
screenshot_unregister_shortcut,
|
||||
screenshot_register_pin_shortcut,
|
||||
screenshot_unregister_pin_shortcut,
|
||||
screenshot_disable_transitions,
|
||||
screenshot_compose_copy,
|
||||
screenshot_compose_png
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化日志系统,日志目录: {app_data_dir}/logs/
|
||||
let log_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("."))
|
||||
.join("logs");
|
||||
app.manage(LogManager::new(log_dir));
|
||||
|
||||
// 初始化 MihomoManager,数据目录: {app_data_dir}/proxy/
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
|
||||
// 永久提权检查:如果标志已设置且当前非管理员,以管理员权限重启自身并退出
|
||||
// 必须在所有模块初始化之前执行(此时无资源需要清理)
|
||||
if monitor_kernel::check_and_relaunch_if_needed(&app_data_dir) {
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let mihomo = MihomoManager::new(app_data_dir.clone());
|
||||
app.manage(mihomo);
|
||||
|
||||
// 初始化 MonitorKernel,数据目录: {app_data_dir}/monitor/
|
||||
let monitor = MonitorKernel::new(app_data_dir.clone());
|
||||
app.manage(monitor);
|
||||
|
||||
// 初始化 NetworkMonitor(网速监控,独立于 ThingHK Kernel)
|
||||
// 网速采样不依赖提权,应用启动即开始
|
||||
let network_monitor = Arc::new(NetworkMonitor::new());
|
||||
app.manage(network_monitor.clone());
|
||||
network_monitor.start(app.handle().clone());
|
||||
|
||||
// 初始化 DownloadEngine,数据目录: {app_data_dir}/downloader/
|
||||
let engine = DownloadEngine::new(
|
||||
app_data_dir.join("downloader"),
|
||||
app.handle().clone(),
|
||||
);
|
||||
let settings = engine.get_settings();
|
||||
app.manage(engine.clone());
|
||||
|
||||
// 启动扩展 HTTP API 服务器
|
||||
let server_engine = engine.clone();
|
||||
let server_port = settings.extension_port;
|
||||
let server_secret = settings.extension_secret.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
ExtensionServer::start(server_engine, server_port, server_secret).await;
|
||||
});
|
||||
|
||||
// 初始化 ClipboardManager,数据目录: {app_data_dir}/clipboard/
|
||||
let clipboard = ClipboardManager::new(app_data_dir.clone());
|
||||
// 应用启动时若已启用则自动开始监听
|
||||
if clipboard.get_settings().enabled {
|
||||
clipboard.start(&app.handle());
|
||||
}
|
||||
// 应用启动时注册快捷弹窗全局快捷键
|
||||
let shortcut = clipboard.get_settings().shortcut.clone();
|
||||
if !shortcut.trim().is_empty() {
|
||||
let app_handle = app.handle().clone();
|
||||
if let Err(e) = clipboard::popup::register_shortcut(&app_handle, &shortcut) {
|
||||
eprintln!("[clipboard] 快捷键注册失败: {}", e);
|
||||
}
|
||||
}
|
||||
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(())
|
||||
})
|
||||
.setup(setup::init)
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
window.hide().ok();
|
||||
api.prevent_close();
|
||||
// 仅主窗口拦截关闭(隐藏到托盘)。其他窗口(截图编辑器/OSD 等)
|
||||
// 调用 close() 是真实销毁语义,全局拦截会导致隐藏窗口累积泄漏。
|
||||
if window.label() == constants::windows::MAIN {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
window.hide().ok();
|
||||
api.prevent_close();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
@@ -298,7 +432,23 @@ pub fn run() {
|
||||
engine.cleanup_on_exit();
|
||||
}
|
||||
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
||||
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
|
||||
// cleanup_on_exit 是 async;在事件循环回调中直接 block_on 有 panic 风险且阻塞退出,
|
||||
// 放到独立 OS 线程执行并通过 channel 限时等待 3s,超时放弃等待直接退出
|
||||
// (进程终止时 OS 回收残留资源),避免清理挂起导致退出卡死。
|
||||
let app_clone = app.clone();
|
||||
let monitor_clone = monitor.inner().clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
tauri::async_runtime::block_on(async move {
|
||||
monitor_clone.cleanup_on_exit(&app_clone).await;
|
||||
});
|
||||
let _ = tx.send(());
|
||||
});
|
||||
let _ = rx.recv_timeout(std::time::Duration::from_secs(3));
|
||||
}
|
||||
if let Some(music) = app.try_state::<MusicManager>() {
|
||||
// 停止音乐桥接进程(kill 快速返回,wait 在后台线程完成)
|
||||
music.cleanup_on_exit();
|
||||
}
|
||||
if let Some(clip) = app.try_state::<ClipboardManager>() {
|
||||
clip.stop();
|
||||
|
||||
+286
-33
@@ -1,8 +1,9 @@
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
/// 日志级别
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -34,11 +35,14 @@ pub struct LogInfo {
|
||||
}
|
||||
|
||||
/// 日志管理器 —— 负责文件轮转、写入、查询
|
||||
/// 所有写/轮转/读操作通过 write_lock 串行化,防止并发写交错与轮转竞争
|
||||
#[derive(Clone)]
|
||||
pub struct LogManager {
|
||||
log_dir: PathBuf,
|
||||
max_file_size: u64,
|
||||
max_files: u32,
|
||||
base_name: String,
|
||||
write_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl LogManager {
|
||||
@@ -48,12 +52,14 @@ impl LogManager {
|
||||
log_dir,
|
||||
max_file_size: 5 * 1024 * 1024,
|
||||
max_files: 5,
|
||||
base_name: "thing".to_string(),
|
||||
base_name: "Thing".to_string(),
|
||||
write_lock: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入一条日志
|
||||
/// 写入一条日志(写 + 轮转在锁内串行执行)
|
||||
pub fn log(&self, level: LogLevel, module: &str, message: &str) {
|
||||
let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let level_str = match level {
|
||||
LogLevel::Debug => "DEBUG",
|
||||
@@ -114,56 +120,57 @@ impl LogManager {
|
||||
}
|
||||
|
||||
/// 读取日志(支持按模块/级别过滤、条数限制)
|
||||
/// 从最新文件向旧文件倒序遍历,每个文件从末尾向前读取,达到 limit 即停止,
|
||||
/// 避免将全部日志读入内存后再排序截断
|
||||
pub fn get_logs(
|
||||
&self,
|
||||
module: Option<&str>,
|
||||
level: Option<LogLevel>,
|
||||
limit: Option<usize>,
|
||||
) -> Vec<LogEntry> {
|
||||
let limit = limit.unwrap_or(100);
|
||||
// 与写入共用同一把锁,避免读到轮转半途/写入半行的状态
|
||||
let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let mut entries: Vec<LogEntry> = Vec::new();
|
||||
|
||||
// 收集所有日志文件(包括轮转文件)
|
||||
let mut log_files: Vec<PathBuf> = Vec::new();
|
||||
// 收集日志文件(最新在前:thing.log → thing.1.log → …)
|
||||
let mut paths: Vec<PathBuf> = Vec::new();
|
||||
let current = self.log_dir.join(format!("{}.log", self.base_name));
|
||||
if current.exists() {
|
||||
log_files.push(current);
|
||||
paths.push(current);
|
||||
}
|
||||
for i in 1..=self.max_files {
|
||||
let rotated = self
|
||||
.log_dir
|
||||
.join(format!("{}.{}.log", self.base_name, i));
|
||||
if rotated.exists() {
|
||||
log_files.push(rotated);
|
||||
paths.push(rotated);
|
||||
}
|
||||
}
|
||||
|
||||
for path in &log_files {
|
||||
if let Ok(file) = File::open(path) {
|
||||
for line in BufReader::new(file).lines().flatten() {
|
||||
if let Some(entry) = Self::parse_line(&line) {
|
||||
if let Some(ref m) = module {
|
||||
if entry.module != *m {
|
||||
continue;
|
||||
}
|
||||
'outer: for path in &paths {
|
||||
// 每文件返回最近 limit 行(从新到旧),收集满即整体停止
|
||||
for line in read_tail_lines(path, limit) {
|
||||
if let Some(entry) = Self::parse_line(&line) {
|
||||
if let Some(ref m) = module {
|
||||
if entry.module != *m {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref l) = level {
|
||||
if entry.level != *l {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(ref l) = level {
|
||||
if entry.level != *l {
|
||||
continue;
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -212,6 +219,7 @@ impl LogManager {
|
||||
|
||||
/// 清空所有日志文件
|
||||
pub fn clear_logs(&self) -> Result<(), String> {
|
||||
let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let current = self.log_dir.join(format!("{}.log", self.base_name));
|
||||
fs::remove_file(¤t).map_err(|e| e.to_string())?;
|
||||
for i in 1..=self.max_files {
|
||||
@@ -259,6 +267,63 @@ impl LogManager {
|
||||
|
||||
// ===== Tauri 命令 =====
|
||||
|
||||
/// 从文件末尾向前读取日志行(返回时间从新到旧的最近 max_lines 行)。
|
||||
/// 按 8KB 块向前 seek 读取并拼接跨块半行,只读取文件尾部,避免全量读入
|
||||
fn read_tail_lines(path: &Path, max_lines: usize) -> Vec<String> {
|
||||
let mut file = match File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let file_len = match file.metadata() {
|
||||
Ok(m) => m.len(),
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
const CHUNK: u64 = 8192;
|
||||
|
||||
// tail 保存"当前块更靠后的半行",下一轮(更早的块)拼在其前
|
||||
let mut tail = String::new();
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut pos = file_len;
|
||||
|
||||
while pos > 0 && lines.len() < max_lines {
|
||||
let start = pos.saturating_sub(CHUNK);
|
||||
let len = (pos - start) as usize;
|
||||
let mut bytes = vec![0u8; len];
|
||||
if file.seek(SeekFrom::Start(start)).is_err() || file.read_exact(&mut bytes).is_err() {
|
||||
break;
|
||||
}
|
||||
pos = start;
|
||||
|
||||
let mut text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
text.push_str(&tail);
|
||||
|
||||
// 最后一段未以 \n 结尾 → 半行,作为下一轮 tail(与本块之前的内容拼接)
|
||||
let mut parts: Vec<&str> = text.split('\n').collect();
|
||||
tail = parts.pop().unwrap_or("").to_string();
|
||||
|
||||
// 从后往前(新到旧)取完整行
|
||||
for part in parts.iter().rev() {
|
||||
let trimmed = part.trim();
|
||||
if !trimmed.is_empty() {
|
||||
lines.push(trimmed.to_string());
|
||||
if lines.len() >= max_lines {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文件头残余(pos == 0 时 tail 里可能是文件第一行)
|
||||
if pos == 0 {
|
||||
let trimmed = tail.trim();
|
||||
if !trimmed.is_empty() && lines.len() < max_lines {
|
||||
lines.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn log_message(
|
||||
state: tauri::State<'_, LogManager>,
|
||||
@@ -278,12 +343,12 @@ pub fn log_message(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_logs(
|
||||
pub async fn log_list(
|
||||
state: tauri::State<'_, LogManager>,
|
||||
module: Option<String>,
|
||||
level: Option<String>,
|
||||
limit: Option<usize>,
|
||||
) -> Vec<LogEntry> {
|
||||
) -> Result<Vec<LogEntry>, String> {
|
||||
let level = level.and_then(|l| match l.as_str() {
|
||||
"debug" => Some(LogLevel::Debug),
|
||||
"info" => Some(LogLevel::Info),
|
||||
@@ -291,15 +356,203 @@ pub fn get_logs(
|
||||
"error" => Some(LogLevel::Error),
|
||||
_ => None,
|
||||
});
|
||||
state.get_logs(module.as_deref(), level, limit)
|
||||
// 文件读取(含 seek 尾部扫描)移出 async runtime 线程
|
||||
let manager = state.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || manager.get_logs(module.as_deref(), level, limit))
|
||||
.await
|
||||
.map_err(|e| format!("读取日志任务失败: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_logs(state: tauri::State<'_, LogManager>) -> Result<(), String> {
|
||||
pub fn log_clear(state: tauri::State<'_, LogManager>) -> Result<(), String> {
|
||||
state.clear_logs()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_log_info(state: tauri::State<'_, LogManager>) -> LogInfo {
|
||||
pub fn log_info_state(state: tauri::State<'_, LogManager>) -> LogInfo {
|
||||
state.get_info()
|
||||
}
|
||||
|
||||
// ===== 进程级全局日志器 =====
|
||||
// 后端模块(无 AppHandle 上下文)通过 log_line 写入统一的日志文件,
|
||||
// 与 Tauri 命令 log_message 共用同一 LogManager(write_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(¤t, "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(¤t, "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(¤t, 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(¤t)
|
||||
.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(¤t).unwrap().len();
|
||||
assert!(cur_len < 100, "轮转后的当前文件应只含新行: {}", cur_len);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
//! 自动切换节点:后端调度,独立于前端模块是否处于激活状态。
|
||||
//! 应用启动后由 `setup.rs` 启动后台任务,按用户设置的间隔周期性测速并切换到当前最优节点,
|
||||
//! 切换完成后通过 `proxy-auto-switch` 事件通知前端刷新界面。
|
||||
//! 节点候选(目标组 + 地区筛选 + 伪节点过滤)与择优逻辑对前端、后台调度、托盘三处保持一致。
|
||||
|
||||
use futures_util::future::join_all;
|
||||
use serde_json::{Map, Value};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use super::{MihomoManager, ProxySettings};
|
||||
use crate::constants::events::PROXY_AUTO_SWITCH;
|
||||
use crate::process_manager::{ProcessManager, ProcessStatus};
|
||||
|
||||
/// 测速目标(同前端/托盘),用于判断节点可用性
|
||||
const TEST_URL: &str = "http://www.gstatic.com/generate_204";
|
||||
/// 单节点测速超时(毫秒)
|
||||
const TEST_TIMEOUT: u32 = 5000;
|
||||
/// 调度轮询粒度(秒)
|
||||
const CHECK_INTERVAL: u64 = 30;
|
||||
|
||||
/// 后端启动自动切换调度任务(幂等,可安全多次调用)。
|
||||
/// 仅在用户开启「自动切换节点」且 mihomo 运行时才会真正执行测速与切换。
|
||||
pub fn start_auto_switch_loop(app: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut last_run: Option<Instant> = None;
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(CHECK_INTERVAL)).await;
|
||||
|
||||
let enabled = app
|
||||
.try_state::<MihomoManager>()
|
||||
.map(|m: State<'_, MihomoManager>| m.load_settings().auto_switch_enabled)
|
||||
.unwrap_or(false);
|
||||
if !enabled {
|
||||
last_run = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
let interval_secs = app
|
||||
.try_state::<MihomoManager>()
|
||||
.map(|m: State<'_, MihomoManager>| m.load_settings().auto_switch_interval.max(1) as u64)
|
||||
.unwrap_or(5)
|
||||
* 60;
|
||||
|
||||
let due = match last_run {
|
||||
Some(t) => t.elapsed().as_secs() >= interval_secs,
|
||||
None => true,
|
||||
};
|
||||
if !due {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((group, name, delay)) = perform_auto_switch(&app).await {
|
||||
let _ = app.emit(
|
||||
PROXY_AUTO_SWITCH,
|
||||
serde_json::json!({
|
||||
"switched": true,
|
||||
"group": group,
|
||||
"name": name,
|
||||
"delay": delay,
|
||||
}),
|
||||
);
|
||||
}
|
||||
last_run = Some(Instant::now());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 执行一次自动切换:若当前不是最优节点则切换并返回 `(组名, 节点名, 延迟)`,否则返回 `None`。
|
||||
/// 供后台调度任务使用(模块内私有)。
|
||||
async fn perform_auto_switch(app: &AppHandle) -> Option<(String, String, u32)> {
|
||||
let mihomo = app.state::<MihomoManager>();
|
||||
let pm = app.state::<ProcessManager>();
|
||||
|
||||
// 仅在 mihomo 运行时执行
|
||||
let running = matches!(
|
||||
pm.get_status("proxy").map(|p| p.status),
|
||||
Some(ProcessStatus::Running)
|
||||
);
|
||||
if !running {
|
||||
return None;
|
||||
}
|
||||
|
||||
let settings = mihomo.load_settings();
|
||||
if !settings.auto_switch_enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (group, name, delay, now) = pick_best(&mihomo, &settings).await.ok()??;
|
||||
|
||||
if name != now && mihomo.select_proxy(&group, &name).await.is_ok() {
|
||||
return Some((group, name, delay));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 按自动切换设置确定候选组与地区,并发测速并返回当前最优节点(不执行切换)。
|
||||
/// `Some((组名, 最优节点, 延迟, 当前节点))`;无候选或全部超时返回 `Ok(None)`。
|
||||
/// 当自动切换关闭时自动切换目标组为空、地区为空,故退化为「主组 + 全部节点」全量择优,
|
||||
/// 托盘「开启代理」与调度任务复用此函数保证行为一致。
|
||||
pub async fn pick_best(
|
||||
mihomo: &MihomoManager,
|
||||
settings: &ProxySettings,
|
||||
) -> Result<Option<(String, String, u32, String)>, String> {
|
||||
let proxies = mihomo.get_proxies().await?;
|
||||
let map = proxies
|
||||
.get("proxies")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.ok_or_else(|| "无法解析代理数据".to_string())?;
|
||||
|
||||
let group_name = if !settings.auto_switch_group.is_empty() {
|
||||
settings.auto_switch_group.clone()
|
||||
} else {
|
||||
match main_selector_group(&map) {
|
||||
Some(g) => g,
|
||||
None => return Ok(None),
|
||||
}
|
||||
};
|
||||
|
||||
let group = match map.get(&group_name) {
|
||||
Some(g) => g,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let now = group
|
||||
.get("now")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let mut nodes: Vec<String> = group
|
||||
.get("all")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|n| n.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default();
|
||||
if nodes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 过滤伪节点(DIRECT、REJECT、流量、套餐等)
|
||||
nodes.retain(|n| !super::is_pseudo_node(n));
|
||||
// 地区筛选
|
||||
if !settings.auto_switch_region.is_empty() {
|
||||
let region = settings.auto_switch_region.clone();
|
||||
nodes.retain(|n| extract_region(n) == region);
|
||||
}
|
||||
if nodes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 并发测速
|
||||
let futures = nodes.iter().map(|name| async move {
|
||||
let d = mihomo.test_delay(name, TEST_URL, TEST_TIMEOUT).await.ok();
|
||||
(name.clone(), d)
|
||||
}).collect::<Vec<_>>();
|
||||
let results = join_all(futures).await;
|
||||
let best = results
|
||||
.into_iter()
|
||||
.filter_map(|(n, d)| d.filter(|x| *x > 0).map(|x| (n, x)))
|
||||
.min_by_key(|(_, d)| *d);
|
||||
|
||||
Ok(best.map(|(name, delay)| (group_name, name, delay, now)))
|
||||
}
|
||||
|
||||
/// 主选择分组:选取名为 PROXY/Proxy/节点选择/代理 的 Selector 组;找不到时回退到第一个 Selector 组
|
||||
fn main_selector_group(map: &Map<String, Value>) -> Option<String> {
|
||||
let mut preferred: Option<String> = None;
|
||||
let mut first_selector: Option<String> = None;
|
||||
for (name, val) in map {
|
||||
let kind = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if kind != "Selector" {
|
||||
continue;
|
||||
}
|
||||
if first_selector.is_none() {
|
||||
first_selector = Some(name.clone());
|
||||
}
|
||||
if preferred.is_none() && ["PROXY", "Proxy", "节点选择", "代理"].contains(&name.as_str()) {
|
||||
preferred = Some(name.clone());
|
||||
}
|
||||
}
|
||||
preferred.or(first_selector)
|
||||
}
|
||||
|
||||
/// 提取节点地区(与前端 extractRegion 保持一致):取名称中第一个数字/符号分隔符之前的文本
|
||||
fn extract_region(name: &str) -> String {
|
||||
for (idx, ch) in name.char_indices() {
|
||||
if ch.is_ascii_digit() || matches!(ch, '|' | '-' | '-' | '—' | '(' | '(' | '【') {
|
||||
return name[..idx].trim().to_string();
|
||||
}
|
||||
}
|
||||
name.trim().to_string()
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! 代理模块 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, TrafficSnapshot,
|
||||
};
|
||||
|
||||
use crate::process_manager::{ProcessInfo, ProcessManager, ProcessStatus};
|
||||
|
||||
/// 判断 mihomo 进程是否处于运行状态
|
||||
fn mihomo_running(pm: &ProcessManager) -> bool {
|
||||
matches!(
|
||||
pm.get_status("proxy").map(|p| p.status),
|
||||
Some(ProcessStatus::Running)
|
||||
)
|
||||
}
|
||||
|
||||
#[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 fn proxy_cancel_kernel_install(state: State<'_, MihomoManager>) -> Result<(), String> {
|
||||
state.cancel_kernel_install();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 前端确认 mihomo 已停止,唤醒等待中的安装流程继续解压替换。
|
||||
/// (下载阶段允许 mihomo 运行以便走系统代理,解压替换前必须停止 mihomo,否则 exe 被占用)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn proxy_confirm_install(state: State<'_, MihomoManager>) -> Result<(), String> {
|
||||
state.confirm_install();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn proxy_apply_kernel_update(
|
||||
state: State<'_, MihomoManager>,
|
||||
app: AppHandle,
|
||||
zip_path: String,
|
||||
) -> Result<KernelInfo, String> {
|
||||
let path = std::path::PathBuf::from(zip_path);
|
||||
state.apply_kernel_update(&app, path).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)?;
|
||||
let info = pm.start(params)?;
|
||||
// 手动启动也遵循「启动时自动开启系统代理」设置
|
||||
state.apply_auto_system_proxy();
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn proxy_stop(state: State<'_, MihomoManager>, pm: State<'_, ProcessManager>) -> Result<(), String> {
|
||||
// 关闭 mihomo 时同步关闭系统代理(若开启),避免系统代理指向已停止的端口导致断网
|
||||
if get_system_proxy_windows() {
|
||||
let _ = state.disable_system_proxy();
|
||||
}
|
||||
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))?;
|
||||
|
||||
// 启动并等待 API 就绪,带有限重试(共 3 次):
|
||||
// - 冷加载大订阅(首次解析 + geo 下载)可能远超过前端 waitForApi 的 10s 预算,
|
||||
// 这里在命令内等满就绪,避免重启成功后仍被前端误判为「重启失败」导致节点不刷新。
|
||||
// - 偶发的端口未及时释放 / 启动瞬间退出,通过重试自愈。
|
||||
let mut last_err = "mihomo 启动失败".to_string();
|
||||
for _ in 0..3 {
|
||||
match start_and_wait(state.inner(), pm.inner(), &app).await {
|
||||
Ok(info) => return Ok(info),
|
||||
Err(e) => {
|
||||
last_err = e;
|
||||
// 上个实例刚退出,多等一会儿释放端口再重试
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1200));
|
||||
})
|
||||
.await
|
||||
.map_err(|x| format!("sleep 失败: {}", x))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 所有启动尝试均失败:mihomo 已停止,同步关闭系统代理(若开启),避免代理指向已停止端口导致断网
|
||||
if get_system_proxy_windows() {
|
||||
let _ = state.disable_system_proxy();
|
||||
}
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
/// 启动 mihomo 并等待其 HTTP API 就绪(最多 20s)。
|
||||
/// - 若启动后进程立即退出,快速返回(不白等满预算),便于外层尽早重试。
|
||||
/// - 若 pm.start 返回「已在运行中」,说明崩溃监控已抢先拉起进程,同样等待其 API 就绪即可。
|
||||
async fn start_and_wait(
|
||||
state: &MihomoManager,
|
||||
pm: &ProcessManager,
|
||||
app: &AppHandle,
|
||||
) -> Result<ProcessInfo, String> {
|
||||
let params = state.prepare_for_start(app)?;
|
||||
if let Err(e) = pm.start(params) {
|
||||
// "已在运行中" = 崩溃监控已拉起,不算失败;其余为上抛
|
||||
if !e.contains("运行中") {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
// 进程已退出且 API 未就绪 → 启动失败(快速失败,交外层重试)
|
||||
if let Some(st) = pm.get_status("proxy") {
|
||||
if !matches!(st.status, ProcessStatus::Running) {
|
||||
return Err("mihomo 启动后立即退出".to_string());
|
||||
}
|
||||
}
|
||||
if state.get_version().await.is_ok() {
|
||||
return pm.get_status("proxy").ok_or_else(|| "mihomo 进程不存在".to_string());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err("mihomo 启动超时,API 无响应".to_string());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[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_traffic(
|
||||
state: State<'_, MihomoManager>,
|
||||
) -> Result<TrafficSnapshot, String> {
|
||||
state.traffic_snapshot().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>,
|
||||
pm: State<'_, ProcessManager>,
|
||||
) -> Result<(), String> {
|
||||
// 停机时禁止开启系统代理:否则系统代理指向已停止的端口,会导致所有网络请求失败
|
||||
if !mihomo_running(&pm) {
|
||||
return Err("mihomo 未运行,无法开启系统代理".into());
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
//! 内核(mihomo.exe)安装 / 更新 / 版本查询。
|
||||
//! 子模块通过 `impl super::MihomoManager` 为管理器追加方法,可访问父模块私有字段。
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use crate::constants::events::KERNEL_INSTALL_PROGRESS;
|
||||
use super::{InstallProgress, KernelInfo, KernelUpdateInfo, MihomoManager};
|
||||
|
||||
/// 用户主动取消下载的标记错误信息(前端据此静默处理,不弹错误 toast)
|
||||
const KERNEL_CANCELLED: &str = "下载已取消";
|
||||
|
||||
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();
|
||||
|
||||
// 构造下载 URL:mihomo 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 ¤t {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。
|
||||
/// zip_path: 下载模块下载完成的 zip 文件路径。
|
||||
/// 任何阶段失败都会 emit error 事件,避免前端进度卡住。
|
||||
pub async fn apply_kernel_update(&self, app: &AppHandle, zip_path: PathBuf) -> Result<KernelInfo, String> {
|
||||
self.kernel_cancel.store(false, Ordering::SeqCst);
|
||||
let _ = self.kernel_cancel_tx.send(false);
|
||||
let result = self.apply_kernel_inner(app, zip_path).await;
|
||||
if let Err(ref e) = result {
|
||||
if e != KERNEL_CANCELLED {
|
||||
let _ = app.emit(
|
||||
KERNEL_INSTALL_PROGRESS,
|
||||
InstallProgress {
|
||||
stage: "error".into(),
|
||||
percent: 0,
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
message: e.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
self.kernel_cancel.store(false, Ordering::SeqCst);
|
||||
let _ = self.kernel_cancel_tx.send(false);
|
||||
result
|
||||
}
|
||||
|
||||
async fn apply_kernel_inner(&self, app: &AppHandle, zip_path: PathBuf) -> Result<KernelInfo, String> {
|
||||
let extract_dir = self.cores_dir().join("mihomo-update-tmp");
|
||||
|
||||
// 检查 zip 文件是否存在
|
||||
if !zip_path.exists() {
|
||||
return Err(format!("下载文件不存在: {}", zip_path.display()));
|
||||
}
|
||||
|
||||
// 解压替换前需要等待前端确认 mihomo 已停止(否则 exe 文件被占用)
|
||||
if self.kernel_cancel.load(Ordering::SeqCst) {
|
||||
return Err(KERNEL_CANCELLED.to_string());
|
||||
}
|
||||
let _ = app.emit(
|
||||
KERNEL_INSTALL_PROGRESS,
|
||||
InstallProgress {
|
||||
stage: "need_stop".into(),
|
||||
percent: 90,
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
message: "需要停止 mihomo 才能继续安装".into(),
|
||||
},
|
||||
);
|
||||
// 创建 oneshot 通道等待前端确认
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
|
||||
*self.install_confirm.lock().unwrap() = Some(tx);
|
||||
let mut cancel_rx = self.kernel_cancel_tx.subscribe();
|
||||
loop {
|
||||
if self.kernel_cancel.load(Ordering::SeqCst) {
|
||||
*self.install_confirm.lock().unwrap() = None;
|
||||
return Err(KERNEL_CANCELLED.to_string());
|
||||
}
|
||||
tokio::select! {
|
||||
_ = &mut rx => break,
|
||||
_ = cancel_rx.changed() => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 解压阶段
|
||||
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 文件
|
||||
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)
|
||||
}
|
||||
|
||||
/// 用 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
//! 代理模块(mihomo 管理器):按功能域拆分子模块。
|
||||
//!
|
||||
//! - [`MihomoManager`]:核心状态与目录/设置/配置/API 方法
|
||||
//! - [`kernel`]:内核安装与更新
|
||||
//! - [`profiles`]:订阅管理
|
||||
//! - [`system_proxy`]:Windows 系统代理开关
|
||||
//! - [`commands`]:Tauri 命令层
|
||||
|
||||
mod autoswitch;
|
||||
mod commands;
|
||||
mod kernel;
|
||||
mod profiles;
|
||||
mod pseudo;
|
||||
mod system_proxy;
|
||||
mod types;
|
||||
|
||||
pub use autoswitch::{pick_best, start_auto_switch_loop};
|
||||
pub use pseudo::is_pseudo_node;
|
||||
pub use system_proxy::get_system_proxy_windows;
|
||||
pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot};
|
||||
pub use commands::{
|
||||
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
||||
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies,
|
||||
proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_traffic,
|
||||
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_profile, proxy_version,
|
||||
};
|
||||
|
||||
use reqwest::Client;
|
||||
use serde_yaml::Value as YamlValue;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::process_manager::{ProcessManager, StartProcessParams};
|
||||
|
||||
// ===================== MihomoManager =====================
|
||||
|
||||
/// settings 内存缓存条目(短时复用,避免高频状态轮询反复读盘)
|
||||
struct SettingsCacheEntry {
|
||||
read_at: Instant,
|
||||
settings: ProxySettings,
|
||||
}
|
||||
|
||||
/// 流量速率差分基线:记录上次采样的会话总量与时刻,用于计算实时速率
|
||||
struct TrafficBaseline {
|
||||
download_total: u64,
|
||||
upload_total: u64,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
pub struct MihomoManager {
|
||||
root: PathBuf,
|
||||
client: Client,
|
||||
settings_cache: Mutex<Option<SettingsCacheEntry>>,
|
||||
/// 流量速率差分基线:记录上次采样总量与时刻,由两次 /connections 总量差异计算实时速率
|
||||
traffic_baseline: Mutex<Option<TrafficBaseline>>,
|
||||
/// 内核安装/更新的取消标志(前端「停止下载」置位,下载循环轮询后中止)
|
||||
kernel_cancel: Arc<AtomicBool>,
|
||||
/// 取消唤醒通道:让停滞在流式读取(stream.next 最多等 30s)中的下载立即感知取消,
|
||||
/// 否则旧任务会残留最长 30s,期间可能与新任务并发写临时文件/互相重置取消标志
|
||||
kernel_cancel_tx: tokio::sync::watch::Sender<bool>,
|
||||
/// 下载完成后解压替换前的确认通道:mihomo 运行时下载不受影响,但解压替换前
|
||||
/// 必须等前端确认已停止 mihomo(否则 exe 文件被占用)。前端确认后通过
|
||||
/// proxy_confirm_install 命令发送信号唤醒等待。
|
||||
/// 用 std Mutex 而非 tokio Mutex:锁只短暂存取 sender,不跨 await 持有。
|
||||
install_confirm: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
||||
}
|
||||
|
||||
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),
|
||||
traffic_baseline: Mutex::new(None),
|
||||
kernel_cancel: Arc::new(AtomicBool::new(false)),
|
||||
kernel_cancel_tx: tokio::sync::watch::channel(false).0,
|
||||
install_confirm: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求取消内核安装/更新(由 proxy_cancel_kernel_install 命令调用)
|
||||
pub fn cancel_kernel_install(&self) {
|
||||
self.kernel_cancel.store(true, Ordering::SeqCst);
|
||||
// 唤醒停滞的流式下载循环,使其立即中止而不是等 30s 超时
|
||||
let _ = self.kernel_cancel_tx.send(true);
|
||||
}
|
||||
|
||||
/// 前端确认 mihomo 已停止,唤醒等待中的安装流程继续解压替换(由 proxy_confirm_install 命令调用)
|
||||
pub fn confirm_install(&self) {
|
||||
if let Some(tx) = self.install_confirm.lock().unwrap().take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
|
||||
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 启动成功后,若配置了「启动时自动开启系统代理」且当前未开,则开启系统代理。
|
||||
/// 手动启动与 App 自启共用,保证设置语义一致(mihomo 运行期间自动跟随系统代理)。
|
||||
pub fn apply_auto_system_proxy(&self) {
|
||||
let settings = self.load_settings();
|
||||
if !settings.auto_system_proxy {
|
||||
return;
|
||||
}
|
||||
// 以注册表实际状态为准判断是否已开启:settings.system_proxy 是会话内标志,
|
||||
// 上次退出 cleanup_on_exit 只清注册表不会回写该标志,重启后会残留 true,
|
||||
// 若用它做守卫会导致「启动时自动开启系统代理」永远被短路而不生效。
|
||||
if system_proxy::get_system_proxy_windows() {
|
||||
return;
|
||||
}
|
||||
let addr = format!("127.0.0.1:{}", settings.mixed_port);
|
||||
if let Err(e) = system_proxy::set_system_proxy_windows(&addr) {
|
||||
crate::logger::log_warn("mihomo", &format!("自动开启系统代理失败: {}", e));
|
||||
return;
|
||||
}
|
||||
let mut s = settings;
|
||||
s.system_proxy = true;
|
||||
let _ = self.save_settings(&s);
|
||||
}
|
||||
|
||||
/// 应用启动时检查是否需要自动启动 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 {
|
||||
// 启动成功后按「启动时自动开启系统代理」设置决定是否开启系统代理
|
||||
self.apply_auto_system_proxy();
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/// 拉取 /connections 并计算实时流量快照。
|
||||
/// 速率由两次采样的会话总量差分得出;mihomo 重启导致总量回退时自动重置基线。
|
||||
pub async fn traffic_snapshot(&self) -> Result<TrafficSnapshot, String> {
|
||||
let conns = self.get_connections().await?;
|
||||
let upload_total = conns["uploadTotal"].as_u64().unwrap_or(0);
|
||||
let download_total = conns["downloadTotal"].as_u64().unwrap_or(0);
|
||||
let active_connections = conns["connections"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||
|
||||
let (upload_speed, download_speed) = {
|
||||
let base = self.traffic_baseline.lock().unwrap();
|
||||
match base.as_ref() {
|
||||
// 正常差分:总量单调递增才计算速率
|
||||
Some(b) if upload_total >= b.upload_total && download_total >= b.download_total => {
|
||||
let dt = b.at.elapsed().as_secs_f64();
|
||||
if dt > 0.0 {
|
||||
let up = ((upload_total - b.upload_total) as f64 / dt).max(0.0) as u64;
|
||||
let down = ((download_total - b.download_total) as f64 / dt).max(0.0) as u64;
|
||||
(up, down)
|
||||
} else {
|
||||
(0, 0)
|
||||
}
|
||||
}
|
||||
// 无基线或总量回退(mihomo 重启):本帧速率为 0,下方重置基线
|
||||
_ => (0, 0),
|
||||
}
|
||||
};
|
||||
|
||||
*self.traffic_baseline.lock().unwrap() = Some(TrafficBaseline {
|
||||
download_total,
|
||||
upload_total,
|
||||
at: Instant::now(),
|
||||
});
|
||||
|
||||
Ok(TrafficSnapshot {
|
||||
download_total,
|
||||
upload_total,
|
||||
download_speed,
|
||||
upload_speed,
|
||||
active_connections,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// ===================== 数据结构 =====================
|
||||
// 代理模块的设置 / 订阅元信息 / 内核信息 / 安装进度等共享类型。
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/// 实时流量快照(由 /connections 的会话总量差分得出实时速率)
|
||||
#[derive(Serialize, Clone, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrafficSnapshot {
|
||||
/// 本次会话累计下载字节数
|
||||
pub download_total: u64,
|
||||
/// 本次会话累计上传字节数
|
||||
pub upload_total: u64,
|
||||
/// 实时下载速率(字节/秒)
|
||||
pub download_speed: u64,
|
||||
/// 实时上传速率(字节/秒)
|
||||
pub upload_speed: u64,
|
||||
/// 当前活跃连接数
|
||||
pub active_connections: usize,
|
||||
}
|
||||
|
||||
/// 内核安装进度事件载荷
|
||||
/// - 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,
|
||||
}
|
||||
+227
-38
@@ -132,6 +132,41 @@ pub fn is_thing_elevated() -> bool {
|
||||
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
|
||||
@@ -175,11 +210,11 @@ pub fn check_and_relaunch_if_needed(app_data_dir: &std::path::Path) -> bool {
|
||||
Ok(e) => e,
|
||||
Err(_) => return false,
|
||||
};
|
||||
eprintln!("[monitor] 检测到永久提权标志,以管理员权限重启 Thing");
|
||||
crate::logger::log_info("monitor", "检测到永久提权标志,以管理员权限重启 Thing");
|
||||
match shell_execute_elevated(&exe.to_string_lossy(), "", None) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
eprintln!("[monitor] 永久提权重启失败(用户可能取消了 UAC): {}", e);
|
||||
crate::logger::log_warn("monitor", &format!("永久提权重启失败(用户可能取消了 UAC): {}", e));
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -193,6 +228,8 @@ pub fn check_and_relaunch_if_needed(app_data_dir: &std::path::Path) -> bool {
|
||||
pub struct KernelStatus {
|
||||
pub ready: bool,
|
||||
pub is_admin: bool,
|
||||
/// PawnIO 驱动是否已安装;旧版内核无此字段,Option 兼容
|
||||
pub pawn_io_installed: Option<bool>,
|
||||
pub uptime_ms: f64,
|
||||
pub group_count: u32,
|
||||
pub sensor_count: u32,
|
||||
@@ -247,6 +284,17 @@ pub struct MonitorKernel {
|
||||
listener_ids: Arc<Mutex<Vec<tauri::EventId>>>,
|
||||
/// 是否以管理员权限运行(提权模式下进程不受 ProcessManager 管控,停止走 /shutdown)
|
||||
elevated: Arc<AtomicBool>,
|
||||
/// SSE 订阅循环活跃标志(重入守卫,防止并发/重复订阅导致双 emit monitor-data)
|
||||
subscribing: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// SSE 订阅循环标志守卫:Drop 时复位 subscribing,覆盖所有 return / abort 路径
|
||||
struct SubGuard(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for SubGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl MonitorKernel {
|
||||
@@ -265,6 +313,7 @@ impl MonitorKernel {
|
||||
sub_handle: Arc::new(Mutex::new(None)),
|
||||
listener_ids: Arc::new(Mutex::new(Vec::new())),
|
||||
elevated: Arc::new(AtomicBool::new(false)),
|
||||
subscribing: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +332,32 @@ impl MonitorKernel {
|
||||
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 {
|
||||
self.root.join("cores")
|
||||
}
|
||||
@@ -297,7 +372,9 @@ impl MonitorKernel {
|
||||
self.root.join("hardware-config.json")
|
||||
}
|
||||
|
||||
/// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制
|
||||
/// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制。
|
||||
/// 同时把 PawnIO_setup.exe(可选资源)复制过去——内核提权启动时会静默安装它,
|
||||
/// 作为 WinRing0 被系统/杀软拦截时读取温度/频率的替代驱动。
|
||||
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<MonitorKernelInfo, String> {
|
||||
let kernel = self.kernel_path();
|
||||
if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
|
||||
@@ -312,6 +389,24 @@ impl MonitorKernel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PawnIO 安装器:可选资源,缺失时仅影响自动安装能力(不影响内核运行)
|
||||
if let Ok(setup_src) = app.path().resolve("binaries/PawnIO_setup.exe", BaseDirectory::Resource) {
|
||||
if setup_src.exists() {
|
||||
let setup_dest = self.cores_dir().join("PawnIO_setup.exe");
|
||||
let need_copy = !setup_dest.exists()
|
||||
|| fs::metadata(&setup_src)
|
||||
.and_then(|s| fs::metadata(&setup_dest).map(|d| s.len() != d.len()))
|
||||
.unwrap_or(true);
|
||||
if need_copy {
|
||||
fs::create_dir_all(self.cores_dir()).ok();
|
||||
if let Err(e) = fs::copy(&setup_src, &setup_dest) {
|
||||
crate::logger::log_warn("monitor", &format!("复制 PawnIO_setup.exe 失败: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MonitorKernelInfo {
|
||||
path: kernel.to_string_lossy().to_string(),
|
||||
exists: kernel.exists(),
|
||||
@@ -381,10 +476,18 @@ impl MonitorKernel {
|
||||
/// 流程:轮询 /status 等 ready → 订阅 /stream → 解析 SSE 事件 → emit "monitor-data"
|
||||
/// 写入熔断:SSE 断开后停止 emit,等待外部调用 reconnect 或 process-status-changed 触发重连
|
||||
pub async fn start_subscription(self: Self, app: AppHandle) {
|
||||
// 重入守卫:已有订阅循环在运行则跳过,防止并发/重复订阅(双 emit monitor-data)
|
||||
if self.subscribing.swap(true, Ordering::SeqCst) {
|
||||
crate::logger::log_warn("monitor", "已有 SSE 订阅循环运行中,跳过重复订阅");
|
||||
return;
|
||||
}
|
||||
// Drop 时复位标志,覆盖所有 return 路径(含 stop 时 abort 取消)
|
||||
let _guard = SubGuard(self.subscribing.clone());
|
||||
|
||||
// 1. 轮询等待 Kernel ready(冷启动约 5s)
|
||||
if let Err(e) = self.wait_for_ready(&app).await {
|
||||
eprintln!("[monitor] 等待 Kernel ready 失败,订阅不启动: {}", e);
|
||||
let _ = app.emit("monitor-error", serde_json::json!({ "stage": "ready", "message": e }));
|
||||
crate::logger::log_warn("monitor", &format!("等待 Kernel ready 失败,订阅不启动: {}", e));
|
||||
let _ = app.emit(crate::constants::events::MONITOR_ERROR, serde_json::json!({ "stage": "ready", "message": e }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -408,12 +511,20 @@ impl MonitorKernel {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
match resp.json::<KernelStatus>().await {
|
||||
Ok(s) if s.ready => {
|
||||
// PawnIO 诊断:已提权但驱动缺失时,温度/频率等 ring0 传感器大概率无法读取
|
||||
if s.is_admin && s.pawn_io_installed == Some(false) {
|
||||
crate::logger::log_warn(
|
||||
"monitor",
|
||||
"Kernel 已提权但 PawnIO 驱动未安装,CPU 温度/频率可能无法读取(检查 cores/PawnIO_setup.exe 是否随包部署)",
|
||||
);
|
||||
}
|
||||
let _ = app.emit(
|
||||
"monitor-ready",
|
||||
crate::constants::events::MONITOR_READY,
|
||||
serde_json::json!({
|
||||
"isAdmin": s.is_admin,
|
||||
"sensorCount": s.sensor_count,
|
||||
"providers": s.providers,
|
||||
"pawnIoInstalled": s.pawn_io_installed,
|
||||
}),
|
||||
);
|
||||
return Ok(());
|
||||
@@ -426,8 +537,10 @@ impl MonitorKernel {
|
||||
Err(e) => last_err = e.to_string(),
|
||||
}
|
||||
// 通知前端正在加载(前端可显示 "Kernel 启动中...")
|
||||
let elapsed = READY_TIMEOUT_MS.saturating_sub(deadline.duration_since(std::time::Instant::now()).as_millis() as u64);
|
||||
let _ = app.emit("monitor-loading", serde_json::json!({ "elapsedMs": elapsed }));
|
||||
// 用 saturating_duration_since:now 超过 deadline 时返回 0,避免 duration_since panic
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now()).as_millis() as u64;
|
||||
let elapsed = READY_TIMEOUT_MS.saturating_sub(remaining);
|
||||
let _ = app.emit(crate::constants::events::MONITOR_LOADING, serde_json::json!({ "elapsedMs": elapsed }));
|
||||
tokio::time::sleep(Duration::from_millis(READY_POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
Err(format!("Kernel 在 {}ms 内未就绪: {}", READY_TIMEOUT_MS, last_err))
|
||||
@@ -441,24 +554,24 @@ impl MonitorKernel {
|
||||
match self.subscribe_once(&url, &app).await {
|
||||
// 正常结束(客户端取消或服务端关闭)
|
||||
Ok(()) => {
|
||||
eprintln!("[monitor] SSE 流正常结束");
|
||||
crate::logger::log_info("monitor", "SSE 流正常结束");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[monitor] SSE 流异常断开: {},3s 后重试", e);
|
||||
crate::logger::log_warn("monitor", &format!("SSE 流异常断开: {},3s 后重试", e));
|
||||
let _ = app.emit(
|
||||
"monitor-disconnected",
|
||||
crate::constants::events::MONITOR_DISCONNECTED,
|
||||
serde_json::json!({ "message": e }),
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
// 重连前先确认 Kernel 是否还活着(可能已被 stop)
|
||||
if !self.is_kernel_alive().await {
|
||||
eprintln!("[monitor] Kernel 已停止,退出 SSE 循环");
|
||||
crate::logger::log_info("monitor", "Kernel 已停止,退出 SSE 循环");
|
||||
// 提权模式下 Kernel 崩溃/退出后重置 elevated 标志,
|
||||
// 否则 monitor_status 会一直认为提权模式但 Kernel 已死,用户无法重启
|
||||
if self.is_elevated() {
|
||||
self.elevated.store(false, Ordering::SeqCst);
|
||||
eprintln!("[monitor] 提权 Kernel 已退出,重置 elevated 标志");
|
||||
crate::logger::log_info("monitor", "提权 Kernel 已退出,重置 elevated 标志");
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -497,7 +610,7 @@ impl MonitorKernel {
|
||||
|
||||
if let Some(json_str) = parse_sse_data(&event_str) {
|
||||
if let Ok(snap) = serde_json::from_str::<SensorSnapshot>(&json_str) {
|
||||
let _ = app.emit("monitor-data", snap);
|
||||
let _ = app.emit(crate::constants::events::MONITOR_DATA, snap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,9 +632,11 @@ impl MonitorKernel {
|
||||
|
||||
/// 停止 SSE 订阅(进程由 ProcessManager.stop 负责)
|
||||
pub async fn stop_subscription(&self, app: &AppHandle) {
|
||||
// 取消 SSE 任务
|
||||
// 取消 SSE 任务并等待其退出,确保 subscribing 重入守卫复位。
|
||||
// 否则 stop 后立即 start 时旧任务仍在 Drop,新订阅会被守卫误跳过。
|
||||
if let Some(handle) = self.sub_handle.lock().await.take() {
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
// 取消 process-status-changed 监听
|
||||
let ids = self.listener_ids.lock().await.drain(..).collect::<Vec<_>>();
|
||||
@@ -552,7 +667,7 @@ impl MonitorKernel {
|
||||
let this = this.clone();
|
||||
let app = app_clone.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
eprintln!("[monitor] 检测到 Kernel 重启恢复,重新订阅 SSE");
|
||||
crate::logger::log_info("monitor", "检测到 Kernel 重启恢复,重新订阅 SSE");
|
||||
// 重启后需要重新等待 ready(冷启动约 5s)
|
||||
this.clone().start_subscription(app).await;
|
||||
});
|
||||
@@ -585,20 +700,23 @@ impl MonitorKernel {
|
||||
if status.ready {
|
||||
if thing_elevated {
|
||||
// Thing 是管理员:停止已有 Kernel(无论什么权限),用 ProcessManager 重启以继承权限
|
||||
eprintln!("[monitor] Thing 已提权,重启 ThingHK 以继承管理员权限");
|
||||
crate::logger::log_info("monitor", "Thing 已提权,重启 ThingHK 以继承管理员权限");
|
||||
let _ = self.shutdown_kernel().await;
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
} else if status.is_admin {
|
||||
// Thing 非管理员,但已有提权 Kernel:直接接管
|
||||
eprintln!("[monitor] 检测到已有提权 Kernel 运行中,直接接管");
|
||||
crate::logger::log_info("monitor", "检测到已有提权 Kernel 运行中,直接接管");
|
||||
self.elevated.store(true, Ordering::SeqCst);
|
||||
let kernel = self.clone();
|
||||
kernel.clone().register_auto_reconnect(app.clone()).await;
|
||||
let app_clone = app.clone();
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
kernel.start_subscription(app_clone).await;
|
||||
});
|
||||
*self.sub_handle.lock().await = Some(handle);
|
||||
// 已有订阅循环在运行则不覆盖句柄(否则旧任务句柄丢失,stop 无法取消)
|
||||
if !kernel.subscribing.load(Ordering::SeqCst) {
|
||||
let app_clone = app.clone();
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
kernel.start_subscription(app_clone).await;
|
||||
});
|
||||
*self.sub_handle.lock().await = Some(handle);
|
||||
}
|
||||
// 提权模式下无 ProcessInfo,返回一个占位的
|
||||
return Ok(crate::process_manager::ProcessInfo {
|
||||
id: PROCESS_ID.into(),
|
||||
@@ -609,7 +727,7 @@ impl MonitorKernel {
|
||||
});
|
||||
} else {
|
||||
// Thing 非管理员,已有普通 Kernel:先停止
|
||||
eprintln!("[monitor] 检测到已有普通权限 Kernel 运行中,先停止再重启");
|
||||
crate::logger::log_info("monitor", "检测到已有普通权限 Kernel 运行中,先停止再重启");
|
||||
let _ = self.shutdown_kernel().await;
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
@@ -623,7 +741,7 @@ impl MonitorKernel {
|
||||
// Thing 是管理员时,ThingHK 继承权限,标记 elevated(仍由 ProcessManager 管理)
|
||||
if thing_elevated {
|
||||
self.elevated.store(true, Ordering::SeqCst);
|
||||
eprintln!("[monitor] ThingHK 已以管理员权限启动(继承自 Thing)");
|
||||
crate::logger::log_info("monitor", "ThingHK 已以管理员权限启动(继承自 Thing)");
|
||||
}
|
||||
|
||||
// 用 self 的 clone(共享 Arc<Mutex> 状态)启动订阅,
|
||||
@@ -631,11 +749,14 @@ impl MonitorKernel {
|
||||
// stop_subscription 时才能正确清理 listener。
|
||||
let kernel = self.clone();
|
||||
kernel.clone().register_auto_reconnect(app.clone()).await;
|
||||
let app_clone = app.clone();
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
kernel.start_subscription(app_clone).await;
|
||||
});
|
||||
*self.sub_handle.lock().await = Some(handle);
|
||||
// 已有订阅循环在运行则不覆盖句柄(否则旧任务句柄丢失,stop 无法取消)
|
||||
if !kernel.subscribing.load(Ordering::SeqCst) {
|
||||
let app_clone = app.clone();
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
kernel.start_subscription(app_clone).await;
|
||||
});
|
||||
*self.sub_handle.lock().await = Some(handle);
|
||||
}
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
@@ -703,17 +824,20 @@ impl MonitorKernel {
|
||||
}
|
||||
|
||||
self.elevated.store(true, Ordering::SeqCst);
|
||||
eprintln!("[monitor] 提权启动已发起,等待 Kernel ready");
|
||||
crate::logger::log_info("monitor", "提权启动已发起,等待 Kernel ready");
|
||||
|
||||
// 5. 启动 SSE 订阅(提权模式不注册 register_auto_reconnect:
|
||||
// 提权进程不归 ProcessManager 管,process-status-changed 事件不会触发,
|
||||
// 注册了反而可能在其他进程状态变化时误触发 SSE 重连)
|
||||
let kernel = self.clone();
|
||||
let app_clone = app.clone();
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
kernel.start_subscription(app_clone).await;
|
||||
});
|
||||
*self.sub_handle.lock().await = Some(handle);
|
||||
// 已有订阅循环在运行则不覆盖句柄(否则旧任务句柄丢失,stop 无法取消)
|
||||
if !kernel.subscribing.load(Ordering::SeqCst) {
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
kernel.start_subscription(app_clone).await;
|
||||
});
|
||||
*self.sub_handle.lock().await = Some(handle);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -745,7 +869,7 @@ impl MonitorKernel {
|
||||
self.stop_subscription(app).await;
|
||||
// 仅当 ThingHK 提权但 Thing 未提权时才需要 /shutdown(ProcessManager 无法 kill 管理员进程)
|
||||
if self.is_elevated() && !is_thing_elevated() {
|
||||
eprintln!("[monitor] 退出清理:停止提权 Kernel(/shutdown)");
|
||||
crate::logger::log_info("monitor", "退出清理:停止提权 Kernel(/shutdown)");
|
||||
let _ = self.shutdown_kernel().await;
|
||||
self.elevated.store(false, Ordering::SeqCst);
|
||||
}
|
||||
@@ -852,13 +976,13 @@ pub async fn monitor_elevate_self(
|
||||
|
||||
if is_thing_elevated() {
|
||||
// 已是管理员,只需设置标志,无需重启
|
||||
eprintln!("[monitor] Thing 已是管理员,仅设置永久提权标志");
|
||||
crate::logger::log_info("monitor", "Thing 已是管理员,仅设置永久提权标志");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().map_err(|e| format!("获取当前路径失败: {}", e))?;
|
||||
let exe_str = exe.to_string_lossy().to_string();
|
||||
eprintln!("[monitor] 永久提权:以管理员权限重启 Thing");
|
||||
crate::logger::log_info("monitor", "永久提权:以管理员权限重启 Thing");
|
||||
shell_execute_elevated(&exe_str, "", None)?;
|
||||
// 退出当前进程(非提权),新的提权进程会接管
|
||||
app.exit(0);
|
||||
@@ -894,6 +1018,52 @@ pub async fn monitor_get_status(state: tauri::State<'_, MonitorKernel>) -> Resul
|
||||
state.get_status().await
|
||||
}
|
||||
|
||||
/// 检测并修复 PawnIO 驱动:确保安装器随内核部署 → 静默安装 → 重启监控内核
|
||||
/// (LHM 打开一次后不会重新发现驱动,装完必须重启才能恢复 CPU 温度/功耗读取)。
|
||||
/// 返回 { installed, needReboot };installed=false 说明未提权或安装失败,交由内核启动自装。
|
||||
#[tauri::command]
|
||||
pub async fn monitor_repair_pawnio(
|
||||
state: tauri::State<'_, MonitorKernel>,
|
||||
pm: tauri::State<'_, ProcessManager>,
|
||||
app: AppHandle,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
// 1. 确保 PawnIO_setup.exe 随内核部署
|
||||
state.prepare_kernel(&app)?;
|
||||
let setup = state.cores_dir().join("PawnIO_setup.exe");
|
||||
if !setup.exists() {
|
||||
return Err("未找到 PawnIO_setup.exe 安装器(binaries 资源未随包部署),请重新部署监控内核".into());
|
||||
}
|
||||
|
||||
// 2. 静默安装驱动(继承当前进程权限;Thing 已提权则直接成功)
|
||||
let mut cmd = std::process::Command::new(&setup);
|
||||
cmd.args(["-install", "-silent"]);
|
||||
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||
let (installed, need_reboot) = match cmd.status() {
|
||||
Ok(status) => {
|
||||
let code = status.code().unwrap_or(-1);
|
||||
match code {
|
||||
3010 => (true, true), // ERROR_SUCCESS_REBOOT_REQUIRED
|
||||
0 => (true, false),
|
||||
_ => (false, false),
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("运行 PawnIO 安装器失败: {}", e)),
|
||||
};
|
||||
|
||||
// 3. 重启监控内核,使 LHM 以 PawnIO 重新打开传感器
|
||||
state.stop_subscription(&app).await;
|
||||
if state.is_elevated() && !is_thing_elevated() {
|
||||
state.shutdown_kernel().await?;
|
||||
state.elevated.store(false, Ordering::SeqCst);
|
||||
state.start_elevated(&app).await?;
|
||||
} else {
|
||||
pm.stop(PROCESS_ID)?;
|
||||
state.start_with_subscription(&app).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "installed": installed, "needReboot": need_reboot }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_get_snapshot(state: tauri::State<'_, MonitorKernel>) -> Result<SensorSnapshot, String> {
|
||||
state.get_snapshot().await
|
||||
@@ -917,6 +1087,25 @@ pub fn monitor_set_elevate_on_launch(
|
||||
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)。
|
||||
/// 返回当前配置 + 可用硬件/传感器类型清单,供前端 Dialog 渲染。
|
||||
#[tauri::command]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
||||
//! 桥接进程生命周期:spawn(stdio JSON-Lines 协议)→ 请求分发 → 事件转发 → 停止。
|
||||
//! 子模块通过 `impl super::MusicManager` 追加方法。
|
||||
//!
|
||||
//! 协议(与 bridge.py 对应):
|
||||
//! 请求 `{"id":1,"method":"ping","params":{}}`
|
||||
//! 响应 `{"id":1,"ok":true,"result":{...}}` 或 `{"id":1,"ok":false,"error":"..."}`
|
||||
//! 无 id 的事件行 `{"event":"download","type":"progress",...}` 由 reader 线程
|
||||
//! 原样转发为 Tauri 事件 `music-download-event`(见 constants::events)。
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use tauri::Emitter;
|
||||
|
||||
use super::{BridgeEntry, MusicManager, PythonEnv};
|
||||
|
||||
/// 桥接脚本源码(内置,运行时写出到 {root}/bridge.py,避免资源目录配置)
|
||||
const BRIDGE_SCRIPT: &str = include_str!("bridge.py");
|
||||
|
||||
/// 桥接请求错误分类:决定是否允许重启桥接进程重试。
|
||||
/// 应用层错误与超时绝不能触发重启——重启会杀掉正在进行的下载任务。
|
||||
pub(crate) enum BridgeError {
|
||||
/// 传输层错误(进程退出/管道损坏/写入失败/通道关闭)→ 可重启重试
|
||||
Transport(String),
|
||||
/// 应用层错误(桥接正常响应 ok:false)→ 不重启
|
||||
App(String),
|
||||
/// 响应超时 → 不重启(进程可能只是忙,如正在执行长耗时搜索)
|
||||
Timeout(String),
|
||||
}
|
||||
|
||||
impl BridgeError {
|
||||
fn into_message(self) -> String {
|
||||
match self {
|
||||
BridgeError::Transport(m) | BridgeError::App(m) | BridgeError::Timeout(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MusicManager {
|
||||
/// 桥接进程是否在运行(存在且未退出)
|
||||
pub fn bridge_running(&self) -> bool {
|
||||
let mut guard = match self.bridge.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return false,
|
||||
};
|
||||
match guard.as_mut() {
|
||||
Some(entry) => entry.child.try_wait().ok().map(|w| w.is_none()).unwrap_or(false),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保桥接进程已启动(已启动则直接返回;并发调用由 start_lock 串行化)
|
||||
pub fn ensure_bridge(&self) -> Result<(), String> {
|
||||
if self.bridge_running() {
|
||||
return Ok(());
|
||||
}
|
||||
let _guard = self.start_lock().lock().map_err(|e| e.to_string())?;
|
||||
// 二次检查(等待锁期间可能已被其他调用方启动)
|
||||
if self.bridge_running() {
|
||||
return Ok(());
|
||||
}
|
||||
// 清理可能残留的旧条目
|
||||
self.bridge.lock().map_err(|e| e.to_string())?.take();
|
||||
let python = self.resolve_python()?;
|
||||
self.spawn_bridge(&python)
|
||||
}
|
||||
|
||||
/// 启动桥接进程:stdin/stdout 管道直连,stderr 写入 runtime/bridge_stderr.log
|
||||
fn spawn_bridge(&self, python: &PythonEnv) -> Result<(), String> {
|
||||
let script = self.bridge_script_path();
|
||||
// 每次启动前重写脚本,保证与当前版本一致(内容固定,成本极低)
|
||||
std::fs::write(&script, BRIDGE_SCRIPT).map_err(|e| format!("写出桥接脚本失败: {}", e))?;
|
||||
|
||||
let stderr_log = self.runtime_dir().join("bridge_stderr.log");
|
||||
let stderr_file = std::fs::File::create(&stderr_log).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut cmd = Command::new(&python.exe);
|
||||
cmd.arg(&script);
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::from(stderr_file));
|
||||
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||
// cwd 统一设为模块根目录:musicdl 会在 cwd 落 search_results.pkl 等缓存文件,
|
||||
// 不设置时(系统 Python)会污染应用工作目录(开发期为仓库根目录)
|
||||
if let Some(parent) = script.parent() {
|
||||
cmd.current_dir(parent);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动桥接进程失败 (python: {}): {}", python.exe.display(), e))?;
|
||||
crate::process_manager::assign_to_job(&child);
|
||||
|
||||
let stdin = child.stdin.take().ok_or_else(|| "无法获取桥接 stdin".to_string())?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| "无法获取桥接 stdout".to_string())?;
|
||||
|
||||
*self.bridge.lock().map_err(|e| e.to_string())? = Some(BridgeEntry { child, stdin });
|
||||
|
||||
// 启动 stdout reader 线程:按行读取,按 id 分发到 pending;无 id 的事件行转发到前端
|
||||
let pending = self.pending.clone();
|
||||
let app = self.app_handle();
|
||||
std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||||
crate::logger::log_warn("music-bridge", &format!("无法解析 stdout: {}", line));
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = v.get("id").and_then(|i| i.as_u64()) {
|
||||
if let Some(tx) = pending.lock().ok().and_then(|mut m| m.remove(&id)) {
|
||||
let _ = tx.send(v);
|
||||
}
|
||||
} else if v.get("event").is_some() {
|
||||
// 事件行(下载进度等):原样转发给前端
|
||||
if let Some(app) = app.as_ref() {
|
||||
let _ = app.emit(crate::constants::events::MUSIC_DOWNLOAD_EVENT, &v);
|
||||
}
|
||||
} else {
|
||||
// 其他无 id 行仅记日志
|
||||
crate::logger::log_info("music-bridge", &line);
|
||||
}
|
||||
}
|
||||
crate::logger::log_info("music-bridge", "stdout 已关闭,reader 线程退出");
|
||||
});
|
||||
|
||||
crate::logger::log_info(
|
||||
"music-bridge",
|
||||
&format!("桥接进程已启动 (python: {})", python.exe.display()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止桥接进程:kill 快速返回,wait 移到后台线程;所有 pending 请求置为失败
|
||||
pub fn stop_bridge(&self) {
|
||||
let entry = self.bridge.lock().ok().and_then(|mut b| b.take());
|
||||
if let Some(mut entry) = entry {
|
||||
let _ = entry.child.kill();
|
||||
// drop stdin/stdout 关闭管道端,reader 线程读到 EOF 退出
|
||||
drop(entry.stdin);
|
||||
std::thread::spawn(move || {
|
||||
let _ = entry.child.wait();
|
||||
});
|
||||
crate::logger::log_info("music-bridge", "桥接进程已停止");
|
||||
}
|
||||
// 通知前端:活动中的下载任务应标记为中断
|
||||
if let Some(app) = self.app_handle() {
|
||||
let _ = app.emit(
|
||||
crate::constants::events::MUSIC_DOWNLOAD_EVENT,
|
||||
serde_json::json!({ "event": "download", "type": "bridge-stopped" }),
|
||||
);
|
||||
}
|
||||
// 唤醒所有等待中的请求(以 Null 表示已中止)
|
||||
if let Ok(mut map) = self.pending.lock() {
|
||||
for (_, tx) in map.drain() {
|
||||
let _ = tx.send(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送一条请求并等待响应(默认 5s 超时)。传输层错误时自动重启重试一次。
|
||||
pub async fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
self.request_with_timeout(method, params, std::time::Duration::from_secs(5))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 发送一条请求并等待响应(自定义超时,供 search 等长耗时操作使用)。
|
||||
/// 仅传输层错误(进程退出/管道损坏)会重启桥接并重试一次;
|
||||
/// 应用层错误(桥接返回 ok:false)与超时不重启——重启会误杀正在下载的任务。
|
||||
pub async fn request_with_timeout(
|
||||
&self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
self.ensure_bridge()?;
|
||||
match self.request_inner(method, params.clone(), timeout).await {
|
||||
Ok(v) => Ok(v),
|
||||
Err(BridgeError::Transport(_)) => {
|
||||
// 一次重启机会(进程可能已退出/管道损坏)
|
||||
self.stop_bridge();
|
||||
self.ensure_bridge()?;
|
||||
self.request_inner(method, params, timeout)
|
||||
.await
|
||||
.map_err(BridgeError::into_message)
|
||||
}
|
||||
Err(e) => Err(e.into_message()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_inner(
|
||||
&self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<serde_json::Value, BridgeError> {
|
||||
let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<serde_json::Value>();
|
||||
self.pending
|
||||
.lock()
|
||||
.map_err(|e| BridgeError::Transport(e.to_string()))?
|
||||
.insert(id, tx);
|
||||
|
||||
// 写入 stdin(同步函数,MutexGuard 在返回时释放,避免跨 await 持有非 Send 值)
|
||||
self.write_request(id, method, ¶ms)
|
||||
.map_err(BridgeError::Transport)?;
|
||||
|
||||
let v = tokio::time::timeout(timeout, rx)
|
||||
.await
|
||||
.map_err(|_| BridgeError::Timeout("桥接响应超时".to_string()))?
|
||||
.map_err(|_| BridgeError::Transport("桥接响应通道已关闭".to_string()))?;
|
||||
|
||||
// 停止桥接时发送 Null 表示中止
|
||||
if v.is_null() {
|
||||
return Err(BridgeError::Transport("桥接进程已停止".into()));
|
||||
}
|
||||
if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
|
||||
Ok(v.get("result").cloned().unwrap_or(serde_json::Value::Null))
|
||||
} else {
|
||||
Err(BridgeError::App(
|
||||
v.get("error")
|
||||
.and_then(|e| e.as_str())
|
||||
.unwrap_or("桥接返回未知错误")
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入一条请求到桥接 stdin(同步;进程已退出 / 管道损坏时返回错误)
|
||||
fn write_request(&self, id: u64, method: &str, params: &serde_json::Value) -> Result<(), String> {
|
||||
let line = format!(
|
||||
"{{\"id\":{},\"method\":{},\"params\":{}}}\n",
|
||||
id,
|
||||
serde_json::to_string(method).map_err(|e| e.to_string())?,
|
||||
params
|
||||
);
|
||||
let mut guard = self.bridge.lock().map_err(|e| e.to_string())?;
|
||||
let Some(entry) = guard.as_mut() else {
|
||||
return Err("桥接进程未启动".into());
|
||||
};
|
||||
// 进程已退出 → 立即失败,交给外层重启
|
||||
if entry.child.try_wait().map_err(|e| e.to_string())?.is_some() {
|
||||
return Err("桥接进程已退出".into());
|
||||
}
|
||||
entry
|
||||
.stdin
|
||||
.write_all(line.as_bytes())
|
||||
.map_err(|e| format!("写入桥接 stdin 失败: {}", e))?;
|
||||
entry.stdin.flush().map_err(|e| format!("刷新桥接 stdin 失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ping 桥接进程(P0 环境层连通性验证)
|
||||
pub async fn ping(&self) -> Result<serde_json::Value, String> {
|
||||
let v = self.request("ping", serde_json::Value::Null).await?;
|
||||
crate::logger::log_info("music-bridge", &format!("ping 成功: {}", v));
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
//! 音乐模块 Tauri 命令层。
|
||||
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::{
|
||||
normalize_base_url, resolve_base_url, extract_fn_id, FeiniuConnection, MusicEnvStatus,
|
||||
MusicManager, MusicSettings,
|
||||
};
|
||||
|
||||
/// 查询环境状态(Python / musicdl / FFmpeg / 桥接进程),设置页「环境检查」面板调用。
|
||||
/// 异步命令:子进程探测在阻塞线程池执行,避免冻结主线程/UI。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn music_env_status(state: State<'_, MusicManager>) -> Result<MusicEnvStatus, String> {
|
||||
state.env_status().await
|
||||
}
|
||||
|
||||
/// 安装便携 Python + musicdl(幂等),全程推送 music-runtime-install-progress 事件
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn music_install_runtime(
|
||||
state: State<'_, MusicManager>,
|
||||
app: AppHandle,
|
||||
) -> Result<MusicEnvStatus, String> {
|
||||
state.install_runtime(&app).await?;
|
||||
Ok(state.env_status().await?)
|
||||
}
|
||||
|
||||
/// 取消便携运行时安装/下载
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn music_cancel_runtime_install(state: State<'_, MusicManager>) -> Result<(), String> {
|
||||
state.cancel();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ping 桥接进程(未启动则自动拉起),返回 {"version","python"};
|
||||
/// 返回 Value 且未标注 specta:前端直接按 JSON 使用
|
||||
#[tauri::command]
|
||||
pub async fn music_ping(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
state.ping().await
|
||||
}
|
||||
|
||||
/// 停止桥接进程
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn music_stop_bridge(state: State<'_, MusicManager>) -> Result<(), String> {
|
||||
state.stop_bridge();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 列出 musicdl 已注册的全部搜索源(客户端名);返回 Value,未标注 specta
|
||||
#[tauri::command]
|
||||
pub async fn music_get_sources(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
state.request("get_sources", serde_json::Value::Null).await
|
||||
}
|
||||
|
||||
/// 多源搜索(最长 90s)。sources 为空时桥接使用默认 3 个大陆源;返回 Value,未标注 specta
|
||||
#[tauri::command]
|
||||
pub async fn music_search(
|
||||
state: State<'_, MusicManager>,
|
||||
keyword: String,
|
||||
sources: Option<Vec<String>>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let params = serde_json::json!({ "keyword": keyword, "sources": sources.unwrap_or_default() });
|
||||
state
|
||||
.request_with_timeout("search", params, std::time::Duration::from_secs(90))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 解析歌单链接(网易云/QQ 等),返回歌曲列表;返回 Value,未标注 specta
|
||||
#[tauri::command]
|
||||
pub async fn music_parse_playlist(
|
||||
state: State<'_, MusicManager>,
|
||||
url: String,
|
||||
sources: Option<Vec<String>>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let params = serde_json::json!({ "url": url, "sources": sources.unwrap_or_default() });
|
||||
state
|
||||
.request_with_timeout("parse_playlist", params, std::time::Duration::from_secs(90))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 读取音乐模块设置
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn music_get_settings(state: State<'_, MusicManager>) -> MusicSettings {
|
||||
state.load_settings()
|
||||
}
|
||||
|
||||
/// 保存音乐模块设置(立即生效)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn music_save_settings(
|
||||
state: State<'_, MusicManager>,
|
||||
settings: MusicSettings,
|
||||
) -> Result<(), String> {
|
||||
state.save_settings(&settings)
|
||||
}
|
||||
|
||||
/// 解析歌曲真实下载链接(懒解析:搜索只取元数据,试听/下载前调用)。
|
||||
/// song(单曲)或 songs(批量)二选一;返回 Value,未标注 specta。
|
||||
#[tauri::command]
|
||||
pub async fn music_resolve(
|
||||
state: State<'_, MusicManager>,
|
||||
song: Option<serde_json::Value>,
|
||||
songs: Option<Vec<serde_json::Value>>,
|
||||
quality: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut list: Vec<serde_json::Value> = songs.unwrap_or_default();
|
||||
if let Some(s) = song {
|
||||
list.insert(0, s);
|
||||
}
|
||||
if list.is_empty() {
|
||||
return Err("未提供歌曲".into());
|
||||
}
|
||||
let params = serde_json::json!({ "songs": list, "quality": quality.unwrap_or_default() });
|
||||
state
|
||||
.request_with_timeout("resolve", params, std::time::Duration::from_secs(180))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 启动下载任务(桥接后台工作池执行,立即返回 taskId;进度经 music-download-event 推送)。
|
||||
/// songs 为搜索结果的歌曲 dict(桥接端用 SongInfo.fromdict 重建)。
|
||||
/// 返回 Value,未标注 specta。
|
||||
#[tauri::command]
|
||||
pub async fn music_download(
|
||||
state: State<'_, MusicManager>,
|
||||
task_id: String,
|
||||
songs: Vec<serde_json::Value>,
|
||||
savedir: String,
|
||||
lyric: Option<bool>,
|
||||
cover: Option<bool>,
|
||||
proxy_url: Option<String>,
|
||||
max_concurrent: Option<u32>,
|
||||
quality: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if songs.is_empty() {
|
||||
return Err("未选择任何歌曲".into());
|
||||
}
|
||||
let params = serde_json::json!({
|
||||
"taskId": task_id,
|
||||
"songs": songs,
|
||||
"savedir": savedir,
|
||||
"lyric": lyric.unwrap_or(true),
|
||||
"cover": cover.unwrap_or(true),
|
||||
"proxy": proxy_url.unwrap_or_default(),
|
||||
"maxConcurrent": max_concurrent.unwrap_or(1).clamp(1, 16),
|
||||
"quality": quality.unwrap_or_default(),
|
||||
});
|
||||
state
|
||||
.request_with_timeout("download", params, std::time::Duration::from_secs(15))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 取消下载任务(队列级:正在下载的歌曲会完成,其余标记取消)
|
||||
#[tauri::command]
|
||||
pub async fn music_download_cancel(
|
||||
state: State<'_, MusicManager>,
|
||||
task_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
state
|
||||
.request("cancel", serde_json::json!({ "taskId": task_id }))
|
||||
.await
|
||||
}
|
||||
|
||||
// ============ 飞牛音乐(NAS)客户端(多连接) ============
|
||||
// 全部命令返回 serde_json::Value、不加 specta:前端用裸 invoke,映射在 feiniuStore。
|
||||
|
||||
/// 连接列表 + 激活 id。返回 `{ activeId, list: [{id,name,kind,baseUrl,username,loggedIn,accessCode,insecure}] }`。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_list_connections(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
let s = state.load_settings();
|
||||
let list: Vec<_> = s
|
||||
.feiniu_connections
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"kind": c.kind,
|
||||
"baseUrl": c.base_url,
|
||||
"username": c.username,
|
||||
"loggedIn": !c.token.is_empty(),
|
||||
"accessCode": c.access_code,
|
||||
"insecure": c.insecure,
|
||||
"fnId": c.fn_id,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "activeId": s.feiniu_active_id, "list": list }))
|
||||
}
|
||||
|
||||
/// 新增/更新一条连接(不触碰已登录的 token;改地址后需重新登录)。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_save_connection(
|
||||
state: State<'_, MusicManager>,
|
||||
connection: FeiniuConnection,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut settings = state.load_settings();
|
||||
let mut conn = connection;
|
||||
conn.base_url = normalize_base_url(&conn.base_url);
|
||||
if conn.id.is_empty() {
|
||||
conn.id = new_conn_id();
|
||||
}
|
||||
if let Some(existing) = settings.feiniu_connections.iter_mut().find(|c| c.id == conn.id) {
|
||||
conn.token = existing.token.clone(); // 保留既有 token
|
||||
*existing = conn;
|
||||
} else {
|
||||
settings.feiniu_connections.push(conn);
|
||||
}
|
||||
state.save_settings(&settings)?;
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 删除一条连接;若删的是激活连接,自动切换激活到第一条。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_delete_connection(
|
||||
state: State<'_, MusicManager>,
|
||||
id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut settings = state.load_settings();
|
||||
settings.feiniu_connections.retain(|c| c.id != id);
|
||||
if settings.feiniu_active_id == id {
|
||||
settings.feiniu_active_id = settings
|
||||
.feiniu_connections
|
||||
.first()
|
||||
.map(|c| c.id.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
state.save_settings(&settings)?;
|
||||
state.feiniu.sync_with_settings(&settings);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 设某连接为激活连接。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_activate_connection(
|
||||
state: State<'_, MusicManager>,
|
||||
id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut settings = state.load_settings();
|
||||
if !settings.feiniu_connections.iter().any(|c| c.id == id) {
|
||||
return Err("连接不存在".into());
|
||||
}
|
||||
settings.feiniu_active_id = id;
|
||||
state.save_settings(&settings)?;
|
||||
state.feiniu.sync_with_settings(&settings);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 登录激活/某连接:校验通过后写回该连接的 token/device_id 并启动本地流代理。
|
||||
/// fnconnect 连接会先用 fnId 解析出可达 base_url 再登录。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_login(
|
||||
state: State<'_, MusicManager>,
|
||||
connection_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut settings = state.load_settings();
|
||||
let mut conn = settings
|
||||
.feiniu_connections
|
||||
.iter()
|
||||
.find(|c| c.id == connection_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "连接不存在,请先保存连接".to_string())?;
|
||||
// fnconnect:用 fnId 解析 base_url
|
||||
if conn.kind == "fnconnect" {
|
||||
let fid = extract_fn_id(&conn.fn_id).ok_or_else(|| "FnConnect 连接缺少有效 fnId".to_string())?;
|
||||
let (url, _relay) = resolve_base_url(&fid).await?;
|
||||
conn.base_url = url;
|
||||
if let Some(c) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||
c.base_url = conn.base_url.clone();
|
||||
}
|
||||
state.save_settings(&settings)?;
|
||||
}
|
||||
// 用该连接配置装备运行期
|
||||
let mut tmp = settings.clone();
|
||||
tmp.feiniu_active_id = conn.id.clone();
|
||||
state.feiniu.sync_with_settings(&tmp);
|
||||
|
||||
let (token, device_id) = state.feiniu.login(&conn.base_url, &username, &password).await?;
|
||||
|
||||
if let Some(existing) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||
existing.token = token.clone();
|
||||
existing.device_id = device_id;
|
||||
existing.username = username;
|
||||
}
|
||||
settings.feiniu_active_id = connection_id.clone();
|
||||
state.save_settings(&settings)?;
|
||||
state.feiniu.sync_with_settings(&settings);
|
||||
let prefix = state.feiniu.media_prefix().await?;
|
||||
Ok(json!({ "ok": true, "userToken": token, "mediaPrefix": prefix }))
|
||||
}
|
||||
|
||||
/// 登出某连接(清 token,保留地址/账号),代理 Cookie 同步失效。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_logout(
|
||||
state: State<'_, MusicManager>,
|
||||
connection_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut settings = state.load_settings();
|
||||
if let Some(c) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||
c.token.clear();
|
||||
}
|
||||
state.save_settings(&settings)?;
|
||||
state.feiniu.sync_with_settings(&settings);
|
||||
state.feiniu.logout();
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 测试某连接是否能登录(不持久化 token),探测后恢复原激活连接的运行期状态。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_test_connection(
|
||||
state: State<'_, MusicManager>,
|
||||
connection_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let settings = state.load_settings();
|
||||
let conn = settings
|
||||
.feiniu_connections
|
||||
.iter()
|
||||
.find(|c| c.id == connection_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "连接不存在".to_string())?;
|
||||
let mut tmp = settings.clone();
|
||||
tmp.feiniu_active_id = conn.id.clone();
|
||||
state.feiniu.sync_with_settings(&tmp);
|
||||
let r = state.feiniu.login(&conn.base_url, &username, &password).await;
|
||||
// 探测可能污染运行期:恢复为持久化的激活连接
|
||||
state.feiniu.sync_with_settings(&state.load_settings());
|
||||
match r {
|
||||
Ok(_) => Ok(json!({ "ok": true })),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询当前激活连接配置:{ activeId, baseUrl, username, loggedIn }。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_get_config(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
let settings = state.load_settings();
|
||||
state.feiniu.sync_with_settings(&settings);
|
||||
let active_id = settings.feiniu_active_id;
|
||||
let mut cfg = state.feiniu.config();
|
||||
cfg["activeId"] = json!(active_id);
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// 分页拉取激活连接曲目列表:{ page, size, keyword? } → NAS 原始 data。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_list_tracks(
|
||||
state: State<'_, MusicManager>,
|
||||
page: Option<u32>,
|
||||
size: Option<u32>,
|
||||
keyword: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
state.feiniu.sync_with_settings(&state.load_settings());
|
||||
state
|
||||
.feiniu
|
||||
.list_tracks(
|
||||
page.unwrap_or(1).max(1),
|
||||
size.unwrap_or(50).clamp(1, 100),
|
||||
keyword.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 获取某曲目歌词:{ lyric }(无则空字符串)。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_lyric(
|
||||
state: State<'_, MusicManager>,
|
||||
guid: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
state.feiniu.sync_with_settings(&state.load_settings());
|
||||
let text = state.feiniu.lyric(&guid).await?;
|
||||
Ok(json!({ "lyric": text }))
|
||||
}
|
||||
|
||||
/// 本地媒体地址前缀:{ mediaPrefix }(首次调用惰性启动本地流代理)。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_media_prefix(
|
||||
state: State<'_, MusicManager>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
state.feiniu.sync_with_settings(&state.load_settings());
|
||||
let prefix = state.feiniu.media_prefix().await?;
|
||||
Ok(json!({ "mediaPrefix": prefix }))
|
||||
}
|
||||
|
||||
/// 扫描本地曲库目录中的音频文件:{ items }(目录 = 下载 savedir + 用户自定义 dirs)。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_scan_local(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
let s = state.load_settings();
|
||||
let mut dirs: Vec<String> = vec![s.savedir.clone()];
|
||||
for d in &s.feiniu_local_dirs {
|
||||
if !dirs.iter().any(|x| x == d) {
|
||||
dirs.push(d.clone());
|
||||
}
|
||||
}
|
||||
Ok(state.feiniu.scan_local(&dirs))
|
||||
}
|
||||
|
||||
/// 播放缓存状态:{ count, usedBytes, usedMb }。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_cache_status(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
Ok(state.feiniu.cache_status())
|
||||
}
|
||||
|
||||
/// 清空播放缓存。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_cache_clear(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
state.feiniu.cache_clear();
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 缓存一首歌(命中则直接返回):{ path } 或 { cached }。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_cache_fetch(
|
||||
state: State<'_, MusicManager>,
|
||||
guid: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
state.feiniu.sync_with_settings(&state.load_settings());
|
||||
let max_gb = state.load_settings().feiniu_cache_max_gb;
|
||||
let hit = state.feiniu.cache_fetch(&guid, max_gb).await?;
|
||||
Ok(json!({ "path": hit, "cached": hit.is_some() }))
|
||||
}
|
||||
|
||||
/// 生成一条新连接的 id(时间戳 + 进程号,避免引 rand)。
|
||||
fn new_conn_id() -> String {
|
||||
let n = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("c{n:x}")
|
||||
}
|
||||
|
||||
// ============ P6:下载到飞牛 + 曲库增删(WebDAV,无状态) ============
|
||||
|
||||
/// 测试 WebDAV 连接与目标目录是否可达。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn webdav_test(
|
||||
config: crate::music::feiniu::webdav::WebDavConfig,
|
||||
dir: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let msg = crate::music::feiniu::webdav::test(&config, &dir).await?;
|
||||
Ok(json!({ "ok": true, "message": msg }))
|
||||
}
|
||||
|
||||
/// 上传本地文件到 WebDAV 目标目录(流式,不整载内存)。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn webdav_upload(
|
||||
config: crate::music::feiniu::webdav::WebDavConfig,
|
||||
local_path: String,
|
||||
remote_path: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let name = crate::music::feiniu::webdav::upload_file(
|
||||
&config,
|
||||
std::path::Path::new(&local_path),
|
||||
&remote_path,
|
||||
)
|
||||
.await?;
|
||||
Ok(json!({ "ok": true, "name": name }))
|
||||
}
|
||||
|
||||
/// 删除 WebDAV 远程文件(404 视为已删除)。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn webdav_delete(
|
||||
config: crate::music::feiniu::webdav::WebDavConfig,
|
||||
remote_path: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
crate::music::feiniu::webdav::delete(&config, &remote_path).await?;
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
// ============ WebDAV 凭据加密存储(Windows 凭据管理器,DPAPI 保护) ============
|
||||
|
||||
/// 凭据以 JSON blob 存入系统凭据管理器:{"username","password"}。
|
||||
/// 明文仅存在于内存与系统凭据库,绝不写回 localStorage / 配置文件。
|
||||
#[cfg(windows)]
|
||||
const WEBDAV_SECRET_SERVICE: &str = "Thing";
|
||||
#[cfg(windows)]
|
||||
const WEBDAV_SECRET_USER: &str = "webdav-credentials";
|
||||
|
||||
#[cfg(windows)]
|
||||
fn webdav_secret_entry() -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new(WEBDAV_SECRET_SERVICE, WEBDAV_SECRET_USER)
|
||||
.map_err(|e| format!("无法访问系统凭据管理器: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn webdav_secret_read() -> Result<Option<serde_json::Value>, String> {
|
||||
let entry = webdav_secret_entry()?;
|
||||
match entry.get_password() {
|
||||
Ok(blob) => serde_json::from_str(&blob)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("凭据数据损坏: {e}")),
|
||||
// 未配置过凭据不算错误
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(format!("读取凭据失败: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn webdav_secret_write(username: &str, password: &str) -> Result<(), String> {
|
||||
let blob = json!({ "username": username, "password": password }).to_string();
|
||||
let entry = webdav_secret_entry()?;
|
||||
entry.set_password(&blob).map_err(|e| format!("保存凭据失败: {e}"))
|
||||
}
|
||||
|
||||
/// 读取 WebDAV 凭据。未配置时 username/password 为 null。
|
||||
#[tauri::command]
|
||||
pub fn webdav_get_secret() -> Result<serde_json::Value, String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
Ok(webdav_secret_read()?.unwrap_or_else(|| json!({ "username": null, "password": null })))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Ok(json!({ "username": null, "password": null }))
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存 WebDAV 凭据(账号 + 密码整体覆盖)。
|
||||
#[tauri::command]
|
||||
pub fn webdav_save_secret(username: String, password: String) -> Result<serde_json::Value, String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
webdav_secret_write(&username, &password)?;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = (&username, &password);
|
||||
}
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 删除本地媒体文件(「下载到飞牛」落地即传流程的收尾)。
|
||||
/// 仅允许音频 / 歌词 / 封面扩展名,且拒绝目录——防止前端误删任意文件。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn feiniu_delete_local(path: String) -> Result<serde_json::Value, String> {
|
||||
const ALLOWED: [&str; 13] = [
|
||||
"mp3", "flac", "wav", "m4a", "aac", "ogg", "ape", "wma", "lrc", "jpg", "jpeg", "png", "webp",
|
||||
];
|
||||
let p = std::path::Path::new(&path);
|
||||
let ext_ok = p
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| ALLOWED.contains(&e.to_lowercase().as_str()))
|
||||
.unwrap_or(false);
|
||||
if !ext_ok {
|
||||
return Err(format!("拒绝删除非媒体文件:{path}"));
|
||||
}
|
||||
let meta = std::fs::metadata(p).map_err(|e| format!("文件不存在: {e}"))?;
|
||||
if meta.is_dir() {
|
||||
return Err("拒绝删除目录".to_string());
|
||||
}
|
||||
std::fs::remove_file(p).map_err(|e| format!("删除失败: {e}"))?;
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
// ============ P7:FnConnect 远程连接解析 ============
|
||||
|
||||
/// 解析 fnId → 可达 base_url(探测后返回)。命令层在 fnconnect 连接登录前调用。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_fnconnect_resolve(
|
||||
state: State<'_, MusicManager>,
|
||||
fn_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let _ = &state;
|
||||
let (url, relay) = resolve_base_url(&fn_id).await?;
|
||||
Ok(json!({ "baseUrl": url, "relay": relay }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 飞牛音乐播放缓存(容量限制 + LRU 逐出)。
|
||||
//!
|
||||
//! 缓存目录:`{app_data}/music/cache`,文件 `guid.<ext>`(ext 缺省记 `bin`)。
|
||||
//! 元数据:`index.json` → `{ "guid": { "size", "lastUsed", "file" } }`。
|
||||
//! 超上限按 lastUsed 升序逐出,直到总占用低于上限。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
/// 单条缓存元数据
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct CacheEntry {
|
||||
pub size: u64,
|
||||
pub last_used: u64,
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
pub struct CacheManager {
|
||||
root: PathBuf,
|
||||
index_path: PathBuf,
|
||||
}
|
||||
|
||||
impl CacheManager {
|
||||
pub fn new(app_data_dir: &Path) -> Self {
|
||||
let root = app_data_dir.join("music").join("cache");
|
||||
fs::create_dir_all(&root).ok();
|
||||
let index_path = root.join("index.json");
|
||||
Self { root, index_path }
|
||||
}
|
||||
|
||||
fn load_index(&self) -> HashMap<String, CacheEntry> {
|
||||
fs::read_to_string(&self.index_path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_index(&self, idx: &HashMap<String, CacheEntry>) {
|
||||
if let Ok(s) = serde_json::to_string(idx) {
|
||||
fs::write(&self.index_path, s).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms(&self) -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 命中缓存:更新 lastUsed 并返回文件路径。
|
||||
pub fn hit(&self, guid: &str) -> Option<String> {
|
||||
let mut idx = self.load_index();
|
||||
if let Some(e) = idx.get_mut(guid) {
|
||||
let path = self.root.join(&e.file);
|
||||
if path.exists() {
|
||||
e.last_used = self.now_ms();
|
||||
self.save_index(&idx);
|
||||
return Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
idx.remove(guid);
|
||||
self.save_index(&idx);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 写入缓存(流式 chunk);按上限(GB)逐出。返回写入总字节数。
|
||||
pub async fn put<E: std::fmt::Display>(
|
||||
&self,
|
||||
guid: &str,
|
||||
ext: &str,
|
||||
max_gb: u32,
|
||||
mut stream: impl futures_util::Stream<Item = Result<bytes::Bytes, E>> + Unpin,
|
||||
) -> Result<u64, String> {
|
||||
let safe_guid = sanitize(guid);
|
||||
let ext = if ext.is_empty() { "bin" } else { ext };
|
||||
let file = format!("{safe_guid}.{ext}");
|
||||
let path = self.root.join(&file);
|
||||
let mut total: u64 = 0;
|
||||
let mut f = fs::File::create(&path).map_err(|e| format!("创建缓存文件失败: {e}"))?;
|
||||
while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await {
|
||||
let chunk = chunk.map_err(|e| format!("读取流失败: {e}"))?;
|
||||
total += chunk.len() as u64;
|
||||
f.write_all(chunk.as_ref()).map_err(|e| format!("写入缓存失败: {e}"))?;
|
||||
}
|
||||
f.flush().ok();
|
||||
|
||||
let mut idx = self.load_index();
|
||||
idx.insert(
|
||||
guid.to_string(),
|
||||
CacheEntry {
|
||||
size: total,
|
||||
last_used: self.now_ms(),
|
||||
file,
|
||||
},
|
||||
);
|
||||
self.save_index(&idx);
|
||||
self.evict(max_gb);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// 按上限(GB)逐出。max_gb==0 视为全部清空。
|
||||
fn evict(&self, max_gb: u32) {
|
||||
if max_gb == 0 {
|
||||
self.clear();
|
||||
return;
|
||||
}
|
||||
let max_bytes = max_gb as u64 * 1024 * 1024 * 1024;
|
||||
let mut idx = self.load_index();
|
||||
let mut total: u64 = idx.values().map(|e| e.size).sum();
|
||||
let mut order: Vec<(String, u64)> = idx.iter().map(|(g, e)| (g.clone(), e.last_used)).collect();
|
||||
order.sort_by_key(|(_, t)| *t);
|
||||
for (guid, _) in order {
|
||||
if total <= max_bytes {
|
||||
break;
|
||||
}
|
||||
if let Some(e) = idx.remove(&guid) {
|
||||
let _ = fs::remove_file(self.root.join(&e.file));
|
||||
total = total.saturating_sub(e.size);
|
||||
}
|
||||
}
|
||||
self.save_index(&idx);
|
||||
}
|
||||
|
||||
/// 当前占用与条目数。
|
||||
pub fn status(&self) -> serde_json::Value {
|
||||
let idx = self.load_index();
|
||||
let total: u64 = idx.values().map(|e| e.size).sum();
|
||||
json!({
|
||||
"count": idx.len(),
|
||||
"usedBytes": total,
|
||||
"usedMb": (total as f64 / 1024.0 / 1024.0 * 10.0).round() / 10.0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
let idx = self.load_index();
|
||||
for e in idx.values() {
|
||||
let _ = fs::remove_file(self.root.join(&e.file));
|
||||
}
|
||||
fs::remove_file(&self.index_path).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! 飞牛音乐原生接口纯函数工具(登录签名、地址规范化、设备 ID)。
|
||||
//! 接口路径/认证方式对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方实现。
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// 规范化服务器地址:去首尾空白、去尾部各层斜杠、去误粘贴的 `/music/api/v1` 后缀。
|
||||
pub fn normalize_base_url(input: &str) -> String {
|
||||
let mut u = input.trim().trim_end_matches('/').to_string();
|
||||
let lower = u.to_lowercase();
|
||||
if lower.ends_with("/music/api/v1") {
|
||||
u = u[..u.len() - "/music/api/v1".len()].to_string();
|
||||
}
|
||||
u.trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// SHA-256 十六进制(登录时密码签名,对齐原生客户端 `sha256Hex(password)`)。
|
||||
pub fn sha256_hex(input: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(input.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// 32 位 hex 设备 ID(首次生成后落 settings 复用;不依赖 rand,用时间戳+进程号哈希)。
|
||||
pub fn generate_device_id() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
sha256_hex(&format!("{}-{}", nanos, std::process::id()))[..32].to_string()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! FnConnect 远程连接解析(参考 feiniu-car-music `fn-api.js`)。
|
||||
//!
|
||||
//! fnId → 网关 `https://5ddd.com/api/v1/fn/con`(authx md5 签名)→ 内网/公网/中继候选 →
|
||||
//! 探测可达性 → 得到可用的 base_url(含 mode=relay 的中继地址)。
|
||||
|
||||
use md5::Md5;
|
||||
use rand::RngCore;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// 网关地址与签名常量(对齐 feiniu-car-music)。
|
||||
const FN_CONNECT_URL: &str = "https://5ddd.com/api/v1/fn/con";
|
||||
const FN_AUTHX_PREFIX: &str = "NDzZTVxnRKP8Z0jXg1VAMonaG8akvh";
|
||||
const FN_API_KEY: &str = "zIGtkc3dqZnJpd29qZXJqa2w7c";
|
||||
|
||||
fn md5_hex(input: &str) -> String {
|
||||
let mut h = Md5::new();
|
||||
h.update(input.as_bytes());
|
||||
format!("{:x}", h.finalize())
|
||||
}
|
||||
|
||||
fn sha256_hex(input: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(input.as_bytes());
|
||||
format!("{:x}", h.finalize())
|
||||
}
|
||||
|
||||
/// 从输入识别 fnId:`fnos.net/<id>`、`<id>.5ddd.com`、或裸 fnId。
|
||||
pub fn extract_fn_id(input: &str) -> Option<String> {
|
||||
let s = input.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(id) = s.split_once("fnos.net/").map(|(_, r)| r.split('/').next().unwrap_or("")) {
|
||||
if !id.is_empty() {
|
||||
return Some(id.trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(rest) = s.rsplit_once("/") {
|
||||
let last = rest.1;
|
||||
if last.ends_with(".5ddd.com") {
|
||||
return Some(last.trim_end_matches(".5ddd.com").to_string());
|
||||
}
|
||||
}
|
||||
if s.ends_with(".5ddd.com") {
|
||||
return Some(s.trim_end_matches(".5ddd.com").to_string());
|
||||
}
|
||||
// 裸 fnId
|
||||
if s.len() >= 3 && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') && !s.starts_with("http") {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 计算网关 authx 签名。
|
||||
fn fn_authx(method: &str, url: &str, data: &Value) -> String {
|
||||
let body = if method.eq_ignore_ascii_case("get") {
|
||||
String::new()
|
||||
} else {
|
||||
serde_json::to_string(data).unwrap_or_default()
|
||||
};
|
||||
let mut nonce = String::new();
|
||||
let mut rng = rand::thread_rng();
|
||||
for _ in 0..6 {
|
||||
nonce.push(char::from(b'0' + (rng.next_u32() % 10) as u8));
|
||||
}
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis().to_string())
|
||||
.unwrap_or_default();
|
||||
let raw = format!(
|
||||
"{FN_AUTHX_PREFIX}_{url}_{nonce}_{timestamp}_{}__{FN_API_KEY}",
|
||||
md5_hex(&body)
|
||||
);
|
||||
format!("nonce={nonce}×tamp={timestamp}&sign={}", md5_hex(&raw))
|
||||
}
|
||||
|
||||
/// 从网关查询 fnId 的连接参数。
|
||||
pub async fn query_fn_connect(fn_id: &str) -> Result<Value, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let body = json!({ "fnId": fn_id });
|
||||
let resp = client
|
||||
.post(FN_CONNECT_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("authx", fn_authx("post", "/api/v1/fn/con", &body))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("FnConnect 网关不可达: {e}"))?;
|
||||
let b: Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
if b["code"].as_i64().unwrap_or(-1) != 0 {
|
||||
return Err(b["msg"].as_str().unwrap_or("FnConnect 网关返回错误").to_string());
|
||||
}
|
||||
Ok(b["data"].clone())
|
||||
}
|
||||
|
||||
/// 构建候选 base_url 列表。返回 (url, is_relay)。
|
||||
pub fn build_candidates(data: &Value) -> Vec<(String, bool)> {
|
||||
let mut out: Vec<(String, bool)> = Vec::new();
|
||||
let port = &data["port"];
|
||||
let http = port["httpPort"].as_u64().unwrap_or(5666);
|
||||
let https = port["httpsPort"].as_u64().unwrap_or(5667);
|
||||
let empty = vec![];
|
||||
for ip in data["ipv4"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
|
||||
out.push((format!("http://{ip}:{http}"), false));
|
||||
out.push((format!("https://{ip}:{https}"), false));
|
||||
}
|
||||
for ip in data["publicIpv4"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
|
||||
out.push((format!("http://{ip}:{http}"), false));
|
||||
out.push((format!("https://{ip}:{https}"), false));
|
||||
}
|
||||
for ip in data["publicIpv6"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
|
||||
out.push((format!("http://[{ip}]:{http}"), false));
|
||||
out.push((format!("https://[{ip}]:{https}"), false));
|
||||
}
|
||||
let relays = data["fn"].as_array().unwrap_or(&empty);
|
||||
let relay_addrs: Vec<String> = if relays.is_empty() {
|
||||
vec!["5ddd.com".to_string()]
|
||||
} else {
|
||||
relays
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
};
|
||||
for addr in relay_addrs {
|
||||
let domain = addr.split(':').next().unwrap_or(&addr).to_string();
|
||||
out.push((format!("https://{domain}"), true));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 探测某个 base_url 是否可用。
|
||||
async fn probe(url: &str) -> bool {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(6))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
let full = format!("{}/music/api/v1/track/list?page=1&size=1", url.trim_end_matches('/'));
|
||||
match client.get(&full).send().await {
|
||||
Ok(resp) => resp.status().as_u16() < 500,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 fnId → 第一个可达的 base_url;返回 (base_url, is_relay)。
|
||||
pub async fn resolve_base_url(fn_id: &str) -> Result<(String, bool), String> {
|
||||
let data = query_fn_connect(fn_id).await?;
|
||||
let candidates = build_candidates(&data);
|
||||
if candidates.is_empty() {
|
||||
return Err("FnConnect 未返回可用地址".into());
|
||||
}
|
||||
for (url, relay) in &candidates {
|
||||
if probe(url).await {
|
||||
return Ok((url.clone(), *relay));
|
||||
}
|
||||
}
|
||||
Err(format!("FnConnect 候选均不可达({} 个)", candidates.len()))
|
||||
}
|
||||
|
||||
/// sha256 hex(登录用)。
|
||||
pub fn sha256_hex_pub(input: &str) -> String {
|
||||
sha256_hex(input)
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
//! 飞牛音乐(NAS)原生接口客户端 + 本地流代理的运行期。
|
||||
//!
|
||||
//! 支持多连接(本地 / frp / 预留 FnConnect),当前以"激活连接"为准。
|
||||
//! 原生接口路径/认证(`music-token` Cookie、`x-access-code` 安全码、`/user/password-login` 登录)
|
||||
//! 对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方纯前端实现翻译。
|
||||
//! 所有对 NAS 的 HTTP 请求在本模块收敛(页面/命令层不直接发请求)。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::MusicSettings;
|
||||
|
||||
pub use conn::normalize_base_url;
|
||||
mod cache;
|
||||
mod conn;
|
||||
mod fnconnect;
|
||||
pub mod proxy;
|
||||
pub mod webdav;
|
||||
|
||||
pub use cache::CacheManager;
|
||||
pub use fnconnect::{extract_fn_id, resolve_base_url};
|
||||
|
||||
use conn::{generate_device_id, sha256_hex};
|
||||
use proxy::{ProxyCfg, ProxyShared};
|
||||
|
||||
/// 一条飞牛音乐连接(持久化在 `MusicSettings`)。
|
||||
#[derive(Serialize, Deserialize, Clone, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FeiniuConnection {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// "lan" | "frp" | "fnconnect"(fnconnect 预留)
|
||||
pub kind: String,
|
||||
/// 服务器地址(http://192.168.x.x:5666 或 https://域名)
|
||||
pub base_url: String,
|
||||
pub username: String,
|
||||
pub token: String,
|
||||
pub device_id: String,
|
||||
pub access_code: String,
|
||||
/// https 遇到自签证书时忽略校验
|
||||
pub insecure: bool,
|
||||
/// fnconnect 连接的 fnId(如 fnos.net/<id> 或裸 id)
|
||||
#[serde(default)]
|
||||
pub fn_id: String,
|
||||
}
|
||||
|
||||
impl Default for FeiniuConnection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
kind: "lan".to_string(),
|
||||
base_url: String::new(),
|
||||
username: String::new(),
|
||||
token: String::new(),
|
||||
device_id: String::new(),
|
||||
access_code: String::new(),
|
||||
insecure: false,
|
||||
fn_id: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前激活连接信息(运行期副本,与 `MusicSettings` 的激活连接一致)。
|
||||
struct Conn {
|
||||
base_url: String,
|
||||
token: String,
|
||||
username: String,
|
||||
device_id: String,
|
||||
access_code: String,
|
||||
insecure: bool,
|
||||
}
|
||||
|
||||
/// LAN 直连,`no_proxy` 避免被代理模块(mihomo)拦走;按 insecure 惰性重建(支持自签证书)。
|
||||
struct ClientSlot {
|
||||
insecure: bool,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
pub struct Feiniu {
|
||||
client: Mutex<Option<ClientSlot>>,
|
||||
conn: Mutex<Conn>,
|
||||
proxy: Mutex<Option<(u16, ProxyShared)>>,
|
||||
cache: CacheManager,
|
||||
}
|
||||
|
||||
impl Default for Feiniu {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
client: Mutex::new(None),
|
||||
conn: Mutex::new(Conn {
|
||||
base_url: String::new(),
|
||||
token: String::new(),
|
||||
username: String::new(),
|
||||
device_id: String::new(),
|
||||
access_code: String::new(),
|
||||
insecure: false,
|
||||
}),
|
||||
proxy: Mutex::new(None),
|
||||
cache: CacheManager::new(Path::new("placeholder")), // 由 set_cache_root 重建
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Feiniu {
|
||||
/// 设置缓存根目录({app_data}/music/cache),应用启动时调用一次。
|
||||
pub fn set_cache_root(&mut self, app_data_dir: &Path) {
|
||||
self.cache = CacheManager::new(app_data_dir);
|
||||
}
|
||||
/// 取(并惰性构建)对应 insecure 的 reqwest client。
|
||||
fn client(&self, insecure: bool) -> reqwest::Client {
|
||||
let mut g = self.client.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let hit = g.as_ref().map(|s| s.insecure == insecure).unwrap_or(false);
|
||||
if !hit {
|
||||
*g = Some(ClientSlot {
|
||||
insecure,
|
||||
client: reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(insecure)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new()),
|
||||
});
|
||||
}
|
||||
g.as_ref().unwrap().client.clone()
|
||||
}
|
||||
|
||||
/// 从持久化设置刷新运行期连接与代理配置(以激活连接为准;幂等)。
|
||||
pub fn sync_with_settings(&self, s: &MusicSettings) {
|
||||
let (base_url, token, username, device_id, access_code, insecure) =
|
||||
match s.feiniu_active() {
|
||||
Some(c) => (
|
||||
c.base_url.clone(),
|
||||
c.token.clone(),
|
||||
c.username.clone(),
|
||||
c.device_id.clone(),
|
||||
c.access_code.clone(),
|
||||
c.insecure,
|
||||
),
|
||||
None => (
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
false,
|
||||
),
|
||||
};
|
||||
if let Ok(mut c) = self.conn.lock() {
|
||||
c.base_url = base_url;
|
||||
c.token = token;
|
||||
c.username = username;
|
||||
c.device_id = device_id;
|
||||
c.access_code = access_code;
|
||||
c.insecure = insecure;
|
||||
}
|
||||
// 确保 client 构建到位(insecure 变化时重建)
|
||||
self.client(insecure);
|
||||
self.sync_proxy_cfg();
|
||||
self.sync_proxy_client();
|
||||
}
|
||||
|
||||
fn sync_proxy_cfg(&self) {
|
||||
if let Ok(g) = self.proxy.lock() {
|
||||
if let Some((_, shared)) = g.as_ref() {
|
||||
let cfg = self.current_cfg();
|
||||
if let Ok(mut c) = shared.cfg.lock() {
|
||||
*c = cfg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_proxy_client(&self) {
|
||||
let (_, client) = self.conn_client();
|
||||
if let Ok(mut g) = self.proxy.lock() {
|
||||
if let Some((_, shared)) = g.as_mut() {
|
||||
shared.client = client;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn conn_client(&self) -> (bool, reqwest::Client) {
|
||||
let insecure = self.conn.lock().unwrap_or_else(|e| e.into_inner()).insecure;
|
||||
(insecure, self.client(insecure))
|
||||
}
|
||||
|
||||
fn current_cfg(&self) -> ProxyCfg {
|
||||
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||
ProxyCfg {
|
||||
base_url: c.base_url.clone(),
|
||||
token: c.token.clone(),
|
||||
access_code: c.access_code.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_triple(&self) -> (String, String, String) {
|
||||
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||
(c.base_url.clone(), c.token.clone(), c.access_code.clone())
|
||||
}
|
||||
|
||||
/// 对某个 base_url 执行登录(探测/登录连接共用)。
|
||||
/// 成功返回 `(userToken, device_id)` 并更新运行期 conn;命令层负责落回对应连接持久化。
|
||||
pub async fn login(
|
||||
&self,
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(String, String), String> {
|
||||
let base = normalize_base_url(base_url);
|
||||
let (device_id, insecure) = {
|
||||
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let d = if c.device_id.is_empty() {
|
||||
generate_device_id()
|
||||
} else {
|
||||
c.device_id.clone()
|
||||
};
|
||||
(d, c.insecure)
|
||||
};
|
||||
let body = json!({
|
||||
"username": username,
|
||||
"password": sha256_hex(password),
|
||||
"deviceId": device_id,
|
||||
});
|
||||
let resp = self
|
||||
.client(insecure)
|
||||
.post(format!("{base}/music/api/v1/user/password-login"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let kind = if e.is_timeout() {
|
||||
"连接超时(NAS 不可达?)"
|
||||
} else {
|
||||
"连接失败"
|
||||
};
|
||||
format!("{kind}: {e}")
|
||||
})?;
|
||||
let status = resp.status();
|
||||
let b: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.unwrap_or_else(|_| json!({ "code": status.as_u16() }));
|
||||
let code = b["code"].as_i64().unwrap_or(i64::from(status.as_u16()));
|
||||
if code != 0 {
|
||||
if code == 120001 {
|
||||
return Err("用户名或密码错误".into());
|
||||
}
|
||||
let msg = b["msg"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("HTTP {status}"));
|
||||
return Err(msg);
|
||||
}
|
||||
let token = b["data"]["userToken"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "登录失败:未返回 token".to_string())?
|
||||
.to_string();
|
||||
|
||||
if let Ok(mut c) = self.conn.lock() {
|
||||
c.base_url = base;
|
||||
c.token = token.clone();
|
||||
c.username = username.to_string();
|
||||
c.device_id = device_id.clone();
|
||||
}
|
||||
self.sync_proxy_cfg();
|
||||
Ok((token, device_id))
|
||||
}
|
||||
|
||||
/// 登出:仅清运行期 token(保留连接信息与账号)。
|
||||
pub fn logout(&self) {
|
||||
if let Ok(mut c) = self.conn.lock() {
|
||||
c.token.clear();
|
||||
}
|
||||
self.sync_proxy_cfg();
|
||||
}
|
||||
|
||||
/// 当前激活连接配置(前端状态展示 + 是否已登录)。
|
||||
pub fn config(&self) -> Value {
|
||||
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||
json!({
|
||||
"baseUrl": c.base_url,
|
||||
"username": c.username,
|
||||
"loggedIn": !c.token.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 分页拉取曲目列表:`GET /music/api/v1/track/list`(可选关键词)。
|
||||
pub async fn list_tracks(&self, page: u32, size: u32, keyword: Option<&str>) -> Result<Value, String> {
|
||||
let mut query = vec![
|
||||
("page".to_string(), page.to_string()),
|
||||
("size".to_string(), size.to_string()),
|
||||
];
|
||||
let kw = keyword.unwrap_or("").trim().to_string();
|
||||
if !kw.is_empty() {
|
||||
query.push(("keyword".to_string(), kw));
|
||||
}
|
||||
self.authed_get("/music/api/v1/track/list", query).await
|
||||
}
|
||||
|
||||
/// 歌词:`GET /music/api/v1/lyric/list?trackGUID=<guid>`。
|
||||
pub async fn lyric(&self, guid: &str) -> Result<String, String> {
|
||||
let v = self
|
||||
.authed_get(
|
||||
"/music/api/v1/lyric/list",
|
||||
vec![("trackGUID".to_string(), guid.to_string())],
|
||||
)
|
||||
.await?;
|
||||
Ok(extract_lyric_text(&v))
|
||||
}
|
||||
|
||||
/// 本地媒体地址前缀:`http://127.0.0.1:<port>/feiniu`。首次调用惰性启动代理。
|
||||
pub async fn media_prefix(&self) -> Result<String, String> {
|
||||
let port = self.ensure_proxy().await?;
|
||||
Ok(format!("http://127.0.0.1:{port}/feiniu"))
|
||||
}
|
||||
|
||||
/// 递归扫描本地曲库目录中的音频文件,返回轻量条目(不解析时长/封面)。
|
||||
pub fn scan_local(&self, dirs: &[String]) -> Value {
|
||||
const EXTS: [&str; 7] = ["mp3", "flac", "wav", "m4a", "aac", "ogg", "ape"];
|
||||
let mut items: Vec<Value> = Vec::new();
|
||||
for dir in dirs {
|
||||
let p = Path::new(dir);
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
for entry in walkdir::WalkDir::new(p).follow_links(false) {
|
||||
let Ok(entry) = entry else { continue };
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
if !EXTS.contains(&ext.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let mtim = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("未知")
|
||||
.to_string();
|
||||
items.push(json!({
|
||||
"path": path.to_string_lossy(),
|
||||
"title": name,
|
||||
"size": size,
|
||||
"mtim": mtim,
|
||||
"dir": dir,
|
||||
}));
|
||||
}
|
||||
}
|
||||
json!({ "items": items })
|
||||
}
|
||||
|
||||
/// 缓存状态。
|
||||
pub fn cache_status(&self) -> Value {
|
||||
self.cache.status()
|
||||
}
|
||||
|
||||
/// 清空缓存。
|
||||
pub fn cache_clear(&self) {
|
||||
self.cache.clear();
|
||||
}
|
||||
|
||||
/// 命中缓存直接返回文件路径;未命中则从 NAS 流式拉取写入缓存后返回。
|
||||
/// 返回缓存文件路径。失败返回 Err。
|
||||
pub async fn cache_fetch(&self, guid: &str, max_gb: u32) -> Result<Option<String>, String> {
|
||||
if let Some(hit) = self.cache.hit(guid) {
|
||||
return Ok(Some(hit));
|
||||
}
|
||||
let (base, token, access_code) = self.auth_triple();
|
||||
if base.is_empty() || token.is_empty() {
|
||||
return Err("未登录".into());
|
||||
}
|
||||
let (_, client) = self.conn_client();
|
||||
let mut rb = client.get(format!("{base}/music/api/v1/track/stream?guid={guid}"));
|
||||
rb = rb.header("cookie", format!("music-token={token}"));
|
||||
if !access_code.is_empty() {
|
||||
use base64::Engine;
|
||||
rb = rb
|
||||
.header(
|
||||
"x-access-code",
|
||||
base64::engine::general_purpose::STANDARD.encode(access_code.as_bytes()),
|
||||
)
|
||||
.header("x-access-source", "app");
|
||||
}
|
||||
let resp = rb.send().await.map_err(|e| format!("拉取失败: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("拉取失败(HTTP {})", resp.status().as_u16()));
|
||||
}
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let ext = match ct.split('/').last() {
|
||||
Some("flac") => "flac",
|
||||
Some("mpeg") => "mp3",
|
||||
Some("wav") => "wav",
|
||||
Some("ogg") => "ogg",
|
||||
Some("mp4") => "m4a",
|
||||
Some("aac") => "aac",
|
||||
_ => "bin",
|
||||
};
|
||||
let stream = resp.bytes_stream();
|
||||
let path = self
|
||||
.cache
|
||||
.put(guid, ext, max_gb, stream)
|
||||
.await
|
||||
.map_err(|e| e)?;
|
||||
let _ = path;
|
||||
Ok(self.cache.hit(guid))
|
||||
}
|
||||
|
||||
// ===== WebDAV 传输(下载到飞牛 / 曲库增删)=====
|
||||
// 无状态:配置由前端每次调用传入,实现在 webdav 模块,命令层直接调用。
|
||||
|
||||
async fn ensure_proxy(&self) -> Result<u16, String> {
|
||||
if let Ok(g) = self.proxy.lock() {
|
||||
if let Some((port, _)) = g.as_ref() {
|
||||
return Ok(*port);
|
||||
}
|
||||
}
|
||||
let (_, client) = self.conn_client();
|
||||
let shared = ProxyShared {
|
||||
client,
|
||||
cfg: Arc::new(Mutex::new(self.current_cfg())),
|
||||
};
|
||||
let port = proxy::start(shared.clone()).await?;
|
||||
if let Ok(mut g) = self.proxy.lock() {
|
||||
*g = Some((port, shared));
|
||||
}
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
async fn authed_get(&self, path: &str, query: Vec<(String, String)>) -> Result<Value, String> {
|
||||
let (base, token, access_code) = self.auth_triple();
|
||||
if base.is_empty() || token.is_empty() {
|
||||
return Err("未登录".into());
|
||||
}
|
||||
let (_, client) = self.conn_client();
|
||||
let qrefs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let mut rb = client.get(format!("{base}{path}")).query(&qrefs);
|
||||
rb = rb.header("cookie", format!("music-token={token}"));
|
||||
if !access_code.is_empty() {
|
||||
use base64::Engine;
|
||||
rb = rb
|
||||
.header(
|
||||
"x-access-code",
|
||||
base64::engine::general_purpose::STANDARD.encode(access_code.as_bytes()),
|
||||
)
|
||||
.header("x-access-source", "app");
|
||||
}
|
||||
let resp = rb.send().await.map_err(|e| format!("请求失败: {e}"))?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.unwrap_or_else(|_| json!({ "code": status.as_u16() }));
|
||||
let code = body["code"].as_i64().unwrap_or(i64::from(status.as_u16()));
|
||||
if code != 0 {
|
||||
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
||||
return Err("登录已过期,请重新登录".into());
|
||||
}
|
||||
let msg = body["msg"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("请求失败(HTTP {status})"));
|
||||
return Err(msg);
|
||||
}
|
||||
Ok(body["data"].clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// 尽力从 /lyric/list 响应中取第一段歌词文本(响应结构未文档化,做宽松映射)。
|
||||
fn extract_lyric_text(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Array(arr) => arr
|
||||
.first()
|
||||
.map(|it| {
|
||||
it["content"]
|
||||
.as_str()
|
||||
.or_else(|| it["lyric"].as_str())
|
||||
.or_else(|| it["text"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Value::Object(_) => v["content"]
|
||||
.as_str()
|
||||
.or_else(|| v["lyric"].as_str())
|
||||
.or_else(|| v["text"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 飞牛音乐本地流代理(axum)。
|
||||
//!
|
||||
//! 原生 `/track/stream`、`/static/cover` 需要 `Cookie: music-token=<token>`,而
|
||||
//! WebView 的 `<audio>/<img>` 无法设置 Cookie。本模块绑定 `127.0.0.1:<动态端口>`,
|
||||
//! 转发请求时注入 Cookie 与可选的安全码头,前端直接用本地地址播放/显示,天然支持 Range/seek。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
|
||||
use super::conn::normalize_base_url;
|
||||
|
||||
/// 由 Feiniu 运行期与代理 handler 共享的连接配置(登录更新、登出置空)。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ProxyCfg {
|
||||
pub base_url: String,
|
||||
pub token: String,
|
||||
pub access_code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyShared {
|
||||
pub client: reqwest::Client,
|
||||
pub cfg: Arc<Mutex<ProxyCfg>>,
|
||||
}
|
||||
|
||||
/// 启动本地流代理,绑定到 `127.0.0.1:0`(动态空闲端口),返回实际端口。
|
||||
/// 代理以独立 tokio 任务常驻应用存活期;token 变化经共享 `ProxyCfg` 即时生效,无需重启。
|
||||
pub async fn start(shared: ProxyShared) -> Result<u16, String> {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("绑定本地端口失败: {e}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| e.to_string())?
|
||||
.port();
|
||||
let app = Router::new()
|
||||
.route("/feiniu/stream", get(proxy_stream))
|
||||
.route("/feiniu/cover", get(proxy_cover))
|
||||
.with_state(shared);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
crate::logger::log_error("music", &format!("飞牛流代理异常: {e}"));
|
||||
}
|
||||
});
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
async fn proxy_stream(
|
||||
State(s): State<ProxyShared>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let guid = q.get("guid").cloned().unwrap_or_default().trim().to_string();
|
||||
if guid.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "missing guid").into_response();
|
||||
}
|
||||
forward(
|
||||
&s,
|
||||
"/music/api/v1/track/stream",
|
||||
vec![("guid".to_string(), guid)],
|
||||
&headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn proxy_cover(
|
||||
State(s): State<ProxyShared>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let cover_id = q
|
||||
.get("coverId")
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if cover_id.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "missing coverId").into_response();
|
||||
}
|
||||
let size = q.get("size").cloned().unwrap_or_else(|| "320".into());
|
||||
forward(
|
||||
&s,
|
||||
"/music/api/v1/static/cover",
|
||||
vec![
|
||||
("coverId".to_string(), cover_id),
|
||||
("size".to_string(), size),
|
||||
],
|
||||
&headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 统一转发:向 NAS 发起上游 GET,注入 Cookie/安全码,透传 Range 与响应头,流式回传 body。
|
||||
async fn forward(
|
||||
s: &ProxyShared,
|
||||
path: &str,
|
||||
query: Vec<(String, String)>,
|
||||
headers: &HeaderMap,
|
||||
) -> Response {
|
||||
let cfg = match s.cfg.lock() {
|
||||
Ok(g) => g.clone(),
|
||||
Err(e) => e.into_inner().clone(),
|
||||
};
|
||||
if cfg.base_url.is_empty() || cfg.token.is_empty() {
|
||||
return (StatusCode::UNAUTHORIZED, "飞牛音乐未登录").into_response();
|
||||
}
|
||||
|
||||
let base = normalize_base_url(&cfg.base_url);
|
||||
let url = format!("{base}{path}");
|
||||
let qrefs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
|
||||
let mut rb = s.client.get(&url).query(&qrefs);
|
||||
rb = rb.header("cookie", format!("music-token={}", cfg.token));
|
||||
if !cfg.access_code.is_empty() {
|
||||
use base64::Engine;
|
||||
rb = rb
|
||||
.header(
|
||||
"x-access-code",
|
||||
base64::engine::general_purpose::STANDARD.encode(cfg.access_code.as_bytes()),
|
||||
)
|
||||
.header("x-access-source", "app");
|
||||
}
|
||||
if let Some(range) = headers.get(header::RANGE) {
|
||||
rb = rb.header(header::RANGE, range.clone());
|
||||
}
|
||||
drop(cfg);
|
||||
|
||||
let resp = match rb.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("上游错误: {e}")).into_response(),
|
||||
};
|
||||
let status = resp.status();
|
||||
|
||||
let ct = resp.headers().get(header::CONTENT_TYPE).cloned();
|
||||
let cl = resp.headers().get(header::CONTENT_LENGTH).cloned();
|
||||
let cr = resp.headers().get(header::CONTENT_RANGE).cloned();
|
||||
let ar = resp.headers().get(header::ACCEPT_RANGES).cloned();
|
||||
|
||||
let body = axum::body::Body::from_stream(resp.bytes_stream());
|
||||
let mut out = Response::new(body);
|
||||
*out.status_mut() = status;
|
||||
let h = out.headers_mut();
|
||||
if let Some(v) = ct {
|
||||
h.insert(header::CONTENT_TYPE, v);
|
||||
}
|
||||
if let Some(v) = cl {
|
||||
h.insert(header::CONTENT_LENGTH, v);
|
||||
}
|
||||
if let Some(v) = cr {
|
||||
h.insert(header::CONTENT_RANGE, v);
|
||||
}
|
||||
if let Some(v) = ar {
|
||||
h.insert(header::ACCEPT_RANGES, v);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! WebDAV 客户端(无状态)——用于把音乐传输到 fnOS NAS 的曲库目录。
|
||||
//!
|
||||
//! 选择 WebDAV 而非 SMB/FTP/NFS 的原因:
|
||||
//! - 纯 HTTP 语义,`reqwest` 即可实现,无原生依赖(SMB 在 Rust 侧生态差);
|
||||
//! - **无会话**:Basic 认证随每个请求携带,没有"重启后要重新登录"的问题;
|
||||
//! - fnOS 原生支持:系统设置 → 文件服务 → WebDAV(HTTP 5005 / HTTPS 5006)。
|
||||
//!
|
||||
//! fnOS 侧需要的设置(作为 UI 提示展示):
|
||||
//! 1. 开启 WebDAV 服务并记下端口;
|
||||
//! 2. 目标文件夹必须在「可见文件夹范围」内(团队文件夹需勾选
|
||||
//! 「允许通过文件共享协议(SMB、WebDAV、FTP)挂载到其他设备上」);
|
||||
//! 3. 账号对该文件夹有读写权限。
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// WebDAV 连接配置(由前端每次调用时传入,后端不持有状态)。
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, specta::Type)]
|
||||
pub struct WebDavConfig {
|
||||
/// 服务地址,如 `http://192.168.110.100:5005`
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// WebDAV 操作结果状态码相关的可读错误。
|
||||
fn status_err(action: &str, status: u16, url: &str) -> String {
|
||||
match status {
|
||||
401 => format!("WebDAV 认证失败(401):请检查账号与密码"),
|
||||
403 => format!("WebDAV 拒绝访问(403):账号可能没有该目录的写入权限"),
|
||||
404 => format!("WebDAV 目标不存在(404):{url}(目录需要先在 NAS 上创建,且在 WebDAV 可见范围内)"),
|
||||
409 => format!("WebDAV 父目录不存在(409):{url}"),
|
||||
_ => format!("WebDAV {action}失败(HTTP {status}):{url}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn dav_client(_cfg: &WebDavConfig) -> Result<reqwest::Client, String> {
|
||||
// NAS 通常在内网,默认禁用系统代理(走代理会连不上);Basic 认证在请求级设置
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.no_proxy()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 拼接 WebDAV 完整 URL:base + 远程路径(逐段百分号编码,保留 `/`)。
|
||||
fn dav_url(cfg: &WebDavConfig, remote_path: &str) -> String {
|
||||
let base = cfg.url.trim_end_matches('/');
|
||||
let path = remote_path.trim_start_matches('/');
|
||||
let encoded: Vec<String> = path
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|seg| {
|
||||
let mut out = String::new();
|
||||
for b in seg.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!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
format!("{base}/{}", encoded.join("/"))
|
||||
}
|
||||
|
||||
fn with_auth(mut rb: reqwest::RequestBuilder, cfg: &WebDavConfig) -> reqwest::RequestBuilder {
|
||||
if !cfg.username.is_empty() {
|
||||
rb = rb.basic_auth(&cfg.username, Some(&cfg.password));
|
||||
}
|
||||
rb
|
||||
}
|
||||
|
||||
/// 逐级 MKCOL 创建远程目录(已存在会被忽略)。
|
||||
async fn ensure_dirs(cfg: &WebDavConfig, remote_path: &str) -> Result<(), String> {
|
||||
let path = remote_path.trim_start_matches('/');
|
||||
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
// 最后一段是文件名,只建目录部分
|
||||
let dirs: Vec<String> = segs
|
||||
.iter()
|
||||
.take(segs.len().saturating_sub(1))
|
||||
.fold(Vec::new(), |mut acc, s| {
|
||||
let prev = acc.last().cloned().unwrap_or_default();
|
||||
acc.push(if prev.is_empty() {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{prev}/{s}")
|
||||
});
|
||||
acc
|
||||
});
|
||||
let client = dav_client(cfg)?;
|
||||
for d in dirs {
|
||||
let url = dav_url(cfg, &d);
|
||||
let method = reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL 是合法的 HTTP 方法");
|
||||
let resp = with_auth(client.request(method, &url), cfg)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("WebDAV MKCOL 失败: {e}"))?;
|
||||
// 201 创建成功;405 已存在;其余只记不阻断(权限问题会在 PUT 时暴露)
|
||||
let code = resp.status().as_u16();
|
||||
if code != 201 && code != 405 && code != 301 && code != 409 && code != 200 {
|
||||
return Err(status_err("创建目录", code, &url));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 测试连接:PROPFIND 目标目录(Depth 0)。成功返回目录确认信息。
|
||||
pub async fn test(cfg: &WebDavConfig, dir: &str) -> Result<String, String> {
|
||||
let url = dav_url(cfg, dir);
|
||||
let client = dav_client(cfg)?;
|
||||
let resp = with_auth(
|
||||
client
|
||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
||||
.header("Depth", "0")
|
||||
.body(String::new()),
|
||||
cfg,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("无法连接 WebDAV 服务:{e}(请确认地址与端口)"))?;
|
||||
let code = resp.status().as_u16();
|
||||
match code {
|
||||
207 | 200 => Ok(format!("连接成功:{url}")),
|
||||
404 => Err(format!(
|
||||
"WebDAV 服务可达,但目标目录不存在(404):{url}\n请先在 NAS 上创建该目录,并确认它在 WebDAV「可见文件夹范围」内"
|
||||
)),
|
||||
401 => Err("WebDAV 认证失败(401):请检查账号与密码".to_string()),
|
||||
403 => Err("WebDAV 拒绝访问(403):账号可能没有该目录的读取权限".to_string()),
|
||||
_ => Err(status_err("连接", code, &url)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传本地文件(流式读取,不整载内存)。返回远程路径。
|
||||
pub async fn upload_file(
|
||||
cfg: &WebDavConfig,
|
||||
local_path: &std::path::Path,
|
||||
remote_path: &str,
|
||||
) -> Result<String, String> {
|
||||
ensure_dirs(cfg, remote_path).await?;
|
||||
let file = tokio::fs::File::open(local_path)
|
||||
.await
|
||||
.map_err(|e| format!("读取本地文件失败: {e}"))?;
|
||||
// 128KB 分块流:用 unfold 把 AsyncRead 转 Stream,避免引入 tokio-util
|
||||
let stream = futures_util::stream::unfold(file, |mut f| async move {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut buf = vec![0u8; 128 * 1024];
|
||||
match f.read(&mut buf).await {
|
||||
Ok(0) => None,
|
||||
Ok(n) => Some((Ok::<Bytes, std::io::Error>(Bytes::copy_from_slice(&buf[..n])), f)),
|
||||
Err(e) => Some((Err(e), f)),
|
||||
}
|
||||
});
|
||||
let url = dav_url(cfg, remote_path);
|
||||
let client = dav_client(cfg)?;
|
||||
let resp = with_auth(client.put(&url), cfg)
|
||||
.header("Content-Type", mime_of(remote_path))
|
||||
.body(reqwest::Body::wrap_stream(stream))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("WebDAV 上传失败: {e}"))?;
|
||||
let code = resp.status().as_u16();
|
||||
if !(200..300).contains(&code) {
|
||||
return Err(status_err("上传", code, &url));
|
||||
}
|
||||
Ok(remote_path.to_string())
|
||||
}
|
||||
|
||||
/// 删除远程文件(404 视为已删除)。
|
||||
pub async fn delete(cfg: &WebDavConfig, remote_path: &str) -> Result<(), String> {
|
||||
let url = dav_url(cfg, remote_path);
|
||||
let client = dav_client(cfg)?;
|
||||
let resp = with_auth(client.delete(&url), cfg)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("WebDAV 删除失败: {e}"))?;
|
||||
let code = resp.status().as_u16();
|
||||
if code == 404 {
|
||||
return Ok(());
|
||||
}
|
||||
if !(200..300).contains(&code) {
|
||||
return Err(status_err("删除", code, &url));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mime_of(name: &str) -> &'static str {
|
||||
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
match ext.as_str() {
|
||||
"mp3" => "audio/mpeg",
|
||||
"flac" => "audio/flac",
|
||||
"wav" => "audio/wav",
|
||||
"m4a" => "audio/mp4",
|
||||
"aac" => "audio/aac",
|
||||
"ogg" => "audio/ogg",
|
||||
"ape" => "audio/x-ape",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// 保留给调用方校验配置完整性。
|
||||
pub fn validate(cfg: &WebDavConfig, dir: &str) -> Result<(), String> {
|
||||
if cfg.url.trim().is_empty() {
|
||||
return Err("请先填写 WebDAV 服务地址(如 http://192.168.110.100:5005)".into());
|
||||
}
|
||||
if cfg.username.trim().is_empty() {
|
||||
return Err("请先填写 WebDAV 账号".into());
|
||||
}
|
||||
if dir.trim().is_empty() {
|
||||
return Err("请先填写 WebDAV 曲库目标目录".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
//! 音乐下载模块(musicdl 桥接)。
|
||||
//!
|
||||
//! 架构:Vue 前端 → Tauri 命令 → [MusicManager] → stdio JSON-Lines → `bridge.py`
|
||||
//! → musicdl(纯 Python 聚合下载器)。
|
||||
//!
|
||||
//! 目录布局({app_data_dir}/music/):
|
||||
//! - `bridge.py`:桥接脚本(include_str! 内置,运行时写出)
|
||||
//! - `runtime/python/`:便携 Python(python.org embeddable,含 pip)
|
||||
//! - `runtime/get-pip.py`:pip 引导脚本(下载后删除)
|
||||
//! - `runtime/*.zip`:下载过程中的临时安装包(完成后删除)
|
||||
//! - `outputs/`:默认音乐下载目录
|
||||
//!
|
||||
//! 子模块:
|
||||
//! - [`runtime`]:Python 运行时探测与便携版安装
|
||||
//! - [`bridge`]:桥接进程生命周期(spawn / JSON 协议 / ping / 停止)
|
||||
//! - [`commands`]:Tauri 命令层
|
||||
|
||||
mod bridge;
|
||||
mod commands;
|
||||
mod feiniu;
|
||||
mod runtime;
|
||||
|
||||
pub use feiniu::{extract_fn_id, normalize_base_url, resolve_base_url, Feiniu, FeiniuConnection};
|
||||
|
||||
pub use commands::{
|
||||
feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status,
|
||||
feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve, feiniu_get_config,
|
||||
feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout, feiniu_lyric,
|
||||
feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, feiniu_test_connection,
|
||||
music_cancel_runtime_install, music_download, music_download_cancel, music_env_status,
|
||||
music_get_settings, music_get_sources, music_install_runtime, music_parse_playlist, music_ping,
|
||||
music_resolve, music_save_settings, music_search, music_stop_bridge, webdav_delete,
|
||||
webdav_get_secret, webdav_save_secret, webdav_test, webdav_upload,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, ChildStdin};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// 便携 Python 版本(python.org embeddable,含 pip 引导)
|
||||
pub const BUNDLED_PY_VERSION: &str = "3.12.10";
|
||||
/// musicdl 锁定版本(其 API 每周都在变,必须锁版本并定期升级)
|
||||
pub const MUSICDL_VERSION: &str = "2.13.11";
|
||||
/// pip 镜像源(国内网络直连 PyPI 较慢,默认用清华镜像,可改回官方)
|
||||
pub const PIP_INDEX_URL: &str = "https://pypi.tuna.tsinghua.edu.cn/simple";
|
||||
/// 默认搜索源(网易云 / QQ音乐 / 酷狗)
|
||||
pub const DEFAULT_SOURCES: [&str; 3] = [
|
||||
"NeteaseMusicClient",
|
||||
"QQMusicClient",
|
||||
"KugouMusicClient",
|
||||
];
|
||||
|
||||
/// 桥接进程条目(自管 stdio,不走 ProcessManager)
|
||||
pub(crate) struct BridgeEntry {
|
||||
pub child: Child,
|
||||
pub stdin: ChildStdin,
|
||||
}
|
||||
|
||||
/// 解析出的 Python 运行时信息(内部使用,不序列化)
|
||||
pub(crate) struct PythonEnv {
|
||||
pub source: &'static str, // "system" | "bundled"
|
||||
pub exe: PathBuf,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// 环境状态(返回前端,设置页「环境检查」面板展示)
|
||||
#[derive(Serialize, Clone, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MusicEnvStatus {
|
||||
/// 系统 Python 版本(如 "3.14.5"),无则 None
|
||||
pub python: Option<String>,
|
||||
/// python 来源:"system" | "bundled" | "none"
|
||||
pub python_source: String,
|
||||
/// 便携 Python 可执行文件路径(未安装则 None)
|
||||
pub bundled_python: Option<String>,
|
||||
/// musicdl 是否可导入
|
||||
pub musicdl_installed: bool,
|
||||
/// musicdl 版本
|
||||
pub musicdl_version: Option<String>,
|
||||
/// FFmpeg 是否可用(部分音源需要,非必需)
|
||||
pub ffmpeg: Option<String>,
|
||||
/// 桥接进程是否在运行
|
||||
pub bridge_running: bool,
|
||||
/// 运行时目录({app_data_dir}/music)
|
||||
pub runtime_dir: String,
|
||||
}
|
||||
|
||||
/// 运行时安装进度事件负载(对应 events::MUSIC_RUNTIME_INSTALL_PROGRESS)
|
||||
#[derive(Serialize, Clone, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MusicInstallProgress {
|
||||
pub stage: String,
|
||||
pub percent: u32,
|
||||
pub downloaded_bytes: u64,
|
||||
pub total_bytes: Option<u64>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 音乐模块设置(settings.json 持久化;变更即时生效)
|
||||
#[derive(Serialize, Deserialize, Clone, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MusicSettings {
|
||||
/// 下载保存目录
|
||||
pub savedir: String,
|
||||
/// 搜索源(musicdl 客户端名,如 NeteaseMusicClient)
|
||||
pub sources: Vec<String>,
|
||||
/// 下载时同步保存歌词
|
||||
pub lyric_download: bool,
|
||||
/// 下载时同步保存封面
|
||||
pub cover_download: bool,
|
||||
/// 搜索/下载请求是否走代理模块(mihomo mixed 端口)
|
||||
pub use_proxy: bool,
|
||||
/// 最大并发下载数
|
||||
pub max_concurrent: u32,
|
||||
/// 下载引擎:"musicdl" | "rust"(P2 生效)
|
||||
pub download_engine: String,
|
||||
/// 下载时是否弹窗选择音质(默认关;开启后点下载弹出所选歌曲档位并集选择)
|
||||
pub select_quality_on_download: bool,
|
||||
/// 下载时默认音质:"" 表示最高;否则为搜索音质档位 label(如 "无损"、"320K")
|
||||
pub default_download_quality: String,
|
||||
/// 飞牛音乐(NAS)连接:服务器地址(如 http://192.168.1.10:5666,空=未配置)
|
||||
#[serde(default)]
|
||||
pub feiniu_base_url: String,
|
||||
/// 飞牛音乐登录 token(登录成功后保存)
|
||||
#[serde(default)]
|
||||
pub feiniu_token: String,
|
||||
/// 飞牛音乐登录账号(展示 + 重新登录回填用)
|
||||
#[serde(default)]
|
||||
pub feiniu_username: String,
|
||||
/// 飞牛音乐设备 ID(32 位 hex,登录签名用,一次生成复用)
|
||||
#[serde(default)]
|
||||
pub feiniu_device_id: String,
|
||||
/// 飞牛音乐访问安全码(可选;仅需访问码的库才填,LAN 通常为空)
|
||||
#[serde(default)]
|
||||
pub feiniu_access_code: String,
|
||||
/// 飞牛音乐连接列表(多连接:本地 / frp / 预留 fnconnect)
|
||||
#[serde(default)]
|
||||
pub feiniu_connections: Vec<FeiniuConnection>,
|
||||
/// 当前激活连接的 id
|
||||
#[serde(default)]
|
||||
pub feiniu_active_id: String,
|
||||
/// 本地曲库扫描目录(默认含音乐下载 savedir)
|
||||
#[serde(default)]
|
||||
pub feiniu_local_dirs: Vec<String>,
|
||||
/// 播放缓存开关
|
||||
#[serde(default)]
|
||||
pub feiniu_cache_enabled: bool,
|
||||
/// 缓存上限(GB)
|
||||
#[serde(default)]
|
||||
pub feiniu_cache_max_gb: u32,
|
||||
/// 播放模式:"stream" 直连流式 | "cache" 缓存后播放
|
||||
#[serde(default)]
|
||||
pub feiniu_play_mode: String,
|
||||
/// 飞牛曲库目标目录(NAS 绝对路径,如 vol1/1000/Music;上传到飞牛用)
|
||||
#[serde(default)]
|
||||
pub feiniu_library_nas_path: String,
|
||||
/// 下载完成后自动上传到飞牛曲库
|
||||
#[serde(default)]
|
||||
pub feiniu_auto_upload: bool,
|
||||
}
|
||||
|
||||
impl MusicSettings {
|
||||
/// 取激活连接:优先按 active_id,否则回退到第一条。
|
||||
pub fn feiniu_active(&self) -> Option<&FeiniuConnection> {
|
||||
self.feiniu_connections
|
||||
.iter()
|
||||
.find(|c| c.id == self.feiniu_active_id)
|
||||
.or_else(|| self.feiniu_connections.first())
|
||||
}
|
||||
|
||||
/// 兼容旧版单连接字段:若连接列表为空且存在旧 feiniu_* 字段,则迁移为一条默认连接。
|
||||
pub fn migrate_feiniu(&mut self) {
|
||||
if self.feiniu_connections.is_empty() {
|
||||
if !self.feiniu_base_url.trim().is_empty() {
|
||||
let base = self.feiniu_base_url.clone();
|
||||
self.feiniu_connections.push(FeiniuConnection {
|
||||
id: "default".to_string(),
|
||||
name: base.clone(),
|
||||
kind: "lan".to_string(),
|
||||
base_url: base,
|
||||
username: self.feiniu_username.clone(),
|
||||
token: self.feiniu_token.clone(),
|
||||
device_id: self.feiniu_device_id.clone(),
|
||||
access_code: self.feiniu_access_code.clone(),
|
||||
insecure: false,
|
||||
fn_id: String::new(),
|
||||
});
|
||||
self.feiniu_active_id = "default".to_string();
|
||||
}
|
||||
} else if self.feiniu_active_id.is_empty()
|
||||
|| !self.feiniu_connections.iter().any(|c| c.id == self.feiniu_active_id)
|
||||
{
|
||||
if let Some(c) = self.feiniu_connections.first() {
|
||||
self.feiniu_active_id = c.id.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// settings 内存缓存条目(短时复用,避免高频调用反复读盘)
|
||||
struct SettingsCacheEntry {
|
||||
read_at: Instant,
|
||||
settings: MusicSettings,
|
||||
}
|
||||
|
||||
/// 音乐模块管理器
|
||||
pub struct MusicManager {
|
||||
root: PathBuf,
|
||||
client: reqwest::Client,
|
||||
/// 桥接进程(stdio 自管)
|
||||
bridge: Mutex<Option<BridgeEntry>>,
|
||||
/// 待响应请求表:id → oneshot(reader 线程按 id 分发)
|
||||
pending: Arc<Mutex<HashMap<u64, tokio::sync::oneshot::Sender<serde_json::Value>>>>,
|
||||
/// 请求 id 自增
|
||||
next_id: AtomicU64,
|
||||
/// 便携运行时安装/下载取消标志
|
||||
runtime_cancel: Arc<AtomicBool>,
|
||||
/// 正在执行的 pip/python 子进程 pid(取消时 taskkill)
|
||||
install_pid: Arc<AtomicU64>,
|
||||
/// 桥接启动互斥锁(防止并发 ensure_bridge 双重 spawn)
|
||||
start_lock: Mutex<()>,
|
||||
/// settings 内存缓存
|
||||
settings_cache: Mutex<Option<SettingsCacheEntry>>,
|
||||
/// 飞牛音乐(NAS)播放器运行期(连接 + 本地流代理)
|
||||
feiniu: Feiniu,
|
||||
/// AppHandle(桥接 reader 线程据此将事件转发给前端;setup 时设置)
|
||||
app: Mutex<Option<AppHandle>>,
|
||||
}
|
||||
|
||||
impl MusicManager {
|
||||
pub fn new(app_data_dir: PathBuf) -> Self {
|
||||
let root = app_data_dir.join("music");
|
||||
for d in ["runtime", "outputs"] {
|
||||
fs::create_dir_all(root.join(d)).ok();
|
||||
}
|
||||
let mut feiniu = Feiniu::default();
|
||||
feiniu.set_cache_root(&app_data_dir);
|
||||
Self {
|
||||
root,
|
||||
client: reqwest::Client::builder()
|
||||
// 默认 30s 兜底超时;流式下载按块推进,不受此限制
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new()),
|
||||
bridge: Mutex::new(None),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
next_id: AtomicU64::new(1),
|
||||
runtime_cancel: Arc::new(AtomicBool::new(false)),
|
||||
install_pid: Arc::new(AtomicU64::new(0)),
|
||||
start_lock: Mutex::new(()),
|
||||
settings_cache: Mutex::new(None),
|
||||
feiniu,
|
||||
app: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 AppHandle(setup 阶段调用;桥接 reader 线程据此转发事件到前端)
|
||||
pub fn set_app(&self, app: AppHandle) {
|
||||
if let Ok(mut guard) = self.app.lock() {
|
||||
*guard = Some(app);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn app_handle(&self) -> Option<AppHandle> {
|
||||
self.app.lock().ok().and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
// ---------- 设置 ----------
|
||||
/// 默认保存目录:系统「下载」目录(不可用时退回 {root}/outputs)
|
||||
fn default_savedir(&self) -> String {
|
||||
dirs::download_dir()
|
||||
.unwrap_or_else(|| self.outputs_dir())
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn default_settings(&self) -> MusicSettings {
|
||||
MusicSettings {
|
||||
savedir: self.default_savedir(),
|
||||
sources: DEFAULT_SOURCES.iter().map(|s| s.to_string()).collect(),
|
||||
lyric_download: true,
|
||||
cover_download: true,
|
||||
use_proxy: false,
|
||||
max_concurrent: 4,
|
||||
download_engine: "musicdl".to_string(),
|
||||
select_quality_on_download: false,
|
||||
default_download_quality: "最高".to_string(), // 默认下载最高音质
|
||||
feiniu_base_url: String::new(),
|
||||
feiniu_token: String::new(),
|
||||
feiniu_username: String::new(),
|
||||
feiniu_device_id: String::new(),
|
||||
feiniu_access_code: String::new(),
|
||||
feiniu_connections: Vec::new(),
|
||||
feiniu_active_id: String::new(),
|
||||
feiniu_local_dirs: Vec::new(),
|
||||
feiniu_cache_enabled: false,
|
||||
feiniu_cache_max_gb: 5,
|
||||
feiniu_play_mode: "stream".to_string(),
|
||||
feiniu_library_nas_path: String::new(),
|
||||
feiniu_auto_upload: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_path(&self) -> PathBuf {
|
||||
self.root.join("settings.json")
|
||||
}
|
||||
|
||||
/// 读取设置(500ms 内存缓存;文件缺失/损坏时回退默认值)
|
||||
pub fn load_settings(&self) -> MusicSettings {
|
||||
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 defaults = self.default_settings();
|
||||
let settings = fs::read_to_string(self.settings_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<MusicSettings>(&s).ok())
|
||||
.unwrap_or_else(|| defaults.clone());
|
||||
// 自愈:保存目录为空 / 源为空时补默认值
|
||||
let mut settings = settings;
|
||||
if settings.savedir.trim().is_empty() {
|
||||
settings.savedir = defaults.savedir;
|
||||
}
|
||||
// 迁移:旧默认保存目录({root}/outputs)→ 系统下载目录
|
||||
if settings.savedir == self.outputs_dir().to_string_lossy() {
|
||||
settings.savedir = self.default_savedir();
|
||||
}
|
||||
if settings.sources.is_empty() {
|
||||
settings.sources = defaults.sources;
|
||||
}
|
||||
// 飞牛音乐多连接迁移:旧单连接字段 → 连接列表
|
||||
settings.migrate_feiniu();
|
||||
if let Ok(mut cache) = self.settings_cache.lock() {
|
||||
*cache = Some(SettingsCacheEntry {
|
||||
read_at: Instant::now(),
|
||||
settings: settings.clone(),
|
||||
});
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
/// 保存设置并更新缓存
|
||||
pub fn save_settings(&self, settings: &MusicSettings) -> Result<(), String> {
|
||||
let json = serde_json::to_string_pretty(settings).map_err(|e| format!("序列化设置失败: {}", e))?;
|
||||
fs::write(self.settings_path(), json).map_err(|e| format!("写入设置失败: {}", e))?;
|
||||
if let Ok(mut cache) = self.settings_cache.lock() {
|
||||
*cache = Some(SettingsCacheEntry {
|
||||
read_at: Instant::now(),
|
||||
settings: settings.clone(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- 目录 ----------
|
||||
pub fn runtime_dir(&self) -> PathBuf {
|
||||
self.root.join("runtime")
|
||||
}
|
||||
/// 默认音乐下载目录
|
||||
pub fn outputs_dir(&self) -> PathBuf {
|
||||
self.root.join("outputs")
|
||||
}
|
||||
pub fn bridge_script_path(&self) -> PathBuf {
|
||||
self.root.join("bridge.py")
|
||||
}
|
||||
pub fn bundled_python_exe(&self) -> PathBuf {
|
||||
self.runtime_dir().join("python").join("python.exe")
|
||||
}
|
||||
|
||||
// ---------- Python 探测 ----------
|
||||
/// 解析桥接要用的 Python 运行时。
|
||||
/// 便携版优先:musicdl 只装入便携运行时,系统 Python 无法保证装有 musicdl;
|
||||
/// 无便携版时退回系统 Python(此时 search 会报 musicdl 未安装,引导用户装便携版)。
|
||||
pub fn resolve_python(&self) -> Result<PythonEnv, String> {
|
||||
if let Some(env) = self.detect_bundled_python() {
|
||||
return Ok(env);
|
||||
}
|
||||
if let Some(env) = detect_system_python() {
|
||||
return Ok(env);
|
||||
}
|
||||
Err("未找到 Python 运行时:系统未安装 Python,且便携版未安装。请点击「安装便携版」".into())
|
||||
}
|
||||
|
||||
/// 便携 Python 是否可用(exe 存在且能跑 --version)
|
||||
pub fn detect_bundled_python(&self) -> Option<PythonEnv> {
|
||||
detect_bundled_python_at(&self.bundled_python_exe())
|
||||
}
|
||||
|
||||
// ---------- 环境状态 ----------
|
||||
/// 查询环境状态。子进程探测(python/musicdl/ffmpeg,便携 Python 冷启动可达数秒)
|
||||
/// 放入阻塞线程池执行,避免阻塞主线程导致 UI 冻结。
|
||||
pub async fn env_status(&self) -> Result<MusicEnvStatus, String> {
|
||||
let bundled_exe = self.bundled_python_exe();
|
||||
let bridge_running = self.bridge.lock().map(|b| b.is_some()).unwrap_or(false);
|
||||
let runtime_dir = self.root.to_string_lossy().to_string();
|
||||
let probe =
|
||||
tauri::async_runtime::spawn_blocking(move || probe_env_blocking(bundled_exe))
|
||||
.await
|
||||
.map_err(|e| format!("环境探测任务失败: {}", e))?;
|
||||
Ok(MusicEnvStatus {
|
||||
python: probe.python,
|
||||
python_source: probe.python_source.to_string(),
|
||||
bundled_python: probe.bundled_python,
|
||||
musicdl_installed: probe.musicdl_installed,
|
||||
musicdl_version: probe.musicdl_version,
|
||||
ffmpeg: probe.ffmpeg,
|
||||
bridge_running,
|
||||
runtime_dir,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 退出清理 ----------
|
||||
/// 应用退出时停止桥接进程(stdin/stdout 随 child drop 关闭,reader 线程读到 EOF 自行退出)
|
||||
pub fn cleanup_on_exit(&self) {
|
||||
self.stop_bridge();
|
||||
}
|
||||
|
||||
// ---------- 内部工具 ----------
|
||||
pub(crate) fn cancel(&self) {
|
||||
self.runtime_cancel.store(true, Ordering::SeqCst);
|
||||
let pid = self.install_pid.load(Ordering::SeqCst);
|
||||
if pid != 0 {
|
||||
// 杀掉正在执行的 pip/python 子进程,避免安装流程挂住
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/F", "/T", "/PID", &pid.to_string()])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_cancelled(&self) -> bool {
|
||||
self.runtime_cancel.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub(crate) fn start_lock(&self) -> &Mutex<()> {
|
||||
&self.start_lock
|
||||
}
|
||||
}
|
||||
|
||||
/// 环境探测结果(spawn_blocking 跨线程返回)
|
||||
struct EnvProbe {
|
||||
python: Option<String>,
|
||||
python_source: &'static str,
|
||||
bundled_python: Option<String>,
|
||||
musicdl_installed: bool,
|
||||
musicdl_version: Option<String>,
|
||||
ffmpeg: Option<String>,
|
||||
}
|
||||
|
||||
/// 阻塞式环境探测(串行 spawn 多个子进程,必须在阻塞线程池执行,禁止占用主线程)
|
||||
fn probe_env_blocking(bundled_exe: PathBuf) -> EnvProbe {
|
||||
let bundled = detect_bundled_python_at(&bundled_exe);
|
||||
let sys_py = detect_system_python();
|
||||
// 便携版优先(与 resolve_python 一致):musicdl 只装入便携运行时,
|
||||
// 若按系统优先检查,装好便携版后 UI 仍会误报 musicdl 未安装
|
||||
let python = bundled.as_ref().or(sys_py.as_ref());
|
||||
|
||||
let (musicdl_installed, musicdl_version) = match python {
|
||||
Some(env) => check_musicdl(&env.exe),
|
||||
None => (false, None),
|
||||
};
|
||||
EnvProbe {
|
||||
python: python.map(|e| e.version.clone()),
|
||||
python_source: match python {
|
||||
Some(e) => e.source,
|
||||
None => "none",
|
||||
},
|
||||
bundled_python: bundled.map(|e| e.exe.to_string_lossy().to_string()),
|
||||
musicdl_installed,
|
||||
musicdl_version,
|
||||
ffmpeg: check_ffmpeg(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定路径的便携 Python 是否可用(exe 存在且能跑 --version)
|
||||
fn detect_bundled_python_at(exe: &PathBuf) -> Option<PythonEnv> {
|
||||
if !exe.exists() {
|
||||
return None;
|
||||
}
|
||||
let version = run_python_version(exe)?;
|
||||
Some(PythonEnv {
|
||||
source: "bundled",
|
||||
exe: exe.clone(),
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
/// 探测系统 Python:依次尝试 python / py / python3,解析 `--version` 输出。
|
||||
/// 注意:Windows 的「应用商店别名」python 会在无安装时打印提示并以非零码退出,会被自然过滤。
|
||||
fn detect_system_python() -> Option<PythonEnv> {
|
||||
for candidate in ["python", "py", "python3"] {
|
||||
let mut cmd = std::process::Command::new(candidate);
|
||||
cmd.arg("--version");
|
||||
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.stdin(std::process::Stdio::null());
|
||||
let out = cmd.output().ok()?;
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
if let Some(version) = parse_python_version(&text) {
|
||||
return Some(PythonEnv {
|
||||
source: "system",
|
||||
exe: PathBuf::from(candidate),
|
||||
version,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 运行 `python --version` 并解析版本号
|
||||
fn run_python_version(exe: &PathBuf) -> Option<String> {
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
cmd.arg("--version");
|
||||
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.stdin(std::process::Stdio::null());
|
||||
let out = cmd.output().ok()?;
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
parse_python_version(&text)
|
||||
}
|
||||
|
||||
/// 从 "Python 3.14.5" 文本中提取 "3.14.5"
|
||||
fn parse_python_version(text: &str) -> Option<String> {
|
||||
text.split_whitespace().find_map(|w| {
|
||||
let mut parts = w.split('.');
|
||||
let major = parts.next()?.parse::<u32>().ok()?;
|
||||
let minor = parts.next()?.parse::<u32>().ok()?;
|
||||
if (major, minor) >= (3, 8) {
|
||||
Some(w.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 检查指定 Python 能否导入 musicdl(同步子进程调用,仅在设置页触发)
|
||||
fn check_musicdl(exe: &PathBuf) -> (bool, Option<String>) {
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
cmd.args([
|
||||
"-c",
|
||||
"import musicdl; print(getattr(musicdl, '__version__', 'unknown'))",
|
||||
]);
|
||||
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.stdin(std::process::Stdio::null());
|
||||
match cmd.output() {
|
||||
Ok(out) if out.status.success() => {
|
||||
let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(true, Some(version))
|
||||
}
|
||||
_ => (false, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查 FFmpeg 是否可用(部分海外音源需要,非必需)
|
||||
fn check_ffmpeg() -> Option<String> {
|
||||
let mut cmd = std::process::Command::new("ffmpeg");
|
||||
cmd.arg("-version");
|
||||
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||
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()
|
||||
.next()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! 便携 Python 运行时安装:流式下载 → 解压 → _pth 补丁 → get-pip 引导 → musicdl 安装。
|
||||
//! 子模块通过 `impl super::MusicManager` 追加方法。
|
||||
//!
|
||||
//! 依赖下载源(已验证):
|
||||
//! - https://www.python.org/ftp/python/{ver}/python-{ver}-embed-amd64.zip
|
||||
//! - https://bootstrap.pypa.io/get-pip.py
|
||||
//!
|
||||
//! 取消:`MusicManager::cancel()` 置标志 + taskkill 当前 pip/python 子进程。
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::{MusicInstallProgress, MusicManager, PIP_INDEX_URL};
|
||||
use crate::constants::events::MUSIC_RUNTIME_INSTALL_PROGRESS;
|
||||
|
||||
/// 用户主动取消安装的标记错误信息(前端据此静默处理)
|
||||
pub(crate) const RUNTIME_CANCELLED: &str = "安装已取消";
|
||||
|
||||
impl MusicManager {
|
||||
/// 安装便携 Python + musicdl(幂等:已就绪的步骤自动跳过),全程推送进度事件。
|
||||
pub async fn install_runtime(&self, app: &AppHandle) -> Result<(), String> {
|
||||
self.runtime_cancel.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
let result = self.install_runtime_inner(app).await;
|
||||
if let Err(ref e) = result {
|
||||
if e != RUNTIME_CANCELLED {
|
||||
let _ = app.emit(
|
||||
MUSIC_RUNTIME_INSTALL_PROGRESS,
|
||||
MusicInstallProgress {
|
||||
stage: "error".into(),
|
||||
percent: 0,
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
message: e.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn install_runtime_inner(&self, app: &AppHandle) -> Result<(), String> {
|
||||
let runtime_dir = self.runtime_dir();
|
||||
fs::create_dir_all(&runtime_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let python_exe = self.bundled_python_exe();
|
||||
|
||||
// ---------- 1. 便携 Python 已就绪则跳过下载/解压 ----------
|
||||
if !(python_exe.exists() && super::run_python_version(&python_exe).is_some()) {
|
||||
self.emit_progress(app, "download", 2, 0, None, "开始下载便携 Python...").await;
|
||||
|
||||
// 下载 embeddable zip(~11MB,流式写入)
|
||||
let zip_path = runtime_dir.join(format!("python-{}-embed.zip", super::BUNDLED_PY_VERSION));
|
||||
let zip_url = format!(
|
||||
"https://www.python.org/ftp/python/{}/python-{}-embed-amd64.zip",
|
||||
super::BUNDLED_PY_VERSION,
|
||||
super::BUNDLED_PY_VERSION
|
||||
);
|
||||
self.download_stream(app, &zip_url, &zip_path).await?;
|
||||
|
||||
// 解压到 runtime/python/
|
||||
self.emit_progress(app, "extract", 42, 0, None, "正在解压便携 Python...").await;
|
||||
let python_dir = runtime_dir.join("python");
|
||||
if python_dir.exists() {
|
||||
fs::remove_dir_all(&python_dir).ok();
|
||||
}
|
||||
fs::create_dir_all(&python_dir).map_err(|e| e.to_string())?;
|
||||
self.extract_zip(&zip_path, &python_dir)?;
|
||||
fs::remove_file(&zip_path).ok();
|
||||
|
||||
// _pth 补丁:启用 site(否则无法识别 site-packages 与 pip)
|
||||
self.emit_progress(app, "patch", 52, 0, None, "正在配置 Python 环境...").await;
|
||||
self.patch_pth(&python_dir)?;
|
||||
|
||||
// 下载 get-pip.py 并引导 pip(只装 pip 本体,走官方源,包很小)
|
||||
self.emit_progress(app, "pip", 54, 0, None, "正在引导 pip...").await;
|
||||
let get_pip = runtime_dir.join("get-pip.py");
|
||||
self.download_bytes(&"https://bootstrap.pypa.io/get-pip.py".to_string(), &get_pip)
|
||||
.await?;
|
||||
let pip_py = python_exe.clone();
|
||||
let pip_py2 = pip_py.clone(); // 供 setuptools 步骤闭包使用(先于 move 克隆)
|
||||
let get_pip2 = get_pip.clone();
|
||||
self.run_blocking_step(
|
||||
app,
|
||||
"pip",
|
||||
55,
|
||||
60,
|
||||
move |pid| {
|
||||
let mut cmd = std::process::Command::new(&pip_py);
|
||||
cmd.arg(&get_pip2).args(["--no-warn-script-location"]);
|
||||
run_cmd_blocking(cmd, pid)
|
||||
},
|
||||
"正在安装 pip...",
|
||||
)
|
||||
.await?;
|
||||
fs::remove_file(&get_pip).ok();
|
||||
|
||||
// 安装 setuptools(musicdl 的 setup.py 构建依赖 setuptools.build_meta,
|
||||
// 必须先于 musicdl 就位,否则构建阶段报 BackendUnavailable)
|
||||
self.run_blocking_step(
|
||||
app,
|
||||
"pip",
|
||||
62,
|
||||
66,
|
||||
move |pid| {
|
||||
let mut cmd = std::process::Command::new(&pip_py2);
|
||||
cmd.args([
|
||||
"-m", "pip", "install", "--no-warn-script-location",
|
||||
"--timeout", "60",
|
||||
"--index-url", PIP_INDEX_URL,
|
||||
"setuptools",
|
||||
]);
|
||||
run_cmd_blocking(cmd, pid)
|
||||
},
|
||||
"正在安装 setuptools...",
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
self.emit_progress(app, "check", 2, 0, None, "便携 Python 已就绪").await;
|
||||
}
|
||||
|
||||
// ---------- 2. 安装 musicdl(幂等:已安装则跳过) ----------
|
||||
if !self.musicdl_ready(&python_exe).await {
|
||||
let pip_py = python_exe.clone();
|
||||
self.run_blocking_step(
|
||||
app,
|
||||
"musicdl",
|
||||
68,
|
||||
95,
|
||||
move |pid| {
|
||||
let mut cmd = std::process::Command::new(&pip_py);
|
||||
cmd.args([
|
||||
"-m", "pip", "install", "--no-warn-script-location",
|
||||
"--timeout", "60",
|
||||
"--index-url", PIP_INDEX_URL,
|
||||
&format!("musicdl=={}", super::MUSICDL_VERSION),
|
||||
]);
|
||||
run_cmd_blocking(cmd, pid)
|
||||
},
|
||||
"正在安装 musicdl(下载依赖较多,可能需几分钟)...",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
self.emit_progress(app, "done", 100, 0, None, "环境就绪").await;
|
||||
crate::logger::log_info("music", "便携 Python + musicdl 安装完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查便携 Python 能否导入 musicdl
|
||||
async fn musicdl_ready(&self, python_exe: &PathBuf) -> bool {
|
||||
let exe = python_exe.clone();
|
||||
let (ok, _) = super::check_musicdl(&exe);
|
||||
ok
|
||||
}
|
||||
|
||||
// ---------- 阶段工具 ----------
|
||||
async fn emit_progress(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
stage: &str,
|
||||
percent: u32,
|
||||
downloaded_bytes: u64,
|
||||
total_bytes: Option<u64>,
|
||||
message: &str,
|
||||
) {
|
||||
let _ = app.emit(
|
||||
MUSIC_RUNTIME_INSTALL_PROGRESS,
|
||||
MusicInstallProgress {
|
||||
stage: stage.into(),
|
||||
percent,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
message: message.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 流式下载(带取消 + 进度事件,percent 0-40 区间),复用内核下载模式
|
||||
async fn download_stream(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
) -> Result<(), String> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求下载失败: {}", e))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("下载返回 HTTP {}", status.as_u16()));
|
||||
}
|
||||
let total: Option<u64> = resp.content_length();
|
||||
let mut file = fs::File::create(dest).map_err(|e| format!("创建文件失败: {}", e))?;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut last_percent: u32 = 0;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if self.is_cancelled() {
|
||||
drop(file);
|
||||
fs::remove_file(dest).ok();
|
||||
return Err(RUNTIME_CANCELLED.to_string());
|
||||
}
|
||||
let chunk = chunk.map_err(|e| format!("下载中断: {}", e))?;
|
||||
file.write_all(&chunk).map_err(|e| format!("写入文件失败: {}", e))?;
|
||||
downloaded += chunk.len() as u64;
|
||||
let percent = total
|
||||
.filter(|t| *t > 0)
|
||||
.map(|t| ((downloaded as f64 / t as f64) * 38.0) as u32)
|
||||
.unwrap_or(0)
|
||||
.min(38);
|
||||
if percent >= last_percent + 1 {
|
||||
last_percent = percent;
|
||||
self.emit_progress(
|
||||
app,
|
||||
"download",
|
||||
percent,
|
||||
downloaded,
|
||||
total,
|
||||
&format!("正在下载便携 Python ({:.1} MB)", downloaded as f64 / 1048576.0),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
file.flush().ok();
|
||||
self.emit_progress(app, "download", 40, downloaded, total, "下载完成").await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 小文件整体下载(get-pip.py),无进度
|
||||
async fn download_bytes(&self, url: &str, dest: &Path) -> Result<(), String> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求下载失败: {}", e))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("下载返回 HTTP {}", status.as_u16()));
|
||||
}
|
||||
let bytes = resp.bytes().await.map_err(|e| format!("读取响应失败: {}", e))?;
|
||||
fs::write(dest, &bytes).map_err(|e| format!("写入文件失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解压 zip(复用内核解压逻辑)
|
||||
fn extract_zip(&self, 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(())
|
||||
}
|
||||
|
||||
/// 修改 pythonXY._pth:启用 `import site`(embeddable 默认注释掉,
|
||||
/// 不启用则无法识别 site-packages / pip 安装的包)
|
||||
fn patch_pth(&self, python_dir: &Path) -> Result<(), String> {
|
||||
let entries = fs::read_dir(python_dir).map_err(|e| e.to_string())?;
|
||||
let pth = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.find(|p| {
|
||||
p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.eq_ignore_ascii_case("_pth"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.ok_or_else(|| "解压目录中未找到 ._pth 文件".to_string())?;
|
||||
|
||||
let content = fs::read_to_string(&pth).map_err(|e| e.to_string())?;
|
||||
let mut patched = content.replace("#import site", "import site");
|
||||
if !patched.contains("import site") {
|
||||
patched.push_str("import site\n");
|
||||
}
|
||||
// 显式把 site-packages 加入 sys.path(pip 默认安装位置)
|
||||
if !patched.contains("Lib\\site-packages") && !patched.contains("Lib/site-packages") {
|
||||
patched.push_str("Lib\\site-packages\n");
|
||||
}
|
||||
fs::write(&pth, patched).map_err(|e| format!("写入 _pth 失败: {}", e))?;
|
||||
crate::logger::log_info("music", &format!("已补丁 _pth: {}", pth.display()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行一个阻塞子进程步骤(get-pip / setuptools / musicdl),带进度推送、取消检查与超时看门狗。
|
||||
/// start_percent / end_percent:本步骤的进度区间(完成前推进到 end_percent)。
|
||||
/// run 闭包接收「子进程 pid 记录器」,供取消时 taskkill。
|
||||
async fn run_blocking_step<F>(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
stage: &str,
|
||||
start_percent: u32,
|
||||
end_percent: u32,
|
||||
run: F,
|
||||
msg: &str,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnOnce(Arc<std::sync::atomic::AtomicU64>) -> Result<(), String> + Send + 'static,
|
||||
{
|
||||
if self.is_cancelled() {
|
||||
return Err(RUNTIME_CANCELLED.to_string());
|
||||
}
|
||||
let pid_ref = self.install_pid.clone();
|
||||
self.emit_progress(app, stage, start_percent, 0, None, msg).await;
|
||||
let result = tauri::async_runtime::spawn_blocking(move || run(pid_ref.clone()))
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {}", e))?;
|
||||
self.install_pid.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
if self.is_cancelled() {
|
||||
return Err(RUNTIME_CANCELLED.to_string());
|
||||
}
|
||||
result?;
|
||||
self.emit_progress(app, stage, end_percent, 0, None, "完成").await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步运行子进程,带超时看门狗(超时 taskkill),并记录 pid 供取消。
|
||||
/// 输出(stdout+stderr 尾部)写入日志;失败返回错误信息。
|
||||
fn run_cmd_blocking(
|
||||
mut cmd: std::process::Command,
|
||||
pid_ref: Arc<std::sync::atomic::AtomicU64>,
|
||||
) -> Result<(), String> {
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.stdin(std::process::Stdio::null());
|
||||
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||
|
||||
let child = cmd.spawn().map_err(|e| format!("启动子进程失败: {}", e))?;
|
||||
pid_ref.store(child.id() as u64, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
// 超时看门狗:30 分钟后仍未结束则强杀(慢网络下 pip 装 musicdl 依赖可能超过 10 分钟)
|
||||
let pid = child.id();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
||||
let watcher = std::thread::spawn(move || {
|
||||
if rx.recv_timeout(std::time::Duration::from_secs(1800)).is_err() {
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/F", "/T", "/PID", &pid.to_string()])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
});
|
||||
|
||||
let output = child.wait_with_output();
|
||||
let _ = tx.send(());
|
||||
let _ = watcher.join();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
// 截取尾部 800 字符,便于定位 pip 报错
|
||||
let tail: String = text.chars().rev().take(800).collect::<String>().chars().rev().collect();
|
||||
Err(format!("子进程退出码 {}: {}", out.status.code().unwrap_or(-1), tail))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("读取子进程输出失败: {}", e)),
|
||||
}
|
||||
}
|
||||
@@ -95,9 +95,9 @@ impl NetworkMonitor {
|
||||
};
|
||||
|
||||
// emit 给前端(失败忽略:窗口可能未就绪)
|
||||
let _ = app.emit("monitor-network", &speed);
|
||||
let _ = app.emit(crate::constants::events::MONITOR_NETWORK, &speed);
|
||||
}
|
||||
eprintln!("[network] 网速采样任务已退出");
|
||||
crate::logger::log_info("network", "网速采样任务已退出");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ impl Default for NetworkMonitor {
|
||||
|
||||
/// Tauri 命令:获取网速监控是否运行
|
||||
#[tauri::command]
|
||||
pub fn network_monitor_status(
|
||||
pub fn network_status(
|
||||
state: tauri::State<'_, NetworkMonitor>,
|
||||
) -> bool {
|
||||
state.running.load(Ordering::SeqCst)
|
||||
|
||||
+247
-23
@@ -6,8 +6,8 @@
|
||||
//! - 任务栏覆盖检测:轮询 GetForegroundWindow,检测系统 UI 出现时暂时取消置顶
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::thread;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
@@ -15,6 +15,12 @@ use tauri::{AppHandle, Emitter};
|
||||
static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||||
/// 任务栏覆盖监视线程停止标志
|
||||
static TOPMOST_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||||
/// 游戏全屏监视线程停止标志
|
||||
static GAME_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);
|
||||
static GAME_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||||
|
||||
fn drag_stop() -> &'static Arc<AtomicBool> {
|
||||
DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||||
@@ -24,18 +30,63 @@ fn topmost_stop() -> &'static Arc<AtomicBool> {
|
||||
TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||||
}
|
||||
|
||||
fn game_stop() -> &'static Arc<AtomicBool> {
|
||||
GAME_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();
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止游戏全屏监视线程并等待其退出
|
||||
fn stop_game_thread() {
|
||||
game_stop().store(true, Ordering::SeqCst);
|
||||
if let Some(h) = GAME_HANDLE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take()
|
||||
{
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod win_api {
|
||||
use tauri::{AppHandle, Manager};
|
||||
use windows_sys::Win32::Foundation::{POINT, RECT};
|
||||
use windows_sys::Win32::Graphics::Gdi::{
|
||||
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||
};
|
||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_RBUTTON};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW,
|
||||
GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos,
|
||||
GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE,
|
||||
SWP_NOSIZE, SWP_SHOWWINDOW, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW,
|
||||
WS_EX_TRANSPARENT,
|
||||
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetWindowLongW,
|
||||
GetWindowRect, GetWindowThreadProcessId, SendMessageW, SetWindowLongPtrW, SetWindowPos,
|
||||
GWL_EXSTYLE, GWL_STYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE,
|
||||
SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE,
|
||||
WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT,
|
||||
};
|
||||
/// 供模块外全屏判定使用的窗口样式常量(pub re-export)
|
||||
pub use windows_sys::Win32::UI::WindowsAndMessaging::WS_CAPTION;
|
||||
|
||||
/// windows-sys 的 HWND 类型别名(isize)
|
||||
pub type Hwnd = isize;
|
||||
@@ -138,6 +189,10 @@ mod win_api {
|
||||
} else {
|
||||
HWND_NOTOPMOST
|
||||
};
|
||||
// 注意:不传 SWP_SHOWWINDOW,仅调整 Z 序,绝不改变窗口可见性。
|
||||
// 否则当 OSD 被 .hide() 隐藏后,任务栏覆盖监视线程在系统 UI 前景切换时
|
||||
// (点击任务栏/托盘关闭主界面、打开托盘菜单)会重新显示已隐藏的 OSD,
|
||||
// 表现为"托盘关闭 OSD 无效 / 关闭主界面后 OSD 又出现"。
|
||||
SetWindowPos(
|
||||
hwnd,
|
||||
insert_after,
|
||||
@@ -145,7 +200,7 @@ mod win_api {
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -163,6 +218,24 @@ mod win_api {
|
||||
}
|
||||
}
|
||||
|
||||
/// 原子设置窗口位置与尺寸(物理像素)
|
||||
///
|
||||
/// 一次 SetWindowPos 调用同时更新 x/y/w/h,避免 setSize + setPosition
|
||||
/// 两次调用之间出现"宽度已变、位置未动"的中间帧(视觉闪烁)。
|
||||
pub fn set_bounds(hwnd: Hwnd, x: i32, y: i32, w: i32, h: i32) {
|
||||
unsafe {
|
||||
SetWindowPos(
|
||||
hwnd,
|
||||
0, // 不改 Z 序
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断窗口类名是否为系统 UI(任务栏、开始菜单、通知区域等)
|
||||
pub fn is_system_ui_class(class_name: &str) -> bool {
|
||||
matches!(
|
||||
@@ -176,6 +249,41 @@ mod win_api {
|
||||
| "Windows.UI.Shell.ShellFlyoutWindow" // Win11 Shell 弹出
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取窗口样式(GWL_STYLE)
|
||||
pub fn get_window_style(hwnd: Hwnd) -> isize {
|
||||
unsafe { GetWindowLongW(hwnd, GWL_STYLE) as isize }
|
||||
}
|
||||
|
||||
/// 判断窗口是否属于本进程(Thing 自身窗口不参与全屏判定)
|
||||
pub fn is_own_process(hwnd: Hwnd) -> bool {
|
||||
let mut pid: u32 = 0;
|
||||
unsafe {
|
||||
GetWindowThreadProcessId(hwnd, &mut pid);
|
||||
}
|
||||
pid != 0 && pid == std::process::id()
|
||||
}
|
||||
|
||||
/// 获取窗口所在显示器(最近匹配)的矩形(物理像素)
|
||||
pub fn get_monitor_rect(hwnd: Hwnd) -> Option<RECT> {
|
||||
let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) };
|
||||
if monitor == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
rcMonitor: RECT { left: 0, top: 0, right: 0, bottom: 0 },
|
||||
rcWork: RECT { left: 0, top: 0, right: 0, bottom: 0 },
|
||||
dwFlags: 0,
|
||||
};
|
||||
unsafe {
|
||||
if GetMonitorInfoW(monitor, &mut info) != 0 {
|
||||
Some(info.rcMonitor)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用 OSD 悬浮窗的原生样式(NoActivate + ToolWindow)
|
||||
@@ -186,7 +294,7 @@ pub fn osd_apply_overlay_style(label: String, app: AppHandle) -> Result<(), Stri
|
||||
let hwnd = win_api::get_hwnd(&label, &app)
|
||||
.ok_or_else(|| format!("窗口 {} 不存在", label))?;
|
||||
win_api::apply_no_activate(hwnd);
|
||||
eprintln!("[osd] 已应用 NoActivate 样式到窗口 {}", label);
|
||||
crate::logger::log_info("osd", &format!("已应用 NoActivate 样式到窗口 {}", label));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -198,15 +306,14 @@ pub fn osd_apply_overlay_style(label: String, app: AppHandle) -> Result<(), Stri
|
||||
/// 右键释放后发出 `osd-end-drag` 事件。
|
||||
#[tauri::command]
|
||||
pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String> {
|
||||
// 停止旧线程,等待退出
|
||||
drag_stop().store(true, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
// 停止旧线程,等待其退出后再启动新线程(避免新旧线程并存)
|
||||
stop_drag_thread();
|
||||
let stop_flag = drag_stop().clone();
|
||||
stop_flag.store(false, Ordering::SeqCst);
|
||||
|
||||
let app_handle = app.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let handle = thread::spawn(move || {
|
||||
let mut rbutton_was_down = false;
|
||||
let mut press_start: Option<Instant> = None;
|
||||
let mut drag_emitted = false;
|
||||
@@ -234,7 +341,7 @@ pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String>
|
||||
(win_api::get_cursor_pos(), win_api::get_window_rect(hwnd))
|
||||
{
|
||||
if win_api::point_in_rect(pt, rect) {
|
||||
let _ = app_handle.emit("osd-start-drag", ());
|
||||
let _ = app_handle.emit(crate::constants::events::OSD_START_DRAG, ());
|
||||
drag_emitted = true;
|
||||
}
|
||||
}
|
||||
@@ -245,7 +352,7 @@ pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String>
|
||||
} else if !rbutton_down && rbutton_was_down {
|
||||
// 右键释放
|
||||
if drag_emitted {
|
||||
let _ = app_handle.emit("osd-end-drag", ());
|
||||
let _ = app_handle.emit(crate::constants::events::OSD_END_DRAG, ());
|
||||
}
|
||||
press_start = None;
|
||||
drag_emitted = false;
|
||||
@@ -258,6 +365,10 @@ pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String>
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(mut guard) = DRAG_HANDLE.lock() {
|
||||
*guard = Some(handle);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -268,14 +379,14 @@ pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String>
|
||||
/// 系统 UI 关闭后发出 `osd-system-ui-inactive` 事件恢复置顶。
|
||||
#[tauri::command]
|
||||
pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> {
|
||||
topmost_stop().store(true, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
// 停止旧线程,等待其退出后再启动新线程
|
||||
stop_topmost_thread();
|
||||
let stop_flag = topmost_stop().clone();
|
||||
stop_flag.store(false, Ordering::SeqCst);
|
||||
|
||||
let app_handle = app.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let handle = thread::spawn(move || {
|
||||
let mut last_foreground: isize = 0;
|
||||
let mut system_ui_active = false;
|
||||
|
||||
@@ -293,15 +404,15 @@ pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> {
|
||||
if win_api::is_system_ui_class(&class) {
|
||||
if !system_ui_active {
|
||||
system_ui_active = true;
|
||||
let _ = app_handle.emit("osd-system-ui-active", ());
|
||||
let _ = app_handle.emit(crate::constants::events::OSD_SYSTEM_UI_ACTIVE, ());
|
||||
}
|
||||
} else if system_ui_active {
|
||||
system_ui_active = false;
|
||||
let _ = app_handle.emit("osd-system-ui-inactive", ());
|
||||
let _ = app_handle.emit(crate::constants::events::OSD_SYSTEM_UI_INACTIVE, ());
|
||||
}
|
||||
} else if system_ui_active {
|
||||
system_ui_active = false;
|
||||
let _ = app_handle.emit("osd-system-ui-inactive", ());
|
||||
let _ = app_handle.emit(crate::constants::events::OSD_SYSTEM_UI_INACTIVE, ());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,14 +421,105 @@ pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> {
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(mut guard) = TOPMOST_HANDLE.lock() {
|
||||
*guard = Some(handle);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 判断窗口是否为全屏应用(无边框/独占全屏游戏)
|
||||
///
|
||||
/// 判定条件(全部满足):
|
||||
/// 1. 无 WS_CAPTION 样式 —— 排除普通应用的"最大化"(即使系统任务栏设为自动隐藏,
|
||||
/// 最大化窗口覆盖率也接近 100%,但它们带标题栏,靠样式即可区分)
|
||||
/// 2. 非本进程窗口(Thing 主窗口/悬浮窗自身)
|
||||
/// 3. 窗口矩形与所在显示器矩形的交集覆盖率 ≥ 95%(兼容缩放/1px 误差)
|
||||
#[cfg(windows)]
|
||||
fn is_fullscreen_game_window(hwnd: isize) -> bool {
|
||||
if win_api::get_window_style(hwnd) & (win_api::WS_CAPTION as isize) != 0 {
|
||||
return false;
|
||||
}
|
||||
if win_api::is_own_process(hwnd) {
|
||||
return false;
|
||||
}
|
||||
let (Some(win_rect), Some(mon_rect)) = (
|
||||
win_api::get_window_rect(hwnd),
|
||||
win_api::get_monitor_rect(hwnd),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let iw = (win_rect.right.min(mon_rect.right) - win_rect.left.max(mon_rect.left)).max(0) as i64;
|
||||
let ih = (win_rect.bottom.min(mon_rect.bottom) - win_rect.top.max(mon_rect.top)).max(0) as i64;
|
||||
let mw = (mon_rect.right - mon_rect.left).max(0) as i64;
|
||||
let mh = (mon_rect.bottom - mon_rect.top).max(0) as i64;
|
||||
let mon_area = (mw * mh).max(1);
|
||||
iw * ih * 100 >= mon_area * 95
|
||||
}
|
||||
|
||||
/// 启动游戏全屏监视
|
||||
///
|
||||
/// 轮询检测前景窗口是否为全屏应用(无边框/独占全屏游戏),
|
||||
/// 状态变化时发出 `osd-game-active` / `osd-game-inactive` 事件。
|
||||
/// 前端据此隐藏/恢复 OSD:透明置顶 WebView 悬浮窗会占用 DWM 合成路径,
|
||||
/// 禁用游戏的独立翻转(MPO),是游戏中掉帧的根源;隐藏悬浮窗即可排除影响。
|
||||
#[tauri::command]
|
||||
pub fn osd_start_game_watch(app: AppHandle) -> Result<(), String> {
|
||||
// 停止旧线程,等待其退出后再启动新线程
|
||||
stop_game_thread();
|
||||
let stop_flag = game_stop().clone();
|
||||
stop_flag.store(false, Ordering::SeqCst);
|
||||
|
||||
let app_handle = app.clone();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let mut fullscreen_active = false;
|
||||
|
||||
loop {
|
||||
if stop_flag.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let fg = win_api::get_foreground_window();
|
||||
let fullscreen = fg != 0
|
||||
&& win_api::get_class_name(fg)
|
||||
.map_or(false, |c| !win_api::is_system_ui_class(&c))
|
||||
&& is_fullscreen_game_window(fg);
|
||||
|
||||
if fullscreen != fullscreen_active {
|
||||
fullscreen_active = fullscreen;
|
||||
let event = if fullscreen {
|
||||
crate::constants::events::OSD_GAME_ACTIVE
|
||||
} else {
|
||||
crate::constants::events::OSD_GAME_INACTIVE
|
||||
};
|
||||
let _ = app_handle.emit(event, ());
|
||||
crate::logger::log_info(
|
||||
"osd",
|
||||
&format!("全屏应用前台: {}", if fullscreen { "是 → 隐藏 OSD" } else { "否 → 恢复 OSD" }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(1000));
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(mut guard) = GAME_HANDLE.lock() {
|
||||
*guard = Some(handle);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止所有 OSD 监视线程
|
||||
#[tauri::command]
|
||||
pub fn osd_stop_watch() {
|
||||
drag_stop().store(true, Ordering::SeqCst);
|
||||
topmost_stop().store(true, Ordering::SeqCst);
|
||||
stop_drag_thread();
|
||||
stop_topmost_thread();
|
||||
stop_game_thread();
|
||||
}
|
||||
|
||||
/// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠)
|
||||
@@ -344,6 +546,28 @@ pub fn osd_set_topmost(label: String, topmost: bool, app: AppHandle) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 原子设置窗口位置与尺寸(物理像素)
|
||||
///
|
||||
/// 一次调用同时更新位置和尺寸,避免 setSize + setPosition 两次 IPC 之间的
|
||||
/// 中间帧(宽度已变、位置未动 → 视觉闪烁)。前端传入物理像素坐标。
|
||||
#[tauri::command]
|
||||
pub fn osd_set_bounds(
|
||||
label: String,
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let hwnd = win_api::get_hwnd(&label, &app)
|
||||
.ok_or_else(|| format!("窗口 {} 不存在", label))?;
|
||||
win_api::set_bounds(hwnd, x, y, w, h);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 启动原生拖动(右键长按触发)
|
||||
///
|
||||
/// 同步关闭点击穿透(WS_EX_TRANSPARENT),然后在独立线程中调用
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
@@ -10,6 +11,11 @@ use tauri::{AppHandle, Emitter, Manager};
|
||||
// CREATE_NO_WINDOW = 0x08000000,阻止子进程创建新的控制台窗口
|
||||
#[cfg(windows)]
|
||||
pub const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
// CREATE_NEW_CONSOLE = 0x00000010,强制为控制台类子进程新开一个可见控制台窗口。
|
||||
// 从 GUI 宿主(无控制台)直接 spawn cmd/powershell 等控制台程序时若不设置,
|
||||
// 子进程会挂到隐藏控制台/不显示窗口,表现为"点击没反应"。
|
||||
#[cfg(windows)]
|
||||
pub const CREATE_NEW_CONSOLE: u32 = 0x00000010;
|
||||
|
||||
// Windows Job Object 相关常量,用于异常退出时自动清理子进程
|
||||
#[cfg(windows)]
|
||||
@@ -80,7 +86,7 @@ fn get_job_handle() -> Option<winapi::HANDLE> {
|
||||
unsafe {
|
||||
let h = winapi::CreateJobObjectW(std::ptr::null_mut(), std::ptr::null());
|
||||
if h.is_null() {
|
||||
eprintln!("[ProcessManager] CreateJobObjectW 失败,异常退出时子进程可能残留");
|
||||
crate::logger::log_error("process", "CreateJobObjectW 失败,异常退出时子进程可能残留");
|
||||
return 0;
|
||||
}
|
||||
// 设置 KILL_ON_JOB_CLOSE:主进程退出时自动终止所有子进程
|
||||
@@ -93,7 +99,7 @@ fn get_job_handle() -> Option<winapi::HANDLE> {
|
||||
std::mem::size_of::<winapi::JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
);
|
||||
if ok == 0 {
|
||||
eprintln!("[ProcessManager] SetInformationJobObject 失败");
|
||||
crate::logger::log_error("process", "SetInformationJobObject 失败");
|
||||
return 0;
|
||||
}
|
||||
h as usize
|
||||
@@ -121,8 +127,9 @@ pub fn setup_creation_flags(_cmd: &mut Command) {
|
||||
|
||||
/// 将已启动的子进程加入 Job Object(异常退出时自动清理)
|
||||
/// 在 Windows 上调用,非 Windows 平台为空操作
|
||||
/// pub(crate):音乐模块的桥接进程(自管 stdio,不走 ProcessManager)也需加入 Job
|
||||
#[cfg(windows)]
|
||||
fn assign_to_job(child: &Child) {
|
||||
pub(crate) fn assign_to_job(child: &Child) {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
if let Some(job) = get_job_handle() {
|
||||
let child_handle = child.as_raw_handle() as winapi::HANDLE;
|
||||
@@ -136,7 +143,7 @@ fn assign_to_job(child: &Child) {
|
||||
fn assign_to_job(_child: &Child) {}
|
||||
|
||||
/// 进程状态枚举
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[derive(Serialize, Clone, Debug, Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProcessStatus {
|
||||
Running,
|
||||
@@ -147,7 +154,8 @@ pub enum ProcessStatus {
|
||||
}
|
||||
|
||||
/// 进程信息(返回给前端)
|
||||
#[derive(Serialize, Clone)]
|
||||
#[derive(Serialize, Clone, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -291,7 +299,7 @@ impl ProcessManager {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
println!("[ProcessManager] 进程 {} 等待退出超时(3s),放弃等待", id);
|
||||
crate::logger::log_warn("process", &format!("进程 {} 等待退出超时(3s),放弃等待", id));
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
@@ -299,7 +307,7 @@ impl ProcessManager {
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
println!("[ProcessManager] 已停止进程: {}", id);
|
||||
crate::logger::log_info("process", &format!("已停止进程: {}", id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,18 +359,24 @@ impl ProcessManager {
|
||||
|
||||
/// 检查所有进程,处理崩溃的进程(自动重启或移除)
|
||||
/// 返回状态发生变化的进程列表
|
||||
/// 注意:kill/wait/sleep(800ms) 等阻塞操作一律在锁外执行,
|
||||
/// 锁内仅做非阻塞的 try_wait 判定,避免阻塞其他进程的状态查询
|
||||
pub fn check_and_cleanup(&self) -> Vec<ProcessInfo> {
|
||||
let mut changes = Vec::new();
|
||||
// 需要重启的条目(锁外执行 kill + sleep + spawn)
|
||||
let mut restarts: Vec<(String, ProcessEntry)> = Vec::new();
|
||||
|
||||
let mut processes = match self.processes.lock() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return changes,
|
||||
};
|
||||
// 阶段 1:锁内快速判定(仅非阻塞 try_wait),收集重启/移除决策
|
||||
{
|
||||
let mut processes = match self.processes.lock() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return changes,
|
||||
};
|
||||
|
||||
let ids: Vec<String> = processes.keys().cloned().collect();
|
||||
let ids: Vec<String> = processes.keys().cloned().collect();
|
||||
|
||||
for id in ids {
|
||||
if let Some(entry) = processes.get_mut(&id) {
|
||||
for id in ids {
|
||||
let Some(entry) = processes.get_mut(&id) else { continue };
|
||||
match entry.child.try_wait() {
|
||||
Ok(None) => {
|
||||
// 仍在运行,无需处理
|
||||
@@ -373,58 +387,10 @@ impl ProcessManager {
|
||||
&& (entry.max_restarts == 0
|
||||
|| entry.restart_count < entry.max_restarts)
|
||||
{
|
||||
// 自动重启
|
||||
let restart_count = entry.restart_count + 1;
|
||||
let executable = entry.executable.clone();
|
||||
let args = entry.args.clone();
|
||||
let cwd = entry.cwd.clone();
|
||||
let name = entry.name.clone();
|
||||
|
||||
// 先终止旧进程
|
||||
let _ = entry.child.kill();
|
||||
let _ = entry.child.wait();
|
||||
// 等待 TCP 端口释放(Windows 上 kill 后端口释放有延迟)
|
||||
std::thread::sleep(std::time::Duration::from_millis(800));
|
||||
|
||||
// 重新启动
|
||||
let mut cmd = Command::new(&executable);
|
||||
cmd.args(&args);
|
||||
if let Some(ref dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.stdin(Stdio::null());
|
||||
setup_creation_flags(&mut cmd);
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok(new_child) => {
|
||||
let pid = new_child.id();
|
||||
entry.child = new_child;
|
||||
entry.restart_count = restart_count;
|
||||
|
||||
changes.push(ProcessInfo {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
status: ProcessStatus::Running,
|
||||
pid: Some(pid),
|
||||
restart_count,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[ProcessManager] 重启进程 '{}' 失败: {}",
|
||||
id, e
|
||||
);
|
||||
processes.remove(&id);
|
||||
changes.push(ProcessInfo {
|
||||
id: id.clone(),
|
||||
name,
|
||||
status: ProcessStatus::Crashed,
|
||||
pid: None,
|
||||
restart_count,
|
||||
});
|
||||
}
|
||||
// 需要自动重启:从 map 移除,锁外执行
|
||||
if let Some(mut e) = processes.remove(&id) {
|
||||
e.restart_count += 1;
|
||||
restarts.push((id.clone(), e));
|
||||
}
|
||||
} else {
|
||||
// 不自动重启,移除记录
|
||||
@@ -455,6 +421,58 @@ impl ProcessManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
} // 锁在此释放
|
||||
|
||||
// 阶段 2:锁外执行重启(kill + wait + 端口释放等待 + spawn,不阻塞进程状态查询)
|
||||
for (id, mut entry) in restarts {
|
||||
// 先终止旧进程
|
||||
let _ = entry.child.kill();
|
||||
let _ = entry.child.wait();
|
||||
// 等待 TCP 端口释放(Windows 上 kill 后端口释放有延迟)
|
||||
std::thread::sleep(std::time::Duration::from_millis(800));
|
||||
|
||||
// 重新启动
|
||||
let mut cmd = Command::new(&entry.executable);
|
||||
cmd.args(&entry.args);
|
||||
if let Some(ref dir) = entry.cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.stdin(Stdio::null());
|
||||
setup_creation_flags(&mut cmd);
|
||||
|
||||
let name = entry.name.clone();
|
||||
let restart_count = entry.restart_count;
|
||||
match cmd.spawn() {
|
||||
Ok(new_child) => {
|
||||
let pid = new_child.id();
|
||||
entry.child = new_child;
|
||||
if let Ok(mut processes) = self.processes.lock() {
|
||||
processes.insert(id.clone(), entry);
|
||||
}
|
||||
changes.push(ProcessInfo {
|
||||
id: id.clone(),
|
||||
name,
|
||||
status: ProcessStatus::Running,
|
||||
pid: Some(pid),
|
||||
restart_count,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
crate::logger::log_error(
|
||||
"process",
|
||||
&format!("重启进程 '{}' 失败: {}", id, e),
|
||||
);
|
||||
changes.push(ProcessInfo {
|
||||
id: id.clone(),
|
||||
name,
|
||||
status: ProcessStatus::Crashed,
|
||||
pid: None,
|
||||
restart_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changes
|
||||
@@ -464,7 +482,7 @@ impl ProcessManager {
|
||||
// ===== Tauri 命令 =====
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_process(
|
||||
pub fn process_start(
|
||||
state: tauri::State<'_, ProcessManager>,
|
||||
params: StartProcessParams,
|
||||
) -> Result<ProcessInfo, String> {
|
||||
@@ -472,7 +490,7 @@ pub fn start_process(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_process(
|
||||
pub fn process_stop(
|
||||
state: tauri::State<'_, ProcessManager>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
@@ -480,7 +498,7 @@ pub fn stop_process(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_process_status(
|
||||
pub fn process_status(
|
||||
state: tauri::State<'_, ProcessManager>,
|
||||
id: String,
|
||||
) -> Option<ProcessInfo> {
|
||||
@@ -488,14 +506,14 @@ pub fn get_process_status(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_all_process_status(
|
||||
pub fn process_all_status(
|
||||
state: tauri::State<'_, ProcessManager>,
|
||||
) -> Vec<ProcessInfo> {
|
||||
state.get_all_status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_all_processes(state: tauri::State<'_, ProcessManager>) {
|
||||
pub fn process_stop_all(state: tauri::State<'_, ProcessManager>) {
|
||||
state.stop_all()
|
||||
}
|
||||
|
||||
@@ -509,7 +527,7 @@ pub fn start_monitoring_thread(app: AppHandle) {
|
||||
let changes = state.check_and_cleanup();
|
||||
|
||||
for change in changes {
|
||||
let _ = app.emit("process-status-changed", &change);
|
||||
let _ = app.emit(crate::constants::events::PROCESS_STATUS_CHANGED, &change);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//! 快速面板:基于 7-Zip(binaries/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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Tauri 命令:快速面板模块
|
||||
|
||||
use tauri::{AppHandle, Emitter, 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 = popup::load_settings(&app);
|
||||
let prev_shortcut = prev.shortcut.clone();
|
||||
let dirs_changed = prev.index_dirs != settings.index_dirs;
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 索引目录变更:闲时自动重建(新增/移除路径后无需手动点"构建索引")
|
||||
if dirs_changed {
|
||||
schedule_auto_build(app, true);
|
||||
}
|
||||
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,LockWorkStation,CREATE_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(())
|
||||
}
|
||||
|
||||
/// 闲时自动建立索引的启动延迟(秒):避开应用启动/模块加载的 IO 高峰
|
||||
const AUTO_BUILD_START_DELAY: u64 = 6;
|
||||
/// 判定"系统空闲"的阈值(毫秒):用户停止输入超过该时长才执行构建
|
||||
const AUTO_BUILD_IDLE_MS: u64 = 3000;
|
||||
/// 等待系统空闲的最长轮询次数(每次间隔 2s,约 60s 上限,超时后不再等待直接构建)
|
||||
const AUTO_BUILD_MAX_WAIT_ITERS: u32 = 30;
|
||||
|
||||
/// 解析要索引的目录:设置为空时用默认(桌面/文档/下载)
|
||||
fn resolve_index_dirs(app: &AppHandle) -> Vec<String> {
|
||||
let settings = popup::load_settings(app);
|
||||
if settings.index_dirs.is_empty() {
|
||||
popup::QuickPanelSettings::default().index_dirs
|
||||
} else {
|
||||
settings.index_dirs
|
||||
}
|
||||
}
|
||||
|
||||
/// 闲时自动建立/重建文件索引。
|
||||
/// - `force=false`:仅首次(从未构建过)自动建立
|
||||
/// - `force=true`:忽略是否已构建,直接重建(索引目录变更后调用)
|
||||
///
|
||||
/// 流程:先延迟避开启动 IO 高峰,再轮询等待系统空闲(用户停止输入),
|
||||
/// 空闲后才开始构建,避免与应用运行/用户操作抢 IO 导致卡顿。
|
||||
/// 构建完成后向前端广播 `quickpanel-index-updated` 事件(负载为条目数),
|
||||
/// 供设置页刷新统计、弹窗启用文件搜索。
|
||||
fn schedule_auto_build(app: AppHandle, force: bool) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 1. 启动延迟:避开应用启动、模块加载等 IO 高峰
|
||||
tokio::time::sleep(std::time::Duration::from_secs(AUTO_BUILD_START_DELAY)).await;
|
||||
// 2. 轮询等待系统空闲(判定阈值见 AUTO_BUILD_IDLE_MS)
|
||||
for _ in 0..AUTO_BUILD_MAX_WAIT_ITERS {
|
||||
if crate::win32_util::get_idle_time_ms() >= AUTO_BUILD_IDLE_MS {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
// 3. 非强制模式:已构建过则跳过(幂等,避免每次启动重建)
|
||||
if !force {
|
||||
file_index::ensure_initialized(&app);
|
||||
let stats = file_index::stats();
|
||||
if stats.last_built_at > 0 && !stats.last_built_dirs.is_empty() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 4. 构建(build_index 内部有并发保护,重复调度/手动构建并发时幂等跳过)
|
||||
let dirs = resolve_index_dirs(&app);
|
||||
match tauri::async_runtime::spawn_blocking(move || file_index::build_index(&dirs)).await {
|
||||
Ok(Ok(count)) => {
|
||||
crate::logger::log_info(
|
||||
"quickpanel",
|
||||
&format!("闲时自动索引完成,共 {} 条", count),
|
||||
);
|
||||
let _ = app.emit(crate::constants::events::QUICKPANEL_INDEX_UPDATED, count);
|
||||
}
|
||||
_ => {
|
||||
crate::logger::log_error("quickpanel", "闲时自动索引失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 初始化文件索引数据库(应用启动时调用)。
|
||||
/// 若存在上次构建的索引(last_built_dirs 非空),自动恢复 notify 增量监听,
|
||||
/// 无需重建即可继续自动同步文件变更。
|
||||
/// 若从未构建过(首次运行),闲时自动建立索引,无需用户手动点"构建索引"。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn quickpanel_init_file_index(app: AppHandle) -> Result<(), String> {
|
||||
let app_for_build = app.clone();
|
||||
let need_auto_build = tauri::async_runtime::spawn_blocking(move || {
|
||||
file_index::init(&app_for_build);
|
||||
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);
|
||||
}
|
||||
// 从未构建过索引 → 需要闲时自动建立
|
||||
stats.last_built_at == 0 || stats.last_built_dirs.is_empty()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("索引初始化任务失败: {}", e))?;
|
||||
|
||||
if need_auto_build {
|
||||
schedule_auto_build(app, false);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn quickpanel_build_file_index(app: AppHandle) -> Result<i64, String> {
|
||||
let dirs = resolve_index_dirs(&app);
|
||||
// 懒加载:首次构建时自动初始化 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(¶ms)
|
||||
.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 快捷方式不能直接 spawn(os 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 等。
|
||||
/// - 控制台类交互程序(cmd/powershell/pwsh)额外设置 CREATE_NEW_CONSOLE,
|
||||
/// 否则从 GUI 宿主启动时无可见控制台窗口(表现为"点击没反应")。
|
||||
/// - .msc 控制台文件(如 devmgmt.msc)不可被 CreateProcess 直接执行,
|
||||
/// 改由 mmc 打开(路径解析到 System32,不受当前工作目录影响)。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn quickpanel_run_system_command(command: String, args: Vec<String>) -> Result<(), String> {
|
||||
let lower = command.to_lowercase();
|
||||
if lower.ends_with(".msc") {
|
||||
// 控制台文件:通过 mmc 打开(GUI 程序,无需新控制台)
|
||||
let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
|
||||
let path = format!("{}\\System32\\{}", system_root, command);
|
||||
let mut c = std::process::Command::new("mmc");
|
||||
c.arg(&path);
|
||||
return c
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("运行系统命令失败: {}", e));
|
||||
}
|
||||
let mut cmd = std::process::Command::new(&command);
|
||||
cmd.args(&args);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
let c = lower;
|
||||
if c == "cmd" || c == "powershell" || c == "pwsh" {
|
||||
cmd.creation_flags(crate::process_manager::CREATE_NEW_CONSOLE);
|
||||
}
|
||||
}
|
||||
cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! 文件索引: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::sync::atomic::{AtomicBool, Ordering};
|
||||
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
|
||||
}
|
||||
|
||||
/// 构建锁:防止手动构建与闲时自动构建并发执行(全量重建含 DELETE+INSERT,
|
||||
/// 两个构建交错会互相清空对方刚写入的数据,导致索引残缺)。
|
||||
static BUILDING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 尝试开始构建;已在构建中则返回 false。
|
||||
pub fn try_begin_build() -> bool {
|
||||
BUILDING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// 构建结束(成功/失败)后调用,释放构建锁。
|
||||
pub fn end_build() {
|
||||
BUILDING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 遍历指定目录列表建立索引(全量重建)。
|
||||
/// 返回索引条目数。在 spawn_blocking 中调用。
|
||||
/// 重建完成后自动启动 notify 监听器做增量更新。
|
||||
/// 若已有构建正在进行(手动/自动并发),直接返回 Ok(0),由进行中的构建负责更新索引。
|
||||
pub fn build_index(dirs: &[String]) -> Result<i64, String> {
|
||||
if !try_begin_build() {
|
||||
crate::logger::log_info("quickpanel", "索引构建已在进行中,跳过本次请求");
|
||||
return Ok(0);
|
||||
}
|
||||
let result = build_index_inner(dirs);
|
||||
end_build();
|
||||
result
|
||||
}
|
||||
|
||||
fn build_index_inner(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]) {
|
||||
// 创建新 watcher(notify 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));
|
||||
}
|
||||
}
|
||||
|
||||
// 替换旧 watcher(drop 时自动 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: ¬ify::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);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 忽略访问/其他事件
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 位图为 RGBA(top-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 设置 alpha:mask 白(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())
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! 快速面板模块:全局快捷键唤起的多源命令面板。
|
||||
//!
|
||||
//! Phase 1:窗口骨架(预创建隐藏窗口 + 快捷键 + 失焦隐藏)
|
||||
//! Phase 2:fuzzy + 拼音引擎,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};
|
||||
@@ -0,0 +1,355 @@
|
||||
//! 快速面板弹窗:全局快捷键唤起的多源命令面板。
|
||||
//!
|
||||
//! 流程(与剪贴板弹窗同构):
|
||||
//! 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 std::time::{Duration, Instant};
|
||||
|
||||
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);
|
||||
|
||||
/// 失焦隐藏的宽限期:show 后窗口激活期间焦点可能短暂弹跳(透明 + focus:false 的
|
||||
/// WebView2 窗口在透明激活时尤其容易出现),导致 Focused(false) 紧跟在 show 之后
|
||||
/// 触发并把刚显示的窗口立即隐藏。距上次 show 不足该时长的失焦事件直接忽略。
|
||||
const SHOW_GRACE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// 最近一次 show 的时间,用于失焦宽限期判断。
|
||||
static LAST_SHOWN: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
|
||||
/// 标记"已发起显示",并记录时间供失焦宽限期使用。
|
||||
fn mark_shown() {
|
||||
if let Ok(mut t) = LAST_SHOWN.lock() {
|
||||
*t = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断距上次 show 是否仍在宽限期内(是则忽略失焦自动隐藏)。
|
||||
fn within_show_grace() -> bool {
|
||||
LAST_SHOWN
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|t| *t)
|
||||
.map(|t| t.elapsed() < SHOW_GRACE)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 显示窗口并确保其到达前台。
|
||||
/// set_focus 受 Windows 前台锁定限制,可能静默失败;此时用 win32_util::force_foreground
|
||||
/// (模拟 Alt 释放重置前台锁定 + SetForegroundWindow + BringWindowToTop)兜底。
|
||||
fn show_and_focus(win: &tauri::WebviewWindow) {
|
||||
if let Err(e) = win.show() {
|
||||
crate::logger::log_error("quickpanel", &format!("show popup failed: {}", e));
|
||||
}
|
||||
mark_shown();
|
||||
let focused = win.set_focus();
|
||||
if focused.is_err() {
|
||||
// set_focus 被前台锁定拒绝时,退回到强制置前(与主窗口焦点命令同法)
|
||||
if let Ok(hwnd) = win.hwnd() {
|
||||
crate::win32_util::force_foreground(hwnd.0 as isize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义命令
|
||||
#[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;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听窗口失焦:自动隐藏
|
||||
// 距上次 show 不足宽限期(激活中焦点弹跳)的失焦事件忽略,避免弹窗刚显示就被隐藏
|
||||
let app_handle = app.clone();
|
||||
let win_handle = win.clone();
|
||||
win.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::Focused(false) = event {
|
||||
if within_show_grace() {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
crate::logger::log_info("quickpanel", "show_popup triggered");
|
||||
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,
|
||||
}));
|
||||
show_and_focus(&win);
|
||||
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 后调用)。
|
||||
///
|
||||
/// 两个路径:
|
||||
/// 1. 预创建路径(POPUP_PENDING_SHOW = false):show_popup 已调用 show_and_focus 显示窗口,
|
||||
/// 但若 Vue 尚未挂载,quickpanel-show 事件可能丢失。检查窗口是否可见,若可见则重新发送事件。
|
||||
/// 2. 兜底创建路径(POPUP_PENDING_SHOW = true):窗口尚未显示,位置为 PENDING_POS,
|
||||
/// 先定位再发事件最后显示+聚焦。
|
||||
pub fn show_window(app: &AppHandle) {
|
||||
if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) {
|
||||
// 预创建路径:窗口已由 show_popup 显示,但事件可能因前端未挂载而丢失。
|
||||
// 窗口可见时重新发送事件,让刚注册的监听器处理(清空输入、聚焦、刷新等)。
|
||||
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||
if win.is_visible().unwrap_or(false) {
|
||||
emit_show(app);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 兜底创建路径:show_popup 兜底重建,窗口尚未显示
|
||||
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);
|
||||
show_and_focus(&win);
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏弹窗(不销毁,保留复用)
|
||||
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" 目录更可靠),
|
||||
//! 文件走 ShellExecuteW(open + 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, ¶ms, "open").is_err() {
|
||||
return shell_execute_verb(path, ¶ms, "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, ¶ms, "open")
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
//! Windows 屏幕捕获核心(仅 Windows 编译)
|
||||
//!
|
||||
//! 实现:
|
||||
//! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素
|
||||
//! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口)
|
||||
//! - 窗口拾取:pick_windows 枚举 Z 序窗口列表(排除本进程,避免命中覆盖层自身),
|
||||
//! 前端缓存列表后本地命中测试
|
||||
//! - 顶层窗口枚举:EnumWindows
|
||||
//! - 像素 → PNG / CF_DIB 转换
|
||||
//!
|
||||
//! 设计说明:
|
||||
//! - 不使用 Windows Graphics Capture:避免引入 windows crate(WinRT)增大体积,
|
||||
//! 且 WGC 默认绘制黄色捕获边框,截图工具不可接受。
|
||||
//! - 一次性捕获使用 BitBlt/PrintWindow,延迟低、无用户提示。
|
||||
//! - 32bpp 捕获后强制 alpha=255:BitBlt 取出的 alpha 通道未定义(常为 0),
|
||||
//! 直接编码 PNG 会得到全透明图,故强制不透明。
|
||||
|
||||
use std::sync::Mutex;
|
||||
use windows_sys::Win32::Foundation::{BOOL, HWND, POINT, RECT};
|
||||
use windows_sys::Win32::Graphics::Gdi::{
|
||||
BitBlt, CombineRgn, CreateCompatibleBitmap, CreateCompatibleDC, CreateRectRgn, DeleteDC,
|
||||
DeleteObject, GetDC, GetDIBits, PatBlt, ReleaseDC, SelectObject, SetWindowRgn, BITMAPINFO,
|
||||
BITMAPINFOHEADER, BLACKNESS, DIB_RGB_COLORS, RGBQUAD, RGN_DIFF, 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, ScrollRegion, WindowInfo};
|
||||
|
||||
/// 捕获结果(PNG 字节 + 原始 BGRA 像素,像素用于剪贴板 DIB 构造,避免重复解码)
|
||||
pub struct CapturedImage {
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub png: Vec<u8>,
|
||||
/// 32bpp BGRA,top-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 开销,
|
||||
/// Chromium(WebView2)原生支持 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 压缩 + 无过滤:历史缩略图/自动保存不需要最优压缩比,
|
||||
/// 大幅降低"点击完成 → 关闭窗口"的编码延迟。
|
||||
pub(crate) 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 像素
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
/// 捕获整个虚拟屏(所有显示器拼接为一张图)
|
||||
///
|
||||
/// 优先走 BitBlt(SDR 全屏一次捕获、低延迟);检测到任一显示器为 HDR 时改用 WGC
|
||||
/// (RGBA16F + HDR→sRGB 色调映射,避免 BitBlt 把 scRGB 线性像素当 sRGB 直出导致过曝)。
|
||||
/// WGC 失败时回退到 BitBlt。
|
||||
pub fn capture_virtual_screen() -> Result<CapturedImage, String> {
|
||||
if super::wgc_capture::is_hdr_enabled() {
|
||||
if let Ok(img) = super::wgc_capture::capture_virtual_screen_wgc() {
|
||||
return Ok(img);
|
||||
}
|
||||
}
|
||||
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> {
|
||||
let img = capture_window_bgra(hwnd)?;
|
||||
let png = bgra_to_png(&img.bgra, img.width, img.height)?;
|
||||
Ok(CapturedImage {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
png,
|
||||
bgra: img.bgra,
|
||||
})
|
||||
}
|
||||
|
||||
/// 捕获指定窗口的原始 BGRA(不做 PNG 编码)。
|
||||
/// 滚动截图每帧只需像素数据做匹配拼接,跳过编码可显著降低单帧耗时。
|
||||
pub fn capture_window_bgra(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).map(|bgra| CapturedImage {
|
||||
width: w,
|
||||
height: h,
|
||||
png: Vec::new(),
|
||||
bgra,
|
||||
})
|
||||
};
|
||||
|
||||
SelectObject(hdc_mem, old);
|
||||
DeleteObject(hbm);
|
||||
DeleteDC(hdc_mem);
|
||||
ReleaseDC(0, hdc_screen);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// 枚举可拾取的顶层窗口(Z 序顶→底)
|
||||
///
|
||||
/// 与逐点拾取同语义:排除本进程窗口(覆盖层/主窗口/编辑器)、不可见窗口、工具窗口。
|
||||
/// 前端在截图开始时缓存该列表,鼠标移动时在 JS 侧做命中测试(rect 包含点,取 Z 序
|
||||
/// 最顶的第一个命中),消除逐帧 window_from_point 的 IPC 往返;且列表与冻结底图
|
||||
/// 同一时刻生成,命中结果与画面严格一致。
|
||||
pub fn pick_windows() -> Vec<WindowInfo> {
|
||||
struct PickContext {
|
||||
my_pid: u32,
|
||||
out: Vec<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;
|
||||
}
|
||||
ctx.out.push(WindowInfo {
|
||||
hwnd,
|
||||
title: get_window_title(hwnd),
|
||||
rect: ScreenRect::from(rect),
|
||||
visual_rect: extended_frame_bounds(hwnd),
|
||||
});
|
||||
}
|
||||
1
|
||||
}
|
||||
|
||||
let mut ctx = PickContext {
|
||||
my_pid: std::process::id(),
|
||||
out: Vec::new(),
|
||||
};
|
||||
unsafe {
|
||||
EnumWindows(Some(enum_proc), &mut ctx as *mut _ as isize);
|
||||
}
|
||||
ctx.out
|
||||
}
|
||||
|
||||
/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// 滚动截图模式:在覆盖层窗口上挖出选区带的"真孔"(内缩 INSET 保留蓝框),或清除恢复整窗。
|
||||
///
|
||||
/// 背景:Chromium 系浏览器(Edge/Chrome)的原生窗口遮挡检测(occlusion tracking)
|
||||
/// 会把被**完全覆盖**的窗口标记为 occluded 并暂停渲染合成——滚动截图时覆盖层
|
||||
/// 铺满全屏盖住目标窗口,网页"看起来完全不滚动",PrintWindow 抓到的也是静止帧。
|
||||
/// 覆盖层并非 layered 窗口,即使其内容视觉透明,窗口矩形对遮挡检测仍算不透明覆盖。
|
||||
/// 挖孔后目标窗口仅部分被覆盖(Chromium 要求完全覆盖才判 occluded,实测 60x60
|
||||
/// 的小孔即可解除),恢复渲染与滚轮响应,拼接匹配随之正常。
|
||||
///
|
||||
/// - `region = Some`:孔 = 选区带内缩 INSET(保留 2px 蓝框 + 1px 白描边);
|
||||
/// 选区过小(< 2*INSET+8)时不挖孔,避免退化区域
|
||||
/// - `region = None`:SetWindowRgn(NULL) 清除窗口区域(恢复整窗)
|
||||
///
|
||||
/// 坐标:region 为屏幕物理像素(与滚动会话同源),按覆盖层窗口原点换算成窗口坐标。
|
||||
/// 区域设置在窗口上持续有效,会话结束/新一轮截图开始时必须传 None 复位。
|
||||
pub fn set_scroll_hole(hwnd: isize, region: Option<ScrollRegion>) -> Result<(), String> {
|
||||
unsafe {
|
||||
match region {
|
||||
Some(r) => {
|
||||
let mut wr: RECT = std::mem::zeroed();
|
||||
if GetWindowRect(hwnd, &mut wr) == 0 {
|
||||
return Err("GetWindowRect 失败".into());
|
||||
}
|
||||
const INSET: i32 = 4;
|
||||
let hx1 = r.x - wr.left + INSET;
|
||||
let hy1 = r.y - wr.top + INSET;
|
||||
let hx2 = r.x + r.width - wr.left - INSET;
|
||||
let hy2 = r.y + r.height - wr.top - INSET;
|
||||
if hx2 - hx1 < 8 || hy2 - hy1 < 8 {
|
||||
// 选区太小:不挖孔(保持整窗)
|
||||
if SetWindowRgn(hwnd, 0, 1) == 0 {
|
||||
return Err("SetWindowRgn 失败".into());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let full = CreateRectRgn(0, 0, wr.right - wr.left, wr.bottom - wr.top);
|
||||
let hole = CreateRectRgn(hx1, hy1, hx2, hy2);
|
||||
if full == 0 || hole == 0 {
|
||||
if full != 0 {
|
||||
DeleteObject(full);
|
||||
}
|
||||
if hole != 0 {
|
||||
DeleteObject(hole);
|
||||
}
|
||||
return Err("CreateRectRgn 失败".into());
|
||||
}
|
||||
CombineRgn(full, full, hole, RGN_DIFF);
|
||||
// 组合结果 full 归 SetWindowRgn 所有;hole 用完即删
|
||||
DeleteObject(hole);
|
||||
if SetWindowRgn(hwnd, full, 1) == 0 {
|
||||
DeleteObject(full);
|
||||
return Err("SetWindowRgn 失败".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
if SetWindowRgn(hwnd, 0, 1) == 0 {
|
||||
return Err("SetWindowRgn 失败".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 枚举所有可见、有标题的顶层窗口(供窗口列表选择)
|
||||
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_DIB(bottom-up,alpha 强制 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
|
||||
}
|
||||
|
||||
pub(crate) 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-up,alpha 强制 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)?;
|
||||
rgba_raw_to_png_checked(rgba, width, height)
|
||||
}
|
||||
|
||||
/// 有标注导出:raw RGBA → PNG base64(仅编码,不写剪贴板;编辑器「保存到文件」用)
|
||||
pub fn compose_png_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));
|
||||
}
|
||||
rgba_raw_to_png_checked(rgba, width, height)
|
||||
}
|
||||
|
||||
/// raw RGBA → PNG base64(带长度校验的封装,供 compose_copy/compose_png 共用)
|
||||
fn rgba_raw_to_png_checked(rgba: &[u8], width: i32, height: i32) -> Result<CaptureData, String> {
|
||||
let png = rgba_raw_to_png(rgba, width, height)?;
|
||||
Ok(CaptureData {
|
||||
png_base64: base64_encode(&png),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
/// RGBA → CF_DIB(BITMAPINFOHEADER + BGRA bottom-up,alpha 强制 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()
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
//! Tauri 命令:截图模块
|
||||
//!
|
||||
//! 命令清单:
|
||||
//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态(不做 PNG 编码),返回捕获时刻光标坐标
|
||||
//! - screenshot_get_fullscreen_bmp:取出全屏捕获的 BMP 原始字节(raw IPC,覆盖层显示用,不移除)
|
||||
//! - screenshot_fullscreen_png:全屏捕获编码 PNG base64 并清除(全屏截图进编辑器用)
|
||||
//! - screenshot_clear_fullscreen:清除静态全屏捕获(覆盖层关闭时)
|
||||
//! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获
|
||||
//! - screenshot_pick_list:枚举可拾取顶层窗口(Z 序,前端缓存后本地命中测试)
|
||||
//! - screenshot_show_overlay:一次 IPC 完成覆盖层 show + focus(关键路径减少往返)
|
||||
//! - screenshot_enum_windows:枚举可见顶层窗口
|
||||
//! - screenshot_capture_window:按 hwnd 捕获指定窗口
|
||||
//! - screenshot_scroll_capture / screenshot_scroll_start / screenshot_scroll_finish /
|
||||
//! screenshot_scroll_cancel:滚动截图(同步一次调用 / 会话式:启动、完成、取消)
|
||||
//! - screenshot_set_scroll_hole:滚动模式遮罩挖孔(防 Chromium 遮挡检测冻结目标窗口)
|
||||
//! - screenshot_take_editor_image_raw:取出编辑器图片(raw IPC,滚动截图会话直接写入)
|
||||
//! - screenshot_compose_png / screenshot_compose_copy:raw RGBA → PNG(仅编码 / 剪贴板+编码)
|
||||
//! - screenshot_copy_image:写入剪贴板(CF_DIB)
|
||||
//! - screenshot_save_png:写入文件
|
||||
//! - screenshot_disable_transitions:禁用窗口显示/隐藏过渡动画(消除覆盖层缩放动画)
|
||||
|
||||
use super::{CaptureData, CaptureStart, WindowInfo};
|
||||
use tauri::{AppHandle, 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 编码。
|
||||
/// 同时返回捕获时刻的光标物理坐标(覆盖层据此做初始窗口拾取,省一次 IPC 往返)。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn screenshot_capture_fullscreen() -> Result<CaptureStart, String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
let img = super::capture::capture_virtual_screen()?;
|
||||
super::capture::store_fullscreen(img)?;
|
||||
let (cursor_x, cursor_y) = super::capture::cursor_pos().unwrap_or((0, 0));
|
||||
Ok(CaptureStart { cursor_x, cursor_y })
|
||||
})
|
||||
.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())
|
||||
}
|
||||
}
|
||||
|
||||
/// 枚举可拾取的顶层窗口(Z 序顶→底,排除本进程/不可见/工具窗口)。
|
||||
/// 前端在截图开始时缓存列表,鼠标移动时在 JS 侧本地命中测试,消除逐帧 IPC 往返。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn screenshot_pick_list() -> Result<Vec<WindowInfo>, String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
tauri::async_runtime::spawn_blocking(super::capture::pick_windows)
|
||||
.await
|
||||
.map_err(|e| format!("枚举失败: {}", e))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn screenshot_show_overlay(
|
||||
app: tauri::AppHandle,
|
||||
label: String,
|
||||
) -> Result<(), String> {
|
||||
let win = app
|
||||
.get_webview_window(&label)
|
||||
.ok_or_else(|| format!("窗口不存在: {}", label))?;
|
||||
win.show().map_err(|e| format!("显示窗口失败: {}", e))?;
|
||||
win.set_focus().map_err(|e| format!("聚焦窗口失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取当前鼠标物理屏幕坐标(贴图窗口拖动跟随等场景使用)
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
|
||||
/// 滚动截图:从窗口当前滚动位置向下拼接到底部,返回超长 PNG。
|
||||
/// `region` 为 Some 时仅在框选区域(屏幕物理坐标)内捕捉,宽 = 选区宽;
|
||||
/// 为 None 时捕捉整个客户区。结束后会把窗口滚回起始位置,不打扰用户。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn screenshot_scroll_capture(
|
||||
hwnd: isize,
|
||||
region: Option<super::ScrollRegion>,
|
||||
) -> Result<CaptureData, String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
super::scroll_capture::scroll_capture(hwnd, region)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("滚动截图任务失败: {}", e))?
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
let _ = region;
|
||||
Err("截图仅支持 Windows".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动滚动截图会话(后台线程持续捕捉拼接,实时推进度事件)。
|
||||
/// `auto = true` 为自动滚动(线程主动下滚拼到底部);`false` 为手动(等用户滚动窗口)。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn screenshot_scroll_start(
|
||||
app: AppHandle,
|
||||
hwnd: isize,
|
||||
region: Option<super::ScrollRegion>,
|
||||
auto: bool,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
super::scroll_session::start(app, hwnd, region, auto)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = (app, hwnd, region, auto);
|
||||
Err("截图仅支持 Windows".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// 结束滚动截图会话并导出结果。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn screenshot_scroll_finish() -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
super::scroll_session::finish()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Err("截图仅支持 Windows".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消滚动截图会话(不导出)。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn screenshot_scroll_cancel() -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
super::scroll_session::cancel_now()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Err("截图仅支持 Windows".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// 滚动模式遮罩挖孔:在截图覆盖层窗口上挖出选区带的真孔(region = None 时复位整窗)。
|
||||
///
|
||||
/// Chromium 系浏览器(Edge/Chrome)的窗口遮挡检测会把被完全覆盖的窗口标记为
|
||||
/// occluded 并暂停渲染——滚动截图时覆盖层铺满全屏,网页"看起来完全不滚动"。
|
||||
/// 挖孔后目标窗口仅部分被覆盖,恢复渲染与滚轮响应(详见 capture::set_scroll_hole)。
|
||||
/// 进入滚动模式时带选区调用,会话结束/新一轮截图开始时必须传 None 复位。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn screenshot_set_scroll_hole(
|
||||
app: AppHandle,
|
||||
region: Option<super::ScrollRegion>,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use raw_window_handle::HasWindowHandle;
|
||||
|
||||
let mut hwnds: Vec<isize> = Vec::new();
|
||||
for (label, win) in app.webview_windows() {
|
||||
if label.starts_with(crate::constants::windows::SCREENSHOT_OVERLAY) {
|
||||
let hwnd = win
|
||||
.window_handle()
|
||||
.ok()
|
||||
.and_then(|h| match h.as_raw() {
|
||||
raw_window_handle::RawWindowHandle::Win32(w) => {
|
||||
Some(w.hwnd.get() as isize)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(h) = hwnd {
|
||||
hwnds.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
if hwnds.is_empty() {
|
||||
return Err("截图覆盖层窗口不存在".into());
|
||||
}
|
||||
for h in hwnds {
|
||||
super::capture::set_scroll_hole(h, region)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = (app, region);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 取出编辑器图片(原始 PNG 字节,raw IPC → 前端 ArrayBuffer → Blob URL,取出即清除)
|
||||
///
|
||||
/// 长图(滚动截图)可达数十 MB:raw IPC 相比 base64 JSON 事件传输省 ~33% 体积,
|
||||
/// 且避免 JSON 序列化/多次广播。注:返回 ipc::Response,specta 无法生成,豁免标注。
|
||||
#[tauri::command]
|
||||
pub async fn screenshot_take_editor_image_raw() -> Result<tauri::ipc::Response, String> {
|
||||
match super::take_editor_image_raw() {
|
||||
Some(bytes) => Ok(tauri::ipc::Response::new(bytes)),
|
||||
None => Err("无待编辑的截图".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 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 body(ArrayBuffer)".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())
|
||||
}
|
||||
}
|
||||
|
||||
/// 有标注导出(仅编码):接收 raw RGBA(前端 canvas.getImageData 直传),一次完成 PNG 编码。
|
||||
/// 与 screenshot_compose_copy 的区别:不写剪贴板(编辑器「保存到文件」用)。
|
||||
/// body 格式:前 8 字节 = width(i32 LE) + height(i32 LE),之后为 raw RGBA 像素。
|
||||
/// 注:参数为 tauri::ipc::Request(原始 body),specta 无法生成,豁免标注。
|
||||
#[tauri::command]
|
||||
pub async fn screenshot_compose_png(
|
||||
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 body(ArrayBuffer)".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_png_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))?
|
||||
}
|
||||
|
||||
/// 读取历史缓存 PNG 原始字节(raw IPC → 前端 ArrayBuffer,贴图窗口显示用:
|
||||
/// 跳过 base64 编码,IPC 传输与前端内存占用均省 ~33%;同 get_fullscreen_bmp 豁免 specta)
|
||||
#[tauri::command]
|
||||
pub async fn screenshot_load_cache_raw(
|
||||
app: tauri::AppHandle,
|
||||
path: String,
|
||||
) -> Result<tauri::ipc::Response, 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))?;
|
||||
Ok(tauri::ipc::Response::new(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))?
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! 截图模块
|
||||
//!
|
||||
//! 跨平台数据结构定义 + Windows 捕获实现(capture.rs)。
|
||||
//! 捕获引擎选型见 capture.rs 顶部说明:BitBlt + PrintWindow,不使用 WGC。
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use specta::Type;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod capture;
|
||||
#[cfg(windows)]
|
||||
pub mod wgc_capture;
|
||||
#[cfg(windows)]
|
||||
pub mod scroll_capture;
|
||||
#[cfg(windows)]
|
||||
pub mod scroll_session;
|
||||
pub mod commands;
|
||||
|
||||
/// 前端可见的捕获数据
|
||||
#[derive(serde::Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptureData {
|
||||
pub png_base64: String,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
/// 截图启动信息:捕获时刻的光标物理坐标(覆盖层据此做初始窗口拾取,省一次 IPC 往返)
|
||||
#[derive(serde::Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptureStart {
|
||||
pub cursor_x: i32,
|
||||
pub cursor_y: 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,
|
||||
}
|
||||
|
||||
/// 滚动截图区域(屏幕物理像素坐标,通常为覆盖层框选区平移到屏幕)
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScrollRegion {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
/// 编辑器图片静态存储:原始 PNG 字节(滚动截图会话完成后直接写入,
|
||||
/// 编辑器窗口通过 raw IPC 取出 → Blob URL 显示,全程不经 base64/JSON 事件传输)
|
||||
static EDITOR_IMAGE_RAW: Mutex<Option<Vec<u8>>> = Mutex::new(None);
|
||||
|
||||
/// 存储编辑器图片(原始 PNG 字节)
|
||||
pub fn set_editor_image_raw(png: Vec<u8>) {
|
||||
if let Ok(mut g) = EDITOR_IMAGE_RAW.lock() {
|
||||
*g = Some(png);
|
||||
}
|
||||
}
|
||||
|
||||
/// 取出并清除编辑器图片(原始 PNG 字节)
|
||||
pub fn take_editor_image_raw() -> Option<Vec<u8>> {
|
||||
EDITOR_IMAGE_RAW.lock().ok()?.take()
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
//! 滚动截图(垂直长图拼接)底层设施(仅 Windows)
|
||||
//!
|
||||
//! ## 原理
|
||||
//! 目标窗口的**客户区**内容往往是可分页垂直滚动的。算法:
|
||||
//! 1. 用 PrintWindow(PW_RENDERFULLCONTENT)抓客户区为 BGRA(不做 PNG 编码);
|
||||
//! 2. 向窗口发送 `WM_MOUSEWHEEL` 使其向下滚动;
|
||||
//! 3. 再抓一帧,用**框选带内多列采样行信号**做一维模板匹配,求出两帧间的垂直
|
||||
//! 滚动像素偏移 `d`:`cur[y] ≈ prev[y+d]`,故把 `cur` 底部新出现的 `d` 行拼到画布末尾;
|
||||
//! 4. 重复直到内容不再变化(滚到底部),把各帧拼成一张超高纵轴图像。
|
||||
//!
|
||||
//! 固定表头/粘性头部由拼接语义天然处理——只追加 `cur` 底部的 `d` 行,
|
||||
//! 表头保留在第一帧中,不会重复。
|
||||
//!
|
||||
//! ## 滚轮投递
|
||||
//! `WM_MOUSEWHEEL` 优先发给**框选带中心处的最深子窗口**(如 Chromium 的
|
||||
//! RenderWidgetHostHWND):很多程序的顶层窗口过程不转发滚轮消息,
|
||||
//! 直接发顶层会导致"完全不滚动"。坐标一律用屏幕物理坐标(lParam 语义)。
|
||||
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
use windows_sys::Win32::Foundation::{HWND, POINT, RECT};
|
||||
use windows_sys::Win32::Graphics::Gdi::{ClientToScreen, ScreenToClient};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
ChildWindowFromPointEx, GetClientRect, GetWindowRect, PostMessageW, SendMessageTimeoutW,
|
||||
WM_MOUSEWHEEL, WHEEL_DELTA, CWP_SKIPDISABLED, CWP_SKIPINVISIBLE, CWP_SKIPTRANSPARENT,
|
||||
SMTO_ABORTIFHUNG,
|
||||
};
|
||||
|
||||
use super::{CaptureData, ScrollRegion};
|
||||
use super::capture::{base64_encode, bgra_to_png, capture_window_bgra, crop_bgra};
|
||||
|
||||
/// 窗口客户区上下文:客户区尺寸 + 客户区在窗口位图内的偏移。
|
||||
pub(crate) struct BandCtx {
|
||||
pub cw: i32,
|
||||
pub ch: i32,
|
||||
pub ox: i32,
|
||||
pub oy: i32,
|
||||
}
|
||||
|
||||
/// 解析框选带:把可选区域(屏幕物理坐标)换算成客户区内的裁剪带。
|
||||
/// 区域未命中客户区时返回错误;`None` 表示整客户区。
|
||||
/// 返回 (ctx, band_x, band_y, band_w, band_h),band_* 为客户区内坐标。
|
||||
pub(crate) fn resolve_band(
|
||||
hwnd: isize,
|
||||
region: Option<ScrollRegion>,
|
||||
) -> Result<(BandCtx, i32, i32, i32, i32), String> {
|
||||
let (cw, ch, ox, oy) = client_info(hwnd)
|
||||
.ok_or_else(|| "无法获取窗口客户区(窗口可能被最小化或已销毁)".to_string())?;
|
||||
if cw <= 0 || ch <= 0 {
|
||||
return Err("窗口客户区尺寸无效".into());
|
||||
}
|
||||
let (wr_x, wr_y) = window_rect_origin(hwnd).ok_or_else(|| "无法获取窗口矩形".to_string())?;
|
||||
let band = match region {
|
||||
Some(r) => {
|
||||
let wbx = r.x - wr_x;
|
||||
let wby = r.y - wr_y;
|
||||
let bx0 = wbx.max(ox);
|
||||
let by0 = wby.max(oy);
|
||||
let bx1 = (wbx + r.width).min(ox + cw);
|
||||
let by1 = (wby + r.height).min(oy + ch);
|
||||
if bx1 <= bx0 || by1 <= by0 {
|
||||
return Err("选区未命中窗口客户区".into());
|
||||
}
|
||||
(bx0 - ox, by0 - oy, bx1 - bx0, by1 - by0)
|
||||
}
|
||||
None => (0, 0, cw, ch),
|
||||
};
|
||||
Ok((
|
||||
BandCtx { cw, ch, ox, oy },
|
||||
band.0,
|
||||
band.1,
|
||||
band.2,
|
||||
band.3,
|
||||
))
|
||||
}
|
||||
|
||||
/// 客户区内坐标 → 屏幕物理坐标。
|
||||
pub(crate) fn client_pt_to_screen(hwnd: isize, cx: i32, cy: i32) -> Option<(i32, i32)> {
|
||||
unsafe {
|
||||
let mut pt = POINT { x: cx, y: cy };
|
||||
if ClientToScreen(hwnd as HWND, &mut pt) == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((pt.x, pt.y))
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归下钻:找到客户区坐标 (cx, cy) 处最深的子窗口(跳过不可见/禁用/透明子窗口)。
|
||||
/// WM_MOUSEWHEEL 优先发给真正处理滚轮的子窗口——顶层窗口过程往往不转发滚轮,
|
||||
/// 这是"自动滚动一开始就不动"的主因之一。
|
||||
pub(crate) fn deep_child_at_point(top: isize, mut cx: i32, mut cy: i32) -> isize {
|
||||
let mut cur = top;
|
||||
for _ in 0..16 {
|
||||
let child = unsafe {
|
||||
ChildWindowFromPointEx(
|
||||
cur as HWND,
|
||||
POINT { x: cx, y: cy },
|
||||
CWP_SKIPINVISIBLE | CWP_SKIPDISABLED | CWP_SKIPTRANSPARENT,
|
||||
)
|
||||
};
|
||||
if child == 0 || child == cur as HWND {
|
||||
break;
|
||||
}
|
||||
// 坐标换算到子窗口客户区
|
||||
let mut pt = POINT { x: cx, y: cy };
|
||||
unsafe {
|
||||
if ClientToScreen(cur as HWND, &mut pt) == 0 {
|
||||
break;
|
||||
}
|
||||
if ScreenToClient(child, &mut pt) == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
cx = pt.x;
|
||||
cy = pt.y;
|
||||
cur = child as isize;
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
/// 构造 WM_MOUSEWHEEL 的 wParam/lParam(lParam 为屏幕坐标)。
|
||||
fn wheel_params(sx: i32, sy: i32, delta: i32) -> (usize, isize) {
|
||||
// wParam 高位字 = 有符号 delta,低位字 = 按键 0
|
||||
let wparam = ((delta as u16) as usize) << 16;
|
||||
// lParam 低 16 位 = x(屏幕),高 16 位 = y(屏幕)
|
||||
let lparam = ((((sy as u32) & 0xFFFF) << 16) | ((sx as u32) & 0xFFFF)) as isize;
|
||||
(wparam, lparam)
|
||||
}
|
||||
|
||||
/// 发送滚轮消息(SendMessageTimeout:目标线程短暂忙/挂起时不至于卡死调用线程)。
|
||||
/// delta > 0 向上滚,delta < 0 向下滚。
|
||||
pub(crate) fn send_wheel(hwnd: isize, sx: i32, sy: i32, delta: i32) {
|
||||
let (wparam, lparam) = wheel_params(sx, sy, delta);
|
||||
let mut result = 0usize;
|
||||
unsafe {
|
||||
let _ = SendMessageTimeoutW(
|
||||
hwnd as HWND,
|
||||
WM_MOUSEWHEEL,
|
||||
wparam,
|
||||
lparam,
|
||||
SMTO_ABORTIFHUNG,
|
||||
80,
|
||||
&mut result,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 异步投递滚轮消息(部分程序只处理经消息泵排队的事件)。
|
||||
pub(crate) fn post_wheel(hwnd: isize, sx: i32, sy: i32, delta: i32) {
|
||||
let (wparam, lparam) = wheel_params(sx, sy, delta);
|
||||
unsafe {
|
||||
let _ = PostMessageW(hwnd as HWND, WM_MOUSEWHEEL, wparam, lparam);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 单次同步滚动截图(一次调用拼到底)=====
|
||||
|
||||
/// 每次向下滚动的「格数」(WHEEL_DELTA=120/格)。
|
||||
const WHEEL_STROKE: i32 = 2;
|
||||
/// 滚动后等待窗口重绘的时间。
|
||||
const SETTLE_MS: u64 = 70;
|
||||
/// 保护上限:最大迭代轮数。
|
||||
const MAX_ITERS: usize = 140;
|
||||
/// 保护上限:最大拼接段数。
|
||||
const MAX_STITCHES: usize = 90;
|
||||
/// 保护上限:拼接后总高(像素)。
|
||||
const MAX_TOTAL_H: u32 = 30000;
|
||||
/// 连续多少次滚动无位移判定为「已到底部」。
|
||||
const NO_CHANGE_STOP: u32 = 2;
|
||||
|
||||
/// 执行滚动截图:把窗口当前滚动位置向下拼接到底部,返回超长 PNG(同步,一次调用)。
|
||||
///
|
||||
/// - `region` 为 `Some` 时,只在**框选区域**内捕捉(列带 + 该区域的纵向视口),
|
||||
/// 输出宽度 = 选区宽度;为 `None` 时捕捉整个客户区。
|
||||
/// 偏移检测始终优先使用带内信号,保证滚动量在窄带下也能稳健匹配。
|
||||
/// - 结束时把窗口滚回起始位置,不打扰用户。
|
||||
pub fn scroll_capture(hwnd: isize, region: Option<ScrollRegion>) -> Result<CaptureData, String> {
|
||||
let (ctx, band_x, band_y, band_w, band_h) = resolve_band(hwnd, region)?;
|
||||
let (cw, ch) = (ctx.cw, ctx.ch);
|
||||
let (client_ox, client_oy) = (ctx.ox, ctx.oy);
|
||||
|
||||
// 滚轮目标:带中心的最深子窗口 + 屏幕坐标
|
||||
let bcx = band_x + band_w / 2;
|
||||
let bcy = band_y + band_h / 2;
|
||||
let wheel_hwnd = deep_child_at_point(hwnd, bcx, bcy);
|
||||
let (sx, sy) = client_pt_to_screen(hwnd, bcx, bcy)
|
||||
.ok_or_else(|| "无法换算屏幕坐标".to_string())?;
|
||||
|
||||
// 第一帧(全客户区)→ 画布(裁剪到框选带)
|
||||
let first = capture_client_bgra(hwnd, client_ox, client_oy, cw, ch)?;
|
||||
let mut canvas = crop_bgra(&first, cw as usize, band_x, band_y, band_w, band_h)?;
|
||||
let mut prev = first;
|
||||
let mut total_h = band_h as u32;
|
||||
let mut total_stitches = 0usize;
|
||||
let mut no_change = 0u32;
|
||||
let mut moved: u32 = 0;
|
||||
let mut iters = 0usize;
|
||||
|
||||
while iters < MAX_ITERS && total_stitches < MAX_STITCHES && total_h < MAX_TOTAL_H {
|
||||
iters += 1;
|
||||
|
||||
send_wheel(wheel_hwnd, sx, sy, -(WHEEL_DELTA as i32) * WHEEL_STROKE);
|
||||
sleep(Duration::from_millis(SETTLE_MS));
|
||||
|
||||
let cur = match capture_client_bgra(hwnd, client_ox, client_oy, cw, ch) {
|
||||
Ok(f) => f,
|
||||
Err(_) => break, // 窗口中途被关闭/失去客户区
|
||||
};
|
||||
|
||||
match detect_vscroll_offset(
|
||||
&prev,
|
||||
&cur,
|
||||
cw as usize,
|
||||
ch as usize,
|
||||
band_x as usize,
|
||||
band_y as usize,
|
||||
band_w as usize,
|
||||
band_h as usize,
|
||||
) {
|
||||
Some(0) => {
|
||||
no_change += 1;
|
||||
if no_change >= NO_CHANGE_STOP {
|
||||
break; // 已到底部
|
||||
}
|
||||
prev = cur;
|
||||
}
|
||||
Some(d) => {
|
||||
no_change = 0;
|
||||
total_stitches += 1;
|
||||
moved += 1;
|
||||
// 追加框选带底部新出现的 d 行(在客户区帧内的带区间 [band_y, band_y+band_h))
|
||||
let new_rows = (d as i32).min(band_h) as usize;
|
||||
let new_y = band_y + band_h - new_rows as i32;
|
||||
let rows = crop_bgra(&cur, cw as usize, band_x, new_y, band_w, new_rows as i32)?;
|
||||
canvas.extend_from_slice(&rows);
|
||||
total_h += new_rows as u32;
|
||||
prev = cur;
|
||||
}
|
||||
None => {
|
||||
// 匹配失败(内容大幅变化/动画等):不拼接、不累计 no_change,下一轮继续
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 结束回滚:上滚与下滚相同数量的手势
|
||||
for _ in 0..moved {
|
||||
send_wheel(wheel_hwnd, sx, sy, WHEEL_DELTA as i32 * WHEEL_STROKE);
|
||||
sleep(Duration::from_millis(20));
|
||||
}
|
||||
|
||||
if total_h <= band_h as u32 {
|
||||
return Err("区域内容未能滚动(可能不支持鼠标滚轮或已到底部)".into());
|
||||
}
|
||||
|
||||
let png = bgra_to_png(&canvas, band_w as i32, total_h as i32)?;
|
||||
Ok(CaptureData {
|
||||
png_base64: base64_encode(&png),
|
||||
width: band_w as i32,
|
||||
height: total_h as i32,
|
||||
})
|
||||
}
|
||||
|
||||
/// 窗口矩形左上角的屏幕坐标(用于把屏幕区域换算成窗口位图内的裁剪坐标)。
|
||||
fn window_rect_origin(hwnd: isize) -> Option<(i32, i32)> {
|
||||
unsafe {
|
||||
let mut wr: RECT = std::mem::zeroed();
|
||||
if GetWindowRect(hwnd as HWND, &mut wr) == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((wr.left, wr.top))
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取窗口客户区尺寸与客户区左上角屏幕坐标。
|
||||
fn client_info(hwnd: isize) -> Option<(i32, i32, i32, i32)> {
|
||||
unsafe {
|
||||
let h = hwnd as HWND;
|
||||
let mut cr: RECT = std::mem::zeroed();
|
||||
if GetClientRect(h, &mut cr) == 0 {
|
||||
return None;
|
||||
}
|
||||
let (cw, ch) = (cr.right - cr.left, cr.bottom - cr.top);
|
||||
let mut pt: POINT = std::mem::zeroed();
|
||||
if ClientToScreen(h, &mut pt) == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut wr: RECT = std::mem::zeroed();
|
||||
if GetWindowRect(h, &mut wr) == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((cw, ch, pt.x - wr.left, pt.y - wr.top))
|
||||
}
|
||||
}
|
||||
|
||||
/// 抓取窗口客户区内容(PrintWindow + 裁剪,不做 PNG 编码)。
|
||||
pub(crate) fn capture_client_bgra(
|
||||
hwnd: isize,
|
||||
client_ox: i32,
|
||||
client_oy: i32,
|
||||
cw: i32,
|
||||
ch: i32,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let img = capture_window_bgra(hwnd)?;
|
||||
// 客户区在窗口位图内的偏移(PrintWindow 从窗口左上角绘制)
|
||||
crop_bgra(
|
||||
&img.bgra,
|
||||
img.width as usize,
|
||||
client_ox,
|
||||
client_oy,
|
||||
cw.min(img.width),
|
||||
ch.min(img.height),
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 帧间垂直偏移检测 =====
|
||||
|
||||
/// 行信号采样列数:每行采 16 列亮度(B+G+R),保留横向细节(文字边缘、分隔线)。
|
||||
/// 相比旧的"整行平均",多列采样大幅降低重复纹理/大面积纯色区的误匹配率。
|
||||
const SIG_COLS: usize = 16;
|
||||
/// 单周期可检测的最大偏移(像素)。超过即匹配失败(调用方自适应降速)。
|
||||
const MAX_OFFSET: usize = 512;
|
||||
|
||||
/// 检测两帧之间的垂直滚动像素偏移 `d`,使 `cur[y] ≈ prev[y+d]`。
|
||||
///
|
||||
/// 匹配范围优先限定在**框选带**(行=带内行、列=带内列):带外内容(工具栏、
|
||||
/// 状态栏、不随滚动的区域)不参与匹配,避免污染信号。带太小/带内匹配失败时
|
||||
/// 逐级回退(全行+带列 → 全行+全列)。
|
||||
///
|
||||
/// 返回:
|
||||
/// - `Some(0)`:两帧实质相同(未滚动 / 已到底部)
|
||||
/// - `Some(d)` d>0:检测到向下滚动了 d 像素
|
||||
/// - `None`:无法可靠匹配(内容动画 / 位移超上限等),调用方跳过本轮
|
||||
pub(crate) fn detect_vscroll_offset(
|
||||
prev: &[u8],
|
||||
cur: &[u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
band_l: usize,
|
||||
band_t: usize,
|
||||
band_w: usize,
|
||||
band_h: usize,
|
||||
) -> Option<usize> {
|
||||
if w < 4 || h < 16 {
|
||||
return None;
|
||||
}
|
||||
let band_ok = band_w >= 8 && band_h >= 24 && band_t + band_h <= h && band_l + band_w <= w;
|
||||
if band_ok {
|
||||
// 主匹配:行、列都限定在带内(带外内容不随滚动变化,会污染匹配信号)
|
||||
let ps = row_signals(prev, w, band_t, band_h, band_l, band_w);
|
||||
let cs = row_signals(cur, w, band_t, band_h, band_l, band_w);
|
||||
if let Some(r) = match_signals(&ps, &cs) {
|
||||
return Some(r);
|
||||
}
|
||||
// 回退(带内匹配失败:快速滚动位移超上限 / 带内大面积动画):
|
||||
// 全客户区行只用于**找位移**,不判"无变化"——带外静止内容会把误差拉低,
|
||||
// 误报 Some(0) 造成假"到底"
|
||||
if band_l + band_w <= w {
|
||||
let ps = row_signals(prev, w, 0, h, band_l, band_w);
|
||||
let cs = row_signals(cur, w, 0, h, band_l, band_w);
|
||||
if let Some(d) = match_signals(&ps, &cs).filter(|d| *d > 0) {
|
||||
return Some(d);
|
||||
}
|
||||
}
|
||||
let ps = row_signals(prev, w, 0, h, 0, w);
|
||||
let cs = row_signals(cur, w, 0, h, 0, w);
|
||||
return match_signals(&ps, &cs).filter(|d| *d > 0);
|
||||
}
|
||||
// 带太小:全客户区回退(此时允许 Some(0),否则永远判不了"到底")
|
||||
if band_w >= 8 && band_l + band_w <= w {
|
||||
let ps = row_signals(prev, w, 0, h, band_l, band_w);
|
||||
let cs = row_signals(cur, w, 0, h, band_l, band_w);
|
||||
if let Some(r) = match_signals(&ps, &cs) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
let ps = row_signals(prev, w, 0, h, 0, w);
|
||||
let cs = row_signals(cur, w, 0, h, 0, w);
|
||||
match_signals(&ps, &cs)
|
||||
}
|
||||
|
||||
/// 每行采样 SIG_COLS 列的亮度(B+G+R,0..765),返回 rows × SIG_COLS 的信号矩阵。
|
||||
fn row_signals(bgra: &[u8], w: usize, y0: usize, rows: usize, l: usize, bw: usize) -> Vec<i32> {
|
||||
// 均匀采样列(含两端)
|
||||
let mut cols = [0usize; SIG_COLS];
|
||||
for i in 0..SIG_COLS {
|
||||
cols[i] = l + (i * (bw - 1)) / (SIG_COLS - 1).max(1);
|
||||
}
|
||||
let mut out = Vec::with_capacity(rows * SIG_COLS);
|
||||
for y in y0..y0 + rows {
|
||||
let base = y * w * 4;
|
||||
for &c in &cols {
|
||||
let i = base + c * 4;
|
||||
// bgra: B,G,R(alpha 通常为 0,不计入亮度)
|
||||
out.push(bgra[i] as i32 + bgra[i + 1] as i32 + bgra[i + 2] as i32);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 信号模板匹配:求 d 使 `cs[y] ≈ ps[y+d]`,取平均绝对误差最小者。
|
||||
fn match_signals(ps: &[i32], cs: &[i32]) -> Option<usize> {
|
||||
let n = ps.len() / SIG_COLS;
|
||||
if n < 16 {
|
||||
return None;
|
||||
}
|
||||
let step = 2; // 隔行采样加速
|
||||
let max_d = MAX_OFFSET.min(n * 3 / 4);
|
||||
if max_d < 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// d=0 基准误差:两帧几乎一致 → 未滚动(到底部)
|
||||
let err0 = {
|
||||
let (mut e, mut c) = (0i64, 0i64);
|
||||
let mut y = 0;
|
||||
while y < n {
|
||||
for k in 0..SIG_COLS {
|
||||
e += (cs[y * SIG_COLS + k] - ps[y * SIG_COLS + k]).abs() as i64;
|
||||
}
|
||||
c += SIG_COLS as i64;
|
||||
y += step;
|
||||
}
|
||||
if c == 0 {
|
||||
return None;
|
||||
}
|
||||
e as f64 / c as f64
|
||||
};
|
||||
if err0 < 4.0 {
|
||||
return Some(0);
|
||||
}
|
||||
|
||||
// 找最小平均误差的 d
|
||||
let mut best_d = 0usize;
|
||||
let mut best_err = f64::MAX;
|
||||
for d in 1..=max_d {
|
||||
let (mut e, mut c) = (0i64, 0i64);
|
||||
let mut y = 0;
|
||||
while y + d < n {
|
||||
for k in 0..SIG_COLS {
|
||||
e += (cs[y * SIG_COLS + k] - ps[(y + d) * SIG_COLS + k]).abs() as i64;
|
||||
}
|
||||
c += SIG_COLS as i64;
|
||||
y += step;
|
||||
// 早停:明显劣于当前最优则放弃该候选
|
||||
if c >= 64 && e as f64 / c as f64 > best_err * 1.8 + 24.0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if c >= 64 {
|
||||
let ae = e as f64 / c as f64;
|
||||
if ae < best_err {
|
||||
best_err = ae;
|
||||
best_d = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if best_d == 0 {
|
||||
return None;
|
||||
}
|
||||
// 阈值按 3 通道和(0..765)标定:亚像素滚动会有轻微重采样模糊,阈值不宜过紧
|
||||
if best_err < 30.0 && best_err * 2.0 < err0 {
|
||||
Some(best_d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
//! 滚动截图会话(仅 Windows):后台线程捕捉拼接 + 独立滚轮线程平滑滚动。
|
||||
//!
|
||||
//! 与 [super::scroll_capture::scroll_capture](同步一次调用)不同,本模块把捕获循环
|
||||
//! 放进后台线程,通过事件把**实时进度**推给前端。
|
||||
//!
|
||||
//! ## 自动模式架构
|
||||
//! - **run_loop(会话线程)**:按节奏捕获客户区帧,与上一帧做带内偏移匹配,拼接新行;
|
||||
//! - **ticker(滚轮线程)**:以 ~16ms 间隔发送小步长 `WM_MOUSEWHEEL`,由目标程序自身的
|
||||
//! 平滑滚动动画呈现连续滚动(替代旧版"每 70ms 一整格"的跳变式滚动);
|
||||
//! - **目标策略升级**:带中心最深子窗口(小步长 Send)→ 子窗口整格 → 顶层整格 Send →
|
||||
//! 顶层整格 Post。解决"一开始就不滚动"(顶层不转发滚轮 / 程序忽略非整格消息);
|
||||
//! - **自适应速度**:按每周期实测位移调滚轮步长 / 捕获周期,逼近"带高 1/4"的理想重叠;
|
||||
//! - **停止补帧(settle)**:用户停止后等目标窗口滚动动画静止再补拼最后一帧,
|
||||
//! 保证最终图片结尾 = 用户停止时窗口实际停留的位置;
|
||||
//! - **精准回滚**:先按累计像素粗估上滚,再用帧匹配对初始帧校准回到起始位置。
|
||||
//!
|
||||
//! 事件(广播到所有窗口):
|
||||
//! - `SCROLL_PROGRESS`:`{ width, height, auto }` 当前已拼接高度
|
||||
//! - `SCROLL_COMPLETE`:`{ width, height }` 完成导出(PNG 原始字节已存入编辑器图片槽,
|
||||
//! 由前端常驻编辑器窗口通过 raw IPC 取出,事件本身不携带图片数据)
|
||||
//! - `SCROLL_CANCELLED`:`{}` 取消(丢弃画布)
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicIsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::thread::sleep;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::WHEEL_DELTA;
|
||||
|
||||
use crate::constants::events as evts;
|
||||
use crate::logger::{log_error, log_info};
|
||||
use super::capture::{bgra_to_png, crop_bgra};
|
||||
use super::scroll_capture::{
|
||||
capture_client_bgra, client_pt_to_screen, deep_child_at_point, detect_vscroll_offset,
|
||||
post_wheel, resolve_band, send_wheel,
|
||||
};
|
||||
use super::ScrollRegion;
|
||||
|
||||
// ===== 可调参数 =====
|
||||
|
||||
/// 滚轮线程发送间隔(毫秒)。小步长 + 高频 → 目标程序的平滑滚动动画连贯不断帧。
|
||||
const TICK_MS: u64 = 16;
|
||||
/// 捕获周期基准(毫秒)。与滚轮解耦:滚动不因捕获/匹配而停顿。
|
||||
const CYCLE_MS: u64 = 130;
|
||||
/// 捕获周期上限(整格模式下按实测位移自适应放大)。
|
||||
const CYCLE_MS_MAX: u64 = 360;
|
||||
/// 每周期理想位移 = clamp(band_h / 4, TARGET_D_MIN, TARGET_D_MAX)。
|
||||
/// 太小 → 慢;太大 → 带内重叠不足、匹配易失败。
|
||||
const TARGET_D_MIN: usize = 32;
|
||||
const TARGET_D_MAX: usize = 180;
|
||||
/// 小步长模式:起始 / 上下限步长(WHEEL_DELTA=120 为一整格)。
|
||||
const DELTA_START: i32 = 20;
|
||||
const DELTA_MIN: i32 = 4;
|
||||
const DELTA_MAX: i32 = 72;
|
||||
/// 整格模式步长(部分程序忽略非整格滚轮消息,累不进小步长)。
|
||||
const DELTA_NOTCH: i32 = 120;
|
||||
/// 策略未锁定时:连续多少周期无位移 → 升级投递策略。
|
||||
const ESCALATE_CYCLES: u32 = 3;
|
||||
/// 策略已锁定时:连续多少周期无位移 → 判定到底。
|
||||
const NO_CHANGE_STOP: u32 = 3;
|
||||
/// 上限:拼接后总高(像素),超出即自动结束。
|
||||
const MAX_TOTAL_H: u32 = 30000;
|
||||
/// 进度事件节流。
|
||||
const PROGRESS_THROTTLE_MS: u64 = 120;
|
||||
/// 停止后等待滚动动画静止的超时(毫秒)。
|
||||
const SETTLE_TIMEOUT_MS: u64 = 900;
|
||||
/// 回滚粗估:每整格对应的滚动像素。
|
||||
const ROLLBACK_PX_PER_NOTCH: f64 = 55.0;
|
||||
|
||||
// ===== 会话状态 =====
|
||||
|
||||
/// 活动会话(会话线程独占读写;命令只改 STOP/CANCEL 原子标志)
|
||||
struct Session {
|
||||
hwnd: isize,
|
||||
/// 客户区尺寸与客户区在窗口位图内偏移
|
||||
cw: i32,
|
||||
ch: i32,
|
||||
ox: i32,
|
||||
oy: i32,
|
||||
band_x: i32,
|
||||
band_y: i32,
|
||||
band_w: i32,
|
||||
band_h: i32,
|
||||
/// 已拼接像素(带宽 × total_h)
|
||||
canvas: Vec<u8>,
|
||||
total_h: u32,
|
||||
/// 累计检测到的滚动像素
|
||||
ttl_px: u32,
|
||||
/// 上一帧(全客户区,匹配基准)
|
||||
prev: Vec<u8>,
|
||||
/// 初始帧(回滚校准用)
|
||||
first: Vec<u8>,
|
||||
auto_scroll: bool,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
static SESSION: Mutex<Option<Session>> = Mutex::new(None);
|
||||
/// 请求线程结束(完成/取消共用)
|
||||
static STOP: AtomicBool = AtomicBool::new(false);
|
||||
/// 结束方式:true = 取消(不导出),false = 完成(导出)
|
||||
static CANCEL: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// ===== 滚轮线程共享控制 =====
|
||||
|
||||
static TICKER_STOP: AtomicBool = AtomicBool::new(false);
|
||||
static WHEEL_TARGET: AtomicIsize = AtomicIsize::new(0);
|
||||
/// 滚轮步长(有符号 delta,正值;发送时取负 = 向下滚)
|
||||
static WHEEL_STEP: AtomicI32 = AtomicI32::new(0);
|
||||
static WHEEL_POST: AtomicBool = AtomicBool::new(false);
|
||||
static WHEEL_SX: AtomicI32 = AtomicI32::new(0);
|
||||
static WHEEL_SY: AtomicI32 = AtomicI32::new(0);
|
||||
|
||||
/// 会话线程每轮用的只读参数(启动时一次性读出)。
|
||||
struct Params {
|
||||
hwnd: isize,
|
||||
cw: i32,
|
||||
ch: i32,
|
||||
ox: i32,
|
||||
oy: i32,
|
||||
band_x: i32,
|
||||
band_y: i32,
|
||||
band_w: i32,
|
||||
band_h: i32,
|
||||
auto_scroll: bool,
|
||||
}
|
||||
|
||||
fn read_params() -> Option<Params> {
|
||||
let g = SESSION.lock().ok()?;
|
||||
let s = g.as_ref().filter(|s| s.active)?;
|
||||
Some(Params {
|
||||
hwnd: s.hwnd,
|
||||
cw: s.cw,
|
||||
ch: s.ch,
|
||||
ox: s.ox,
|
||||
oy: s.oy,
|
||||
band_x: s.band_x,
|
||||
band_y: s.band_y,
|
||||
band_w: s.band_w,
|
||||
band_h: s.band_h,
|
||||
auto_scroll: s.auto_scroll,
|
||||
})
|
||||
}
|
||||
|
||||
/// 会话是否仍活动(run_loop 每周期自检,异常路径提前退出)
|
||||
fn is_active() -> bool {
|
||||
SESSION
|
||||
.lock()
|
||||
.map(|g| g.as_ref().map(|s| s.active).unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 启动滚动截图会话。`auto = true` 时线程自动下滚拼到底部;否则等用户手动滚动。
|
||||
pub fn start(app: AppHandle, hwnd: isize, region: Option<ScrollRegion>, auto: bool) -> Result<(), String> {
|
||||
{
|
||||
let g = SESSION.lock().map_err(|e| e.to_string())?;
|
||||
if g.as_ref().map(|s| s.active).unwrap_or(false) {
|
||||
return Err("已有滚动截图进行中".into());
|
||||
}
|
||||
}
|
||||
let (ctx, bx, by, bw, bh) = resolve_band(hwnd, region)?;
|
||||
let first = capture_client_bgra(hwnd, ctx.ox, ctx.oy, ctx.cw, ctx.ch)?;
|
||||
let canvas = crop_bgra(&first, ctx.cw as usize, bx, by, bw, bh)?;
|
||||
|
||||
{
|
||||
let mut g = SESSION.lock().map_err(|e| e.to_string())?;
|
||||
*g = Some(Session {
|
||||
hwnd,
|
||||
cw: ctx.cw,
|
||||
ch: ctx.ch,
|
||||
ox: ctx.ox,
|
||||
oy: ctx.oy,
|
||||
band_x: bx,
|
||||
band_y: by,
|
||||
band_w: bw,
|
||||
band_h: bh,
|
||||
canvas,
|
||||
total_h: bh as u32,
|
||||
ttl_px: 0,
|
||||
first: first.clone(),
|
||||
prev: first,
|
||||
auto_scroll: auto,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
STOP.store(false, Ordering::Release);
|
||||
CANCEL.store(false, Ordering::Release);
|
||||
|
||||
log_info("scroll-session", &format!(
|
||||
"启动滚动截图 hwnd={} band={}x{} auto={}",
|
||||
hwnd, bw, bh, auto
|
||||
));
|
||||
std::thread::spawn(move || run_loop(app));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 请求结束会话并导出(完成)。立即返回,线程在下一轮感知并完成。
|
||||
pub fn finish() -> Result<(), String> {
|
||||
if !running() {
|
||||
return Err("没有进行中的滚动截图".into());
|
||||
}
|
||||
STOP.store(true, Ordering::Release);
|
||||
CANCEL.store(false, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 请求取消会话(不导出)。立即返回,线程在下一轮感知并回滚终止。
|
||||
pub fn cancel_now() -> Result<(), String> {
|
||||
if !running() {
|
||||
return Err("没有进行中的滚动截图".into());
|
||||
}
|
||||
STOP.store(true, Ordering::Release);
|
||||
CANCEL.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn running() -> bool {
|
||||
SESSION.lock().map(|g| g.as_ref().map(|s| s.active).unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 滚轮线程:高频小步长发送,滚动手感交给目标程序的平滑滚动动画。
|
||||
fn ticker_loop() {
|
||||
loop {
|
||||
if TICKER_STOP.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let target = WHEEL_TARGET.load(Ordering::Acquire);
|
||||
let delta = WHEEL_STEP.load(Ordering::Acquire);
|
||||
let sx = WHEEL_SX.load(Ordering::Acquire);
|
||||
let sy = WHEEL_SY.load(Ordering::Acquire);
|
||||
if target != 0 && delta != 0 {
|
||||
if WHEEL_POST.load(Ordering::Acquire) {
|
||||
post_wheel(target, sx, sy, -delta);
|
||||
} else {
|
||||
send_wheel(target, sx, sy, -delta);
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(TICK_MS));
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用滚轮投递策略(未检测到位移时逐级升级,直到找到能滚动的通道)。
|
||||
fn apply_strategy(strategy: u8, p: &Params, bcx: i32, bcy: i32) {
|
||||
match strategy {
|
||||
0 => {
|
||||
// 子窗口 + 小步长(Chromium/WinUI 等会累加小步长并平滑滚动)
|
||||
WHEEL_TARGET.store(deep_child_at_point(p.hwnd, bcx, bcy), Ordering::Release);
|
||||
WHEEL_STEP.store(DELTA_START, Ordering::Release);
|
||||
WHEEL_POST.store(false, Ordering::Release);
|
||||
}
|
||||
1 => {
|
||||
// 子窗口 + 整格(经典 Win32 控件只认整格消息)
|
||||
WHEEL_TARGET.store(deep_child_at_point(p.hwnd, bcx, bcy), Ordering::Release);
|
||||
WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release);
|
||||
WHEEL_POST.store(false, Ordering::Release);
|
||||
}
|
||||
2 => {
|
||||
// 顶层 + 整格 Send(部分程序由顶层统一处理滚轮)
|
||||
WHEEL_TARGET.store(p.hwnd, Ordering::Release);
|
||||
WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release);
|
||||
WHEEL_POST.store(false, Ordering::Release);
|
||||
}
|
||||
_ => {
|
||||
// 顶层 + 整格 Post(只处理消息泵排队事件的程序)
|
||||
WHEEL_TARGET.store(p.hwnd, Ordering::Release);
|
||||
WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release);
|
||||
WHEEL_POST.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
log_info("scroll-session", &format!("滚轮策略升级为 {}", strategy));
|
||||
}
|
||||
|
||||
/// 单周期匹配结果
|
||||
enum CycleResult {
|
||||
/// 两帧一致(未滚动)
|
||||
NoChange,
|
||||
/// 向下滚了 d 像素(已拼接)
|
||||
Moved(usize),
|
||||
/// 匹配失败(动画 / 位移超上限)
|
||||
MatchFail,
|
||||
}
|
||||
|
||||
/// 一轮捕获-匹配-拼接。返回 (band_w, total_h, CycleResult)。
|
||||
fn stitch_cycle(p: &Params, cur: &[u8]) -> (i32, u32, CycleResult) {
|
||||
let mut g = match SESSION.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
log_error("scroll-session", &format!("会话锁异常: {}", e));
|
||||
return (p.band_w, 0, CycleResult::MatchFail);
|
||||
}
|
||||
};
|
||||
let s = match g.as_mut() {
|
||||
Some(s) => s,
|
||||
None => return (p.band_w, 0, CycleResult::MatchFail),
|
||||
};
|
||||
let prev = std::mem::take(&mut s.prev);
|
||||
let (bx, by, bw, bh) = (s.band_x, s.band_y, s.band_w, s.band_h);
|
||||
let off = detect_vscroll_offset(
|
||||
&prev,
|
||||
cur,
|
||||
p.cw as usize,
|
||||
p.ch as usize,
|
||||
bx as usize,
|
||||
by as usize,
|
||||
bw as usize,
|
||||
bh as usize,
|
||||
);
|
||||
match off {
|
||||
Some(0) => {
|
||||
s.prev = cur.to_vec();
|
||||
(bw, s.total_h, CycleResult::NoChange)
|
||||
}
|
||||
Some(d) => {
|
||||
// 一次最多拼接带的整高:单步滚动量超过带高时无法恢复中间内容,
|
||||
// 以整带兜底(自适应速度会把位移压回安全区间)
|
||||
let new_rows = (d as i32).min(bh) as usize;
|
||||
let new_y = bh - new_rows as i32; // 带在客户区内的底对齐
|
||||
match crop_bgra(cur, p.cw as usize, bx, s.band_y + new_y, bw, new_rows as i32) {
|
||||
Ok(rows) => {
|
||||
s.canvas.extend_from_slice(&rows);
|
||||
s.total_h += new_rows as u32;
|
||||
s.ttl_px += d as u32;
|
||||
s.prev = cur.to_vec();
|
||||
(bw, s.total_h, CycleResult::Moved(d))
|
||||
}
|
||||
Err(_) => {
|
||||
s.prev = cur.to_vec();
|
||||
(bw, s.total_h, CycleResult::MatchFail)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// 匹配失败:保留旧基准帧(下一帧与更早的稳定帧比对,累积位移仍能对上)
|
||||
s.prev = prev;
|
||||
(bw, s.total_h, CycleResult::MatchFail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_loop(app: AppHandle) {
|
||||
let p = match read_params() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// 滚轮目标初始参数:带中心(客户区坐标)→ 屏幕坐标
|
||||
let bcx = p.band_x + p.band_w / 2;
|
||||
let bcy = p.band_y + p.band_h / 2;
|
||||
let (bsx, bsy) = client_pt_to_screen(p.hwnd, bcx, bcy).unwrap_or((0, 0));
|
||||
WHEEL_SX.store(bsx, Ordering::Release);
|
||||
WHEEL_SY.store(bsy, Ordering::Release);
|
||||
|
||||
// 自动模式:启动滚轮线程(策略 0 起步)
|
||||
let mut strategy: u8 = 0;
|
||||
let mut ticker = None;
|
||||
if p.auto_scroll {
|
||||
apply_strategy(0, &p, bcx, bcy);
|
||||
TICKER_STOP.store(false, Ordering::Release);
|
||||
ticker = Some(std::thread::spawn(ticker_loop));
|
||||
}
|
||||
|
||||
let mut no_change: u32 = 0; // 锁定后:连续无位移(到底判定)
|
||||
let mut no_move: u32 = 0; // 未锁定:连续无位移(策略升级判定)
|
||||
let mut locked = false; // 是否已确认当前策略能滚动
|
||||
let mut gave_up = false; // 所有策略都滚不动
|
||||
let mut cycle_ms = CYCLE_MS;
|
||||
let mut capture_errors: u32 = 0;
|
||||
let mut last_emit = Instant::now();
|
||||
|
||||
loop {
|
||||
if STOP.load(Ordering::Acquire) || !is_active() {
|
||||
break;
|
||||
}
|
||||
// 周期节拍:捕获+匹配耗时计入周期
|
||||
let t0 = Instant::now();
|
||||
|
||||
let cur = match capture_client_bgra(p.hwnd, p.ox, p.oy, p.cw, p.ch) {
|
||||
Ok(f) => {
|
||||
capture_errors = 0;
|
||||
f
|
||||
}
|
||||
Err(e) => {
|
||||
// 瞬时失败先重试,连续多次失败才视为窗口关闭/失去客户区
|
||||
capture_errors += 1;
|
||||
log_error("scroll-session", &format!("捕获失败({}/3): {}", capture_errors, e));
|
||||
if capture_errors >= 3 {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(100));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let (band_w, total_h, result) = stitch_cycle(&p, &cur);
|
||||
|
||||
// 目标位移:带高 1/4,夹在安全区间
|
||||
let target_d = (p.band_h as usize / 4).clamp(TARGET_D_MIN, TARGET_D_MAX);
|
||||
|
||||
match result {
|
||||
CycleResult::Moved(d) => {
|
||||
if !locked {
|
||||
locked = true;
|
||||
log_info("scroll-session", &format!("策略 {} 生效,锁定", strategy));
|
||||
}
|
||||
no_change = 0;
|
||||
no_move = 0;
|
||||
if p.auto_scroll {
|
||||
if strategy == 0 {
|
||||
// 小步长模式:按实测位移调步长(sqrt 阻尼防过冲)
|
||||
let delta = WHEEL_STEP.load(Ordering::Acquire) as f64;
|
||||
let factor = target_d as f64 / d.max(6) as f64;
|
||||
let nd = (delta * factor.sqrt()).clamp(DELTA_MIN as f64, DELTA_MAX as f64);
|
||||
WHEEL_STEP.store(nd as i32, Ordering::Release);
|
||||
} else {
|
||||
// 整格模式:步长固定,按实测位移调捕获周期
|
||||
if d as f64 > p.band_h as f64 * 0.55 {
|
||||
cycle_ms = ((cycle_ms as f64) * 1.25).min(CYCLE_MS_MAX as f64) as u64;
|
||||
} else if d < target_d / 2 && cycle_ms > CYCLE_MS {
|
||||
cycle_ms = ((cycle_ms as f64) * 0.8).max(CYCLE_MS as f64) as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CycleResult::NoChange => {
|
||||
if locked {
|
||||
no_change += 1;
|
||||
if no_change >= NO_CHANGE_STOP && p.auto_scroll {
|
||||
break; // 已到底部
|
||||
}
|
||||
// 手动模式由用户控制,不自动结束
|
||||
} else if p.auto_scroll {
|
||||
no_move += 1;
|
||||
if no_move >= ESCALATE_CYCLES {
|
||||
no_move = 0;
|
||||
strategy += 1;
|
||||
if strategy > 3 {
|
||||
gave_up = true; // 所有通道都滚不动 → 结束(前端提示)
|
||||
break;
|
||||
}
|
||||
apply_strategy(strategy, &p, bcx, bcy);
|
||||
}
|
||||
}
|
||||
}
|
||||
CycleResult::MatchFail => {
|
||||
// 内容大幅变化/动画/位移超上限:若持续失败则降速,
|
||||
// 避免越滚越快导致匹配一直失败(表现即"滚动中断")
|
||||
if p.auto_scroll {
|
||||
if locked && strategy == 0 {
|
||||
let delta = WHEEL_STEP.load(Ordering::Acquire) as f64;
|
||||
let nd = (delta * 0.75).max(DELTA_MIN as f64);
|
||||
WHEEL_STEP.store(nd as i32, Ordering::Release);
|
||||
} else if !locked {
|
||||
// 未锁定:匹配失败也算"无位移"参与策略升级,
|
||||
// 防止动画页面导致升级判定永不触发
|
||||
no_move += 1;
|
||||
if no_move >= ESCALATE_CYCLES {
|
||||
no_move = 0;
|
||||
strategy += 1;
|
||||
if strategy > 3 {
|
||||
gave_up = true;
|
||||
break;
|
||||
}
|
||||
apply_strategy(strategy, &p, bcx, bcy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 进度事件(节流)
|
||||
if matches!(result, CycleResult::Moved(_))
|
||||
&& last_emit.elapsed().as_millis() as u64 >= PROGRESS_THROTTLE_MS
|
||||
{
|
||||
last_emit = Instant::now();
|
||||
emit_progress(&app, band_w, total_h, p.auto_scroll);
|
||||
}
|
||||
if total_h >= MAX_TOTAL_H {
|
||||
break;
|
||||
}
|
||||
|
||||
// 补足周期剩余时间
|
||||
let el = t0.elapsed().as_millis() as u64;
|
||||
if el < cycle_ms {
|
||||
sleep(Duration::from_millis(cycle_ms - el));
|
||||
}
|
||||
}
|
||||
|
||||
// 停滚轮线程(join 等它退出,最多再发一拍)
|
||||
if let Some(t) = ticker.take() {
|
||||
TICKER_STOP.store(true, Ordering::Release);
|
||||
let _ = t.join();
|
||||
}
|
||||
|
||||
let cancel = CANCEL.load(Ordering::Acquire);
|
||||
// 完成(非取消、非放弃)时:等滚动动画静止后补拼最后一帧,
|
||||
// 保证图片结尾 = 用户停止时窗口实际停留的位置
|
||||
if !cancel && !gave_up {
|
||||
settle_and_stitch(&p);
|
||||
}
|
||||
|
||||
// 取出会话数据
|
||||
let mut s = match SESSION.lock().ok().and_then(|mut g| g.take()) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
log_info("scroll-session", "会话已不存在(未导出)");
|
||||
let _ = app.emit(evts::SCROLL_CANCELLED, serde_json::json!({}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 编码与回滚并行:PNG 编码只依赖画布(已定格),回滚不依赖画布,
|
||||
// 两者无数据依赖,并行可显著缩短「停止 → 打开编辑器」的等待
|
||||
let (band_w, total_h) = (s.band_w, s.total_h);
|
||||
let encode = if cancel {
|
||||
None
|
||||
} else {
|
||||
let canvas = std::mem::take(&mut s.canvas);
|
||||
Some(std::thread::spawn(move || {
|
||||
bgra_to_png(&canvas, band_w, total_h as i32)
|
||||
}))
|
||||
};
|
||||
|
||||
// 回滚到起始位置(与编码并行):先按累计像素粗估上滚,再帧匹配校准
|
||||
if s.ttl_px > 0 {
|
||||
rollback(&s);
|
||||
}
|
||||
|
||||
STOP.store(false, Ordering::Release);
|
||||
CANCEL.store(false, Ordering::Release);
|
||||
|
||||
// 等编码完成:PNG 原始字节直接存入编辑器图片槽(不经 base64 / JSON 事件传输)
|
||||
let png = encode.and_then(|h| h.join().ok()).and_then(|r| r.ok());
|
||||
match png {
|
||||
Some(png) => {
|
||||
log_info("scroll-session", &format!(
|
||||
"完成导出 width={} height={}",
|
||||
band_w, total_h
|
||||
));
|
||||
super::set_editor_image_raw(png);
|
||||
let _ = app.emit(
|
||||
evts::SCROLL_COMPLETE,
|
||||
serde_json::json!({ "width": band_w, "height": total_h }),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
log_info("scroll-session", "会话已取消(未导出)");
|
||||
let _ = app.emit(evts::SCROLL_CANCELLED, serde_json::json!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止后等待目标窗口滚动动画静止,并把静止帧补拼进画布(结尾对齐停止位置)。
|
||||
fn settle_and_stitch(p: &Params) {
|
||||
let deadline = Instant::now() + Duration::from_millis(SETTLE_TIMEOUT_MS);
|
||||
loop {
|
||||
sleep(Duration::from_millis(70));
|
||||
if Instant::now() >= deadline || !is_active() {
|
||||
return;
|
||||
}
|
||||
let cur = match capture_client_bgra(p.hwnd, p.ox, p.oy, p.cw, p.ch) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return,
|
||||
};
|
||||
match stitch_cycle(p, &cur).2 {
|
||||
CycleResult::NoChange => return, // 已静止且无新内容
|
||||
CycleResult::Moved(_) | CycleResult::MatchFail => {
|
||||
// 动画仍在进行(Moved 已拼接;MatchFail 继续等)
|
||||
if Instant::now() >= deadline {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 回滚:粗估整格数上滚 → 与初始帧逐次比对校准,直到回到起始位置。
|
||||
/// 帧匹配校准消除"每格滚动像素因程序而异"带来的累计误差。
|
||||
fn rollback(s: &Session) {
|
||||
let target = {
|
||||
let t = WHEEL_TARGET.load(Ordering::Acquire);
|
||||
if t != 0 { t } else { s.hwnd }
|
||||
};
|
||||
let mut sx = WHEEL_SX.load(Ordering::Acquire);
|
||||
let mut sy = WHEEL_SY.load(Ordering::Acquire);
|
||||
if sx == 0 && sy == 0 {
|
||||
if let Some((x, y)) = client_pt_to_screen(
|
||||
s.hwnd,
|
||||
s.band_x + s.band_w / 2,
|
||||
s.band_y + s.band_h / 2,
|
||||
) {
|
||||
sx = x;
|
||||
sy = y;
|
||||
}
|
||||
}
|
||||
|
||||
// 粗估上滚(宁可略少,剩余交给校准补齐)。
|
||||
// 上限按累计像素推算:长图捕获(累计数万 px)也要能回滚到位,
|
||||
// 固定小上限会导致超长捕获结束后窗口停在半途。
|
||||
let notches = (((s.ttl_px as f64) / ROLLBACK_PX_PER_NOTCH).floor() as i32).clamp(1, 800);
|
||||
for _ in 0..notches {
|
||||
send_wheel(target, sx, sy, WHEEL_DELTA as i32);
|
||||
sleep(Duration::from_millis(3));
|
||||
}
|
||||
sleep(Duration::from_millis(80));
|
||||
|
||||
// 帧匹配校准:d = 当前仍相对起始位置向下滚动的像素
|
||||
let mut last_d = usize::MAX;
|
||||
for _ in 0..12 {
|
||||
let cur = match capture_client_bgra(s.hwnd, s.ox, s.oy, s.cw, s.ch) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return,
|
||||
};
|
||||
let d = detect_vscroll_offset(
|
||||
&s.first,
|
||||
&cur,
|
||||
s.cw as usize,
|
||||
s.ch as usize,
|
||||
s.band_x as usize,
|
||||
s.band_y as usize,
|
||||
s.band_w as usize,
|
||||
s.band_h as usize,
|
||||
);
|
||||
match d {
|
||||
Some(0) | None => return, // 已回到起始位置 / 无法匹配(视为完成)
|
||||
Some(d) if d < 10 => return, // 误差 10px 内视为到位
|
||||
Some(d) => {
|
||||
if d >= last_d {
|
||||
return; // 不再下降:窗口可能不支持向上滚(虚拟化列表),放弃
|
||||
}
|
||||
last_d = d;
|
||||
let more = (((d as f64) / ROLLBACK_PX_PER_NOTCH).ceil() as i32).clamp(1, 60);
|
||||
for _ in 0..more {
|
||||
send_wheel(target, sx, sy, WHEEL_DELTA as i32);
|
||||
sleep(Duration::from_millis(3));
|
||||
}
|
||||
sleep(Duration::from_millis(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, width: i32, height: u32, auto: bool) {
|
||||
let _ = app.emit(
|
||||
evts::SCROLL_PROGRESS,
|
||||
serde_json::json!({ "width": width, "height": height, "auto": auto }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! HDR 全屏捕获实现(Windows Graphics Capture + HDR→sRGB 色调映射)
|
||||
//!
|
||||
//! ## 为什么需要它
|
||||
//! Windows 高级色彩(HDR)开启时,桌面由 DWM 用**线性 scRGB(RGBA16F)**合成。
|
||||
//! [crate::screenshot::capture] 的 `BitBlt` 从屏幕 DC 直接取出 8bit 像素,
|
||||
//! 把这些线性/scRGB 高亮值当 sRGB 编码,会导致亮部越界被裁、局部过曝失真。
|
||||
//!
|
||||
//! ## 方案
|
||||
//! 检测到任意显示器为 HDR 输出时,改用 WGC:
|
||||
//! 1. 以 `RGBA16F`(scRGB 线性)捕获每个显示器;
|
||||
//! 2. 按 SDR 参考白缩放(`÷ (sdr_white_nits / 80)`)后钳制到 [0,1];
|
||||
//! 3. 做 sRGB 伽马编码,得到与屏幕观感一致的 BGRA8;
|
||||
//! 4. 按各显示器在虚拟屏上的偏移拼接到一整张虚拟屏图像(与 BitBlt 输出同构)。
|
||||
//!
|
||||
//! 仅当 HDR 时才走此路径;SDR 显示器仍用快速的 BitBlt。
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use windows_capture::capture::{Context, GraphicsCaptureApiHandler};
|
||||
use windows_capture::frame::Frame;
|
||||
use windows_capture::graphics_capture_api::InternalCaptureControl;
|
||||
use windows_capture::monitor::Monitor;
|
||||
use windows_capture::settings::{
|
||||
ColorFormat, CursorCaptureSettings, DirtyRegionSettings, DrawBorderSettings,
|
||||
MinimumUpdateIntervalSettings, SecondaryWindowSettings, Settings,
|
||||
};
|
||||
|
||||
use super::capture::CapturedImage;
|
||||
|
||||
// ===== 虚拟屏尺寸(与 capture.rs 一致)=====
|
||||
const SM_XVIRTUALSCREEN: i32 = 76;
|
||||
const SM_YVIRTUALSCREEN: i32 = 77;
|
||||
const SM_CXVIRTUALSCREEN: i32 = 78;
|
||||
const SM_CYVIRTUALSCREEN: i32 = 79;
|
||||
|
||||
/// Windows「SDR 内容亮度」的默认参考白(nits)。未自定义时为 203。
|
||||
/// 严格值可用 DISPLAYCONFIG_SDR_WHITE_LEVEL 查询;此处取系统默认,覆盖绝大多数情况。
|
||||
const DEFAULT_SDR_WHITE_NITS: f32 = 203.0;
|
||||
|
||||
/// 单帧捕获参数(作为 WGC handler 的 Flags 传入,把抓到的帧回传给调用线程)
|
||||
#[derive(Clone)]
|
||||
struct CaptureFlags {
|
||||
tx: mpsc::SyncSender<Result<FramePixels, String>>,
|
||||
}
|
||||
|
||||
/// 一帧 Rgba16F 像素(已去掉行尾 padding,top-down)
|
||||
struct FramePixels {
|
||||
width: u32,
|
||||
height: u32,
|
||||
raw: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 一次性截图 handler:拿到第一帧即回传并停止捕获
|
||||
struct OneShot {
|
||||
flags: CaptureFlags,
|
||||
}
|
||||
|
||||
impl GraphicsCaptureApiHandler for OneShot {
|
||||
type Flags = CaptureFlags;
|
||||
type Error = String;
|
||||
|
||||
fn new(ctx: Context<Self::Flags>) -> Result<Self, Self::Error> {
|
||||
Ok(Self { flags: ctx.flags })
|
||||
}
|
||||
|
||||
fn on_frame_arrived(
|
||||
&mut self,
|
||||
frame: &mut Frame,
|
||||
capture_control: InternalCaptureControl,
|
||||
) -> Result<(), Self::Error> {
|
||||
let mut buffer = frame.buffer().map_err(|e| e.to_string())?;
|
||||
let width = buffer.width();
|
||||
let height = buffer.height();
|
||||
let row_pitch = buffer.row_pitch() as usize;
|
||||
let raw = buffer.as_raw_buffer();
|
||||
// 拷贝并去掉行 padding(Rgba16F = 每像素 8 字节)
|
||||
let row_bytes = (width as usize) * 8;
|
||||
let mut packed = vec![0u8; row_bytes * (height as usize)];
|
||||
for y in 0..(height as usize) {
|
||||
let src = y * row_pitch;
|
||||
let dst = y * row_bytes;
|
||||
packed[dst..dst + row_bytes].copy_from_slice(&raw[src..src + row_bytes]);
|
||||
}
|
||||
let _ = self.flags.tx.send(Ok(FramePixels {
|
||||
width,
|
||||
height,
|
||||
raw: packed,
|
||||
}));
|
||||
// 单帧足够,立即结束捕获(internal stop → 捕获线程退出)
|
||||
capture_control.stop();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 捕获单个显示器(按 hmonitor),返回 tonemap 后的 BGRA8(top-down)
|
||||
fn capture_monitor_bgra(
|
||||
hmonitor: *mut c_void,
|
||||
sdr_white: f32,
|
||||
) -> Result<(Vec<u8>, u32, u32), String> {
|
||||
let monitor = Monitor::from_raw_hmonitor(hmonitor);
|
||||
let (tx, rx) = mpsc::sync_channel::<Result<FramePixels, String>>(1);
|
||||
let flags = CaptureFlags { tx };
|
||||
let settings = Settings::new(
|
||||
monitor,
|
||||
CursorCaptureSettings::WithCursor,
|
||||
DrawBorderSettings::WithoutBorder,
|
||||
SecondaryWindowSettings::Default,
|
||||
MinimumUpdateIntervalSettings::Custom(Duration::from_millis(32)),
|
||||
DirtyRegionSettings::Default,
|
||||
ColorFormat::Rgba16F,
|
||||
flags,
|
||||
);
|
||||
|
||||
let control = OneShot::start_free_threaded(settings)
|
||||
.map_err(|e| format!("WGC 捕获启动失败: {}", e))?;
|
||||
|
||||
let frame = match rx.recv_timeout(Duration::from_secs(10)) {
|
||||
Ok(Ok(f)) => f,
|
||||
Ok(Err(e)) => {
|
||||
let _ = control.stop();
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = control.stop();
|
||||
return Err(format!("等待 WGC 帧超时: {}", e));
|
||||
}
|
||||
};
|
||||
let _ = control.stop();
|
||||
|
||||
let bgra = tonemap_rgba16f_to_bgra(&frame.raw, frame.width, frame.height, sdr_white);
|
||||
Ok((bgra, frame.width, frame.height))
|
||||
}
|
||||
|
||||
/// 捕获整个虚拟屏(多显示器拼接),返回与 BitBlt 同构的 `CapturedImage`(BGRA top-down)。
|
||||
/// 仅当检测到 HDR 时由 [capture] 调用。
|
||||
pub fn capture_virtual_screen_wgc() -> Result<CapturedImage, String> {
|
||||
// 用 QueryDisplayConfig 读系统实际 SDR 白电平(nits),取不到才回退系统默认 203
|
||||
let sdr_white = query_sdr_white_level().unwrap_or(DEFAULT_SDR_WHITE_NITS);
|
||||
let monitors = enum_monitors();
|
||||
if monitors.is_empty() {
|
||||
return Err("未检测到显示器".into());
|
||||
}
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::GetSystemMetrics as gsm;
|
||||
let vx = unsafe { gsm(SM_XVIRTUALSCREEN) };
|
||||
let vy = unsafe { gsm(SM_YVIRTUALSCREEN) };
|
||||
let vw = unsafe { gsm(SM_CXVIRTUALSCREEN) };
|
||||
let vh = unsafe { gsm(SM_CYVIRTUALSCREEN) };
|
||||
if vw <= 0 || vh <= 0 {
|
||||
return Err("无法获取虚拟屏尺寸".into());
|
||||
}
|
||||
let mut canvas = vec![0u8; (vw as usize) * (vh as usize) * 4]; // 透明区以黑填充
|
||||
for m in &monitors {
|
||||
let (bgra, mw, mh) = capture_monitor_bgra(m.handle as *mut c_void, sdr_white)?;
|
||||
let ow = m.x - vx;
|
||||
let oh = m.y - vy;
|
||||
if mw as i32 != m.w || mh as i32 != m.h {
|
||||
// 尺寸失配(极少见,如缩放中途)— 中止避免错位拼接
|
||||
return Err(format!(
|
||||
"显示器捕获尺寸不符: 枚举 {}x{} vs WGC {}x{}",
|
||||
m.w, m.h, mw, mh
|
||||
));
|
||||
}
|
||||
let mw = mw as usize;
|
||||
for row in 0..mh {
|
||||
let src = (row as usize) * mw * 4;
|
||||
let dst = ((oh + row as i32) as usize) * (vw as usize) * 4 + (ow as usize) * 4;
|
||||
let len = mw * 4;
|
||||
canvas[dst..dst + len].copy_from_slice(&bgra[src..src + len]);
|
||||
}
|
||||
}
|
||||
Ok(CapturedImage {
|
||||
width: vw,
|
||||
height: vh,
|
||||
png: Vec::new(),
|
||||
bgra: canvas,
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 显示器枚举与 HDR 检测 =====
|
||||
|
||||
struct MonInfo {
|
||||
handle: usize,
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
}
|
||||
|
||||
fn enum_monitors() -> Vec<MonInfo> {
|
||||
let mut out: Vec<MonInfo> = Vec::new();
|
||||
unsafe extern "system" fn cb(
|
||||
hmon: isize,
|
||||
_hdc: isize,
|
||||
rect: *mut windows_sys::Win32::Foundation::RECT,
|
||||
lparam: isize,
|
||||
) -> windows_sys::Win32::Foundation::BOOL {
|
||||
let v = &mut *(lparam as *mut Vec<MonInfo>);
|
||||
let r = *rect;
|
||||
v.push(MonInfo {
|
||||
handle: hmon as usize,
|
||||
x: r.left,
|
||||
y: r.top,
|
||||
w: r.right - r.left,
|
||||
h: r.bottom - r.top,
|
||||
});
|
||||
1
|
||||
}
|
||||
unsafe {
|
||||
windows_sys::Win32::Graphics::Gdi::EnumDisplayMonitors(
|
||||
0,
|
||||
std::ptr::null(),
|
||||
Some(cb),
|
||||
&mut out as *mut _ as isize,
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 枚举 DXGI 输出,任一显示器为 HDR(advanced color / PQ / HLG)输出则返回 true。
|
||||
/// 任何错误一律视为非 HDR(回退 BitBlt),保证普通 SDR 环境不受影响。
|
||||
pub fn is_hdr_enabled() -> bool {
|
||||
#[allow(unused_imports)]
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709, DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020,
|
||||
DXGI_COLOR_SPACE_TYPE,
|
||||
};
|
||||
|
||||
fn is_hdr_space(space: DXGI_COLOR_SPACE_TYPE) -> bool {
|
||||
// HDR10(PQ,BT.2020 主色)与 scRGB 高级色彩工作空间即视为 HDR
|
||||
matches!(
|
||||
space,
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 | DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709
|
||||
)
|
||||
}
|
||||
|
||||
use windows::Win32::Graphics::Dxgi::IDXGIFactory1;
|
||||
let Ok(factory) = (unsafe {
|
||||
windows::Win32::Graphics::Dxgi::CreateDXGIFactory1::<IDXGIFactory1>()
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
use windows::core::ComInterface;
|
||||
unsafe {
|
||||
let mut ai = 0u32;
|
||||
while let Ok(adapter) = factory.EnumAdapters1(ai) {
|
||||
ai += 1;
|
||||
let mut oi = 0u32;
|
||||
while let Ok(output) = adapter.EnumOutputs(oi) {
|
||||
oi += 1;
|
||||
if let Ok(out6) =
|
||||
output.cast::<windows::Win32::Graphics::Dxgi::IDXGIOutput6>()
|
||||
{
|
||||
let mut desc: windows::Win32::Graphics::Dxgi::DXGI_OUTPUT_DESC1 =
|
||||
std::mem::zeroed();
|
||||
if out6.GetDesc1(&mut desc).is_ok() && is_hdr_space(desc.ColorSpace) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 通过 QueryDisplayConfig 读取 Windows 实际的 SDR 参考白电平(nits)。
|
||||
///
|
||||
/// SDR 白电平并非 union 的声明成员,而是系统/驱动写入在 DISPLAYCONFIG_MODE_INFO
|
||||
/// 的 union 尾部(desktopImageInfo 之后)的 4 字节(DISPLAYCONFIG_SDR_WHITE_LEVEL)。
|
||||
/// 取不到(非 HDR / 查询失败)时返回 None,由调用方回退系统默认 203。
|
||||
fn query_sdr_white_level() -> Option<f32> {
|
||||
use windows_sys::Win32::Devices::Display::{
|
||||
GetDisplayConfigBufferSizes, QueryDisplayConfig, DISPLAYCONFIG_MODE_INFO,
|
||||
DISPLAYCONFIG_MODE_INFO_TYPE_DESKTOP_IMAGE, DISPLAYCONFIG_PATH_INFO,
|
||||
DISPLAYCONFIG_SDR_WHITE_LEVEL, QDC_ONLY_ACTIVE_PATHS,
|
||||
};
|
||||
unsafe {
|
||||
let mut num_paths = 0u32;
|
||||
let mut num_modes = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut num_paths, &mut num_modes) != 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut paths = vec![std::mem::zeroed::<DISPLAYCONFIG_PATH_INFO>(); num_paths as usize];
|
||||
let mut modes = vec![std::mem::zeroed::<DISPLAYCONFIG_MODE_INFO>(); num_modes as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ONLY_ACTIVE_PATHS,
|
||||
&mut num_paths,
|
||||
paths.as_mut_ptr(),
|
||||
&mut num_modes,
|
||||
modes.as_mut_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
) != 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for mode in modes.iter().take(num_modes as usize) {
|
||||
if mode.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_DESKTOP_IMAGE {
|
||||
let base = mode as *const DISPLAYCONFIG_MODE_INFO as *const u8;
|
||||
let off = std::mem::size_of::<DISPLAYCONFIG_MODE_INFO>()
|
||||
- std::mem::size_of::<DISPLAYCONFIG_SDR_WHITE_LEVEL>();
|
||||
let white = std::ptr::read_unaligned::<u32>(base.add(off) as *const u32);
|
||||
if white > 0 {
|
||||
return Some(white as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ===== HDR→sRGB 色调映射 =====
|
||||
|
||||
/// IEEE754 半精度 float → f32
|
||||
fn half_to_f32(h: u16) -> f32 {
|
||||
let sign = (h & 0x8000) != 0;
|
||||
let exp = (h >> 10) & 0x1f;
|
||||
let man = (h & 0x3ff) as f32;
|
||||
let v = match exp {
|
||||
0 => man / 1024.0 * 2.0f32.powi(-14), // 次正规
|
||||
0x1f => f32::NAN, // Inf/NaN,按 NaN 处理(后续 clamp 为 0~255 安全)
|
||||
_ => (1.0 + man / 1024.0) * 2.0f32.powi(exp as i32 - 15),
|
||||
};
|
||||
if sign {
|
||||
-v
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// 线性 scRGB 值 → sRGB 8bit(标准 sRGB OETF)
|
||||
fn srgb_encode(linear: f32) -> u8 {
|
||||
let l = linear.clamp(0.0, 1.0);
|
||||
let e = if l <= 0.003_130_8 {
|
||||
12.92 * l
|
||||
} else {
|
||||
1.055 * l.powf(1.0 / 2.4) - 0.055
|
||||
};
|
||||
(e * 255.0 + 0.5) as u8
|
||||
}
|
||||
|
||||
/// Rgba16F(scRGB 线性,top-down)→ BGRA8(sRGB),按 SDR 参考白缩放。
|
||||
fn tonemap_rgba16f_to_bgra(raw: &[u8], width: u32, height: u32, sdr_white: f32) -> Vec<u8> {
|
||||
let inv_scale = 80.0 / sdr_white.max(1.0);
|
||||
let n = (width as usize) * (height as usize);
|
||||
let mut out = vec![0u8; n * 4];
|
||||
for i in 0..n {
|
||||
let pi = i * 8;
|
||||
let r16 = u16::from_le_bytes([raw[pi], raw[pi + 1]]);
|
||||
let g16 = u16::from_le_bytes([raw[pi + 2], raw[pi + 3]]);
|
||||
let b16 = u16::from_le_bytes([raw[pi + 4], raw[pi + 5]]);
|
||||
let b = srgb_encode(half_to_f32(b16) * inv_scale);
|
||||
let g = srgb_encode(half_to_f32(g16) * inv_scale);
|
||||
let r = srgb_encode(half_to_f32(r16) * inv_scale);
|
||||
let oi = i * 4;
|
||||
out[oi] = b;
|
||||
out[oi + 1] = g;
|
||||
out[oi + 2] = r;
|
||||
out[oi + 3] = 255; // 强制不透明(与 BitBlt 路径一致)
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 应用启动初始化 —— 从 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::music::MusicManager;
|
||||
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);
|
||||
|
||||
// ===== 音乐模块:MusicManager(Python 运行时 + 桥接进程) =====
|
||||
// 仅注册状态,不主动启动桥接(由前端模块激活/首次请求时按需拉起)
|
||||
let music = MusicManager::new(app_data_dir.clone());
|
||||
// 注入 AppHandle:桥接 reader 线程把下载事件行转发给前端
|
||||
music.set_app(app.handle().clone());
|
||||
app.manage(music);
|
||||
|
||||
// 网速采样不依赖提权,应用启动即开始
|
||||
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);
|
||||
// 同时预创建独立预览窗口(隐藏),弹窗悬停条目时直接显示
|
||||
crate::clipboard::popup::ensure_preview_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());
|
||||
|
||||
// ===== 代理:自动切换节点后台调度(独立于模块激活状态) =====
|
||||
crate::mihomo_manager::start_auto_switch_loop(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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user