diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 2d9f701..95968c9 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -33,6 +33,7 @@ windows = { workspace = true, features = [ "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_System_Variant", + "Win32_UI_Accessibility", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", diff --git a/crates/core/src/agent.rs b/crates/core/src/agent.rs index daa6b4f..072caaa 100644 --- a/crates/core/src/agent.rs +++ b/crates/core/src/agent.rs @@ -12,9 +12,10 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{ }; use windows::Win32::UI::WindowsAndMessaging::{ AppendMenuW, CW_USEDEFAULT, ChangeWindowMessageFilterEx, CreatePopupMenu, CreateWindowExW, - DefWindowProcW, DestroyMenu, DestroyWindow, DispatchMessageW, GWLP_USERDATA, GetCursorPos, - GetMessageW, GetWindowLongPtrW, KillTimer, MF_CHECKED, MF_SEPARATOR, MF_STRING, MSG, - MSGFLT_ALLOW, PostMessageW, PostQuitMessage, RegisterClassW, SetForegroundWindow, SetTimer, + DefWindowProcW, DestroyMenu, DestroyWindow, DispatchMessageW, EVENT_OBJECT_DESTROY, + EVENT_OBJECT_NAMECHANGE, EVENT_OBJECT_SHOW, GWLP_USERDATA, GetCursorPos, GetMessageW, + GetWindowLongPtrW, KillTimer, MF_CHECKED, MF_SEPARATOR, MF_STRING, MSG, MSGFLT_ALLOW, + PostMessageW, PostQuitMessage, RegisterClassW, SetForegroundWindow, SetTimer, SetWindowLongPtrW, TPM_BOTTOMALIGN, TPM_LEFTALIGN, TrackPopupMenu, TranslateMessage, WINDOW_EX_STYLE, WM_APP, WM_COMMAND, WM_DESTROY, WM_HOTKEY, WM_LBUTTONUP, WM_RBUTTONUP, WM_TIMER, WNDCLASSW, WS_OVERLAPPED, @@ -36,6 +37,7 @@ use crate::platform::win32::WindowsWindowManager; use crate::tray::TrayIcon; use crate::tray_badge::TrayIconSet; use crate::util::append_menu_item; +use crate::win_event::{WM_APP_WINEVENT, WinEventHook}; use crate::{idle, ipc_server, log_error, log_warn, logging, recovery}; const HK_HIDE: i32 = 1; @@ -94,6 +96,8 @@ struct AgentState { effects_worker: Option, /// 恢复文件写入失败是否已提醒过(每次运行只弹一次气泡)。 persist_warned: bool, + /// 窗口事件钩子(销毁 / 显示 / 改标题),实时维护隐藏记录与规则标题。 + win_event_hook: Option, tray: Option, /// 托盘图标的角标变体缓存;未启用托盘时为 None。 tray_icons: Option, @@ -370,6 +374,33 @@ impl AgentState { } } + /// 处理窗口事件:销毁 / 被外部恢复显示的窗口移出隐藏记录, + /// 标题变化同步进隐藏记录与规则(仅内存,随下一次正常落盘写出)。 + fn on_win_event(&mut self, event: u32, hwnd: i64) { + match event { + EVENT_OBJECT_DESTROY | EVENT_OBJECT_SHOW => { + if self.controller.forget_window(hwnd) { + self.persist_recovery(); + self.sync_tray(); + } + } + EVENT_OBJECT_NAMECHANGE => { + let tracked_rule = self + .config + .window_rules + .iter() + .any(|r| r.regex.is_none() && r.hwnd == hwnd); + if !tracked_rule && !self.controller.tracks_window(hwnd) { + return; + } + let title = self.controller.window_title(hwnd); + self.controller.update_title(hwnd, &title); + crate::hide::sync_rule_titles(&mut self.config.window_rules, hwnd, &title); + } + _ => {} + } + } + fn apply_show(&mut self) { let outcome = self.controller.show(); log_show(outcome); @@ -538,12 +569,12 @@ fn log_hide(config: &Config, outcomes: &[RuleOutcome], plan: &HidePlan) { } } -/// 记录恢复结果;有失效记录被跳过时如实写出。 +/// 记录恢复结果;失效与找回的数量如实写出。 fn log_show(outcome: ShowOutcome) { if outcome.stale > 0 { logging::info(&format!( - "恢复显示 {} 个窗口,另有 {} 条记录已失效(窗口已关闭或句柄被复用),已跳过", - outcome.shown, outcome.stale + "恢复显示 {} 个窗口;{} 条记录句柄已失效,其中 {} 个按进程路径与标题重新找回", + outcome.shown, outcome.stale, outcome.refound )); } else { logging::info(&format!("恢复显示 {} 个窗口", outcome.shown)); @@ -833,6 +864,10 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: } LRESULT(0) } + WM_APP_WINEVENT => { + state.on_win_event(wparam.0 as u32, lparam.0 as i64); + LRESULT(0) + } WM_APP_IPC => { let mut should_quit = false; while let Ok((cmd, reply_tx)) = state.ipc_rx.try_recv() { @@ -1111,6 +1146,7 @@ pub fn run(options: AgentOptions) { controller: HideController::new(WindowsWindowManager, effects_worker.effects()), effects_worker: Some(effects_worker), persist_warned: false, + win_event_hook: None, tray, tray_icons, ipc_rx, @@ -1156,7 +1192,14 @@ pub fn run(options: AgentOptions) { ); } - state.borrow_mut().sync_monitoring(hwnd); + { + let mut state = state.borrow_mut(); + state.win_event_hook = WinEventHook::install(hwnd); + if state.win_event_hook.is_none() { + log_warn!("窗口事件钩子安装失败,隐藏记录仅在触发隐藏 / 恢复时维护"); + } + state.sync_monitoring(hwnd); + } let hwnd_value = hwnd.0 as isize; ipc_server::spawn(options.pipe_name.clone(), move |cmd| { diff --git a/crates/core/src/hide.rs b/crates/core/src/hide.rs index acf6e4f..7c47b5b 100644 --- a/crates/core/src/hide.rs +++ b/crates/core/src/hide.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use bosskey_common::matching::{WindowResolution, match_process_rule, resolve_window_rule}; -use bosskey_common::{Config, Setting, WindowInfo}; +use bosskey_common::{Config, NO_TITLE, Setting, WindowInfo, WindowRule}; use serde::{Deserialize, Serialize}; use crate::effects::Effects; @@ -210,6 +210,25 @@ pub struct ShowOutcome { pub shown: usize, /// 因句柄失效或已被其他窗口复用而跳过的记录数。 pub stale: usize, + /// 句柄失效后按「进程路径 + 标题」重新找回并显示的窗口数。 + pub refound: usize, +} + +/// 把标题变化同步进句柄匹配的精确窗口规则(仅内存,随下次配置保存落盘)。 +/// `NO_TITLE` 不参与同步。返回是否有规则被更新。 +pub fn sync_rule_titles(rules: &mut [WindowRule], hwnd: i64, title: &str) -> bool { + if title == NO_TITLE { + return false; + } + let mut changed = false; + for rule in rules + .iter_mut() + .filter(|r| r.regex.is_none() && r.hwnd == hwnd && r.title != title) + { + rule.title = title.to_string(); + changed = true; + } + changed } pub struct HideController { @@ -251,6 +270,42 @@ impl HideController { self.wm.foreground() } + /// 窗口当前标题(封装 wm 依赖,供事件追踪查询)。 + pub fn window_title(&self, hwnd: i64) -> String { + self.wm.window_title(hwnd) + } + + /// 该句柄是否在隐藏记录里。 + pub fn tracks_window(&self, hwnd: i64) -> bool { + self.hidden.iter().any(|t| t.hwnd == hwnd) + } + + /// 移除句柄对应的隐藏记录(窗口已销毁或被外部恢复显示时由事件追踪调用)。 + /// 返回是否有记录被移除。 + pub fn forget_window(&mut self, hwnd: i64) -> bool { + let before = self.hidden.len(); + self.hidden.retain(|t| t.hwnd != hwnd); + self.hidden.len() != before + } + + /// 同步隐藏记录里的窗口标题(标题变化事件);`NO_TITLE` 不参与同步。 + /// 返回是否有记录被更新。 + pub fn update_title(&mut self, hwnd: i64, title: &str) -> bool { + if title == NO_TITLE { + return false; + } + let mut changed = false; + for t in self + .hidden + .iter_mut() + .filter(|t| t.hwnd == hwnd && t.title != title) + { + t.title = title.to_string(); + changed = true; + } + changed + } + /// 计算一次隐藏的执行计划,不做任何窗口 / 副作用动作;顺带完成隐藏集剪枝 /// 与 PID 补查(仍查不到的目标剔除)。`freeze_pids` 为最终要冻结的 PID 集 /// (可能已含子进程树),仅在 `freeze_after_hide` 开启时生效。 @@ -393,6 +448,7 @@ impl HideController { } let hidden = std::mem::take(&mut self.hidden); + let mut stale: Vec<&Target> = Vec::new(); for t in &hidden { // 句柄仍存活且仍属于当初的进程才恢复,避免弹出复用同一句柄的无关窗口。 if self.wm.is_window(t.hwnd) && (t.pid == 0 || self.wm.window_pid(t.hwnd) == t.pid) { @@ -400,8 +456,10 @@ impl HideController { outcome.shown += 1; } else { outcome.stale += 1; + stale.push(t); } } + outcome.refound = self.refind_stale(&hidden, &stale); let muted = std::mem::take(&mut self.muted); for r in &muted { @@ -412,6 +470,36 @@ impl HideController { outcome } + /// 按「进程路径 + 标题」为失效记录找回窗口:只匹配当前不可见、且不在 + /// 本次隐藏集内的窗口,找到即恢复显示。返回找回数。 + fn refind_stale(&self, hidden: &[Target], stale: &[&Target]) -> usize { + if stale + .iter() + .all(|t| t.process_path.is_empty() || t.title.is_empty() || t.title == NO_TITLE) + { + return 0; + } + let windows = self.wm.enumerate(); + let mut used: HashSet = hidden.iter().map(|t| t.hwnd).collect(); + let mut refound = 0; + for t in stale { + if t.process_path.is_empty() || t.title.is_empty() || t.title == NO_TITLE { + continue; + } + if let Some(w) = windows.iter().find(|w| { + !w.visible + && !used.contains(&w.hwnd) + && w.path == t.process_path + && w.title == t.title + }) { + used.insert(w.hwnd); + self.wm.show(w.hwnd); + refound += 1; + } + } + refound + } + /// 释放指定进程的隐藏状态:显示窗口、解冻、取消静音,并从隐藏集移除。返回被释放的窗口数。 pub fn release_pids(&mut self, pids: &[u32]) -> usize { if pids.is_empty() { @@ -797,12 +885,14 @@ mod tests { } impl WindowManager for MockWm { + // 与真实平台一致:不可见窗口也在枚举结果里,visible 标记如实反映当前状态。 fn enumerate(&self) -> Vec { let visible = self.visible.borrow(); + let exists = self.exists.borrow(); self.windows .iter() - .filter(|w| visible.contains(&w.hwnd)) - .cloned() + .filter(|w| exists.contains(&w.hwnd)) + .map(|w| w.clone().with_visibility(visible.contains(&w.hwnd))) .collect() } fn hide(&self, hwnd: i64) { @@ -833,6 +923,13 @@ mod tests { .map(|w| w.pid) .unwrap_or(0) } + fn window_title(&self, hwnd: i64) -> String { + self.windows + .iter() + .find(|w| w.hwnd == hwnd) + .map(|w| w.title.clone()) + .unwrap_or_else(|| bosskey_common::NO_TITLE.to_string()) + } fn process_start_time(&self, pid: u32) -> i64 { if pid == 0 { return 0; @@ -895,7 +992,7 @@ mod tests { assert_eq!(*controller.effects.pauses.borrow(), 1, "应发送一次暂停键"); let outcome = controller.show(); - assert_eq!(outcome, ShowOutcome { shown: 1, stale: 0 }); + assert_eq!(outcome, ShowOutcome { shown: 1, stale: 0, refound: 0 }); assert!(!controller.is_hidden()); assert!(controller.wm.is_visible(10), "恢复后微信应可见"); assert_eq!(*controller.effects.resumes.borrow(), vec![10], "应解冻"); @@ -1115,7 +1212,7 @@ mod tests { ..Default::default() }); - assert_eq!(outcome, ShowOutcome { shown: 1, stale: 0 }); + assert_eq!(outcome, ShowOutcome { shown: 1, stale: 0, refound: 0 }); assert!(!controller.is_hidden(), "恢复完成后应回到未隐藏状态"); assert!(controller.wm.is_visible(10), "崩溃前隐藏的窗口应被找回"); assert_eq!(*controller.effects.resumes.borrow(), vec![10], "应解冻进程"); @@ -1183,7 +1280,7 @@ mod tests { let outcome = controller.show(); assert_eq!( outcome, - ShowOutcome { shown: 1, stale: 2 }, + ShowOutcome { shown: 1, stale: 2, refound: 0 }, "死句柄与被复用句柄都应跳过" ); assert!(!controller.wm.is_visible(20), "被复用的句柄不得被弹出来"); @@ -1249,6 +1346,112 @@ mod tests { ); } + #[test] + fn forget_window_removes_record_and_update_title_syncs_it() { + let setting = Setting { + hide_current: false, + ..Setting::default() + }; + let wm = MockWm::new(vec![win("微信", 10, "WeChat.exe", "C:\\WeChat.exe")], 0); + let mut controller = HideController::new(wm, MockEffects::default()); + controller.apply_hide(&setting, &[Target::from_window(&win("微信", 10, "WeChat.exe", "C:\\WeChat.exe"))], &[]); + + assert!(controller.tracks_window(10)); + assert!(controller.update_title(10, "微信 - 新会话")); + assert!(!controller.update_title(10, "微信 - 新会话"), "标题未变不算更新"); + assert!( + !controller.update_title(10, bosskey_common::NO_TITLE), + "NO_TITLE 不参与同步" + ); + assert_eq!(controller.snapshot().hidden[0].title, "微信 - 新会话"); + + assert!(!controller.forget_window(99), "未记录的句柄无事发生"); + assert!(controller.forget_window(10)); + assert!(!controller.is_hidden(), "记录移除后不再处于隐藏态"); + } + + #[test] + fn sync_rule_titles_updates_exact_rules_only() { + let mut rules = vec![ + wrule("旧标题", 10, "app.exe", "C:\\app.exe"), + WindowRule::from_regex("^项目"), + ]; + assert!(sync_rule_titles(&mut rules, 10, "新标题")); + assert_eq!(rules[0].title, "新标题"); + assert!(!sync_rule_titles(&mut rules, 10, "新标题"), "标题未变不算更新"); + assert!(!sync_rule_titles(&mut rules, 99, "别的"), "句柄不匹配不更新"); + assert!( + !sync_rule_titles(&mut rules, 10, NO_TITLE), + "NO_TITLE 不参与同步" + ); + } + + #[test] + fn show_refinds_stale_records_by_path_and_title() { + let setting = Setting { + hide_current: false, + ..Setting::default() + }; + // 99 是同进程同标题的新窗口(程序重建了窗口),当前不可见。 + let wm = MockWm::new( + vec![ + win_pid("微信", 10, "WeChat.exe", 500, "C:\\WeChat.exe"), + win_pid("微信", 99, "WeChat.exe", 600, "C:\\WeChat.exe"), + ], + 0, + ); + wm.hide(99); + let mut controller = HideController::new(wm, MockEffects::default()); + controller.apply_hide( + &setting, + &[Target::from_window(&win_pid( + "微信", + 10, + "WeChat.exe", + 500, + "C:\\WeChat.exe", + ))], + &[], + ); + + controller.wm.destroy(10); + let outcome = controller.show(); + assert_eq!( + outcome, + ShowOutcome { + shown: 0, + stale: 1, + refound: 1 + } + ); + assert!( + controller.wm.is_visible(99), + "应按进程路径 + 标题找回重建的窗口" + ); + } + + #[test] + fn refind_skips_visible_windows_and_records_without_info() { + let setting = Setting { + hide_current: false, + ..Setting::default() + }; + let wm = MockWm::new( + vec![ + win_pid("微信", 10, "WeChat.exe", 500, "C:\\WeChat.exe"), + win_pid("微信", 99, "WeChat.exe", 600, "C:\\WeChat.exe"), + ], + 0, + ); + let mut controller = HideController::new(wm, MockEffects::default()); + // bare 目标没有路径 / 标题信息,失效后无从找回。 + controller.apply_hide(&setting, &[Target::bare(10, 500)], &[]); + controller.wm.destroy(10); + let outcome = controller.show(); + assert_eq!(outcome.refound, 0, "无路径 / 标题信息的记录不找回"); + assert!(controller.wm.is_visible(99), "可见窗口不受影响"); + } + #[test] fn release_windows_frees_whole_process_and_shows_unknown_handles() { let setting = Setting { diff --git a/crates/core/src/i18n.rs b/crates/core/src/i18n.rs index fd03cbf..235c500 100644 --- a/crates/core/src/i18n.rs +++ b/crates/core/src/i18n.rs @@ -85,7 +85,7 @@ impl Msg { Msg::ErrCoreExited => "核心已退出", Msg::ErrNotifyCore => "无法通知核心", Msg::ErrCoreTimeout => "核心响应超时", - Msg::ErrCoreExeMissing => "未找到核心程序 {exe}", + Msg::ErrCoreExeMissing => "未找到核心程序 {exe}。它很可能被杀毒软件拦截或隔离了:请尝试将 Boss Key 的程序目录加入杀毒软件的白名单 / 信任区,再从隔离区恢复该文件;若无法恢复,请重新下载完整程序包。", Msg::ErrFreezePartial => "{failed}/{total} 个进程冻结失败", Msg::ErrResumePartial => "{failed}/{total} 个进程解冻失败", Msg::ErrUrlSchemeNotAllowed => "只允许打开 http/https/mailto 链接", @@ -123,7 +123,7 @@ impl Msg { Msg::ErrCoreExited => "The core has exited", Msg::ErrNotifyCore => "Cannot reach the core", Msg::ErrCoreTimeout => "The core did not respond in time", - Msg::ErrCoreExeMissing => "Core program {exe} not found", + Msg::ErrCoreExeMissing => "Core program {exe} not found. It was most likely blocked or quarantined by antivirus software: add the Boss Key program folder to your antivirus allowlist, then restore the file from quarantine; if that is not possible, download the full package again.", Msg::ErrFreezePartial => "Failed to freeze {failed} of {total} processes", Msg::ErrResumePartial => "Failed to resume {failed} of {total} processes", Msg::ErrUrlSchemeNotAllowed => "Only http/https/mailto links may be opened", @@ -161,7 +161,7 @@ impl Msg { Msg::ErrCoreExited => "核心已結束", Msg::ErrNotifyCore => "無法通知核心", Msg::ErrCoreTimeout => "核心回應逾時", - Msg::ErrCoreExeMissing => "找不到核心程式 {exe}", + Msg::ErrCoreExeMissing => "找不到核心程式 {exe}。它很可能被防毒軟體攔截或隔離了:請將 Boss Key 的程式資料夾加入防毒軟體的信任區/白名單,再從隔離區還原該檔案;若無法還原,請重新下載完整程式包。", Msg::ErrFreezePartial => "{failed}/{total} 個程序凍結失敗", Msg::ErrResumePartial => "{failed}/{total} 個程序解除凍結失敗", Msg::ErrUrlSchemeNotAllowed => "僅允許開啟 http/https/mailto 連結", diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 7570b5e..6d4c608 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -22,5 +22,6 @@ pub mod shell; pub mod single_instance; pub mod tray; pub mod tray_badge; +pub mod win_event; mod util; diff --git a/crates/core/src/platform/mod.rs b/crates/core/src/platform/mod.rs index 42b6a9f..febd813 100644 --- a/crates/core/src/platform/mod.rs +++ b/crates/core/src/platform/mod.rs @@ -12,6 +12,8 @@ pub trait WindowManager { fn is_window(&self, hwnd: i64) -> bool; /// 句柄当前所属进程的 PID;查不到返回 0。 fn window_pid(&self, hwnd: i64) -> u32; + /// 窗口当前标题;无标题或查不到时返回 `NO_TITLE` 占位。 + fn window_title(&self, hwnd: i64) -> String; /// 进程创建时刻(Unix 毫秒),用于识别 PID 复用;查不到返回 0。 fn process_start_time(&self, pid: u32) -> i64; } diff --git a/crates/core/src/platform/win32.rs b/crates/core/src/platform/win32.rs index 86e2e86..0b2c00a 100644 --- a/crates/core/src/platform/win32.rs +++ b/crates/core/src/platform/win32.rs @@ -190,6 +190,10 @@ impl WindowManager for WindowsWindowManager { window_pid(hwnd_from(hwnd)) } + fn window_title(&self, hwnd: i64) -> String { + window_title(hwnd_from(hwnd)) + } + fn process_start_time(&self, pid: u32) -> i64 { process_start_time(pid) } diff --git a/crates/core/src/win_event.rs b/crates/core/src/win_event.rs new file mode 100644 index 0000000..7731e68 --- /dev/null +++ b/crates/core/src/win_event.rs @@ -0,0 +1,110 @@ +//! 窗口事件追踪:用 `SetWinEventHook` 订阅顶层窗口的销毁 / 显示 / 改标题事件, +//! 转发给代理窗口,由 agent 实时维护隐藏记录与规则信息。 +//! 回调只做过滤与 `PostMessageW`,重活都在代理窗口的消息处理里。 + +use std::sync::atomic::{AtomicIsize, Ordering::Relaxed}; + +use windows::Win32::Foundation::{HWND, LPARAM, WPARAM}; +use windows::Win32::UI::Accessibility::{HWINEVENTHOOK, SetWinEventHook, UnhookWinEvent}; +use windows::Win32::UI::WindowsAndMessaging::{ + CHILDID_SELF, EVENT_OBJECT_DESTROY, EVENT_OBJECT_NAMECHANGE, EVENT_OBJECT_SHOW, OBJID_WINDOW, + PostMessageW, WINEVENT_OUTOFCONTEXT, WM_APP, +}; + +/// 窗口事件转发给代理窗口的消息;`wparam` 为事件号,`lparam` 为窗口句柄。 +pub const WM_APP_WINEVENT: u32 = WM_APP + 6; + +static HWND_RAW: AtomicIsize = AtomicIsize::new(0); + +/// 事件是否需要转发:只关心顶层窗口自身的销毁 / 显示 / 改标题。 +fn relevant(event: u32, id_object: i32, id_child: i32) -> bool { + id_object == OBJID_WINDOW.0 + && id_child == CHILDID_SELF as i32 + && matches!( + event, + EVENT_OBJECT_DESTROY | EVENT_OBJECT_SHOW | EVENT_OBJECT_NAMECHANGE + ) +} + +unsafe extern "system" fn hook_proc( + _hook: HWINEVENTHOOK, + event: u32, + hwnd: HWND, + id_object: i32, + id_child: i32, + _id_thread: u32, + _time: u32, +) { + if !relevant(event, id_object, id_child) || hwnd.is_invalid() { + return; + } + let raw = HWND_RAW.load(Relaxed); + if raw == 0 { + return; + } + unsafe { + let agent = HWND(raw as *mut std::ffi::c_void); + let _ = PostMessageW( + Some(agent), + WM_APP_WINEVENT, + WPARAM(event as usize), + LPARAM(hwnd.0 as isize), + ); + } +} + +/// 窗口事件钩子;析构时卸载。须在有消息循环的线程(代理窗口线程)上安装。 +pub struct WinEventHook { + handle: HWINEVENTHOOK, +} + +impl WinEventHook { + /// 一次挂钩覆盖 DESTROY(0x8001)–NAMECHANGE(0x800C) 区间,回调里再过滤。 + pub fn install(agent_hwnd: HWND) -> Option { + HWND_RAW.store(agent_hwnd.0 as isize, Relaxed); + let handle = unsafe { + SetWinEventHook( + EVENT_OBJECT_DESTROY, + EVENT_OBJECT_NAMECHANGE, + None, + Some(hook_proc), + 0, + 0, + WINEVENT_OUTOFCONTEXT, + ) + }; + if handle.is_invalid() { + None + } else { + Some(WinEventHook { handle }) + } + } +} + +impl Drop for WinEventHook { + fn drop(&mut self) { + unsafe { + let _ = UnhookWinEvent(self.handle); + } + HWND_RAW.store(0, Relaxed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use windows::Win32::UI::WindowsAndMessaging::{EVENT_OBJECT_FOCUS, EVENT_OBJECT_HIDE, OBJID_CURSOR}; + + #[test] + fn only_toplevel_destroy_show_namechange_are_relevant() { + let obj = OBJID_WINDOW.0; + let child = CHILDID_SELF as i32; + assert!(relevant(EVENT_OBJECT_DESTROY, obj, child)); + assert!(relevant(EVENT_OBJECT_SHOW, obj, child)); + assert!(relevant(EVENT_OBJECT_NAMECHANGE, obj, child)); + assert!(!relevant(EVENT_OBJECT_HIDE, obj, child), "隐藏事件不用追踪"); + assert!(!relevant(EVENT_OBJECT_FOCUS, obj, child), "区间内的无关事件应过滤"); + assert!(!relevant(EVENT_OBJECT_DESTROY, OBJID_CURSOR.0, child), "非窗口对象应过滤"); + assert!(!relevant(EVENT_OBJECT_DESTROY, obj, 3), "子对象事件应过滤"); + } +} diff --git a/crates/core/tests/agent_window_tracking.rs b/crates/core/tests/agent_window_tracking.rs new file mode 100644 index 0000000..d662503 --- /dev/null +++ b/crates/core/tests/agent_window_tracking.rs @@ -0,0 +1,112 @@ +//! 端到端:被隐藏的窗口自行销毁后,窗口事件追踪应实时移除记录并更新恢复文件。 + +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::{LPARAM, WPARAM}; +use windows::Win32::System::Threading::GetCurrentThreadId; +use windows::Win32::UI::WindowsAndMessaging::{ + CW_USEDEFAULT, CreateWindowExW, DestroyWindow, DispatchMessageW, GetMessageW, MSG, + PostThreadMessageW, SW_SHOW, ShowWindow, TranslateMessage, WINDOW_EX_STYLE, WM_QUIT, + WS_OVERLAPPEDWINDOW, +}; +use windows::core::w; + +/// 在带消息循环的独立线程上创建一个可见窗口;线程收到 WM_QUIT 后销毁窗口。 +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!("BossKeyTrackingTestWindow"), + 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) +} + +#[test] +fn destroying_a_hidden_window_clears_the_record_in_real_time() { + let (hwnd, window_tid, window_thread) = spawn_visible_window(); + + 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_window_tracking"; + 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_eq!( + client.send(&Command::GetState).unwrap(), + Response::State { hidden: true } + ); + assert!(recovery_path.exists()); + + // 结束窗口线程 → 窗口销毁 → 事件钩子应让核心实时移除记录。 + unsafe { + let _ = PostThreadMessageW(window_tid, WM_QUIT, WPARAM(0), LPARAM(0)); + } + window_thread.join().unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if client.send(&Command::GetState).unwrap() == (Response::State { hidden: false }) { + break; + } + assert!( + Instant::now() < deadline, + "窗口销毁后核心未在时限内移除隐藏记录" + ); + std::thread::sleep(Duration::from_millis(50)); + } + 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(); +} diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 0793809..a4e44c1 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -88,6 +88,7 @@ Boss-Key/ │ mouse_hook.rs WH_MOUSE_LL(中键/侧键/四角) │ keyboard_hook.rs WH_KEYBOARD_LL(「不传递」热键拦截) │ idle.rs GetLastInputInfo 空闲 + 自动隐藏判定 +│ win_event.rs SetWinEventHook 窗口事件追踪(销毁/显示/改标题 → 实时维护记录) │ tray.rs Shell_NotifyIcon 托盘 + 气泡 │ ipc_server.rs 命名管道服务端(创建失败退避重试,不退出) │ autostart.rs 开机自启(计划任务 XML 含失败自动重启 + 注册表回退) @@ -120,10 +121,13 @@ Boss-Key/ - 鼠标钩子(中键 / 侧键 / 四角); - 命名管道服务端(来自配置界面的命令); - 定时器(空闲检测、状态维护等); +- 窗口事件(`SetWinEventHook`:顶层窗口销毁 / 显示 / 改标题); - 托盘图标交互。 消息循环状态由 `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 都会被系统回收复用,校验不过的记录跳过并如实计入日志。 diff --git a/docs/en/dev/architecture.md b/docs/en/dev/architecture.md index ead110c..3ee898e 100644 --- a/docs/en/dev/architecture.md +++ b/docs/en/dev/architecture.md @@ -88,6 +88,7 @@ Boss-Key/ │ 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 +│ win_event.rs SetWinEventHook window-event tracking (destroy/show/title change → live record upkeep) │ tray.rs Shell_NotifyIcon tray + balloons │ 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) @@ -120,10 +121,13 @@ Boss-Key/ - The mouse hook (middle / side buttons, corners); - The named-pipe server (commands from the settings window); - Timers (idle detection, state maintenance, and so on); +- Window events (`SetWinEventHook`: top-level window destroy / show / title change); - Tray icon interaction. 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. +Window events keep the hidden records maintained in real time: when a hidden window is destroyed or shown externally, its record is removed and persisted immediately; title changes are synced into hidden records and exact window rules (in memory only, written out with the next regular persist), so reacquisition and refinding by "title + process path" always work on fresh data. On restore, records with dead handles are additionally refound among currently invisible windows by "process path + title". + 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. diff --git a/docs/en/guide/recovery.md b/docs/en/guide/recovery.md index 4e177e3..2cf1643 100644 --- a/docs/en/guide/recovery.md +++ b/docs/en/guide/recovery.md @@ -32,7 +32,7 @@ The core writes key events and panic information to log files in the `logs` fold Each time windows are hidden, the core records which windows were hidden and which processes were frozen or muted into `recovery.json`, and deletes the file on a normal restore or exit. -If that file still exists when the core restarts, the previous run **exited abnormally** — so the core automatically **restores the windows, resumes the processes and unmutes them**. +If that file still exists when the core restarts, the previous run **exited abnormally** — so the core automatically **restores the windows, resumes the processes and unmutes them**. If a window handle is no longer valid (for example the app recreated its window), the core additionally tries to refind the window by process path and title. ### Layer 3: the scheduled task diff --git a/docs/guide/recovery.md b/docs/guide/recovery.md index 864634b..7cfe654 100644 --- a/docs/guide/recovery.md +++ b/docs/guide/recovery.md @@ -32,7 +32,7 @@ Boss Key 核心内置了**崩溃自愈三层防线**,即使程序意外崩溃 每次隐藏窗口时,核心会把"隐藏了哪些窗口、冻结 / 静音了哪些进程"写入 `recovery.json`;正常显示或退出时删除该文件。 -核心重启时若发现该文件仍然存在,说明上次是**异常退出**——于是它会自动**找回窗口、解冻进程、取消静音**。 +核心重启时若发现该文件仍然存在,说明上次是**异常退出**——于是它会自动**找回窗口、解冻进程、取消静音**。恢复时若窗口句柄已失效(如程序重建了窗口),核心还会按进程路径与标题尝试重新找回。 ### 第三层:计划任务 diff --git a/docs/zh-tw/dev/architecture.md b/docs/zh-tw/dev/architecture.md index c119da5..dffd501 100644 --- a/docs/zh-tw/dev/architecture.md +++ b/docs/zh-tw/dev/architecture.md @@ -120,10 +120,13 @@ Boss-Key/ - 滑鼠掛鉤(中鍵/側鍵/四角); - 具名管道伺服端(來自設定介面的命令); - 計時器(閒置偵測、狀態維護等); +- 視窗事件(`SetWinEventHook`:頂層視窗銷毀/顯示/改標題); - 通知區域圖示互動。 訊息迴圈狀態由 `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 都會被系統回收重複使用,校驗不過的紀錄跳過並如實計入日誌。 diff --git a/docs/zh-tw/guide/recovery.md b/docs/zh-tw/guide/recovery.md index 8efbea3..983d322 100644 --- a/docs/zh-tw/guide/recovery.md +++ b/docs/zh-tw/guide/recovery.md @@ -32,7 +32,7 @@ Boss Key 核心內建了**當機自癒三層防線**,即使程式意外當機 每次隱藏視窗時,核心會把「隱藏了哪些視窗、凍結/靜音了哪些程序」寫入 `recovery.json`;正常顯示或結束時刪除該檔案。 -核心重新啟動時若發現該檔案仍然存在,表示上次是**異常結束**——於是它會自動**找回視窗、解除凍結程序、取消靜音**。 +核心重新啟動時若發現該檔案仍然存在,表示上次是**異常結束**——於是它會自動**找回視窗、解除凍結程序、取消靜音**。復原時若視窗控制代碼已失效(如程式重建了視窗),核心還會按程序路徑與標題嘗試重新找回。 ### 第三層:排程工作