diff --git a/README.md b/README.md index 628d0e7..1b65dff 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Thing/ - [x] 自建进程内下载引擎(多线程 HTTP/HTTPS,无需外部内核) - [x] 接管浏览器下载,浏览器扩展(Thing Extension) - [x] HTTP 下载支持 -- [ ] BT/磁力链接支持(后续支持) +- [x] BT/磁力链接支持(后续支持) - [x] 下载任务管理(历史) - [x] 速度限制 - [x] 断点续传 @@ -124,8 +124,8 @@ Thing/ ### 第三阶段:优化与完善 -- [x] 性能优化(P1/P2:轮询随窗口可见性暂停、批量测速限并发、渲染 memo 化等,见 MODULE_REVIEW.md) -- [x] 错误处理与日志完善(B5 进程级全局日志器、异常兜底) +- [x] 性能优化(见 MODULE_REVIEW.md) +- [x] 错误处理与日志完善(全局日志器、异常兜底) - [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节) - [x] 自动更新机制 - [x] 打包发布 diff --git a/ThingHK/Contracts.cs b/ThingHK/Contracts.cs index 55705f1..e75e30c 100644 --- a/ThingHK/Contracts.cs +++ b/ThingHK/Contracts.cs @@ -66,6 +66,8 @@ internal sealed class KernelStatus { public bool Ready { get; set; } public bool IsAdmin { get; set; } + /// PawnIO 驱动是否已安装(ring0 传感器读取依赖它或 WinRing0,缺失时温度/频率通常无法读取) + public bool PawnIoInstalled { get; set; } public double UptimeMs { get; set; } public int GroupCount { get; set; } public int SensorCount { get; set; } diff --git a/ThingHK/HardwareManager.cs b/ThingHK/HardwareManager.cs index 0ba7735..b594c8f 100644 --- a/ThingHK/HardwareManager.cs +++ b/ThingHK/HardwareManager.cs @@ -277,7 +277,7 @@ internal sealed class HardwareManager : IDisposable _ => "", }; - private static bool IsRunningAsAdmin() + internal static bool IsRunningAsAdmin() { try { diff --git a/ThingHK/HttpEndpoints.cs b/ThingHK/HttpEndpoints.cs index b7da4e8..20b7db9 100644 --- a/ThingHK/HttpEndpoints.cs +++ b/ThingHK/HttpEndpoints.cs @@ -44,6 +44,7 @@ internal static class HttpEndpoints { Ready = hw?.Ready ?? false, IsAdmin = hw?.IsAdmin ?? false, + PawnIoInstalled = PawnIoSupport.IsServiceInstalled(), UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds, GroupCount = snap?.Groups.Count ?? 0, SensorCount = kernel.Scheduler.Cache.SensorCount, diff --git a/ThingHK/PawnIoSupport.cs b/ThingHK/PawnIoSupport.cs new file mode 100644 index 0000000..18b8583 --- /dev/null +++ b/ThingHK/PawnIoSupport.cs @@ -0,0 +1,101 @@ +using System.Diagnostics; +using Microsoft.Win32; + +namespace ThingHK; + +/// +/// 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 诊断模式不安装,保持被动。 +/// +internal static class PawnIoSupport +{ + /// 驱动服务注册表键:存在即认为已安装 + private const string ServiceKeyName = @"SYSTEM\CurrentControlSet\Services\PawnIO"; + + private const string SetupFileName = "PawnIO_setup.exe"; + + /// 3010 = ERROR_SUCCESS_REBOOT_REQUIRED(安装成功但需重启生效) + private const int ExitCodeRebootRequired = 3010; + + /// 驱动安装通常数秒内完成,留足余量防止卡死启动流程 + private const int InstallTimeoutMs = 90_000; + + /// 检测 PawnIO 驱动服务是否已注册 + public static bool IsServiceInstalled() + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(ServiceKeyName); + return key != null; + } + catch + { + return false; + } + } + + /// + /// 确保 PawnIO 就绪:已安装直接返回;未安装且当前已提权时静默安装。 + /// 返回描述性结果(写入 stderr 日志 + /status 诊断)。 + /// + 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}"; + } + } +} diff --git a/ThingHK/Program.cs b/ThingHK/Program.cs index 04075b8..195f484 100644 --- a/ThingHK/Program.cs +++ b/ThingHK/Program.cs @@ -87,6 +87,11 @@ internal static class Program Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} config={configPath ?? "(默认)"} fast={fastMs}ms slow={slowMs}ms"); + // PawnIO:ring0 传感器读取的首选驱动(未安装且已提权时静默安装, + // 避开 WinRing0 被系统阻止列表/杀软拦截导致的温度/频率缺失) + string pawnIoResult = PawnIoSupport.EnsureInstalled(); + Console.Error.WriteLine($"[ThingHK] PawnIO: {pawnIoResult}"); + using var kernel = new KernelHost(); await kernel.StartAsync(configPath, fastMs, slowMs); diff --git a/ThingHK/ThingHK.csproj b/ThingHK/ThingHK.csproj index 21e6b43..678124d 100644 --- a/ThingHK/ThingHK.csproj +++ b/ThingHK/ThingHK.csproj @@ -25,7 +25,7 @@ - + diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5442008..eb21671 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -69,7 +69,7 @@ windows-sys = { version = "0.52", features = [ "Win32_Storage_Xps", "Win32_Storage_FileSystem", ] } -# Explorer 髯キ隨ャ・ヲ髮・スコ莨・€・カ繝サ・ョ髯滄摩・「髮」・ス・」・つ€髮趣スャ陷茨スキ繝サ・シ郢晢スサShellWindows COM郢晢スサ闔ィ螟イ・ス・シ陞「・サ繝サ・サ郢晢スサ繝サ・シ陷肴コ倥・鬨セ蛹・スス・ィ髯具スサ繝サ・ー鬨セ・ァ郢晢スサfeature郢晢スサ隴エ・ァ髢迹壼エ輔・・カ鬩帛ク帙・繝サ・ッ陷ソ・ー繝サ・ス鬪ー蜈キ・ス・ァ繝サ・ッ +# Explorer 鬯ョ・ッ繝サ・キ鬮ォ・ィ繝サ・ャ郢晢スサ繝サ・ヲ鬯ョ・ョ郢晢スサ繝サ・ス繝サ・コ髣費スィ郢晢スサ・つ€郢晢スサ繝サ・カ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ョ・ッ雋翫・譚溽ケ晢スサ繝サ・「鬯ョ・ョ繝サ・」郢晢スサ繝サ・ス郢晢スサ繝サ・」郢晢スサ邵コ・、・つ€鬯ョ・ョ髮懶ス」繝サ・ス繝サ・ャ鬮ッ・キ髣鯉スィ繝サ・ス繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・シ鬩幢ス「隴趣ス「繝サ・ス繝サ・サShellWindows COM鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮」雋サ・ス・ィ髯樊サゑスス・イ郢晢スサ繝サ・ス郢晢スサ繝サ・シ鬮ッ讖ク・ス・「郢晢スサ繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬮ッ・キ髢ァ・エ繝サ・コ陋滂ス・郢晢スサ鬯ッ・ィ繝サ・セ髯具スケ郢晢スサ繝サ・ス繝サ・ス郢晢スサ繝サ・ィ鬯ョ・ッ陷茨スキ繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ィ繝サ・セ郢晢スサ繝サ・ァ鬩幢ス「隴趣ス「繝サ・ス繝サ・サfeature鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮ォ・エ繝サ・エ郢晢スサ繝サ・ァ鬯ョ・「繝サ・ー髴托スケ陞「・シ繝サ・エ髴域鱒繝サ郢晢スサ繝サ・カ鬯ッ・ゥ陝カ蟷「・ス・ク陝カ蜷カ繝サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬮ッ・キ繝サ・ソ郢晢スサ繝サ・ー驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ェ繝サ・ー髯キ闌ィ・ス・キ郢晢スサ繝サ・ス郢晢スサ繝サ・ァ驛「譎「・ス・サ郢晢スサ繝サ・ッ windows = { version = "0.52", features = [ "Win32_Foundation", "Win32_System_Com", @@ -82,14 +82,14 @@ windows = { version = "0.52", features = [ [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2" -# ===== 编译优化 ===== -# dev 构建启用增量编译 + 行号级调试(提速本地迭代),仅影响 `tauri dev` +# ===== 驛帛・・ッ蜿ー・シ莨懷密 ===== +# dev 隴ォ繝サ・サ・コ陷キ・ッ騾包スィ陟・ィ」纃シ驛帛・・ッ繝サ+ 髯ヲ謔滓差驛、・ァ髫ケ繝サ・ッ蛹・スシ蝓溽スイ鬨セ貊捺た陜ィ・ー髴托スュ闔会ス」繝サ莨夲スシ蠕。・サ繝サ・ス・ア陷ゥ繝サ`tauri dev` [profile.dev] incremental = true debug = "line-tables-only" -# release 譫・サコ逖ヲ霄ォ・壼悉隨ヲ蜿キ + LTO・悟㍼蟆丞ョ芽」・桁菴鍋ァッ +# release 髫エ・ォ郢晢スサ繝サ・サ繝サ・コ鬨セ蜴・スス・ヲ鬮エ繝サ・ス・ォ郢晢スサ陞「・シ隰碑崟蝨キ繝サ・ヲ髯キ・ソ繝サ・キ + LTO郢晢スサ隰疲コ倩ゥ宣劑繝サ・ク讖ク・ス・ョ髣・スス繝サ・」郢晢スサ隴ッ竏ャ謚・ェー蜈キ・ス・ァ繝サ・ッ [profile.release] strip = true lto = true diff --git a/src-tauri/binaries/PawnIO_setup.exe b/src-tauri/binaries/PawnIO_setup.exe new file mode 100644 index 0000000..5c3cfb2 Binary files /dev/null and b/src-tauri/binaries/PawnIO_setup.exe differ diff --git a/src-tauri/binaries/ThingHK.exe b/src-tauri/binaries/ThingHK.exe index 91bd0c6..779d907 100644 Binary files a/src-tauri/binaries/ThingHK.exe and b/src-tauri/binaries/ThingHK.exe differ diff --git a/src-tauri/binaries/mihomo.exe b/src-tauri/binaries/mihomo.exe index fa1f39b..6677296 100644 Binary files a/src-tauri/binaries/mihomo.exe and b/src-tauri/binaries/mihomo.exe differ diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index 6a782c2..9340efa 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -44,6 +44,8 @@ pub mod events { // OSD 窗口 pub const OSD_SYSTEM_UI_ACTIVE: &str = "osd-system-ui-active"; pub const OSD_SYSTEM_UI_INACTIVE: &str = "osd-system-ui-inactive"; + pub const OSD_GAME_ACTIVE: &str = "osd-game-active"; + pub const OSD_GAME_INACTIVE: &str = "osd-game-inactive"; pub const OSD_START_DRAG: &str = "osd-start-drag"; pub const OSD_END_DRAG: &str = "osd-end-drag"; // 截图 diff --git a/src-tauri/src/download_engine/engine.rs b/src-tauri/src/download_engine/engine.rs index e649b7a..f46cc98 100644 --- a/src-tauri/src/download_engine/engine.rs +++ b/src-tauri/src/download_engine/engine.rs @@ -663,6 +663,67 @@ impl DownloadEngine { } } + /// BT 任务重新入会:句柄缺失(元数据未解析完/句柄被移除)时,重新加入会话以取回句柄。 + /// librqbit 对已加入的种子返回 AlreadyManaged(复用现有句柄,已下载片段保留), + /// 因此对暂停→继续、错误→继续等场景都幂等、安全。 + async fn reenter_bt(&self, id: &str, url: &str, dir: &str) -> Result<(), String> { + let added = tokio::time::timeout( + std::time::Duration::from_secs(crate::download_engine::torrent::INSPECT_TIMEOUT_SECS), + self.inner.torrent.add_async(url, dir), + ).await; + let (_, handle) = match added { + Ok(Ok(v)) => v, + Ok(Err(e)) => return Err(e), + Err(_) => return Err("解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息".to_string()), + }; + + // 存回句柄(句柄存在则更新,缺失则新建) + { + let mut handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + match handles.get_mut(id) { + Some(h) => h.bt = Some(handle.clone()), + None => { + handles.insert( + id.to_string(), + TaskHandle { + gen: self.inner.next_gen.fetch_add(1, Ordering::SeqCst) + 1, + cancel: Arc::new(AtomicBool::new(false)), + progress: Vec::new(), + join: Mutex::new(None), + bt: Some(handle.clone()), + }, + ); + } + } + } + + // 等待元数据就绪(本地 .torrent 秒回;磁力依赖网络,带超时) + if let Err(e) = self.inner.torrent.wait_initialized(&handle).await { + return Err(e); + } + // 刷新结构化任务元数据(filename / infohash / 文件列表 / 大小) + match self.inner.torrent.inspect(url).await { + Ok(info) => { + let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(t) = tasks.get_mut(id) { + t.filename = info.name.clone(); + t.info_hash = Some(info.info_hash.clone()); + t.bt_files = info.files.clone(); + t.total_size = info.total_size; + t.bt_metadata_ready = true; + t.error = None; + // 复位为 Queued:start_download 只在 Queued 下继续推进(首调时已置 Active) + if matches!(t.status, TaskStatus::Active | TaskStatus::Error) { + t.status = TaskStatus::Queued; + } + } + } + Err(e) => return Err(e), + } + self.persist_now(); + Ok(()) + } + /// BT 元数据解析失败:任务置为 Error 并通知前端刷新 fn fail_bt_resolve(engine: &DownloadEngine, id: &str, error: String) { { @@ -1245,15 +1306,28 @@ impl DownloadEngine { // ===== BitTorrent 下载分支 ===== if is_bt { let Some(bt) = existing_bt else { - // 无 BT 句柄(异常):标记 Error - { - let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(task) = tasks.get_mut(&id) { - task.status = TaskStatus::Error; - task.error = Some("BitTorrent 句柄缺失".to_string()); + // 无 BT 句柄:常见于元数据尚未解析完就点了"暂停/继续",或句柄已被移除。 + // 不能直接报"句柄缺失"(否则此类任务点"继续"会立即失败),改为后台重新加入 + // 会话(如已加入则复用已有句柄,已下载片段保留),元数据就绪后再进入下载。 + let engine2 = self.clone(); + let id2 = id.clone(); + let url2 = task.url.clone(); + let dir2 = task.dir.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = engine2.reenter_bt(&id2, &url2, &dir2).await { + crate::logger::log_error("download", &format!("BT 任务重入会话失败: {}", e)); + let mut tasks = engine2.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(t) = tasks.get_mut(&id2) { + if t.status == TaskStatus::Active || t.status == TaskStatus::Queued { + t.status = TaskStatus::Error; + t.error = Some(e); + } + } + engine2.persist_now(); + return; } - } - self.persist_now(); + engine2.start_download(id2); + }); return; }; diff --git a/src-tauri/src/download_engine/torrent.rs b/src-tauri/src/download_engine/torrent.rs index 614c578..77afda8 100644 --- a/src-tauri/src/download_engine/torrent.rs +++ b/src-tauri/src/download_engine/torrent.rs @@ -414,13 +414,21 @@ impl TorrentDownloader { .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) -> Result<(), String> { let session = self.get_session().await?; - session - .unpause(handle) - .await - .map_err(|e| format!("继续种子失败: {}", e)) + 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 是否同时删除已下载文件) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index df21afd..b8c2c3a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,7 +43,8 @@ use monitor_kernel::{ use network_monitor::network_status; use osd_window::{ 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::{ process_all_status, process_start, process_status, @@ -84,7 +85,10 @@ use quickpanel::{ quickpanel_show_window, quickpanel_unregister_shortcut, quickpanel_focus_main_window, }; use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready}; -use updater::{app_version, update_check, update_install, update_thinghk}; +use updater::{ + app_version, update_check, update_install, update_thinghk_apply, update_thinghk_cancel, + update_thinghk_confirm, ThinghkUpdateState, +}; #[tauri::command] fn quit_app(app: tauri::AppHandle) { @@ -109,8 +113,9 @@ fn export_bindings() { // 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper .error_handling(ErrorHandlingMode::Throw) .commands(collect_commands![ - // 应用更新(4) - app_version, update_check, update_install, update_thinghk, + // 应用更新(6) + app_version, update_check, update_install, update_thinghk_apply, + update_thinghk_confirm, update_thinghk_cancel, // proxy(20) proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings, @@ -175,12 +180,15 @@ pub fn run() { .build() ) .manage(ProcessManager::new()) + .manage(ThinghkUpdateState::new()) .invoke_handler(tauri::generate_handler![ quit_app, app_version, update_check, update_install, - update_thinghk, + update_thinghk_apply, + update_thinghk_confirm, + update_thinghk_cancel, process_start, process_stop, process_status, @@ -238,6 +246,7 @@ pub fn run() { osd_set_topmost, osd_start_drag_watch, osd_start_topmost_watch, + osd_start_game_watch, osd_stop_watch, downloader_get_tasks, downloader_add_task, diff --git a/src-tauri/src/monitor_kernel.rs b/src-tauri/src/monitor_kernel.rs index dbd2257..e3f70da 100644 --- a/src-tauri/src/monitor_kernel.rs +++ b/src-tauri/src/monitor_kernel.rs @@ -228,6 +228,8 @@ pub fn check_and_relaunch_if_needed(app_data_dir: &std::path::Path) -> bool { pub struct KernelStatus { pub ready: bool, pub is_admin: bool, + /// PawnIO 驱动是否已安装;旧版内核无此字段,Option 兼容 + pub pawn_io_installed: Option, pub uptime_ms: f64, pub group_count: u32, pub sensor_count: u32, @@ -370,7 +372,9 @@ impl MonitorKernel { self.root.join("hardware-config.json") } - /// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制 + /// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制。 + /// 同时把 PawnIO_setup.exe(可选资源)复制过去——内核提权启动时会静默安装它, + /// 作为 WinRing0 被系统/杀软拦截时读取温度/频率的替代驱动。 pub fn prepare_kernel(&self, app: &AppHandle) -> Result { let kernel = self.kernel_path(); 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 { path: kernel.to_string_lossy().to_string(), exists: kernel.exists(), @@ -489,12 +511,20 @@ impl MonitorKernel { Ok(resp) if resp.status().is_success() => { match resp.json::().await { Ok(s) if s.ready => { + // PawnIO 诊断:已提权但驱动缺失时,温度/频率等 ring0 传感器大概率无法读取 + if s.is_admin && s.pawn_io_installed == Some(false) { + crate::logger::log_warn( + "monitor", + "Kernel 已提权但 PawnIO 驱动未安装,CPU 温度/频率可能无法读取(检查 cores/PawnIO_setup.exe 是否随包部署)", + ); + } let _ = app.emit( crate::constants::events::MONITOR_READY, serde_json::json!({ "isAdmin": s.is_admin, "sensorCount": s.sensor_count, "providers": s.providers, + "pawnIoInstalled": s.pawn_io_installed, }), ); return Ok(()); diff --git a/src-tauri/src/osd_window.rs b/src-tauri/src/osd_window.rs index 21cdee2..0c2dcd6 100644 --- a/src-tauri/src/osd_window.rs +++ b/src-tauri/src/osd_window.rs @@ -15,9 +15,12 @@ use tauri::{AppHandle, Emitter}; static DRAG_STOP: OnceLock> = OnceLock::new(); /// 任务栏覆盖监视线程停止标志 static TOPMOST_STOP: OnceLock> = OnceLock::new(); +/// 游戏全屏监视线程停止标志 +static GAME_STOP: OnceLock> = OnceLock::new(); /// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏) static DRAG_HANDLE: Mutex>> = Mutex::new(None); static TOPMOST_HANDLE: Mutex>> = Mutex::new(None); +static GAME_HANDLE: Mutex>> = Mutex::new(None); fn drag_stop() -> &'static Arc { DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true))) @@ -27,6 +30,10 @@ fn topmost_stop() -> &'static Arc { TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true))) } +fn game_stop() -> &'static Arc { + GAME_STOP.get_or_init(|| Arc::new(AtomicBool::new(true))) +} + /// 停止右键拖动监视线程并等待其退出(标志置位后线程最迟一个轮询周期退出) fn stop_drag_thread() { 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)] mod win_api { use tauri::{AppHandle, Manager}; use windows_sys::Win32::Foundation::{POINT, RECT}; + use windows_sys::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST, + }; use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_RBUTTON}; use windows_sys::Win32::UI::WindowsAndMessaging::{ - GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, - GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos, - GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, - SWP_NOSIZE, SWP_NOZORDER, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE, + GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetWindowLongW, + GetWindowRect, GetWindowThreadProcessId, SendMessageW, SetWindowLongPtrW, SetWindowPos, + GWL_EXSTYLE, GWL_STYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, + SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, }; + /// 供模块外全屏判定使用的窗口样式常量(pub re-export) + pub use windows_sys::Win32::UI::WindowsAndMessaging::WS_CAPTION; /// windows-sys 的 HWND 类型别名(isize) pub type Hwnd = isize; @@ -225,6 +249,41 @@ mod win_api { | "Windows.UI.Shell.ShellFlyoutWindow" // Win11 Shell 弹出 ) } + + /// 获取窗口样式(GWL_STYLE) + pub fn get_window_style(hwnd: Hwnd) -> isize { + unsafe { GetWindowLongW(hwnd, GWL_STYLE) as isize } + } + + /// 判断窗口是否属于本进程(Thing 自身窗口不参与全屏判定) + pub fn is_own_process(hwnd: Hwnd) -> bool { + let mut pid: u32 = 0; + unsafe { + GetWindowThreadProcessId(hwnd, &mut pid); + } + pid != 0 && pid == std::process::id() + } + + /// 获取窗口所在显示器(最近匹配)的矩形(物理像素) + pub fn get_monitor_rect(hwnd: Hwnd) -> Option { + let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) }; + if monitor == 0 { + return None; + } + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() 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) @@ -369,11 +428,98 @@ pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> { Ok(()) } +/// 判断窗口是否为全屏应用(无边框/独占全屏游戏) +/// +/// 判定条件(全部满足): +/// 1. 无 WS_CAPTION 样式 —— 排除普通应用的"最大化"(即使系统任务栏设为自动隐藏, +/// 最大化窗口覆盖率也接近 100%,但它们带标题栏,靠样式即可区分) +/// 2. 非本进程窗口(Thing 主窗口/悬浮窗自身) +/// 3. 窗口矩形与所在显示器矩形的交集覆盖率 ≥ 95%(兼容缩放/1px 误差) +#[cfg(windows)] +fn is_fullscreen_game_window(hwnd: isize) -> bool { + if win_api::get_window_style(hwnd) & (win_api::WS_CAPTION as isize) != 0 { + return false; + } + if win_api::is_own_process(hwnd) { + return false; + } + let (Some(win_rect), Some(mon_rect)) = ( + win_api::get_window_rect(hwnd), + win_api::get_monitor_rect(hwnd), + ) else { + return false; + }; + let iw = (win_rect.right.min(mon_rect.right) - win_rect.left.max(mon_rect.left)).max(0) as i64; + let ih = (win_rect.bottom.min(mon_rect.bottom) - win_rect.top.max(mon_rect.top)).max(0) as i64; + let mw = (mon_rect.right - mon_rect.left).max(0) as i64; + let mh = (mon_rect.bottom - mon_rect.top).max(0) as i64; + let mon_area = (mw * mh).max(1); + iw * ih * 100 >= mon_area * 95 +} + +/// 启动游戏全屏监视 +/// +/// 轮询检测前景窗口是否为全屏应用(无边框/独占全屏游戏), +/// 状态变化时发出 `osd-game-active` / `osd-game-inactive` 事件。 +/// 前端据此隐藏/恢复 OSD:透明置顶 WebView 悬浮窗会占用 DWM 合成路径, +/// 禁用游戏的独立翻转(MPO),是游戏中掉帧的根源;隐藏悬浮窗即可排除影响。 +#[tauri::command] +pub fn osd_start_game_watch(app: AppHandle) -> Result<(), String> { + // 停止旧线程,等待其退出后再启动新线程 + stop_game_thread(); + let stop_flag = game_stop().clone(); + stop_flag.store(false, Ordering::SeqCst); + + let app_handle = app.clone(); + + let handle = thread::spawn(move || { + let mut fullscreen_active = false; + + loop { + if stop_flag.load(Ordering::SeqCst) { + break; + } + + #[cfg(windows)] + { + let fg = win_api::get_foreground_window(); + let fullscreen = fg != 0 + && win_api::get_class_name(fg) + .map_or(false, |c| !win_api::is_system_ui_class(&c)) + && is_fullscreen_game_window(fg); + + if fullscreen != fullscreen_active { + fullscreen_active = fullscreen; + let event = if fullscreen { + crate::constants::events::OSD_GAME_ACTIVE + } else { + crate::constants::events::OSD_GAME_INACTIVE + }; + let _ = app_handle.emit(event, ()); + crate::logger::log_info( + "osd", + &format!("全屏应用前台: {}", if fullscreen { "是 → 隐藏 OSD" } else { "否 → 恢复 OSD" }), + ); + } + } + + thread::sleep(Duration::from_millis(1000)); + } + }); + + if let Ok(mut guard) = GAME_HANDLE.lock() { + *guard = Some(handle); + } + + Ok(()) +} + /// 停止所有 OSD 监视线程 #[tauri::command] pub fn osd_stop_watch() { stop_drag_thread(); stop_topmost_thread(); + stop_game_thread(); } /// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠) diff --git a/src-tauri/src/process_manager.rs b/src-tauri/src/process_manager.rs index 8b0c609..744db84 100644 --- a/src-tauri/src/process_manager.rs +++ b/src-tauri/src/process_manager.rs @@ -11,6 +11,11 @@ use tauri::{AppHandle, Emitter, Manager}; // CREATE_NO_WINDOW = 0x08000000,阻止子进程创建新的控制台窗口 #[cfg(windows)] pub const CREATE_NO_WINDOW: u32 = 0x08000000; +// CREATE_NEW_CONSOLE = 0x00000010,强制为控制台类子进程新开一个可见控制台窗口。 +// 从 GUI 宿主(无控制台)直接 spawn cmd/powershell 等控制台程序时若不设置, +// 子进程会挂到隐藏控制台/不显示窗口,表现为"点击没反应"。 +#[cfg(windows)] +pub const CREATE_NEW_CONSOLE: u32 = 0x00000010; // Windows Job Object 相关常量,用于异常退出时自动清理子进程 #[cfg(windows)] diff --git a/src-tauri/src/quickpanel/commands.rs b/src-tauri/src/quickpanel/commands.rs index 91edc26..57a296b 100644 --- a/src-tauri/src/quickpanel/commands.rs +++ b/src-tauri/src/quickpanel/commands.rs @@ -505,11 +505,35 @@ pub fn quickpanel_run_custom_command(command: String, args: Vec) -> Resu /// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口) /// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。 +/// - 控制台类交互程序(cmd/powershell/pwsh)额外设置 CREATE_NEW_CONSOLE, +/// 否则从 GUI 宿主启动时无可见控制台窗口(表现为"点击没反应")。 +/// - .msc 控制台文件(如 devmgmt.msc)不可被 CreateProcess 直接执行, +/// 改由 mmc 打开(路径解析到 System32,不受当前工作目录影响)。 #[tauri::command] #[specta::specta] pub fn quickpanel_run_system_command(command: String, args: Vec) -> Result<(), String> { + let lower = command.to_lowercase(); + if lower.ends_with(".msc") { + // 控制台文件:通过 mmc 打开(GUI 程序,无需新控制台) + let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into()); + let path = format!("{}\\System32\\{}", system_root, command); + let mut c = std::process::Command::new("mmc"); + c.arg(&path); + return c + .spawn() + .map(|_| ()) + .map_err(|e| format!("运行系统命令失败: {}", e)); + } let mut cmd = std::process::Command::new(&command); cmd.args(&args); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + let c = lower; + if c == "cmd" || c == "powershell" || c == "pwsh" { + cmd.creation_flags(crate::process_manager::CREATE_NEW_CONSOLE); + } + } cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?; Ok(()) } diff --git a/src-tauri/src/updater/mod.rs b/src-tauri/src/updater/mod.rs index c2d1356..e329bea 100644 --- a/src-tauri/src/updater/mod.rs +++ b/src-tauri/src/updater/mod.rs @@ -2,18 +2,65 @@ //! 更新源为自建 Gitea:`https://gitea.atie.fun/LFeng/Thing` 的 release 资产。 //! - 便携版(无 unins000.exe 且不在 Program Files):下载新 thing.exe → update.bat 覆盖重启 //! - 安装版(NSIS):下载新 setup.exe → 提权静默安装 /S -//! - ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖 {app_data}/monitor/cores/ThingHK.exe -//! mihomo 内核更新继续复用代理模块已有的 GitHub 下载机制,不在此模块处理。 -use futures_util::StreamExt; +//! - ThingHK 内核:下载由前端下载模块完成 → apply 命令 need_stop 等待确认 → 解压覆盖 +//! {app_data}/monitor/cores/ThingHK.exe(与代理模块 mihomo 内核更新同模式) use serde::Serialize; use specta::Type; use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::sync::{atomic::{AtomicBool, Ordering}, Mutex}; use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::{oneshot, watch}; 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>>, + /// 取消标志 + 唤醒通道(前端调 cancel 命令时置位,apply 等待循环立即返回) + cancel_flag: AtomicBool, + cancel_tx: watch::Sender, + cancel_rx: watch::Receiver, +} + +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) const GITEA_REPO: &str = "LFeng/Thing"; const GITEA_BASE: &str = "https://gitea.atie.fun"; @@ -125,72 +172,8 @@ async fn fetch_latest_release() -> Result { }) } -// ---------- 下载 / 解压 ---------- - -/// 下载文件到 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(()) -} +// ---------- 解压 ---------- +// ThingHK 内核更新包由前端下载模块负责下载(同 mihomo),此处仅解压替换。 /// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题) fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> { @@ -377,47 +360,72 @@ pub async fn update_install(app: AppHandle, downloaded_path: String) -> Result<( Ok(()) } -/// 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件 +/// 应用 ThingHK 内核更新:下载阶段已由下载模块完成,本命令仅做 +/// need_stop(等待前端停止监控内核并确认)→ 解压 → 替换。 +/// 进度通过 UPDATE_PROGRESS 事件上报,前端据 need_stop 弹出确认对话框。 #[tauri::command] #[specta::specta] -pub async fn update_thinghk(app: AppHandle) -> Result<(), String> { - let result = update_thinghk_inner(&app).await; +pub async fn update_thinghk_apply( + 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 { - // 失败时 emit error 阶段,避免前端进度卡在最后状态无提示 - let _ = app.emit( - UPDATE_PROGRESS, - UpdateProgress { - stage: "error".into(), - percent: 0, - downloaded_bytes: 0, - total_bytes: None, - message: e.clone(), - }, - ); + // 取消是用户主动行为,静默返回即可;其余失败 emit error 阶段避免前端进度卡死 + if e != THINGHK_UPDATE_CANCELLED { + let _ = app.emit( + UPDATE_PROGRESS, + UpdateProgress { + stage: "error".into(), + percent: 0, + downloaded_bytes: 0, + total_bytes: None, + message: e.clone(), + }, + ); + } } result } -async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> { - let latest = fetch_latest_release().await?; - let asset = latest - .assets - .iter() - .find(|a| a.name.starts_with("thing-hk_") && a.name.ends_with(".zip")) - .ok_or("未在 release 中找到 ThingHK 内核包".to_string())?; - // 停止监控内核(含提权模式的 /shutdown 兜底由前端先停模块),避免 exe 被占用 - if let Some(monitor) = app.try_state::() { - monitor.stop_subscription(app).await; +async fn update_thinghk_apply_inner( + app: &AppHandle, + state: &ThinghkUpdateState, + zip_path: PathBuf, +) -> Result<(), String> { + if !zip_path.exists() { + return Err(format!("下载文件不存在: {}", zip_path.display())); } - if let Some(pm) = app.try_state::() { - 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! { + _ = &mut rx => break, + _ = cancel_rx.changed() => {} + } } - // 等待进程退出释放文件句柄 - tokio::time::sleep(std::time::Duration::from_millis(600)).await; - let temp_dir = std::env::temp_dir().join("thing-update"); - fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?; - let zip_path = temp_dir.join(&asset.name); - download_with_progress(app, &asset.browser_download_url, &zip_path).await?; + + // 解压阶段 let _ = app.emit( UPDATE_PROGRESS, UpdateProgress { @@ -428,9 +436,11 @@ async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> { message: "正在解压内核...".into(), }, ); + let temp_dir = std::env::temp_dir().join("thing-update"); let extract_dir = temp_dir.join("thinghk_extract"); let _ = fs::remove_dir_all(&extract_dir); extract_zip(&zip_path, &extract_dir)?; + // 在解压目录中查找 ThingHK.exe let exe_path = find_thinghk_exe(&extract_dir).ok_or("内核包中未找到 ThingHK.exe".to_string())?; let app_data = app @@ -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::copy(&exe_path, cores_dir.join("ThingHK.exe")) .map_err(|e| format!("覆盖内核文件失败(请确认监控模块已停止): {}", e))?; + // 清理临时文件 let _ = fs::remove_file(&zip_path); let _ = fs::remove_dir_all(&extract_dir); @@ -457,6 +468,22 @@ async fn update_thinghk_inner(app: &AppHandle) -> Result<(), String> { 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 { if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7849c85..03d0433 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "identifier": "thing.lfeng.me", "build": { "beforeDevCommand": "bun run dev", - "devUrl": "http://localhost:1420", + "devUrl": "http://localhost:14210", "beforeBuildCommand": "bun run build", "frontendDist": "../dist" }, diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 1b39821..d360e57 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -16,8 +16,16 @@ export const commands = { * 调用返回前会触发应用退出。 */ updateInstall: (downloadedPath: string) => __TAURI_INVOKE("update_install", { downloadedPath }), - /** 更新 ThingHK 内核:下载 thing-hk_{v}.zip → 停止监控内核 → 覆盖内核文件 */ - updateThinghk: () => __TAURI_INVOKE("update_thinghk"), + /** + * 应用 ThingHK 内核更新:下载阶段已由下载模块完成,本命令仅做 + * need_stop(等待前端停止监控内核并确认)→ 解压 → 替换。 + * 进度通过 UPDATE_PROGRESS 事件上报,前端据 need_stop 弹出确认对话框。 + */ + updateThinghkApply: (zipPath: string) => __TAURI_INVOKE("update_thinghk_apply", { zipPath }), + /** 前端已停止监控内核,确认继续解压替换(唤醒 need_stop 等待) */ + updateThinghkConfirm: () => __TAURI_INVOKE("update_thinghk_confirm"), + /** 取消 ThingHK 内核更新(need_stop 等待阶段有效:唤醒 apply 以「已取消」返回,zip 保留便于重试) */ + updateThinghkCancel: () => __TAURI_INVOKE("update_thinghk_cancel"), proxyActivateProfile: (id: string) => __TAURI_INVOKE("proxy_activate_profile", { id }), /** 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。 */ proxyApplyKernelUpdate: (zipPath: string) => __TAURI_INVOKE("proxy_apply_kernel_update", { zipPath }), @@ -110,6 +118,10 @@ export const commands = { /** * 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口) * 适用于内置系统工具: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("quickpanel_run_system_command", { command, args }), /** 列出目录下的压缩包文件(供批量解压面板使用)。 */ diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 4796279..6d15aa3 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -61,6 +61,10 @@ export const EVENTS = { osdContentSize: 'osd-content-size', osdSystemUiActive: 'osd-system-ui-active', osdSystemUiInactive: 'osd-system-ui-inactive', + /** 前台出现全屏应用(游戏):OSD 应隐藏以避免游戏掉帧 */ + osdGameActive: 'osd-game-active', + /** 全屏应用退出前台:OSD 可恢复显示 */ + osdGameInactive: 'osd-game-inactive', osdStartDrag: 'osd-start-drag', osdEndDrag: 'osd-end-drag', monitorReady: 'monitor-ready', diff --git a/src/main.ts b/src/main.ts index 4d8d1b3..4f04fd9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,8 +9,14 @@ const logger = createLogger('main') // 禁用 WebView 默认右键菜单(桌面应用体验,主窗口与独立窗口共用) 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) => { + if (event.message && BENIGN_RESIZE_OBSERVER_RE.test(event.message)) return logger.error(`全局JS错误: ${event.message} @ ${event.filename}:${event.lineno}`) }) diff --git a/src/modules/monitor/MonitorModule.vue b/src/modules/monitor/MonitorModule.vue index d90cf85..756682b 100644 --- a/src/modules/monitor/MonitorModule.vue +++ b/src/modules/monitor/MonitorModule.vue @@ -6,7 +6,7 @@ import { ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks, Monitor as MonitorIcon, GripVertical, SlidersHorizontal, Eye, EyeOff, MousePointerClick, Plus, PencilLine, - CircuitBoard, BatteryFull, + CircuitBoard, BatteryFull, Gamepad2, } from '@lucide/vue' import type { LucideIcon } from '@lucide/vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue' @@ -2156,6 +2156,20 @@ watch(() => store.status?.ready, (ready, prev) => { @update:model-value="updateOsdConfig('clickThrough', Boolean($event))" /> + +
+
+ + 检测到全屏应用(游戏)前台时隐藏悬浮窗,退出后自动恢复,避免游戏掉帧 +
+ +
diff --git a/src/modules/monitor/OsdWindow.vue b/src/modules/monitor/OsdWindow.vue index d922e57..1efdc99 100644 --- a/src/modules/monitor/OsdWindow.vue +++ b/src/modules/monitor/OsdWindow.vue @@ -512,6 +512,13 @@ onMounted(async () => { console.error('[OSD] 启动置顶监视失败:', e) } + // 启动游戏全屏监视(前台全屏应用时通知主窗口隐藏 OSD,避免游戏掉帧) + try { + await invoke('osd_start_game_watch') + } catch (e) { + console.error('[OSD] 启动游戏全屏监视失败:', e) + } + // 监听主窗口推送的 OSD 配置(低频通道) unlistenFns.push(await listen(EVENTS.osdStateUpdate, (e) => { config.value = e.payload.config diff --git a/src/modules/quickpanel/QuickPanel.vue b/src/modules/quickpanel/QuickPanel.vue index 636ff28..d1d62dc 100644 --- a/src/modules/quickpanel/QuickPanel.vue +++ b/src/modules/quickpanel/QuickPanel.vue @@ -5,11 +5,10 @@ import { getCurrentWindow, LogicalSize, Effect, EffectState } from '@tauri-apps/ // Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts) import { commands } 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 { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion' +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 { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' 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 HistoryPicker from './HistoryPicker.vue' @@ -143,14 +142,32 @@ const renameResults = ref(null) const renameOkCount = ref(0) let renameTimer: ReturnType | null = null -// ===== 历史分区(从 results 中分离历史项与其他结果) ===== -// 展示顺序:目录操作(批量解压/重命名/删除)> 历史 > 更多历史 > 其他 -const dirActionItems = computed(() => results.value.filter(r => r.group === '目录操作')) -const historyItems = computed(() => results.value.filter(r => r.group === '历史')) -const otherItems = computed(() => results.value.filter(r => r.group !== '历史' && r.group !== '目录操作')) -// Accordion 中的更多历史项(不参与键盘上下导航,仅鼠标点击) -const moreHistoryItems = ref([]) -const moreHistoryCount = ref(0) +// ===== 结果分区(从 results 中分离历史项与其他结果) ===== +// 展示顺序:目录操作(批量解压/重命名/删除)> 历史(折叠分组)> 其他 +// 目录操作为置顶行为项;历史在空查询时置顶但默认折叠,按 Tab 展开 +const dirActionItems = ref([]) +const otherItems = computed(() => results.value) +// 历史项单独从 localStorage 加载(不再混入 results),默认折叠 +const historyItems = ref([]) +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(() => [ + ...dirActionItems.value, + ...(historyExpanded.value ? historyItems.value : []), + ...otherItems.value, +]) + +function toggleHistory() { + collapseSubActions() + historyExpanded.value = !historyExpanded.value +} // ===== 历史频率(localStorage 持久化,用于排序加权) ===== const HISTORY_KEY = STORAGE_KEYS.quickpanelHistory @@ -192,24 +209,25 @@ async function doSearch() { const seq = ++searchSeq const q = query.value.trim() if (!q) { - // 空查询:当前目录文件操作(若检测到 Explorer 目录)+ 历史置顶 + 系统相关条目 + // 空查询:目录操作(若检测到 Explorer 目录)+ 历史置顶(默认折叠)+ 系统相关条目 // (程序相关设置不参与默认展示;所有 Provider 空查询零 IPC,首屏即时) const items = await aggregateSearch('') if (seq !== searchSeq) return // 过期请求丢弃 - const dirItems = getExplorerActions() - results.value = applyHistoryBoost([...dirItems, ...items]) + dirActionItems.value = getExplorerActions() + results.value = applyHistoryBoost(items) + // 历史置顶但默认折叠,按 Tab 展开(每次显示重置为折叠态) + historyItems.value = getAllHistoryItems() + historyExpanded.value = false selectedIndex.value = 0 - // 加载更多历史(Accordion 折叠区,不参与键盘导航) - moreHistoryItems.value = getMoreHistoryItems() - moreHistoryCount.value = getMoreHistoryCount() // 后台加载应用图标(含历史中的图标) void loadAppIconsForResults(results.value) - void loadAppIconsForResults(moreHistoryItems.value) + void loadAppIconsForResults(historyItems.value) return } - // 非空查询:清空历史分区 - moreHistoryItems.value = [] - moreHistoryCount.value = 0 + // 非空查询:清空历史分区与目录操作 + dirActionItems.value = [] + historyItems.value = [] + historyExpanded.value = false loading.value = true try { const items = await aggregateSearch(q) @@ -680,7 +698,7 @@ async function confirmDelete() { // 子动作展开/收起 function toggleSubActions(idx: number) { - const item = results.value[idx] + const item = navItems.value[idx] if (!item?.subActions?.length) return if (subActionExpanded.value === idx) { subActionExpanded.value = null @@ -697,7 +715,7 @@ function collapseSubActions() { // 当前展开的子动作列表 function currentSubActions(): QPSubAction[] { 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 subs = currentSubActions() - const expandedItem = expanded ? results.value[subActionExpanded.value!] : undefined + const expandedItem = expanded ? navItems.value[subActionExpanded.value!] : undefined if (expanded) { // 子动作导航模式 @@ -760,7 +778,7 @@ function onKeydown(e: KeyboardEvent) { // 结果列表导航模式 if (e.key === 'ArrowDown') { e.preventDefault() - selectedIndex.value = Math.min(selectedIndex.value + 1, results.value.length - 1) + selectedIndex.value = Math.min(selectedIndex.value + 1, navTotal.value - 1) scrollSelectedIntoView() } else if (e.key === 'ArrowUp') { e.preventDefault() @@ -768,17 +786,23 @@ function onKeydown(e: KeyboardEvent) { scrollSelectedIntoView() } else if (e.key === 'Enter') { e.preventDefault() - const item = results.value[selectedIndex.value] + const item = navItems.value[selectedIndex.value] if (item) executeItem(item) } else if (e.key === 'Escape') { e.preventDefault() hideWindow() } else if (e.key === 'Tab') { - // Tab 展开子动作 - const item = results.value[selectedIndex.value] - if (item?.subActions?.length) { + if (!query.value.trim()) { + // 默认视图:Tab 展开/收起历史分组 e.preventDefault() - toggleSubActions(selectedIndex.value) + toggleHistory() + } else { + // 搜索视图:Tab 展开子动作 + const item = navItems.value[selectedIndex.value] + if (item?.subActions?.length) { + e.preventDefault() + toggleSubActions(selectedIndex.value) + } } } } @@ -1393,58 +1417,35 @@ onUnmounted(() => { - - - - - - - - - - 更多历史({{ moreHistoryCount }} 条) - - - + + + - +