diff --git a/Cargo.lock b/Cargo.lock index 962f2dc..3f2f960 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -308,6 +308,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-single-instance", + "tempfile", "verhub-sdk", ] @@ -4218,9 +4219,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "verhub-sdk" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61704ff45c9dd4e311e21b254be2405e8e3c9a81a5c1ad0ef8e8bb123fe65115" +checksum = "b6d327c305b7a92625c9bd30009a1ab80091c401c268b52c4d4576978aecb165" dependencies = [ "log", "os_info", diff --git a/Cargo.toml b/Cargo.toml index cdfa8d3..112262b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ regex = "1.11.1" tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } windows = "0.62.2" tempfile = "3.27.0" -verhub-sdk = { version = "0.2.5", default-features = false, features = ["native-tls"] } +verhub-sdk = { version = "0.2.6", default-features = false, features = ["native-tls"] } [profile.release] opt-level = "z" diff --git a/apps/config/src-tauri/Cargo.toml b/apps/config/src-tauri/Cargo.toml index 6069384..2962491 100644 --- a/apps/config/src-tauri/Cargo.toml +++ b/apps/config/src-tauri/Cargo.toml @@ -21,3 +21,6 @@ bosskey-common = { path = "../../../crates/common" } bosskey-core = { path = "../../../crates/core" } tauri-plugin-single-instance = "2.4.2" verhub-sdk = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/apps/config/src-tauri/src/lib.rs b/apps/config/src-tauri/src/lib.rs index 748491d..4fbb4f7 100644 --- a/apps/config/src-tauri/src/lib.rs +++ b/apps/config/src-tauri/src/lib.rs @@ -124,10 +124,25 @@ async fn show_all_windows() -> Result<(), String> { blocking(|| notify_core(&Command::Show).map(|_| ())).await } -/// 恢复显示指定窗口(窗口恢复工具):直接对选中的句柄 ShowWindow。 +/// 把恢复工具的窗口操作交给核心执行;核心未应答(未运行 / 出错)时返回 false, +/// 由调用方退回直接操作。 +fn try_core_window_op(command: &Command) -> bool { + matches!( + PipeClient::connect_default().fast().send(command), + Ok(Response::Ok) + ) +} + +/// 恢复显示指定窗口(窗口恢复工具)。优先经核心释放并更新记录; +/// 核心不在运行才直接对句柄 ShowWindow。 #[tauri::command] async fn show_windows(hwnds: Vec) { blocking(move || { + if try_core_window_op(&Command::ReleaseWindows { + hwnds: hwnds.clone(), + }) { + return; + } use bosskey_core::platform::WindowManager; let mgr = bosskey_core::platform::manager(); for h in hwnds { @@ -137,10 +152,16 @@ async fn show_windows(hwnds: Vec) { .await } -/// 隐藏指定窗口(窗口恢复工具):直接对选中的句柄隐藏。 +/// 隐藏指定窗口(窗口恢复工具)。优先经核心纳入隐藏记录(不施加静音 / 冻结); +/// 核心不在运行才直接对句柄隐藏。 #[tauri::command] async fn hide_windows(hwnds: Vec) { blocking(move || { + if try_core_window_op(&Command::AdoptWindows { + hwnds: hwnds.clone(), + }) { + return; + } use bosskey_core::platform::WindowManager; let mgr = bosskey_core::platform::manager(); for h in hwnds { @@ -402,6 +423,15 @@ async fn open_external(url: String) -> Result<(), String> { blocking(move || bosskey_core::shell::open(&url)).await } +/// 项目公开链接(主页 / 仓库 / 文档等)。带缓存(内存 + exe 同目录磁盘文件, +/// 有效期一天),过期才请求 Verhub;请求失败退回过期缓存。 +#[tauri::command] +async fn verhub_project_links() -> Result { + verhub::project_links(&exe_dir().join("verhub_cache.json")) + .await + .map_err(|e| e.to_string()) +} + /// 检查更新。`required=true` 即强制更新,界面须阻断使用。 #[tauri::command] async fn verhub_check_update(include_preview: bool) -> Result { @@ -528,6 +558,7 @@ pub fn run() { startup_action, app_info, open_external, + verhub_project_links, verhub_check_update, verhub_announcements, verhub_submit_feedback, diff --git a/apps/config/src-tauri/src/verhub.rs b/apps/config/src-tauri/src/verhub.rs index 5f8e9b2..f9998e7 100644 --- a/apps/config/src-tauri/src/verhub.rs +++ b/apps/config/src-tauri/src/verhub.rs @@ -1,15 +1,17 @@ -//! Verhub 客户端:版本 / 公告 / 反馈 / 日志,基于官方 verhub-sdk。 +//! Verhub 客户端:版本 / 公告 / 反馈 / 日志 / 项目链接,基于官方 verhub-sdk。 //! //! 只用公开端点(无需凭据)。HTTP 由 SDK 完成;本模块把 SDK 的响应类型映射成 //! 前端 IPC 契约所需的可序列化 DTO,字段名保持不变。 +use std::path::Path; +use std::sync::Mutex; use std::time::Duration; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use verhub_sdk::VerhubClient; use verhub_sdk::models::{ AnnouncementItem, CheckUpdateInput, CreateFeedbackInput, JsonObject, ListAnnouncementsOptions, - LogLevel, Platform, UploadLogInput, VersionDownloadLink, VersionItem, + LogLevel, Platform, ProjectItem, UploadLogInput, VersionDownloadLink, VersionItem, }; /// Verhub 基础路径。 @@ -190,6 +192,101 @@ pub async fn upload_log(content: &str, device_info: serde_json::Value) -> Result Ok(()) } +/// 项目公开链接(主页 / 仓库 / 文档等)的缓存有效期。链接极少变动,一天刷新一次足够。 +const PROJECT_CACHE_TTL_SECS: i64 = 24 * 60 * 60; + +/// 项目公开链接。所有字段都可能缺省(Verhub 上未填写);前端须自备回退链接。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ProjectLinks { + pub name: Option, + pub website_url: Option, + pub repo_url: Option, + pub docs_url: Option, + pub author: Option, + pub author_homepage_url: Option, + /// 拉取时刻(Unix 秒),用于判断缓存新鲜度。 + pub fetched_at: i64, +} + +/// 进程内缓存,避免每次都读盘。 +static PROJECT_CACHE: Mutex> = Mutex::new(None); + +fn map_project(item: ProjectItem, fetched_at: i64) -> ProjectLinks { + ProjectLinks { + name: Some(item.name), + website_url: item.website_url, + repo_url: item.repo_url, + docs_url: item.docs_url, + author: item.author, + author_homepage_url: item.author_homepage_url, + fetched_at, + } +} + +/// 缓存是否仍在有效期内。`fetched_at` 在未来(时钟回拨)按过期处理。 +fn cache_fresh(links: &ProjectLinks, now: i64) -> bool { + (0..PROJECT_CACHE_TTL_SECS).contains(&(now - links.fetched_at)) +} + +fn cache_get() -> Option { + PROJECT_CACHE.lock().ok()?.clone() +} + +fn cache_put(links: ProjectLinks) { + if let Ok(mut cache) = PROJECT_CACHE.lock() { + *cache = Some(links); + } +} + +/// 读磁盘缓存;文件不存在或损坏一律当作没有缓存。 +fn read_cache_file(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +/// 写磁盘缓存;尽力而为,写失败只影响下次冷启动的命中率。 +fn write_cache_file(path: &Path, links: &ProjectLinks) { + if let Ok(json) = serde_json::to_string_pretty(links) { + let _ = std::fs::write(path, json); + } +} + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// 项目公开链接:内存缓存 → 磁盘缓存(`cache_path`)→ Verhub API 逐级回退。 +/// API 拉取失败时退回过期缓存(有旧数据总比没有强),完全没有缓存才报错。 +pub async fn project_links(cache_path: &Path) -> Result { + let now = unix_now(); + if let Some(cached) = cache_get() + && cache_fresh(&cached, now) + { + return Ok(cached); + } + if let Some(cached) = read_cache_file(cache_path) + && cache_fresh(&cached, now) + { + cache_put(cached.clone()); + return Ok(cached); + } + match client()?.public().get_project().await { + Ok(item) => { + let links = map_project(item, now); + write_cache_file(cache_path, &links); + cache_put(links.clone()); + Ok(links) + } + Err(err) => match cache_get().or_else(|| read_cache_file(cache_path)) { + Some(stale) => Ok(stale), + None => Err(err), + }, + } +} + /// 截到上限以内,按字符边界切以避免切碎多字节字符。 fn truncate_log(content: &str) -> String { if content.len() <= LOG_CONTENT_MAX { @@ -232,4 +329,42 @@ mod tests { assert_eq!(u8::from(LogLevel::Debug), 0); assert_eq!(u8::from(LogLevel::Error), 3); } + + #[test] + fn cache_fresh_within_ttl_only() { + let links = ProjectLinks { + fetched_at: 1_000_000, + ..Default::default() + }; + assert!(cache_fresh(&links, 1_000_000)); // 刚拉取 + assert!(cache_fresh(&links, 1_000_000 + PROJECT_CACHE_TTL_SECS - 1)); + assert!(!cache_fresh(&links, 1_000_000 + PROJECT_CACHE_TTL_SECS)); // 到期 + assert!(!cache_fresh(&links, 999_999)); // 时钟回拨 + } + + #[test] + fn cache_file_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("verhub_cache.json"); + let links = ProjectLinks { + name: Some("Boss Key".into()), + website_url: Some("https://example.com/".into()), + fetched_at: 42, + ..Default::default() + }; + write_cache_file(&path, &links); + let read = read_cache_file(&path).expect("缓存应可读回"); + assert_eq!(read.name.as_deref(), Some("Boss Key")); + assert_eq!(read.website_url.as_deref(), Some("https://example.com/")); + assert_eq!(read.fetched_at, 42); + } + + #[test] + fn cache_file_tolerates_missing_and_corrupt() { + let dir = tempfile::tempdir().unwrap(); + assert!(read_cache_file(&dir.path().join("absent.json")).is_none()); + let bad = dir.path().join("bad.json"); + std::fs::write(&bad, "not json{{").unwrap(); + assert!(read_cache_file(&bad).is_none()); + } } diff --git a/apps/config/ui/src/components/AboutPanel.svelte b/apps/config/ui/src/components/AboutPanel.svelte index cf12f5c..9631375 100644 --- a/apps/config/ui/src/components/AboutPanel.svelte +++ b/apps/config/ui/src/components/AboutPanel.svelte @@ -24,6 +24,14 @@ const v = $derived(app.config.verhub); const year = new Date().getFullYear(); + // 链接以 Verhub 项目信息为准(后端带缓存),拉不到时退回内置地址。 + const links = $derived(app.project); + const homepageUrl = $derived(links?.website_url || "https://boss-key.ivan-hanloth.cn/"); + const repoUrl = $derived(links?.repo_url || info?.website || "https://github.com/IvanHanloth/Boss-Key"); + const docsUrl = $derived(links?.docs_url || "https://boss-key.ivan-hanloth.cn/guide/"); + const authorUrl = $derived(links?.author_homepage_url || info?.blog || "https://www.ivan-hanloth.cn/"); + const authorName = $derived(links?.author || info?.author || "Ivan Hanloth"); + let content = $state(""); let rating = $state(0); let hoverRating = $state(0); @@ -81,22 +89,22 @@

{t("about.version", { version: info?.version ?? "…" })}

{t("about.tagline")}

- - - - +

Copyright © 2022-{year} - All Rights Reserved.

