3 Commits
Author SHA1 Message Date
zhongluofeng 6b9f71da08 bug修复调整 2026-08-31 18:05:27 +08:00
zhongluofeng 28e0c4664a 构建修正 2026-08-27 18:32:33 +08:00
zhongluofeng d21649c60e 优化调整、BT下载(有bug) 2026-08-27 18:25:45 +08:00
55 changed files with 5977 additions and 476 deletions
+3 -3
View File
@@ -111,7 +111,7 @@ Thing/
- [x] 自建进程内下载引擎(多线程 HTTP/HTTPS,无需外部内核) - [x] 自建进程内下载引擎(多线程 HTTP/HTTPS,无需外部内核)
- [x] 接管浏览器下载,浏览器扩展(Thing Extension - [x] 接管浏览器下载,浏览器扩展(Thing Extension
- [x] HTTP 下载支持 - [x] HTTP 下载支持
- [ ] BT/磁力链接支持(后续支持) - [x] BT/磁力链接支持(后续支持)
- [x] 下载任务管理(历史) - [x] 下载任务管理(历史)
- [x] 速度限制 - [x] 速度限制
- [x] 断点续传 - [x] 断点续传
@@ -124,8 +124,8 @@ Thing/
### 第三阶段:优化与完善 ### 第三阶段:优化与完善
- [x] 性能优化(P1/P2:轮询随窗口可见性暂停、批量测速限并发、渲染 memo 化等,见 MODULE_REVIEW.md - [x] 性能优化(见 MODULE_REVIEW.md
- [x] 错误处理与日志完善(B5 进程级全局日志器、异常兜底) - [x] 错误处理与日志完善(全局日志器、异常兜底)
- [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节) - [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节)
- [x] 自动更新机制 - [x] 自动更新机制
- [x] 打包发布 - [x] 打包发布
+2
View File
@@ -66,6 +66,8 @@ internal sealed class KernelStatus
{ {
public bool Ready { get; set; } public bool Ready { get; set; }
public bool IsAdmin { get; set; } public bool IsAdmin { get; set; }
/// <summary>PawnIO 驱动是否已安装(ring0 传感器读取依赖它或 WinRing0,缺失时温度/频率通常无法读取)</summary>
public bool PawnIoInstalled { get; set; }
public double UptimeMs { get; set; } public double UptimeMs { get; set; }
public int GroupCount { get; set; } public int GroupCount { get; set; }
public int SensorCount { get; set; } public int SensorCount { get; set; }
+1 -1
View File
@@ -277,7 +277,7 @@ internal sealed class HardwareManager : IDisposable
_ => "", _ => "",
}; };
private static bool IsRunningAsAdmin() internal static bool IsRunningAsAdmin()
{ {
try try
{ {
+1
View File
@@ -44,6 +44,7 @@ internal static class HttpEndpoints
{ {
Ready = hw?.Ready ?? false, Ready = hw?.Ready ?? false,
IsAdmin = hw?.IsAdmin ?? false, IsAdmin = hw?.IsAdmin ?? false,
PawnIoInstalled = PawnIoSupport.IsServiceInstalled(),
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds, UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
GroupCount = snap?.Groups.Count ?? 0, GroupCount = snap?.Groups.Count ?? 0,
SensorCount = kernel.Scheduler.Cache.SensorCount, SensorCount = kernel.Scheduler.Cache.SensorCount,
+101
View File
@@ -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}";
}
}
}
+5
View File
@@ -87,6 +87,11 @@ internal static class Program
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} config={configPath ?? "()"} fast={fastMs}ms slow={slowMs}ms"); 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(); using var kernel = new KernelHost();
await kernel.StartAsync(configPath, fastMs, slowMs); await kernel.StartAsync(configPath, fastMs, slowMs);
+1 -1
View File
@@ -25,7 +25,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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" /> <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup> </ItemGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "thing", "name": "thing",
"private": true, "private": true,
"version": "26.8.2", "version": "26.8.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1355 -24
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "thing" name = "thing"
version = "26.8.2" version = "26.8.4"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -45,6 +45,8 @@ base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
walkdir = "2" walkdir = "2"
notify = { version = "6", features = [] } notify = { version = "6", features = [] }
librqbit = "9"
bytes = "1"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.52" winreg = "0.52"
@@ -67,7 +69,7 @@ windows-sys = { version = "0.52", features = [
"Win32_Storage_Xps", "Win32_Storage_Xps",
"Win32_Storage_FileSystem", "Win32_Storage_FileSystem",
] } ] }
# Explorer 蜑榊床逶ョ蠖墓」€豬具シ・ShellWindows COM・会シ壻サ・シ募・逕ィ蛻ー逧・feature・梧而蛻カ郛冶ッ台ス鍋ァ # Explorer 鬯ョ・ッ繝サ・キ鬮ォ・ィ繝サ・ャ郢晢スサ繝サ・ヲ鬯ョ・ョ郢晢スサ繝サ・ス繝サ・コ髣費スィ郢晢スサ・つ€郢晢スサ繝サ・カ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ョ・ッ雋翫・譚溽ケ晢スサ繝サ・「鬯ョ・ョ繝サ・」郢晢スサ繝サ・ス郢晢スサ繝サ・」郢晢スサ邵コ・、・つ€鬯ョ・ョ髮懶ス」繝サ・ス繝サ・ャ鬮ッ・キ髣鯉スィ繝サ・ス繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・シ鬩幢ス「隴趣ス「繝サ・ス繝サ・サShellWindows COM鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮」雋サ・ス・ィ髯樊サゑスス・イ郢晢スサ繝サ・ス郢晢スサ繝サ・シ鬮ッ讖ク・ス・「郢晢スサ繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬮ッ・キ髢ァ・エ繝サ・コ陋滂ス・郢晢スサ鬯ッ・ィ繝サ・セ髯具スケ郢晢スサ繝サ・ス繝サ・ス郢晢スサ繝サ・ィ鬯ョ・ッ陷茨スキ繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ィ繝サ・セ郢晢スサ繝サ・ァ鬩幢ス「隴趣ス「繝サ・ス繝サ・サfeature鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮ォ・エ繝サ・エ郢晢スサ繝サ・ァ鬯ョ・「繝サ・ー髴托スケ陞「・シ繝サ・エ髴域鱒繝サ郢晢スサ繝サ・カ鬯ッ・ゥ陝カ蟷「・ス・ク陝カ蜷カ繝サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬮ッ・キ繝サ・ソ郢晢スサ繝サ・ー驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ェ繝サ・ー髯キ闌ィ・ス・キ郢晢スサ繝サ・ス郢晢スサ繝サ・ァ驛「譎「・ス・サ郢晢スサ繝サ・
windows = { version = "0.52", features = [ windows = { version = "0.52", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_System_Com", "Win32_System_Com",
@@ -80,3 +82,15 @@ windows = { version = "0.52", features = [
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2" tauri-plugin-autostart = "2"
# ===== 驛帛・・ッ蜿ー・シ莨懷密 =====
# dev 隴ォ繝サ・サ・コ陷キ・ッ騾包スィ陟・ィ」纃シ驛帛・・ッ繝サ+ 髯ヲ謔滓差驛、・ァ髫ケ繝サ・ッ蛹・スシ蝓溽スイ鬨セ貊捺た陜ィ・ー髴托スュ闔会ス」繝サ莨夲スシ蠕。・サ繝サ・ス・ア陷ゥ繝サ`tauri dev`
[profile.dev]
incremental = true
debug = "line-tables-only"
# release 髫エ・ォ郢晢スサ繝サ・サ繝サ・コ鬨セ蜴・スス・ヲ鬮エ繝サ・ス・ォ郢晢スサ陞「・シ隰碑崟蝨キ繝サ・ヲ髯キ・ソ繝サ・キ + LTO郢晢スサ隰疲コ倩ゥ宣劑繝サ・ク讖ク・ス・ョ髣・スス繝サ・」郢晢スサ隴ッ竏ャ謚・ェー蜈キ・ス・ァ繝サ・ッ
[profile.release]
strip = true
lto = true
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
]
}
+3 -1
View File
@@ -44,6 +44,8 @@ pub mod events {
// OSD 窗口 // OSD 窗口
pub const OSD_SYSTEM_UI_ACTIVE: &str = "osd-system-ui-active"; 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_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_START_DRAG: &str = "osd-start-drag";
pub const OSD_END_DRAG: &str = "osd-end-drag"; pub const OSD_END_DRAG: &str = "osd-end-drag";
// 截图 // 截图
@@ -60,6 +62,6 @@ pub mod events {
pub const DOWNLOAD_ADDED: &str = "download-added"; pub const DOWNLOAD_ADDED: &str = "download-added";
/// 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表) /// 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表)
pub const DOWNLOAD_REMOVED: &str = "download-removed"; pub const DOWNLOAD_REMOVED: &str = "download-removed";
/// 浏览器扩展通过 HTTP API 新增下载(前端需置前主窗口并跳到下载画面 /// 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口
pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added"; pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added";
} }
+53 -2
View File
@@ -1,10 +1,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use tauri::{AppHandle, State}; use tauri::{AppHandle, Manager, State};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use super::engine::{CheckUrlResult, DownloadEngine}; use super::engine::{CheckUrlResult, DownloadEngine};
use super::task::{DownloadTask, DownloaderSettings}; use super::task::{DownloadTask, DownloaderSettings};
use super::torrent::TorrentInfo;
/// 获取所有任务 /// 获取所有任务
#[tauri::command] #[tauri::command]
@@ -13,6 +14,20 @@ pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTa
engine.get_tasks() 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 重复性并探测文件信息(添加下载前调用) /// 检查 URL 重复性并探测文件信息(添加下载前调用)
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -57,8 +72,9 @@ pub async fn downloader_add_task(
dir: Option<String>, dir: Option<String>,
headers: Option<HashMap<String, String>>, headers: Option<HashMap<String, String>>,
auto_rename: Option<bool>, auto_rename: Option<bool>,
only_files: Option<Vec<u32>>,
) -> Result<String, String> { ) -> 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
} }
/// 暂停任务 /// 暂停任务
@@ -75,6 +91,20 @@ pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) ->
engine.resume_task(&id) 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] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -141,3 +171,24 @@ pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
.open_url(url, None::<&str>) .open_url(url, None::<&str>)
.map_err(|e| format!("打开链接失败: {}", e)) .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(())
}
File diff suppressed because it is too large Load Diff
+53 -4
View File
@@ -53,14 +53,34 @@ impl HttpDownloader {
} }
/// 探测下载资源信息(大小、是否支持 Range、文件名) /// 探测下载资源信息(大小、是否支持 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( pub async fn probe(
&self, &self,
url: &str, url: &str,
headers: &HashMap<String, String>, headers: &HashMap<String, String>,
use_proxy: bool, use_proxy: bool,
) -> Result<ProbeResult, String> { ) -> Result<ProbeResult, String> {
let client = self.client(use_proxy); 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 和获取大小) // 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小)
let mut req = client let mut req = client
.get(url) .get(url)
@@ -165,7 +185,36 @@ impl HttpDownloader {
limiter: Arc<RateLimiter>, limiter: Arc<RateLimiter>,
use_proxy: bool, use_proxy: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let client = self.client(use_proxy); // 代理降级:仅当 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(); let total_size = segments.iter().map(|s| s.len()).sum();
// 预分配文件(若已知大小) // 预分配文件(若已知大小)
@@ -191,7 +240,7 @@ impl HttpDownloader {
// 单线程下载(不支持 Range 或文件太小) // 单线程下载(不支持 Range 或文件太小)
let seg = &segments[0]; let seg = &segments[0];
let prog = &progress[0]; let prog = &progress[0];
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter, client) self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter.clone(), client)
.await?; .await?;
return Ok(()); return Ok(());
} }
+3 -2
View File
@@ -5,10 +5,11 @@ pub mod rate_limit;
pub mod server; pub mod server;
pub mod storage; pub mod storage;
pub mod task; pub mod task;
pub mod torrent;
pub use commands::{ pub use commands::{
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, 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_remove_task, downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status, downloader_resume_task, downloader_save_settings, downloader_status,
}; };
pub use engine::DownloadEngine; pub use engine::DownloadEngine;
+7 -6
View File
@@ -122,13 +122,14 @@ async fn create_download(
return Ok(Json(CreateDownloadResponse { id: existing.id })); return Ok(Json(CreateDownloadResponse { id: existing.id }));
} }
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await { match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true, None).await {
Ok(id) => { Ok(id) => {
// 浏览器扩展发起下载:置前主窗口并通知前端跳到下载画面(替代原桌面通知) // 浏览器扩展发起下载:不再置前主窗口,改为带 task id 通知前端,
crate::tray_menu::focus_main_window(&state.app_handle); // 由前端为该任务创建一个专属的一次性下载窗口(不打断主界面)
let _ = state let _ = state.app_handle.emit(
.app_handle crate::constants::events::DOWNLOAD_EXTENSION_ADDED,
.emit(crate::constants::events::DOWNLOAD_EXTENSION_ADDED, ()); serde_json::json!({ "id": id }),
);
Ok(Json(CreateDownloadResponse { id })) Ok(Json(CreateDownloadResponse { id }))
} }
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))), Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
+55 -2
View File
@@ -2,6 +2,17 @@ use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use std::collections::HashMap; 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, Type)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -16,6 +27,8 @@ pub enum TaskStatus {
Complete, Complete,
/// 错误 /// 错误
Error, Error,
/// 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载)
Cancelled,
} }
/// 下载分段(多线程 Range 下载 / 断点续传用) /// 下载分段(多线程 Range 下载 / 断点续传用)
@@ -52,18 +65,42 @@ impl Segment {
} }
} }
/// 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, Type)] #[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DownloadTask { pub struct DownloadTask {
/// 任务 ID(自增 hex 字符串) /// 任务 ID(自增 hex 字符串)
pub id: String, pub id: String,
/// 下载地址 /// 下载地址HTTP URL 或磁力链接)
pub url: String, pub url: String,
/// 文件名 /// 文件名(HTTP:目标文件名;BT:种子名称)
pub filename: String, pub filename: String,
/// 保存目录(绝对路径) /// 保存目录(绝对路径)
pub dir: 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, pub status: TaskStatus,
/// 文件总大小(字节),0=未知 /// 文件总大小(字节),0=未知
@@ -141,6 +178,18 @@ pub struct DownloaderSettings {
/// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 /// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub use_proxy: bool, pub use_proxy: bool,
/// BitTorrent 上传限速 KB/s0=不限)
#[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 { fn default_max_concurrent() -> u32 {
@@ -180,6 +229,10 @@ impl Default for DownloaderSettings {
delete_files_on_remove: false, delete_files_on_remove: false,
check_duplicate: true, check_duplicate: true,
use_proxy: true, use_proxy: true,
bt_upload_limit_kb: 0,
bt_seed_after_download: false,
bt_listen_port: 0,
bt_use_proxy: false,
} }
} }
} }
+477
View File
@@ -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,
/// infohashhex 小写字符串)
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/s0=不限),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 仍会生效)");
}
/// 会话级公共 trackerHashSet<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()
}
+26 -11
View File
@@ -20,8 +20,8 @@ mod win32_util;
use download_engine::{ use download_engine::{
DownloadEngine, DownloadEngine,
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, 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_remove_task, downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
downloader_resume_task, downloader_save_settings, downloader_status, downloader_resume_task, downloader_save_settings, downloader_status,
}; };
use logger::{ use logger::{
@@ -31,7 +31,7 @@ use mihomo_manager::{
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy, 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_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_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_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_traffic,
proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager, proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager,
}; };
use monitor_kernel::{ use monitor_kernel::{
@@ -43,7 +43,8 @@ use monitor_kernel::{
use network_monitor::network_status; use network_monitor::network_status;
use osd_window::{ use osd_window::{
osd_apply_overlay_style, osd_begin_drag, osd_set_bounds, osd_set_click_through, 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_stop_watch, osd_set_topmost, osd_start_drag_watch, osd_start_game_watch, osd_start_topmost_watch,
osd_stop_watch,
}; };
use process_manager::{ use process_manager::{
process_all_status, process_start, process_status, process_all_status, process_start, process_status,
@@ -84,7 +85,10 @@ use quickpanel::{
quickpanel_show_window, quickpanel_unregister_shortcut, quickpanel_focus_main_window, quickpanel_show_window, quickpanel_unregister_shortcut, quickpanel_focus_main_window,
}; };
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready}; use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
use updater::{app_version, update_check, update_install, update_thinghk}; use updater::{
app_version, update_check, update_install, update_thinghk_apply, update_thinghk_cancel,
update_thinghk_confirm, ThinghkUpdateState,
};
#[tauri::command] #[tauri::command]
fn quit_app(app: tauri::AppHandle) { fn quit_app(app: tauri::AppHandle) {
@@ -109,14 +113,15 @@ fn export_bindings() {
// 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper // 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper
.error_handling(ErrorHandlingMode::Throw) .error_handling(ErrorHandlingMode::Throw)
.commands(collect_commands![ .commands(collect_commands![
// 应用更新(4 // 应用更新(6
app_version, update_check, update_install, update_thinghk, app_version, update_check, update_install, update_thinghk_apply,
update_thinghk_confirm, update_thinghk_cancel,
// proxy20 // proxy20
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy, 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_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings,
proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info,
proxy_restart, proxy_save_settings, proxy_restart, proxy_save_settings,
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_traffic,
proxy_test_delay, proxy_update_profile, proxy_test_delay, proxy_update_profile,
// quickpanel22 // quickpanel22
quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut, quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut,
@@ -140,8 +145,8 @@ fn export_bindings() {
clipboard_preview_interacted, clipboard_preview_interacted,
// download_engine10,豁免 2 // download_engine10,豁免 2
downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task, downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task,
downloader_resume_task, downloader_remove_task, downloader_get_settings, downloader_resume_task, downloader_cancel_task, downloader_redownload, downloader_remove_task, downloader_get_settings,
downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_focus_window, downloader_inspect, downloader_select_bt_files,
// screenshot22,豁免 3get_fullscreen_bmp / load_cache_raw 返回 ipc::Response、compose_copy 接收 ipc::Request // screenshot22,豁免 3get_fullscreen_bmp / load_cache_raw 返回 ipc::Response、compose_copy 接收 ipc::Request
screenshot_disable_transitions, screenshot_show_overlay, screenshot_register_shortcut, screenshot_disable_transitions, screenshot_show_overlay, screenshot_register_shortcut,
screenshot_unregister_shortcut, screenshot_register_pin_shortcut, screenshot_unregister_shortcut, screenshot_register_pin_shortcut,
@@ -175,12 +180,15 @@ pub fn run() {
.build() .build()
) )
.manage(ProcessManager::new()) .manage(ProcessManager::new())
.manage(ThinghkUpdateState::new())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
quit_app, quit_app,
app_version, app_version,
update_check, update_check,
update_install, update_install,
update_thinghk, update_thinghk_apply,
update_thinghk_confirm,
update_thinghk_cancel,
process_start, process_start,
process_stop, process_stop,
process_status, process_status,
@@ -200,6 +208,7 @@ pub fn run() {
proxy_status, proxy_status,
proxy_start, proxy_start,
proxy_stop, proxy_stop,
proxy_traffic,
proxy_restart, proxy_restart,
proxy_version, proxy_version,
proxy_get_proxies, proxy_get_proxies,
@@ -237,12 +246,15 @@ pub fn run() {
osd_set_topmost, osd_set_topmost,
osd_start_drag_watch, osd_start_drag_watch,
osd_start_topmost_watch, osd_start_topmost_watch,
osd_start_game_watch,
osd_stop_watch, osd_stop_watch,
downloader_get_tasks, downloader_get_tasks,
downloader_add_task, downloader_add_task,
downloader_check_url, downloader_check_url,
downloader_pause_task, downloader_pause_task,
downloader_resume_task, downloader_resume_task,
downloader_cancel_task,
downloader_redownload,
downloader_remove_task, downloader_remove_task,
downloader_get_settings, downloader_get_settings,
downloader_save_settings, downloader_save_settings,
@@ -250,6 +262,9 @@ pub fn run() {
downloader_get_extension_info, downloader_get_extension_info,
downloader_open_dir, downloader_open_dir,
downloader_open_url, downloader_open_url,
downloader_focus_window,
downloader_inspect,
downloader_select_bt_files,
clipboard_get_history, clipboard_get_history,
clipboard_get_pinned, clipboard_get_pinned,
clipboard_search, clipboard_search,
+9 -1
View File
@@ -4,7 +4,7 @@ use tauri::{AppHandle, State};
use super::system_proxy::{clear_system_proxy_windows, get_system_proxy_windows, set_system_proxy_windows}; use super::system_proxy::{clear_system_proxy_windows, get_system_proxy_windows, set_system_proxy_windows};
use super::{ use super::{
KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus, KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot,
}; };
use crate::process_manager::{ProcessInfo, ProcessManager, ProcessStatus}; use crate::process_manager::{ProcessInfo, ProcessManager, ProcessStatus};
@@ -238,6 +238,14 @@ pub async fn proxy_get_connections(
state.get_connections().await 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] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn proxy_close_connection( pub async fn proxy_close_connection(
+63 -4
View File
@@ -17,11 +17,11 @@ mod types;
pub use autoswitch::{pick_best, start_auto_switch_loop}; pub use autoswitch::{pick_best, start_auto_switch_loop};
pub use pseudo::is_pseudo_node; pub use pseudo::is_pseudo_node;
pub use system_proxy::get_system_proxy_windows; pub use system_proxy::get_system_proxy_windows;
pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus}; pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot};
pub use commands::{ pub use commands::{
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy, 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_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_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_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, proxy_start, proxy_status, proxy_stop, proxy_test_delay, proxy_update_profile, proxy_version,
}; };
@@ -45,10 +45,19 @@ struct SettingsCacheEntry {
settings: ProxySettings, settings: ProxySettings,
} }
/// 流量速率差分基线:记录上次采样的会话总量与时刻,用于计算实时速率
struct TrafficBaseline {
download_total: u64,
upload_total: u64,
at: Instant,
}
pub struct MihomoManager { pub struct MihomoManager {
root: PathBuf, root: PathBuf,
client: Client, client: Client,
settings_cache: Mutex<Option<SettingsCacheEntry>>, settings_cache: Mutex<Option<SettingsCacheEntry>>,
/// 流量速率差分基线:记录上次采样总量与时刻,由两次 /connections 总量差异计算实时速率
traffic_baseline: Mutex<Option<TrafficBaseline>>,
/// 内核安装/更新的取消标志(前端「停止下载」置位,下载循环轮询后中止) /// 内核安装/更新的取消标志(前端「停止下载」置位,下载循环轮询后中止)
kernel_cancel: Arc<AtomicBool>, kernel_cancel: Arc<AtomicBool>,
/// 取消唤醒通道:让停滞在流式读取(stream.next 最多等 30s)中的下载立即感知取消, /// 取消唤醒通道:让停滞在流式读取(stream.next 最多等 30s)中的下载立即感知取消,
@@ -75,6 +84,7 @@ impl MihomoManager {
.build() .build()
.unwrap_or_else(|_| Client::new()), .unwrap_or_else(|_| Client::new()),
settings_cache: Mutex::new(None), settings_cache: Mutex::new(None),
traffic_baseline: Mutex::new(None),
kernel_cancel: Arc::new(AtomicBool::new(false)), kernel_cancel: Arc::new(AtomicBool::new(false)),
kernel_cancel_tx: tokio::sync::watch::channel(false).0, kernel_cancel_tx: tokio::sync::watch::channel(false).0,
install_confirm: std::sync::Mutex::new(None), install_confirm: std::sync::Mutex::new(None),
@@ -308,7 +318,15 @@ impl MihomoManager {
/// 手动启动与 App 自启共用,保证设置语义一致(mihomo 运行期间自动跟随系统代理)。 /// 手动启动与 App 自启共用,保证设置语义一致(mihomo 运行期间自动跟随系统代理)。
pub fn apply_auto_system_proxy(&self) { pub fn apply_auto_system_proxy(&self) {
let settings = self.load_settings(); let settings = self.load_settings();
if settings.auto_system_proxy && !settings.system_proxy && !system_proxy::get_system_proxy_windows() { 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); let addr = format!("127.0.0.1:{}", settings.mixed_port);
if let Err(e) = system_proxy::set_system_proxy_windows(&addr) { if let Err(e) = system_proxy::set_system_proxy_windows(&addr) {
crate::logger::log_warn("mihomo", &format!("自动开启系统代理失败: {}", e)); crate::logger::log_warn("mihomo", &format!("自动开启系统代理失败: {}", e));
@@ -318,7 +336,6 @@ impl MihomoManager {
s.system_proxy = true; s.system_proxy = true;
let _ = self.save_settings(&s); let _ = self.save_settings(&s);
} }
}
/// 应用启动时检查是否需要自动启动 mihomo 和系统代理 /// 应用启动时检查是否需要自动启动 mihomo 和系统代理
pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) { pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) {
@@ -450,6 +467,48 @@ impl MihomoManager {
self.api_get("/connections").await 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> { pub async fn close_connection(&self, id: &str) -> Result<(), String> {
self.api_request( self.api_request(
reqwest::Method::DELETE, reqwest::Method::DELETE,
+16
View File
@@ -124,6 +124,22 @@ pub struct ProxyStatus {
pub restart_count: 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 /// - stage: downloading | extracting | replacing | done | error
/// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示) /// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示)
+31 -1
View File
@@ -228,6 +228,8 @@ pub fn check_and_relaunch_if_needed(app_data_dir: &std::path::Path) -> bool {
pub struct KernelStatus { pub struct KernelStatus {
pub ready: bool, pub ready: bool,
pub is_admin: bool, pub is_admin: bool,
/// PawnIO 驱动是否已安装;旧版内核无此字段,Option 兼容
pub pawn_io_installed: Option<bool>,
pub uptime_ms: f64, pub uptime_ms: f64,
pub group_count: u32, pub group_count: u32,
pub sensor_count: u32, pub sensor_count: u32,
@@ -370,7 +372,9 @@ impl MonitorKernel {
self.root.join("hardware-config.json") self.root.join("hardware-config.json")
} }
/// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制 /// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制
/// 同时把 PawnIO_setup.exe(可选资源)复制过去——内核提权启动时会静默安装它,
/// 作为 WinRing0 被系统/杀软拦截时读取温度/频率的替代驱动。
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<MonitorKernelInfo, String> { pub fn prepare_kernel(&self, app: &AppHandle) -> Result<MonitorKernelInfo, String> {
let kernel = self.kernel_path(); let kernel = self.kernel_path();
if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) { if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
@@ -385,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 { Ok(MonitorKernelInfo {
path: kernel.to_string_lossy().to_string(), path: kernel.to_string_lossy().to_string(),
exists: kernel.exists(), exists: kernel.exists(),
@@ -489,12 +511,20 @@ impl MonitorKernel {
Ok(resp) if resp.status().is_success() => { Ok(resp) if resp.status().is_success() => {
match resp.json::<KernelStatus>().await { match resp.json::<KernelStatus>().await {
Ok(s) if s.ready => { 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( let _ = app.emit(
crate::constants::events::MONITOR_READY, crate::constants::events::MONITOR_READY,
serde_json::json!({ serde_json::json!({
"isAdmin": s.is_admin, "isAdmin": s.is_admin,
"sensorCount": s.sensor_count, "sensorCount": s.sensor_count,
"providers": s.providers, "providers": s.providers,
"pawnIoInstalled": s.pawn_io_installed,
}), }),
); );
return Ok(()); return Ok(());
+155 -5
View File
@@ -15,9 +15,12 @@ use tauri::{AppHandle, Emitter};
static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new(); static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
/// 任务栏覆盖监视线程停止标志 /// 任务栏覆盖监视线程停止标志
static TOPMOST_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new(); static TOPMOST_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
/// 游戏全屏监视线程停止标志
static GAME_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
/// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏) /// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏)
static DRAG_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None); static DRAG_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
static TOPMOST_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> { fn drag_stop() -> &'static Arc<AtomicBool> {
DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true))) DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
@@ -27,6 +30,10 @@ fn topmost_stop() -> &'static Arc<AtomicBool> {
TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true))) TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
} }
fn game_stop() -> &'static Arc<AtomicBool> {
GAME_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
}
/// 停止右键拖动监视线程并等待其退出(标志置位后线程最迟一个轮询周期退出) /// 停止右键拖动监视线程并等待其退出(标志置位后线程最迟一个轮询周期退出)
fn stop_drag_thread() { fn stop_drag_thread() {
drag_stop().store(true, Ordering::SeqCst); drag_stop().store(true, Ordering::SeqCst);
@@ -51,18 +58,35 @@ fn stop_topmost_thread() {
} }
} }
/// 停止游戏全屏监视线程并等待其退出
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)] #[cfg(windows)]
mod win_api { mod win_api {
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use windows_sys::Win32::Foundation::{POINT, RECT}; 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::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_RBUTTON};
use windows_sys::Win32::UI::WindowsAndMessaging::{ use windows_sys::Win32::UI::WindowsAndMessaging::{
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetWindowLongW,
GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos, GetWindowRect, GetWindowThreadProcessId, SendMessageW, SetWindowLongPtrW, SetWindowPos,
GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, GWL_EXSTYLE, GWL_STYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE,
SWP_NOSIZE, SWP_NOZORDER, SWP_SHOWWINDOW, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE,
WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT,
}; };
/// 供模块外全屏判定使用的窗口样式常量(pub re-export
pub use windows_sys::Win32::UI::WindowsAndMessaging::WS_CAPTION;
/// windows-sys 的 HWND 类型别名(isize /// windows-sys 的 HWND 类型别名(isize
pub type Hwnd = isize; pub type Hwnd = isize;
@@ -165,6 +189,10 @@ mod win_api {
} else { } else {
HWND_NOTOPMOST HWND_NOTOPMOST
}; };
// 注意:不传 SWP_SHOWWINDOW,仅调整 Z 序,绝不改变窗口可见性。
// 否则当 OSD 被 .hide() 隐藏后,任务栏覆盖监视线程在系统 UI 前景切换时
// (点击任务栏/托盘关闭主界面、打开托盘菜单)会重新显示已隐藏的 OSD,
// 表现为"托盘关闭 OSD 无效 / 关闭主界面后 OSD 又出现"。
SetWindowPos( SetWindowPos(
hwnd, hwnd,
insert_after, insert_after,
@@ -172,7 +200,7 @@ mod win_api {
0, 0,
0, 0,
0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
); );
} }
} }
@@ -221,6 +249,41 @@ mod win_api {
| "Windows.UI.Shell.ShellFlyoutWindow" // Win11 Shell 弹出 | "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 /// 应用 OSD 悬浮窗的原生样式(NoActivate + ToolWindow
@@ -365,11 +428,98 @@ pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> {
Ok(()) 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 监视线程 /// 停止所有 OSD 监视线程
#[tauri::command] #[tauri::command]
pub fn osd_stop_watch() { pub fn osd_stop_watch() {
stop_drag_thread(); stop_drag_thread();
stop_topmost_thread(); stop_topmost_thread();
stop_game_thread();
} }
/// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠) /// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠)
+5
View File
@@ -11,6 +11,11 @@ use tauri::{AppHandle, Emitter, Manager};
// CREATE_NO_WINDOW = 0x08000000,阻止子进程创建新的控制台窗口 // CREATE_NO_WINDOW = 0x08000000,阻止子进程创建新的控制台窗口
#[cfg(windows)] #[cfg(windows)]
pub const CREATE_NO_WINDOW: u32 = 0x08000000; 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 相关常量,用于异常退出时自动清理子进程 // Windows Job Object 相关常量,用于异常退出时自动清理子进程
#[cfg(windows)] #[cfg(windows)]
+24
View File
@@ -505,11 +505,35 @@ pub fn quickpanel_run_custom_command(command: String, args: Vec<String>) -> Resu
/// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口) /// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
/// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。 /// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
/// - 控制台类交互程序(cmd/powershell/pwsh)额外设置 CREATE_NEW_CONSOLE
/// 否则从 GUI 宿主启动时无可见控制台窗口(表现为"点击没反应")。
/// - .msc 控制台文件(如 devmgmt.msc)不可被 CreateProcess 直接执行,
/// 改由 mmc 打开(路径解析到 System32,不受当前工作目录影响)。
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn quickpanel_run_system_command(command: String, args: Vec<String>) -> Result<(), String> { 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); let mut cmd = std::process::Command::new(&command);
cmd.args(&args); 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))?; cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?;
Ok(()) Ok(())
} }
+118 -91
View File
@@ -2,18 +2,65 @@
//! 更新源为自建 Gitea`https://gitea.atie.fun/LFeng/Thing` 的 release 资产。 //! 更新源为自建 Gitea`https://gitea.atie.fun/LFeng/Thing` 的 release 资产。
//! - 便携版(无 unins000.exe 且不在 Program Files):下载新 thing.exe → update.bat 覆盖重启 //! - 便携版(无 unins000.exe 且不在 Program Files):下载新 thing.exe → update.bat 覆盖重启
//! - 安装版(NSIS):下载新 setup.exe → 提权静默安装 /S //! - 安装版(NSIS):下载新 setup.exe → 提权静默安装 /S
//! - ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖 {app_data}/monitor/cores/ThingHK.exe //! - ThingHK 内核:下载由前端下载模块完成 → apply 命令 need_stop 等待确认 → 解压覆盖
//! mihomo 内核更新继续复用代理模块已有的 GitHub 下载机制,不在此模块处理。 //! {app_data}/monitor/cores/ThingHK.exe(与代理模块 mihomo 内核更新同模式)
use futures_util::StreamExt;
use serde::Serialize; use serde::Serialize;
use specta::Type; use specta::Type;
use std::fs; use std::fs;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{atomic::{AtomicBool, Ordering}, Mutex};
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::{oneshot, watch};
use crate::constants::events::UPDATE_PROGRESS; use crate::constants::events::UPDATE_PROGRESS;
/// 用户主动取消 ThingHK 更新的标记错误信息(前端据此静默处理,不弹错误 toast)
const THINGHK_UPDATE_CANCELLED: &str = "更新已取消";
/// ThingHK 内核更新的跨命令状态:apply 过程中 need_stop 阶段等待前端确认。
/// 与 MihomoManager 的 install_confirm/kernel_cancel 同构。
pub struct ThinghkUpdateState {
/// need_stop 等待阶段的确认通道(前端调 confirm 命令时唤醒 apply 继续)
confirm_tx: Mutex<Option<oneshot::Sender<()>>>,
/// 取消标志 + 唤醒通道(前端调 cancel 命令时置位,apply 等待循环立即返回)
cancel_flag: AtomicBool,
cancel_tx: watch::Sender<bool>,
cancel_rx: watch::Receiver<bool>,
}
impl ThinghkUpdateState {
pub fn new() -> Self {
let (tx, rx) = watch::channel(false);
Self {
confirm_tx: Mutex::new(None),
cancel_flag: AtomicBool::new(false),
cancel_tx: tx,
cancel_rx: rx,
}
}
/// 前端已停止监控内核,唤醒 apply 继续解压替换
fn confirm(&self) {
if let Some(tx) = self.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()).take() {
let _ = tx.send(());
}
}
/// 取消更新:置位取消标志并唤醒 apply 等待循环
fn cancel(&self) {
self.cancel_flag.store(true, Ordering::SeqCst);
let _ = self.cancel_tx.send(true);
}
/// 进入新的 apply 流程前复位取消标志
fn reset(&self) {
self.cancel_flag.store(false, Ordering::SeqCst);
let _ = self.cancel_tx.send(false);
*self.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
}
/// 发布仓库(Gitea /// 发布仓库(Gitea
const GITEA_REPO: &str = "LFeng/Thing"; const GITEA_REPO: &str = "LFeng/Thing";
const GITEA_BASE: &str = "https://gitea.atie.fun"; const GITEA_BASE: &str = "https://gitea.atie.fun";
@@ -125,72 +172,8 @@ async fn fetch_latest_release() -> Result<LatestRelease, String> {
}) })
} }
// ---------- 下载 / 解压 ---------- // ---------- 解压 ----------
// ThingHK 内核更新包由前端下载模块负责下载(同 mihomo),此处仅解压替换。
/// 下载文件到 dest,期间通过 UPDATE_PROGRESS 事件上报进度
async fn download_with_progress(app: &AppHandle, url: &str, dest: &Path) -> Result<(), String> {
// 注意:reqwest 的 timeout 是"从连接到响应体读完"的总超时。大文件(如
// thing.exe 10+MB)在慢速网络下 30s 内读不完会被掐断流导致下载到一半失败
// (此前 bug:更新包总在 ~50% 处中断)。这里只限制连接建立 15s,
// 总超时放宽到 10 分钟兜底防止永久悬挂。
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(15))
.timeout(std::time::Duration::from_secs(600))
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
let resp = client
.get(url)
.header("User-Agent", "thing-app")
.send()
.await
.map_err(|e| format!("下载请求失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("下载失败: HTTP {}", resp.status()));
}
let total = resp.content_length();
let mut stream = resp.bytes_stream();
let mut file = fs::File::create(dest).map_err(|e| format!("创建文件失败: {}", e))?;
let mut downloaded: u64 = 0;
let mut last_percent: u8 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("读取下载流失败: {}", e))?;
file.write_all(&chunk).map_err(|e| format!("写入文件失败: {}", e))?;
downloaded += chunk.len() as u64;
let percent = match total {
Some(t) if t > 0 => ((downloaded as f64 / t as f64) * 100.0) as u8,
_ => 0,
};
if percent >= last_percent + 1 {
last_percent = percent;
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "downloading".into(),
percent,
downloaded_bytes: downloaded,
total_bytes: total,
message: format!(
"已下载 {:.2} MB / {:.2} MB",
downloaded as f64 / 1024.0 / 1024.0,
total.unwrap_or(0) as f64 / 1024.0 / 1024.0
),
},
);
}
}
file.flush().map_err(|e| format!("flush 失败: {}", e))?;
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "downloaded".into(),
percent: 100,
downloaded_bytes: downloaded,
total_bytes: total,
message: "下载完成".into(),
},
);
Ok(())
}
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题) /// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> { fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> {
@@ -377,13 +360,21 @@ pub async fn update_install(app: AppHandle, downloaded_path: String) -> Result<(
Ok(()) Ok(())
} }
/// 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件 /// 应用 ThingHK 内核更新:下载阶段已由下载模块完成,本命令仅做
/// need_stop(等待前端停止监控内核并确认)→ 解压 → 替换。
/// 进度通过 UPDATE_PROGRESS 事件上报,前端据 need_stop 弹出确认对话框。
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn update_thinghk(app: AppHandle) -> Result<(), String> { pub async fn update_thinghk_apply(
let result = update_thinghk_inner(&app).await; app: AppHandle,
state: tauri::State<'_, ThinghkUpdateState>,
zip_path: String,
) -> Result<(), String> {
state.reset();
let result = update_thinghk_apply_inner(&app, &state, PathBuf::from(&zip_path)).await;
if let Err(ref e) = result { if let Err(ref e) = result {
// 失败 emit error 阶段避免前端进度卡在最后状态无提示 // 取消是用户主动行为,静默返回即可;其余失败 emit error 阶段避免前端进度卡
if e != THINGHK_UPDATE_CANCELLED {
let _ = app.emit( let _ = app.emit(
UPDATE_PROGRESS, UPDATE_PROGRESS,
UpdateProgress { UpdateProgress {
@@ -395,29 +386,46 @@ pub async fn update_thinghk(app: AppHandle) -> Result<(), String> {
}, },
); );
} }
}
result result
} }
async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> { async fn update_thinghk_apply_inner(
let latest = fetch_latest_release().await?; app: &AppHandle,
let asset = latest state: &ThinghkUpdateState,
.assets zip_path: PathBuf,
.iter() ) -> Result<(), String> {
.find(|a| a.name.starts_with("thing-hk_") && a.name.ends_with(".zip")) if !zip_path.exists() {
.ok_or("未在 release 中找到 ThingHK 内核包".to_string())?; return Err(format!("下载文件不存在: {}", zip_path.display()));
// 停止监控内核(含提权模式的 /shutdown 兜底由前端先停模块),避免 exe 被占用
if let Some(monitor) = app.try_state::<crate::monitor_kernel::MonitorKernel>() {
monitor.stop_subscription(app).await;
} }
if let Some(pm) = app.try_state::<crate::process_manager::ProcessManager>() {
let _ = pm.stop("monitor"); // need_stop:等待前端停止监控内核并确认(exe 被占用会导致覆盖失败)。
// 确认/取消由 confirm/cancel 命令跨命令唤醒(与 mihomo need_stop 同构)。
let _ = app.emit(
UPDATE_PROGRESS,
UpdateProgress {
stage: "need_stop".into(),
percent: 90,
downloaded_bytes: 0,
total_bytes: None,
message: "需要停止监控内核才能继续安装".into(),
},
);
let (tx, mut rx) = oneshot::channel::<()>();
*state.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
let mut cancel_rx = state.cancel_rx.clone();
loop {
if state.cancel_flag.load(Ordering::SeqCst) {
*state.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
return Err(THINGHK_UPDATE_CANCELLED.to_string());
} }
// 等待进程退出释放文件句柄 tokio::select! {
tokio::time::sleep(std::time::Duration::from_millis(600)).await; _ = &mut rx => break,
let temp_dir = std::env::temp_dir().join("thing-update"); _ = cancel_rx.changed() => {}
fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?; }
let zip_path = temp_dir.join(&asset.name); }
download_with_progress(app, &asset.browser_download_url, &zip_path).await?;
// 解压阶段
let _ = app.emit( let _ = app.emit(
UPDATE_PROGRESS, UPDATE_PROGRESS,
UpdateProgress { UpdateProgress {
@@ -428,9 +436,11 @@ async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
message: "正在解压内核...".into(), message: "正在解压内核...".into(),
}, },
); );
let temp_dir = std::env::temp_dir().join("thing-update");
let extract_dir = temp_dir.join("thinghk_extract"); let extract_dir = temp_dir.join("thinghk_extract");
let _ = fs::remove_dir_all(&extract_dir); let _ = fs::remove_dir_all(&extract_dir);
extract_zip(&zip_path, &extract_dir)?; extract_zip(&zip_path, &extract_dir)?;
// 在解压目录中查找 ThingHK.exe // 在解压目录中查找 ThingHK.exe
let exe_path = find_thinghk_exe(&extract_dir).ok_or("内核包中未找到 ThingHK.exe".to_string())?; let exe_path = find_thinghk_exe(&extract_dir).ok_or("内核包中未找到 ThingHK.exe".to_string())?;
let app_data = app let app_data = app
@@ -441,6 +451,7 @@ async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
fs::create_dir_all(&cores_dir).map_err(|e| format!("创建内核目录失败: {}", e))?; fs::create_dir_all(&cores_dir).map_err(|e| format!("创建内核目录失败: {}", e))?;
fs::copy(&exe_path, cores_dir.join("ThingHK.exe")) fs::copy(&exe_path, cores_dir.join("ThingHK.exe"))
.map_err(|e| format!("覆盖内核文件失败(请确认监控模块已停止): {}", e))?; .map_err(|e| format!("覆盖内核文件失败(请确认监控模块已停止): {}", e))?;
// 清理临时文件 // 清理临时文件
let _ = fs::remove_file(&zip_path); let _ = fs::remove_file(&zip_path);
let _ = fs::remove_dir_all(&extract_dir); let _ = fs::remove_dir_all(&extract_dir);
@@ -457,6 +468,22 @@ async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
/// 前端已停止监控内核,确认继续解压替换(唤醒 need_stop 等待)
#[tauri::command]
#[specta::specta]
pub fn update_thinghk_confirm(state: tauri::State<'_, ThinghkUpdateState>) -> Result<(), String> {
state.confirm();
Ok(())
}
/// 取消 ThingHK 内核更新(need_stop 等待阶段有效:唤醒 apply 以「已取消」返回,zip 保留便于重试)
#[tauri::command]
#[specta::specta]
pub fn update_thinghk_cancel(state: tauri::State<'_, ThinghkUpdateState>) -> Result<(), String> {
state.cancel();
Ok(())
}
fn find_thinghk_exe(dir: &Path) -> Option<PathBuf> { fn find_thinghk_exe(dir: &Path) -> Option<PathBuf> {
if let Ok(entries) = fs::read_dir(dir) { if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() { for entry in entries.flatten() {
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "thing", "productName": "thing",
"version": "26.8.2", "version": "26.8.4",
"identifier": "thing.lfeng.me", "identifier": "thing.lfeng.me",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:14210",
"beforeBuildCommand": "bun run build", "beforeBuildCommand": "bun run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
+57 -9
View File
@@ -14,8 +14,8 @@ import { useProcessStore } from '@/stores/processStore'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry' import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module' import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents' import { pendingNewDownload } from '@/lib/trayEvents'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants' import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
import { commands } from '@/lib/bindings' import { commands } from '@/lib/bindings'
const appStore = useAppStore() const appStore = useAppStore()
@@ -104,6 +104,56 @@ const handleSearch = (moduleId: string) => {
handleModuleChange(moduleId) handleModuleChange(moduleId)
} }
// 为单个下载任务创建专属的一次性下载窗口(浏览器扩展发起)。
// label 带 task id 保证同时多个下载时各占一个窗口;对应 capabilities/download-window.json 的 glob "download-window-*"
async function openDownloadWindow(taskId: string) {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const { currentMonitor } = await import('@tauri-apps/api/window')
const label = `${WINDOWS.downloadWindow}-${taskId}`
try {
const existing = await WebviewWindow.getByLabel(label)
if (existing) {
// 已存在:用 Rust 端强制置前(绕过前台锁定,双屏/后台创建也能到前台)
await commands.downloaderFocusWindow(label)
return
}
// 定位到主窗口当前所在显示器的中央偏上
const monitor = await currentMonitor()
const scale = monitor?.scaleFactor ?? 1
const w = 420
const h = 176
const x = Math.round(((monitor?.size.width ?? 1920) / scale - w) / 2)
const y = Math.round(((monitor?.size.height ?? 1080) / scale - h) / 2 * 0.8)
const win = new WebviewWindow(label, {
url: `index.html#download-window?task=${encodeURIComponent(taskId)}`,
title: '下载',
width: w,
height: h,
x,
y,
decorations: false,
transparent: true,
resizable: false,
maximizable: false,
minimizable: true,
shadow: true,
visible: false,
focus: false,
// 默认不置顶、放入任务栏(可最小化,任务栏图标唤出);下载完成时窗口置前提醒。
// 隐藏创建:由 DownloadWindow 贴合内容高度后一次性 show,避免显示后再 resize 闪烁
})
win.once('tauri://error', (e) => console.error('创建下载窗口失败:', e))
// 窗口改为隐藏创建:由 DownloadWindow 在 onMounted 贴合内容高度后一次性 show,
// 避免"先以 176 高度显示、再 resize 到内容高度"造成的闪烁。
// 此处仅保留异常兜底:WebView 加载异常导致 DownloadWindow 未 reveal 时,强制显示。
win.once('tauri://created', () => {
window.setTimeout(() => { void commands.downloaderFocusWindow(label) }, 2500)
})
} catch (e) {
console.error('创建下载窗口失败:', e)
}
}
const getFallbackModule = () => { const getFallbackModule = () => {
const enabledIds = appStore.enabledModules.map(m => m.id) const enabledIds = appStore.enabledModules.map(m => m.id)
const fallback = moduleRegistry.getAllMetas().find( const fallback = moduleRegistry.getAllMetas().find(
@@ -198,14 +248,12 @@ onMounted(async () => {
} }
}) })
) )
// 浏览器扩展新增下载:直接切到下载模块的任务列表页(主窗口已由 Rust 端置前) // 浏览器扩展新增下载:为该任务创建一个专属的一次性下载窗口(不打断主界面)。
// 主窗口本身无需置前,下载进度/完成事件由独立窗口自行监听。
trayUnlisteners.push( trayUnlisteners.push(
await listen(EVENTS.downloadExtensionAdded, () => { await listen<{ id: string }>(EVENTS.downloadExtensionAdded, (e) => {
const enabledIds = appStore.enabledModules.map(m => m.id) if (!e.payload?.id) return
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) { void openDownloadWindow(e.payload.id)
pendingShowDownloadTasks.value = true
handleModuleChange('downloader')
}
}) })
) )
trayUnlisteners.push( trayUnlisteners.push(
+1 -1
View File
@@ -27,7 +27,7 @@ watch(
<template> <template>
<main <main
ref="containerRef" ref="containerRef"
class="flex-1" class="flex-1 min-w-0"
:style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }" :style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }"
> >
<ScrollArea data-main-scroll class="h-full w-full"> <ScrollArea data-main-scroll class="h-full w-full">
+10
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { ref } from "vue"
import { useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -17,10 +18,19 @@ const modelValue = useVModel(props, "modelValue", emits, {
passive: true, passive: true,
defaultValue: props.defaultValue, defaultValue: props.defaultValue,
}) })
// 暴露原生 input 元素与 focus,供父级 `ref="xx"` 后调用 xx.focus()
// (组件默认不转发,ref 拿到的是组件实例,调用 .focus() 会报 "focus is not a function"
const inputEl = ref<HTMLInputElement | null>(null)
defineExpose({
focus: () => inputEl.value?.focus(),
element: () => inputEl.value,
})
</script> </script>
<template> <template>
<input <input
ref="inputEl"
v-model="modelValue" v-model="modelValue"
data-slot="input" data-slot="input"
:class="cn( :class="cn(
+95 -6
View File
@@ -16,8 +16,16 @@ export const commands = {
* 调用返回前会触发应用退出。 * 调用返回前会触发应用退出。
*/ */
updateInstall: (downloadedPath: string) => __TAURI_INVOKE<null>("update_install", { downloadedPath }), updateInstall: (downloadedPath: string) => __TAURI_INVOKE<null>("update_install", { downloadedPath }),
/** 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件 */ /**
updateThinghk: () => __TAURI_INVOKE<null>("update_thinghk"), * 应用 ThingHK 内核更新:下载阶段已由下载模块完成,本命令仅做
* need_stop(等待前端停止监控内核并确认)→ 解压 → 替换。
* 进度通过 UPDATE_PROGRESS 事件上报,前端据 need_stop 弹出确认对话框。
*/
updateThinghkApply: (zipPath: string) => __TAURI_INVOKE<null>("update_thinghk_apply", { zipPath }),
/** 前端已停止监控内核,确认继续解压替换(唤醒 need_stop 等待) */
updateThinghkConfirm: () => __TAURI_INVOKE<null>("update_thinghk_confirm"),
/** 取消 ThingHK 内核更新(need_stop 等待阶段有效:唤醒 apply 以「已取消」返回,zip 保留便于重试) */
updateThinghkCancel: () => __TAURI_INVOKE<null>("update_thinghk_cancel"),
proxyActivateProfile: (id: string) => __TAURI_INVOKE<null>("proxy_activate_profile", { id }), proxyActivateProfile: (id: string) => __TAURI_INVOKE<null>("proxy_activate_profile", { id }),
/** 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。 */ /** 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。 */
proxyApplyKernelUpdate: (zipPath: string) => __TAURI_INVOKE<KernelInfo>("proxy_apply_kernel_update", { zipPath }), proxyApplyKernelUpdate: (zipPath: string) => __TAURI_INVOKE<KernelInfo>("proxy_apply_kernel_update", { zipPath }),
@@ -43,6 +51,7 @@ export const commands = {
proxyStart: () => __TAURI_INVOKE<ProcessInfo>("proxy_start"), proxyStart: () => __TAURI_INVOKE<ProcessInfo>("proxy_start"),
proxyStatus: () => __TAURI_INVOKE<ProxyStatus>("proxy_status"), proxyStatus: () => __TAURI_INVOKE<ProxyStatus>("proxy_status"),
proxyStop: () => __TAURI_INVOKE<null>("proxy_stop"), proxyStop: () => __TAURI_INVOKE<null>("proxy_stop"),
proxyTraffic: () => __TAURI_INVOKE<TrafficSnapshot>("proxy_traffic"),
proxyTestDelay: (name: string, url: string | null, timeout: number | null) => __TAURI_INVOKE<number>("proxy_test_delay", { name, url, timeout }), proxyTestDelay: (name: string, url: string | null, timeout: number | null) => __TAURI_INVOKE<number>("proxy_test_delay", { name, url, timeout }),
proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }), proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }),
/** 读取快速面板设置(快捷键等) */ /** 读取快速面板设置(快捷键等) */
@@ -109,6 +118,10 @@ export const commands = {
/** /**
* 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口) * 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
* 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。 * 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
* - 控制台类交互程序(cmd/powershell/pwsh)额外设置 CREATE_NEW_CONSOLE
* 否则从 GUI 宿主启动时无可见控制台窗口(表现为"点击没反应")。
* - .msc 控制台文件(如 devmgmt.msc)不可被 CreateProcess 直接执行,
* 改由 mmc 打开(路径解析到 System32,不受当前工作目录影响)。
*/ */
quickpanelRunSystemCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_system_command", { command, args }), quickpanelRunSystemCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_system_command", { command, args }),
/** 列出目录下的压缩包文件(供批量解压面板使用)。 */ /** 列出目录下的压缩包文件(供批量解压面板使用)。 */
@@ -198,11 +211,15 @@ export const commands = {
/** 检查 URL 重复性并探测文件信息(添加下载前调用) */ /** 检查 URL 重复性并探测文件信息(添加下载前调用) */
downloaderCheckUrl: (url: string, dir: string | null, headers: { [key in string]: string } | null) => __TAURI_INVOKE<CheckUrlResult>("downloader_check_url", { url, dir, headers }), downloaderCheckUrl: (url: string, dir: string | null, headers: { [key in string]: string } | null) => __TAURI_INVOKE<CheckUrlResult>("downloader_check_url", { url, dir, headers }),
/** 添加下载任务 */ /** 添加下载任务 */
downloaderAddTask: (url: string, filename: string | null, dir: string | null, headers: { [key in string]: string } | null, autoRename: boolean | null) => __TAURI_INVOKE<string>("downloader_add_task", { url, filename, dir, headers, autoRename }), downloaderAddTask: (url: string, filename: string | null, dir: string | null, headers: { [key in string]: string } | null, autoRename: boolean | null, onlyFiles: number[] | null) => __TAURI_INVOKE<string>("downloader_add_task", { url, filename, dir, headers, autoRename, onlyFiles }),
/** 暂停任务 */ /** 暂停任务 */
downloaderPauseTask: (id: string) => __TAURI_INVOKE<null>("downloader_pause_task", { id }), downloaderPauseTask: (id: string) => __TAURI_INVOKE<null>("downloader_pause_task", { id }),
/** 恢复任务 */ /** 恢复任务 */
downloaderResumeTask: (id: string) => __TAURI_INVOKE<null>("downloader_resume_task", { id }), downloaderResumeTask: (id: string) => __TAURI_INVOKE<null>("downloader_resume_task", { id }),
/** 取消任务(置为已取消,清空进度并删除下载文件,但保留记录) */
downloaderCancelTask: (id: string) => __TAURI_INVOKE<null>("downloader_cancel_task", { id }),
/** 重新下载已取消/出错的任务 */
downloaderRedownload: (id: string) => __TAURI_INVOKE<null>("downloader_redownload", { id }),
/** 移除任务 */ /** 移除任务 */
downloaderRemoveTask: (id: string, deleteFiles: boolean | null) => __TAURI_INVOKE<null>("downloader_remove_task", { id, deleteFiles }), downloaderRemoveTask: (id: string, deleteFiles: boolean | null) => __TAURI_INVOKE<null>("downloader_remove_task", { id, deleteFiles }),
/** 获取设置 */ /** 获取设置 */
@@ -213,6 +230,17 @@ export const commands = {
downloaderOpenDir: (path: string) => __TAURI_INVOKE<null>("downloader_open_dir", { path }), downloaderOpenDir: (path: string) => __TAURI_INVOKE<null>("downloader_open_dir", { path }),
/** 用系统默认浏览器打开 URL */ /** 用系统默认浏览器打开 URL */
downloaderOpenUrl: (url: string) => __TAURI_INVOKE<null>("downloader_open_url", { url }), downloaderOpenUrl: (url: string) => __TAURI_INVOKE<null>("downloader_open_url", { url }),
/**
* 将指定 label 的下载窗口显示并强制置为前台。
* Tauri 的 set_focus 在 Windows 上受前台锁定限制(尤其下载窗口由后台进程创建、
* 或创建到非主显示器时更明显),改用原生 SetForegroundWindow + BringWindowToTop
* (模拟 Alt 键重置前台锁定),保证开始/完成下载时窗口能正确定位到前台。
*/
downloaderFocusWindow: (label: string) => __TAURI_INVOKE<null>("downloader_focus_window", { label }),
/** 解析磁力链 / .torrent 文件,返回种子信息(名称 / infohash / 文件列表),供前端做文件勾选 */
downloaderInspect: (input: string) => __TAURI_INVOKE<TorrentInfo>("downloader_inspect", { input }),
/** 磁力任务:元数据解析成功后,用户勾选文件并确认开始下载 */
downloaderSelectBtFiles: (id: string, onlyFiles: number[]) => __TAURI_INVOKE<null>("downloader_select_bt_files", { id, onlyFiles }),
/** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */ /** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }), screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
/** 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返) */ /** 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返) */
@@ -280,6 +308,16 @@ export type ArchiveInfo = {
size: number, size: number,
}; };
/** BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示) */
export type BtFileInfo = {
/** 文件在种子内的索引 */
index: number,
/** 相对种子根目录的路径(如 "sub/file.mkv" */
path: string,
/** 文件大小(字节) */
size: number,
};
/** 前端可见的捕获数据 */ /** 前端可见的捕获数据 */
export type CaptureData = { export type CaptureData = {
pngBase64: string, pngBase64: string,
@@ -372,12 +410,20 @@ export type DeleteResult = {
export type DownloadTask = { export type DownloadTask = {
/** 任务 ID(自增 hex 字符串) */ /** 任务 ID(自增 hex 字符串) */
id: string, id: string,
/** 下载地址 */ /** 下载地址HTTP URL 或磁力链接) */
url: string, url: string,
/** 文件名 */ /** 文件名(HTTP:目标文件名;BT:种子名称) */
filename: string, filename: string,
/** 保存目录(绝对路径) */ /** 保存目录(绝对路径) */
dir: string, dir: string,
/** 协议类型 */
protocol?: TaskProtocol,
/** BT 种子 infohash(协议=BitTorrent 时存在) */
infoHash?: string | null,
/** BT 种子内文件列表(协议=BitTorrent 时存在) */
btFiles?: BtFileInfo[],
/** BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过) */
btMetadataReady?: boolean,
/** 状态 */ /** 状态 */
status: TaskStatus, status: TaskStatus,
/** 文件总大小(字节),0=未知 */ /** 文件总大小(字节),0=未知 */
@@ -420,6 +466,14 @@ export type DownloaderSettings = {
checkDuplicate?: boolean, checkDuplicate?: boolean,
/** 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 */ /** 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 */
useProxy?: boolean, useProxy?: boolean,
/** BitTorrent 上传限速 KB/s0=不限) */
btUploadLimitKb?: number,
/** BitTorrent 下载完成后是否继续做种上传(false=下载完即停止上传) */
btSeedAfterDownload?: boolean,
/** BitTorrent 监听端口(0=自动选择) */
btListenPort?: number,
/** BitTorrent 使用代理下载:开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时降级直连 */
btUseProxy?: boolean,
}; };
/** 重复类型 */ /** 重复类型 */
@@ -604,6 +658,13 @@ export type SpecialLocation = {
args: string[], args: string[],
}; };
/** 任务下载协议类型 */
export type TaskProtocol =
/** HTTP/HTTPS 直链 */
"http" |
/** BitTorrent(磁力链 / .torrent 文件) */
"bittorrent";
/** 任务状态 */ /** 任务状态 */
export type TaskStatus = export type TaskStatus =
/** 排队等待(并发数已满) */ /** 排队等待(并发数已满) */
@@ -615,7 +676,35 @@ export type TaskStatus =
/** 已完成 */ /** 已完成 */
"complete" | "complete" |
/** 错误 */ /** 错误 */
"error"; "error" |
/** 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载) */
"cancelled";
/** 种子信息(inspect 解析结果,供命令返回给前端做文件勾选) */
export type TorrentInfo = {
/** 种子名称 */
name: string,
/** infohashhex 小写字符串) */
infoHash: string,
/** 种子内全部文件总大小(字节) */
totalSize: number,
/** 种子内文件列表 */
files: BtFileInfo[],
};
/** 实时流量快照(由 /connections 的会话总量差分得出实时速率) */
export type TrafficSnapshot = {
/** 本次会话累计下载字节数 */
downloadTotal: number,
/** 本次会话累计上传字节数 */
uploadTotal: number,
/** 实时下载速率(字节/秒) */
downloadSpeed: number,
/** 实时上传速率(字节/秒) */
uploadSpeed: number,
/** 当前活跃连接数 */
activeConnections: number,
};
/** release 中的一个资产 */ /** release 中的一个资产 */
export type UpdateAsset = { export type UpdateAsset = {
+9 -1
View File
@@ -10,6 +10,8 @@ export const WINDOWS = {
osdOverlay: 'osd-overlay', osdOverlay: 'osd-overlay',
screenshotOverlay: 'screenshot-overlay', screenshotOverlay: 'screenshot-overlay',
screenshotPin: 'screenshot-pin', screenshotPin: 'screenshot-pin',
/** 单文件一次性下载窗口前缀,实际 label = `${downloadWindow}-<taskId>` */
downloadWindow: 'download-window',
} as const } as const
/** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */ /** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */
@@ -59,6 +61,10 @@ export const EVENTS = {
osdContentSize: 'osd-content-size', osdContentSize: 'osd-content-size',
osdSystemUiActive: 'osd-system-ui-active', osdSystemUiActive: 'osd-system-ui-active',
osdSystemUiInactive: 'osd-system-ui-inactive', osdSystemUiInactive: 'osd-system-ui-inactive',
/** 前台出现全屏应用(游戏):OSD 应隐藏以避免游戏掉帧 */
osdGameActive: 'osd-game-active',
/** 全屏应用退出前台:OSD 可恢复显示 */
osdGameInactive: 'osd-game-inactive',
osdStartDrag: 'osd-start-drag', osdStartDrag: 'osd-start-drag',
osdEndDrag: 'osd-end-drag', osdEndDrag: 'osd-end-drag',
monitorReady: 'monitor-ready', monitorReady: 'monitor-ready',
@@ -72,7 +78,7 @@ export const EVENTS = {
downloadAdded: 'download-added', downloadAdded: 'download-added',
/** 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表) */ /** 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表) */
downloadRemoved: 'download-removed', downloadRemoved: 'download-removed',
/** 浏览器扩展通过 HTTP API 新增下载(置前主窗口并跳到下载画面 */ /** 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口 */
downloadExtensionAdded: 'download-extension-added', downloadExtensionAdded: 'download-extension-added',
} as const } as const
@@ -94,6 +100,8 @@ export const STORAGE_KEYS = {
quickpanelDeleteFilterFavs: 'thing_quickpanel_delete_filter_favs', quickpanelDeleteFilterFavs: 'thing_quickpanel_delete_filter_favs',
currencyRates: 'thing_quickpanel_currency_rates', currencyRates: 'thing_quickpanel_currency_rates',
monitorOsdConfig: 'thing_monitor_osd_config', monitorOsdConfig: 'thing_monitor_osd_config',
/** 关闭"自动启动监控内核"时暂存的 OSD 开关状态(开启自动启动时据此恢复) */
monitorOsdPending: 'thing_monitor_osd_pending',
monitorOverviewCards: 'thing_monitor_overview_cards', monitorOverviewCards: 'thing_monitor_overview_cards',
screenshotHistory: 'thing_screenshot_history', screenshotHistory: 'thing_screenshot_history',
screenshotPinIndex: 'thing_screenshot_pin_index', screenshotPinIndex: 'thing_screenshot_pin_index',
+12 -4
View File
@@ -9,8 +9,14 @@ const logger = createLogger('main')
// 禁用 WebView 默认右键菜单(桌面应用体验,主窗口与独立窗口共用) // 禁用 WebView 默认右键菜单(桌面应用体验,主窗口与独立窗口共用)
document.addEventListener('contextmenu', (e) => e.preventDefault()) document.addEventListener('contextmenu', (e) => e.preventDefault())
// 良性通知过滤:ResizeObserver 回调引发的布局变化在同一帧内级联时,
// 浏览器会派发此 ErrorEvent(规范定义为"通知"而非异常,无可操作信息)。
// 监控数据每秒刷新、reka-ui 组件挂载时高发,直接忽略避免污染日志。
const BENIGN_RESIZE_OBSERVER_RE = /^ResizeObserver loop (completed with undelivered notifications|limit exceeded)/i
// 全局未捕获异常日志 // 全局未捕获异常日志
window.addEventListener('error', (event) => { window.addEventListener('error', (event) => {
if (event.message && BENIGN_RESIZE_OBSERVER_RE.test(event.message)) return
logger.error(`全局JS错误: ${event.message} @ ${event.filename}:${event.lineno}`) logger.error(`全局JS错误: ${event.message} @ ${event.filename}:${event.lineno}`)
}) })
@@ -31,14 +37,16 @@ const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Pr
['#screenshot-overlay', '截图覆盖层', () => import('./modules/screenshot/ScreenshotOverlay.vue')], ['#screenshot-overlay', '截图覆盖层', () => import('./modules/screenshot/ScreenshotOverlay.vue')],
['#screenshot-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')], ['#screenshot-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')],
['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')], ['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')],
['#download-window', '下载窗口', () => import('./modules/downloader/DownloadWindow.vue')],
] ]
const winHash = window.location.hash const winHash = window.location.hash
// #screenshot-overlay 带窗口号参数(多屏),按前缀匹配;其余精确匹配 // #screenshot-overlay 带窗口号参数(多屏)、#download-window 带 ?task= 参数,按前缀匹配;其余精确匹配
const matched = standaloneWindowApps.find(([hash]) => const matched = standaloneWindowApps.find(([hash]) => {
hash === '#screenshot-overlay' ? winHash.startsWith(hash) : winHash === hash if (hash === '#screenshot-overlay' || hash === '#download-window') return winHash.startsWith(hash)
) return winHash === hash
})
if (matched) { if (matched) {
const [, label, loader] = matched const [, label, loader] = matched
+495
View File
@@ -0,0 +1,495 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
import { LogicalSize } from '@tauri-apps/api/dpi'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { Pause, Play, X, Minus, FolderOpen, AlertCircle, Loader2, XCircle } from '@lucide/vue'
import { commands, type DownloadTask, type Segment, type TaskStatus } from '@/lib/bindings'
import { STORAGE_KEYS } from '@/lib/constants'
import { createLogger } from '@/lib/logger'
const logger = createLogger('download-window')
// ===== 从 URL hash 解析任务 id#download-window?task=<id> =====
const hash = window.location.hash
const taskId = new URLSearchParams(hash.split('?')[1] ?? '').get('task') ?? ''
if (!taskId) logger.warn('下载窗口缺少 task 参数')
const win = getCurrentWindow()
const task = ref<DownloadTask | null>(null)
const loading = ref(true)
const actionError = ref('')
// ===== 进度事件载荷(与 Rust 端 ProgressPayload 对应) =====
interface ProgressPayload {
id: string
completedSize: number
totalSize: number
speed: number
status: TaskStatus
segments: number[]
}
const loadTask = async () => {
try {
const all = await commands.downloaderGetTasks()
task.value = all.find(t => t.id === taskId) ?? null
} catch (e) {
logger.error('获取任务失败: ' + e)
} finally {
loading.value = false
}
// 任务已不存在(被模块/扩展删除):本窗口无存在意义,直接关闭
if (!task.value) {
await win.close()
return
}
// 文件名显示到窗口标题(无边框窗口下仍影响任务栏悬浮标题)
try { await win.setTitle(task.value.filename) } catch { /* 忽略 */ }
}
// ===== 状态派生 =====
const totalSize = computed(() => task.value?.totalSize ?? 0)
const completedSize = computed(() => task.value?.completedSize ?? 0)
const speed = computed(() => task.value?.speed ?? 0)
const status = computed<TaskStatus>(() => task.value?.status ?? 'active')
const progress = computed(() => {
if (!totalSize.value || totalSize.value === 0) return 0
return Math.min(100, Math.round((completedSize.value / totalSize.value) * 100))
})
const segments = computed(() => task.value?.segments ?? [])
const showSegments = computed(() => segments.value.length > 1)
const statusText = computed(() => {
switch (status.value) {
case 'active': return '下载中'
case 'queued': return '等待中'
case 'paused': return '已暂停'
case 'complete': return '已完成'
case 'error': return '出错'
case 'cancelled': return '已取消'
}
})
const statusTone = computed(() => {
if (status.value === 'complete') return 'success'
if (status.value === 'error') return 'destructive'
if (status.value === 'paused' || status.value === 'cancelled') return 'muted'
return 'active'
})
// ===== 格式化 =====
const formatByte = (b: number) => {
if (!b || isNaN(b)) return '0 B'
if (b < 1024) return `${b} B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(2)} MB`
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`
}
const formatTotal = (b: number) => (!b || b === 0 ? '未知' : formatByte(b))
// ===== 分段进度(横向分段条:每段宽度按其长度占整个文件的比例) =====
const segLen = (s: Segment) => s.end - s.start + 1
const totalSpan = computed(() => segments.value.reduce((a, s) => a + segLen(s), 0))
const segPercent = (s: Segment) => {
if (segLen(s) <= 0) return 0
return Math.min(100, Math.round((s.completed / segLen(s)) * 100))
}
// ===== 操作 =====
const errorTip = (e: unknown) => {
actionError.value = typeof e === 'string' ? e : '操作失败'
}
const doPause = async () => {
try { await commands.downloaderPauseTask(taskId) } catch (e) { return errorTip(e) }
await loadTask().catch(() => {})
}
const doResume = async () => {
try { await commands.downloaderResumeTask(taskId) } catch (e) { return errorTip(e) }
await loadTask().catch(() => {})
}
// 取消:二次确认(3 秒内再点一次才真正删除)
const armedCancel = ref(false)
let cancelArmTimer = 0
const doCancel = async () => {
if (!armedCancel.value) {
armedCancel.value = true
window.clearTimeout(cancelArmTimer)
cancelArmTimer = window.setTimeout(() => (armedCancel.value = false), 3000)
return
}
window.clearTimeout(cancelArmTimer)
armedCancel.value = false
try { await commands.downloaderCancelTask(taskId) } catch (e) { return errorTip(e) }
await win.close()
}
const doOpenFolder = async () => {
if (!task.value?.dir) return
try { await commands.downloaderOpenDir(task.value.dir) } catch (e) { errorTip(e) }
}
const doMinimize = () => win.minimize()
const doClose = () => win.close()
// 标题文字按下拖动窗口:data-tauri-drag-region 对文字子元素可能不生效
// (文字层拦截 pointer 事件),改用显式 startDragging(仅左键)
const startDrag = () => {
win.startDragging().catch(() => {})
}
// 只读提示:错误等瞬态信息 3 秒后清除
const clearErrorSoon = () => {
if (!actionError.value) return
window.setTimeout(() => { if (!actionError.value) return; actionError.value = '' }, 3000)
}
// ===== 主题与窗口效果(与主应用同步:主题 + mica/acrylic 效果) =====
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return { theme: s.theme ?? 'system', effect: s.effect ?? 'mica' }
}
} catch { /* 忽略 */ }
return { theme: 'system', effect: 'mica' }
}
/** 判断当前是否应为深色主题(弹窗独立窗口,system 模式用 matchMedia 可靠) */
function resolveIsDark(theme: string): boolean {
if (theme === 'dark') return true
if (theme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
async function applyTheme() {
const root = document.documentElement
const { theme, effect } = readMainTheme()
// 1. 设置窗口原生主题(system → null 跟随系统)
try {
if (theme === 'system') await win.setTheme(null)
else await win.setTheme(theme as 'dark' | 'light')
} catch { /* 忽略 */ }
// 2. 用 matchMedia 判断深浅(不依赖主应用状态)
const isDark = resolveIsDark(theme)
// 3. 设置 DOM classeffect 类供 style.css 变量联动)
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
// 4. 设置窗口效果:mica/acrylic 下卡片透明,普通模式用不透明背景
try {
await win.clearEffects()
if (effect === 'mica') {
await win.setEffects({
effects: [Effect.Mica],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await win.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else if (effect === 'acrylic') {
await win.setEffects({
effects: [Effect.Acrylic],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await win.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else {
await win.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
}
} catch { /* 忽略 */ }
}
// ===== 自动贴合内容高度:消除卡片下方留白 =====
const rootRef = ref<HTMLElement | null>(null)
let lastHeight = 0
let autoResizeObserver: ResizeObserver | null = null
// 仅兜底防窗口过小;正常状态下窗口严格贴合内容高度(既不高出导致底部露白,
// 也不低于 titlebar/loading 所需),由 getBoundingClientRect().height 决定
const MIN_H = 52
const WINDOW_W = 420
const applyAutoHeight = async () => {
if (!rootRef.value) return
try {
if (await win.isMinimized()) return
} catch { /* 忽略 */ }
const h = Math.max(MIN_H, Math.ceil(rootRef.value.getBoundingClientRect().height))
if (Math.abs(h - lastHeight) > 4) {
lastHeight = h
win.setSize(new LogicalSize(WINDOW_W, h)).catch(() => {})
}
}
// ===== 事件监听 =====
let progressUnlisten: UnlistenFn | null = null
let completeUnlisten: UnlistenFn | null = null
let onThemeChange: (() => void) | null = null
// 完成时置前提醒:恢复最小化 + 显示 + 聚焦(窗口从任务栏/后台唤出到最前)。
// 用 Rust 端 downloaderFocusWindow 强制置前,绕过 Windows 前台锁定(纯 setFocus 会被忽略)
const handleComplete = async () => {
await loadTask().catch(() => {})
if (status.value === 'complete') {
try { await commands.downloaderFocusWindow(win.label) } catch { /* 忽略 */ }
}
}
onMounted(async () => {
await applyTheme()
await loadTask()
progressUnlisten = await listen<ProgressPayload>('download-progress', (e) => {
if (e.payload.id !== taskId || !task.value) return
const t = task.value
t.completedSize = e.payload.completedSize
t.totalSize = e.payload.totalSize
t.speed = e.payload.speed
t.status = e.payload.status
if (Array.isArray(e.payload.segments) && t.segments) {
t.segments.forEach((seg, i) => {
const v = e.payload.segments[i]
if (v !== undefined) seg.completed = v
})
}
})
completeUnlisten = await listen<{ id: string }>('download-complete', (e) => {
if (e.payload?.id !== taskId) return
handleComplete()
})
const mq = window.matchMedia('(prefers-color-scheme: dark)')
onThemeChange = () => applyTheme()
mq.addEventListener('change', onThemeChange)
// 内容变化时自动贴合窗口高度(始终保留,覆盖后续加载→数据等状态下的高度变化)
lastHeight = 0
autoResizeObserver = new ResizeObserver(applyAutoHeight)
if (rootRef.value) autoResizeObserver.observe(rootRef.value)
// 首次显示:窗口由 App.vue 隐藏创建,此处先在隐藏态贴合到内容高度,
// 再延迟一小段等待 setSize 生效后一次性 show + 置前,
// 避免"先以初始高度显示、再 resize"造成的尺寸跳变闪烁。
void applyAutoHeight()
window.setTimeout(async () => {
// 隐藏态下多贴合一次,确保尺寸已对内容就位
await applyAutoHeight()
try { await win.show() } catch { /* 忽略 */ }
try { await win.unminimize() } catch { /* 忽略 */ }
void commands.downloaderFocusWindow(win.label).catch(() => {})
}, 120)
})
onUnmounted(() => {
progressUnlisten?.()
completeUnlisten?.()
autoResizeObserver?.disconnect()
if (onThemeChange) window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', onThemeChange)
window.clearTimeout(cancelArmTimer)
})
</script>
<template>
<div ref="rootRef" class="dlw-root">
<!-- 顶部标题栏可拖动文件名作为标题右侧放操作按钮 + 最小化 + 关闭 -->
<div class="dlw-titlebar" data-tauri-drag-region>
<div class="dlw-title-left" data-tauri-drag-region>
<Loader2 v-if="status === 'active'" class="size-3 shrink-0 animate-spin text-primary" />
<XCircle v-else-if="status === 'error'" class="size-3 shrink-0 text-red-500" />
<Pause v-else class="size-3 shrink-0 text-muted-foreground" />
<span v-if="loading" class="text-xs font-medium text-muted-foreground">下载</span>
<span v-else-if="task" class="dlw-title-name" :title="task.filename"
@mousedown.left.prevent="startDrag">{{ task.filename }}</span>
</div>
<div class="dlw-title-right">
<button v-if="status === 'active'" class="dlw-btn" @click="doPause">
<Pause class="size-3" /> 暂停
</button>
<button v-else-if="status === 'paused'" class="dlw-btn" @click="doResume">
<Play class="size-3" /> 继续
</button>
<button v-if="status !== 'complete' && status !== 'error'" class="dlw-btn dlw-btn-danger" @click="doCancel">
{{ armedCancel ? '再点一次取消' : '取消' }}
</button>
<button v-if="status === 'complete'" class="dlw-btn dlw-btn-primary" @click="doOpenFolder">
<FolderOpen class="size-3" /> 打开文件夹
</button>
<button v-else-if="status === 'error' && task?.dir" class="dlw-btn" @click="doOpenFolder">
<FolderOpen class="size-3" /> 打开目录
</button>
<button class="dlw-icon-btn" title="最小化" @click="doMinimize">
<Minus class="size-3.5" />
</button>
<button class="dlw-icon-btn" title="关闭(下载在后台继续)" @click="doClose">
<X class="size-3.5" />
</button>
</div>
</div>
<!-- 主体状态 + 总进度 + 分段条 -->
<div class="dlw-body">
<div v-if="loading" class="dlw-loading">
<Loader2 class="size-5 animate-spin text-muted-foreground" />
</div>
<template v-else-if="task">
<div class="flex items-center justify-between gap-2 text-xs">
<span class="dlw-status-badge" :class="`tone-${statusTone}`">{{ statusText }}</span>
<span class="truncate text-muted-foreground">
{{ formatByte(completedSize) }} / {{ formatTotal(totalSize) }} · {{ progress }}%
<template v-if="status === 'active' && speed > 0"> · {{ formatByte(speed) }}/s</template>
</span>
</div>
<div class="dlw-bar mt-1.5">
<div class="dlw-bar-fill" :style="{ width: progress + '%' }" />
</div>
<!-- 分段进度横向分段条 -->
<div v-if="showSegments" class="mt-2">
<span class="text-[10px] text-muted-foreground">分段 ({{ segments.length }} 线程)</span>
<div class="dlw-seg-strip mt-1">
<div
v-for="(seg, i) in segments"
:key="i"
class="dlw-seg"
:style="{ width: ((segLen(seg) / totalSpan) * 100).toFixed(2) + '%' }"
:title="`#${i + 1} ${segPercent(seg)}%`"
>
<div class="dlw-seg-fill" :style="{ width: segPercent(seg) + '%' }" />
</div>
</div>
</div>
<div v-if="actionError" class="mt-1.5 truncate text-right text-[10px] text-red-500" @click="clearErrorSoon">
{{ actionError }}
</div>
</template>
<div v-else class="dlw-loading">
<span class="flex items-center text-xs text-muted-foreground">
<AlertCircle class="size-4 mr-1.5" /> 任务已不存在
</span>
</div>
</div>
</div>
</template>
<style scoped>
.dlw-root {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 6px 6px;
overflow: hidden;
}
/* 标题栏 */
.dlw-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 34px;
padding: 0 2px 0 8px;
gap: 6px;
user-select: none;
flex-shrink: 0;
}
.dlw-title-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.dlw-title-name {
font-size: 12px;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dlw-title-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
/* 图标按钮(最小化/关闭) */
.dlw-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 5px;
color: var(--muted-foreground);
}
.dlw-icon-btn:hover { background: var(--accent); color: var(--accent-foreground); }
/* 主体卡片:背景色由 --popup-bg 控制(mica/acrylic 下透明,普通模式不透明)。
flex:1 撑满 titlebar 下方的剩余高度,避免内容(尤其无分段时)比窗口矮导致底部露出
白色/透明的窗口背景区空白。 */
.dlw-body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--popup-bg, transparent);
padding: 9px 11px 9px;
box-shadow: 0 8px 30px rgb(0 0 0 / 0.18);
}
/* 加载/任务缺失等仅有一行的场景,内容垂直居中于撑满的卡片内 */
.dlw-body > .dlw-loading { flex: 1 1 auto; margin: auto; }
.dlw-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 56px;
}
.dlw-status-badge {
flex-shrink: 0;
font-size: 10px;
line-height: 1.6;
padding: 0 8px;
border-radius: 999px;
white-space: nowrap;
}
.dlw-status-badge.tone-active { background: var(--primary); color: var(--primary-foreground); }
.dlw-status-badge.tone-success { background: #10b981; color: #fff; }
.dlw-status-badge.tone-destructive { background: #ef4444; color: #fff; }
.dlw-status-badge.tone-muted { background: var(--muted); color: var(--muted-foreground); }
.dlw-bar { height: 7px; border-radius: 999px; background: var(--muted); overflow: hidden; }
.dlw-bar-fill { height: 100%; border-radius: 999px; background: var(--primary); transition: width 0.2s linear; }
.dlw-seg-strip { display: flex; gap: 2px; height: 7px; }
.dlw-seg { height: 100%; border-radius: 3px; background: var(--muted); overflow: hidden; }
.dlw-seg-fill { height: 100%; background: var(--primary); opacity: 0.75; transition: width 0.2s linear; }
/* 操作按钮(紧凑) */
.dlw-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
font-size: 11px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--foreground);
cursor: pointer;
white-space: nowrap;
}
.dlw-btn:hover { background: var(--accent); color: var(--accent-foreground); }
.dlw-btn-danger { color: #ef4444; }
.dlw-btn-danger:hover { background: rgb(239 68 68 / 0.12); color: #dc2626; }
.dlw-btn-primary { background: var(--primary); color: var(--primary-foreground); border-color: transparent; }
/* hover 时显式保持前景色,避免被上面的 .dlw-btn:hover 覆盖成深色导致黑字黑底不可见 */
.dlw-btn-primary:hover { background: var(--primary); color: var(--primary-foreground); filter: brightness(1.05); }
</style>
+439 -23
View File
@@ -5,7 +5,7 @@ import {
Link2, Loader2, FolderOpen, Copy, Puzzle, Link2, Loader2, FolderOpen, Copy, Puzzle,
CheckCircle2, Clock, Zap, Eye, EyeOff, Search, CheckCircle2, Clock, Zap, Eye, EyeOff, Search,
ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ExternalLink, Globe, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ExternalLink, Globe,
Info Info, XCircle, Square
} from '@lucide/vue' } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -57,6 +57,118 @@ const addDir = ref('')
const addingTask = ref(false) const addingTask = ref(false)
const addDialogOpen = ref(false) const addDialogOpen = ref(false)
// ===== BT 种子文件勾选对话框(异步添加:元数据解析成功后由事件驱动弹出) =====
interface BtSelectEntry {
taskId: string
/** 已勾选的文件索引(默认全选) */
selected: number[]
}
/** 待勾选文件的任务队列(可能多个磁力同时解析完成) */
const btSelectQueue = ref<BtSelectEntry[]>([])
const btSelectOpen = ref(false)
/** 当前展示的勾选入口 */
const btSelectEntry = computed<BtSelectEntry | null>(() => btSelectQueue.value[0] ?? null)
/** 当前勾选入口对应的任务(含文件列表) */
const btSelectTask = computed<DownloadTask | null>(() => {
const e = btSelectEntry.value
if (!e) return null
return store.tasks.find((t) => t.id === e.taskId) ?? null
})
/** 判断单个输入是否为 BT 链接 */
const isBtInput = (input: string): boolean => {
const s = input.trim()
return s.toLowerCase().startsWith('magnet:') || /\.torrent($|\?)/i.test(s)
}
/** 切换某个文件勾选 */
const btnToggleFile = (idx: number) => {
const e = btSelectEntry.value
if (!e) return
const i = e.selected.indexOf(idx)
if (i >= 0) e.selected.splice(i, 1)
else e.selected.push(idx)
}
/** 全选 / 全不选 */
const btnSetAll = (all: boolean) => {
const e = btSelectEntry.value
const task = btSelectTask.value
if (!e || !task) return
e.selected = all ? task.btFiles.map((f) => f.index) : []
}
/** 当前任务是否已全选 */
const btnIsAll = (): boolean => {
const task = btSelectTask.value
const e = btSelectEntry.value
if (!task || !e) return false
return e.selected.length === task.btFiles.length
}
/** 当前已勾选文件总大小 */
const btnSelectedSize = (): number => {
const task = btSelectTask.value
const e = btSelectEntry.value
if (!task || !e) return 0
return task.btFiles
.filter((f) => e.selected.includes(f.index))
.reduce((sum, f) => sum + f.size, 0)
}
/** 收起当前入口,展示队列中的下一个(或关闭) */
const nextBtSelect = () => {
btSelectQueue.value.shift()
btSelectOpen.value = btSelectQueue.value.length > 0
}
/** 磁力元数据就绪回调:刷新任务后把该任务加入勾选队列并弹出 */
const onBtInspectReady = (taskId: string) => {
const task = store.tasks.find((t) => t.id === taskId)
if (!task) return
// 单文件种子无勾选必要:直接全选并开始下载,跳过勾选对话框
if (task.btFiles.length === 1) {
store.selectBtFiles(taskId, [task.btFiles[0].index]).catch((err) => {
toast.error('开始下载失败: ' + err)
})
return
}
btSelectQueue.value.push({
taskId,
selected: task.btFiles.map((f) => f.index) // 默认全选
})
btSelectOpen.value = true
}
/** 确认:设置勾选文件并开始下载,然后处理下一个 */
const onBtFileConfirm = async () => {
const e = btSelectEntry.value
if (!e) return
try {
await store.selectBtFiles(e.taskId, e.selected)
} catch (err) {
toast.error('设置下载文件失败: ' + err)
return
}
nextBtSelect()
}
/** 取消:不下载该任务的勾选,跳过到下一个 */
const onBtFileCancel = async () => {
const e = btSelectEntry.value
if (e) {
// 元数据已就绪且任务仍为 Queued,若不改状态会被下一次 schedule()
// 以"全选默认"自动开始下载;置为 Paused 才真正保持冻结,
// 用户想继续下载时点"继续"即可(恢复为全部文件)。
try {
await store.pauseTask(e.taskId)
} catch (err) {
logger.error('暂停未确认的磁力任务失败: ' + err)
}
}
nextBtSelect()
}
// 扩展密钥显示 // 扩展密钥显示
const showSecret = ref(false) const showSecret = ref(false)
@@ -94,6 +206,7 @@ const STATUS_OPTIONS: { label: string; value: StatusFilter }[] = [
{ label: '等待中', value: 'queued' }, { label: '等待中', value: 'queued' },
{ label: '已暂停', value: 'paused' }, { label: '已暂停', value: 'paused' },
{ label: '已完成', value: 'complete' }, { label: '已完成', value: 'complete' },
{ label: '已取消', value: 'cancelled' },
{ label: '错误', value: 'error' } { label: '错误', value: 'error' }
] ]
@@ -107,6 +220,17 @@ const SORT_OPTIONS: { label: string; value: SortField }[] = [
const running = computed(() => store.status.running) const running = computed(() => store.status.running)
// ===== 工具函数 ===== // ===== 工具函数 =====
/** BT 第 i 个文件已下载字节(progress 事件把每文件进度写入 segments[i].completed */
const btFileDownloaded = (task: DownloadTask, i: number): number =>
task.segments?.[i]?.completed ?? 0
/** BT 第 i 个文件下载百分比 */
const btFileProgress = (task: DownloadTask, i: number): number => {
const f = task.btFiles?.[i]
if (!f || !f.size) return 0
return Math.min(100, Math.round((btFileDownloaded(task, i) / f.size) * 100))
}
const formatSize = (bytes: number): string => { const formatSize = (bytes: number): string => {
if (!bytes || isNaN(bytes)) return '0 B' if (!bytes || isNaN(bytes)) return '0 B'
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
@@ -167,6 +291,8 @@ const getTaskStatusBadge = (task: DownloadTask) => {
return { variant: 'default' as const, text: '已完成', icon: CheckCircle2 } return { variant: 'default' as const, text: '已完成', icon: CheckCircle2 }
case 'error': case 'error':
return { variant: 'destructive' as const, text: '错误', icon: AlertCircle } return { variant: 'destructive' as const, text: '错误', icon: AlertCircle }
case 'cancelled':
return { variant: 'secondary' as const, text: '已取消', icon: XCircle }
default: default:
return { variant: 'outline' as const, text: task.status, icon: AlertCircle } return { variant: 'outline' as const, text: task.status, icon: AlertCircle }
} }
@@ -199,6 +325,45 @@ const handleResume = async (id: string) => {
} }
} }
// ===== 取消下载(参考下载窗口:二次点击确认,3 秒内未二次点击则取消)=====
const cancelArmed = ref<Record<string, boolean>>({})
let cancelArmTimers: Record<string, number> = {}
const handleCancel = (id: string) => {
if (cancelArmed.value[id]) {
// 二次点击:执行取消
window.clearTimeout(cancelArmTimers[id])
delete cancelArmTimers[id]
cancelArmed.value[id] = false
doCancel(id)
} else {
// 首次点击:进入待确认状态
cancelArmed.value[id] = true
window.clearTimeout(cancelArmTimers[id])
cancelArmTimers[id] = window.setTimeout(() => {
cancelArmed.value[id] = false
delete cancelArmTimers[id]
}, 3000)
}
}
const doCancel = async (id: string) => {
try {
await store.cancelTask(id)
toast.success('已取消,下载文件已删除')
} catch (e) {
toast.error('取消失败: ' + e)
}
}
// ===== 重新下载(已取消/出错任务) =====
const handleRedownload = async (id: string) => {
try {
await store.redownload(id)
toast.success('已重新下载')
} catch (e) {
toast.error('重新下载失败: ' + e)
}
}
// ===== 删除任务专用对话框(带"同时删除文件"开关) ===== // ===== 删除任务专用对话框(带"同时删除文件"开关) =====
const removeDialogState = ref<{ const removeDialogState = ref<{
open: boolean open: boolean
@@ -310,27 +475,54 @@ const handleAddDownload = async () => {
return return
} }
// 分离 BT 链接(磁力/种子)与 HTTP 链接
const httpUris: string[] = []
const btUris: string[] = []
for (const uri of uris) {
if (isBtInput(uri)) btUris.push(uri)
else httpUris.push(uri)
}
const dir = addDir.value.trim()
addingTask.value = true addingTask.value = true
try { try {
const dir = addDir.value.trim() || undefined // HTTP 链接走原流程(含重复检查)
// 检查是否启用重复检查 if (httpUris.length > 0) {
const checkEnabled = store.settings?.checkDuplicate ?? true const checkEnabled = store.settings?.checkDuplicate ?? true
if (checkEnabled) { if (checkEnabled) {
// 逐个检查重复
duplicateSuccessCount.value = 0 duplicateSuccessCount.value = 0
await processUrlsWithCheck(uris, dir) processUrlsWithCheck(httpUris, dir || undefined)
} else { } else {
// 直接添加
let successCount = 0 let successCount = 0
for (const uri of uris) { for (const uri of httpUris) {
try { try {
await store.addTask(uri, undefined, dir, undefined, true) await store.addTask(uri, undefined, dir || undefined, undefined, true)
successCount++ successCount++
} catch (e) { } catch (e) {
logger.error(`添加 ${uri} 失败: ` + e) logger.error(`添加 ${uri} 失败: ` + e)
} }
} }
finishAdd(successCount) if (successCount > 0) finishAdd(successCount)
}
}
// BT 链接:异步添加(立即返回,不卡在元数据解析)。后台解析,成功后弹文件勾选。
let btCount = 0
for (const uri of btUris) {
try {
await store.addTask(uri, undefined, dir || undefined, undefined, true)
btCount++
} catch (e) {
toast.error(`添加磁力任务失败:${e}`)
}
}
if (btCount > 0) {
toast.info(`已添加 ${btCount} 个磁力任务,正在后台解析元数据…`)
// 关闭新建下载对话框(任务已出现在列表,解析成功后自动弹文件勾选)
addDialogOpen.value = false
addUriText.value = ''
addDir.value = ''
activeTab.value = 'tasks'
} }
} catch (e) { } catch (e) {
toast.error('添加失败: ' + e) toast.error('添加失败: ' + e)
@@ -498,6 +690,23 @@ const handleSelectDir = async () => {
} }
} }
/** 选择本地 .torrent 种子文件,追加到下载链接输入框(支持一次选多个,每行一个) */
const handleSelectTorrentFile = async () => {
try {
const selected = await openDialog({
multiple: true,
filters: [{ name: '种子文件', extensions: ['torrent'] }]
})
const paths = Array.isArray(selected) ? selected : selected ? [selected] : []
if (paths.length === 0) return
const existing = addUriText.value.trim()
const next = existing ? existing.trimEnd() + '\n' + paths.join('\n') : paths.join('\n')
addUriText.value = next
} catch (e) {
logger.error('选择种子文件失败: ' + e)
}
}
const handleSelectSettingsDir = async () => { const handleSelectSettingsDir = async () => {
try { try {
const selected = await openDialog({ directory: true, multiple: false }) const selected = await openDialog({ directory: true, multiple: false })
@@ -588,6 +797,8 @@ watch(pendingShowDownloadTasks, (v) => {
onMounted(async () => { onMounted(async () => {
await store.init() await store.init()
// 注册磁力元数据就绪回调:解析成功后弹文件勾选对话框
store.setBtInspectReadyHandler(onBtInspectReady)
// 消费托盘菜单"新建下载"标志位(挂载前设置的场景,watcher 尚未生效) // 消费托盘菜单"新建下载"标志位(挂载前设置的场景,watcher 尚未生效)
if (pendingNewDownload.value) { if (pendingNewDownload.value) {
pendingNewDownload.value = false pendingNewDownload.value = false
@@ -601,6 +812,7 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
store.setBtInspectReadyHandler(null)
store.stopEventListeners() store.stopEventListeners()
}) })
@@ -610,7 +822,7 @@ const allTasks = computed<DownloadTask[]>(() => store.tasks)
// 状态栏计数:单次遍历统计各状态任务数(替代模板内 4 次 filter 全量扫描) // 状态栏计数:单次遍历统计各状态任务数(替代模板内 4 次 filter 全量扫描)
const statusCounts = computed(() => { const statusCounts = computed(() => {
const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0 } const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0, cancelled: 0 }
for (const t of allTasks.value) counts[t.status]++ for (const t of allTasks.value) counts[t.status]++
return counts return counts
}) })
@@ -693,8 +905,8 @@ const toggleSortOrder = () => {
</div> </div>
<!-- ===== 下载任务 ===== --> <!-- ===== 下载任务 ===== -->
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 tab-animate"> <TabsContent value="tasks" class="flex-1 mt-4 min-h-0 min-w-0 tab-animate">
<div class="h-full flex flex-col gap-4"> <div class="h-full flex flex-col gap-4 min-w-0">
<!-- 状态栏 --> <!-- 状态栏 -->
<Card class="shrink-0 !py-0 !gap-0"> <Card class="shrink-0 !py-0 !gap-0">
<CardContent class="pl-4 pr-4 py-3"> <CardContent class="pl-4 pr-4 py-3">
@@ -727,6 +939,10 @@ const toggleSortOrder = () => {
<Check class="size-3" /> <Check class="size-3" />
已完成 {{ statusCounts.complete }} 已完成 {{ statusCounts.complete }}
</Badge> </Badge>
<Badge v-if="statusCounts.cancelled > 0" variant="secondary" class="gap-1">
<XCircle class="size-3" />
已取消 {{ statusCounts.cancelled }}
</Badge>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -849,7 +1065,7 @@ const toggleSortOrder = () => {
</div> </div>
<!-- 任务列表 --> <!-- 任务列表 -->
<ScrollArea class="flex-1 min-h-0"> <ScrollArea class="flex-1 min-h-0 min-w-0">
<div v-if="pagedTasks.length === 0" key="empty" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10"> <div v-if="pagedTasks.length === 0" key="empty" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
<CheckCircle2 class="size-16 opacity-30" /> <CheckCircle2 class="size-16 opacity-30" />
<p>暂无符合条件的任务</p> <p>暂无符合条件的任务</p>
@@ -863,10 +1079,15 @@ const toggleSortOrder = () => {
<CardContent class="pl-4 pr-4 py-3"> <CardContent class="pl-4 pr-4 py-3">
<div class="flex items-start justify-between gap-3 mb-3"> <div class="flex items-start justify-between gap-3 mb-3">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1 min-w-0">
<span class="font-medium truncate cursor-default"> <!-- flex-1 + min-w-0 + truncateflex 子项默认 min-width:auto 不收缩
长文件名会把整行撑宽flex-1 让名字占满可用宽度并省略号截断 -->
<span class="min-w-0 flex-1 font-medium truncate cursor-default" :title="getFileName(task)">
{{ getFileName(task) }} {{ getFileName(task) }}
</span> </span>
<Badge v-if="task.protocol === 'bittorrent'" variant="outline" class="shrink-0 gap-1 py-0 px-1.5 text-[10px]">
BT
</Badge>
<Badge <Badge
:variant="getTaskStatusBadge(task).variant" :variant="getTaskStatusBadge(task).variant"
class="shrink-0" class="shrink-0"
@@ -895,7 +1116,7 @@ const toggleSortOrder = () => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent class="max-w-[480px] break-words">{{ task.dir }}</TooltipContent> <TooltipContent class="max-w-[480px] break-words">{{ task.dir }}</TooltipContent>
</Tooltip> </Tooltip>
<span v-if="task.error" class="text-destructive"> <span v-if="task.error" class="text-destructive break-all">
{{ task.error }} {{ task.error }}
</span> </span>
</div> </div>
@@ -927,6 +1148,35 @@ const toggleSortOrder = () => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>继续</TooltipContent> <TooltipContent>继续</TooltipContent>
</Tooltip> </Tooltip>
<!-- 已取消任务只能再次下载 -->
<Tooltip v-if="task.status === 'cancelled'">
<TooltipTrigger as-child>
<Button
size="icon"
variant="outline"
class="size-8 text-primary"
@click="handleRedownload(task.id)"
>
<RefreshCw class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>再次下载</TooltipContent>
</Tooltip>
<!-- 取消下载二次点击确认 -->
<Tooltip v-if="task.status !== 'complete' && task.status !== 'cancelled'">
<TooltipTrigger as-child>
<Button
size="icon"
variant="ghost"
class="size-8 text-destructive hover:text-destructive"
:class="{ 'font-bold': cancelArmed[task.id] }"
@click="handleCancel(task.id)"
>
<XCircle class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ cancelArmed[task.id] ? '再点一次确认取消' : '取消(再点一次确认)' }}</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
@@ -940,7 +1190,7 @@ const toggleSortOrder = () => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>详细信息</TooltipContent> <TooltipContent>详细信息</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip v-if="task.dir"> <Tooltip v-if="task.dir && task.status !== 'cancelled'">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
size="icon" size="icon"
@@ -968,8 +1218,9 @@ const toggleSortOrder = () => {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<Progress :model-value="getProgress(task)" class="h-1.5" /> <!-- 已取消任务不展示进度条与进度进度与文件均已清除 -->
<div class="flex justify-between mt-1 text-xs text-muted-foreground"> <Progress v-if="task.status !== 'cancelled'" :model-value="getProgress(task)" class="h-1.5" />
<div v-if="task.status !== 'cancelled'" class="flex justify-between mt-1 text-xs text-muted-foreground">
<span>{{ getProgress(task) }}%</span> <span>{{ getProgress(task) }}%</span>
<span v-if="task.status === 'active' && task.speed > 0"> <span v-if="task.status === 'active' && task.speed > 0">
{{ formatEta(getEta(task)) }} {{ formatEta(getEta(task)) }}
@@ -1153,6 +1404,60 @@ const toggleSortOrder = () => {
</CardContent> </CardContent>
</Card> </Card>
<!-- BT 专属设置 -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Zap class="size-4 text-primary" />
BitTorrent 设置
</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">上传限速 KB/s0=不限)</Label>
<Input
:model-value="settingsDraft?.btUploadLimitKb ?? 0"
type="number"
min="0"
@update:model-value="(v: string | number) => settingsDraft && (settingsDraft.btUploadLimitKb = parseInt(String(v)) || 0)"
/>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">监听端口(0=自动选择)</Label>
<Input
:model-value="settingsDraft?.btListenPort ?? 0"
type="number"
min="0"
max="65535"
@update:model-value="(v: string | number) => settingsDraft && (settingsDraft.btListenPort = parseInt(String(v)) || 0)"
/>
<p class="text-xs text-muted-foreground">下载完成后继续上传做种需要对外开放连接端口。修改端口后重启应用生效。</p>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label for="bt-proxy" class="cursor-pointer">使用代理下载</Label>
<span class="text-xs text-muted-foreground">开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时自动降级为直连</span>
</div>
<Switch
id="bt-proxy"
:model-value="settingsDraft?.btUseProxy ?? false"
@update:model-value="(v: boolean) => settingsDraft && (settingsDraft.btUseProxy = v)"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label for="bt-seed" class="cursor-pointer">下载完成后继续做种上传</Label>
<span class="text-xs text-muted-foreground">关闭则下载完成后立即停止上传(节省流量,推荐)</span>
</div>
<Switch
id="bt-seed"
:model-value="settingsDraft?.btSeedAfterDownload ?? false"
@update:model-value="(v: boolean) => settingsDraft && (settingsDraft.btSeedAfterDownload = v)"
/>
</div>
</CardContent>
</Card>
<!-- 扩展 API 设置 --> <!-- 扩展 API 设置 -->
<Card> <Card>
<CardHeader> <CardHeader>
@@ -1452,6 +1757,68 @@ const toggleSortOrder = () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<!-- ===== BT 种子文件勾选弹窗(元数据解析成功后弹出) ===== -->
<Dialog v-model:open="btSelectOpen">
<DialogContent class="max-w-xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<ListChecks class="size-4 text-primary" />
选择要下载的文件
</DialogTitle>
<DialogDescription class="text-xs">
<span class="block break-all font-medium">"{{ btSelectTask?.filename }}"</span>
<span class="block mt-0.5">已解析完成,默认全选,可取消不需要的文件。</span>
<span v-if="btSelectQueue.length > 1">(还有 {{ btSelectQueue.length - 1 }} 个任务待选择)</span>
</DialogDescription>
</DialogHeader>
<ScrollArea class="flex-1 min-h-0 pr-3">
<div v-if="btSelectTask && btSelectTask.btFiles.length" class="flex flex-col gap-2 py-2">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground truncate">{{ btSelectTask.btFiles.length }} 个文件</span>
<Button
size="sm"
variant="outline"
class="h-7 px-2 text-xs shrink-0"
@click="btnSetAll(!btnIsAll())"
>
<Check v-if="btnIsAll()" class="size-3" />
<Square v-else class="size-3" />
{{ btnIsAll() ? '全不选' : '全选' }}
</Button>
</div>
<div class="flex flex-col gap-1 max-h-64 overflow-y-auto">
<label
v-for="f in btSelectTask.btFiles"
:key="f.index"
class="flex items-center gap-2 text-xs rounded px-1.5 py-1 hover:bg-muted cursor-pointer"
>
<input
type="checkbox"
class="accent-primary size-3.5"
:checked="btSelectEntry?.selected.includes(f.index)"
@change="btnToggleFile(f.index)"
/>
<span class="truncate flex-1">{{ f.path }}</span>
<span class="text-muted-foreground shrink-0">{{ formatSize(f.size) }}</span>
</label>
</div>
<div class="text-xs text-muted-foreground flex justify-end">
已选 {{ btSelectEntry?.selected.length ?? 0 }}/{{ btSelectTask.btFiles.length }} 文件 · {{ formatSize(btnSelectedSize()) }}
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" @click="onBtFileCancel">取消</Button>
<Button @click="onBtFileConfirm">
<Download class="size-4" />
开始下载
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- ===== 新建下载弹窗 ===== --> <!-- ===== 新建下载弹窗 ===== -->
<Dialog v-model:open="addDialogOpen"> <Dialog v-model:open="addDialogOpen">
<DialogContent class="max-w-lg"> <DialogContent class="max-w-lg">
@@ -1461,7 +1828,7 @@ const toggleSortOrder = () => {
新建下载 新建下载
</DialogTitle> </DialogTitle>
<DialogDescription class="text-xs"> <DialogDescription class="text-xs">
支持 HTTP/HTTPS 直链,每行一个 URL。留空下载目录则使用默认目录。 支持 HTTP/HTTPS 直链、磁力链接(magnet:)与 .torrent 种子文件(本地文件或 http(s) 链接),每行一个。留空下载目录则使用默认目录。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -1471,9 +1838,18 @@ const toggleSortOrder = () => {
<Label class="text-xs">下载链接</Label> <Label class="text-xs">下载链接</Label>
<Textarea <Textarea
v-model="addUriText" v-model="addUriText"
placeholder="https://example.com/file.zip&#10;https://example.com/file2.zip" placeholder="https://example.com/file.zip&#10;magnet:?xt=urn:btih:...&#10;C:\path\to\file.torrent"
class="min-h-[120px] font-mono text-sm [field-sizing:fixed] break-all" class="min-h-[120px] font-mono text-sm [field-sizing:fixed] break-all"
/> />
<Button
size="sm"
variant="outline"
class="h-7 gap-1 px-2 text-xs bg-transparent self-start"
@click="handleSelectTorrentFile"
>
<FolderOpen class="size-3" />
选择本地种子文件
</Button>
</div> </div>
<!-- 下载目录 --> <!-- 下载目录 -->
@@ -1529,7 +1905,7 @@ const toggleSortOrder = () => {
<span class="block font-mono text-xs break-all">{{ duplicateDialogState.url }}</span> <span class="block font-mono text-xs break-all">{{ duplicateDialogState.url }}</span>
<span v-if="duplicateDialogState.result?.existing" class="block mt-2 text-xs"> <span v-if="duplicateDialogState.result?.existing" class="block mt-2 text-xs">
已存在 已存在
<span class="font-medium">{{ duplicateDialogState.result.existing.filename }}</span> <span class="font-medium break-all">{{ duplicateDialogState.result.existing.filename }}</span>
</span> </span>
<span class="block mt-2 text-muted-foreground"> <span class="block mt-2 text-muted-foreground">
选择"仍然下载"将自动重命名追加序号选择"跳过"将不下载此链接 选择"仍然下载"将自动重命名追加序号选择"跳过"将不下载此链接
@@ -1553,7 +1929,9 @@ const toggleSortOrder = () => {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>删除任务</AlertDialogTitle> <AlertDialogTitle>删除任务</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确定要删除任务 "{{ removeDialogState.task ? getFileName(removeDialogState.task) : '' }}" 确定要删除任务
<span class="block font-mono text-xs break-all mt-1">{{ removeDialogState.task ? getFileName(removeDialogState.task) : '' }}</span>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div class="flex items-center justify-between py-2 px-1"> <div class="flex items-center justify-between py-2 px-1">
@@ -1665,6 +2043,44 @@ const toggleSortOrder = () => {
<Separator /> <Separator />
<!-- BT 种子信息(磁力/种子任务) -->
<template v-if="detailTask.protocol === 'bittorrent'">
<div class="flex flex-col gap-2">
<span class="text-xs text-muted-foreground">BT 种子信息</span>
<div class="flex flex-col gap-1 text-xs">
<div class="flex items-start justify-between gap-2">
<span class="text-muted-foreground shrink-0">Infohash</span>
<span class="font-mono break-all">{{ detailTask.infoHash }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">文件数</span>
<span>{{ detailTask.btFiles.length }}</span>
</div>
</div>
<!-- 文件列表 + 单文件进度(progress 事件经 segments 下发每文件已下载字节) -->
<div v-if="detailTask.btFiles.length > 0" class="flex flex-col gap-1.5">
<div
v-for="(f, i) in detailTask.btFiles"
:key="f.index"
class="flex flex-col gap-0.5"
>
<div class="flex items-center justify-between gap-2 text-xs">
<span class="truncate">{{ f.path }}</span>
<span class="text-muted-foreground shrink-0">{{ btFileProgress(detailTask, i) }}%</span>
</div>
<div class="flex items-center gap-2">
<Progress :model-value="btFileProgress(detailTask, i)" class="h-1" />
<span class="text-muted-foreground text-[10px] shrink-0">
{{ formatSize(btFileDownloaded(detailTask, i)) }} / {{ formatSize(f.size) }}
</span>
</div>
</div>
</div>
</div>
</template>
<Separator />
<!-- 文件大小信息 --> <!-- 文件大小信息 -->
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
+58 -1
View File
@@ -6,7 +6,7 @@ import {
ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks, ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks,
Monitor as MonitorIcon, GripVertical, SlidersHorizontal, Monitor as MonitorIcon, GripVertical, SlidersHorizontal,
Eye, EyeOff, MousePointerClick, Plus, PencilLine, Eye, EyeOff, MousePointerClick, Plus, PencilLine,
CircuitBoard, BatteryFull, CircuitBoard, BatteryFull, Gamepad2,
} from '@lucide/vue' } from '@lucide/vue'
import type { LucideIcon } from '@lucide/vue' import type { LucideIcon } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -1109,8 +1109,29 @@ function removeOsdItem(key: string) {
} }
} }
/** 开启 OSD 但监控内核未运行时的提示对话框开关 */
const osdNeedsKernelOpen = ref(false)
/** 从对话框启动内核:先直接开启 OSD(立即显示),内核在后台自行启动 */
async function startKernelForOsd() {
osdNeedsKernelOpen.value = false
// 直接开启 OSD(绕过内联内核检查),store 的 overlayEnabled watch 立即创建/显示悬浮窗
osdConfig.value.overlayEnabled = true
saveOsdConfigDebounced(osdConfig.value)
// 后台启动内核(不 await,不阻塞 OSD 显示;内核就绪后 OSD 自动填充数据)
store.start().catch(() => {})
}
/** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */ /** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */
function updateOsdConfig(field: keyof OsdConfig, value: unknown) { function updateOsdConfig(field: keyof OsdConfig, value: unknown) {
// 开启 OSD 时,若监控内核未运行,弹出提示并取消本次开启(OSD 依赖内核提供数据)
if (field === 'overlayEnabled' && value === true) {
const kernelRunning = store.status?.running === true
if (!kernelRunning) {
osdNeedsKernelOpen.value = true
return
}
}
;(osdConfig.value as unknown as Record<string, unknown>)[field] = value ;(osdConfig.value as unknown as Record<string, unknown>)[field] = value
saveOsdConfigDebounced(osdConfig.value) saveOsdConfigDebounced(osdConfig.value)
} }
@@ -2135,6 +2156,20 @@ watch(() => store.status?.ready, (ready, prev) => {
@update:model-value="updateOsdConfig('clickThrough', Boolean($event))" @update:model-value="updateOsdConfig('clickThrough', Boolean($event))"
/> />
</div> </div>
<!-- 游戏全屏自动隐藏 -->
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label class="text-sm flex items-center gap-1.5 cursor-pointer">
<Gamepad2 class="size-3.5 text-muted-foreground" />
游戏全屏时自动隐藏
</Label>
<span class="text-[11px] text-muted-foreground">检测到全屏应用游戏前台时隐藏悬浮窗退出后自动恢复避免游戏掉帧</span>
</div>
<Switch
:model-value="osdConfig.gameAutoHide"
@update:model-value="updateOsdConfig('gameAutoHide', Boolean($event))"
/>
</div>
</div> </div>
<Separator /> <Separator />
@@ -2404,6 +2439,28 @@ watch(() => store.status?.ready, (ready, prev) => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<!-- 开启 OSD 但监控内核未运行提示 -->
<Dialog v-model:open="osdNeedsKernelOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<TriangleAlert class="size-4 text-amber-500" />
需先启动监控内核
</DialogTitle>
<DialogDescription>
OSD 悬浮窗依赖监控内核提供数据。当前内核未运行,请先启动内核后再开启 OSD 显示。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="osdNeedsKernelOpen = false">取消</Button>
<Button @click="startKernelForOsd">
<Play class="size-3.5" />
启动内核
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- OSD 显示项选择 Dialog --> <!-- OSD 显示项选择 Dialog -->
<Dialog v-model:open="osdPickDialogOpen"> <Dialog v-model:open="osdPickDialogOpen">
<DialogContent class="max-w-lg"> <DialogContent class="max-w-lg">
+27
View File
@@ -444,6 +444,23 @@ function scheduleMeasure() {
}, 50) }, 50)
} }
/** 内容尺寸监视器:内核未启动时显示占位符(如 '--' 很短),数据就绪后实际值更长,
* 导致 osd-bar 变宽。仅靠配置变化触发测量不够——需监听 osd-bar 尺寸变化,
* 任何内容变宽/变高(数据加载、配置变更)都自动重测上报,驱动主窗口放大。 */
let resizeObserver: ResizeObserver | null = null
function observeBarSize() {
resizeObserver?.disconnect()
resizeObserver = null
const root = osdRootEl.value
const bar = root?.querySelector<HTMLElement>('.osd-bar')
if (!bar) return
resizeObserver = new ResizeObserver(() => scheduleMeasure())
resizeObserver.observe(bar)
}
// 根容器就绪后(config 到达、v-if 挂载)开始监视 osd-bar 尺寸变化
watch(osdRootEl, () => { void nextTick(observeBarSize) })
// ===== 应用鼠标穿透 ===== // ===== 应用鼠标穿透 =====
// 同时调用 Tauri setIgnoreCursorEvents(处理 webview2 子窗口)和 Rust WS_EX_TRANSPARENT(处理原生窗口) // 同时调用 Tauri setIgnoreCursorEvents(处理 webview2 子窗口)和 Rust WS_EX_TRANSPARENT(处理原生窗口)
// 仅靠原生 WS_EX_TRANSPARENT 不足:Tauri 窗口包含 webview2 子窗口,需两者都设置才能完全穿透 // 仅靠原生 WS_EX_TRANSPARENT 不足:Tauri 窗口包含 webview2 子窗口,需两者都设置才能完全穿透
@@ -495,6 +512,13 @@ onMounted(async () => {
console.error('[OSD] 启动置顶监视失败:', e) console.error('[OSD] 启动置顶监视失败:', e)
} }
// 启动游戏全屏监视(前台全屏应用时通知主窗口隐藏 OSD,避免游戏掉帧)
try {
await invoke('osd_start_game_watch')
} catch (e) {
console.error('[OSD] 启动游戏全屏监视失败:', e)
}
// 监听主窗口推送的 OSD 配置(低频通道) // 监听主窗口推送的 OSD 配置(低频通道)
unlistenFns.push(await listen<OsdStatePayload>(EVENTS.osdStateUpdate, (e) => { unlistenFns.push(await listen<OsdStatePayload>(EVENTS.osdStateUpdate, (e) => {
config.value = e.payload.config config.value = e.payload.config
@@ -537,6 +561,9 @@ onUnmounted(() => {
unlistenFns.forEach(fn => fn()) unlistenFns.forEach(fn => fn())
// 停止监视线程 // 停止监视线程
invoke('osd_stop_watch').catch(() => {}) invoke('osd_stop_watch').catch(() => {})
// 断开内容尺寸监视器
resizeObserver?.disconnect()
resizeObserver = null
}) })
</script> </script>
+357 -3
View File
@@ -2,7 +2,8 @@
import { import {
Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2, Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2,
Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks, Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks,
Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud,
Waypoints, ArrowDown, ArrowUp, Activity, X
} from '@lucide/vue' } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -10,7 +11,7 @@ import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { appDataDir } from '@tauri-apps/api/path' import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener' import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore' import { useProxyStore, type ProxyNode, type ProxyConnection } from '@/stores/proxyStore'
import { useModuleTabs } from '@/lib/use-module-tabs' import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore' import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger' import { createLogger } from '@/lib/logger'
@@ -84,6 +85,7 @@ const activeTab = ref('overview')
const tabsStore = useModuleTabsStore() const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs('proxy', activeTab, [ const tabsListRef = useModuleTabs('proxy', activeTab, [
{ value: 'overview', label: '概览' }, { value: 'overview', label: '概览' },
{ value: 'connections', label: '连接' },
{ value: 'proxies', label: '节点' }, { value: 'proxies', label: '节点' },
{ value: 'profiles', label: '订阅' }, { value: 'profiles', label: '订阅' },
{ value: 'settings', label: '设置' } { value: 'settings', label: '设置' }
@@ -141,9 +143,179 @@ const accordionValue = ref<string>('')
// 进程状态轮询 // 进程状态轮询
let statusTimer: ReturnType<typeof setInterval> | null = null let statusTimer: ReturnType<typeof setInterval> | null = null
let trafficTimer: ReturnType<typeof setInterval> | null = null
let connTimer: ReturnType<typeof setInterval> | null = null
/** 字节 → 人类可读大小(B / KB / MB / GB / TB */
function fmtBytes(v: number): string {
if (!v && v !== 0) return '--'
if (v < 1024) return v + ' B'
const units = ['KB', 'MB', 'GB', 'TB']
let n = v / 1024
let u = 0
while (n >= 1024 && u < units.length - 1) {
n /= 1024
u++
}
return (n >= 100 ? n.toFixed(0) : n >= 10 ? n.toFixed(1) : n.toFixed(2)) + ' ' + units[u]
}
/** 速率显示(字节/秒 → /s) */
function fmtSpeed(v: number): string {
return fmtBytes(v) + '/s'
}
const running = computed(() => store.status.running) const running = computed(() => store.status.running)
// ===== 连接页签 =====
/** 当前连接列表(store.connections 可能为 null → 视为空) */
const connList = computed(() => store.connections ?? [])
const connFilter = ref('')
/** 内网/国内/国外 一键过滤('all' = 全部) */
const connScopeFilter = ref<'all' | ConnScope>('all')
/** 一键过滤选项 */
const scopeFilterOptions: { value: 'all' | ConnScope; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'direct', label: '国内' },
{ value: 'proxy', label: '国外' }
]
/** 顶部下载/上传速率(复用实时流量快照的整体速率) */
const connTotalDownloadSec = computed(() => store.traffic?.downloadSpeed ?? 0)
const connTotalUploadSec = computed(() => store.traffic?.uploadSpeed ?? 0)
/** 命中规则总数 = 活跃连接数(每条连接命中一条规则) */
const ruleHitCount = computed(() => connList.value.length)
/** 按规则聚合当前连接,便于观察哪些规则被频繁命中 */
const ruleHits = computed(() => {
const map = new Map<string, number>()
for (const c of connList.value) {
const r = c.rule || 'DIRECT'
map.set(r, (map.get(r) ?? 0) + 1)
}
return [...map.entries()]
.map(([rule, count]) => ({ rule, count }))
.sort((a, b) => b.count - a.count)
})
/** 按内网/国内/国外、通用关键词过滤后的连接 */
const filteredConnections = computed(() => {
const q = connFilter.value.trim().toLowerCase()
return connList.value.filter((c) => {
if (connScopeFilter.value !== 'all' && connScopeOf(c) !== connScopeFilter.value) return false
if (!q) return true
const process = (c.metadata?.process ?? '').toLowerCase()
const host = (c.metadata?.host ?? '').toLowerCase()
const rule = (c.rule ?? '').toLowerCase()
return process.includes(q) || host.includes(q) || rule.includes(q)
})
})
/** 连接进程显示名 */
const connProcess = (c: ProxyConnection) => c.metadata?.process || '未知'
/** 连接源地址显示(IP:端口) */
const connSource = (c: ProxyConnection) => {
const ip = c.metadata?.sourceIP
const port = c.metadata?.sourcePort
return ip ? `${ip}${port ? ':' + port : ''}` : '--'
}
/** 连接目标显示:优先 host,否则用 IP:端口 */
const connHost = (c: ProxyConnection) => {
const h = c.metadata?.host
if (h) return h
const ip = c.metadata?.destinationIP
const port = c.metadata?.destinationPort
return ip ? `${ip}${port ? ':' + port : ''}` : '--'
}
// ===== 规则中文名 / 内外网判断 =====
/** 规则类型归一化:忽略大小写与 "-" "_" 空格(订阅里可能写成 DomainSuffix / DOMAIN-SUFFIX */
const normRuleType = (s: string) => s.trim().replace(/[-_\s]/g, '').toLowerCase()
const RULE_CN: Record<string, string> = {
// 匹配/动作
match: '兜底', final: '兜底', ruleset: '规则集', direct: '直连', reject: '拒绝',
// 域名
domain: '域名', domainsuffix: '域名后缀', domainkeyword: '域名关键字', domainregex: '域名正则',
// 地理 / 站点
geoip: '地区', geosite: '域名组', ipasn: 'ASN',
// 地址网段
ipcidr: 'IP段', ipcidr6: 'IP段(v6)', srcipcidr: '源IP段', srcipcidr6: '源IP段(v6)',
dstnet: '目标地址', srcnet: '源地址', network: '网络类型',
// 端口
srcport: '源端口', dstport: '目标端口', srcportrange: '源端口范围', dstportrange: '目标端口范围',
// 进程 / 用户
process: '进程', processname: '进程名', processpath: '进程路径', processpathregex: '进程路径正则', uid: '用户ID',
// 入站
intype: '入站类型', inuser: '入站用户', inname: '入站名称', inport: '入站端口',
// 规则集衍生
rulesetipcidr: '规则集IP', rulesetipcidr6: '规则集IP(v6)', rulesetdomainsuffix: '规则集域名后缀',
rulesetdomainkeyword: '规则集域名关键字', rulesetdomainregex: '规则集域名正则', rulesetgeoip: '规则集地区',
// 逻辑
and: '与', not: '非', or: '或', subrule: '子规则'
}
/** 将 mihomo 规则翻译为中文类型名(仅替换类型关键字,保留匹配内容) */
const translateRule = (rule: string): string => {
const parts = rule.split(',')
const mapped = RULE_CN[normRuleType(parts[0])]
if (!mapped) return rule
return [mapped, ...parts.slice(1)].join(',')
}
/**
* 连接走向分类:只区分国内/国外。
* - direct 国内(未走代理;内网/局域网因代理过滤也已直连,归入国内)
* - proxy 国外(已走代理节点;代理多用于访问境外,故视为国外)
* 判定依据:链路最后一跳是否为 DIRECT。
*/
type ConnScope = 'direct' | 'proxy'
const connScopeOf = (c: ProxyConnection): ConnScope => {
const chain = c.chains
if (chain && chain.length) {
return chain[chain.length - 1] === 'DIRECT' ? 'direct' : 'proxy'
}
// 退化:无链路信息时按国内直连兜底
return 'direct'
}
/** 各规则的简短释义(供「规则命中」展示),仅为便于理解,非精确语义 */
const RULE_DESC: Record<string, string> = {
// 匹配/动作
match: '未匹配任何规则时的兜底', final: '未匹配任何规则时的兜底', direct: '直连', reject: '拒绝访问',
// 域名
domain: '完全匹配该域名', domainsuffix: '匹配该域名及其子域名', domainkeyword: '域名包含该关键词', domainregex: '域名按正则匹配',
// 地理 / 站点
geoip: '按 IP 所属国家/地区', geosite: '按域名所属站点类别', ipasn: '按 IP 所属 ASN 自治域',
// 地址网段
ipcidr: '匹配该 IP 网段', ipcidr6: '匹配该 IPv6 网段', srcipcidr: '按源 IP 网段', srcipcidr6: '按源 IPv6 网段',
dstnet: '按目标 IP/域名', srcnet: '按源 IP/域名', network: '按网络类型(TCP/UDP',
// 端口
srcport: '按源端口', dstport: '按目标端口', srcportrange: '按源端口范围', dstportrange: '按目标端口范围',
// 进程 / 用户
process: '按进程', processname: '按进程名', processpath: '按进程可执行路径', processpathregex: '按进程路径正则',
uid: '按 Linux 用户 ID',
// 入站
intype: '按入站类型', inuser: '按入站用户', inname: '按入站名称', inport: '按入站端口',
// 规则集衍生
ruleset: '按规则集内容匹配',
rulesetipcidr: '匹配规则集中任一 IP 网段', rulesetipcidr6: '匹配规则集中任一 IPv6 网段',
rulesetdomainsuffix: '匹配规则集中任一域名后缀', rulesetdomainkeyword: '匹配规则集中任一域名关键字',
rulesetdomainregex: '匹配规则集正则', rulesetgeoip: '匹配规则集中任一地区',
// 逻辑
and: '多个条件同时满足(与)', or: '任一条件满足(或)', not: '取反(非)', subrule: '子规则分发'
}
/** 取了某条规则的类型释义;未知类型返回空串 */
const ruleDesc = (rule: string): string => {
return RULE_DESC[normRuleType(rule.split(',')[0])] ?? ''
}
/** 断开全部连接 */
const closeAllConnections = async () => {
const ok = await showConfirm({
title: '断开全部连接',
description: `确定断开当前 ${connList.value.length} 条活跃连接吗?`,
confirmText: '断开',
destructive: true
})
if (!ok) return
for (const c of [...connList.value]) {
await store.closeConnection(c.id).catch(() => {})
}
}
// 伪节点关键词:DIRECT/REJECT/流量/套餐等非具体代理节点 // 伪节点关键词:DIRECT/REJECT/流量/套餐等非具体代理节点
const PSEUDO_NODE_KEYWORDS = [ const PSEUDO_NODE_KEYWORDS = [
'DIRECT', 'REJECT', 'PASS', 'COMPATIBLE', 'DIRECT', 'REJECT', 'PASS', 'COMPATIBLE',
@@ -353,6 +525,16 @@ onMounted(() => {
// 同步系统代理真实状态(注册表可能被外部改动,3s 周期足够感知) // 同步系统代理真实状态(注册表可能被外部改动,3s 周期足够感知)
await store.refreshSystemProxy() await store.refreshSystemProxy()
}, 3000) }, 3000)
// 流量采样:运行中每秒拉取一次实时速率/累计流量
trafficTimer = setInterval(async () => {
if (document.hidden) return
if (running.value) await store.refreshTraffic()
}, 1000)
// 连接列表:仅「连接」页签激活且运行时低频拉取
connTimer = setInterval(async () => {
if (document.hidden) return
if (activeTab.value === 'connections' && running.value) await store.refreshConnections()
}, 3000)
// 页面重新可见时立即刷新一次系统代理状态(切回标签页/从托盘返回主窗口) // 页面重新可见时立即刷新一次系统代理状态(切回标签页/从托盘返回主窗口)
document.addEventListener('visibilitychange', onVisibilityChange) document.addEventListener('visibilitychange', onVisibilityChange)
// 监听后端自动切换节点完成事件(后台执行,不依赖模块激活) // 监听后端自动切换节点完成事件(后台执行,不依赖模块激活)
@@ -363,6 +545,8 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
if (statusTimer) clearInterval(statusTimer) if (statusTimer) clearInterval(statusTimer)
if (trafficTimer) clearInterval(trafficTimer)
if (connTimer) clearInterval(connTimer)
document.removeEventListener('visibilitychange', onVisibilityChange) document.removeEventListener('visibilitychange', onVisibilityChange)
autoSwitchUnlisten.forEach(fn => fn()) autoSwitchUnlisten.forEach(fn => fn())
autoSwitchUnlisten = [] autoSwitchUnlisten = []
@@ -960,8 +1144,9 @@ tabsStore.registerSave(saveSettingsForm)
<div class="h-full p-6"> <div class="h-full p-6">
<Tabs v-model="activeTab" class="h-full flex flex-col"> <Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef"> <div ref="tabsListRef">
<TabsList class="grid w-full grid-cols-4 max-w-md !bg-transparent !p-0 !shadow-none"> <TabsList class="grid w-full grid-cols-5 max-w-md !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger> <TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger>
<TabsTrigger value="connections" class="gap-1.5"><Waypoints class="size-3.5" />连接</TabsTrigger>
<TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger> <TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger>
<TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger> <TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger> <TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
@@ -972,6 +1157,42 @@ tabsStore.registerSave(saveSettingsForm)
<TabsContent value="overview" class="flex-1 mt-4 tab-animate"> <TabsContent value="overview" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3"> <ScrollArea class="h-full pr-3">
<div class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid"> <div class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
<!-- 实时流量 -->
<Card>
<CardHeader>
<CardTitle class="flex items-center justify-between text-base">
<span class="flex items-center gap-2"><Activity class="size-4 text-primary" />实时流量</span>
<Badge v-if="running" variant="outline" class="gap-1 text-xs">
<span class="size-1.5 rounded-full bg-emerald-500" />实时更新
</Badge>
</CardTitle>
</CardHeader>
<CardContent class="space-y-4 text-sm">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
<ArrowDown class="size-3.5 text-emerald-500" />下载
</div>
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.downloadSpeed ?? 0) }}</p>
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.downloadTotal) : '--' }}</p>
</div>
<div>
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
<ArrowUp class="size-3.5 text-rose-500" />上传
</div>
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.uploadSpeed ?? 0) }}</p>
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.uploadTotal) : '--' }}</p>
</div>
</div>
<Separator />
<div class="flex items-center justify-between">
<span class="flex items-center gap-1.5 text-muted-foreground">
<Waypoints class="size-3.5" />活跃连接
</span>
<span class="font-semibold tabular-nums">{{ store.traffic?.activeConnections ?? '--' }}</span>
</div>
</CardContent>
</Card>
<!-- 内核状态 --> <!-- 内核状态 -->
<Card> <Card>
<CardHeader> <CardHeader>
@@ -1436,6 +1657,139 @@ tabsStore.registerSave(saveSettingsForm)
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
<!-- 连接 -->
<TabsContent value="connections" class="flex-1 mt-4 tab-animate">
<div v-if="!running" key="conn-not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
<Waypoints class="size-12 opacity-30" />
<p class="text-sm">mihomo 未运行,请先在概览页启动</p>
</div>
<div v-else class="h-full flex flex-col gap-4 pr-3">
<!-- 顶部统计 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Waypoints class="size-3.5" />活跃连接</div>
<p class="text-xl font-semibold tabular-nums">{{ connList.length }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowDown class="size-3.5 text-emerald-500" />下载速率</div>
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalDownloadSec) }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowUp class="size-3.5 text-rose-500" />上传速率</div>
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalUploadSec) }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Target class="size-3.5" />命中规则</div>
<p class="text-xl font-semibold tabular-nums">{{ ruleHitCount }}</p>
</CardContent>
</Card>
</div>
<!-- 规则命中分布 -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm flex items-center gap-2"><Target class="size-3.5 text-primary" />规则命中</CardTitle>
</CardHeader>
<CardContent class="pt-0">
<div v-if="!ruleHits.length" class="text-xs text-muted-foreground py-2">暂无连接</div>
<div v-else class="space-y-1.5">
<div v-for="r in ruleHits.slice(0, 6)" :key="r.rule" class="flex items-baseline gap-2 text-xs">
<span class="w-1.5 h-1.5 rounded-full bg-primary shrink-0 self-center" />
<span class="font-medium shrink-0">{{ translateRule(r.rule) }}</span>
<span class="flex-1 min-w-0 truncate text-muted-foreground">
<template v-if="ruleDesc(r.rule)">{{ ruleDesc(r.rule) }}</template>
</span>
<span class="shrink-0 tabular-nums">×{{ r.count }}</span>
</div>
</div>
</CardContent>
</Card>
<!-- 连接列表 -->
<Card class="flex-1 min-h-0 flex flex-col">
<CardHeader class="pb-2 space-y-2">
<div class="flex items-center justify-between gap-3">
<CardTitle class="text-sm flex items-center gap-2"><Waypoints class="size-3.5 text-primary" />当前连接</CardTitle>
<div class="flex items-center gap-2">
<Input v-model="connFilter" placeholder="按进程/域名/规则过滤" class="h-8 w-56" />
<Button size="xs" variant="outline" :disabled="!connList.length" @click="closeAllConnections">
<Square class="size-3" />断开全部
</Button>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-xs text-muted-foreground">走向:</span>
<Button
v-for="s in scopeFilterOptions"
:key="s.value"
size="xs"
:variant="connScopeFilter === s.value ? 'default' : 'outline'"
@click="connScopeFilter = s.value"
>{{ s.label }}</Button>
</div>
</CardHeader>
<CardContent class="flex-1 min-h-0 overflow-hidden pt-0">
<ScrollArea class="h-full">
<table class="w-full text-xs">
<thead class="sticky top-0 z-10 bg-card text-muted-foreground">
<tr class="border-b">
<th class="text-left font-medium py-2 px-2">进程 / 源地址</th>
<th class="text-left font-medium py-2 px-2">目标</th>
<th class="text-left font-medium py-2 px-2">规则</th>
<th class="text-right font-medium py-2 px-2">下载</th>
<th class="text-right font-medium py-2 px-2">上传</th>
<th class="text-center font-medium py-2 px-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="c in filteredConnections" :key="c.id" class="border-b last:border-0 hover:bg-muted/40">
<td class="py-2 px-2 align-baseline">
<span class="font-medium truncate block max-w-[140px]">{{ connProcess(c) }}</span>
<span class="text-muted-foreground">{{ connSource(c) }}</span>
</td>
<td class="py-2 px-2 align-baseline">
<div class="flex items-center gap-1.5">
<Badge
v-if="connScopeOf(c) === 'direct'"
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-emerald-500 text-emerald-500"
>国内</Badge>
<Badge
v-else
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-sky-500 text-sky-500"
>国外</Badge>
<span class="truncate block max-w-[140px]">{{ connHost(c) }}</span>
</div>
<span class="text-muted-foreground">{{ c.metadata?.network }} / {{ c.metadata?.type }}</span>
</td>
<td class="py-2 px-2 align-baseline text-muted-foreground">
<span class="truncate block max-w-[160px]">{{ translateRule(c.rule || 'DIRECT') }}</span>
</td>
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.download) }}</td>
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.upload) }}</td>
<td class="py-2 px-2 text-center align-baseline">
<Button size="icon" variant="ghost" class="size-6" title="断开连接" @click="store.closeConnection(c.id)">
<X class="size-3.5" />
</Button>
</td>
</tr>
<tr v-if="!connList.length">
<td colspan="6" class="text-center text-muted-foreground py-8">暂无活跃连接</td>
</tr>
</tbody>
</table>
</ScrollArea>
</CardContent>
</Card>
</div>
</TabsContent>
<!-- 节点 --> <!-- 节点 -->
<TabsContent value="proxies" class="flex-1 mt-4 tab-animate"> <TabsContent value="proxies" class="flex-1 mt-4 tab-animate">
<div v-if="!running" key="not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10"> <div v-if="!running" key="not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
+94 -108
View File
@@ -5,11 +5,10 @@ import { getCurrentWindow, LogicalSize, Effect, EffectState } from '@tauri-apps/
// Rust tauri-specta bindings.ts // Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings' import { commands } from '@/lib/bindings'
import type { ArchiveInfo, DeleteResult, ExtractResult, FileEntry, RenamePreview, RenameResult } from '@/lib/bindings' import type { ArchiveInfo, DeleteResult, ExtractResult, FileEntry, RenamePreview, RenameResult } from '@/lib/bindings'
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, ChevronLeft, History, FolderOpen, Ruler, Trash2, Terminal, Archive as ArchiveIcon, Regex, FileText, Settings } from '@lucide/vue' import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, ChevronLeft, ChevronDown, History, FolderOpen, Ruler, Trash2, Terminal, Archive as ArchiveIcon, Regex, FileText, Settings } from '@lucide/vue'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers' import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getAllHistoryItems, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants' import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import HistoryPicker from './HistoryPicker.vue' import HistoryPicker from './HistoryPicker.vue'
@@ -143,14 +142,32 @@ const renameResults = ref<RenameResult[] | null>(null)
const renameOkCount = ref(0) const renameOkCount = ref(0)
let renameTimer: ReturnType<typeof setTimeout> | null = null let renameTimer: ReturnType<typeof setTimeout> | null = null
// ===== results ===== // ===== results =====
// //> > > // //> >
const dirActionItems = computed(() => results.value.filter(r => r.group === '目录操作')) // Tab
const historyItems = computed(() => results.value.filter(r => r.group === '历史')) const dirActionItems = ref<QPItem[]>([])
const otherItems = computed(() => results.value.filter(r => r.group !== '历史' && r.group !== '目录操作')) const otherItems = computed(() => results.value)
// Accordion // localStorage results
const moreHistoryItems = ref<QPItem[]>([]) const historyItems = ref<QPItem[]>([])
const moreHistoryCount = ref(0) const historyExpanded = ref(false)
// + +
const dirCount = computed(() => dirActionItems.value.length)
const historyCount = computed(() => historyItems.value.length)
const otherCount = computed(() => otherItems.value.length)
const otherNavStart = computed(() => dirCount.value + (historyExpanded.value ? historyCount.value : 0))
const navTotal = computed(() => otherNavStart.value + otherCount.value)
// selectedIndex + +
const navItems = computed<QPItem[]>(() => [
...dirActionItems.value,
...(historyExpanded.value ? historyItems.value : []),
...otherItems.value,
])
function toggleHistory() {
collapseSubActions()
historyExpanded.value = !historyExpanded.value
}
// ===== localStorage ===== // ===== localStorage =====
const HISTORY_KEY = STORAGE_KEYS.quickpanelHistory const HISTORY_KEY = STORAGE_KEYS.quickpanelHistory
@@ -192,24 +209,25 @@ async function doSearch() {
const seq = ++searchSeq const seq = ++searchSeq
const q = query.value.trim() const q = query.value.trim()
if (!q) { if (!q) {
// Explorer + + // Explorer + +
// Provider IPC // Provider IPC
const items = await aggregateSearch('') const items = await aggregateSearch('')
if (seq !== searchSeq) return // if (seq !== searchSeq) return //
const dirItems = getExplorerActions() dirActionItems.value = getExplorerActions()
results.value = applyHistoryBoost([...dirItems, ...items]) results.value = applyHistoryBoost(items)
// Tab
historyItems.value = getAllHistoryItems()
historyExpanded.value = false
selectedIndex.value = 0 selectedIndex.value = 0
// Accordion
moreHistoryItems.value = getMoreHistoryItems()
moreHistoryCount.value = getMoreHistoryCount()
// //
void loadAppIconsForResults(results.value) void loadAppIconsForResults(results.value)
void loadAppIconsForResults(moreHistoryItems.value) void loadAppIconsForResults(historyItems.value)
return return
} }
// //
moreHistoryItems.value = [] dirActionItems.value = []
moreHistoryCount.value = 0 historyItems.value = []
historyExpanded.value = false
loading.value = true loading.value = true
try { try {
const items = await aggregateSearch(q) const items = await aggregateSearch(q)
@@ -680,7 +698,7 @@ async function confirmDelete() {
// / // /
function toggleSubActions(idx: number) { function toggleSubActions(idx: number) {
const item = results.value[idx] const item = navItems.value[idx]
if (!item?.subActions?.length) return if (!item?.subActions?.length) return
if (subActionExpanded.value === idx) { if (subActionExpanded.value === idx) {
subActionExpanded.value = null subActionExpanded.value = null
@@ -697,7 +715,7 @@ function collapseSubActions() {
// //
function currentSubActions(): QPSubAction[] { function currentSubActions(): QPSubAction[] {
if (subActionExpanded.value === null) return [] if (subActionExpanded.value === null) return []
return results.value[subActionExpanded.value]?.subActions || [] return navItems.value[subActionExpanded.value]?.subActions || []
} }
// ===== ===== // ===== =====
@@ -725,7 +743,7 @@ function onKeydown(e: KeyboardEvent) {
const expanded = subActionExpanded.value !== null const expanded = subActionExpanded.value !== null
const subs = currentSubActions() const subs = currentSubActions()
const expandedItem = expanded ? results.value[subActionExpanded.value!] : undefined const expandedItem = expanded ? navItems.value[subActionExpanded.value!] : undefined
if (expanded) { if (expanded) {
// //
@@ -760,7 +778,7 @@ function onKeydown(e: KeyboardEvent) {
// //
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, results.value.length - 1) selectedIndex.value = Math.min(selectedIndex.value + 1, navTotal.value - 1)
scrollSelectedIntoView() scrollSelectedIntoView()
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
@@ -768,19 +786,25 @@ function onKeydown(e: KeyboardEvent) {
scrollSelectedIntoView() scrollSelectedIntoView()
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
const item = results.value[selectedIndex.value] const item = navItems.value[selectedIndex.value]
if (item) executeItem(item) if (item) executeItem(item)
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
hideWindow() hideWindow()
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
// Tab if (!query.value.trim()) {
const item = results.value[selectedIndex.value] // Tab /
e.preventDefault()
toggleHistory()
} else {
// Tab
const item = navItems.value[selectedIndex.value]
if (item?.subActions?.length) { if (item?.subActions?.length) {
e.preventDefault() e.preventDefault()
toggleSubActions(selectedIndex.value) toggleSubActions(selectedIndex.value)
} }
} }
}
} }
function scrollSelectedIntoView() { function scrollSelectedIntoView() {
@@ -1393,13 +1417,35 @@ onUnmounted(() => {
</div> </div>
</template> </template>
<!-- 历史置顶可键盘导航索引偏移 dirActionItems.length --> <!-- 历史分组置顶默认折叠 Tab 展开 -->
<div v-if="historyCount > 0" class="qp-history-section">
<div
class="qp-item qp-history-trigger"
@click="toggleHistory"
>
<History class="size-4 text-muted-foreground shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm">历史</p>
<p class="text-xs text-muted-foreground">{{ historyCount }} 条最近记录</p>
</div>
<kbd class="qp-kbd shrink-0" @click.stop="toggleHistory">Tab</kbd>
<ChevronDown
v-if="historyExpanded"
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
/>
<ChevronRight
v-else
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
/>
</div>
<!-- 展开后的历史项索引偏移 dirCount可键盘导航 -->
<template v-if="historyExpanded">
<template v-for="(item, idx) in historyItems" :key="item.id"> <template v-for="(item, idx) in historyItems" :key="item.id">
<div <div
class="qp-item" class="qp-item"
:class="{ 'qp-item-selected': (idx + dirActionItems.length) === selectedIndex }" :class="{ 'qp-item-selected': (dirCount + idx) === selectedIndex }"
@click="executeItem(item)" @click="executeItem(item)"
@mouseenter="onItemHover(idx + dirActionItems.length)" @mouseenter="onItemHover(dirCount + idx)"
> >
<img <img
v-if="item.iconUrl" v-if="item.iconUrl"
@@ -1419,62 +1465,21 @@ onUnmounted(() => {
</div> </div>
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span> <span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
<CornerDownLeft <CornerDownLeft
v-if="(idx + dirActionItems.length) === selectedIndex" v-if="(dirCount + idx) === selectedIndex"
class="h-3.5 w-3.5 text-primary shrink-0" class="h-3.5 w-3.5 text-primary shrink-0"
/> />
</div> </div>
</template> </template>
</template>
<!-- 更多历史 Accordion固定在历史下方不参与键盘导航 -->
<Accordion
v-if="moreHistoryCount > 0"
type="single"
collapsible
class="qp-more-history"
>
<AccordionItem value="more" class="border-0">
<AccordionTrigger class="qp-more-trigger">
<span class="flex items-center gap-2">
<History class="h-3.5 w-3.5 text-muted-foreground" />
更多历史{{ moreHistoryCount }}
</span>
</AccordionTrigger>
<AccordionContent class="qp-more-content">
<div
v-for="item in moreHistoryItems"
:key="item.id"
class="qp-item qp-more-item"
@click="executeItem(item)"
>
<img
v-if="item.iconUrl"
:src="item.iconUrl"
class="qp-app-icon shrink-0"
alt=""
/>
<component
v-else
:is="groupIcon(item.group)"
class="size-4 text-muted-foreground shrink-0"
:class="isAppLike(item) ? '' : 'mt-0.5'"
/>
<div class="flex-1 min-w-0 flex" :class="isAppLike(item) ? 'items-center' : 'flex-col'">
<p class="text-sm truncate">{{ item.title }}</p>
<p v-if="!isAppLike(item) && item.subtitle" class="text-xs text-muted-foreground truncate">{{ item.subtitle }}</p>
</div> </div>
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<!-- 其他结果设置/应用/系统等可键盘导航索引偏移 dirActionItems.length + historyItems.length --> <!-- 其他结果设置/应用/系统等可键盘导航索引偏移 otherNavStart -->
<template v-for="(item, idx) in otherItems" :key="item.id"> <template v-for="(item, idx) in otherItems" :key="item.id">
<div <div
class="qp-item" class="qp-item"
:class="{ 'qp-item-selected': (idx + dirActionItems.length + historyItems.length) === selectedIndex }" :class="{ 'qp-item-selected': (otherNavStart + idx) === selectedIndex }"
@click="executeItem(item)" @click="executeItem(item)"
@mouseenter="onItemHover(idx + dirActionItems.length + historyItems.length)" @mouseenter="onItemHover(otherNavStart + idx)"
> >
<img <img
v-if="item.iconUrl" v-if="item.iconUrl"
@@ -1494,21 +1499,21 @@ onUnmounted(() => {
</div> </div>
<span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span> <span class="qp-group-badge" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
<ChevronRight <ChevronRight
v-if="item.subActions?.length && (idx + dirActionItems.length + historyItems.length) !== selectedIndex" v-if="item.subActions?.length && (otherNavStart + idx) !== selectedIndex"
class="h-3.5 w-3.5 text-muted-foreground shrink-0" class="h-3.5 w-3.5 text-muted-foreground shrink-0"
/> />
<kbd <kbd
v-else-if="item.subActions?.length && (idx + dirActionItems.length + historyItems.length) === selectedIndex" v-else-if="item.subActions?.length && (otherNavStart + idx) === selectedIndex"
class="qp-kbd shrink-0" class="qp-kbd shrink-0"
@click.stop="toggleSubActions(idx + dirActionItems.length + historyItems.length)" @click.stop="toggleSubActions(otherNavStart + idx)"
>Tab</kbd> >Tab</kbd>
<CornerDownLeft <CornerDownLeft
v-else-if="(idx + dirActionItems.length + historyItems.length) === selectedIndex" v-else-if="(otherNavStart + idx) === selectedIndex"
class="h-3.5 w-3.5 text-primary shrink-0" class="h-3.5 w-3.5 text-primary shrink-0"
/> />
</div> </div>
<!-- 子动作展开面板 --> <!-- 子动作展开面板 -->
<div v-if="subActionExpanded === (idx + dirActionItems.length + historyItems.length) && item.subActions?.length" class="qp-sub-panel"> <div v-if="subActionExpanded === (otherNavStart + idx) && item.subActions?.length" class="qp-sub-panel">
<div <div
v-for="(sub, sIdx) in item.subActions" v-for="(sub, sIdx) in item.subActions"
:key="sub.id" :key="sub.id"
@@ -1529,7 +1534,8 @@ onUnmounted(() => {
<!-- 底部提示 --> <!-- 底部提示 -->
<div class="qp-footer"> <div class="qp-footer">
<span><kbd></kbd><kbd></kbd> 导航</span> <span><kbd></kbd><kbd></kbd> 导航</span>
<span v-if="subActionExpanded === null"><kbd>Tab</kbd> 子动作</span> <span v-if="!query.trim()"><kbd>Tab</kbd> 历史</span>
<span v-else-if="subActionExpanded === null"><kbd>Tab</kbd> 子动作</span>
<span v-else><kbd>1-9</kbd> 快捷执行</span> <span v-else><kbd>1-9</kbd> 快捷执行</span>
<span><kbd>Enter</kbd> 执行</span> <span><kbd>Enter</kbd> 执行</span>
<span><kbd>Esc</kbd> {{ subActionExpanded !== null ? '收起' : '关闭' }}</span> <span><kbd>Esc</kbd> {{ subActionExpanded !== null ? '收起' : '关闭' }}</span>
@@ -1995,35 +2001,15 @@ onUnmounted(() => {
background: var(--accent); background: var(--accent);
} }
/* 更多历史 Accordion */ /* 历史折叠分组 */
.qp-more-history { .qp-history-section {
margin: 0 6px 4px; margin: 0 6px 4px;
} }
.qp-more-trigger { .qp-history-trigger {
padding: 6px 12px; min-height: 36px;
font-size: 12px;
font-weight: 500;
color: var(--muted-foreground);
min-height: 28px;
border-radius: var(--radius);
/* 覆盖 reka-ui 默认 py-4 */
padding-top: 6px;
padding-bottom: 6px;
}
.qp-more-trigger:hover {
background: var(--muted);
}
.qp-more-content {
/* 覆盖 AccordionContent 默认 pb-4 */
padding-top: 0;
padding-bottom: 2px;
}
.qp-more-item {
padding: 6px 12px; padding: 6px 12px;
font-size: 13px;
} }
.qp-item-selected:hover { .qp-item-selected:hover {
@@ -4,7 +4,6 @@
*/ */
import type { QPItem, QPProvider } from './types' import type { QPItem, QPProvider } from './types'
import { appRankFromPath } from './utils' import { appRankFromPath } from './utils'
import { HistoryProvider } from './history'
import { CommandProvider } from './command' import { CommandProvider } from './command'
import { CustomCommandProvider } from './customCommand' import { CustomCommandProvider } from './customCommand'
import { AppProvider } from './app' import { AppProvider } from './app'
@@ -21,7 +20,6 @@ let providers: QPProvider[] | null = null
export function getProviders(): QPProvider[] { export function getProviders(): QPProvider[] {
if (!providers) { if (!providers) {
providers = [ providers = [
new HistoryProvider(),
new CommandProvider(), new CommandProvider(),
new CustomCommandProvider(), new CustomCommandProvider(),
new AppProvider(), new AppProvider(),
+5 -32
View File
@@ -1,18 +1,15 @@
/** /**
* history Provider * history Provider
* localStorage * localStorage QuickPanel
* action * action
*/ */
import { STORAGE_KEYS } from '@/lib/constants' import { STORAGE_KEYS } from '@/lib/constants'
import type { HistoryEntry, QPItem, QPProvider } from './types' import type { HistoryEntry, QPItem } from './types'
import { aggregateSearch } from './aggregate' import { aggregateSearch } from './aggregate'
const HISTORY_ITEMS_KEY = STORAGE_KEYS.quickpanelHistoryItems const HISTORY_ITEMS_KEY = STORAGE_KEYS.quickpanelHistoryItems
const HISTORY_MAX = 50 const HISTORY_MAX = 50
/** 空查询时默认展示的历史条数(置顶部分) */
export const HISTORY_PREVIEW_COUNT = 3
function loadHistoryEntries(): HistoryEntry[] { function loadHistoryEntries(): HistoryEntry[] {
try { try {
const raw = localStorage.getItem(HISTORY_ITEMS_KEY) const raw = localStorage.getItem(HISTORY_ITEMS_KEY)
@@ -76,32 +73,8 @@ export function clearHistory() {
localStorage.removeItem(HISTORY_ITEMS_KEY) localStorage.removeItem(HISTORY_ITEMS_KEY)
} }
/** 获取置顶的历史项(最近 N 条),用于空查询时在结果列表顶部显示 */ /** 获取全部历史项(最近优先),供顶部可折叠的历史分组使用 */
export function getTopHistoryItems(): QPItem[] { export function getAllHistoryItems(): QPItem[] {
const entries = loadHistoryEntries() const entries = loadHistoryEntries()
return entries.slice(0, HISTORY_PREVIEW_COUNT).map(buildHistoryItem) return entries.map(buildHistoryItem)
}
/** 获取置顶历史之后的剩余历史项,用于 Accordion 折叠显示 */
export function getMoreHistoryItems(): QPItem[] {
const entries = loadHistoryEntries()
return entries.slice(HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
}
/** 获取剩余历史数量(用于 Accordion 标题显示) */
export function getMoreHistoryCount(): number {
const entries = loadHistoryEntries()
return Math.max(0, entries.length - HISTORY_PREVIEW_COUNT)
}
export class HistoryProvider implements QPProvider {
id = 'history'
label = '历史'
priority = 99 // 最高优先级,空查询时显示在最前
async search(query: string): Promise<QPItem[]> {
if (query.trim()) return [] // 历史只在空查询时显示
// 只返回置顶3条,剩余由 Accordion 承载
return getTopHistoryItems()
}
} }
+1 -4
View File
@@ -14,10 +14,7 @@ export { loadAppIconsForResults, invalidateAppIconCache } from './app'
export { setFileIndexReady } from './file' export { setFileIndexReady } from './file'
export { invalidateCustomCommandsCache } from './customCommand' export { invalidateCustomCommandsCache } from './customCommand'
export { export {
HISTORY_PREVIEW_COUNT,
recordHistoryItem, recordHistoryItem,
clearHistory, clearHistory,
getTopHistoryItems, getAllHistoryItems,
getMoreHistoryItems,
getMoreHistoryCount,
} from './history' } from './history'
@@ -54,6 +54,14 @@ const SYSTEM_COMMANDS: SystemCommandDef[] = [
command: 'taskmgr', command: 'taskmgr',
args: [], args: [],
}, },
{
id: 'sys-devmgmt',
title: '设备管理器',
subtitle: 'devmgmt.msc',
keywords: ['devmgmt', '设备管理', '硬件', '驱动', 'sheb'],
command: 'devmgmt.msc',
args: [],
},
{ {
id: 'sys-explorer', id: 'sys-explorer',
title: '资源管理器', title: '资源管理器',
+240 -7
View File
@@ -10,6 +10,8 @@ import { useAppStore, type Theme, type EffectType, type ModuleInfo } from '@/sto
import { useSearchStore } from '@/stores/searchStore' import { useSearchStore } from '@/stores/searchStore'
import { useProcessStore } from '@/stores/processStore' import { useProcessStore } from '@/stores/processStore'
import { useDownloaderStore } from '@/stores/downloaderStore' import { useDownloaderStore } from '@/stores/downloaderStore'
import { useMonitorStore } from '@/stores/monitorStore'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
import { getModuleIcon } from '@/modules/icons' import { getModuleIcon } from '@/modules/icons'
import { commands, type UpdateCheckResult } from '@/lib/bindings' import { commands, type UpdateCheckResult } from '@/lib/bindings'
import { EVENTS } from '@/lib/constants' import { EVENTS } from '@/lib/constants'
@@ -44,7 +46,6 @@ const appUpdating = ref(false)
/** ThingHK 内核更新中 */ /** ThingHK 内核更新中 */
const kernelUpdating = ref(false) const kernelUpdating = ref(false)
const progress = ref<UpdateProgress | null>(null) const progress = ref<UpdateProgress | null>(null)
const thinghkExists = ref(false)
let progressUnlisten: UnlistenFn | null = null let progressUnlisten: UnlistenFn | null = null
const installTypeText = computed(() => const installTypeText = computed(() =>
@@ -215,19 +216,203 @@ const cancelUpdateDownload = async () => {
cancelAppDownload?.() cancelAppDownload?.()
} }
/** 更新 ThingHK 内核:后端先停止监控内核再覆盖文件 */ /** ThingHK apply need_stop
* 与代理模块 mihomo 内核更新同模式 */
const thinghkExists = ref(false)
/** 需要停止监控内核的确认弹窗状态(need_stop 阶段由后端 event 触发) */
const thinghkConfirmState = ref<{ open: boolean; wasRunning: boolean; busy: boolean; resolved: boolean }>({
open: false,
wasRunning: false,
busy: false,
resolved: false,
})
const monitorStore = useMonitorStore()
/** 下载中可取消(沿用应用更新下载的取消交互) */
const kernelDownloadCancellable = ref(false)
/** 取消 ThingHK 内核包下载的唤醒回调 */
let cancelKernelDownload: (() => void) | null = null
/** 停止 Kernel 确认弹窗中处理中标记(防止重复触发) */
let handlingThinghkNeedStop = false
const handleThinghkNeedStop = () => {
if (handlingThinghkNeedStop) return
handlingThinghkNeedStop = true
try {
// /
invoke('quickpanel_focus_main_window').catch(() => { /* 忽略 */ })
const wasRunning = monitorStore.status?.running ?? false
thinghkConfirmState.value = { open: true, wasRunning, busy: false, resolved: false }
} finally {
handlingThinghkNeedStop = false
}
}
/** 确认停止内核并唤醒后端 apply 继续解压替换 */
const onThinghkNeedStopConfirm = async () => {
const s = thinghkConfirmState.value
if (s.resolved) return
// resolved AlertDialog update:open(false)
s.resolved = true
s.busy = true
try {
if (s.wasRunning) {
await monitorStore.stop()
}
await invoke('update_thinghk_confirm')
} catch (e) {
toast.error('停止监控内核失败', { description: String(e) })
// exe
try { await invoke('update_thinghk_cancel') } catch { /* 忽略 */ }
} finally {
s.busy = false
s.open = false
}
}
/** 取消 ThingHK 内核更新(中止后端等待中的 apply 流程,zip 保留便于重试) */
const onThinghkNeedStopCancel = () => {
const s = thinghkConfirmState.value
if (s.resolved) return
s.resolved = true
s.open = false
invoke('update_thinghk_cancel').catch(() => { /* 忽略 */ })
}
/** AlertDialog cancel/action update:open(false) click
* 仅在此前未被 click 处理器 resolve 时才当作取消遮罩/Esc 关闭否则会误判确认/取消 */
const onThinghkNeedStopOpenChange = (open: boolean) => {
const s = thinghkConfirmState.value
if (!open && !s.resolved) {
setTimeout(() => {
if (!thinghkConfirmState.value.resolved) onThinghkNeedStopCancel()
}, 0)
}
thinghkConfirmState.value.open = open
}
/** 取消 ThingHK 内核包下载(仅下载阶段) */
const cancelKernelUpdateDownload = () => {
if (!kernelDownloadCancellable.value) return
cancelKernelDownload?.()
}
/** 更新 ThingHK 内核 */
const updateThinghkKernel = async () => { const updateThinghkKernel = async () => {
if (kernelUpdating.value) return if (kernelUpdating.value) return
const result = updateResult.value
if (!result) return
// release assets ThingHK zip
const asset = result.assets.find(a => {
const n = a.name.toLowerCase()
return (n.includes('thing-hk') || n.includes('thinghk')) && n.endsWith('.zip')
})
if (!asset) {
toast.error('未找到 ThingHK 内核更新包', {
description: '请确认 release 资产中已上传 ThingHK 内核 zip 包',
})
return
}
kernelUpdating.value = true kernelUpdating.value = true
progress.value = {
stage: 'downloading',
percent: 0,
downloadedBytes: 0,
totalBytes: asset.size || null,
message: '准备开始下载...',
}
let taskId: string | null = null
let downloadProgressFn: UnlistenFn | null = null
// TS proxyStore
const completeHolder: { fn: UnlistenFn | null } = { fn: null }
let downloadOk = false
try { try {
await commands.updateThinghk() // UI
try { await downloaderStore.startEventListeners() } catch { /* 忽略 */ }
taskId = await downloaderStore.addTask(asset.browserDownloadUrl, asset.name, undefined, {}, false)
//
downloadProgressFn = await listen<{
id: string; completedSize: number; totalSize: number; speed: number; status: string
}>('download-progress', (e) => {
if (e.payload.id !== taskId || !kernelUpdating.value) return
const pct = e.payload.totalSize > 0
? Math.round((e.payload.completedSize / e.payload.totalSize) * 100)
: 0
progress.value = {
stage: 'downloading',
percent: pct,
downloadedBytes: e.payload.completedSize,
totalBytes: e.payload.totalSize,
message: e.payload.speed > 0
? `正在下载... ${fmtSpeed(e.payload.speed)}`
: '正在下载...',
}
})
// / /
kernelDownloadCancellable.value = true
const dlResult = await new Promise<{ ok: boolean; error?: string }>((resolve) => {
let settled = false
const finish = (r: { ok: boolean; error?: string }) => {
if (settled) return
settled = true
cancelKernelDownload = null
resolve(r)
}
//
const initial = downloaderStore.tasks.find(t => t.id === taskId)
if (initial) {
if (initial.status === 'complete') { finish({ ok: true }); return }
if (initial.status === 'error') { finish({ ok: false, error: initial.error || '下载失败' }); return }
}
cancelKernelDownload = () => finish({ ok: false, error: '已取消下载' })
listen<{ id: string; status: string; error: string | null }>('download-complete', (e) => {
if (e.payload.id === taskId) {
if (e.payload.status === 'complete') finish({ ok: true })
else finish({ ok: false, error: e.payload.error || '下载失败' })
}
}).then(fn => { completeHolder.fn = fn })
})
kernelDownloadCancellable.value = false
if (!dlResult.ok) throw new Error(dlResult.error || '下载失败')
downloadOk = true
// apply
const dlTask = downloaderStore.tasks.find(t => t.id === taskId)
if (!dlTask) throw new Error('下载任务未找到')
const zipPath = dlTask.dir + '/' + dlTask.filename
try {
await downloaderStore.removeTask(taskId, false)
taskId = null
} catch { /* 任务清理失败不阻断更新 */ }
// apply need_stop
await invoke('update_thinghk_apply', { zipPath })
await loadAppInfo() await loadAppInfo()
toast.success('ThingHK 内核更新完成') toast.success('ThingHK 内核更新完成')
} catch (e) { } catch (e) {
console.error('[updater] ThingHK 更新失败', e) console.error('[updater] ThingHK 更新失败', e)
toast.error('ThingHK 内核更新失败', { description: String(e) }) const msg = String(e)
if (msg.includes('已取消下载')) {
toast.info('已取消更新下载')
} else if (msg.includes('更新已取消')) {
toast.info('已取消内核更新')
} else {
toast.error('ThingHK 内核更新失败', { description: msg })
}
// /apply zip 便
if (taskId) {
try { await downloaderStore.removeTask(taskId, !downloadOk) } catch { /* 忽略 */ }
}
} finally { } finally {
kernelUpdating.value = false kernelUpdating.value = false
progress.value = null
kernelDownloadCancellable.value = false
cancelKernelDownload = null
if (downloadProgressFn) downloadProgressFn()
if (completeHolder.fn) completeHolder.fn()
} }
} }
@@ -240,10 +425,14 @@ onMounted(() => {
// ThingHK // ThingHK
listen<UpdateProgress>(EVENTS.updateProgress, (e) => { listen<UpdateProgress>(EVENTS.updateProgress, (e) => {
progress.value = e.payload progress.value = e.payload
if (e.payload.stage === 'done') { const p = e.payload
if (p.stage === 'need_stop') {
// ThingHK
handleThinghkNeedStop()
} else if (p.stage === 'done') {
kernelUpdating.value = false kernelUpdating.value = false
progress.value = null progress.value = null
} else if (e.payload.stage === 'error') { } else if (p.stage === 'error') {
// ThingHK emit reject // ThingHK emit reject
kernelUpdating.value = false kernelUpdating.value = false
} }
@@ -407,6 +596,16 @@ const onDragEnd = () => {
@update:model-value="(checked: boolean) => appStore.toggleAutoStart(checked)" @update:model-value="(checked: boolean) => appStore.toggleAutoStart(checked)"
/> />
</div> </div>
<div class="flex items-center justify-between py-2 border-t border-border/60 mt-2">
<div class="space-y-1">
<Label class="text-base font-medium">静默启动</Label>
<p class="text-sm text-muted-foreground">启动后不打开主界面静默驻留托盘托盘左键可呼出</p>
</div>
<Switch
:model-value="appStore.silentAutoStart"
@update:model-value="(checked: boolean) => appStore.toggleSilentAutoStart(checked)"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -675,8 +874,42 @@ const onDragEnd = () => {
<span>{{ progress.message }}</span> <span>{{ progress.message }}</span>
<span class="font-mono">{{ progress.percent }}%</span> <span class="font-mono">{{ progress.percent }}%</span>
</div> </div>
<Progress :model-value="progress.percent" /> <div class="flex items-center gap-2">
<Progress :model-value="progress.percent" class="flex-1" />
<Button
v-if="kernelDownloadCancellable"
variant="ghost"
size="sm"
class="h-5 px-1.5 text-xs"
@click="cancelKernelUpdateDownload"
>
<X class="size-3 mr-0.5" />
取消
</Button>
</div> </div>
</div>
<!-- 停止监控内核确认need_stop 阶段 mihomo 同模式 -->
<AlertDialog :open="thinghkConfirmState.open" @update:open="onThinghkNeedStopOpenChange">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>停止监控内核后继续</AlertDialogTitle>
<AlertDialogDescription>
{{
thinghkConfirmState.wasRunning
? '内核更新包已下载。安装新内核前需要停止监控内核,点击「停止并继续」将自动停止监控并完成安装。'
: '内核更新包已下载。即将安装新内核,点击「继续」完成安装。'
}}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="onThinghkNeedStopCancel">取消</AlertDialogCancel>
<AlertDialogAction :disabled="thinghkConfirmState.busy" @click="onThinghkNeedStopConfirm">
{{ thinghkConfirmState.busy ? '正在停止...' : (thinghkConfirmState.wasRunning ? '停止并继续' : '继续') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent> </CardContent>
</Card> </Card>
+15 -3
View File
@@ -426,13 +426,25 @@ onMounted(async () => {
mq.addEventListener('change', onThemeChange) mq.addEventListener('change', onThemeChange)
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange)) unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// /线 tray-menu-show
// 150ms
// " resize"
let menuJustShown = 0 // ""
let finalizeTimer = 0
unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => { unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => {
await applyTheme() await applyTheme()
Object.assign(state, event.payload) Object.assign(state, event.payload)
osdVisible.value = readOsdVisible() osdVisible.value = readOsdVisible()
// / // /
resetMenuState() resetMenuState()
//
menuJustShown = Date.now()
window.clearTimeout(finalizeTimer)
finalizeTimer = window.setTimeout(async () => {
menuJustShown = 0
await measureAndShow() await measureAndShow()
}, 150)
})) }))
// //
@@ -440,12 +452,12 @@ onMounted(async () => {
if (!focused) resetMenuState() if (!focused) resetMenuState()
})) }))
// // - finalizeTimer
// - // -
// - // - mihomo node
// tray_menu_ready +resize
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', async (event) => { unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', async (event) => {
Object.assign(state, event.payload) Object.assign(state, event.payload)
if (menuJustShown) return // finalize
try { try {
const visible = await getCurrentWindow().isVisible() const visible = await getCurrentWindow().isVisible()
if (visible) await measureAndShow() if (visible) await measureAndShow()
+13
View File
@@ -57,6 +57,7 @@ export const useAppStore = defineStore('app', () => {
const theme = ref<Theme>('system') const theme = ref<Theme>('system')
const effect = ref<EffectType>('mica') const effect = ref<EffectType>('mica')
const isAutoStart = ref(false) const isAutoStart = ref(false)
const silentAutoStart = ref(false)
const isInitialized = ref(false) const isInitialized = ref(false)
const modules = ref<ModuleInfo[]>(initModulesFromRegistry()) const modules = ref<ModuleInfo[]>(initModulesFromRegistry())
const moduleOrder = ref<string[]>(initModuleOrder()) const moduleOrder = ref<string[]>(initModuleOrder())
@@ -88,6 +89,8 @@ export const useAppStore = defineStore('app', () => {
if (settings.theme) theme.value = settings.theme if (settings.theme) theme.value = settings.theme
if (settings.effect) effect.value = settings.effect if (settings.effect) effect.value = settings.effect
if (typeof settings.isAutoStart === 'boolean') isAutoStart.value = settings.isAutoStart
if (typeof settings.silentAutoStart === 'boolean') silentAutoStart.value = settings.silentAutoStart
if (settings.modules) { if (settings.modules) {
const savedModules = settings.modules as Array<{ id: string; enabled: boolean }> const savedModules = settings.modules as Array<{ id: string; enabled: boolean }>
savedModules.forEach(sm => { savedModules.forEach(sm => {
@@ -127,6 +130,7 @@ export const useAppStore = defineStore('app', () => {
theme: theme.value, theme: theme.value,
effect: effect.value, effect: effect.value,
isAutoStart: isAutoStart.value, isAutoStart: isAutoStart.value,
silentAutoStart: silentAutoStart.value,
modules: modulesData, modules: modulesData,
moduleOrder: moduleOrder.value moduleOrder: moduleOrder.value
})) }))
@@ -323,6 +327,11 @@ export const useAppStore = defineStore('app', () => {
} }
} }
const toggleSilentAutoStart = (checked?: boolean) => {
silentAutoStart.value = checked !== undefined ? checked : !silentAutoStart.value
saveSettings()
}
const applyTheme = async () => { const applyTheme = async () => {
const root = document.documentElement const root = document.documentElement
root.classList.remove('dark') root.classList.remove('dark')
@@ -472,6 +481,8 @@ export const useAppStore = defineStore('app', () => {
isInitialized.value = true isInitialized.value = true
} finally { } finally {
// 静默启动:仅当未开启时才显示主窗口(开启后启动静默驻留托盘,托盘左键可呼出)
if (silentAutoStart.value) return
try { try {
const tauriWindow = getCurrentWindow() const tauriWindow = getCurrentWindow()
await tauriWindow.show() await tauriWindow.show()
@@ -486,6 +497,7 @@ export const useAppStore = defineStore('app', () => {
effect, effect,
systemDark, systemDark,
isAutoStart, isAutoStart,
silentAutoStart,
isInitialized, isInitialized,
modules, modules,
moduleOrder, moduleOrder,
@@ -497,6 +509,7 @@ export const useAppStore = defineStore('app', () => {
setTheme, setTheme,
setEffect, setEffect,
toggleAutoStart, toggleAutoStart,
toggleSilentAutoStart,
applyTheme, applyTheme,
applyEffect, applyEffect,
init, init,
+82 -7
View File
@@ -9,6 +9,7 @@ import type {
DownloadTask as BindDownloadTask, DownloadTask as BindDownloadTask,
DownloaderSettings as BindDownloaderSettings, DownloaderSettings as BindDownloaderSettings,
CheckUrlResult as BindCheckUrlResult, CheckUrlResult as BindCheckUrlResult,
TorrentInfo as BindTorrentInfo,
TaskStatus, TaskStatus,
} from '@/lib/bindings' } from '@/lib/bindings'
@@ -19,11 +20,14 @@ const logger = createLogger('downloader')
export type DownloadTask = Required<BindDownloadTask> export type DownloadTask = Required<BindDownloadTask>
export type DownloaderSettings = Required<BindDownloaderSettings> export type DownloaderSettings = Required<BindDownloaderSettings>
export type CheckUrlResult = Required<BindCheckUrlResult> export type CheckUrlResult = Required<BindCheckUrlResult>
export type TorrentInfo = Required<BindTorrentInfo>
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变 // re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
export type { export type {
TaskStatus, TaskStatus,
TaskProtocol,
Segment, Segment,
BtFileInfo,
DuplicateKind, DuplicateKind,
ExistingTaskInfo, ExistingTaskInfo,
} from '@/lib/bindings' } from '@/lib/bindings'
@@ -71,6 +75,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
let completeUnlisten: UnlistenFn | null = null let completeUnlisten: UnlistenFn | null = null
let addedUnlisten: UnlistenFn | null = null let addedUnlisten: UnlistenFn | null = null
let removedUnlisten: UnlistenFn | null = null let removedUnlisten: UnlistenFn | null = null
let inspectReadyUnlisten: UnlistenFn | null = null
/** 磁力元数据解析就绪回调(模块注册,用于弹文件勾选对话框) */
let btInspectReadyHandler: ((id: string) => void) | null = null
// ===== 任务列表 ===== // ===== 任务列表 =====
const refreshTasks = async () => { const refreshTasks = async () => {
@@ -85,13 +93,26 @@ export const useDownloaderStore = defineStore('downloader', () => {
if ( if (
freshTask.status === 'paused' || freshTask.status === 'paused' ||
freshTask.status === 'complete' || freshTask.status === 'complete' ||
freshTask.status === 'error' freshTask.status === 'error' ||
freshTask.status === 'cancelled'
) { ) {
return freshTask return freshTask
} }
const local = tasks.value.find(t => t.id === freshTask.id) const local = tasks.value.find(t => t.id === freshTask.id)
if (!local) return freshTask if (!local) return freshTask
return { ...local, status: freshTask.status, error: freshTask.error } // 进度/速度沿用本地实时值;但元数据字段(文件名/文件列表/总大小/infohash
// 必须以后端为准 —— 这些只在后台解析完成后才就绪,本地快照在添加时是占位空值,
// 若沿用会导致"元数据已解析但勾选对话框仍无文件列表"(旧快照覆盖新元数据)
return {
...local,
status: freshTask.status,
error: freshTask.error,
filename: freshTask.filename,
btFiles: freshTask.btFiles,
btMetadataReady: freshTask.btMetadataReady,
infoHash: freshTask.infoHash,
totalSize: freshTask.totalSize,
}
}) })
tasks.value = merged tasks.value = merged
} catch (e) { } catch (e) {
@@ -105,9 +126,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
const task = tasks.value.find((t) => t.id === payload.id) const task = tasks.value.find((t) => t.id === payload.id)
if (task) { if (task) {
// 终态任务忽略迟到的进度事件(下载完成后 in-flight 事件可能把状态/进度回退) // 终态任务忽略迟到的进度事件(下载完成后 in-flight 事件可能把状态/进度回退)
if (task.status === 'complete' || task.status === 'error') return if (task.status === 'complete' || task.status === 'error' || task.status === 'cancelled') return
// 已暂停任务忽略仍携带 active 的迟到事件(暂停瞬间发出的旧事件) // 已暂停任务忽略"停止瞬间残留的 active 心跳"(无速度且进度未变化的迟到事件)。
if (task.status === 'paused' && payload.status === 'active') return // 真正恢复下载后发来的 active(有速度或进度增长)必须放行,否则恢复后列表一直停留在暂停态
if (
task.status === 'paused' &&
payload.status === 'active' &&
payload.speed <= 0 &&
payload.completedSize <= task.completedSize
) return
task.completedSize = payload.completedSize task.completedSize = payload.completedSize
task.totalSize = payload.totalSize task.totalSize = payload.totalSize
task.speed = payload.speed task.speed = payload.speed
@@ -134,19 +161,32 @@ export const useDownloaderStore = defineStore('downloader', () => {
filename?: string, filename?: string,
dir?: string, dir?: string,
headers?: Record<string, string>, headers?: Record<string, string>,
autoRename = false autoRename = false,
onlyFiles?: number[]
): Promise<string> => { ): Promise<string> => {
const id = await commands.downloaderAddTask( const id = await commands.downloaderAddTask(
url, url,
filename || null, filename || null,
dir || null, dir || null,
headers || null, headers || null,
autoRename autoRename,
onlyFiles || null
) )
await refreshTasks() await refreshTasks()
return id return id
} }
/** 解析磁力链 / .torrent,返回种子信息(文件勾选用) */
const inspect = async (input: string): Promise<TorrentInfo> => {
return await commands.downloaderInspect(input)
}
/** 磁力任务元数据解析成功后:设置勾选文件并开始下载 */
const selectBtFiles = async (id: string, onlyFiles: number[]) => {
await commands.downloaderSelectBtFiles(id, onlyFiles)
await refreshTasks()
}
/** 检查 URL 重复性并探测文件信息 */ /** 检查 URL 重复性并探测文件信息 */
const checkUrl = async ( const checkUrl = async (
url: string, url: string,
@@ -171,6 +211,18 @@ export const useDownloaderStore = defineStore('downloader', () => {
await refreshTasks() await refreshTasks()
} }
/** 取消下载:置为已取消、清空进度并删除下载文件,但保留记录 */
const cancelTask = async (id: string) => {
await commands.downloaderCancelTask(id)
await refreshTasks()
}
/** 重新下载已取消/出错的任务 */
const redownload = async (id: string) => {
await commands.downloaderRedownload(id)
await refreshTasks()
}
// ===== 设置 ===== // ===== 设置 =====
const loadSettings = async () => { const loadSettings = async () => {
try { try {
@@ -250,6 +302,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
refreshTasks() refreshTasks()
}) })
} }
if (!inspectReadyUnlisten) {
inspectReadyUnlisten = await listen<{ id: string }>('download-inspect-ready', async (e) => {
// 磁力元数据解析成功:先刷新任务(拿到文件列表),再通知模块弹文件勾选对话框。
// 必须 await —— 否则回调读取的 store.tasks 仍是旧快照(btFiles 为空),
// 导致勾选对话框无条目、默认选中空数组,进而在后端被 librqbit 秒判为"已完成 0%"
await refreshTasks()
btInspectReadyHandler?.(e.payload.id)
})
}
} }
const stopEventListeners = () => { const stopEventListeners = () => {
@@ -269,6 +330,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
removedUnlisten() removedUnlisten()
removedUnlisten = null removedUnlisten = null
} }
if (inspectReadyUnlisten) {
inspectReadyUnlisten()
inspectReadyUnlisten = null
}
} }
// ===== 初始化 ===== // ===== 初始化 =====
@@ -281,6 +346,11 @@ export const useDownloaderStore = defineStore('downloader', () => {
// ===== 工具函数 ===== // ===== 工具函数 =====
const openDir = (path: string) => commands.downloaderOpenDir(path) const openDir = (path: string) => commands.downloaderOpenDir(path)
/** 注册磁力元数据就绪回调(模块传入处理函数,替换式) */
const setBtInspectReadyHandler = (handler: ((id: string) => void) | null) => {
btInspectReadyHandler = handler
}
return { return {
// state // state
tasks, tasks,
@@ -290,10 +360,14 @@ export const useDownloaderStore = defineStore('downloader', () => {
// tasks // tasks
refreshTasks, refreshTasks,
addTask, addTask,
inspect,
selectBtFiles,
checkUrl, checkUrl,
pauseTask, pauseTask,
resumeTask, resumeTask,
removeTask, removeTask,
cancelTask,
redownload,
// settings // settings
loadSettings, loadSettings,
saveSettings, saveSettings,
@@ -307,6 +381,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
// init // init
init, init,
// utils // utils
setBtInspectReadyHandler,
openDir openDir
} }
}) })
+203 -50
View File
@@ -185,6 +185,8 @@ export interface OsdConfig {
/** 悬浮窗窗口保存位置(null=使用百分比计算默认位置) */ /** 悬浮窗窗口保存位置(null=使用百分比计算默认位置) */
overlayX?: number | null overlayX?: number | null
overlayY?: number | null overlayY?: number | null
/** 游戏全屏时自动隐藏悬浮窗(前台全屏应用会因置顶透明窗口掉帧,默认开启) */
gameAutoHide: boolean
} }
/** OSD 悬浮窗窗口 label(与 Tauri 窗口创建对应,见 constants::WINDOWS */ /** OSD 悬浮窗窗口 label(与 Tauri 窗口创建对应,见 constants::WINDOWS */
@@ -261,19 +263,20 @@ function defaultOsdConfig(): OsdConfig {
labelLanguage: 'zh', labelLanguage: 'zh',
layout: 'single', layout: 'single',
updateIntervalMs: 1000, updateIntervalMs: 1000,
// 默认关闭点击穿透:关闭后左键可直接拖动悬浮窗 // 默认开启点击穿透:悬浮窗不拦截鼠标,需拖动时临时关闭
clickThrough: false, clickThrough: true,
fontColor: '#ffffff', fontColor: '#ffffff',
fontOpacity: 100, fontOpacity: 100,
bgColor: 'transparent', bgColor: 'transparent',
colorThemeEnabled: true, colorThemeEnabled: true,
colorTheme: { ...DEFAULT_COLOR_THEME }, colorTheme: { ...DEFAULT_COLOR_THEME },
fontStrokeEnabled: false, fontStrokeEnabled: true,
fontStrokeWidth: 1, fontStrokeWidth: 1,
fontStrokeColor: '#000000', fontStrokeColor: '#000000',
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } }, alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
overlayX: null, overlayX: null,
overlayY: null, overlayY: null,
gameAutoHide: true,
} }
} }
@@ -340,6 +343,10 @@ export const useMonitorStore = defineStore('monitor', () => {
*/ */
const osdConfig = ref<OsdConfig>(loadOsdConfig()) const osdConfig = ref<OsdConfig>(loadOsdConfig())
/** Rust osd-game-active/inactive
* OSD DWM */
const gameFullscreen = ref(false)
/** OSD 配置防抖保存:滑块/输入连续变化时合并为一次 localStorage 写入(避免每帧全量序列化) */ /** OSD 配置防抖保存:滑块/输入连续变化时合并为一次 localStorage 写入(避免每帧全量序列化) */
let osdSaveTimer: ReturnType<typeof setTimeout> | null = null let osdSaveTimer: ReturnType<typeof setTimeout> | null = null
/** OSD 配置防抖推送定时器(initOsd 内注册的 deep watch 使用,dispose 时需清理) */ /** OSD 配置防抖推送定时器(initOsd 内注册的 deep watch 使用,dispose 时需清理) */
@@ -367,6 +374,8 @@ export const useMonitorStore = defineStore('monitor', () => {
/** 推送 OSD 数据到所有 OSD 窗口(高频通道:仅显示项 key→value 映射 + 网速,每秒一次) */ /** 推送 OSD 数据到所有 OSD 窗口(高频通道:仅显示项 key→value 映射 + 网速,每秒一次) */
async function pushOsdState() { async function pushOsdState() {
if (!osdConfig.value.overlayEnabled) return if (!osdConfig.value.overlayEnabled) return
// 游戏全屏自动隐藏期间停止推送:OSD 窗口已隐藏,推送只会白白消耗 IPC 和 WebView JS 时间片
if (gameFullscreen.value && osdConfig.value.gameAutoHide) return
try { try {
const map = sensorKeyMap.value const map = sensorKeyMap.value
const data: Record<string, number | null> = {} const data: Record<string, number | null> = {}
@@ -583,11 +592,41 @@ export const useMonitorStore = defineStore('monitor', () => {
return autoStart.value return autoStart.value
} }
/** 设置"自动启动监控内核"开关 */ /** 关闭"自动启动监控内核"时暂存的 OSD 开关状态(开启自动启动时据此恢复) */
const OSD_PENDING_KEY = STORAGE_KEYS.monitorOsdPending
/** 待恢复的 OSD 开关状态:关闭自动启动时记录、开启时按记录恢复。持久化于 localStorage,跨重启有效。 */
let pendingOsdEnabled: boolean | null = null
try {
pendingOsdEnabled = JSON.parse(localStorage.getItem(OSD_PENDING_KEY) ?? 'null')
} catch { /* 忽略非法缓存,视为无恢复记录 */ }
/**
* "自动启动监控内核"
* OSD OSD
* - OSD OSDOSD
* - OSD
*/
async function setAutoStart(enabled: boolean) { async function setAutoStart(enabled: boolean) {
try { try {
await invoke('monitor_set_auto_start', { enabled }) await invoke('monitor_set_auto_start', { enabled })
autoStart.value = enabled autoStart.value = enabled
if (!enabled) {
// 关闭时记录当前 OSD 状态,随后由 overlayEnabled watch 统一隐藏悬浮窗
pendingOsdEnabled = osdConfig.value.overlayEnabled
try { localStorage.setItem(OSD_PENDING_KEY, JSON.stringify(pendingOsdEnabled)) } catch { /* 忽略 */ }
osdConfig.value.overlayEnabled = false
saveOsdConfig(osdConfig.value)
} else {
// 开启时若有记录且曾为开,恢复 OSD 显示
if (pendingOsdEnabled === true) {
osdConfig.value.overlayEnabled = true
saveOsdConfig(osdConfig.value)
}
// 消费记录,避免下次开启再次恢复
pendingOsdEnabled = null
try { localStorage.removeItem(OSD_PENDING_KEY) } catch { /* 忽略 */ }
}
} catch (e) { } catch (e) {
errorMsg.value = String(e) errorMsg.value = String(e)
logger.error('设置自动启动开关失败: ' + e) logger.error('设置自动启动开关失败: ' + e)
@@ -758,67 +797,172 @@ export const useMonitorStore = defineStore('monitor', () => {
/** /**
* single: 单行分组式 | * single: 单行分组式 |
* group: 分组横排 + * group: 分组横排 +
* multiline: 多行 + */ * multiline: 多行 +
*
* OsdWindow.vue
* - / CJK fontSizeASCII 0.6em
* - fmtFixedValue pad fmtFixedUnit
* - gap 3pxsingle 4pxgroup 8px gap margin
* - osd-bar padding4*2
*
* computePositionFromPct */
function computeOsdWindowSize( function computeOsdWindowSize(
_itemCount: number, _itemCount: number,
layout: 'single' | 'group' | 'multiline', layout: 'single' | 'group' | 'multiline',
fontSize: number, fontSize: number,
_hasNetItem = false, _hasNetItem = false,
items?: OsdItem[], items: OsdItem[] = [],
): { w: number; h: number } { ): { w: number; h: number } {
const charW = fontSize * 0.62 const charW = fontSize * 0.6 // 等宽 ASCII 字符宽(Cascadia/Consolas ≈0.6em
const cjkW = fontSize // CJK 全角字符宽
const barHPad = 8 // osd-bar 左右 padding 4*2 const barHPad = 8 // osd-bar 左右 padding 4*2
// 按硬件类型分组(与渲染逻辑一致) const isCjk = (ch: string) => {
const groupMap = new Map<string, OsdItem[]>() const c = ch.codePointAt(0)!
if (items?.length) { return (
for (const item of items) { (c >= 0x2e80 && c <= 0x9fff) ||
let gkey: string (c >= 0x3000 && c <= 0x303f) ||
if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network' (c >= 0xff00 && c <= 0xffef) ||
else if (item.groupId.startsWith('gpu')) gkey = 'gpu' (c >= 0xf900 && c <= 0xfaff)
else gkey = item.groupId )
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(item)
} }
// 字符串像素宽(CJK 全角,ASCII 等宽)
const textPx = (s: string) => {
let w = 0
for (const ch of s) w += isCjk(ch) ? cjkW : charW
return w
} }
const groupCount = Math.max(1, groupMap.size)
// 每组数据列宽度:标签(6ch) + 各项(数值+单位+箭头/gap) const showLabel = osdConfig.value?.showLabel !== false
const groupWidths: number[] = [] const en = osdConfig.value?.labelLanguage === 'en'
for (const [, groupItems] of groupMap) { const groupLabelText = (gkey: string): string => {
const labelW = 6 if (en) {
const dataW = groupItems.reduce((sum, item) => { switch (gkey) {
const isNet = item.special === 'net-up' || item.special === 'net-down' case 'cpu': return 'CPU'
return sum + (isNet ? 11 : 8) + 1 case 'gpu': return 'GPU'
}, 0) case 'memory': return 'RAM'
groupWidths.push(labelW + dataW) case 'storage': return 'DISK'
case 'network': return 'NET'
case 'motherboard': return 'MB'
case 'battery': return 'BAT'
case 'psu': return 'PSU'
default: return gkey.toUpperCase().slice(0, 6)
} }
}
switch (gkey) {
case 'cpu': return 'CPU'
case 'gpu': return 'GPU'
case 'memory': return '内存'
case 'storage': return '存储'
case 'network': return '网络'
case 'motherboard': return '主板'
case 'battery': return '电池'
case 'psu': return '电源'
default: return gkey
}
}
// 数值固定宽度(字符数,对应 fmtFixedValue 的 pad 宽度)
const numChars = (it: OsdItem): number => {
if (it.special === 'net-up' || it.special === 'net-down') return 6
switch (it.type) {
case 'load':
case 'level':
case 'temperature': return 3
case 'power':
case 'voltage': return 5
case 'clock':
case 'frequency': return 4
case 'fan': return 4
case 'data':
case 'smalldata': return 5
default: return 5
}
}
// 单位文本(对应 fmtFixedUnit;网速取最宽单位 MB/s 估算)
const showUnit = osdConfig.value?.showUnit !== false
const unitText = (it: OsdItem): string => {
if (it.special === 'net-up' || it.special === 'net-down') return showUnit ? 'MB/s' : ''
if (!showUnit) return ''
switch (it.type) {
case 'temperature': return '°C'
case 'load': return '%'
case 'power': return 'W'
case 'voltage': return 'V'
case 'fan': return 'RPM'
case 'clock':
case 'frequency': return 'MHz'
case 'data':
case 'smalldata': return 'GB'
case 'level': return '%'
default: return it.unit || ''
}
}
// 单一项像素宽:箭头 + 数值 + 单位 + 项内 gap(1px) + 单位 margin(1px)
const itemPx = (it: OsdItem): number => {
let w = it.special ? charW : 0 // 箭头
if (it.special) w += 1 // 箭头与数值 gap
w += numChars(it) * charW
const unit = unitText(it)
if (unit) w += textPx(unit) + 1 + 1 // 数值-单位 gap + 单位 left margin
return w
}
// 一个分组的像素宽:组内各子项 gap(3px) + 标签 + 各项
const groupWidthPx = (gkey: string, list: OsdItem[]): number => {
const labelW = showLabel ? textPx(groupLabelText(gkey)) : 0
const itemsW = list.reduce((s, it) => s + itemPx(it), 0)
const gapCount = list.length + (showLabel ? 1 : 0) - 1
return Math.ceil(labelW + itemsW + Math.max(0, gapCount) * 3)
}
// 构建分组(归一化 key,与渲染逻辑一致)
const groupMap = new Map<string, OsdItem[]>()
for (const it of items) {
let gkey = it.groupId
if (it.special === 'net-up' || it.special === 'net-down') gkey = 'network'
else if (it.groupId.startsWith('gpu')) gkey = 'gpu'
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(it)
}
const groupEntries = [...groupMap.entries()]
const groupCount = Math.max(1, groupEntries.length)
const gw = groupEntries.map(([k, list]) => groupWidthPx(k, list))
const lineH = Math.ceil(fontSize + 2)
if (layout === 'multiline') { if (layout === 'multiline') {
// 多行:取最宽行 // 每行 = 标签(min 4ch) + 组内 gap(4px) + 各项;取最宽行
const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10 const labelMinW = 4 * charW
const w = Math.ceil(maxLineW * charW + barHPad) let maxLineW = 0
const lineH = Math.ceil(fontSize + 2) for (const [gkey, list] of groupEntries) {
const h = Math.ceil(groupCount * lineH + 6) const labelW = showLabel ? Math.max(textPx(groupLabelText(gkey)), labelMinW) : 0
return { w: Math.max(120, w), h: Math.max(28, h) } const itemsW = list.reduce((s, it) => s + itemPx(it), 0)
const gapCount = list.length + (showLabel ? 1 : 0) - 1
maxLineW = Math.max(maxLineW, labelW + itemsW + Math.max(0, gapCount) * 4)
}
const w = Math.max(120, Math.ceil(maxLineW + barHPad))
const h = Math.max(28, Math.ceil(groupCount * lineH + 6))
return { w, h }
} }
if (layout === 'group') { if (layout === 'group') {
// 分组横排:各组横排 + 标题行 // 分组横排:各组横排,组间 gap(8px),每组含 padding(4*2)
const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4 const totalW = gw.reduce((s, w) => s + w, 0)
const w = Math.ceil(totalW * charW + barHPad) + groupCount * 8 // 每组左右 padding 4*2
+ Math.max(0, groupCount - 1) * 8 // 组间 gap
const w = Math.max(120, Math.ceil(totalW + barHPad))
const titleH = Math.ceil(fontSize * 0.85) + 2 const titleH = Math.ceil(fontSize * 0.85) + 2
const dataH = Math.ceil(fontSize) + 2 const dataH = lineH
const h = Math.ceil(titleH + dataH + 10) const h = Math.max(40, Math.ceil(titleH + dataH + 3 + 3))
return { w: Math.max(120, w), h: Math.max(40, h) } return { w, h }
} }
// single:单行分组式,各组横排 + 组间 | 分隔符(1ch) // single:单行分组式,组间 | 分隔符 + 组间 gap(4px)
const sepW = (groupCount - 1) * 1 const sepW = (groupCount - 1) * (textPx('|') + 4)
const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW const betweenW = Math.max(0, groupCount - 1) * 4
const w = Math.ceil(totalW * charW + barHPad) const w = Math.max(120, Math.ceil(gw.reduce((s, w) => s + w, 0) + sepW + betweenW + barHPad))
const h = Math.ceil(fontSize + 8) const h = Math.max(28, Math.ceil(fontSize + 8))
return { w: Math.max(120, w), h: Math.max(28, h) } return { w, h }
} }
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */ /** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
@@ -1049,21 +1193,30 @@ export const useMonitorStore = defineStore('monitor', () => {
// stop 句柄存入 osdWatchStopsdispose 时统一释放,避免模块重挂载后重复注册 // stop 句柄存入 osdWatchStopsdispose 时统一释放,避免模块重挂载后重复注册
// OSD 开关变化时创建/隐藏悬浮窗 // OSD 开关变化时创建/隐藏悬浮窗
// 注意:开启 OSD 不再自动开启"应用启动时自动启动监控内核"。
// 二者保持独立(双向联动会导致:关自动启动→关 OSD→再开 OSD→自动启动又被强行打开)。
osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => { osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => {
if (enabled) { if (enabled) {
// OSD 显示开启时自动开启"应用启动时自动启动监控内核",
// 使 OSD 持续显示不因重启而中断
if (!autoStart.value) {
setAutoStart(true).catch(e => logger.error('[OSD] 自动开启 autoStart 失败: ' + e))
}
// 开启时若显示项为空则不创建窗口 // 开启时若显示项为空则不创建窗口
if (osdConfig.value.overlayItems.length === 0) return if (osdConfig.value.overlayItems.length === 0) return
// 游戏全屏自动隐藏期间不创建窗口(退出全屏时由 osd-game-inactive 统一恢复)
if (gameFullscreen.value && osdConfig.value.gameAutoHide) return
ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e)) ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e))
} else { } else {
hideOverlayWindow().catch(e => logger.error('[OSD] 隐藏悬浮窗失败: ' + e)) hideOverlayWindow().catch(e => logger.error('[OSD] 隐藏悬浮窗失败: ' + e))
} }
})) }))
// 游戏中切换"自动隐藏"开关:立即生效(关闭时恢复显示,开启时立即隐藏)
osdWatchStops.push(watch(() => osdConfig.value.gameAutoHide, (enabled) => {
if (!osdConfig.value.overlayEnabled || !gameFullscreen.value) return
if (enabled) {
hideOverlayWindow().catch(e => logger.error('[OSD] 游戏全屏隐藏悬浮窗失败: ' + e))
} else {
ensureOverlayWindow().catch(e => logger.error('[OSD] 恢复悬浮窗失败: ' + e))
}
}))
// 显示项变化时:无项则隐藏窗口,有项且开关开则确保窗口存在 // 显示项变化时:无项则隐藏窗口,有项且开关开则确保窗口存在
osdWatchStops.push(watch(() => osdConfig.value.overlayItems.length, (len) => { osdWatchStops.push(watch(() => osdConfig.value.overlayItems.length, (len) => {
if (!osdConfig.value.overlayEnabled) return if (!osdConfig.value.overlayEnabled) return
+72 -1
View File
@@ -12,7 +12,8 @@ import {
type ProfileMeta, type ProfileMeta,
type KernelInfo, type KernelInfo,
type KernelUpdateInfo, type KernelUpdateInfo,
type ProxyStatus type ProxyStatus,
type TrafficSnapshot,
} from '@/lib/bindings' } from '@/lib/bindings'
// Rust 端结构体字段均带 serde(default),返回必完整;用 Required 收窄 bindings 的 optional // Rust 端结构体字段均带 serde(default),返回必完整;用 Required 收窄 bindings 的 optional
@@ -53,6 +54,32 @@ export interface ProxiesResponse {
proxies: Record<string, ProxyNode> proxies: Record<string, ProxyNode>
} }
/** mihomo /connections 单条连接(完整字段以原始 JSON 为准,仅取前端用到的部分) */
export interface ProxyConnection {
id: string
chains?: string[]
rule?: string
rulePayload?: string
upload: number
download: number
start: string
metadata?: {
network?: string
type?: string
process?: string
host?: string
sourceIP?: string
sourcePort?: number
destinationIP?: string
destinationPort?: number
}
}
/** mihomo /connections 响应(原始 JSON,字段可能缺失) */
export interface ConnectionsResponse {
connections?: ProxyConnection[]
}
export interface MihomoVersion { export interface MihomoVersion {
version: string version: string
meta?: boolean meta?: boolean
@@ -65,6 +92,10 @@ export const useProxyStore = defineStore('proxy', () => {
const proxies = ref<Record<string, ProxyNode>>({}) const proxies = ref<Record<string, ProxyNode>>({})
const settings = ref<FullProxySettings | null>(null) const settings = ref<FullProxySettings | null>(null)
const systemProxy = ref(false) const systemProxy = ref(false)
/** 实时流量快照(上传/下载速率、会话总量、活跃连接数) */
const traffic = ref<TrafficSnapshot | null>(null)
/** 当前活跃连接列表(仅连接页签需要时拉取) */
const connections = ref<ProxyConnection[] | null>(null)
/** 是否已完成首次加载(避免初始 null/false 导致闪烁误导状态) */ /** 是否已完成首次加载(避免初始 null/false 导致闪烁误导状态) */
const initialized = ref(false) const initialized = ref(false)
@@ -209,6 +240,40 @@ export const useProxyStore = defineStore('proxy', () => {
} }
} }
// ---------- 流量 / 连接 ----------
/** 刷新实时流量快照(速率 + 会话总量 + 活跃连接数)。失败时保留上一次数据,避免抖动。 */
const refreshTraffic = async () => {
try {
traffic.value = await commands.proxyTraffic()
} catch {
/* mihomo 瞬时不可用(如重启)时保留上一次数据 */
}
}
/** 刷新当前活跃连接列表(原始 /connections)。供「连接」页签低频拉取。 */
const refreshConnections = async () => {
try {
const resp = await invoke<ConnectionsResponse>('proxy_get_connections')
connections.value = resp.connections ?? []
} catch {
/* mihomo 瞬时不可用(如重启)时保留上一次数据 */
}
}
/** 关闭指定连接 */
const closeConnection = async (id: string) => {
try {
await commands.proxyCloseConnection(id)
// 本地立即移除,无需等下一轮轮询
if (connections.value) {
connections.value = connections.value.filter((c) => c.id !== id)
}
} catch (e) {
logger.error('关闭连接失败: ' + e)
throw e
}
}
const saveSettings = async (s: FullProxySettings) => { const saveSettings = async (s: FullProxySettings) => {
await commands.proxySaveSettings(s) await commands.proxySaveSettings(s)
settings.value = s settings.value = s
@@ -487,6 +552,8 @@ export const useProxyStore = defineStore('proxy', () => {
proxies, proxies,
settings, settings,
systemProxy, systemProxy,
traffic,
connections,
initialized, initialized,
installing, installing,
installProgress, installProgress,
@@ -516,6 +583,10 @@ export const useProxyStore = defineStore('proxy', () => {
setSystemProxy, setSystemProxy,
clearSystemProxy, clearSystemProxy,
toggleSystemProxy, toggleSystemProxy,
// traffic & connections
refreshTraffic,
refreshConnections,
closeConnection,
// kernel update / install // kernel update / install
checkKernelUpdate, checkKernelUpdate,
updateKernel, updateKernel,
+18 -4
View File
@@ -54,19 +54,33 @@ export default defineConfig(async () => ({
clearScreen: false, clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available // 2. tauri expects a fixed port, fail if that port is not available
server: { server: {
port: 1420, // 1420/1421 落在 Windows 保留端口段 1333-1432 内(Hyper-V/WinNAT 动态保留),
// 绑定时报 EACCES,故改用 14210/14211netsh excludedportrange 确认可用)
port: 14210,
strictPort: true, strictPort: true,
host: host || false, host: host || false,
hmr: host hmr: host
? { ? {
protocol: "ws", protocol: "ws",
host, host,
port: 1421, port: 14211,
} }
: undefined, : undefined,
watch: { watch: {
// 3. tell Vite to ignore watching `src-tauri` // 3. Vite 忽略监听这些大目录,避免 Windows 上 chokidar 递归注册监听句柄
ignored: ["**/src-tauri/**"], // 拖慢 dev 冷启动。src-tauri/target 是 Rust 构建产物(本仓库可达 5 万+ 文件、
// 数十 GB),默认仅忽略 node_modules/.git/.vite,若被递归遍历,WebView 首屏
// 加载会被阻塞几十秒(详见 juejin.cn/post/7657865700393451554)。
ignored: [
"**/src-tauri/**",
"**/node_modules/**",
"**/dist/**",
"**/release_stage/**",
"**/ThingHK/**",
"**/.git/**",
"**/.idea/**",
"**/.vite/**",
],
}, },
}, },
})); }));