diff --git a/apps/config/ui/src/lib/ipc.js b/apps/config/ui/src/lib/ipc.js index 55d4ea3..7cae640 100644 --- a/apps/config/ui/src/lib/ipc.js +++ b/apps/config/ui/src/lib/ipc.js @@ -152,6 +152,16 @@ function mockInvoke(cmd, args) { blog: "https://blog.ivan-hanloth.cn/", license: "MIT", }; + case "verhub_project_links": + return { + name: "Boss Key", + website_url: "https://boss-key.ivan-hanloth.cn/", + repo_url: "https://github.com/IvanHanloth/Boss-Key", + docs_url: "https://boss-key.ivan-hanloth.cn/guide/", + author: "Ivan Hanloth", + author_homepage_url: "https://www.ivan-hanloth.cn/", + fetched_at: Math.floor(Date.now() / 1000), + }; case "verhub_check_update": return { should_update: false, diff --git a/apps/config/ui/src/lib/state.svelte.js b/apps/config/ui/src/lib/state.svelte.js index 9038ebd..5e272f9 100644 --- a/apps/config/ui/src/lib/state.svelte.js +++ b/apps/config/ui/src/lib/state.svelte.js @@ -23,6 +23,8 @@ export const app = $state({ /** 当前自启注册方式:"task"|"registry"|null(未注册)。 */ autostartMethod: null, info: null, + /** Verhub 上的项目公开链接(主页 / 仓库 / 文档等);null 时用内置回退链接。 */ + project: null, maximized: false, saving: false, /** 程序目录下是否存在 pssuspend64.exe(增强冻结的前置条件)。 */ @@ -139,6 +141,11 @@ export async function loadAll() { }), invoke("pssuspend_available").then((v) => (app.pssuspend = !!v)), ]; + // 项目链接拉不到时静默——「关于」页有内置回退链接,不值得打扰用户。 + verhub + .projectLinks() + .then((p) => (app.project = p)) + .catch(() => {}); const results = await Promise.allSettled(tasks); const failed = results.find((r) => r.status === "rejected"); if (failed) toast(t("state.partialLoadFailed", { reason: failed.reason }), true); diff --git a/apps/config/ui/src/lib/verhub.js b/apps/config/ui/src/lib/verhub.js index 743caed..2c04068 100644 --- a/apps/config/ui/src/lib/verhub.js +++ b/apps/config/ui/src/lib/verhub.js @@ -2,6 +2,11 @@ import { invoke } from "./ipc.js"; +/** 项目公开链接(主页 / 仓库 / 文档等)。后端带缓存,可随意调用。 */ +export function projectLinks() { + return invoke("verhub_project_links"); +} + /** 检查更新。返回 { should_update, required, target_version, latest_version, … }。 */ export function checkUpdate(includePreview = false) { return invoke("verhub_check_update", { includePreview }); diff --git a/crates/common/src/ipc.rs b/crates/common/src/ipc.rs index 3b58667..b238d5b 100644 --- a/crates/common/src/ipc.rs +++ b/crates/common/src/ipc.rs @@ -27,6 +27,16 @@ pub enum Command { SetHotkeys { enabled: bool, }, + /// 窗口恢复工具:恢复显示指定句柄。在核心隐藏记录里的窗口按整进程释放 + /// (连同解冻 / 取消静音);不在记录里的句柄直接恢复显示。 + ReleaseWindows { + hwnds: Vec, + }, + /// 窗口恢复工具:隐藏指定句柄并纳入核心隐藏记录(享受崩溃恢复保护), + /// 不施加静音 / 冻结 / 暂停键。 + AdoptWindows { + hwnds: Vec, + }, Quit, } @@ -164,6 +174,11 @@ mod tests { }, Command::SetHotkeys { enabled: true }, Command::SetHotkeys { enabled: false }, + Command::ReleaseWindows { + hwnds: vec![1, 2, 3], + }, + Command::ReleaseWindows { hwnds: vec![] }, + Command::AdoptWindows { hwnds: vec![42] }, Command::Quit, ]; for c in cases { diff --git a/crates/core/src/agent.rs b/crates/core/src/agent.rs index 7b379df..daa6b4f 100644 --- a/crates/core/src/agent.rs +++ b/crates/core/src/agent.rs @@ -1,9 +1,10 @@ +use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration; use bosskey_common::ipc::{Command, Response}; -use bosskey_common::{APP_NAME, ARG_ABOUT, ARG_RESTORE, Config}; +use bosskey_common::{APP_NAME, ARG_ABOUT, ARG_RESTORE, Config, Setting}; use windows::Win32::Foundation::{ERROR_HOTKEY_ALREADY_REGISTERED, HWND, LPARAM, LRESULT, WPARAM}; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::UI::Input::KeyboardAndMouse::{ @@ -21,10 +22,11 @@ use windows::Win32::UI::WindowsAndMessaging::{ use windows::core::{PCWSTR, w}; use crate::effects::WinEffects; +use crate::effects_worker::{AsyncEffects, EffectsWorker}; use crate::float_window::{FLOAT_MENU, FLOAT_TOGGLE, FloatWindow, WM_APP_FLOAT}; use crate::hide::{ - HideController, RuleOutcome, expand_descendants, foreground_target, freezable_pids, - resolve_targets, + HideController, HidePlan, RuleOutcome, ShowOutcome, expand_descendants, foreground_target, + freezable_pids, resolve_targets, }; use crate::hotkey::{MOD_NOREPEAT, ParsedHotkey, is_disabled, parse_hotkey}; use crate::i18n::{self, Msg}; @@ -34,7 +36,7 @@ use crate::platform::win32::WindowsWindowManager; use crate::tray::TrayIcon; use crate::tray_badge::TrayIconSet; use crate::util::append_menu_item; -use crate::{idle, ipc_server, logging, recovery}; +use crate::{idle, ipc_server, log_error, log_warn, logging, recovery}; const HK_HIDE: i32 = 1; const HK_CLOSE: i32 = 2; @@ -62,6 +64,8 @@ const TRAY_RETRY_TIMER_ID: usize = 13; const AUTO_HIDE_INTERVAL_MS: u32 = 5000; const TRAY_RETRY_INTERVAL_MS: u32 = 2000; const IPC_REPLY_TIMEOUT: Duration = Duration::from_secs(3); +/// 退出时等待副作用线程排干队列(解冻 / 取消静音)的上限。 +const EFFECTS_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); pub struct AgentOptions { pub config_path: PathBuf, @@ -85,7 +89,11 @@ struct AgentState { config: Config, config_path: PathBuf, recovery_path: PathBuf, - controller: HideController, + controller: HideController, + /// 副作用专职线程;退出时须 shutdown 排干队列。 + effects_worker: Option, + /// 恢复文件写入失败是否已提醒过(每次运行只弹一次气泡)。 + persist_warned: bool, tray: Option, /// 托盘图标的角标变体缓存;未启用托盘时为 None。 tray_icons: Option, @@ -136,18 +144,18 @@ impl AgentState { ), ] { if is_disabled(raw) { - logging::info(&format!("{label}热键已置空,不注册")); + logging::debug(&format!("{label}热键已置空,不注册")); continue; } match parse_hotkey(raw) { Ok(hk) if intercept => intercepts.push((id, label, raw.clone(), hk)), Ok(hk) => unsafe { match register(hwnd, id, &hk) { - Ok(()) => logging::info(&format!("{label}热键已注册: {raw}")), - Err(e) => logging::warn(&hotkey_failure_message(label, raw, &e)), + Ok(()) => logging::debug(&format!("{label}热键已注册: {raw}")), + Err(e) => log_warn!("{}", hotkey_failure_message(label, raw, &e)), } }, - Err(e) => logging::warn(&format!("{label}热键解析失败,该热键不生效: {raw} — {e}")), + Err(e) => log_warn!("{label}热键解析失败,该热键不生效: {raw} — {e}"), } } self.arm_intercepts(hwnd, intercepts); @@ -174,17 +182,17 @@ impl AgentState { } if self.keyboard_hook.is_some() { for (_, label, raw, _) in &intercepts { - logging::info(&format!("{label}热键已由键盘钩子拦截(不传递): {raw}")); + logging::debug(&format!("{label}热键已由键盘钩子拦截(不传递): {raw}")); } return; } keyboard_hook::set_hotkeys(&[]); - logging::warn("键盘钩子安装失败,「不传递」不生效,相关热键回退为普通注册"); + log_warn!("键盘钩子安装失败,「不传递」不生效,相关热键回退为普通注册"); for (id, label, raw, hk) in &intercepts { unsafe { match register(hwnd, *id, hk) { - Ok(()) => logging::info(&format!("{label}热键已注册: {raw}")), - Err(e) => logging::warn(&hotkey_failure_message(label, raw, &e)), + Ok(()) => logging::debug(&format!("{label}热键已注册: {raw}")), + Err(e) => log_warn!("{}", hotkey_failure_message(label, raw, &e)), } } } @@ -283,89 +291,88 @@ impl AgentState { }); } - /// 隐藏状态落盘:崩溃后下次启动据此找回窗口。快照为空时清除文件。 - fn persist_recovery(&self) { - if let Err(e) = recovery::save(&self.recovery_path, &self.controller.snapshot()) { - // 落盘失败不影响本次隐藏,但核心若随后崩溃,这些窗口将无法自动找回。 - logging::warn(&format!( + /// 把指定快照落盘。失败不阻断本次隐藏,只影响崩溃找回;每次运行提醒用户一次。 + fn persist_snapshot(&mut self, snapshot: &recovery::Snapshot) { + if let Err(e) = recovery::save(&self.recovery_path, snapshot) { + log_error!( "写入崩溃恢复文件失败,核心若异常退出将无法自动找回窗口: {} — {e}", self.recovery_path.display() - )); + ); + if !self.persist_warned { + self.persist_warned = true; + self.balloon(APP_NAME, i18n::t(Msg::RecoveryPersistFailedBody)); + } } } - fn apply_hide(&mut self) { - let windows = self.controller.enumerate(); - let foreground = self.controller.foreground(); - let (targets, outcomes) = resolve_targets(&mut self.config, &windows, foreground); - let freezable = freezable_pids(&targets, &windows); - // 「冻结完整进程」:把可冻结集展开到整棵子进程树。 - let freeze_set = if self.config.setting.freeze_whole_tree { + /// 按控制器当前状态落盘。 + fn persist_recovery(&mut self) { + let snapshot = self.controller.snapshot(); + self.persist_snapshot(&snapshot); + } + + /// 意图先行:先落盘计划后的快照,再隐藏窗口;副作用由专职线程异步执行。 + fn hide_with_plan(&mut self, targets: &[crate::hide::Target], freeze_set: &[u32]) -> HidePlan { + let setting = self.config.setting.clone(); + let plan = self.hide_with_plan_using(&setting, targets, freeze_set); + if self.config.notifications.on_hide && !plan.fresh.is_empty() { + self.balloon(APP_NAME, i18n::t(Msg::HiddenBody)); + } + plan + } + + /// [`Self::hide_with_plan`] 的按指定设置版本(恢复工具的手动隐藏关闭副作用)。 + fn hide_with_plan_using( + &mut self, + setting: &Setting, + targets: &[crate::hide::Target], + freeze_set: &[u32], + ) -> HidePlan { + let plan = self.controller.plan_hide(setting, targets, freeze_set); + let planned = self.controller.planned_snapshot(&plan); + self.persist_snapshot(&planned); + self.controller.commit_hide(plan.clone()); + self.sync_tray(); + plan + } + + /// 「冻结完整进程」开启时把可冻结集展开到整棵子进程树。 + fn freeze_set(&self, freezable: Vec) -> Vec { + if self.config.setting.freeze_whole_tree { expand_descendants(&freezable, &crate::freeze::process_tree()) } else { freezable - }; - log_resolution(&self.config, &outcomes, &targets, &windows); - // 冻结开启时,把最终冻结集连同进程名逐个写入日志,便于定位冻结问题。 - if self.config.setting.freeze_after_hide && !freeze_set.is_empty() { - let names = crate::freeze::process_names(); - logging::info(&format!( - "本次冻结集共 {} 个进程(增强冻结={},完整进程树={})", - freeze_set.len(), - self.config.setting.enhanced_freeze, - self.config.setting.freeze_whole_tree - )); - for pid in &freeze_set { - let name = names.get(pid).map(String::as_str).unwrap_or("未知进程"); - logging::info(&format!("待冻结进程 pid={pid} name={name}")); - } - } - self.controller - .apply_hide(&self.config.setting, &targets, &freeze_set); - self.persist_recovery(); - self.sync_tray(); - if self.config.notifications.on_hide { - self.balloon(APP_NAME, i18n::t(Msg::HiddenBody)); } } + + fn apply_hide(&mut self) { + let windows = self.controller.enumerate(); + let foreground = self.controller.foreground(); + let (targets, outcomes) = resolve_targets(&mut self.config, &windows, foreground); + let freeze_set = self.freeze_set(freezable_pids(&targets, &windows)); + let plan = self.hide_with_plan(&targets, &freeze_set); + log_hide(&self.config, &outcomes, &plan); + } + fn apply_hide_foreground(&mut self) { let windows = self.controller.enumerate(); let foreground = self.controller.foreground(); let Some(target) = foreground_target(&windows, foreground) else { - logging::info("触发隐藏前台窗口:当前没有可隐藏的前台窗口,本次不隐藏"); + logging::info("隐藏前台窗口:当前没有可隐藏的前台窗口"); return; }; let targets = [target]; - let freezable = freezable_pids(&targets, &windows); - let freeze_set = if self.config.setting.freeze_whole_tree { - expand_descendants(&freezable, &crate::freeze::process_tree()) - } else { - freezable - }; - - let w = windows.iter().find(|w| w.hwnd == target.hwnd); - logging::info(&format!( - "触发隐藏前台窗口 hwnd={} pid={} process={} title=「{}」", - target.hwnd, - target.pid, - w.map(|w| w.process.as_str()).unwrap_or("未知进程"), - w.map(|w| w.title.as_str()).unwrap_or(""), - )); - - self.controller - .apply_hide(&self.config.setting, &targets, &freeze_set); - self.persist_recovery(); - self.sync_tray(); - if self.config.notifications.on_hide { - self.balloon(APP_NAME, i18n::t(Msg::HiddenBody)); + let freeze_set = self.freeze_set(freezable_pids(&targets, &windows)); + let plan = self.hide_with_plan(&targets, &freeze_set); + if let Some(t) = plan.fresh.first() { + logging::info(&format!("隐藏前台窗口: {}", t.describe())); } } fn apply_show(&mut self) { - let count = self.controller.hidden_count(); - self.controller.show(); - logging::info(&format!("已恢复显示 {count} 个窗口")); + let outcome = self.controller.show(); + log_show(outcome); self.persist_recovery(); self.sync_tray(); if self.config.notifications.on_show { @@ -468,28 +475,40 @@ impl AgentState { } (Response::Ok, false) } + Command::ReleaseWindows { hwnds } => { + let released = self.controller.release_windows(&hwnds); + if released > 0 { + logging::info(&format!("窗口恢复工具释放 {released} 个窗口")); + self.persist_recovery(); + self.sync_tray(); + } + (Response::Ok, false) + } + Command::AdoptWindows { hwnds } => { + let targets: Vec = + hwnds.iter().map(|&h| crate::hide::Target::bare(h, 0)).collect(); + // 恢复工具的手动隐藏不施加副作用,仅隐藏并纳入记录。 + let mut setting = self.config.setting.clone(); + setting.mute_after_hide = false; + setting.freeze_after_hide = false; + setting.send_before_hide = false; + let plan = self.hide_with_plan_using(&setting, &targets, &[]); + if !plan.fresh.is_empty() { + logging::info(&format!("窗口恢复工具隐藏 {} 个窗口", plan.fresh.len())); + } + (Response::Ok, false) + } Command::Quit => (Response::Ok, true), } } } -/// 记录本次隐藏的分级日志。 -fn log_resolution( - config: &Config, - outcomes: &[RuleOutcome], - targets: &[crate::hide::Target], - windows: &[bosskey_common::WindowInfo], -) { - logging::info(&format!( - "触发隐藏:命中 {} 个窗口(窗口规则 {} 条 / 进程规则 {} 条)", - targets.len(), - config.window_rules.len(), - config.process_rules.len() - )); +/// 摘要式记录本次隐藏;规则失效 / 追溯事件逐条保留。 +fn log_hide(config: &Config, outcomes: &[RuleOutcome], plan: &HidePlan) { for (rule, outcome) in config.window_rules.iter().zip(outcomes) { match outcome { RuleOutcome::Reacquired => logging::info(&format!( - "窗口规则「{}」的句柄已失效(目标程序重启过),已重新匹配到当前窗口并更新规则", + "窗口规则「{}」的句柄已失效(目标程序重启过),已重新匹配并更新规则", rule.title )), // 「未能追溯」只说明当前没有匹配的窗口,看不出是关闭了还是标题变了,故不臆断原因。 @@ -500,15 +519,45 @@ fn log_resolution( _ => {} } } - // 逐个写出隐藏目标的句柄、PID 与进程名/标题(info 级,release 也可见)。 - for t in targets { - let w = windows.iter().find(|w| w.hwnd == t.hwnd); - let process = w.map(|w| w.process.as_str()).unwrap_or("未知进程"); - let title = w.map(|w| w.title.as_str()).unwrap_or(""); + if plan.fresh.is_empty() { + logging::info("触发隐藏:没有新的目标窗口"); + return; + } + logging::info(&format!( + "隐藏 {} 个窗口: {}", + plan.fresh.len(), + summarize(plan.fresh.iter().map(|t| t.describe())) + )); + if !plan.freeze.is_empty() { + logging::info(&format!( + "冻结 {} 个进程(增强={}): {}", + plan.freeze.len(), + plan.enhanced, + summarize(plan.freeze.iter().map(|r| r.pid.to_string())) + )); + } +} + +/// 记录恢复结果;有失效记录被跳过时如实写出。 +fn log_show(outcome: ShowOutcome) { + if outcome.stale > 0 { logging::info(&format!( - "隐藏目标 hwnd={} pid={} process={process} title=「{title}」", - t.hwnd, t.pid + "恢复显示 {} 个窗口,另有 {} 条记录已失效(窗口已关闭或句柄被复用),已跳过", + outcome.shown, outcome.stale )); + } else { + logging::info(&format!("恢复显示 {} 个窗口", outcome.shown)); + } +} + +/// 拼接清单,最多列出前 8 项,其余以「等 N 项」收尾。 +fn summarize(items: impl Iterator) -> String { + const MAX: usize = 8; + let all: Vec = items.collect(); + if all.len() <= MAX { + all.join("、") + } else { + format!("{}、等 {} 项", all[..MAX].join("、"), all.len()) } } @@ -571,9 +620,7 @@ fn toggle_auto_hide(state: &mut AgentState, hwnd: HWND) { "托盘菜单:已暂停自动隐藏" }); if let Err(e) = state.config.save(&state.config_path) { - logging::warn(&format!( - "自动隐藏开关写入配置失败,核心重启后将丢失本次切换: {e}" - )); + log_warn!("自动隐藏开关写入配置失败,核心重启后将丢失本次切换: {e}"); } // 重新武装/停掉空闲检测定时器,并刷新托盘角标。 state.refresh_runtime(hwnd); @@ -608,10 +655,12 @@ fn toggle_autostart(state: &AgentState) { } } -fn state_mut<'a>(hwnd: HWND) -> Option<&'a mut AgentState> { +/// 代理窗口 `GWLP_USERDATA` 里存放的状态单元。用 `RefCell` 而非裸 `&mut`: +/// 菜单模态循环会重入 `wndproc`,重入时借用失败、事件被安全丢弃。 +fn state_cell<'a>(hwnd: HWND) -> Option<&'a RefCell> { unsafe { - let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut AgentState; - ptr.as_mut() + let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *const RefCell; + ptr.as_ref() } } @@ -727,14 +776,21 @@ fn launch_settings(state: &mut AgentState, action: Option<&str>) { cmd.arg(arg); } if let Err(e) = cmd.spawn() { - logging::warn(&format!("启动配置程序失败: {e}")); + log_warn!("启动配置程序失败: {e}"); } } fn quit(state: &mut AgentState, hwnd: HWND) { - state.controller.show(); + let outcome = state.controller.show(); + if outcome.shown > 0 || outcome.stale > 0 { + log_show(outcome); + } state.persist_recovery(); state.unregister_hotkeys(hwnd); + // 排干副作用队列,确保退出前解冻 / 取消静音已生效。 + if let Some(worker) = state.effects_worker.take() { + worker.shutdown(EFFECTS_SHUTDOWN_TIMEOUT); + } logging::info("核心正常退出"); let notify_quit = state.config.notifications.on_quit; if let Some(tray) = &mut state.tray { @@ -749,9 +805,14 @@ fn quit(state: &mut AgentState, hwnd: HWND) { } unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { - let Some(state) = state_mut(hwnd) else { + let Some(cell) = state_cell(hwnd) else { + return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }; + }; + // 借用失败即模态菜单重入:丢弃本次事件。 + let Ok(mut state) = cell.try_borrow_mut() else { return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }; }; + let state = &mut *state; if msg != 0 && msg == crate::tray::taskbar_created_msg() { if let Some(tray) = &mut state.tray { tray.on_taskbar_created(); @@ -797,6 +858,10 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: state.controller.is_hidden(), state.config.setting.auto_hide_enabled, ); + // 补发 IPC 唤醒,排干菜单模态期间积压的命令。 + unsafe { + let _ = PostMessageW(Some(hwnd), WM_APP_IPC, WPARAM(0), LPARAM(0)); + } } _ => {} } @@ -813,6 +878,9 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: // 右键菜单以代理窗口为宿主,经 WM_COMMAND 复用处理。 FLOAT_MENU => { crate::float_window::show_float_menu(hwnd, MENU_SETTINGS, MENU_QUIT); + unsafe { + let _ = PostMessageW(Some(hwnd), WM_APP_IPC, WPARAM(0), LPARAM(0)); + } } _ => {} } @@ -941,17 +1009,14 @@ fn load_config_logging_fallback(path: &Path) -> Config { match Config::load_reporting(path) { Ok((config, None)) => config, Ok((config, Some(parse_error))) => { - logging::error(&format!( + log_error!( "配置文件解析失败,{FALLBACK}: {} — {parse_error}", path.display() - )); + ); config } Err(e) => { - logging::error(&format!( - "配置文件读取失败,{FALLBACK}: {} — {e}", - path.display() - )); + log_error!("配置文件读取失败,{FALLBACK}: {} — {e}", path.display()); Config::default() } } @@ -975,14 +1040,14 @@ pub fn run(options: AgentOptions) { // 记录当前版本并落盘,避免下次启动重复弹出(首次启动时顺带创建配置文件)。 config.version = bosskey_common::APP_CONFIG_VERSION.to_string(); if let Err(e) = config.save(&options.config_path) { - logging::warn(&format!("写入配置版本失败: {e}")); + log_warn!("写入配置版本失败: {e}"); } if let Some(path) = find_config_exe() { if let Err(e) = std::process::Command::new(&path).spawn() { - logging::warn(&format!("启动配置程序失败: {e}")); + log_warn!("启动配置程序失败: {e}"); } } else { - logging::warn("未找到配置程序,无法在启动时拉起"); + log_warn!("未找到配置程序,无法在启动时拉起"); } } @@ -990,10 +1055,7 @@ pub fn run(options: AgentOptions) { let hwnd = match create_agent_window() { Ok(hwnd) => hwnd, Err(e) => { - logging::error(&format!( - "创建代理窗口失败,核心无法启动: {}", - crate::util::win_err(&e) - )); + log_error!("创建代理窗口失败,核心无法启动: {}", crate::util::win_err(&e)); return; } }; @@ -1039,11 +1101,16 @@ pub fn run(options: AgentOptions) { let tray_icons = tray.as_ref().map(|_| TrayIconSet::new()); - let mut state = Box::new(AgentState { + // 副作用由专职线程执行,消息循环不被慢操作阻塞。 + let effects_worker = EffectsWorker::spawn(WinEffects::new(exe_dir)); + + let state = Box::new(RefCell::new(AgentState { config, config_path: options.config_path.clone(), recovery_path, - controller: HideController::new(WindowsWindowManager, WinEffects::new(exe_dir)), + controller: HideController::new(WindowsWindowManager, effects_worker.effects()), + effects_worker: Some(effects_worker), + persist_warned: false, tray, tray_icons, ipc_rx, @@ -1052,28 +1119,44 @@ pub fn run(options: AgentOptions) { monitoring: true, hotkeys_armed: false, keyboard_hook: None, - }); + })); // 恢复文件存在即上次异常退出仍有窗口被隐藏,先找回。 - if let Some(snapshot) = recovery::load(&state.recovery_path) { - logging::warn(&format!( - "检测到上次异常退出,开始找回 {} 个被隐藏的窗口(另需解冻 {} 个进程、取消静音 {} 个进程)", - snapshot.hidden.len(), - snapshot.frozen.len(), - snapshot.muted.len() - )); - state.controller.restore_from(snapshot); - recovery::clear(&state.recovery_path); - // 解冻/取消静音的失败由 effects 各自记录;窗口显示无可靠的失败信号 - // (ShowWindow 的返回值是「先前是否可见」而非成败),故此处只标记流程走完。 - logging::info("崩溃恢复流程已完成"); + { + let mut state = state.borrow_mut(); + if let Some(snapshot) = recovery::load(&state.recovery_path) { + if snapshot.is_restorable(recovery::current_boot_time_ms()) { + logging::warn(&format!( + "检测到上次异常退出,开始找回 {} 个被隐藏的窗口(另需解冻 {} 个、取消静音 {} 个进程)", + snapshot.hidden.len(), + snapshot.frozen.len(), + snapshot.muted.len() + )); + let outcome = state.controller.restore_from(snapshot); + logging::info(&format!( + "崩溃恢复完成:恢复 {} 个窗口,跳过 {} 条失效记录", + outcome.shown, outcome.stale + )); + } else { + // 跨重启(或旧版格式)快照中的句柄与 PID 已失效,只丢弃并留档。 + log_warn!( + "恢复文件来自上一次开机或旧版本,其中的窗口句柄已失效,跳过恢复: {}", + state.recovery_path.display() + ); + } + recovery::clear(&state.recovery_path); + } } unsafe { - SetWindowLongPtrW(hwnd, GWLP_USERDATA, &mut *state as *mut AgentState as isize); + SetWindowLongPtrW( + hwnd, + GWLP_USERDATA, + &*state as *const RefCell as isize, + ); } - state.sync_monitoring(hwnd); + state.borrow_mut().sync_monitoring(hwnd); let hwnd_value = hwnd.0 as isize; ipc_server::spawn(options.pipe_name.clone(), move |cmd| { @@ -1098,10 +1181,13 @@ pub fn run(options: AgentOptions) { }) }); - if state.config.notifications.on_start - && let Some(tray) = &state.tray { - tray.balloon(i18n::t(Msg::StartTitle), i18n::t(Msg::StartBody)); + let state = state.borrow(); + if state.config.notifications.on_start + && let Some(tray) = &state.tray + { + tray.balloon(i18n::t(Msg::StartTitle), i18n::t(Msg::StartBody)); + } } if let Some(ms) = options.auto_quit_ms { @@ -1125,6 +1211,11 @@ pub fn run(options: AgentOptions) { let _ = DestroyWindow(hwnd); } + // 非 quit() 路径(如 WM_DESTROY)退出时兜底排干副作用队列。 + if let Some(worker) = state.borrow_mut().effects_worker.take() { + worker.shutdown(EFFECTS_SHUTDOWN_TIMEOUT); + } + drop(state); } diff --git a/crates/core/src/effects.rs b/crates/core/src/effects.rs index 94253df..0559b63 100644 --- a/crates/core/src/effects.rs +++ b/crates/core/src/effects.rs @@ -1,16 +1,25 @@ use std::path::PathBuf; +use std::time::Duration; -use crate::{audio, freeze, input, logging}; +use crate::{audio, freeze, input, log_warn, logging}; +/// 隐藏 / 恢复的副作用(静音、冻结、暂停键)。 +/// +/// 实现可以是异步的(如 [`crate::effects_worker::AsyncEffects`]):调用方 +/// 不得依赖「方法返回即动作已生效」,只能依赖调用顺序与执行顺序一致(FIFO)。 pub trait Effects { fn mute(&self, pid: u32, mute: bool); fn suspend(&self, pid: u32, enhanced: bool); fn resume(&self, pid: u32, enhanced: bool); - /// 隐藏前发送媒体「播放/暂停」键。返回是否真的发送了 - /// (仅在检测到有音视频正在播放时才发送)。 - fn send_pause(&self) -> bool; + /// 发送媒体「播放/暂停」键(仅在检测到有音视频正在播放时才发送), + /// 并等待其生效。检测与等待都由实现负责。 + fn send_pause(&self); } +/// 暂停键发出后等待媒体程序响应的时长。冻结须在这之后(FIFO 保证), +/// 否则被冻结的进程收不到按键。 +const SEND_PAUSE_DELAY: Duration = Duration::from_millis(200); + pub struct WinEffects { exe_dir: PathBuf, } @@ -27,19 +36,18 @@ impl Effects for WinEffects { } fn suspend(&self, pid: u32, enhanced: bool) { - // 每一次冻结调用都写日志:成功记 info,失败记 warn,便于事后按 pid 追溯。 if enhanced && freeze::pssuspend_available(&self.exe_dir) { match freeze::suspend_enhanced(&self.exe_dir, pid) { Ok(()) => { - logging::info(&format!("增强冻结成功 (pid={pid})")); + logging::debug(&format!("增强冻结成功 (pid={pid})")); return; } - Err(e) => logging::warn(&format!("增强冻结失败,回退普通冻结 (pid={pid}): {e}")), + Err(e) => log_warn!("增强冻结失败,回退普通冻结 (pid={pid}): {e}"), } } match freeze::suspend_process(pid) { - Ok(()) => logging::info(&format!("普通冻结成功 (pid={pid})")), - Err(e) => logging::warn(&format!("普通冻结失败,该进程未被冻结 (pid={pid}): {e}")), + Ok(()) => logging::debug(&format!("普通冻结成功 (pid={pid})")), + Err(e) => log_warn!("冻结失败,该进程未被冻结 (pid={pid}): {e}"), } } @@ -47,27 +55,24 @@ impl Effects for WinEffects { if enhanced && freeze::pssuspend_available(&self.exe_dir) { match freeze::resume_enhanced(&self.exe_dir, pid) { Ok(()) => { - logging::info(&format!("增强解冻成功 (pid={pid})")); + logging::debug(&format!("增强解冻成功 (pid={pid})")); return; } - Err(e) => logging::warn(&format!("增强解冻失败,回退普通解冻 (pid={pid}): {e}")), + Err(e) => log_warn!("增强解冻失败,回退普通解冻 (pid={pid}): {e}"), } } match freeze::resume_process(pid) { - Ok(()) => logging::info(&format!("普通解冻成功 (pid={pid})")), - // 不升级为 error:进程在隐藏期间正常退出时同样会走到这里(OpenProcess 失败), - // 那是常态而非故障。具体是「已退出」还是「权限不足」由错误码分辨。 - Err(e) => logging::warn(&format!("普通解冻失败 (pid={pid}): {e}")), + Ok(()) => logging::debug(&format!("普通解冻成功 (pid={pid})")), + // 不升级为 error:身份校验后仍可能竞态(解冻前进程恰好退出)。 + Err(e) => log_warn!("解冻失败 (pid={pid}): {e}"), } } - fn send_pause(&self) -> bool { + fn send_pause(&self) { // 没有音视频在播放时不发键,避免把静止的播放器切成播放。 if audio::is_audio_playing() { input::send_media_pause(); - true - } else { - false + std::thread::sleep(SEND_PAUSE_DELAY); } } } diff --git a/crates/core/src/effects_worker.rs b/crates/core/src/effects_worker.rs new file mode 100644 index 0000000..cb37d78 --- /dev/null +++ b/crates/core/src/effects_worker.rs @@ -0,0 +1,159 @@ +//! 副作用专职线程:把静音 / 冻结 / 暂停键等慢操作挪出窗口消息循环。 +//! 任务经 [`AsyncEffects`] 入队,由单线程按 FIFO 顺序执行;通道无界,事件不丢。 + +use std::sync::mpsc::{Sender, channel}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use crate::effects::Effects; +use crate::{log_error, log_warn}; + +enum Task { + Mute { pid: u32, mute: bool }, + Suspend { pid: u32, enhanced: bool }, + Resume { pid: u32, enhanced: bool }, + SendPause, + Quit, +} + +/// 副作用线程句柄。`shutdown` 排干队列后退出,保证退出前解冻 / 取消静音已生效。 +pub struct EffectsWorker { + tx: Sender, + handle: Option>, +} + +impl EffectsWorker { + /// 启动专职线程;`inner` 是真正执行副作用的实现(生产为 `WinEffects`)。 + pub fn spawn(inner: E) -> Self { + let (tx, rx) = channel::(); + let handle = std::thread::Builder::new() + .name("bosskey-effects".into()) + .spawn(move || { + while let Ok(task) = rx.recv() { + match task { + Task::Mute { pid, mute } => inner.mute(pid, mute), + Task::Suspend { pid, enhanced } => inner.suspend(pid, enhanced), + Task::Resume { pid, enhanced } => inner.resume(pid, enhanced), + Task::SendPause => inner.send_pause(), + Task::Quit => break, + } + } + }) + .expect("创建副作用线程失败"); + Self { + tx, + handle: Some(handle), + } + } + + /// 供 [`crate::hide::HideController`] 使用的异步 [`Effects`] 句柄。 + pub fn effects(&self) -> AsyncEffects { + AsyncEffects { + tx: self.tx.clone(), + } + } + + /// 排干队列并结束线程;超时放弃等待并 warn。 + pub fn shutdown(mut self, timeout: Duration) { + let _ = self.tx.send(Task::Quit); + let Some(handle) = self.handle.take() else { + return; + }; + let deadline = Instant::now() + timeout; + while !handle.is_finished() { + if Instant::now() >= deadline { + log_warn!("副作用线程未在 {timeout:?} 内排干队列,放弃等待"); + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + let _ = handle.join(); + } +} + +/// 把每个副作用调用转成任务入队的 [`Effects`] 实现。克隆廉价。 +#[derive(Clone)] +pub struct AsyncEffects { + tx: Sender, +} + +impl AsyncEffects { + fn send(&self, task: Task) { + if self.tx.send(task).is_err() { + log_error!("副作用线程已退出,本次副作用未执行"); + } + } +} + +impl Effects for AsyncEffects { + fn mute(&self, pid: u32, mute: bool) { + self.send(Task::Mute { pid, mute }); + } + fn suspend(&self, pid: u32, enhanced: bool) { + self.send(Task::Suspend { pid, enhanced }); + } + fn resume(&self, pid: u32, enhanced: bool) { + self.send(Task::Resume { pid, enhanced }); + } + fn send_pause(&self) { + self.send(Task::SendPause); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + /// 线程安全的记录用 Effects:按调用顺序记下每个动作。 + #[derive(Clone, Default)] + struct Recorder { + calls: Arc>>, + } + + impl Effects for Recorder { + fn mute(&self, pid: u32, mute: bool) { + self.calls.lock().unwrap().push(format!("mute:{pid}:{mute}")); + } + fn suspend(&self, pid: u32, _enhanced: bool) { + self.calls.lock().unwrap().push(format!("suspend:{pid}")); + } + fn resume(&self, pid: u32, _enhanced: bool) { + self.calls.lock().unwrap().push(format!("resume:{pid}")); + } + fn send_pause(&self) { + self.calls.lock().unwrap().push("pause".into()); + } + } + + #[test] + fn tasks_run_in_fifo_order_and_shutdown_drains_the_queue() { + let recorder = Recorder::default(); + let worker = EffectsWorker::spawn(recorder.clone()); + let effects = worker.effects(); + + // 暂停键必须先于冻结执行(冻结后的进程收不到按键)。 + effects.send_pause(); + effects.mute(100, true); + effects.suspend(100, false); + effects.resume(100, false); + + worker.shutdown(Duration::from_secs(5)); + + assert_eq!( + *recorder.calls.lock().unwrap(), + vec!["pause", "mute:100:true", "suspend:100", "resume:100"], + "任务应按入队顺序全部执行完毕(shutdown 排干队列)" + ); + } + + #[test] + fn send_after_shutdown_does_not_panic() { + let worker = EffectsWorker::spawn(Recorder::default()); + let effects = worker.effects(); + worker.shutdown(Duration::from_secs(5)); + // 线程已退出,入队应静默失败(记日志)而非 panic。 + effects.mute(1, true); + effects.send_pause(); + } +} diff --git a/crates/core/src/hide.rs b/crates/core/src/hide.rs index 5d391a6..acf6e4f 100644 --- a/crates/core/src/hide.rs +++ b/crates/core/src/hide.rs @@ -1,5 +1,4 @@ use std::collections::HashSet; -use std::time::Duration; use bosskey_common::matching::{WindowResolution, match_process_rule, resolve_window_rule}; use bosskey_common::{Config, Setting, WindowInfo}; @@ -7,14 +6,50 @@ use serde::{Deserialize, Serialize}; use crate::effects::Effects; use crate::platform::WindowManager; -use crate::recovery::Snapshot; +use crate::recovery::{ProcRecord, Snapshot}; -const SEND_PAUSE_DELAY: Duration = Duration::from_millis(200); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// 一条隐藏记录。`process_path` / `title` 供日志与排查使用;旧恢复文件缺省为空串。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Target { pub hwnd: i64, pub pid: u32, + #[serde(default)] + pub process_path: String, + #[serde(default)] + pub title: String, +} + +impl Target { + /// 只有句柄与 PID 的记录(来源没有路径 / 标题信息时使用)。 + pub fn bare(hwnd: i64, pid: u32) -> Self { + Self { + hwnd, + pid, + process_path: String::new(), + title: String::new(), + } + } + + pub fn from_window(w: &WindowInfo) -> Self { + Self { + hwnd: w.hwnd, + pid: w.pid, + process_path: w.path.clone(), + title: w.title.clone(), + } + } + + /// 日志用的一行摘要:`进程名「标题」(hwnd=…, pid=…)`。 + pub fn describe(&self) -> String { + let process = std::path::Path::new(&self.process_path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("未知进程"); + format!( + "{process}「{}」(hwnd={}, pid={})", + self.title, self.hwnd, self.pid + ) + } } /// 一条窗口规则的解析结果摘要。 @@ -40,29 +75,20 @@ pub fn resolve_targets( let outcome = match resolve_window_rule(rule, windows) { WindowResolution::Live(w) => { rule.title = w.title.clone(); - result.push(Target { - hwnd: w.hwnd, - pid: w.pid, - }); + result.push(Target::from_window(w)); RuleOutcome::Live } WindowResolution::Reacquired(w) => { rule.hwnd = w.hwnd; rule.pid = w.pid; rule.title = w.title.clone(); - result.push(Target { - hwnd: w.hwnd, - pid: w.pid, - }); + result.push(Target::from_window(w)); RuleOutcome::Reacquired } WindowResolution::Missing => RuleOutcome::Missing, WindowResolution::Regex(hits) => { for w in &hits { - result.push(Target { - hwnd: w.hwnd, - pid: w.pid, - }); + result.push(Target::from_window(w)); } RuleOutcome::Regex(hits.len()) } @@ -72,23 +98,16 @@ pub fn resolve_targets( for rule in &config.process_rules { for w in match_process_rule(rule, windows) { - result.push(Target { - hwnd: w.hwnd, - pid: w.pid, - }); + result.push(Target::from_window(w)); } } if config.setting.hide_current && foreground != 0 { - let pid = windows - .iter() - .find(|w| w.hwnd == foreground) - .map(|w| w.pid) - .unwrap_or(0); - result.push(Target { - hwnd: foreground, - pid, - }); + // 前台窗口可能不在枚举结果里(如工具窗口);缺失的 PID 由 plan_hide 补查。 + match windows.iter().find(|w| w.hwnd == foreground) { + Some(w) => result.push(Target::from_window(w)), + None => result.push(Target::bare(foreground, 0)), + } } let mut seen = HashSet::new(); @@ -107,10 +126,7 @@ pub fn foreground_target(windows: &[WindowInfo], foreground: i64) -> Option Vec { out } +/// 一次隐藏的执行计划:由 [`HideController::plan_hide`] 算出、 +/// [`HideController::commit_hide`] 原样执行,两段之间由调用方落盘意图。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HidePlan { + /// 本次新增的隐藏目标(已剔除死句柄 / 已隐藏项 / 查不到 PID 的项)。 + pub fresh: Vec, + /// 本次新增的静音进程。 + pub mute: Vec, + /// 本次新增的冻结进程。 + pub freeze: Vec, + /// 是否发送媒体暂停键。 + pub send_pause: bool, + /// 本轮冻结方式(首轮跟随设置,之后沿用,保证解冻方式一致)。 + pub enhanced: bool, +} + +/// [`HideController::show`] 的执行结果,供调用方如实记录日志。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ShowOutcome { + /// 实际恢复显示的窗口数。 + pub shown: usize, + /// 因句柄失效或已被其他窗口复用而跳过的记录数。 + pub stale: usize, +} + pub struct HideController { wm: W, effects: E, hidden: Vec, - frozen: Vec, - muted: Vec, + frozen: Vec, + muted: Vec, used_enhanced: bool, } @@ -210,71 +251,165 @@ impl HideController { self.wm.foreground() } - /// 隐藏已解析出的目标:隐藏窗口并按设置应用静音 / 冻结 / 发送暂停键。 - /// `freeze_pids` 为最终要冻结的 PID 集(调用方算好,可能已含递归展开的子进程树, - /// 见 [`freezable_pids`] 与 [`expand_descendants`]);仅在 `freeze_after_hide` 开启时生效。 + /// 计算一次隐藏的执行计划,不做任何窗口 / 副作用动作;顺带完成隐藏集剪枝 + /// 与 PID 补查(仍查不到的目标剔除)。`freeze_pids` 为最终要冻结的 PID 集 + /// (可能已含子进程树),仅在 `freeze_after_hide` 开启时生效。 /// - /// 隐藏是累加的:先前隐藏的窗口留在隐藏集内,`show` 时一并恢复。已在隐藏集 / - /// 静音集 / 冻结集里的目标会被跳过——静音与挂起若重复施加,挂起尤其是计数式的, - /// 需要同样次数的解冻才能恢复。 - pub fn apply_hide(&mut self, setting: &Setting, targets: &[Target], freeze_pids: &[u32]) { + /// 隐藏是累加的,`show` 时一并恢复。已在隐藏 / 静音 / 冻结集内的目标会被 + /// 跳过——挂起是计数式的,重复施加会让解冻次数对不上。 + pub fn plan_hide( + &mut self, + setting: &Setting, + targets: &[Target], + freeze_pids: &[u32], + ) -> HidePlan { + self.prune_stale(); let known: HashSet = self.hidden.iter().map(|t| t.hwnd).collect(); - let fresh: Vec = targets - .iter() - .copied() - .filter(|t| !known.contains(&t.hwnd)) - .collect(); - // 隐藏前先发一次媒体「播放/暂停」键,暂停正在播放的音视频。 - // 只发一次:多目标时重复发送会把「播放/暂停」这个切换键又切回播放。 - // 仅当确有音视频在播放时才真正发键(由 effects 判断),此时才需等待其生效。 - if setting.send_before_hide && !fresh.is_empty() && self.effects.send_pause() { - std::thread::sleep(SEND_PAUSE_DELAY); + let mut fresh: Vec = Vec::new(); + for t in targets { + if known.contains(&t.hwnd) + || fresh.iter().any(|f| f.hwnd == t.hwnd) + || !self.wm.is_window(t.hwnd) + { + continue; + } + let mut t = t.clone(); + if t.pid == 0 { + t.pid = self.wm.window_pid(t.hwnd); + if t.pid == 0 { + continue; + } + } + fresh.push(t); } - for t in &fresh { - self.wm.hide(t.hwnd); - if setting.mute_after_hide && t.pid != 0 && !self.muted.contains(&t.pid) { - self.effects.mute(t.pid, true); - self.muted.push(t.pid); + let mut mute: Vec = Vec::new(); + if setting.mute_after_hide { + for t in &fresh { + if !self.muted.iter().any(|r| r.pid == t.pid) + && !mute.iter().any(|r| r.pid == t.pid) + { + mute.push(self.proc_record(t.pid)); + } } } - // 冻结与目标窗口解耦:freeze_pids 已是最终集合(含子进程树),逐个挂起。 + let mut freeze: Vec = Vec::new(); if setting.freeze_after_hide { - // 冻结方式在解冻时必须与冻结时一致,故只有冻结集为空(本轮是第一次冻结) - // 时才跟随当前设置,之后沿用同一方式。 - if self.frozen.is_empty() { - self.used_enhanced = setting.enhanced_freeze; - } for pid in freeze_pids { - if *pid != 0 && !self.frozen.contains(pid) { - self.effects.suspend(*pid, self.used_enhanced); - self.frozen.push(*pid); + if *pid != 0 + && !self.frozen.iter().any(|r| r.pid == *pid) + && !freeze.iter().any(|r| r.pid == *pid) + { + freeze.push(self.proc_record(*pid)); } } - self.frozen.sort_unstable(); } - self.muted.sort_unstable(); - self.hidden.extend(fresh); + HidePlan { + send_pause: setting.send_before_hide && !fresh.is_empty(), + // 解冻方式必须与冻结时一致:仅首轮冻结跟随设置,之后沿用。 + enhanced: if self.frozen.is_empty() { + setting.enhanced_freeze + } else { + self.used_enhanced + }, + fresh, + mute, + freeze, + } + } + + /// 执行计划:同步隐藏窗口(`SW_HIDE`),静音 / 冻结 / 暂停键经 [`Effects`] 施加 + /// (生产实现为异步队列)。 + pub fn commit_hide(&mut self, plan: HidePlan) { + // 暂停键先入队:冻结后的进程收不到按键。 + if plan.send_pause { + self.effects.send_pause(); + } + + for t in &plan.fresh { + self.wm.hide(t.hwnd); + } + + for r in &plan.mute { + self.effects.mute(r.pid, true); + self.muted.push(*r); + } + self.muted.sort_unstable_by_key(|r| r.pid); + + self.used_enhanced = plan.enhanced; + for r in &plan.freeze { + self.effects.suspend(r.pid, plan.enhanced); + self.frozen.push(*r); + } + self.frozen.sort_unstable_by_key(|r| r.pid); + + self.hidden.extend(plan.fresh); + } + + /// plan + commit 的便捷封装(不需要意图落盘的调用方使用)。 + pub fn apply_hide(&mut self, setting: &Setting, targets: &[Target], freeze_pids: &[u32]) { + let plan = self.plan_hide(setting, targets, freeze_pids); + self.commit_hide(plan); + } + + /// 剔除已不成立的隐藏记录:句柄失效、句柄被别的进程复用(PID 不符)、 + /// 或窗口已重新可见(被外部恢复显示或句柄复用)。 + fn prune_stale(&mut self) { + let wm = &self.wm; + self.hidden.retain(|t| { + wm.is_window(t.hwnd) && wm.window_pid(t.hwnd) == t.pid && !wm.is_visible(t.hwnd) + }); + } + + /// 记录进程身份;创建时刻查不到时记 0,恢复侧对 0 不做校验。 + fn proc_record(&self, pid: u32) -> ProcRecord { + ProcRecord { + pid, + created_at: self.wm.process_start_time(pid), + } + } + + /// 进程记录是否仍指向当初那个进程(PID 会被系统回收复用,须比对创建时刻)。 + fn proc_alive(&self, r: &ProcRecord) -> bool { + let now = self.wm.process_start_time(r.pid); + if now == 0 { + return false; // 进程已退出或查不到。 + } + r.created_at == 0 || now == r.created_at } - pub fn show(&mut self) { - for pid in &self.frozen { - self.effects.resume(*pid, self.used_enhanced); + /// 恢复全部隐藏窗口并撤销副作用;失效记录一律跳过并计入返回值。 + pub fn show(&mut self) -> ShowOutcome { + let mut outcome = ShowOutcome::default(); + + let frozen = std::mem::take(&mut self.frozen); + for r in &frozen { + if self.proc_alive(r) { + self.effects.resume(r.pid, self.used_enhanced); + } } - self.frozen.clear(); - for t in &self.hidden { - self.wm.show(t.hwnd); + let hidden = std::mem::take(&mut self.hidden); + for t in &hidden { + // 句柄仍存活且仍属于当初的进程才恢复,避免弹出复用同一句柄的无关窗口。 + if self.wm.is_window(t.hwnd) && (t.pid == 0 || self.wm.window_pid(t.hwnd) == t.pid) { + self.wm.show(t.hwnd); + outcome.shown += 1; + } else { + outcome.stale += 1; + } } - for pid in &self.muted { - self.effects.mute(*pid, false); + let muted = std::mem::take(&mut self.muted); + for r in &muted { + if self.proc_alive(r) { + self.effects.mute(r.pid, false); + } } - self.muted.clear(); - self.hidden.clear(); + outcome } /// 释放指定进程的隐藏状态:显示窗口、解冻、取消静音,并从隐藏集移除。返回被释放的窗口数。 @@ -284,56 +419,96 @@ impl HideController { } // 先解冻,否则窗口显示出来仍是卡死的。 - let (thaw, keep): (Vec, Vec) = self + let (thaw, keep): (Vec, Vec) = self .frozen .iter() .copied() - .partition(|pid| pids.contains(pid)); - for pid in &thaw { - self.effects.resume(*pid, self.used_enhanced); + .partition(|r| pids.contains(&r.pid)); + for r in &thaw { + if self.proc_alive(r) { + self.effects.resume(r.pid, self.used_enhanced); + } } self.frozen = keep; let (show, keep): (Vec, Vec) = self .hidden .iter() - .copied() + .cloned() .partition(|t| pids.contains(&t.pid)); for t in &show { - self.wm.show(t.hwnd); + if self.wm.is_window(t.hwnd) { + self.wm.show(t.hwnd); + } } self.hidden = keep; - let (unmute, keep): (Vec, Vec) = self + let (unmute, keep): (Vec, Vec) = self .muted .iter() .copied() - .partition(|pid| pids.contains(pid)); - for pid in &unmute { - self.effects.mute(*pid, false); + .partition(|r| pids.contains(&r.pid)); + for r in &unmute { + if self.proc_alive(r) { + self.effects.mute(r.pid, false); + } } self.muted = keep; show.len() } - /// 当前隐藏状态的快照,用于崩溃恢复落盘。 + /// 恢复显示指定句柄(窗口恢复工具经 IPC 调用)。在隐藏记录里的窗口按 + /// 整进程释放(连同解冻 / 取消静音);不在记录里的句柄直接恢复显示。 + /// 返回从记录中释放的窗口数。 + pub fn release_windows(&mut self, hwnds: &[i64]) -> usize { + let known: HashSet = self.hidden.iter().map(|t| t.hwnd).collect(); + let mut pids: Vec = self + .hidden + .iter() + .filter(|t| hwnds.contains(&t.hwnd)) + .map(|t| t.pid) + .collect(); + pids.sort_unstable(); + pids.dedup(); + let released = self.release_pids(&pids); + for &hwnd in hwnds { + if !known.contains(&hwnd) && self.wm.is_window(hwnd) { + self.wm.show(hwnd); + } + } + released + } + + /// 当前隐藏状态的快照,用于崩溃恢复落盘。版本与开机时刻由 `recovery::save` 盖章。 pub fn snapshot(&self) -> Snapshot { Snapshot { hidden: self.hidden.clone(), frozen: self.frozen.clone(), muted: self.muted.clone(), enhanced: self.used_enhanced, + ..Default::default() } } - /// 从崩溃前的快照恢复:显示窗口、解冻进程、取消静音。 - pub fn restore_from(&mut self, snapshot: Snapshot) { + /// 在当前状态上叠加执行计划后的快照,供意图先行落盘。 + pub fn planned_snapshot(&self, plan: &HidePlan) -> Snapshot { + let mut snapshot = self.snapshot(); + snapshot.hidden.extend(plan.fresh.iter().cloned()); + snapshot.frozen.extend(plan.freeze.iter().copied()); + snapshot.muted.extend(plan.mute.iter().copied()); + snapshot.enhanced = plan.enhanced; + snapshot + } + + /// 从崩溃前的快照恢复。快照整体有效性由调用方先行校验, + /// 逐条身份校验由 [`Self::show`] 完成。 + pub fn restore_from(&mut self, snapshot: Snapshot) -> ShowOutcome { self.hidden = snapshot.hidden; self.frozen = snapshot.frozen; self.muted = snapshot.muted; self.used_enhanced = snapshot.enhanced; - self.show(); + self.show() } } @@ -341,6 +516,7 @@ impl HideController { mod tests { use super::*; use std::cell::RefCell; + use std::collections::HashMap; use bosskey_common::{ProcessRule, WindowRule}; @@ -348,10 +524,25 @@ mod tests { WindowInfo::new(title, hwnd, process, hwnd as u32, path) } + /// 与 `win` 相同,但可指定 PID(用于构造同一进程的多个窗口)。 + fn win_pid(title: &str, hwnd: i64, process: &str, pid: u32, path: &str) -> WindowInfo { + WindowInfo::new(title, hwnd, process, pid, path) + } + fn wrule(title: &str, hwnd: i64, process: &str, path: &str) -> WindowRule { WindowRule::from_window(&win(title, hwnd, process, path)) } + /// 断言用:目标列表压缩为 (hwnd, pid) 序列。 + fn ids(targets: &[Target]) -> Vec<(i64, u32)> { + targets.iter().map(|t| (t.hwnd, t.pid)).collect() + } + + /// Mock 里进程创建时刻的默认值:`1000 + pid`。 + fn start_of(pid: u32) -> i64 { + 1000 + pid as i64 + } + /// 复刻 agent 的隐藏编排:解析目标(含追溯回填)后交给控制器应用副作用。 fn do_hide( controller: &mut HideController, @@ -375,10 +566,23 @@ mod tests { win("记事本", 20, "notepad.exe", "C:\\notepad.exe"), ]; let (targets, outcomes) = resolve_targets(&mut config, &windows, 0); - assert_eq!(targets, vec![Target { hwnd: 10, pid: 10 }]); + assert_eq!(ids(&targets), vec![(10, 10)]); assert_eq!(outcomes, vec![RuleOutcome::Live]); } + #[test] + fn resolved_target_carries_path_and_title() { + let mut config = Config { + window_rules: vec![wrule("微信", 10, "WeChat.exe", "C:\\WeChat.exe")], + ..Default::default() + }; + let windows = vec![win("微信", 10, "WeChat.exe", "C:\\WeChat.exe")]; + let (targets, _) = resolve_targets(&mut config, &windows, 0); + assert_eq!(targets[0].process_path, "C:\\WeChat.exe"); + assert_eq!(targets[0].title, "微信"); + assert_eq!(targets[0].describe(), "WeChat.exe「微信」(hwnd=10, pid=10)"); + } + #[test] fn window_rule_syncs_title_on_hide() { let mut config = Config { @@ -387,7 +591,7 @@ mod tests { }; let windows = vec![win("新标题", 10, "app.exe", "C:\\app.exe")]; let (targets, _) = resolve_targets(&mut config, &windows, 0); - assert_eq!(targets, vec![Target { hwnd: 10, pid: 10 }]); + assert_eq!(ids(&targets), vec![(10, 10)]); assert_eq!( config.window_rules[0].title, "新标题", "隐藏时应同步最新标题" @@ -402,7 +606,7 @@ mod tests { }; let windows = vec![win("微信", 99, "WeChat.exe", "C:\\WeChat.exe")]; let (targets, outcomes) = resolve_targets(&mut config, &windows, 0); - assert_eq!(targets, vec![Target { hwnd: 99, pid: 99 }]); + assert_eq!(ids(&targets), vec![(99, 99)]); assert_eq!(outcomes, vec![RuleOutcome::Reacquired]); assert_eq!(config.window_rules[0].hwnd, 99, "应回填新句柄"); } @@ -436,15 +640,7 @@ mod tests { win("记事本", 20, "notepad.exe", "C:\\notepad.exe"), ]; let (targets, _) = resolve_targets(&mut config, &windows, 0); - assert_eq!( - targets, - vec![Target { hwnd: 10, pid: 10 }, Target { hwnd: 11, pid: 11 }] - ); - } - - /// 与 `win` 相同,但可指定 PID(用于构造同一进程的多个窗口)。 - fn win_pid(title: &str, hwnd: i64, process: &str, pid: u32, path: &str) -> WindowInfo { - WindowInfo::new(title, hwnd, process, pid, path) + assert_eq!(ids(&targets), vec![(10, 10), (11, 11)]); } #[test] @@ -453,7 +649,7 @@ mod tests { win_pid("微信", 10, "WeChat.exe", 500, "C:\\WeChat.exe"), win_pid("文件传输助手", 11, "WeChat.exe", 500, "C:\\WeChat.exe"), ]; - let targets = vec![Target { hwnd: 10, pid: 500 }]; + let targets = vec![Target::bare(10, 500)]; assert!( freezable_pids(&targets, &windows).is_empty(), "还有窗口开着时不应冻结" @@ -466,7 +662,7 @@ mod tests { win_pid("微信", 10, "WeChat.exe", 500, "C:\\WeChat.exe"), win_pid("文件传输助手", 11, "WeChat.exe", 500, "C:\\WeChat.exe"), ]; - let targets = vec![Target { hwnd: 10, pid: 500 }, Target { hwnd: 11, pid: 500 }]; + let targets = vec![Target::bare(10, 500), Target::bare(11, 500)]; assert_eq!(freezable_pids(&targets, &windows), vec![500]); } @@ -476,7 +672,7 @@ mod tests { win_pid("微信", 10, "WeChat.exe", 500, "C:\\WeChat.exe"), win_pid("后台窗口", 11, "WeChat.exe", 500, "C:\\WeChat.exe").with_visibility(false), ]; - let targets = vec![Target { hwnd: 10, pid: 500 }]; + let targets = vec![Target::bare(10, 500)]; assert_eq!(freezable_pids(&targets, &windows), vec![500]); } @@ -556,34 +752,48 @@ mod tests { win("记事本", 20, "notepad.exe", "C:\\notepad.exe"), ]; let (targets, _) = resolve_targets(&mut config.clone(), &windows, 20); - assert_eq!( - targets, - vec![Target { hwnd: 10, pid: 10 }, Target { hwnd: 20, pid: 20 }] - ); + assert_eq!(ids(&targets), vec![(10, 10), (20, 20)]); let (same, _) = resolve_targets(&mut config, &windows, 10); - assert_eq!( - same, - vec![Target { hwnd: 10, pid: 10 }], - "前台与已命中窗口相同应去重" - ); + assert_eq!(ids(&same), vec![(10, 10)], "前台与已命中窗口相同应去重"); } struct MockWm { windows: Vec, foreground: i64, visible: RefCell>, + /// 仍然存在的句柄集合(窗口销毁 = 从中移除;与 visible 是两回事)。 + exists: RefCell>, + /// 覆写某句柄当前所属的 PID(模拟句柄被别的窗口复用)。 + pid_overrides: RefCell>, + /// 覆写某进程的创建时刻(模拟 PID 被回收复用;0 = 进程已退出)。 + start_overrides: RefCell>, } impl MockWm { fn new(windows: Vec, foreground: i64) -> Self { - let visible = windows.iter().map(|w| w.hwnd).collect(); + let handles: HashSet = windows.iter().map(|w| w.hwnd).collect(); Self { windows, foreground, - visible: RefCell::new(visible), + visible: RefCell::new(handles.clone()), + exists: RefCell::new(handles), + pid_overrides: RefCell::new(HashMap::new()), + start_overrides: RefCell::new(HashMap::new()), } } + + /// 模拟窗口被销毁:句柄失效且不可见。 + fn destroy(&self, hwnd: i64) { + self.visible.borrow_mut().remove(&hwnd); + self.exists.borrow_mut().remove(&hwnd); + } + + /// 模拟句柄被系统回收后分配给了新窗口。 + fn revive(&self, hwnd: i64) { + self.exists.borrow_mut().insert(hwnd); + self.visible.borrow_mut().insert(hwnd); + } } impl WindowManager for MockWm { @@ -607,6 +817,32 @@ mod tests { fn foreground(&self) -> i64 { self.foreground } + fn is_window(&self, hwnd: i64) -> bool { + self.exists.borrow().contains(&hwnd) + } + fn window_pid(&self, hwnd: i64) -> u32 { + if !self.is_window(hwnd) { + return 0; + } + if let Some(pid) = self.pid_overrides.borrow().get(&hwnd) { + return *pid; + } + self.windows + .iter() + .find(|w| w.hwnd == hwnd) + .map(|w| w.pid) + .unwrap_or(0) + } + fn process_start_time(&self, pid: u32) -> i64 { + if pid == 0 { + return 0; + } + self.start_overrides + .borrow() + .get(&pid) + .copied() + .unwrap_or(start_of(pid)) + } } #[derive(Default)] @@ -627,9 +863,8 @@ mod tests { fn resume(&self, pid: u32, _enhanced: bool) { self.resumes.borrow_mut().push(pid); } - fn send_pause(&self) -> bool { + fn send_pause(&self) { *self.pauses.borrow_mut() += 1; - true } } @@ -659,7 +894,8 @@ mod tests { assert_eq!(*controller.effects.suspends.borrow(), vec![10]); assert_eq!(*controller.effects.pauses.borrow(), 1, "应发送一次暂停键"); - controller.show(); + let outcome = controller.show(); + assert_eq!(outcome, ShowOutcome { shown: 1, stale: 0 }); assert!(!controller.is_hidden()); assert!(controller.wm.is_visible(10), "恢复后微信应可见"); assert_eq!(*controller.effects.resumes.borrow(), vec![10], "应解冻"); @@ -688,8 +924,8 @@ mod tests { ); let mut controller = HideController::new(wm, MockEffects::default()); - controller.apply_hide(&setting, &[Target { hwnd: 10, pid: 10 }], &[]); - controller.apply_hide(&setting, &[Target { hwnd: 20, pid: 20 }], &[]); + controller.apply_hide(&setting, &[Target::bare(10, 10)], &[]); + controller.apply_hide(&setting, &[Target::bare(20, 20)], &[]); assert_eq!( controller.hidden_count(), @@ -722,7 +958,7 @@ mod tests { let wm = MockWm::new(vec![win("微信", 10, "WeChat.exe", "C:\\WeChat.exe")], 10); let mut controller = HideController::new(wm, MockEffects::default()); - let targets = [Target { hwnd: 10, pid: 10 }]; + let targets = [Target::bare(10, 10)]; controller.apply_hide(&setting, &targets, &[10]); controller.apply_hide(&setting, &targets, &[10]); @@ -750,8 +986,8 @@ mod tests { win_pid("已隐藏", 11, "app.exe", 600, "C:\\app.exe").with_visibility(false), ]; assert_eq!( - foreground_target(&windows, 10), - Some(Target { hwnd: 10, pid: 500 }) + foreground_target(&windows, 10).map(|t| (t.hwnd, t.pid)), + Some((10, 500)) ); assert_eq!(foreground_target(&windows, 0), None, "无前台窗口"); assert_eq!( @@ -811,27 +1047,75 @@ mod tests { do_hide(&mut controller, &mut config); let snapshot = controller.snapshot(); - assert_eq!(snapshot.hidden, vec![Target { hwnd: 10, pid: 10 }]); - assert_eq!(snapshot.frozen, vec![10]); - assert_eq!(snapshot.muted, vec![10]); + assert_eq!(ids(&snapshot.hidden), vec![(10, 10)]); + assert_eq!( + snapshot.frozen, + vec![ProcRecord { + pid: 10, + created_at: start_of(10) + }], + "冻结记录应带进程创建时刻" + ); + assert_eq!( + snapshot.muted, + vec![ProcRecord { + pid: 10, + created_at: start_of(10) + }] + ); controller.show(); assert!(controller.snapshot().is_empty(), "显示后快照应清空"); } + #[test] + fn planned_snapshot_matches_state_after_commit() { + let mut config = Config::default(); + config.setting.hide_current = false; + config.setting.mute_after_hide = true; + config.setting.freeze_after_hide = true; + config.window_rules = vec![wrule("微信", 10, "WeChat.exe", "C:\\WeChat.exe")]; + + let wm = MockWm::new(vec![win("微信", 10, "WeChat.exe", "C:\\WeChat.exe")], 10); + let mut controller = HideController::new(wm, MockEffects::default()); + + let windows = controller.enumerate(); + let (targets, _) = resolve_targets(&mut config, &windows, 0); + let freezable = freezable_pids(&targets, &windows); + + let plan = controller.plan_hide(&config.setting, &targets, &freezable); + let planned = controller.planned_snapshot(&plan); + controller.commit_hide(plan); + let actual = controller.snapshot(); + + // 意图快照与提交后的实际状态一致——落盘发生在动作前,两者必须等价。 + assert_eq!(planned.hidden, actual.hidden); + assert_eq!(planned.frozen, actual.frozen); + assert_eq!(planned.muted, actual.muted); + assert_eq!(planned.enhanced, actual.enhanced); + } + #[test] fn restore_from_snapshot_shows_windows_and_reverts_effects() { let wm = MockWm::new(vec![win("微信", 10, "WeChat.exe", "C:\\WeChat.exe")], 10); wm.hide(10); let mut controller = HideController::new(wm, MockEffects::default()); - controller.restore_from(Snapshot { - hidden: vec![Target { hwnd: 10, pid: 10 }], - frozen: vec![10], - muted: vec![10], + let outcome = controller.restore_from(Snapshot { + hidden: vec![Target::bare(10, 10)], + frozen: vec![ProcRecord { + pid: 10, + created_at: start_of(10), + }], + muted: vec![ProcRecord { + pid: 10, + created_at: start_of(10), + }], enhanced: false, + ..Default::default() }); + assert_eq!(outcome, ShowOutcome { shown: 1, stale: 0 }); assert!(!controller.is_hidden(), "恢复完成后应回到未隐藏状态"); assert!(controller.wm.is_visible(10), "崩溃前隐藏的窗口应被找回"); assert_eq!(*controller.effects.resumes.borrow(), vec![10], "应解冻进程"); @@ -843,6 +1127,164 @@ mod tests { assert!(controller.snapshot().is_empty()); } + #[test] + fn dead_handle_is_pruned_and_reused_handle_can_hide_again() { + let setting = Setting { + hide_current: false, + ..Setting::default() + }; + let wm = MockWm::new(vec![win("微信", 10, "WeChat.exe", "C:\\WeChat.exe")], 10); + let mut controller = HideController::new(wm, MockEffects::default()); + + controller.apply_hide(&setting, &[Target::bare(10, 10)], &[]); + assert_eq!(controller.hidden_count(), 1); + + // 旧窗口销毁,句柄随后被新窗口复用。 + controller.wm.destroy(10); + controller.wm.revive(10); + + controller.apply_hide(&setting, &[Target::bare(10, 10)], &[]); + assert_eq!( + controller.hidden_count(), + 1, + "死记录应被剪掉,复用句柄的新窗口不应被误判为已隐藏" + ); + assert!(!controller.wm.is_visible(10), "新窗口应真的被隐藏"); + } + + #[test] + fn show_skips_destroyed_and_reused_handles() { + let setting = Setting { + hide_current: false, + ..Setting::default() + }; + let wm = MockWm::new( + vec![ + win("微信", 10, "WeChat.exe", "C:\\WeChat.exe"), + win("记事本", 20, "notepad.exe", "C:\\notepad.exe"), + win("画图", 30, "mspaint.exe", "C:\\mspaint.exe"), + ], + 10, + ); + let mut controller = HideController::new(wm, MockEffects::default()); + controller.apply_hide( + &setting, + &[ + Target::bare(10, 10), + Target::bare(20, 20), + Target::bare(30, 30), + ], + &[], + ); + + controller.wm.destroy(10); // 窗口已退出。 + controller.wm.pid_overrides.borrow_mut().insert(20, 999); // 句柄被别的进程复用。 + + let outcome = controller.show(); + assert_eq!( + outcome, + ShowOutcome { shown: 1, stale: 2 }, + "死句柄与被复用句柄都应跳过" + ); + assert!(!controller.wm.is_visible(20), "被复用的句柄不得被弹出来"); + assert!(controller.wm.is_visible(30), "正常窗口应恢复"); + } + + #[test] + fn resume_is_skipped_when_pid_was_recycled_or_process_exited() { + let setting = Setting { + hide_current: false, + mute_after_hide: false, + freeze_after_hide: true, + ..Setting::default() + }; + let wm = MockWm::new( + vec![ + win_pid("A", 10, "a.exe", 100, "C:\\a.exe"), + win_pid("B", 20, "b.exe", 200, "C:\\b.exe"), + ], + 0, + ); + let mut controller = HideController::new(wm, MockEffects::default()); + controller.apply_hide( + &setting, + &[Target::bare(10, 100), Target::bare(20, 200)], + &[100, 200], + ); + + // PID 100 被回收给了新进程(创建时刻变了);PID 200 的进程已退出。 + controller + .wm + .start_overrides + .borrow_mut() + .insert(100, 9_999); + controller.wm.start_overrides.borrow_mut().insert(200, 0); + + controller.show(); + assert!( + controller.effects.resumes.borrow().is_empty(), + "身份不符或已退出的进程不得解冻,避免干扰无关进程" + ); + } + + #[test] + fn plan_fills_missing_pid_and_drops_unresolvable_targets() { + let setting = Setting { + hide_current: false, + ..Setting::default() + }; + let wm = MockWm::new( + vec![win_pid("画图", 30, "mspaint.exe", 33, "C:\\mspaint.exe")], + 0, + ); + let mut controller = HideController::new(wm, MockEffects::default()); + + // hwnd 40 不存在(is_window=false),hwnd 30 的 pid 现场补查为 33。 + controller.apply_hide(&setting, &[Target::bare(30, 0), Target::bare(40, 0)], &[]); + assert_eq!(controller.hidden_count(), 1, "查无此窗的目标应被剔除"); + assert_eq!( + controller.release_pids(&[33]), + 1, + "补齐 PID 后 release_pids 应能按进程释放该窗口" + ); + } + + #[test] + fn release_windows_frees_whole_process_and_shows_unknown_handles() { + let setting = Setting { + hide_current: false, + mute_after_hide: true, + freeze_after_hide: true, + ..Setting::default() + }; + let wm = MockWm::new( + vec![ + win_pid("主窗口", 10, "game.exe", 500, "C:\\game.exe"), + win_pid("子窗口", 11, "game.exe", 500, "C:\\game.exe"), + win_pid("无关窗口", 30, "other.exe", 700, "C:\\other.exe"), + ], + 0, + ); + // 记录外的句柄:被别的途径隐藏,核心不知情。 + wm.hide(30); + let mut controller = HideController::new(wm, MockEffects::default()); + controller.apply_hide( + &setting, + &[Target::bare(10, 500), Target::bare(11, 500)], + &[500], + ); + + // 只点选了一个窗口 + 一个记录外句柄。 + assert_eq!(controller.release_windows(&[10, 30]), 2); + assert!( + controller.wm.is_visible(10) && controller.wm.is_visible(11), + "同进程的两个窗口应一起释放,避免解冻后仍有窗口藏着" + ); + assert_eq!(*controller.effects.resumes.borrow(), vec![500], "应解冻"); + assert!(controller.wm.is_visible(30), "记录外的句柄应直接恢复显示"); + assert!(!controller.is_hidden()); + } + #[test] fn expand_descendants_collects_multi_level_tree() { // 1 → 2 → 4, 1 → 3;另有无关的 9 → 10。 diff --git a/crates/core/src/i18n.rs b/crates/core/src/i18n.rs index db163b2..fd03cbf 100644 --- a/crates/core/src/i18n.rs +++ b/crates/core/src/i18n.rs @@ -21,6 +21,7 @@ pub enum Msg { HiddenBody, ShownBody, ConfigExeMissing, + RecoveryPersistFailedBody, AutostartOffTitle, AutostartOffBody, AutostartOnTitle, @@ -67,6 +68,7 @@ impl Msg { Msg::HiddenBody => "已隐藏窗口", Msg::ShownBody => "已恢复显示窗口", Msg::ConfigExeMissing => "未找到配置程序", + Msg::RecoveryPersistFailedBody => "无法写入崩溃恢复文件,异常退出后将无法自动找回窗口", Msg::AutostartOffTitle => "开机自启已关闭", Msg::AutostartOffBody => "Boss Key 将不再随系统启动", Msg::AutostartOnTitle => "开机自启已开启", @@ -104,6 +106,7 @@ impl Msg { Msg::HiddenBody => "Windows hidden", Msg::ShownBody => "Windows restored", Msg::ConfigExeMissing => "Settings app not found", + Msg::RecoveryPersistFailedBody => "Cannot write the crash-recovery file; windows cannot be restored automatically after an abnormal exit", Msg::AutostartOffTitle => "Startup disabled", Msg::AutostartOffBody => "Boss Key will no longer start with Windows", Msg::AutostartOnTitle => "Startup enabled", @@ -141,6 +144,7 @@ impl Msg { Msg::HiddenBody => "已隱藏視窗", Msg::ShownBody => "已復原顯示視窗", Msg::ConfigExeMissing => "找不到設定程式", + Msg::RecoveryPersistFailedBody => "無法寫入當機復原檔案,異常結束後將無法自動找回視窗", Msg::AutostartOffTitle => "已關閉開機自動啟動", Msg::AutostartOffBody => "Boss Key 將不再隨系統啟動", Msg::AutostartOnTitle => "已開啟開機自動啟動", @@ -214,7 +218,7 @@ mod tests { use super::*; /// 全部文案键;新增 Msg 变体后必须同步登记,否则跨语言校验会漏掉它。 - const ALL_MSGS: [Msg; 32] = [ + const ALL_MSGS: [Msg; 33] = [ Msg::MenuSettings, Msg::MenuShowWindows, Msg::MenuHideWindows, @@ -226,6 +230,7 @@ mod tests { Msg::HiddenBody, Msg::ShownBody, Msg::ConfigExeMissing, + Msg::RecoveryPersistFailedBody, Msg::AutostartOffTitle, Msg::AutostartOffBody, Msg::AutostartOnTitle, diff --git a/crates/core/src/ipc_server.rs b/crates/core/src/ipc_server.rs index 63a408d..5964abc 100644 --- a/crates/core/src/ipc_server.rs +++ b/crates/core/src/ipc_server.rs @@ -14,10 +14,22 @@ use windows::Win32::System::Pipes::{ }; use windows::core::{HRESULT, PCWSTR}; +use crate::log_error; use crate::util::to_wide_null; const PIPE_BUF_SIZE: u32 = 4096; +/// 创建管道失败后的重试间隔:逐级退避,最后一档一直沿用,线程不退出。 +const RETRY_DELAYS: [std::time::Duration; 3] = [ + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(5), + std::time::Duration::from_secs(30), +]; + +fn retry_delay(attempt: u32) -> std::time::Duration { + RETRY_DELAYS[(attempt as usize).min(RETRY_DELAYS.len() - 1)] +} + /// 命名管道安全描述符(SDDL):Everyone 可读写,完整性标签设为 Low, /// 使普通配置程序能连上以管理员运行的核心。 const PIPE_SDDL: &str = "D:(A;;GRGW;;;WD)S:(ML;;NW;;;LW)"; @@ -95,6 +107,7 @@ where crate::logging::warn("命名管道安全描述符构造失败,管理员核心下普通配置程序可能无法连接"); } let sa_ptr = security.as_ref().map(PipeSecurity::as_ptr); + let mut failures: u32 = 0; loop { let handle = unsafe { CreateNamedPipeW( @@ -109,9 +122,16 @@ where ) }; if handle == INVALID_HANDLE_VALUE { - eprintln!("创建命名管道失败: {pipe_name}"); - return; + let err = std::io::Error::last_os_error(); + let delay = retry_delay(failures); + failures += 1; + log_error!( + "创建命名管道失败(第 {failures} 次),{delay:?} 后重试: {pipe_name} — {err}" + ); + std::thread::sleep(delay); + continue; } + failures = 0; if !is_client_connected(unsafe { ConnectNamedPipe(handle, None) }) { unsafe { @@ -188,6 +208,15 @@ mod tests { buf.trim_end().to_string() } + #[test] + fn retry_delay_backs_off_and_caps_at_the_last_step() { + assert_eq!(retry_delay(0), Duration::from_secs(1)); + assert_eq!(retry_delay(1), Duration::from_secs(5)); + assert_eq!(retry_delay(2), Duration::from_secs(30)); + assert_eq!(retry_delay(3), Duration::from_secs(30)); + assert_eq!(retry_delay(1000), Duration::from_secs(30)); + } + #[test] fn pipe_security_descriptor_builds_from_sddl() { let sec = PipeSecurity::new().expect("SDDL 应能解析出安全描述符"); diff --git a/crates/core/src/keyboard_hook.rs b/crates/core/src/keyboard_hook.rs index 8b85742..ad46f79 100644 --- a/crates/core/src/keyboard_hook.rs +++ b/crates/core/src/keyboard_hook.rs @@ -118,16 +118,11 @@ unsafe extern "system" fn hook_proc(ncode: i32, wparam: WPARAM, lparam: LPARAM) let data = unsafe { &*(lparam.0 as *const KBDLLHOOKSTRUCT) }; // 注入的按键(自家的媒体键模拟等)不参与判定,避免反馈回路。 if data.flags.0 & LLKHF_INJECTED.0 == 0 { + // GetKeyState 在锁外调用,锁内只剩纯内存判定。 + let pressed = pressed_modifiers(); let decision = HOTKEYS .lock() - .map(|mut states| { - decide( - wparam.0 as u32, - data.vkCode as u16, - pressed_modifiers(), - &mut states, - ) - }) + .map(|mut states| decide(wparam.0 as u32, data.vkCode as u16, pressed, &mut states)) .unwrap_or(Decision::Pass); match decision { Decision::Pass => {} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1e10722..7570b5e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent; pub mod audio; pub mod autostart; pub mod effects; +pub mod effects_worker; pub mod elevation; pub mod float_window; pub mod freeze; diff --git a/crates/core/src/logging.rs b/crates/core/src/logging.rs index 6345595..bbcc9d9 100644 --- a/crates/core/src/logging.rs +++ b/crates/core/src/logging.rs @@ -180,6 +180,16 @@ pub fn error(message: &str) { log(Level::Error, message); } +/// 供 [`crate::log_warn!`] 使用:warn 并附上报错位置。 +pub fn warn_at(file: &str, line: u32, message: &str) { + log(Level::Warn, &format!("{message} ({file}:{line})")); +} + +/// 供 [`crate::log_error!`] 使用:error 并附上报错位置。 +pub fn error_at(file: &str, line: u32, message: &str) { + log(Level::Error, &format!("{message} ({file}:{line})")); +} + fn log(level: Level, message: &str) { if !should_emit(level) { return; @@ -192,6 +202,22 @@ fn log(level: Level, message: &str) { } } +/// warn 级日志并自动附上调用处的 `文件:行号`。 +#[macro_export] +macro_rules! log_warn { + ($($arg:tt)*) => { + $crate::logging::warn_at(file!(), line!(), &format!($($arg)*)) + }; +} + +/// error 级日志并自动附上调用处的 `文件:行号`。 +#[macro_export] +macro_rules! log_error { + ($($arg:tt)*) => { + $crate::logging::error_at(file!(), line!(), &format!($($arg)*)) + }; +} + /// 格式化 panic 信息为单条日志(便于单元测试)。 fn format_panic(message: &str, location: Option<&str>) -> String { match location { @@ -336,6 +362,17 @@ mod tests { assert!(!dir.join("logs").exists(), "关闭日志时不应创建目录"); } + #[test] + fn warn_at_appends_source_location() { + let dir = temp_dir(); + let logger = Logger::new(dir.clone(), 7); + // 直接验证格式化结果(warn_at 走全局 logger,测试里用本地实例复刻其格式)。 + logger.log(Level::Warn, &format!("{} ({}:{})", "落盘失败", "agent.rs", 42)); + let logs: Vec<_> = fs::read_dir(&dir).unwrap().flatten().collect(); + let content = fs::read_to_string(logs[0].path()).unwrap(); + assert!(content.contains("[WARN] 落盘失败 (agent.rs:42)")); + } + #[test] fn panic_message_includes_location_when_available() { assert_eq!( diff --git a/crates/core/src/platform/mod.rs b/crates/core/src/platform/mod.rs index 5f0d8f7..42b6a9f 100644 --- a/crates/core/src/platform/mod.rs +++ b/crates/core/src/platform/mod.rs @@ -8,6 +8,12 @@ pub trait WindowManager { fn show(&self, hwnd: i64); fn is_visible(&self, hwnd: i64) -> bool; fn foreground(&self) -> i64; + /// 句柄当前是否仍指向一个存在的窗口(句柄值会被系统回收复用)。 + fn is_window(&self, hwnd: i64) -> bool; + /// 句柄当前所属进程的 PID;查不到返回 0。 + fn window_pid(&self, hwnd: i64) -> u32; + /// 进程创建时刻(Unix 毫秒),用于识别 PID 复用;查不到返回 0。 + fn process_start_time(&self, pid: u32) -> i64; } pub fn manager() -> impl WindowManager { diff --git a/crates/core/src/platform/win32.rs b/crates/core/src/platform/win32.rs index b86802d..86e2e86 100644 --- a/crates/core/src/platform/win32.rs +++ b/crates/core/src/platform/win32.rs @@ -1,14 +1,15 @@ use std::ffi::c_void; use bosskey_common::{NO_TITLE, WindowInfo}; -use windows::Win32::Foundation::{CloseHandle, HWND, LPARAM}; +use windows::Win32::Foundation::{CloseHandle, FILETIME, HWND, LPARAM}; use windows::Win32::System::Threading::{ - OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, + GetProcessTimes, OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, + QueryFullProcessImageNameW, }; use windows::Win32::UI::WindowsAndMessaging::{ EnumWindows, GW_OWNER, GWL_EXSTYLE, GetForegroundWindow, GetWindow, GetWindowLongPtrW, - GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, IsWindowVisible, SW_HIDE, - SW_SHOW, ShowWindow, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW, + GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, IsWindow, IsWindowVisible, + SW_HIDE, SW_SHOW, ShowWindow, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW, }; use windows::core::{BOOL, PWSTR}; @@ -77,6 +78,35 @@ pub(crate) fn process_path(pid: u32) -> String { } } +/// FILETIME(1601 起 100ns)→ Unix 毫秒。 +fn filetime_to_unix_ms(ft: FILETIME) -> i64 { + const EPOCH_DIFF_100NS: i64 = 116_444_736_000_000_000; + let raw = ((ft.dwHighDateTime as i64) << 32) | ft.dwLowDateTime as i64; + (raw - EPOCH_DIFF_100NS) / 10_000 +} + +/// 进程创建时刻(Unix 毫秒);打不开进程时返回 0。 +pub(crate) fn process_start_time(pid: u32) -> i64 { + if pid == 0 { + return 0; + } + unsafe { + let Ok(handle) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else { + return 0; + }; + let mut created = FILETIME::default(); + let mut exited = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + let result = GetProcessTimes(handle, &mut created, &mut exited, &mut kernel, &mut user); + let _ = CloseHandle(handle); + match result { + Ok(()) => filetime_to_unix_ms(created), + Err(_) => 0, + } + } +} + fn process_name_from_path(path: &str) -> String { std::path::Path::new(path) .file_name() @@ -151,6 +181,18 @@ impl WindowManager for WindowsWindowManager { fn foreground(&self) -> i64 { unsafe { hwnd_to_i64(GetForegroundWindow()) } } + + fn is_window(&self, hwnd: i64) -> bool { + unsafe { IsWindow(Some(hwnd_from(hwnd))).as_bool() } + } + + fn window_pid(&self, hwnd: i64) -> u32 { + window_pid(hwnd_from(hwnd)) + } + + fn process_start_time(&self, pid: u32) -> i64 { + process_start_time(pid) + } } #[cfg(test)] @@ -235,4 +277,59 @@ mod tests { let _ = DestroyWindow(hwnd); } } + + #[test] + fn filetime_epoch_maps_to_zero() { + // 1970-01-01 对应的 FILETIME 值应换算为 0 毫秒。 + let ft = FILETIME { + dwLowDateTime: (116_444_736_000_000_000u64 & 0xFFFF_FFFF) as u32, + dwHighDateTime: (116_444_736_000_000_000u64 >> 32) as u32, + }; + assert_eq!(filetime_to_unix_ms(ft), 0); + } + + #[test] + fn dead_handle_and_pid_are_detected() { + unsafe { + let hwnd = CreateWindowExW( + WINDOW_EX_STYLE(0), + w!("Static"), + w!("BossKeyIdentityTestWindow"), + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, + CW_USEDEFAULT, + 100, + 80, + None, + None, + None, + None, + ) + .expect("创建测试窗口失败"); + let wm = WindowsWindowManager; + let id = hwnd_to_i64(hwnd); + + assert!(wm.is_window(id), "存活窗口应通过 IsWindow"); + assert_eq!( + wm.window_pid(id), + std::process::id(), + "自建窗口应属于本进程" + ); + + let _ = DestroyWindow(hwnd); + assert!(!wm.is_window(id), "销毁后句柄应判定失效"); + assert_eq!(wm.window_pid(id), 0, "失效句柄查不到 PID"); + } + } + + #[test] + fn own_process_start_time_is_recent_and_stable() { + let wm = WindowsWindowManager; + let t1 = wm.process_start_time(std::process::id()); + let t2 = wm.process_start_time(std::process::id()); + assert!(t1 > 0, "本进程创建时刻应可查询"); + assert_eq!(t1, t2, "同一进程两次查询应一致"); + assert_eq!(wm.process_start_time(0), 0, "PID 0 应返回 0"); + assert_eq!(wm.process_start_time(0xFFFF_FFF0), 0, "无效 PID 应返回 0"); + } } diff --git a/crates/core/src/recovery.rs b/crates/core/src/recovery.rs index 151b459..7bf19a3 100644 --- a/crates/core/src/recovery.rs +++ b/crates/core/src/recovery.rs @@ -1,19 +1,50 @@ //! 崩溃恢复:隐藏状态落盘,核心异常退出后下次启动自动找回窗口。 +//! +//! 写入在隐藏动作执行前发生(意图先行),走 tmp + rename 原子替换; +//! 快照带开机时刻与进程创建时刻,跨重启后失效的快照在加载侧被丢弃。 -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::hide::Target; +use crate::log_warn; /// 恢复文件名(与 config.json 同目录)。 pub const RECOVERY_FILE_NAME: &str = "recovery.json"; +/// 当前快照格式版本。旧版(无该字段,缺省 0)不含身份信息,加载时按不可信丢弃。 +pub const SCHEMA_CURRENT: u32 = 1; + +/// 判定「同一次开机」的容差(GetTickCount64 精度约 10–16ms,另留时钟漂移余量)。 +const BOOT_TOLERANCE_MS: i64 = 5_000; + +/// 带创建时刻的进程记录(PID 会被系统回收复用,须配合创建时刻标识进程)。 +/// `created_at` 为 0 表示记录时查不到,恢复时不做身份校验。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcRecord { + pub pid: u32, + #[serde(default)] + pub created_at: i64, +} + +impl ProcRecord { + pub fn bare(pid: u32) -> Self { + Self { pid, created_at: 0 } + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Snapshot { + /// 快照格式版本;由 [`save`] 盖章,加载侧与 [`SCHEMA_CURRENT`] 不符即丢弃。 + #[serde(default)] + pub schema: u32, + /// 写入时刻推算出的本次开机时刻(Unix 毫秒);由 [`save`] 盖章。 + #[serde(default)] + pub boot_time_ms: i64, pub hidden: Vec, - pub frozen: Vec, - pub muted: Vec, + pub frozen: Vec, + pub muted: Vec, pub enhanced: bool, } @@ -22,23 +53,67 @@ impl Snapshot { pub fn is_empty(&self) -> bool { self.hidden.is_empty() && self.frozen.is_empty() && self.muted.is_empty() } + + /// 快照是否可用于恢复:格式为当前版本,且写入时与现在处于同一次开机。 + pub fn is_restorable(&self, now_boot_time_ms: i64) -> bool { + self.schema == SCHEMA_CURRENT && same_boot(self.boot_time_ms, now_boot_time_ms) + } +} + +/// 由「当前时间 − 开机以来的毫秒数」推算本次开机时刻(Unix 毫秒)。 +pub fn current_boot_time_ms() -> i64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + let uptime = unsafe { windows::Win32::System::SystemInformation::GetTickCount64() } as i64; + now - uptime } -/// 保存快照。快照为空时等价于清除(避免留下无意义的文件)。 +/// 两个推算出的开机时刻是否指同一次开机(容差内)。 +pub fn same_boot(a: i64, b: i64) -> bool { + (a - b).abs() <= BOOT_TOLERANCE_MS +} + +/// 保存快照:盖上版本与开机时刻,写入 tmp 后 rename 原子替换。 +/// 快照为空时等价于清除。 pub fn save(path: &Path, snapshot: &Snapshot) -> std::io::Result<()> { if snapshot.is_empty() { clear(path); return Ok(()); } - let json = serde_json::to_string_pretty(snapshot) + let mut stamped = snapshot.clone(); + stamped.schema = SCHEMA_CURRENT; + stamped.boot_time_ms = current_boot_time_ms(); + let json = serde_json::to_string_pretty(&stamped) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - std::fs::write(path, json) + let tmp = tmp_path(path); + std::fs::write(&tmp, json)?; + // Windows 下 rename 走 MOVEFILE_REPLACE_EXISTING,同目录替换是原子的。 + std::fs::rename(&tmp, path) +} + +fn tmp_path(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(".tmp"); + path.with_file_name(name) } -/// 读取快照。文件不存在、损坏或内容为空时返回 None。 +/// 读取快照。文件不存在或内容为空时返回 None;损坏时改名保留现场并 warn。 pub fn load(path: &Path) -> Option { let content = std::fs::read_to_string(path).ok()?; - let snapshot: Snapshot = serde_json::from_str(&content).ok()?; + let snapshot: Snapshot = match serde_json::from_str(&content) { + Ok(s) => s, + Err(e) => { + let corrupt = corrupt_path(path); + let _ = std::fs::rename(path, &corrupt); + log_warn!( + "恢复文件解析失败,本次不恢复;原文件已改名为 {}: {e}", + corrupt.display() + ); + return None; + } + }; if snapshot.is_empty() { None } else { @@ -46,6 +121,12 @@ pub fn load(path: &Path) -> Option { } } +fn corrupt_path(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(".corrupt"); + path.with_file_name(name) +} + /// 删除恢复文件(不存在时静默成功)。 pub fn clear(path: &Path) { let _ = std::fs::remove_file(path); @@ -65,19 +146,32 @@ mod tests { fn sample() -> Snapshot { Snapshot { - hidden: vec![Target { hwnd: 10, pid: 100 }, Target { hwnd: 20, pid: 200 }], - frozen: vec![100], - muted: vec![200], + hidden: vec![Target::bare(10, 100), Target::bare(20, 200)], + frozen: vec![ProcRecord { + pid: 100, + created_at: 111, + }], + muted: vec![ProcRecord::bare(200)], enhanced: true, + ..Default::default() } } #[test] - fn save_load_round_trip() { + fn save_load_round_trip_and_stamps_identity() { let path = temp_file(); - let snapshot = sample(); - save(&path, &snapshot).expect("保存快照应成功"); - assert_eq!(load(&path), Some(snapshot)); + save(&path, &sample()).expect("保存快照应成功"); + let loaded = load(&path).expect("应能读回"); + assert_eq!(loaded.hidden, sample().hidden); + assert_eq!(loaded.frozen, sample().frozen); + assert_eq!(loaded.muted, sample().muted); + assert_eq!(loaded.schema, SCHEMA_CURRENT, "保存时应盖上版本"); + assert!( + same_boot(loaded.boot_time_ms, current_boot_time_ms()), + "保存时应盖上本次开机时刻" + ); + assert!(loaded.is_restorable(current_boot_time_ms())); + assert!(!path.with_file_name("recovery.json.tmp").exists()); } #[test] @@ -89,10 +183,35 @@ mod tests { } #[test] - fn load_corrupt_file_returns_none() { + fn load_corrupt_file_renames_it_for_inspection() { let path = temp_file(); std::fs::write(&path, "{ not valid json !!").unwrap(); assert_eq!(load(&path), None, "损坏文件应按无快照处理而非 panic"); + assert!(!path.exists(), "损坏文件不应留在原名"); + assert!( + corrupt_path(&path).exists(), + "损坏文件应改名保留现场供排查" + ); + } + + #[test] + fn snapshot_from_previous_boot_is_not_restorable() { + let now = current_boot_time_ms(); + let mut snapshot = sample(); + snapshot.schema = SCHEMA_CURRENT; + snapshot.boot_time_ms = now - 3_600_000; // 一小时前的「开机」 + assert!(!snapshot.is_restorable(now), "跨重启的快照不可恢复"); + snapshot.boot_time_ms = now - 1_000; // 容差内 + assert!(snapshot.is_restorable(now)); + } + + #[test] + fn legacy_snapshot_without_schema_is_not_restorable() { + // 旧版快照(frozen/muted 是裸 PID 数组)应能解析但不可恢复。 + let json = r#"{"hidden":[{"hwnd":10,"pid":100}],"frozen":[],"muted":[],"enhanced":false}"#; + let snapshot: Snapshot = serde_json::from_str(json).unwrap(); + assert_eq!(snapshot.schema, 0); + assert!(!snapshot.is_restorable(current_boot_time_ms())); } #[test] @@ -112,4 +231,12 @@ mod tests { assert!(!path.exists()); clear(&path); } + + #[test] + fn same_boot_respects_tolerance() { + assert!(same_boot(1_000_000, 1_000_000)); + assert!(same_boot(1_000_000, 1_000_000 + BOOT_TOLERANCE_MS)); + assert!(!same_boot(1_000_000, 1_000_000 + BOOT_TOLERANCE_MS + 1)); + assert!(!same_boot(1_000_000, 995_000 - 1)); + } } diff --git a/crates/core/tests/agent_recovery.rs b/crates/core/tests/agent_recovery.rs index ef58598..1b5200d 100644 --- a/crates/core/tests/agent_recovery.rs +++ b/crates/core/tests/agent_recovery.rs @@ -66,13 +66,16 @@ fn agent_restores_hidden_windows_left_by_a_crash() { .unwrap(); let recovery_path = dir.path().join(recovery::RECOVERY_FILE_NAME); + // save 会盖上版本与本次开机时刻,等价于核心崩溃前留下的真实快照。 + // 测试窗口属于本进程,pid 须如实填写,否则恢复侧的身份校验会拦下它。 recovery::save( &recovery_path, &Snapshot { - hidden: vec![Target { hwnd, pid: 0 }], + hidden: vec![Target::bare(hwnd, std::process::id())], frozen: vec![], muted: vec![], enhanced: false, + ..Default::default() }, ) .unwrap(); @@ -113,3 +116,62 @@ fn agent_restores_hidden_windows_left_by_a_crash() { } window_thread.join().unwrap(); } + +/// 跨重启的快照里 HWND 与 PID 都已失效,核心不得执行恢复动作,只清除文件。 +#[test] +fn agent_discards_snapshot_from_a_previous_boot() { + let (hwnd, window_tid, window_thread) = spawn_hidden_window(); + assert!(!is_visible(hwnd), "测试前提:窗口已隐藏"); + + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + bosskey_common::Config::default() + .save(&config_path) + .unwrap(); + + // 手工构造「上一次开机」留下的快照:boot_time_ms 远早于本次开机。 + let recovery_path = dir.path().join(recovery::RECOVERY_FILE_NAME); + let stale = Snapshot { + schema: recovery::SCHEMA_CURRENT, + boot_time_ms: recovery::current_boot_time_ms() - 86_400_000, + hidden: vec![Target::bare(hwnd, std::process::id())], + frozen: vec![], + muted: vec![], + enhanced: false, + }; + std::fs::write(&recovery_path, serde_json::to_string(&stale).unwrap()).unwrap(); + + let pipe = r"\\.\pipe\bosskey_test_agent_recovery_stale"; + let options = AgentOptions { + config_path, + pipe_name: pipe.to_string(), + enable_tray: false, + auto_quit_ms: Some(15_000), + }; + let agent_thread = std::thread::spawn(move || agent::run(options)); + + let client = PipeClient::new(pipe); + let state = client.send(&Command::GetState).unwrap(); + assert_eq!(state, Response::State { hidden: false }); + + assert!( + !is_visible(hwnd), + "跨重启快照中的句柄不可信,不得执行恢复动作" + ); + assert!(!recovery_path.exists(), "过期快照应被清除,不再反复触发"); + + let quit = client.send(&Command::Quit).unwrap(); + assert_eq!(quit, Response::Ok); + + let deadline = Instant::now() + Duration::from_secs(10); + while !agent_thread.is_finished() { + assert!(Instant::now() < deadline, "代理线程未在退出命令后结束"); + std::thread::sleep(Duration::from_millis(50)); + } + agent_thread.join().unwrap(); + + unsafe { + let _ = PostThreadMessageW(window_tid, WM_QUIT, WPARAM(0), LPARAM(0)); + } + window_thread.join().unwrap(); +} diff --git a/crates/core/tests/agent_tool_alignment.rs b/crates/core/tests/agent_tool_alignment.rs new file mode 100644 index 0000000..91113aa --- /dev/null +++ b/crates/core/tests/agent_tool_alignment.rs @@ -0,0 +1,116 @@ +//! 端到端:窗口恢复工具经 IPC 隐藏 / 释放窗口,核心记录与恢复文件同步更新。 + +use std::time::{Duration, Instant}; + +use bosskey_common::ipc::{Command, PipeClient, Response}; +use bosskey_core::agent::{self, AgentOptions}; +use bosskey_core::recovery; +use windows::Win32::Foundation::{HWND, LPARAM, WPARAM}; +use windows::Win32::System::Threading::GetCurrentThreadId; +use windows::Win32::UI::WindowsAndMessaging::{ + CW_USEDEFAULT, CreateWindowExW, DestroyWindow, DispatchMessageW, GetMessageW, IsWindowVisible, + MSG, PostThreadMessageW, SW_SHOW, ShowWindow, TranslateMessage, WINDOW_EX_STYLE, WM_QUIT, + WS_OVERLAPPEDWINDOW, +}; +use windows::core::w; + +/// 在带消息循环的独立线程上创建一个可见窗口。 +fn spawn_visible_window() -> (i64, u32, std::thread::JoinHandle<()>) { + let (tx, rx) = std::sync::mpsc::channel::<(i64, u32)>(); + let handle = std::thread::spawn(move || unsafe { + let hwnd = CreateWindowExW( + WINDOW_EX_STYLE(0), + w!("Static"), + w!("BossKeyToolAlignmentTestWindow"), + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, + CW_USEDEFAULT, + 200, + 120, + None, + None, + None, + None, + ) + .expect("创建测试窗口失败"); + let _ = ShowWindow(hwnd, SW_SHOW); + tx.send((hwnd.0 as isize as i64, GetCurrentThreadId())) + .unwrap(); + + let mut msg: MSG = std::mem::zeroed(); + while GetMessageW(&mut msg, None, 0, 0).0 > 0 { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + let _ = DestroyWindow(hwnd); + }); + let (hwnd, tid) = rx.recv().expect("窗口线程未上报句柄"); + (hwnd, tid, handle) +} + +fn is_visible(hwnd: i64) -> bool { + unsafe { IsWindowVisible(HWND(hwnd as isize as *mut std::ffi::c_void)).as_bool() } +} + +#[test] +fn adopt_and_release_keep_core_records_and_recovery_file_in_sync() { + let (hwnd, window_tid, window_thread) = spawn_visible_window(); + assert!(is_visible(hwnd), "测试前提:窗口可见"); + + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + bosskey_common::Config::default() + .save(&config_path) + .unwrap(); + let recovery_path = dir.path().join(recovery::RECOVERY_FILE_NAME); + + let pipe = r"\\.\pipe\bosskey_test_tool_alignment"; + let options = AgentOptions { + config_path, + pipe_name: pipe.to_string(), + enable_tray: false, + auto_quit_ms: Some(15_000), + }; + let agent_thread = std::thread::spawn(move || agent::run(options)); + let client = PipeClient::new(pipe); + + // 恢复工具隐藏:窗口不可见、核心记录为隐藏态、恢复文件已写出。 + let reply = client + .send(&Command::AdoptWindows { hwnds: vec![hwnd] }) + .unwrap(); + assert_eq!(reply, Response::Ok); + assert!(!is_visible(hwnd), "经核心隐藏后窗口应不可见"); + assert_eq!( + client.send(&Command::GetState).unwrap(), + Response::State { hidden: true }, + "核心记录应包含工具隐藏的窗口" + ); + assert!(recovery_path.exists(), "工具隐藏的窗口应受崩溃恢复保护"); + + // 恢复工具释放:窗口找回、记录清空、恢复文件删除。 + let reply = client + .send(&Command::ReleaseWindows { hwnds: vec![hwnd] }) + .unwrap(); + assert_eq!(reply, Response::Ok); + assert!(is_visible(hwnd), "释放后窗口应恢复可见"); + assert_eq!( + client.send(&Command::GetState).unwrap(), + Response::State { hidden: false } + ); + assert!(!recovery_path.exists(), "记录清空后恢复文件应删除"); + + let quit = client.send(&Command::Quit).unwrap(); + assert_eq!(quit, Response::Ok); + + let deadline = Instant::now() + Duration::from_secs(10); + while !agent_thread.is_finished() { + assert!(Instant::now() < deadline, "代理线程未在退出命令后结束"); + std::thread::sleep(Duration::from_millis(50)); + } + agent_thread.join().unwrap(); + + unsafe { + let _ = PostThreadMessageW(window_tid, WM_QUIT, WPARAM(0), LPARAM(0)); + } + window_thread.join().unwrap(); +} diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 51f54c6..0793809 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -79,25 +79,29 @@ Boss-Key/ │ platform/win32.rs 窗口枚举/隐藏/显示(WindowManager trait) │ agent.rs 消息循环,聚合热键/托盘/IPC/鼠标/定时器 │ hotkey.rs 热键字符串 → RegisterHotKey 解析 -│ hide.rs 隐藏选择逻辑 + HideController(隐藏/显示编排) +│ hide.rs 隐藏选择逻辑 + HideController(plan/commit 两段式编排, +│ 含死句柄剪枝与恢复前的窗口/进程身份校验) │ effects.rs Effects trait(静音/冻结/暂停键,可注入 mock) +│ effects_worker.rs 副作用专职线程(FIFO 队列;消息循环只做 SW_HIDE) │ audio.rs Core Audio 会话静音 │ freeze.rs NtSuspend/Resume + pssuspend64 增强冻结 │ mouse_hook.rs WH_MOUSE_LL(中键/侧键/四角) │ keyboard_hook.rs WH_KEYBOARD_LL(「不传递」热键拦截) │ idle.rs GetLastInputInfo 空闲 + 自动隐藏判定 │ tray.rs Shell_NotifyIcon 托盘 + 气泡 -│ ipc_server.rs 命名管道服务端 +│ ipc_server.rs 命名管道服务端(创建失败退避重试,不退出) │ autostart.rs 开机自启(计划任务 XML 含失败自动重启 + 注册表回退) │ elevation.rs 管理员检测 + UAC 提权重启 │ i18n.rs 核心用户可见文案 catalog(托盘菜单 / 气泡 / IPC 错误;日志不走它) │ logging.rs 分级文件日志(logs/BossKey-YYYY-MM-DD.log 按天切割 + panic 钩子) -│ recovery.rs 崩溃恢复(隐藏状态落盘,异常退出后找回窗口) +│ recovery.rs 崩溃恢复(意图先行落盘 + 原子写;快照带开机时刻与 +│ 进程创建时刻,跨重启的快照会被丢弃) │ icon.rs 进程图标提取(HICON → 手写 PNG/base64 编码) │ single_instance.rs 命名互斥单实例 └── apps/config/ 配置界面(Tauri 2 + Svelte 5) ├── src-tauri/ Rust 后端命令 + tauri.conf.json + capabilities - │ └── src/verhub.rs Verhub 客户端(版本/公告/反馈/日志,基于 verhub-sdk) + │ └── src/verhub.rs Verhub 客户端(版本/公告/反馈/日志/项目链接,基于 verhub-sdk; + │ 项目链接带缓存:内存 + 同目录 verhub_cache.json,有效期一天) ├── ui/ 前端源码(Vite + Svelte 5) │ └── src/ lib/(纯逻辑 + vitest 测试)+ components/(Svelte 组件) │ + locales/(三语文案 catalog,以 zh-CN.js 为基准) @@ -118,7 +122,11 @@ Boss-Key/ - 定时器(空闲检测、状态维护等); - 托盘图标交互。 -当触发隐藏 / 显示时,交由 `HideController` 编排:选择命中的窗口 → 应用 `Effects`(静音 / 冻结 / 暂停键)→ 隐藏 / 显示窗口,并把状态写入 `recovery.json`。 +消息循环状态由 `RefCell` 承载:托盘 / 悬浮窗菜单的模态循环(`TrackPopupMenu`)会重入 `wndproc`,重入期间的事件借用失败即被安全丢弃,避免出现两个可变引用的别名。IPC 线程创建命名管道失败时按退避(1s → 5s → 30s)重试,不会退出。 + +当触发隐藏 / 显示时,交由 `HideController` 编排,流程为「意图先行」两段式:`plan_hide` 算出执行计划(剪掉失效记录、补齐 PID)→ 把计划后的快照写入 `recovery.json`(先落盘再动手,隐藏中途崩溃不丢记录)→ `commit_hide` 同步隐藏窗口(`SW_HIDE`),并把静音 / 冻结 / 暂停键交给副作用专职线程(`effects_worker.rs`)按 FIFO 异步执行——消息循环不被慢操作(音频枚举、pssuspend 等待)阻塞,热键与界面保持响应。 + +恢复(显示)时逐条校验记录的有效性:句柄须仍存在且仍属于当初的进程(`IsWindow` + PID 比对),冻结 / 静音记录须匹配进程创建时刻——句柄与 PID 都会被系统回收复用,校验不过的记录跳过并如实计入日志。 ::: info 可测试性设计 `Effects` 被抽象为 trait,测试时可注入 mock,从而在不真正静音 / 冻结系统的情况下验证隐藏编排逻辑。同理 `WindowManager` 也是 trait。 @@ -127,7 +135,7 @@ Boss-Key/ ## 稳定性设计(崩溃自愈三层防线) 1. **崩溃日志**:关键事件与 panic 写入 exe 同目录的 `logs/BossKey-YYYY-MM-DD.log`(按天切割,按 `log_retention_days` 保留,0 表示关闭日志;release 构建丢弃 DEBUG 级)。 -2. **崩溃恢复**:隐藏时把"隐藏 / 冻结 / 静音了什么"写入 `recovery.json`,异常退出后重启自动找回。 +2. **崩溃恢复**:隐藏动作执行前先把"将要隐藏 / 冻结 / 静音什么"写入 `recovery.json`(tmp + rename 原子替换),异常退出后重启自动找回;快照带开机时刻与进程创建时刻,跨重启的过期快照直接丢弃,不会对无关窗口 / 进程做恢复动作。 3. **看门狗**:计划任务 `RestartOnFailure`(崩溃后 1 分钟内重启,最多 3 次)。release 构建 `panic = "abort"`,panic 钩子写完日志后以非零码退出,正好触发计划任务重启。 用户视角的说明见 [窗口恢复与崩溃自愈](/guide/recovery)。 diff --git a/docs/dev/frontend.md b/docs/dev/frontend.md index 619435e..7e2755d 100644 --- a/docs/dev/frontend.md +++ b/docs/dev/frontend.md @@ -31,7 +31,7 @@ apps/config/ui/ │ ├── theme.js 主题切换(配 theme.test.js) │ ├── i18n.svelte.js 界面语言:catalog 查表 + 语言解析(配 i18n.test.js) │ ├── markdown.js 公告/更新日志的 Markdown 渲染(配 markdown.test.js) -│ └── verhub.js 检查更新/公告/打开外链 +│ └── verhub.js 检查更新/公告/项目链接/打开外链 ├── locales/ 三语文案 catalog(zh-CN.js / en.js / zh-TW.js) ├── vite.config.js └── svelte.config.js diff --git a/docs/dev/ipc-protocol.md b/docs/dev/ipc-protocol.md index c1c280b..52d1b9f 100644 --- a/docs/dev/ipc-protocol.md +++ b/docs/dev/ipc-protocol.md @@ -27,6 +27,8 @@ title: IPC 协议 | `Toggle` | `{"cmd":"toggle"}` | 切换隐藏 / 显示 | | `SetAutostart` | `{"cmd":"set_autostart","enabled":true}` | 设置开机自启 | | `SetHotkeys` | `{"cmd":"set_hotkeys","enabled":false}` | 临时停用 / 恢复热键与鼠标监控 | +| `ReleaseWindows` | `{"cmd":"release_windows","hwnds":[..]}` | 窗口恢复工具:恢复显示指定句柄。在核心记录里的窗口按整进程释放(连同解冻 / 取消静音);记录外的句柄直接显示 | +| `AdoptWindows` | `{"cmd":"adopt_windows","hwnds":[..]}` | 窗口恢复工具:隐藏指定句柄并纳入核心记录(享受崩溃恢复),不施加静音 / 冻结 | | `Quit` | `{"cmd":"quit"}` | 退出核心 | ## Response(核心 → 配置界面) diff --git a/docs/en/dev/architecture.md b/docs/en/dev/architecture.md index d1e4df7..ead110c 100644 --- a/docs/en/dev/architecture.md +++ b/docs/en/dev/architecture.md @@ -79,25 +79,29 @@ Boss-Key/ │ platform/win32.rs Window enumeration/hiding/showing (WindowManager trait) │ agent.rs Message loop; aggregates hotkeys/tray/IPC/mouse/timers │ hotkey.rs Hotkey string → RegisterHotKey parsing -│ hide.rs Hiding selection logic + HideController (hide/show orchestration) +│ hide.rs Hiding selection logic + HideController (plan/commit two-phase orchestration +│ with stale-handle pruning and window/process identity checks before restore) │ effects.rs Effects trait (muting/freezing/pause key; mockable) +│ effects_worker.rs Dedicated side-effect thread (FIFO queue; the message loop only does SW_HIDE) │ audio.rs Core Audio session muting │ freeze.rs NtSuspend/Resume + pssuspend64 enhanced freezing │ mouse_hook.rs WH_MOUSE_LL (middle/side buttons, corners) │ keyboard_hook.rs WH_KEYBOARD_LL ("don't pass through" hotkey interception) │ idle.rs GetLastInputInfo idle detection + auto-hide decision │ tray.rs Shell_NotifyIcon tray + balloons -│ ipc_server.rs Named-pipe server +│ ipc_server.rs Named-pipe server (retries pipe creation with backoff instead of exiting) │ autostart.rs Startup (scheduled-task XML with restart-on-failure + registry fallback) │ elevation.rs Administrator detection + UAC elevation restart │ i18n.rs Catalog of user-visible core strings (tray menu / balloons / IPC errors; logs excluded) │ logging.rs Levelled file logging (logs/BossKey-YYYY-MM-DD.log, rotated daily + panic hook) -│ recovery.rs Crash recovery (hidden state persisted; windows recovered after an abnormal exit) +│ recovery.rs Crash recovery (intent persisted before acting + atomic writes; snapshots carry +│ boot time and process creation times, snapshots from a previous boot are discarded) │ icon.rs Process icon extraction (HICON → hand-written PNG/base64 encoding) │ single_instance.rs Named-mutex single instance └── apps/config/ Settings window (Tauri 2 + Svelte 5) ├── src-tauri/ Rust backend commands + tauri.conf.json + capabilities - │ └── src/verhub.rs Verhub client (versions/announcements/feedback/logs, built on verhub-sdk) + │ └── src/verhub.rs Verhub client (versions/announcements/feedback/logs/project links, built on verhub-sdk; + │ project links are cached: in memory + verhub_cache.json next to the exe, valid for one day) ├── ui/ Frontend source (Vite + Svelte 5) │ └── src/ lib/ (pure logic + vitest tests) + components/ (Svelte components) │ + locales/ (three-language catalogs; zh-CN.js is the source of truth) @@ -118,7 +122,11 @@ Boss-Key/ - Timers (idle detection, state maintenance, and so on); - Tray icon interaction. -When hiding or showing is triggered, `HideController` orchestrates it: select the matching windows → apply `Effects` (muting / freezing / pause key) → hide or show the windows, and write the state to `recovery.json`. +Message-loop state lives in a `RefCell`: the modal loops of the tray / floating-window menus (`TrackPopupMenu`) re-enter `wndproc`, and events arriving during re-entry fail the borrow and are safely dropped, so no aliased mutable references can exist. The IPC thread retries pipe creation with backoff (1s → 5s → 30s) instead of exiting. + +When hiding or showing is triggered, `HideController` orchestrates it with a two-phase, intent-first flow: `plan_hide` computes the execution plan (pruning stale records and backfilling PIDs) → the planned snapshot is written to `recovery.json` (persist first, act second — a crash mid-hide loses no records) → `commit_hide` hides the windows synchronously (`SW_HIDE`) and hands muting / freezing / the pause key to the dedicated side-effect thread (`effects_worker.rs`), executed asynchronously in FIFO order — the message loop is never blocked by slow operations (audio enumeration, waiting on pssuspend), so hotkeys and the UI stay responsive. + +When restoring (showing), every record is validated first: the handle must still exist and still belong to the original process (`IsWindow` + PID comparison), and frozen / muted records must match the process creation time — both handles and PIDs are recycled by the system, and records that fail validation are skipped and reported truthfully in the log. ::: info Designed for testability `Effects` is a trait, so tests can inject a mock and verify the hiding orchestration without actually muting or freezing the system. `WindowManager` is a trait for the same reason. @@ -127,7 +135,7 @@ When hiding or showing is triggered, `HideController` orchestrates it: select th ## Stability (three layers of crash self-healing) 1. **Crash logs**: key events and panics are written to `logs/BossKey-YYYY-MM-DD.log` next to the exe (rotated daily, retained per `log_retention_days`; 0 disables logging; release builds drop the DEBUG level). -2. **Crash recovery**: hiding writes what was hidden / frozen / muted into `recovery.json`, recovered automatically on the next start after an abnormal exit. +2. **Crash recovery**: before any hide action executes, what is *about to be* hidden / frozen / muted is written to `recovery.json` (tmp + rename atomic replace); windows are recovered automatically on the next start after an abnormal exit. Snapshots carry the boot time and process creation times, so stale snapshots from a previous boot are discarded instead of acting on unrelated windows / processes. 3. **Watchdog**: the scheduled task's `RestartOnFailure` (restart within a minute of a crash, up to 3 times). Release builds use `panic = "abort"`, and the panic hook exits with a non-zero code once the log is written — exactly what triggers the scheduled-task restart. For the user-facing explanation see [Window recovery & crash self-healing](/en/guide/recovery). diff --git a/docs/en/dev/frontend.md b/docs/en/dev/frontend.md index 77597af..28a7d84 100644 --- a/docs/en/dev/frontend.md +++ b/docs/en/dev/frontend.md @@ -31,7 +31,7 @@ apps/config/ui/ │ ├── theme.js Theme switching (with theme.test.js) │ ├── i18n.svelte.js Display language: catalog lookup + language resolution (with i18n.test.js) │ ├── markdown.js Markdown rendering for announcements/release notes (with markdown.test.js) -│ └── verhub.js Update checks / announcements / opening external links +│ └── verhub.js Update checks / announcements / project links / opening external links ├── locales/ Three-language catalogs (zh-CN.js / en.js / zh-TW.js) ├── vite.config.js └── svelte.config.js diff --git a/docs/en/dev/ipc-protocol.md b/docs/en/dev/ipc-protocol.md index bf910e9..cdc76c6 100644 --- a/docs/en/dev/ipc-protocol.md +++ b/docs/en/dev/ipc-protocol.md @@ -27,6 +27,8 @@ Serialised with `#[serde(tag = "cmd", rename_all = "snake_case")]`. | `Toggle` | `{"cmd":"toggle"}` | Toggle hide / show | | `SetAutostart` | `{"cmd":"set_autostart","enabled":true}` | Configure startup | | `SetHotkeys` | `{"cmd":"set_hotkeys","enabled":false}` | Temporarily suspend / resume hotkey and mouse monitoring | +| `ReleaseWindows` | `{"cmd":"release_windows","hwnds":[..]}` | Recovery tool: show the given handles. Windows tracked by the core are released per whole process (including unfreeze / unmute); untracked handles are simply shown | +| `AdoptWindows` | `{"cmd":"adopt_windows","hwnds":[..]}` | Recovery tool: hide the given handles and track them in the core (covered by crash recovery), without muting / freezing | | `Quit` | `{"cmd":"quit"}` | Exit the core | ## Response (core → settings window) diff --git a/docs/en/guide/recovery.md b/docs/en/guide/recovery.md index ee2362e..4e177e3 100644 --- a/docs/en/guide/recovery.md +++ b/docs/en/guide/recovery.md @@ -18,6 +18,8 @@ The tool lists every window on the system (including currently invisible ones). The tool can also **freeze / resume** windows' processes. A frozen window stops rendering, which lowers resource usage. Frozen windows can still be recovered with the tool. +While the core is running, the tool's show / hide operations are carried out by the core: manually hidden windows are covered by crash recovery too, and restoring a window also unfreezes and unmutes its process. When the core is not running, the tool operates on windows directly. + ## Three layers of defence The Boss Key core has **three layers of crash self-healing**, so windows stay safe even if the program crashes. diff --git a/docs/guide/recovery.md b/docs/guide/recovery.md index e36aef7..864634b 100644 --- a/docs/guide/recovery.md +++ b/docs/guide/recovery.md @@ -18,6 +18,8 @@ title: 窗口恢复与崩溃防护 窗口恢复工具也支持将窗口**冻结 / 解冻**,冻结后窗口会被暂停渲染,降低资源消耗。冻结的窗口仍然可通过恢复工具找回。 +核心运行时,工具的显示 / 隐藏操作会交给核心执行:手动隐藏的窗口同样纳入崩溃恢复保护;恢复显示时会连同解冻、取消静音一起完成。核心未运行时工具直接操作窗口。 + ## 三层防线 Boss Key 核心内置了**崩溃自愈三层防线**,即使程序意外崩溃也能尽量保证窗口安全。 diff --git a/docs/zh-tw/dev/architecture.md b/docs/zh-tw/dev/architecture.md index 3657e24..c119da5 100644 --- a/docs/zh-tw/dev/architecture.md +++ b/docs/zh-tw/dev/architecture.md @@ -79,25 +79,29 @@ Boss-Key/ │ platform/win32.rs 視窗列舉/隱藏/顯示(WindowManager trait) │ agent.rs 訊息迴圈,彙整快速鍵/通知區域/IPC/滑鼠/計時器 │ hotkey.rs 快速鍵字串 → RegisterHotKey 解析 -│ hide.rs 隱藏選擇邏輯 + HideController(隱藏/顯示編排) +│ hide.rs 隱藏選擇邏輯 + HideController(plan/commit 兩段式編排, +│ 含死控制代碼剪枝與復原前的視窗/處理程序身分校驗) │ effects.rs Effects trait(靜音/凍結/暫停鍵,可注入 mock) +│ effects_worker.rs 副作用專職執行緒(FIFO 佇列;訊息迴圈只做 SW_HIDE) │ audio.rs Core Audio 工作階段靜音 │ freeze.rs NtSuspend/Resume + pssuspend64 增強凍結 │ mouse_hook.rs WH_MOUSE_LL(中鍵/側鍵/四角) │ keyboard_hook.rs WH_KEYBOARD_LL(「不傳遞」快速鍵攔截) │ idle.rs GetLastInputInfo 閒置 + 自動隱藏判定 │ tray.rs Shell_NotifyIcon 通知區域 + 通知 -│ ipc_server.rs 具名管道伺服端 +│ ipc_server.rs 具名管道伺服端(建立失敗退避重試,不結束) │ autostart.rs 開機自動啟動(排程工作 XML 含失敗自動重新啟動 + 登錄檔回落) │ elevation.rs 系統管理員偵測 + UAC 提升權限重新啟動 │ i18n.rs 核心使用者可見文案 catalog(通知區域選單/通知/IPC 錯誤;記錄檔不走它) │ logging.rs 分級檔案記錄(logs/BossKey-YYYY-MM-DD.log 按日切割 + panic 掛鉤) -│ recovery.rs 當機復原(隱藏狀態寫入磁碟,異常結束後找回視窗) +│ recovery.rs 當機復原(意圖先行寫入 + 原子寫;快照帶開機時刻與 +│ 處理程序建立時刻,跨重新開機的快照會被丟棄) │ icon.rs 程序圖示擷取(HICON → 手寫 PNG/base64 編碼) │ single_instance.rs 具名互斥鎖單一執行個體 └── apps/config/ 設定介面(Tauri 2 + Svelte 5) ├── src-tauri/ Rust 後端命令 + tauri.conf.json + capabilities - │ └── src/verhub.rs Verhub 用戶端(版本/公告/回饋/日誌,基於 verhub-sdk) + │ └── src/verhub.rs Verhub 用戶端(版本/公告/回饋/日誌/專案連結,基於 verhub-sdk; + │ 專案連結帶快取:記憶體 + 同目錄 verhub_cache.json,有效期一天) ├── ui/ 前端原始碼(Vite + Svelte 5) │ └── src/ lib/(純邏輯 + vitest 測試)+ components/(Svelte 元件) │ + locales/(三語文案 catalog,以 zh-CN.js 為基準) @@ -118,7 +122,11 @@ Boss-Key/ - 計時器(閒置偵測、狀態維護等); - 通知區域圖示互動。 -當觸發隱藏/顯示時,交由 `HideController` 編排:選擇命中的視窗 → 套用 `Effects`(靜音/凍結/暫停鍵)→ 隱藏/顯示視窗,並把狀態寫入 `recovery.json`。 +訊息迴圈狀態由 `RefCell` 承載:通知區域/懸浮窗選單的強制回應迴圈(`TrackPopupMenu`)會重入 `wndproc`,重入期間的事件借用失敗即被安全丟棄,避免出現兩個可變參考的別名。IPC 執行緒建立具名管道失敗時按退避(1s → 5s → 30s)重試,不會結束。 + +當觸發隱藏/顯示時,交由 `HideController` 編排,流程為「意圖先行」兩段式:`plan_hide` 算出執行計畫(剪掉失效紀錄、補齊 PID)→ 把計畫後的快照寫入 `recovery.json`(先寫入再動手,隱藏中途當機不丟紀錄)→ `commit_hide` 同步隱藏視窗(`SW_HIDE`),並把靜音/凍結/暫停鍵交給副作用專職執行緒(`effects_worker.rs`)按 FIFO 非同步執行——訊息迴圈不被慢操作(音訊列舉、pssuspend 等待)阻塞,快速鍵與介面保持回應。 + +復原(顯示)時逐條校驗紀錄的有效性:控制代碼須仍存在且仍屬於當初的處理程序(`IsWindow` + PID 比對),凍結/靜音紀錄須符合處理程序建立時刻——控制代碼與 PID 都會被系統回收重複使用,校驗不過的紀錄跳過並如實計入日誌。 ::: info 可測試性設計 `Effects` 被抽象為 trait,測試時可注入 mock,從而在不真正靜音/凍結系統的情況下驗證隱藏編排邏輯。同理 `WindowManager` 也是 trait。 @@ -127,7 +135,7 @@ Boss-Key/ ## 穩定性設計(當機自癒三層防線) 1. **當機記錄**:關鍵事件與 panic 寫入 exe 同資料夾的 `logs/BossKey-YYYY-MM-DD.log`(按日切割,依 `log_retention_days` 保留,0 表示不記錄;release 建置丟棄 DEBUG 級)。 -2. **當機復原**:隱藏時把「隱藏/凍結/靜音了什麼」寫入 `recovery.json`,異常結束後重新啟動自動找回。 +2. **當機復原**:隱藏動作執行前先把「將要隱藏/凍結/靜音什麼」寫入 `recovery.json`(tmp + rename 原子替換),異常結束後重新啟動自動找回;快照帶開機時刻與處理程序建立時刻,跨重新開機的過期快照直接丟棄,不會對無關視窗/處理程序做復原動作。 3. **監控程式**:排程工作 `RestartOnFailure`(當機後 1 分鐘內重新啟動,最多 3 次)。release 建置 `panic = "abort"`,panic 掛鉤寫完記錄後以非零碼結束,正好觸發排程工作重新啟動。 使用者視角的說明見 [視窗復原與當機自癒](/zh-tw/guide/recovery)。 diff --git a/docs/zh-tw/dev/frontend.md b/docs/zh-tw/dev/frontend.md index c807bd9..790a6ee 100644 --- a/docs/zh-tw/dev/frontend.md +++ b/docs/zh-tw/dev/frontend.md @@ -31,7 +31,7 @@ apps/config/ui/ │ ├── theme.js 佈景主題切換(配 theme.test.js) │ ├── i18n.svelte.js 介面語言:catalog 查表 + 語言解析(配 i18n.test.js) │ ├── markdown.js 公告/更新記錄的 Markdown 算繪(配 markdown.test.js) -│ └── verhub.js 檢查更新/公告/開啟外部連結 +│ └── verhub.js 檢查更新/公告/專案連結/開啟外部連結 ├── locales/ 三語文案 catalog(zh-CN.js / en.js / zh-TW.js) ├── vite.config.js └── svelte.config.js diff --git a/docs/zh-tw/dev/ipc-protocol.md b/docs/zh-tw/dev/ipc-protocol.md index d8a652c..33896da 100644 --- a/docs/zh-tw/dev/ipc-protocol.md +++ b/docs/zh-tw/dev/ipc-protocol.md @@ -27,6 +27,8 @@ title: IPC 協定 | `Toggle` | `{"cmd":"toggle"}` | 切換隱藏/顯示 | | `SetAutostart` | `{"cmd":"set_autostart","enabled":true}` | 設定開機自動啟動 | | `SetHotkeys` | `{"cmd":"set_hotkeys","enabled":false}` | 暫時停用/復原快速鍵與滑鼠監控 | +| `ReleaseWindows` | `{"cmd":"release_windows","hwnds":[..]}` | 視窗復原工具:復原顯示指定控制代碼。在核心紀錄裡的視窗按整個處理程序釋放(連同解除凍結/取消靜音);紀錄外的控制代碼直接顯示 | +| `AdoptWindows` | `{"cmd":"adopt_windows","hwnds":[..]}` | 視窗復原工具:隱藏指定控制代碼並納入核心紀錄(享有當機復原保護),不施加靜音/凍結 | | `Quit` | `{"cmd":"quit"}` | 結束核心 | ## Response(核心 → 設定介面) diff --git a/docs/zh-tw/guide/recovery.md b/docs/zh-tw/guide/recovery.md index 3513713..8efbea3 100644 --- a/docs/zh-tw/guide/recovery.md +++ b/docs/zh-tw/guide/recovery.md @@ -18,6 +18,8 @@ title: 視窗復原與當機防護 視窗復原工具也支援將視窗**凍結/解除凍結**,凍結後視窗會被暫停算圖,降低資源消耗。凍結的視窗仍然可透過復原工具找回。 +核心執行時,工具的顯示/隱藏操作會交給核心執行:手動隱藏的視窗同樣納入當機復原保護;復原顯示時會連同解除凍結、取消靜音一起完成。核心未執行時工具直接操作視窗。 + ## 三層防線 Boss Key 核心內建了**當機自癒三層防線**,即使程式意外當機也能盡量保證視窗安全。