From bc090540e55ff9454d343457f2804387cbeeab9e Mon Sep 17 00:00:00 2001 From: Ivan Hanloth Date: Wed, 29 Jul 2026 20:00:09 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=AD=89=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/config/src-tauri/src/lib.rs | 25 +- apps/config/src-tauri/src/verhub.rs | 7 +- .../ui/src/components/AboutPanel.svelte | 1 + .../ui/src/components/ErrorReportModal.svelte | 5 +- .../ui/src/components/OptionsPanel.svelte | 23 + apps/config/ui/src/lib/ipc.js | 8 +- apps/config/ui/src/lib/verhub.js | 6 +- apps/config/ui/src/locales/en.js | 11 +- apps/config/ui/src/locales/zh-CN.js | 11 +- apps/config/ui/src/locales/zh-TW.js | 11 +- crates/common/src/config.rs | 66 +++ crates/core/src/agent.rs | 314 +++++++++---- crates/core/src/audio.rs | 18 +- crates/core/src/effects.rs | 29 +- crates/core/src/effects_worker.rs | 26 +- crates/core/src/freeze.rs | 2 +- crates/core/src/hide.rs | 14 +- crates/core/src/input_hooks.rs | 10 +- crates/core/src/ipc_server.rs | 31 +- crates/core/src/logging.rs | 429 +++++++++++++++++- crates/core/src/main.rs | 45 +- crates/core/src/recovery.rs | 24 +- crates/core/src/tray.rs | 9 +- crates/core/src/util.rs | 22 + docs/dev/architecture.md | 40 +- docs/dev/config-reference.md | 5 +- docs/en/dev/architecture.md | 40 +- docs/en/dev/config-reference.md | 5 +- docs/en/guide/options.md | 15 + docs/en/guide/recovery.md | 2 +- docs/guide/options.md | 15 + docs/guide/recovery.md | 2 +- docs/zh-tw/dev/architecture.md | 40 +- docs/zh-tw/dev/config-reference.md | 5 +- docs/zh-tw/guide/options.md | 15 + docs/zh-tw/guide/recovery.md | 2 +- 36 files changed, 1126 insertions(+), 207 deletions(-) diff --git a/apps/config/src-tauri/src/lib.rs b/apps/config/src-tauri/src/lib.rs index a540044..05668cb 100644 --- a/apps/config/src-tauri/src/lib.rs +++ b/apps/config/src-tauri/src/lib.rs @@ -528,26 +528,11 @@ async fn verhub_upload_log(content: String) -> Result<(), String> { .map_err(|e| e.to_string()) } -/// 最新日志文件的末尾若干行。 +/// 核心最近一次运行的日志:从该次运行的 `[START]` 起至今,压到上报预算以内。 #[tauri::command] -async fn recent_log_tail(lines: usize) -> String { - blocking(move || { - let dir = log_dir(); - let latest = std::fs::read_dir(&dir) - .ok() - .into_iter() - .flatten() - .flatten() - .filter(|e| e.path().is_file()) - .max_by_key(|e| e.metadata().and_then(|m| m.modified()).ok()); - let Some(entry) = latest else { - return String::new(); - }; - let content = std::fs::read_to_string(entry.path()).unwrap_or_default(); - let tail: Vec<&str> = content.lines().rev().take(lines).collect(); - tail.into_iter().rev().collect::>().join("\n") - }) - .await +async fn current_session_log() -> String { + blocking(move || bosskey_core::logging::latest_session(&log_dir(), verhub::LOG_EXCERPT_MAX)) + .await } /// 系统版本描述,形如 `Microsoft Windows [版本 10.0.26200.1234]`。 @@ -613,7 +598,7 @@ pub fn run() { verhub_feedback_options, verhub_submit_feedback, verhub_upload_log, - recent_log_tail, + current_session_log, ]) // 兜底:前端起不来时,5 秒后强制显示窗口。 .setup(|app| { diff --git a/apps/config/src-tauri/src/verhub.rs b/apps/config/src-tauri/src/verhub.rs index 5d261c7..c381a64 100644 --- a/apps/config/src-tauri/src/verhub.rs +++ b/apps/config/src-tauri/src/verhub.rs @@ -22,6 +22,8 @@ pub const PLATFORM: Platform = Platform::Windows; const TIMEOUT: Duration = Duration::from_secs(10); const LOG_CONTENT_MAX: usize = 4096; +/// 上报正文里留给日志摘录的预算,其余部分留给错误描述与详情。 +pub const LOG_EXCERPT_MAX: usize = LOG_CONTENT_MAX * 3 / 5; type Result = verhub_sdk::Result; @@ -356,7 +358,10 @@ mod tests { #[test] fn normalize_contact_treats_blank_as_absent() { - assert_eq!(normalize_contact(" ivan@o5g.top "), Some("ivan@o5g.top".into())); + assert_eq!( + normalize_contact(" ivan@o5g.top "), + Some("ivan@o5g.top".into()) + ); assert_eq!(normalize_contact(""), None); assert_eq!(normalize_contact(" \t\n "), None); } diff --git a/apps/config/ui/src/components/AboutPanel.svelte b/apps/config/ui/src/components/AboutPanel.svelte index cb8bc87..76bc7aa 100644 --- a/apps/config/ui/src/components/AboutPanel.svelte +++ b/apps/config/ui/src/components/AboutPanel.svelte @@ -162,6 +162,7 @@
(hoverRating = 0)} > diff --git a/apps/config/ui/src/components/ErrorReportModal.svelte b/apps/config/ui/src/components/ErrorReportModal.svelte index 7bc9cba..8f8e118 100644 --- a/apps/config/ui/src/components/ErrorReportModal.svelte +++ b/apps/config/ui/src/components/ErrorReportModal.svelte @@ -3,7 +3,7 @@ import IconTriangleAlert from "~icons/lucide/triangle-alert"; import Modal from "./Modal.svelte"; import { app, toast } from "../lib/state.svelte.js"; - import { recentLogTail, uploadLog } from "../lib/verhub.js"; + import { currentSessionLog, uploadLog } from "../lib/verhub.js"; import { t } from "../lib/i18n.svelte.js"; const report = $derived(app.errorReport); @@ -12,12 +12,11 @@ let sending = $state(false); let sent = $state(false); - // 弹框出现时取本地日志尾部供用户过目。 $effect(() => { if (!report) return; sent = false; logTail = ""; - recentLogTail(60) + currentSessionLog() .then((t) => (logTail = t)) .catch(() => (logTail = "")); }); diff --git a/apps/config/ui/src/components/OptionsPanel.svelte b/apps/config/ui/src/components/OptionsPanel.svelte index b6cd291..78846d2 100644 --- a/apps/config/ui/src/components/OptionsPanel.svelte +++ b/apps/config/ui/src/components/OptionsPanel.svelte @@ -27,6 +27,16 @@ const s = $derived(app.config.setting); + // 日志输出等级,由低到高;value 与核心 Setting::log_level 的取值一致。 + // 文案键须为字面量,供 scripts/i18n-check.ps1 静态检查。 + const LOG_LEVELS = [ + { value: "debug", label: "options.logLevel.debug" }, + { value: "info", label: "options.logLevel.info" }, + { value: "warn", label: "options.logLevel.warn" }, + { value: "error", label: "options.logLevel.error" }, + ]; + const logDisabled = $derived(s.log_retention_days === 0); + // 自启注册方式的标注;仅在已开启自启时显示。 const autostartMethodText = $derived( !app.autostart @@ -202,6 +212,19 @@ {/snippet} + + {#snippet control()} + + {/snippet} +
diff --git a/apps/config/ui/src/lib/ipc.js b/apps/config/ui/src/lib/ipc.js index 711f123..1fd79bb 100644 --- a/apps/config/ui/src/lib/ipc.js +++ b/apps/config/ui/src/lib/ipc.js @@ -53,6 +53,7 @@ const mockConfig = { allow_move_restore: false, corner_fast_only: true, log_retention_days: 7, + log_level: "warn", autostart_admin: false, language: "auto", }, @@ -189,8 +190,11 @@ function mockInvoke(cmd, args) { case "verhub_upload_log": case "open_external": return null; - case "recent_log_tail": - return "[mock] 2026-07-14 12:00:00 WARN 这是预览环境的假日志"; + case "current_session_log": + return [ + "[mock] 2026-07-14 12:00:00 [START] 核心启动 3.1.0(配置 schema v3.0.0.0,日志等级 warn)", + "[mock] 2026-07-14 12:00:05 [WARN] 这是预览环境的假日志", + ].join("\n"); default: return null; } diff --git a/apps/config/ui/src/lib/verhub.js b/apps/config/ui/src/lib/verhub.js index cfbc7f3..f641824 100644 --- a/apps/config/ui/src/lib/verhub.js +++ b/apps/config/ui/src/lib/verhub.js @@ -35,9 +35,9 @@ export function uploadLog(content) { return invoke("verhub_upload_log", { content }); } -/** 本地日志末尾若干行。 */ -export function recentLogTail(lines = 60) { - return invoke("recent_log_tail", { lines }); +/** 核心最近一次运行的日志(从该次运行的 [START] 起至今,后端已压到上报预算内)。 */ +export function currentSessionLog() { + return invoke("current_session_log"); } /** 用系统浏览器打开外链。 */ diff --git a/apps/config/ui/src/locales/en.js b/apps/config/ui/src/locales/en.js index 0ac7ae4..9354eaf 100644 --- a/apps/config/ui/src/locales/en.js +++ b/apps/config/ui/src/locales/en.js @@ -252,6 +252,13 @@ export default { "Core logs are stored per day in the logs folder inside the program directory, and older logs are cleaned up on startup. Choose “Off” to disable logging.", "options.logOff": "Off", "options.logDays": "{n} days", + "options.logLevel": "Log level", + "options.logLevelDesc": + "Only records entries at the selected level or above. The default is “Warning”, which keeps warnings and errors while leaving out routine hide/restore activity. Lower it temporarily to collect more detail before reporting an issue.", + "options.logLevel.debug": "Debug (most detailed)", + "options.logLevel.info": "Info", + "options.logLevel.warn": "Warning (default)", + "options.logLevel.error": "Error (least detailed)", "options.languageCard": "Language", "options.language": "Display language", @@ -351,7 +358,7 @@ export default { "error.title": "Something went wrong", "error.summary": - "Expand to review what will be sent (the log may contain window titles and program paths — please check first)", + "Expand to review what will be sent (logs contain no window titles, and the user folder is replaced with %USERPROFILE%)", "error.hint": "The log is sent only when you click report; nothing is sent automatically.", "error.dontReport": "Don’t report", "error.reporting": "Reporting…", @@ -361,7 +368,7 @@ export default { "error.reportFailed": "Could not report: {err}", "error.payloadError": "Error: {message}", "error.payloadDetail": "Details: {detail}", - "error.payloadLog": "Recent log:", + "error.payloadLog": "Log of the core's current run:", "state.suspendFailed": "Could not pause hotkey monitoring: {err}", "state.pssuspendFound": "pssuspend64.exe detected", diff --git a/apps/config/ui/src/locales/zh-CN.js b/apps/config/ui/src/locales/zh-CN.js index 8a2d733..12b77f8 100644 --- a/apps/config/ui/src/locales/zh-CN.js +++ b/apps/config/ui/src/locales/zh-CN.js @@ -242,6 +242,13 @@ export default { "核心日志按天保存在程序目录的 logs 文件夹内,启动时自动清理更早的日志;选择「关闭」则不记录日志。", "options.logOff": "关闭", "options.logDays": "{n} 天", + "options.logLevel": "日志输出等级", + "options.logLevelDesc": + "只记录所选等级及以上的日志。默认「警告」,即仅记录警告与错误;日常隐藏、恢复等流水不写入。反馈问题前可临时调低以取得更详细的记录。", + "options.logLevel.debug": "调试(最详细)", + "options.logLevel.info": "信息", + "options.logLevel.warn": "警告(默认)", + "options.logLevel.error": "错误(最精简)", "options.languageCard": "语言", "options.language": "界面语言", @@ -336,7 +343,7 @@ export default { "dataNotice.gotIt": "知道了", "error.title": "出错了", - "error.summary": "展开查看将要发送的内容(日志里可能含窗口标题与程序路径,请先检查)", + "error.summary": "展开查看将要发送的内容(日志不含窗口标题,用户目录已替换为 %USERPROFILE%)", "error.hint": "日志仅在点击上报后发送,不会自动上报。", "error.dontReport": "不上报", "error.reporting": "上报中…", @@ -346,7 +353,7 @@ export default { "error.reportFailed": "上报失败:{err}", "error.payloadError": "错误:{message}", "error.payloadDetail": "详情:{detail}", - "error.payloadLog": "最近日志:", + "error.payloadLog": "核心本次运行日志:", "state.suspendFailed": "暂停热键监控失败:{err}", "state.pssuspendFound": "已检测到 pssuspend64.exe", diff --git a/apps/config/ui/src/locales/zh-TW.js b/apps/config/ui/src/locales/zh-TW.js index a9edab9..439b685 100644 --- a/apps/config/ui/src/locales/zh-TW.js +++ b/apps/config/ui/src/locales/zh-TW.js @@ -241,6 +241,13 @@ export default { "核心記錄檔會按日儲存在程式資料夾的 logs 資料夾內,啟動時自動清除更早的記錄檔;選擇「關閉」則不記錄。", "options.logOff": "關閉", "options.logDays": "{n} 天", + "options.logLevel": "記錄輸出等級", + "options.logLevelDesc": + "只記錄所選等級及以上的內容。預設「警告」,即僅記錄警告與錯誤;日常隱藏、還原等流水不寫入。回報問題前可暫時調低以取得更詳細的記錄。", + "options.logLevel.debug": "偵錯(最詳細)", + "options.logLevel.info": "資訊", + "options.logLevel.warn": "警告(預設)", + "options.logLevel.error": "錯誤(最精簡)", "options.languageCard": "語言", "options.language": "介面語言", @@ -337,7 +344,7 @@ export default { "dataNotice.gotIt": "知道了", "error.title": "發生錯誤", - "error.summary": "展開檢視將要傳送的內容(記錄檔裡可能含視窗標題與程式路徑,請先檢查)", + "error.summary": "展開檢視將要傳送的內容(記錄檔不含視窗標題,使用者目錄已替換為 %USERPROFILE%)", "error.hint": "記錄檔僅在點按回報後才傳送,不會自動回報。", "error.dontReport": "不回報", "error.reporting": "回報中…", @@ -347,7 +354,7 @@ export default { "error.reportFailed": "回報失敗:{err}", "error.payloadError": "錯誤:{message}", "error.payloadDetail": "詳細資料:{detail}", - "error.payloadLog": "最近的記錄:", + "error.payloadLog": "核心本次執行的記錄:", "state.suspendFailed": "暫停快速鍵監聽失敗:{err}", "state.pssuspendFound": "已偵測到 pssuspend64.exe", diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index aa2d154..dee8c96 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -11,6 +11,20 @@ pub const DEFAULT_CLOSE_HOTKEY: &str = "Win+Esc"; pub const DEFAULT_AUTO_HIDE_TIME: u32 = 5; /// 日志默认保留天数(`0` 表示关闭日志)。 pub const DEFAULT_LOG_RETENTION_DAYS: u32 = 7; +/// 日志输出等级的取值。低于所选等级的日志不写入文件。 +pub const LOG_LEVEL_DEBUG: &str = "debug"; +pub const LOG_LEVEL_INFO: &str = "info"; +pub const LOG_LEVEL_WARN: &str = "warn"; +pub const LOG_LEVEL_ERROR: &str = "error"; +/// 由低到高的全部合法等级。 +pub const LOG_LEVELS: [&str; 4] = [ + LOG_LEVEL_DEBUG, + LOG_LEVEL_INFO, + LOG_LEVEL_WARN, + LOG_LEVEL_ERROR, +]; +/// 默认输出等级:只记录警告及以上。 +pub const DEFAULT_LOG_LEVEL: &str = LOG_LEVEL_WARN; /// 连击判定窗口默认值(毫秒):两次点击间隔不超过它才算连击。 pub const DEFAULT_MULTI_CLICK_MS: u32 = 350; pub const MIN_MULTI_CLICK_MS: u32 = 150; @@ -36,6 +50,24 @@ fn default_auto_hide_time() -> u32 { fn default_log_retention_days() -> u32 { DEFAULT_LOG_RETENTION_DAYS } +fn default_log_level() -> String { + DEFAULT_LOG_LEVEL.to_string() +} + +/// 归一日志等级:忽略大小写与首尾空白,兼容 `warning`;无法识别时回落默认值。 +pub fn normalize_log_level(value: &str) -> String { + let v = value.trim().to_ascii_lowercase(); + let v = if v == "warning" { + LOG_LEVEL_WARN.to_string() + } else { + v + }; + if LOG_LEVELS.contains(&v.as_str()) { + v + } else { + DEFAULT_LOG_LEVEL.to_string() + } +} fn default_clicks() -> u8 { 1 } @@ -356,6 +388,9 @@ pub struct Setting { /// 日志保留天数;`0` 表示关闭日志。 #[serde(default = "default_log_retention_days")] pub log_retention_days: u32, + /// 日志输出等级:`debug`/`info`/`warn`/`error`,低于它的日志不写入文件。 + #[serde(default = "default_log_level")] + pub log_level: String, /// 开机自启是否以管理员身份启动:`true` 注册最高权限计划任务,`false` 用普通权限。 /// 仅影响计划任务方式;注册表回退始终以普通权限运行。 #[serde(default)] @@ -392,6 +427,7 @@ impl Default for Setting { allow_move_restore: false, corner_fast_only: true, log_retention_days: DEFAULT_LOG_RETENTION_DAYS, + log_level: default_log_level(), autostart_admin: false, language: default_language(), } @@ -412,6 +448,7 @@ impl Setting { self.tray_badges.normalize(); self.mouse.normalize(); self.language = crate::i18n::normalize_pref(&self.language); + self.log_level = normalize_log_level(&self.log_level); } } @@ -784,9 +821,38 @@ mod tests { assert!(!c.setting.freeze_whole_tree); assert_eq!(c.setting.auto_hide_time, 5); assert_eq!(c.setting.log_retention_days, 7, "日志保留天数默认 7"); + assert_eq!(c.setting.log_level, "warn", "日志等级默认只记警告及以上"); assert!(!c.setting.autostart_admin, "自启默认普通权限"); } + #[test] + fn log_level_round_trips_and_normalizes() { + assert_eq!(Setting::default().log_level, LOG_LEVEL_WARN); + + let c = Config::from_json(r#"{"setting": {"log_level": "debug"}}"#).unwrap(); + assert_eq!(c.setting.log_level, LOG_LEVEL_DEBUG); + let back = Config::from_json(&c.to_json().unwrap()).unwrap(); + assert_eq!(back.setting.log_level, LOG_LEVEL_DEBUG, "写回后应保留"); + + assert_eq!( + normalize_log_level(" INFO "), + LOG_LEVEL_INFO, + "忽略大小写与空白" + ); + assert_eq!( + normalize_log_level("warning"), + LOG_LEVEL_WARN, + "兼容 warning" + ); + assert_eq!( + normalize_log_level("verbose"), + DEFAULT_LOG_LEVEL, + "未知等级回落默认值" + ); + let c = Config::from_json(r#"{"setting": {"log_level": "verbose"}}"#).unwrap(); + assert_eq!(c.setting.log_level, DEFAULT_LOG_LEVEL); + } + #[test] fn tray_badges_default_bindings() { let d = TrayBadges::default(); diff --git a/crates/core/src/agent.rs b/crates/core/src/agent.rs index fe40de7..845161b 100644 --- a/crates/core/src/agent.rs +++ b/crates/core/src/agent.rs @@ -70,6 +70,39 @@ const IPC_REPLY_TIMEOUT: Duration = Duration::from_secs(3); /// 退出时等待副作用线程排干队列(解冻 / 取消静音)的上限。 const EFFECTS_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); +/// 一次隐藏 / 恢复的触发来源,仅用于日志。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Trigger { + Hotkey, + MouseButton, + Corner, + Idle, + TrayClick, + TrayMenu, + FloatWindow, + /// 配置程序经 IPC 下发。 + Ipc, + /// 核心退出前的收尾恢复。 + Quit, +} + +impl std::fmt::Display for Trigger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { + Trigger::Hotkey => "热键", + Trigger::MouseButton => "鼠标按键", + Trigger::Corner => "移动到屏幕四角", + Trigger::Idle => "空闲自动隐藏", + Trigger::TrayClick => "单击托盘图标", + Trigger::TrayMenu => "托盘菜单", + Trigger::FloatWindow => "悬浮窗", + Trigger::Ipc => "配置程序", + Trigger::Quit => "核心退出前", + }; + f.write_str(name) + } +} + pub struct AgentOptions { pub config_path: PathBuf, pub pipe_name: String, @@ -259,6 +292,9 @@ impl AgentState { if self.config.setting.show_float_window { if self.float_window.is_none() { self.float_window = FloatWindow::create(hwnd); + if self.float_window.is_none() { + log_warn!("悬浮窗创建失败,本次运行不显示悬浮窗,其余功能不受影响"); + } } } else { self.float_window = None; @@ -357,20 +393,22 @@ impl AgentState { } } - fn apply_hide(&mut self) { + fn apply_hide(&mut self, trigger: Trigger) { 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); + log_hide(trigger, &self.config, &outcomes, &plan); } - fn apply_hide_foreground(&mut self) { + fn apply_hide_foreground(&mut self, trigger: Trigger) { let windows = self.controller.enumerate(); let foreground = self.controller.foreground(); let Some(target) = foreground_target(&windows, foreground) else { - logging::info("隐藏前台窗口:当前没有可隐藏的前台窗口"); + logging::debug(&format!( + "{trigger}触发隐藏前台窗口:当前没有可隐藏的前台窗口" + )); return; }; @@ -378,7 +416,7 @@ impl AgentState { 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())); + logging::debug(&format!("{trigger}触发隐藏前台窗口: {}", t.describe())); } } @@ -409,9 +447,9 @@ impl AgentState { } } - fn apply_show(&mut self) { + fn apply_show(&mut self, trigger: Trigger) { let outcome = self.controller.show(); - log_show(outcome); + log_show(trigger, outcome); self.persist_recovery(); self.sync_tray(); if self.config.notifications.on_show { @@ -426,11 +464,11 @@ impl AgentState { } } - fn apply_toggle(&mut self) { + fn apply_toggle(&mut self, trigger: Trigger) { if self.controller.is_hidden() { - self.apply_show(); + self.apply_show(trigger); } else { - self.apply_hide(); + self.apply_hide(trigger); } } @@ -445,15 +483,24 @@ impl AgentState { } self.config = config; i18n::set_from_pref(&self.config.setting.language); + // 保留天数只在启动时清理,此处只对齐输出等级。 + logging::set_level(logging::Level::from_config(&self.config.setting.log_level)); self.sync_monitoring(hwnd); + logging::debug("配置已重新加载,热键、鼠标监控与日志等级均已对齐"); (Response::Ok, false) } - Err(e) => ( - Response::Error { - message: i18n::tf(Msg::ErrReloadConfig, &[("err", &e.to_string())]), - }, - false, - ), + Err(e) => { + log_error!( + "重新加载配置失败,本次改动未生效,核心仍在用上一次加载的配置: {} — {e}", + self.config_path.display() + ); + ( + Response::Error { + message: i18n::tf(Msg::ErrReloadConfig, &[("err", &e.to_string())]), + }, + false, + ) + } }, Command::GetState => ( Response::State { @@ -477,15 +524,15 @@ impl AgentState { false, ), Command::Hide => { - self.apply_hide(); + self.apply_hide(Trigger::Ipc); (Response::Ok, false) } Command::Show => { - self.apply_show(); + self.apply_show(Trigger::Ipc); (Response::Ok, false) } Command::Toggle => { - self.apply_toggle(); + self.apply_toggle(Trigger::Ipc); (Response::Ok, false) } Command::SetAutostart { enabled, admin } => (set_autostart(enabled, admin), false), @@ -517,7 +564,7 @@ impl AgentState { Command::ReleaseWindows { hwnds } => { let released = self.controller.release_windows(&hwnds); if released > 0 { - logging::info(&format!("窗口恢复工具释放 {released} 个窗口")); + logging::debug(&format!("窗口恢复工具释放 {released} 个窗口")); self.persist_recovery(); self.sync_tray(); } @@ -535,7 +582,7 @@ impl AgentState { 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())); + logging::debug(&format!("窗口恢复工具隐藏 {} 个窗口", plan.fresh.len())); } (Response::Ok, false) } @@ -544,33 +591,45 @@ impl AgentState { } } -/// 摘要式记录本次隐藏;规则失效 / 追溯事件逐条保留。 -fn log_hide(config: &Config, outcomes: &[RuleOutcome], plan: &HidePlan) { - for (rule, outcome) in config.window_rules.iter().zip(outcomes) { +/// 日志中指代一条窗口规则的写法:序号 + 进程名。 +/// 不含规则标题——标题即窗口标题,不写入日志。 +fn rule_label(index: usize, rule: &bosskey_common::WindowRule) -> String { + let kind = if rule.is_regex() { "正则" } else { "精确" }; + let process = if rule.process.is_empty() { + "未知进程" + } else { + &rule.process + }; + format!("{kind}窗口规则 #{}({process})", index + 1) +} + +/// 摘要式记录本次隐藏:明细记 debug,规则未匹配到窗口记 warn。 +fn log_hide(trigger: Trigger, config: &Config, outcomes: &[RuleOutcome], plan: &HidePlan) { + for (index, (rule, outcome)) in config.window_rules.iter().zip(outcomes).enumerate() { match outcome { - RuleOutcome::Reacquired => logging::info(&format!( - "窗口规则「{}」的句柄已失效(目标程序重启过),已重新匹配并更新规则", - rule.title + RuleOutcome::Reacquired => logging::debug(&format!( + "{} 的句柄已失效(目标程序重启过),已重新匹配并更新规则", + rule_label(index, rule) )), // 「未能追溯」只说明当前没有匹配的窗口,看不出是关闭了还是标题变了,故不臆断原因。 RuleOutcome::Missing => logging::warn(&format!( - "窗口规则「{}」未匹配到任何窗口(可能已关闭或标题已变),本次不隐藏它", - rule.title + "{} 未匹配到任何窗口(可能已关闭或标题已变),本次不隐藏它", + rule_label(index, rule) )), _ => {} } } if plan.fresh.is_empty() { - logging::info("触发隐藏:没有新的目标窗口"); + logging::debug(&format!("{trigger}触发隐藏:没有新的目标窗口")); return; } - logging::info(&format!( - "隐藏 {} 个窗口: {}", + logging::debug(&format!( + "{trigger}触发隐藏 {} 个窗口: {}", plan.fresh.len(), summarize(plan.fresh.iter().map(|t| t.describe())) )); if !plan.freeze.is_empty() { - logging::info(&format!( + logging::debug(&format!( "冻结 {} 个进程(增强={}): {}", plan.freeze.len(), plan.enhanced, @@ -579,15 +638,16 @@ fn log_hide(config: &Config, outcomes: &[RuleOutcome], plan: &HidePlan) { } } -/// 记录恢复结果。 -fn log_show(outcome: ShowOutcome) { - if outcome.stale > 0 { - logging::info(&format!( - "恢复显示 {} 个窗口;{} 条记录句柄已失效,其中 {} 个按进程路径与标题重新找回", +/// 记录恢复结果:有记录未能找回时记 warn,否则记 debug。 +fn log_show(trigger: Trigger, outcome: ShowOutcome) { + let lost = outcome.stale.saturating_sub(outcome.refound); + if lost > 0 { + logging::warn(&format!( + "{trigger}触发恢复:显示 {} 个窗口;{} 条记录的句柄已失效,其中 {} 个已按进程与标题找回,{lost} 个未能找回", outcome.shown, outcome.stale, outcome.refound )); } else { - logging::info(&format!("恢复显示 {} 个窗口", outcome.shown)); + logging::debug(&format!("{trigger}触发恢复显示 {} 个窗口", outcome.shown)); } } @@ -626,10 +686,19 @@ fn hotkey_failure_message(label: &str, raw: &str, e: &windows::core::Error) -> S } } +/// 开机自启注册方式的日志写法。 +fn autostart_method_name(method: crate::autostart::Method) -> &'static str { + match method { + crate::autostart::Method::TaskScheduler => "计划任务", + crate::autostart::Method::Registry => "注册表启动项", + } +} + fn set_autostart(enabled: bool, admin: bool) -> Response { let auto = match crate::autostart::Autostart::standard() { Ok(a) => a, Err(e) => { + log_warn!("设置开机自启失败,无法确定核心程序路径,自启状态未改变: {e}"); return Response::Error { message: e.to_string(), }; @@ -637,13 +706,26 @@ fn set_autostart(enabled: bool, admin: bool) -> Response { }; if enabled { match auto.enable(admin) { - Ok(_) => Response::Ok, - Err(e) => Response::Error { - message: e.to_string(), - }, + Ok(method) => { + logging::debug(&format!( + "已开启开机自启,方式为{}(管理员权限={admin})", + autostart_method_name(method) + )); + Response::Ok + } + Err(e) => { + log_warn!( + "开启开机自启失败,开机后核心不会自动运行(管理员权限={admin},任务名 {}): {e}", + crate::autostart::TASK_NAME + ); + Response::Error { + message: e.to_string(), + } + } } } else { auto.disable(); + logging::debug("已关闭开机自启"); Response::Ok } } @@ -653,7 +735,7 @@ fn set_autostart(enabled: bool, admin: bool) -> Response { fn toggle_auto_hide(state: &mut AgentState, hwnd: HWND) { let enabled = !state.config.setting.auto_hide_enabled; state.config.setting.auto_hide_enabled = enabled; - logging::info(if enabled { + logging::debug(if enabled { "托盘菜单:已启用自动隐藏" } else { "托盘菜单:已暂停自动隐藏" @@ -665,25 +747,44 @@ fn toggle_auto_hide(state: &mut AgentState, hwnd: HWND) { } fn toggle_autostart(state: &AgentState) { - let Ok(auto) = crate::autostart::Autostart::standard() else { - return; + let auto = match crate::autostart::Autostart::standard() { + Ok(auto) => auto, + Err(e) => { + log_warn!("托盘菜单切换开机自启失败,无法确定核心程序路径,自启状态未改变: {e}"); + return; + } }; let admin = state.config.setting.autostart_admin; let (title, message) = if auto.status().is_some() { auto.disable(); + logging::debug("托盘菜单:已关闭开机自启"); (Msg::AutostartOffTitle, Msg::AutostartOffBody) } else { match auto.enable(admin) { - Ok(crate::autostart::Method::TaskScheduler) if admin => { - (Msg::AutostartOnTitle, Msg::AutostartOnTaskAdmin) - } - Ok(crate::autostart::Method::TaskScheduler) => { - (Msg::AutostartOnTitle, Msg::AutostartOnTaskUser) + Ok(method) => { + logging::debug(&format!( + "托盘菜单:已开启开机自启,方式为{}(管理员权限={admin})", + autostart_method_name(method) + )); + match method { + crate::autostart::Method::TaskScheduler if admin => { + (Msg::AutostartOnTitle, Msg::AutostartOnTaskAdmin) + } + crate::autostart::Method::TaskScheduler => { + (Msg::AutostartOnTitle, Msg::AutostartOnTaskUser) + } + crate::autostart::Method::Registry => { + (Msg::AutostartOnTitle, Msg::AutostartOnRegistry) + } + } } - Ok(crate::autostart::Method::Registry) => { - (Msg::AutostartOnTitle, Msg::AutostartOnRegistry) + Err(e) => { + log_warn!( + "托盘菜单开启开机自启失败,开机后核心不会自动运行(管理员权限={admin},任务名 {}): {e}", + crate::autostart::TASK_NAME + ); + (Msg::AutostartFailTitle, Msg::AutostartFailBody) } - Err(_) => (Msg::AutostartFailTitle, Msg::AutostartFailBody), } }; if state.config.notifications.on_autostart @@ -779,7 +880,7 @@ fn release_config_windows(state: &mut AgentState, exe: &Path) { let released = state.controller.release_pids(&pids); if released > 0 { - logging::info(&format!( + logging::debug(&format!( "配置窗口此前被 Boss Key 隐藏,已先释放 {released} 个窗口再拉起设置" )); state.persist_recovery(); @@ -801,6 +902,9 @@ fn find_config_exe() -> Option { /// 拉起配置程序。`action` 会作为命令行参数传入(如 `restore` 直达窗口恢复工具)。 fn launch_settings(state: &mut AgentState, action: Option<&str>) { let Some(path) = find_config_exe() else { + log_warn!( + "核心所在目录下找不到配置程序(config.exe / bosskey-config.exe),无法打开设置界面" + ); if let Some(tray) = &state.tray { tray.balloon(APP_NAME, i18n::t(Msg::ConfigExeMissing)); } @@ -814,14 +918,18 @@ fn launch_settings(state: &mut AgentState, action: Option<&str>) { cmd.arg(arg); } if let Err(e) = cmd.spawn() { - log_warn!("启动配置程序失败: {e}"); + log_warn!( + "启动配置程序失败,设置界面未打开(可能被安全软件拦截): {} — {e}", + path.display() + ); } } -fn quit(state: &mut AgentState, hwnd: HWND) { +/// 退出核心。`reason` 写进会话结束标记。 +fn quit(state: &mut AgentState, hwnd: HWND, reason: &str) { let outcome = state.controller.show(); if outcome.shown > 0 || outcome.stale > 0 { - log_show(outcome); + log_show(Trigger::Quit, outcome); } state.persist_recovery(); state.unregister_hotkeys(hwnd); @@ -831,7 +939,7 @@ fn quit(state: &mut AgentState, hwnd: HWND) { if let Some(worker) = state.effects_worker.take() { worker.shutdown(EFFECTS_SHUTDOWN_TIMEOUT); } - logging::info("核心正常退出"); + logging::session_exit(&format!("核心正常退出({reason})")); let notify_quit = state.config.notifications.on_quit; if let Some(tray) = &mut state.tray { if notify_quit { @@ -864,11 +972,11 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: // WM_KEY_TRIGGER 是「不传递」热键经键盘钩子转发的等价触发。 WM_HOTKEY | WM_KEY_TRIGGER => { match wparam.0 as i32 { - HK_HIDE => state.apply_toggle(), - HK_CLOSE => quit(state, hwnd), - HK_HIDE_ONLY => state.apply_hide(), - HK_SHOW_ONLY => state.apply_show(), - HK_HIDE_FOREGROUND => state.apply_hide_foreground(), + HK_HIDE => state.apply_toggle(Trigger::Hotkey), + HK_CLOSE => quit(state, hwnd, "关闭热键"), + HK_HIDE_ONLY => state.apply_hide(Trigger::Hotkey), + HK_SHOW_ONLY => state.apply_show(Trigger::Hotkey), + HK_HIDE_FOREGROUND => state.apply_hide_foreground(Trigger::Hotkey), _ => {} } LRESULT(0) @@ -885,7 +993,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: should_quit |= quit_flag; } if should_quit { - quit(state, hwnd); + quit(state, hwnd, "配置程序发来退出命令"); } LRESULT(0) } @@ -893,7 +1001,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: match lparam.0 as u32 { WM_LBUTTONUP => { if state.config.setting.click_to_hide { - state.apply_toggle(); + state.apply_toggle(Trigger::TrayClick); } } WM_RBUTTONUP => { @@ -916,7 +1024,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: // 双击悬浮窗触发。 FLOAT_TOGGLE => { if state.config.setting.click_to_hide { - state.apply_toggle(); + state.apply_toggle(Trigger::FloatWindow); } } // 右键菜单以代理窗口为宿主,经 WM_COMMAND 复用处理。 @@ -935,10 +1043,10 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: MENU_SETTINGS => launch_settings(state, None), MENU_RESTORE => launch_settings(state, Some(ARG_RESTORE)), MENU_ABOUT => launch_settings(state, Some(ARG_ABOUT)), - MENU_TOGGLE => state.apply_toggle(), + MENU_TOGGLE => state.apply_toggle(Trigger::TrayMenu), MENU_AUTO_HIDE => toggle_auto_hide(state, hwnd), MENU_AUTOSTART => toggle_autostart(state), - MENU_QUIT => quit(state, hwnd), + MENU_QUIT => quit(state, hwnd, "托盘菜单"), _ => {} } LRESULT(0) @@ -950,12 +1058,17 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: } else { state.config.setting.mouse.allow_click_restore }; + let trigger = if wparam.0 == TRIGGER_CORNER { + Trigger::Corner + } else { + Trigger::MouseButton + }; if state.controller.is_hidden() { if allow_restore { - state.apply_show(); + state.apply_show(trigger); } } else { - state.apply_hide(); + state.apply_hide(trigger); } LRESULT(0) } @@ -965,7 +1078,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: unsafe { let _ = KillTimer(Some(hwnd), AUTO_QUIT_TIMER_ID); } - quit(state, hwnd); + quit(state, hwnd, "冒烟测试计时到点"); } SUSPEND_GUARD_TIMER_ID => { unsafe { @@ -997,7 +1110,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: state.config.setting.auto_hide_time, state.controller.is_hidden(), ) { - state.apply_hide(); + state.apply_hide(Trigger::Idle); } } _ => {} @@ -1101,14 +1214,22 @@ pub fn run(options: AgentOptions) { config.version = bosskey_common::APP_CONFIG_VERSION.to_string(); config.app_version = bosskey_common::APP_VERSION.to_string(); if let Err(e) = config.save(&options.config_path) { - log_warn!("写入配置版本失败: {e}"); + log_warn!( + "写入程序版本到配置失败,下次启动会重复拉起配置程序: {} — {e}", + options.config_path.display() + ); } if let Some(path) = find_config_exe() { if let Err(e) = std::process::Command::new(&path).spawn() { - log_warn!("启动配置程序失败: {e}"); + log_warn!( + "启动时拉起配置程序失败(可能被安全软件拦截): {} — {e}", + path.display() + ); } } else { - log_warn!("未找到配置程序,无法在启动时拉起"); + log_warn!( + "核心所在目录下找不到配置程序(config.exe / bosskey-config.exe),启动时无法拉起设置界面" + ); } } @@ -1171,7 +1292,9 @@ pub fn run(options: AgentOptions) { // 低级输入钩子挂在专职线程上:代理线程的枚举 / 落盘等重活不得拖慢全局输入。 let input_hooks = InputHooks::spawn(hwnd); if input_hooks.is_none() { - log_error!("输入钩子线程启动失败,鼠标绑定与「不传递」热键本次不可用"); + log_error!( + "输入钩子线程未能就绪(失败原因见前一条记录),本次运行鼠标按键触发、四角触发与「不传递」热键均不可用" + ); } let state = Box::new(RefCell::new(AgentState { @@ -1196,23 +1319,19 @@ pub fn run(options: AgentOptions) { 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!( - "检测到上次异常退出,开始找回 {} 个被隐藏的窗口(另需解冻 {} 个、取消静音 {} 个进程)", + let (hidden, frozen, muted) = ( snapshot.hidden.len(), snapshot.frozen.len(), - snapshot.muted.len() - )); + snapshot.muted.len(), + ); let outcome = state.controller.restore_from(snapshot); - logging::info(&format!( - "崩溃恢复完成:恢复 {} 个窗口,跳过 {} 条失效记录", + logging::warn(&format!( + "检测到上次异常退出:{hidden} 个被隐藏的窗口中恢复 {} 个、跳过 {} 条失效记录(另解冻 {frozen} 个、取消静音 {muted} 个进程)", outcome.shown, outcome.stale )); } else { - // 跨重启(或旧版格式)快照中的句柄与 PID 已失效,只丢弃并留档。 - log_warn!( - "恢复文件来自上一次开机或旧版本,其中的窗口句柄已失效,跳过恢复: {}", - state.recovery_path.display() - ); + // 跨重启(或旧版格式)快照中的句柄与 PID 已失效,丢弃不恢复。 + logging::debug("恢复文件来自上一次开机或旧版本,其中的窗口句柄已失效,跳过恢复"); } recovery::clear(&state.recovery_path); } @@ -1376,6 +1495,25 @@ mod tests { } } + #[test] + fn rule_label_names_the_process_and_never_the_window_title() { + let mut rule = bosskey_common::WindowRule::from_window(&bosskey_common::WindowInfo::new( + "与某人的聊天", + 10, + "WeChat.exe", + 2001, + "C:\\WeChat.exe", + )); + let label = rule_label(0, &rule); + assert_eq!(label, "精确窗口规则 #1(WeChat.exe)"); + assert!(!label.contains("与某人的聊天"), "标题属隐私,不得进日志"); + + rule.regex = Some("机密.*".to_string()); + let label = rule_label(2, &rule); + assert_eq!(label, "正则窗口规则 #3(WeChat.exe)"); + assert!(!label.contains("机密"), "正则本体也可能含标题片段,不写出"); + } + #[test] fn other_hotkey_failures_report_the_error_code_instead_of_guessing() { // 非 1409 的失败不应谎称「已被占用」,而应带出真实错误码。 diff --git a/crates/core/src/audio.rs b/crates/core/src/audio.rs index c605e5a..b521f58 100644 --- a/crates/core/src/audio.rs +++ b/crates/core/src/audio.rs @@ -17,7 +17,11 @@ pub fn set_mute(pid: u32, mute: bool) { let hr = CoInitializeEx(None, COINIT_MULTITHREADED); let should_uninit = hr.is_ok(); if let Err(e) = mute_matching_sessions(pid, mute) { - eprintln!("设置静音失败 (pid={pid}): {e}"); + let action = if mute { "静音" } else { "取消静音" }; + crate::log_warn!( + "{action}失败,该进程的声音未受影响 (pid={pid}): {}", + crate::util::win_err(&e) + ); } if should_uninit { CoUninitialize(); @@ -68,7 +72,17 @@ pub fn is_audio_playing() -> bool { unsafe { let hr = CoInitializeEx(None, COINIT_MULTITHREADED); let should_uninit = hr.is_ok(); - let playing = any_active_session().unwrap_or(false); + // 查不出播放状态时按「没在播放」处理。 + let playing = match any_active_session() { + Ok(playing) => playing, + Err(e) => { + crate::logging::debug(&format!( + "枚举音频会话失败,本次按「无音频播放」处理,不发送媒体暂停键: {}", + crate::util::win_err(&e) + )); + false + } + }; if should_uninit { CoUninitialize(); } diff --git a/crates/core/src/effects.rs b/crates/core/src/effects.rs index 3627f4d..bf664b0 100644 --- a/crates/core/src/effects.rs +++ b/crates/core/src/effects.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use crate::{audio, freeze, input, log_warn, logging}; @@ -25,11 +26,33 @@ const FREEZE_SETTLE_DELAY: Duration = Duration::from_millis(200); pub struct WinEffects { exe_dir: PathBuf, + /// 「已开增强冻结但缺 pssuspend」是否已记过,每次运行只记一条。 + missing_tool_logged: AtomicBool, } impl WinEffects { pub fn new(exe_dir: PathBuf) -> Self { - Self { exe_dir } + Self { + exe_dir, + missing_tool_logged: AtomicBool::new(false), + } + } + + /// 增强冻结是否可用;因缺少 pssuspend 而不可用时,每次运行提醒一次。 + fn enhanced_ready(&self, enhanced: bool) -> bool { + if !enhanced { + return false; + } + if freeze::pssuspend_available(&self.exe_dir) { + return true; + } + if !self.missing_tool_logged.swap(true, Ordering::Relaxed) { + log_warn!( + "已启用增强冻结,但核心所在目录下没有 {},本次运行一律改用普通冻结", + freeze::PSSUSPEND_EXE + ); + } + false } } @@ -43,7 +66,7 @@ impl Effects for WinEffects { } fn suspend(&self, pid: u32, enhanced: bool) { - if enhanced && freeze::pssuspend_available(&self.exe_dir) { + if self.enhanced_ready(enhanced) { match freeze::suspend_enhanced(&self.exe_dir, pid) { Ok(()) => { logging::debug(&format!("增强冻结成功 (pid={pid})")); @@ -59,7 +82,7 @@ impl Effects for WinEffects { } fn resume(&self, pid: u32, enhanced: bool) { - if enhanced && freeze::pssuspend_available(&self.exe_dir) { + if self.enhanced_ready(enhanced) { match freeze::resume_enhanced(&self.exe_dir, pid) { Ok(()) => { logging::debug(&format!("增强解冻成功 (pid={pid})")); diff --git a/crates/core/src/effects_worker.rs b/crates/core/src/effects_worker.rs index 4afe541..d45069b 100644 --- a/crates/core/src/effects_worker.rs +++ b/crates/core/src/effects_worker.rs @@ -17,6 +17,21 @@ enum Task { Quit, } +impl Task { + /// 日志里指代该任务的写法,含目标进程。 + fn describe(&self) -> String { + match self { + Task::Mute { pid, mute: true } => format!("静音 (pid={pid})"), + Task::Mute { pid, mute: false } => format!("取消静音 (pid={pid})"), + Task::SettleBeforeFreeze => "冻结前静置".to_string(), + Task::Suspend { pid, enhanced } => format!("冻结 (pid={pid}, 增强={enhanced})"), + Task::Resume { pid, enhanced } => format!("解冻 (pid={pid}, 增强={enhanced})"), + Task::SendPause => "发送媒体暂停键".to_string(), + Task::Quit => "结束副作用线程".to_string(), + } + } +} + /// 副作用线程句柄。`shutdown` 排干队列后退出,保证退出前解冻 / 取消静音已生效。 pub struct EffectsWorker { tx: Sender, @@ -64,7 +79,9 @@ impl EffectsWorker { let deadline = Instant::now() + timeout; while !handle.is_finished() { if Instant::now() >= deadline { - log_warn!("副作用线程未在 {timeout:?} 内排干队列,放弃等待"); + log_warn!( + "副作用线程未在 {timeout:?} 内排干队列,放弃等待;本次退出可能残留未解冻或未取消静音的进程" + ); return; } std::thread::sleep(Duration::from_millis(10)); @@ -81,8 +98,11 @@ pub struct AsyncEffects { impl AsyncEffects { fn send(&self, task: Task) { - if self.tx.send(task).is_err() { - log_error!("副作用线程已退出,本次副作用未执行"); + if let Err(e) = self.tx.send(task) { + log_error!( + "副作用线程已退出,以下副作用未执行,相关进程可能仍处于静音或冻结状态: {}", + e.0.describe() + ); } } } diff --git a/crates/core/src/freeze.rs b/crates/core/src/freeze.rs index 5f2a114..8cae48e 100644 --- a/crates/core/src/freeze.rs +++ b/crates/core/src/freeze.rs @@ -6,7 +6,7 @@ use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress}; use windows::Win32::System::Threading::{OpenProcess, PROCESS_SUSPEND_RESUME}; use windows::core::{PCSTR, s, w}; -const PSSUSPEND_EXE: &str = "pssuspend64.exe"; +pub const PSSUSPEND_EXE: &str = "pssuspend64.exe"; type NtProc = unsafe extern "system" fn(HANDLE) -> i32; diff --git a/crates/core/src/hide.rs b/crates/core/src/hide.rs index 537a553..7100a67 100644 --- a/crates/core/src/hide.rs +++ b/crates/core/src/hide.rs @@ -39,16 +39,14 @@ impl Target { } } - /// 日志用的一行摘要:`进程名「标题」(hwnd=…, pid=…)`。 + /// 日志用的一行摘要:`进程名(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 - ) + format!("{process}(hwnd={}, pid={})", self.hwnd, self.pid) } } @@ -664,7 +662,11 @@ mod tests { 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)"); + assert_eq!( + targets[0].describe(), + "WeChat.exe(hwnd=10, pid=10)", + "日志摘要不带窗口标题" + ); } #[test] diff --git a/crates/core/src/input_hooks.rs b/crates/core/src/input_hooks.rs index cca4860..29ea92c 100644 --- a/crates/core/src/input_hooks.rs +++ b/crates/core/src/input_hooks.rs @@ -161,8 +161,14 @@ impl InputHooks { DispatchMessageW(&msg); } } - }) - .ok()?; + }); + let thread = match thread { + Ok(thread) => thread, + Err(e) => { + crate::log_error!("创建输入钩子线程失败,鼠标绑定与「不传递」热键将不可用: {e}"); + return None; + } + }; match rx.recv() { Ok(hwnd) if hwnd != 0 => Some(InputHooks { diff --git a/crates/core/src/ipc_server.rs b/crates/core/src/ipc_server.rs index 5964abc..aa5eb2f 100644 --- a/crates/core/src/ipc_server.rs +++ b/crates/core/src/ipc_server.rs @@ -125,12 +125,24 @@ where let err = std::io::Error::last_os_error(); let delay = retry_delay(failures); failures += 1; - log_error!( - "创建命名管道失败(第 {failures} 次),{delay:?} 后重试: {pipe_name} — {err}" - ); + // 首次失败记 error,后续重试记 debug。 + if failures == 1 { + log_error!( + "创建命名管道失败,配置程序将无法连接核心,{delay:?} 后重试: {pipe_name} — {err}" + ); + } else { + crate::logging::debug(&format!( + "创建命名管道失败(第 {failures} 次),{delay:?} 后重试: {pipe_name} — {err}" + )); + } std::thread::sleep(delay); continue; } + if failures > 0 { + crate::logging::warn(&format!( + "命名管道重试 {failures} 次后创建成功,配置程序已可连接" + )); + } failures = 0; if !is_client_connected(unsafe { ConnectNamedPipe(handle, None) }) { @@ -165,9 +177,16 @@ where } let response = match Command::from_line(&line) { Ok(cmd) => executor(cmd), - Err(e) => Response::Error { - message: format!("无法解析命令: {e}"), - }, + Err(e) => { + // 命令内容来路不明,只记开头一小段。 + crate::log_warn!( + "收到无法解析的 IPC 命令,已忽略: {} — {e}", + crate::util::head_chars(&line, 120) + ); + Response::Error { + message: format!("无法解析命令: {e}"), + } + } }; let Ok(mut out) = response.to_line() else { break; diff --git a/crates/core/src/logging.rs b/crates/core/src/logging.rs index 8245cc1..db39649 100644 --- a/crates/core/src/logging.rs +++ b/crates/core/src/logging.rs @@ -1,20 +1,28 @@ //! 分级文件日志 + panic 钩子。 //! -//! 按天切割(`BossKey-YYYY-MM-DD.log`)、按天保留、分级输出(release 丢弃 DEBUG)。 +//! 按天切割(`BossKey-YYYY-MM-DD.log`)、按天保留,按用户所选的 +//! [输出等级](bosskey_common::config::LOG_LEVELS)过滤。 +//! +//! 写入前统一脱敏(见 [`redact_user_dir`]);调用方不得把窗口标题一类的内容交给日志。 use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::PathBuf; +use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Mutex, OnceLock}; +use bosskey_common::config; use windows::Win32::System::SystemInformation::GetLocalTime; pub const LOG_DIR_NAME: &str = "logs"; /// 日志文件名前缀;面向用户,用品牌大小写。 const LOG_FILE_PREFIX: &str = "BossKey-"; const LOG_FILE_SUFFIX: &str = ".log"; +/// 用户目录在日志中的替代写法。 +const USER_DIR_PLACEHOLDER: &str = "%USERPROFILE%"; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// 日志等级,按严重程度递增排序。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Level { Debug, Info, @@ -31,6 +39,42 @@ impl Level { Level::Error => "ERROR", } } + + /// 配置文件里的写法。 + pub fn as_config_str(self) -> &'static str { + match self { + Level::Debug => config::LOG_LEVEL_DEBUG, + Level::Info => config::LOG_LEVEL_INFO, + Level::Warn => config::LOG_LEVEL_WARN, + Level::Error => config::LOG_LEVEL_ERROR, + } + } + + /// 解析配置里的等级取值,无法识别时回落默认等级。 + pub fn from_config(value: &str) -> Self { + match config::normalize_log_level(value).as_str() { + config::LOG_LEVEL_DEBUG => Level::Debug, + config::LOG_LEVEL_INFO => Level::Info, + config::LOG_LEVEL_ERROR => Level::Error, + _ => Level::Warn, + } + } +} + +/// 会话标记的类型:每次运行各一条,不受输出等级过滤。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Marker { + Start, + Exit, +} + +impl Marker { + fn as_str(self) -> &'static str { + match self { + Marker::Start => "START", + Marker::Exit => "EXIT", + } + } } pub struct Logger { @@ -49,6 +93,11 @@ impl Logger { } pub fn log(&self, level: Level, message: &str) { + self.write(level.as_str(), message); + } + + /// 写一条带标签的记录,落盘前统一脱敏。 + fn write(&self, tag: &str, message: &str) { let now = unsafe { GetLocalTime() }; let entry = format_entry( &format_timestamp( @@ -59,8 +108,8 @@ impl Logger { now.wMinute, now.wSecond, ), - level, - message, + tag, + &redact_user_dir(message, user_dir()), ); let path = self .dir @@ -131,8 +180,146 @@ fn is_expired(name: &str, today: i64, retention_days: u32) -> bool { age >= retention_days as i64 } -fn format_entry(timestamp: &str, level: Level, message: &str) -> String { - format!("{timestamp} [{}] {message}\n", level.as_str()) +fn format_entry(timestamp: &str, tag: &str, message: &str) -> String { + format!("{timestamp} [{tag}] {message}\n") +} + +/// 会话起始行的标志串,形如 `[START]`。 +fn start_mark() -> String { + format!("[{}]", Marker::Start.as_str()) +} + +/// 摘录里省略中间部分时插入的说明。 +const OMISSION_MARK: &str = "……(超出上报长度,已省略中间 {n} 行)"; + +/// 最近一次会话在该文件中的部分:最后一个 `[START]` 行到文件末尾。 +/// 没有会话标记(如本次运行前的旧格式日志)时返回 `None`。 +fn session_excerpt(content: &str) -> Option<&str> { + let mark = start_mark(); + let mut start = None; + let mut offset = 0; + for line in content.split_inclusive('\n') { + if line.contains(&mark) { + start = Some(offset); + } + offset += line.len(); + } + Some(&content[start?..]) +} + +/// 把摘录压到 `max_bytes` 以内:保留首行(会话标记,含版本与数据目录)与末尾若干行, +/// 中间以 [`OMISSION_MARK`] 说明省略了多少行。`max_bytes` 为 0 时不做限制。 +fn fit_within(excerpt: &str, max_bytes: usize) -> String { + if max_bytes == 0 || excerpt.len() <= max_bytes { + return excerpt.to_string(); + } + let lines: Vec<&str> = excerpt.lines().collect(); + let head = lines.first().copied().unwrap_or_default(); + // 预留首行与省略说明的位置,其余预算留给末尾。 + let reserved = head.len() + OMISSION_MARK.len() + 2; + let mut tail: Vec<&str> = Vec::new(); + let mut used = 0; + for line in lines.iter().skip(1).rev() { + let cost = line.len() + 1; + if reserved + used + cost > max_bytes { + break; + } + used += cost; + tail.push(line); + } + tail.reverse(); + let omitted = lines.len().saturating_sub(1 + tail.len()); + let mut out = String::with_capacity(max_bytes); + out.push_str(head); + out.push('\n'); + if omitted > 0 { + out.push_str(&OMISSION_MARK.replace("{n}", &omitted.to_string())); + out.push('\n'); + } + out.push_str(&tail.join("\n")); + out +} + +/// 按日期从新到旧列出日志文件路径。 +fn log_files_newest_first(dir: &std::path::Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut files: Vec<(i64, PathBuf)> = entries + .flatten() + .filter_map(|e| { + let name = e.file_name(); + let (y, m, d) = parse_log_date(name.to_str()?)?; + Some((days_from_civil(y, m, d), e.path())) + }) + .collect(); + files.sort_by(|a, b| b.0.cmp(&a.0)); + files.into_iter().map(|(_, path)| path).collect() +} + +/// 跨零点的会话最多回溯几个日志文件。 +const SESSION_LOOKBACK_FILES: usize = 2; + +/// 最近一次运行的日志:从该次运行的 `[START]` 起到目前为止,压到 `max_bytes` 以内。 +/// 会话跨零点时向前回溯至多 [`SESSION_LOOKBACK_FILES`] 个文件。 +pub fn latest_session(dir: &std::path::Path, max_bytes: usize) -> String { + let files = log_files_newest_first(dir); + let mut parts: Vec = Vec::new(); + for path in files.iter().take(SESSION_LOOKBACK_FILES) { + let Ok(content) = fs::read_to_string(path) else { + continue; + }; + if let Some(excerpt) = session_excerpt(&content) { + parts.push(excerpt.to_string()); + return fit_within(&join_parts(parts), max_bytes); + } + // 文件里没有起始标记:整个文件都属于更早开始的那次运行。 + parts.push(content); + } + fit_within(&join_parts(parts), max_bytes) +} + +/// 把片段按从旧到新拼接;入参为从新到旧。 +fn join_parts(mut parts: Vec) -> String { + parts.reverse(); + parts + .iter() + .map(|p| p.trim_end_matches('\n')) + .filter(|p| !p.is_empty()) + .collect::>() + .join("\n") +} + +/// 当前用户目录;取不到(或为空)时不做替换。 +fn user_dir() -> &'static str { + static USER_DIR: OnceLock = OnceLock::new(); + USER_DIR.get_or_init(|| std::env::var("USERPROFILE").unwrap_or_default()) +} + +/// 把消息里的用户目录换成 [`USER_DIR_PLACEHOLDER`],避免日志上传时带出用户名。 +/// 大小写按 ASCII 规则忽略(Windows 路径的大小写差异只出现在 ASCII 部分)。 +fn redact_user_dir(message: &str, user_dir: &str) -> String { + if user_dir.is_empty() || message.len() < user_dir.len() { + return message.to_string(); + } + let bytes = message.as_bytes(); + let needle = user_dir.as_bytes(); + let mut out = String::with_capacity(message.len()); + let mut i = 0; + while i < bytes.len() { + if bytes.len() - i >= needle.len() + && bytes[i..i + needle.len()].eq_ignore_ascii_case(needle) + { + out.push_str(USER_DIR_PLACEHOLDER); + i += needle.len(); + continue; + } + // 按字符推进,命中位置必定落在字符边界上。 + let ch = message[i..].chars().next().unwrap_or('\u{fffd}'); + out.push(ch); + i += ch.len_utf8(); + } + out } fn format_timestamp( @@ -147,9 +334,12 @@ fn format_timestamp( } static GLOBAL: OnceLock = OnceLock::new(); +/// 当前输出等级,存 [`Level`] 的判别序号;默认与配置默认值一致。 +static LEVEL: AtomicU8 = AtomicU8::new(Level::Warn as u8); /// 初始化全局日志;`retention_days == 0` 表示关闭日志。 -pub fn init(dir: PathBuf, retention_days: u32) { +pub fn init(dir: PathBuf, retention_days: u32, level: Level) { + set_level(level); if retention_days == 0 { return; } @@ -158,9 +348,37 @@ pub fn init(dir: PathBuf, retention_days: u32) { let _ = GLOBAL.set(logger); } -/// 该级别是否应输出:生产(release)构建丢弃 DEBUG。 +/// 调整输出等级;配置热重载时调用。 +pub fn set_level(level: Level) { + LEVEL.store(level as u8, Ordering::Relaxed); +} + +pub fn level() -> Level { + match LEVEL.load(Ordering::Relaxed) { + v if v == Level::Debug as u8 => Level::Debug, + v if v == Level::Info as u8 => Level::Info, + v if v == Level::Error as u8 => Level::Error, + _ => Level::Warn, + } +} + +/// 该级别是否应输出:低于当前输出等级的一律丢弃。 fn should_emit(level: Level) -> bool { - level != Level::Debug || cfg!(debug_assertions) + level >= self::level() +} + +/// 会话起始标记:不受输出等级影响,每次运行一条。 +pub fn session_start(message: &str) { + if let Some(logger) = GLOBAL.get() { + logger.write(Marker::Start.as_str(), message); + } +} + +/// 会话结束标记:不受输出等级影响。日志末尾没有它即上次未正常退出。 +pub fn session_exit(message: &str) { + if let Some(logger) = GLOBAL.get() { + logger.write(Marker::Exit.as_str(), message); + } } pub fn debug(message: &str) { @@ -258,7 +476,7 @@ mod tests { #[test] fn entry_contains_level_and_message() { - let entry = format_entry("2026-07-13 10:00:00", Level::Warn, "热键注册失败"); + let entry = format_entry("2026-07-13 10:00:00", Level::Warn.as_str(), "热键注册失败"); assert_eq!(entry, "2026-07-13 10:00:00 [WARN] 热键注册失败\n"); } @@ -348,19 +566,200 @@ mod tests { } #[test] - fn debug_is_only_emitted_in_dev_builds() { - assert!(should_emit(Level::Info)); - assert!(should_emit(Level::Warn)); - assert_eq!(should_emit(Level::Debug), cfg!(debug_assertions)); + fn level_orders_by_severity() { + assert!(Level::Debug < Level::Info); + assert!(Level::Info < Level::Warn); + assert!(Level::Warn < Level::Error); + } + + #[test] + fn level_parses_config_values() { + assert_eq!(Level::from_config("debug"), Level::Debug); + assert_eq!(Level::from_config("INFO"), Level::Info); + assert_eq!(Level::from_config("warning"), Level::Warn); + assert_eq!(Level::from_config("error"), Level::Error); + assert_eq!( + Level::from_config("胡说"), + Level::Warn, + "未知取值回落默认等级" + ); + assert_eq!( + Level::from_config(bosskey_common::config::DEFAULT_LOG_LEVEL), + Level::Warn, + "默认等级即「仅记录警告及以上」" + ); + } + + /// 修改全局等级的测试串行执行,避免相互干扰。 + fn with_level(level: Level, body: impl FnOnce() -> T) -> T { + static SERIAL: Mutex<()> = Mutex::new(()); + let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + let previous = self::level(); + set_level(level); + let out = body(); + set_level(previous); + out + } + + #[test] + fn default_level_drops_info_and_debug() { + with_level(Level::Warn, || { + assert!(!should_emit(Level::Debug)); + assert!(!should_emit(Level::Info), "默认等级下 INFO 不记录"); + assert!(should_emit(Level::Warn)); + assert!(should_emit(Level::Error)); + }); + } + + #[test] + fn lowering_level_lets_details_through() { + with_level(Level::Debug, || { + assert!(should_emit(Level::Debug)); + assert!(should_emit(Level::Info)); + }); + with_level(Level::Error, || { + assert!(!should_emit(Level::Warn), "只记错误时连警告都丢弃"); + assert!(should_emit(Level::Error)); + }); } #[test] fn init_with_zero_retention_disables_logging() { let dir = temp_dir(); - init(dir.join("logs"), 0); + init(dir.join("logs"), 0, Level::Warn); assert!(!dir.join("logs").exists(), "关闭日志时不应创建目录"); } + #[test] + fn session_markers_bypass_level_filter() { + let dir = temp_dir(); + let logger = Logger::new(dir.clone(), 7); + logger.write(Marker::Start.as_str(), "核心启动 9.9.9"); + logger.write(Marker::Exit.as_str(), "核心正常退出"); + let logs: Vec<_> = fs::read_dir(&dir).unwrap().flatten().collect(); + let content = fs::read_to_string(logs[0].path()).unwrap(); + assert!(content.contains("[START] 核心启动 9.9.9")); + assert!(content.contains("[EXIT] 核心正常退出")); + } + + #[test] + fn session_excerpt_starts_at_the_last_start_marker() { + let content = "\ +2026-07-29 10:00:00 [START] 核心启动 3.0.0 +2026-07-29 10:00:01 [WARN] 上一次运行的警告 +2026-07-29 10:05:00 [EXIT] 核心正常退出 +2026-07-29 11:00:00 [START] 核心启动 3.1.0 +2026-07-29 11:00:02 [ERROR] 本次运行的错误 +"; + let excerpt = session_excerpt(content).expect("应找到会话起始标记"); + assert!(excerpt.starts_with("2026-07-29 11:00:00 [START] 核心启动 3.1.0")); + assert!(excerpt.contains("本次运行的错误")); + assert!(!excerpt.contains("上一次运行的警告"), "不应带上更早的运行"); + assert_eq!(session_excerpt("没有任何标记的旧日志\n"), None); + } + + #[test] + fn latest_session_spans_midnight_and_skips_older_runs() { + let dir = temp_dir(); + fs::write( + dir.join("BossKey-2026-07-28.log"), + "2026-07-28 08:00:00 [START] 更早的一次运行\n\ + 2026-07-28 09:00:00 [EXIT] 核心正常退出\n\ + 2026-07-28 23:59:00 [START] 跨零点这次运行\n\ + 2026-07-28 23:59:30 [WARN] 零点前的警告\n", + ) + .unwrap(); + // 当天文件里没有起始标记:本次运行是昨晚开始的。 + fs::write( + dir.join("BossKey-2026-07-29.log"), + "2026-07-29 00:00:10 [ERROR] 零点后的错误\n", + ) + .unwrap(); + fs::write(dir.join("keep.txt"), "不是日志").unwrap(); + + let session = latest_session(&dir, 0); + assert!(session.starts_with("2026-07-28 23:59:00 [START] 跨零点这次运行")); + assert!(session.contains("零点前的警告")); + assert!(session.contains("零点后的错误"), "应续上当天的记录"); + assert!(!session.contains("更早的一次运行")); + } + + #[test] + fn latest_session_falls_back_when_no_marker_exists() { + let dir = temp_dir(); + fs::write( + dir.join("BossKey-2026-07-29.log"), + "2026-07-29 08:00:00 [WARN] 旧格式日志,没有会话标记\n", + ) + .unwrap(); + let session = latest_session(&dir, 0); + assert!( + session.contains("旧格式日志"), + "取不到会话标记时仍应上报已有内容" + ); + assert_eq!(latest_session(&temp_dir(), 0), "", "没有日志文件时为空"); + } + + #[test] + fn oversized_session_keeps_the_marker_and_the_tail() { + let mut content = String::from("2026-07-29 10:00:00 [START] 核心启动 3.1.0\n"); + for i in 0..200 { + content.push_str(&format!("2026-07-29 10:00:{i:02} [DEBUG] 第 {i} 行流水\n")); + } + content.push_str("2026-07-29 10:30:00 [ERROR] 最后的错误\n"); + + let fitted = fit_within(&content, 600); + assert!(fitted.len() <= 600, "应压到预算以内: {}", fitted.len()); + assert!( + fitted.starts_with("2026-07-29 10:00:00 [START] 核心启动 3.1.0"), + "首行的版本信息须保留" + ); + assert!(fitted.contains("最后的错误"), "末尾的现场须保留"); + assert!(fitted.contains("已省略中间"), "省略须写明"); + assert_eq!(fit_within("短内容", 600), "短内容", "不超预算时原样返回"); + } + + #[test] + fn user_dir_is_redacted_before_writing() { + assert_eq!( + redact_user_dir( + r"配置: C:\Users\张三\AppData\Roaming\BossKey", + r"C:\Users\张三" + ), + r"配置: %USERPROFILE%\AppData\Roaming\BossKey" + ); + assert_eq!( + redact_user_dir(r"c:\users\Ivan\logs", r"C:\Users\Ivan"), + r"%USERPROFILE%\logs", + "路径大小写差异不应漏掉" + ); + assert_eq!( + redact_user_dir("无关内容", r"C:\Users\Ivan"), + "无关内容", + "不含用户目录的消息原样保留" + ); + assert_eq!(redact_user_dir("短", ""), "短", "取不到用户目录时不替换"); + } + + #[test] + fn logger_redacts_user_dir_on_disk() { + let dir = temp_dir(); + let logger = Logger::new(dir.clone(), 7); + // 脱敏取真实环境变量,此处验证写盘路径上已调用脱敏。 + let profile = user_dir().to_string(); + if profile.is_empty() { + return; + } + logger.log(Level::Error, &format!("读取失败: {profile}\\config.json")); + let logs: Vec<_> = fs::read_dir(&dir).unwrap().flatten().collect(); + let content = fs::read_to_string(logs[0].path()).unwrap(); + assert!( + !content.contains(&profile), + "日志不应落下用户目录: {content}" + ); + assert!(content.contains("%USERPROFILE%\\config.json")); + } + #[test] fn warn_at_appends_source_location() { let dir = temp_dir(); diff --git a/crates/core/src/main.rs b/crates/core/src/main.rs index 137d01c..aee56fe 100644 --- a/crates/core/src/main.rs +++ b/crates/core/src/main.rs @@ -9,6 +9,8 @@ use bosskey_core::logging; use bosskey_core::single_instance::SingleInstance; const MUTEX_NAME: &str = "BossKey_SingleInstance_Mutex"; +/// 提权重启时等待前一个实例退出的上限。 +const ELEVATED_HANDOVER_WAIT: Duration = Duration::from_secs(4); fn main() { // 数据目录只定位一次,配置、日志、恢复文件共用,避免两次定位得出不同结果。 @@ -16,20 +18,28 @@ fn main() { let data_dir = located.dir.clone(); let config_path = data_dir.join(paths::CONFIG_FILE_NAME); - // 日志与 panic 钩子最先就位。日志保留天数取自配置(0 = 关闭日志)。 - let retention_days = bosskey_common::Config::load(&config_path) - .map(|c| c.setting.log_retention_days) - .unwrap_or(bosskey_common::config::DEFAULT_LOG_RETENTION_DAYS); - logging::init(data_dir.join(logging::LOG_DIR_NAME), retention_days); + // 日志与 panic 钩子最先就位。保留天数与输出等级取自配置(0 天 = 关闭日志)。 + let (retention_days, log_level) = bosskey_common::Config::load(&config_path) + .map(|c| (c.setting.log_retention_days, c.setting.log_level)) + .unwrap_or_else(|_| { + ( + bosskey_common::config::DEFAULT_LOG_RETENTION_DAYS, + bosskey_common::config::DEFAULT_LOG_LEVEL.to_string(), + ) + }); + let log_level = logging::Level::from_config(&log_level); + logging::init( + data_dir.join(logging::LOG_DIR_NAME), + retention_days, + log_level, + ); logging::install_panic_hook(); - logging::info(&format!( - "核心启动 {}(配置 schema {})", + // 会话起始标记:不受输出等级过滤,每次启动一条。 + logging::session_start(&format!( + "核心启动 {}(配置 schema {},日志等级 {})|数据目录: {}({})", bosskey_common::APP_VERSION, - bosskey_common::APP_CONFIG_VERSION - )); - // 数据究竟落在哪里是排查读写失败的第一手信息,每次启动都记一笔。 - logging::info(&format!( - "数据目录: {}({})", + bosskey_common::APP_CONFIG_VERSION, + log_level.as_config_str(), data_dir.display(), match located.kind { paths::DataDirKind::Installed => "安装版", @@ -50,12 +60,19 @@ fn main() { // 以管理员身份重启时,等待旧实例释放互斥后再接管。 let elevated_restart = args.iter().any(|a| a == "elevated"); let instance = if elevated_restart { - SingleInstance::acquire_waiting(MUTEX_NAME, Duration::from_secs(4)) + logging::debug("以管理员身份重启,等待前一个实例释放单实例互斥体"); + SingleInstance::acquire_waiting(MUTEX_NAME, ELEVATED_HANDOVER_WAIT) } else { SingleInstance::acquire(MUTEX_NAME) }; if instance.already_running() { - logging::warn("已有核心实例在运行,本次启动退出"); + if elevated_restart { + logging::warn(&format!( + "以管理员身份重启失败:等待 {ELEVATED_HANDOVER_WAIT:?} 后前一个实例仍在运行,本次启动退出,核心仍以原权限运行" + )); + } else { + logging::warn("已有核心实例在运行,本次启动退出"); + } return; } diff --git a/crates/core/src/recovery.rs b/crates/core/src/recovery.rs index a0934b3..ac65a9d 100644 --- a/crates/core/src/recovery.rs +++ b/crates/core/src/recovery.rs @@ -106,11 +106,15 @@ pub fn load(path: &Path) -> Option { Ok(s) => s, Err(e) => { let corrupt = corrupt_path(path); - let _ = std::fs::rename(path, &corrupt); - log_warn!( - "恢复文件解析失败,本次不恢复;原文件已改名为 {}: {e}", - corrupt.display() - ); + // 改名保留现场;改名失败时如实说明。 + let kept = match std::fs::rename(path, &corrupt) { + Ok(()) => format!("原文件已改名为 {}", corrupt.display()), + Err(rename_err) => format!( + "原文件改名保留失败({rename_err}),仍在 {}", + path.display() + ), + }; + log_warn!("恢复文件解析失败,上次异常退出遗留的窗口本次不予恢复;{kept}: {e}"); return None; } }; @@ -128,8 +132,16 @@ fn corrupt_path(path: &Path) -> PathBuf { } /// 删除恢复文件(不存在时静默成功)。 +/// 除「文件本就不存在」外,删除失败都会记录。 pub fn clear(path: &Path) { - let _ = std::fs::remove_file(path); + if let Err(e) = std::fs::remove_file(path) + && e.kind() != std::io::ErrorKind::NotFound + { + log_warn!( + "删除恢复文件失败,下次启动可能误判为异常退出并重复执行恢复: {} — {e}", + path.display() + ); + } } #[cfg(test)] diff --git a/crates/core/src/tray.rs b/crates/core/src/tray.rs index d4e4e8c..ed1d6b9 100644 --- a/crates/core/src/tray.rs +++ b/crates/core/src/tray.rs @@ -50,6 +50,9 @@ pub(crate) fn load_app_icon() -> HICON { return icon; } + logging::warn( + "未能加载程序图标(exe 内嵌资源与同目录 icon.ico 均不可用),托盘改用系统默认图标", + ); unsafe { LoadIconW(None, IDI_APPLICATION).unwrap_or_default() } } @@ -124,7 +127,7 @@ impl TrayIcon { callback_msg, }; if !tray.try_add() { - logging::warn("托盘图标初始挂载失败(任务栏可能尚未就绪),将在任务栏就绪后自动补挂"); + logging::debug("托盘图标初始挂载失败(任务栏可能尚未就绪),将在任务栏就绪后自动补挂"); } tray } @@ -179,7 +182,7 @@ impl TrayIcon { self.visible = false; self.retry_attempts = 0; if self.desired && self.try_add() { - logging::info("任务栏已就绪,托盘图标已重新挂载"); + logging::debug("任务栏已就绪,托盘图标已重新挂载"); } } @@ -190,7 +193,7 @@ impl TrayIcon { } self.retry_attempts += 1; if self.try_add() { - logging::info("托盘图标重试挂载成功"); + logging::debug("托盘图标重试挂载成功"); return false; } if self.retry_attempts >= MAX_TRAY_RETRY { diff --git a/crates/core/src/util.rs b/crates/core/src/util.rs index c47bc65..e631018 100644 --- a/crates/core/src/util.rs +++ b/crates/core/src/util.rs @@ -64,6 +64,17 @@ pub fn win_err(e: &windows::core::Error) -> String { } } +/// 取字符串开头至多 `max` 个字符,其余以「…(共 N 字符)」代替。 +/// 按字符切,不会切碎多字节字符。 +pub fn head_chars(text: &str, max: usize) -> String { + let total = text.chars().count(); + if total <= max { + return text.to_string(); + } + let head: String = text.chars().take(max).collect(); + format!("{head}…(共 {total} 字符)") +} + /// 把系统 ANSI 代码页(CP_ACP,中文系统为 GBK/936)编码的字节解码成 `String`。 /// 用于读取只输出本地代码页文本的旧式控制台程序(如 pssuspend),避免直接按 /// UTF-8 解码得到乱码。空输入返回空串;解码失败退回 UTF-8 有损解码。 @@ -112,6 +123,17 @@ mod tests { assert!(!text.starts_with('('), "应同时带系统消息: {text}"); } + #[test] + fn head_chars_limits_length_without_breaking_characters() { + assert_eq!(head_chars("短内容", 10), "短内容", "不超长时原样返回"); + assert_eq!( + head_chars("中文中文中文", 3), + "中文中…(共 6 字符)", + "应按字符切并注明原长" + ); + assert_eq!(head_chars("", 5), ""); + } + #[test] fn win_err_without_message_still_reports_code() { // 自定义 HRESULT 通常没有对应的系统消息文本,此时不应只剩空串。 diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index b46d5c0..4057e81 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -96,7 +96,7 @@ Boss-Key/ │ autostart.rs 开机自启(计划任务 XML 含失败自动重启 + 注册表回退) │ elevation.rs 管理员检测 + UAC 提权重启 │ i18n.rs 核心用户可见文案 catalog(托盘菜单 / 气泡 / IPC 错误;日志不走它) -│ logging.rs 分级文件日志(logs/BossKey-YYYY-MM-DD.log 按天切割 + panic 钩子) +│ logging.rs 分级文件日志(按天切割 + 等级过滤 + 脱敏 + panic 钩子) │ recovery.rs 崩溃恢复(意图先行落盘 + 原子写;快照带开机时刻与 │ 进程创建时刻,跨重启的快照会被丢弃) │ icon.rs 进程图标提取(HICON → 手写 PNG/base64 编码) @@ -149,7 +149,7 @@ Boss-Key/ Tauri 按 `tauri.conf.json` 里的 identifier 把 WebView2 用户数据放在 `%LOCALAPPDATA%\cn.hanloth.bosskey.config`,不在数据目录里,也不由 `paths.rs` 管。安装包的卸载程序与便携版随包的 `scripts/cleanup.ps1` 都会清理它。 ::: -每次启动的实际数据目录与判定结果会写进日志首屏,排查读写失败先看它。 +每次启动的实际数据目录与判定结果会写进日志的 `[START]` 标记,排查读写失败先看它(路径中的用户目录已脱敏为 `%USERPROFILE%`)。 ## 核心内部:Agent 消息循环 @@ -186,12 +186,46 @@ agent 线程本身**不**提优先级:它干的是枚举 / 冻结 / 落盘这 ## 稳定性设计(崩溃自愈三层防线) -1. **崩溃日志**:关键事件与 panic 写入[数据目录](#数据目录)下的 `logs/BossKey-YYYY-MM-DD.log`(按天切割,按 `log_retention_days` 保留,0 表示关闭日志;release 构建丢弃 DEBUG 级)。 +1. **崩溃日志**:关键事件与 panic 写入[数据目录](#数据目录)下的 `logs/BossKey-YYYY-MM-DD.log`(按天切割,按 `log_retention_days` 保留,0 表示关闭日志;按 `log_level` 过滤,默认只记 WARN 及以上,详见[日志分级与脱敏](#日志分级与脱敏))。 2. **崩溃恢复**:隐藏动作执行前先把"将要隐藏 / 冻结 / 静音什么"写入 `recovery.json`(tmp + rename 原子替换),异常退出后重启自动找回;快照带开机时刻与进程创建时刻,跨重启的过期快照直接丢弃,不会对无关窗口 / 进程做恢复动作。 3. **看门狗**:计划任务 `RestartOnFailure`(崩溃后 1 分钟内重启,最多 3 次)。release 构建 `panic = "abort"`,panic 钩子写完日志后以非零码退出,正好触发计划任务重启。 用户视角的说明见 [窗口恢复与崩溃自愈](/guide/recovery)。 +## 日志分级与脱敏 + +`crates/core/src/logging.rs` 提供 `debug` / `info` / `warn` / `error` 四级与两条会话标记,写入前统一脱敏。用户在配置界面选择的 `log_level` 是**记录门槛**:低于它的日志直接丢弃,默认 `warn` 即只留警告与错误。等级在 `reload_config` 时即时生效,无需重启核心。 + +新增日志时按下表归类: + +| 级别 | 收什么 | 例子 | +| --- | --- | --- | +| `error` | 功能已不可用或数据有丢失风险,用户需要知道 | 代理窗口创建失败、配置解析失败回退默认、恢复文件写入失败 | +| `warn` | 降级但仍能用,或出现了用户会察觉的异常 | 热键注册失败、钩子安装失败、规则未匹配到窗口、检测到上次异常退出 | +| `info` | 每次运行至多一两条的里程碑 | 更新后首次启动并拉起配置程序 | +| `debug` | 逐次动作的流水与自愈过程,仅排查时才需要 | 每次隐藏 / 恢复的明细、热键注册成功、托盘图标补挂 | + +两条**会话标记**不受等级过滤,每次运行各一条:`[START]` 记录版本、配置 schema、生效等级与数据目录,`[EXIT]` 记录正常退出及其来由(关闭热键 / 托盘菜单 / 配置程序下发 / 冒烟计时)。日志末尾没有 `[EXIT]` 即上次是崩溃或被强杀;只有 `[START]` 能让一段上传上来的日志说清楚它出自哪个版本。 + +隐藏与恢复的记录带上**触发来源**(`Trigger`:热键、鼠标按键、四角、空闲、托盘、悬浮窗、配置程序)。用户反馈「窗口莫名其妙被隐藏」时,第一步就是分清是谁触发的。 + +### 上报只取本次运行 + +`logging::latest_session` 从最近一个 `[START]` 起截到当前,跨零点时向前回溯一个日志文件;配置程序的 `current_session_log` 命令即取这一段。不按固定末尾若干行取,是因为那样既会混进上几次运行的内容,又会漏掉本次运行开头的版本与数据目录。超出上报预算([`verhub::LOG_EXCERPT_MAX`])时保留首行与末尾,中间注明省略了多少行——首行是版本信息,末尾是出错现场,两头都不能丢。 + +::: warning 日志可能被用户上传 +配置界面的反馈功能会把日志发往 Verhub,因此日志里**不得出现窗口标题**(可能是文件名、聊天对象、网页标题),窗口一律以「进程名 + 句柄 + PID」指代,窗口规则以「序号 + 进程名」指代。用户目录由 `logging.rs` 统一替换为 `%USERPROFILE%`,无须各调用点自行处理。 +::: + +### error 信息 + +- **对象**:出问题的路径、PID、热键、管道名等,路径直接 `display()`(脱敏由日志层负责)。 +- **原因**:系统 API 用 `util::win_err`(消息 + 十六进制错误码),IO 与子进程错误直接带 `{e}`。 +- **后果**:哪个功能因此不可用、数据是否可能丢失,而不是只说「失败」。 +- **位置**:用 `log_error!` / `log_warn!` 宏,自动附上 `文件:行号`。 + +例:`重新加载配置失败,本次改动未生效,核心仍在用上一次加载的配置: <路径> — <原因> (agent.rs:123)`。 + ## 界面语言 核心与配置程序共用 `crates/common` 的 `Lang`(`zh-CN` / `en` / `zh-TW`)与语言偏好解析,文案则各自维护: diff --git a/docs/dev/config-reference.md b/docs/dev/config-reference.md index e45d937..8ec2ae7 100644 --- a/docs/dev/config-reference.md +++ b/docs/dev/config-reference.md @@ -63,12 +63,11 @@ Boss Key 的配置保存在 `config.json` 中,便携版存在程序目录, | `corner_fast_only` | bool | `true` | 仅快速移动触发 | | `allow_move_restore` | bool | `false` | 角落恢复 | | `log_retention_days` | number | `7` | [日志保留天数](/guide/options#日志保留天数)(0 = 关闭) | +| `log_level` | string | `"warn"` | [日志输出等级](/guide/options#日志输出等级):`debug`|`info`|`warn`|`error` | | `autostart_admin` | bool | `false` | [以管理员身份自启](/guide/autostart)(仅计划任务方式生效) | | `language` | string | `"auto"` | [界面语言](/guide/options#界面语言):`auto`|`zh-CN`|`en`|`zh-TW` | -::: info `language` 的取值与归一化 -`auto` 表示跟随系统显示语言。读取时会归一化:合法的 BCP-47 标签折叠为 `zh-CN`/`en`/`zh-TW`(如 `zh_TW`、`zh-Hant` → `zh-TW`;`en-US` → `en`),无对应翻译的值(如 `ja-JP`)一律回落为 `auto`。核心与配置程序共用该字段。 -::: + ::: details 旧版扁平鼠标开关(已废弃) `middle_button_hide` / `side_button1_hide` / `side_button2_hide` 仅用于反序列化迁移,迁移后清零、不再写回文件。请使用 `mouse` 结构。 diff --git a/docs/en/dev/architecture.md b/docs/en/dev/architecture.md index 5461dba..9a7d087 100644 --- a/docs/en/dev/architecture.md +++ b/docs/en/dev/architecture.md @@ -96,7 +96,7 @@ Boss-Key/ │ 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) +│ logging.rs Levelled file logging (daily rotation + level filter + redaction + panic hook) │ 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) @@ -150,7 +150,7 @@ Whenever the user folder is used, a `config.json` in the program folder is moved Following the identifier in `tauri.conf.json`, Tauri puts the WebView2 user data in `%LOCALAPPDATA%\cn.hanloth.bosskey.config`. It is not part of the data folder and is not managed by `paths.rs`. Both the installer's uninstaller and the `scripts/cleanup.ps1` shipped with the portable edition remove it. ::: -The data folder actually in use, and how it was chosen, are written to the log on every start; check that first when diagnosing read/write failures. +The data folder actually in use, and how it was chosen, are written to the log's `[START]` marker on every start; check that first when diagnosing read/write failures (the user folder in the path is redacted to `%USERPROFILE%`). ## Inside the core: the agent message loop @@ -187,12 +187,46 @@ When restoring (showing), every record is validated first: the handle must still ## Stability (three layers of crash self-healing) -1. **Crash logs**: key events and panics are written to `logs/BossKey-YYYY-MM-DD.log` in the [data folder](#data-folder) (rotated daily, retained per `log_retention_days`; 0 disables logging; release builds drop the DEBUG level). +1. **Crash logs**: key events and panics are written to `logs/BossKey-YYYY-MM-DD.log` in the [data folder](#data-folder) (rotated daily, retained per `log_retention_days`; 0 disables logging; filtered by `log_level`, which defaults to WARN and above — see [Log levels and redaction](#log-levels-and-redaction)). 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). +## Log levels and redaction + +`crates/core/src/logging.rs` provides four levels — `debug` / `info` / `warn` / `error` — plus two session markers, and redacts every entry before it is written. The `log_level` the user picks in the settings program is the **recording threshold**: anything below it is dropped, and the default `warn` keeps only warnings and errors. The level is applied on `reload_config`, so it takes effect without restarting the core. + +Classify new log entries as follows: + +| Level | What belongs here | Examples | +| --- | --- | --- | +| `error` | A feature is unavailable or data may be lost, and the user needs to know | Agent window creation failed, config parsing fell back to defaults, the recovery file could not be written | +| `warn` | Degraded but still usable, or an anomaly the user will notice | Hotkey registration failed, a hook could not be installed, a rule matched no window, an unclean shutdown was detected | +| `info` | Milestones that occur at most once or twice per run | First launch after an update, which also opens the settings program | +| `debug` | Per-action activity and self-healing steps, needed only while troubleshooting | Details of each hide/restore, successful hotkey registration, tray icon re-attachment | + +The two **session markers** bypass the level filter, one of each per run: `[START]` records the version, config schema, effective level and data folder; `[EXIT]` records a clean exit and what caused it (close hotkey, tray menu, a quit command from the settings program, the smoke-test timer). A log that ends without `[EXIT]` means the previous run crashed or was killed, and `[START]` is the only thing that tells you which build an uploaded log excerpt came from. + +Hide and restore entries name their **trigger** (`Trigger`: hotkey, mouse button, screen corner, idle timer, tray, floating window, settings program). When a user reports that "windows disappeared out of nowhere", telling these apart is the first step. + +### Uploads cover the current run only + +`logging::latest_session` takes everything from the most recent `[START]` up to now, looking back one more log file when the run spans midnight; the settings program's `current_session_log` command returns exactly that. A fixed number of trailing lines is not used because it both mixes in earlier runs and drops the version and data folder recorded at the start of this one. When the excerpt exceeds the upload budget ([`verhub::LOG_EXCERPT_MAX`]), the first line and the tail are kept and the number of omitted lines is stated — the first line carries the version, the tail carries the failure, and neither can be lost. + +::: warning Logs may be uploaded by users +The feedback feature in the settings program sends log lines to Verhub, so logs must **never contain window titles** (which may be file names, contacts, or page titles). Windows are referred to by process name, handle and PID; window rules by index and process name. The user folder is replaced with `%USERPROFILE%` centrally in `logging.rs`, so call sites do not need to handle it. +::: + +### What an error entry carries + +- **Subject**: the path, PID, hotkey or pipe name involved; paths go in via `display()` (redaction is the logging layer's job). +- **Cause**: `util::win_err` for Windows APIs (message plus hex code), `{e}` for IO and child-process errors. +- **Consequence**: which feature is now unavailable, or whether data may be lost — not merely that something "failed". +- **Location**: use the `log_error!` / `log_warn!` macros, which append `file:line` automatically. + +For example: `重新加载配置失败,本次改动未生效,核心仍在用上一次加载的配置: (agent.rs:123)`. + ## Display language The core and the settings program share `Lang` (`zh-CN` / `en` / `zh-TW`) and preference resolution from `crates/common`, but maintain their strings separately: diff --git a/docs/en/dev/config-reference.md b/docs/en/dev/config-reference.md index 376f056..69c6466 100644 --- a/docs/en/dev/config-reference.md +++ b/docs/en/dev/config-reference.md @@ -63,13 +63,10 @@ The settings window reads and writes the configuration automatically. This page | `corner_fast_only` | bool | `true` | Only trigger on fast movement | | `allow_move_restore` | bool | `false` | Restore from a corner | | `log_retention_days` | number | `7` | [Log retention](/en/guide/options) (0 = off) | +| `log_level` | string | `"warn"` | [Log level](/en/guide/options): `debug` \| `info` \| `warn` \| `error` | | `autostart_admin` | bool | `false` | [Start as administrator](/en/guide/autostart) (scheduled-task method only) | | `language` | string | `"auto"` | [Display language](/en/guide/options): `auto` \| `zh-CN` \| `en` \| `zh-TW` | -::: info Values and normalisation of `language` -`auto` follows the system display language. Values are normalised on read: valid BCP-47 tags collapse to `zh-CN` / `en` / `zh-TW` (for example `zh_TW` and `zh-Hant` → `zh-TW`; `en-US` → `en`), and values with no matching translation (such as `ja-JP`) fall back to `auto`. The core and the settings program share this field. -::: - ::: details Legacy flat mouse switches (deprecated) `middle_button_hide` / `side_button1_hide` / `side_button2_hide` exist only for deserialisation and migration; they are cleared afterwards and never written back. Use the `mouse` structure instead. ::: diff --git a/docs/en/guide/options.md b/docs/en/guide/options.md index ddddfb7..6917687 100644 --- a/docs/en/guide/options.md +++ b/docs/en/guide/options.md @@ -88,6 +88,21 @@ Core runtime logs are stored per day in the **`logs` folder inside the program d Options: **Off / 3 days / 7 days / 14 days / 30 days**. Default **7 days**. Choosing "Off" disables logging entirely. +### Log level + +Only entries at the selected level **or above** are recorded. Options: **Debug / Info / Warning / Error**. Default **Warning**. + +| Level | What gets recorded | +| --- | --- | +| Debug | Everything, including every hide/restore and hotkey registration result | +| Info | Milestones beyond that routine activity, such as the first launch after an update | +| Warning (default) | Warnings and errors only: hotkey registration failures, rules that matched no window, an unclean shutdown detected on the previous run | +| Error | Errors only: the core failing to start, an unreadable config, crash reports | + +At the default "Warning", everyday hide and restore activity is **not written to the log**, leaving only entries that deserve attention. Before reporting an issue you can lower it to "Debug", reproduce the problem once, then set it back. The option is unavailable while log retention is set to "Off". + +Whatever the level, each run writes one session marker on startup and one on a clean exit, carrying the version and the data folder. + ::: tip Check the logs first when troubleshooting When something goes wrong, the logs in the `logs` folder are the primary source for diagnosing it. Attaching the relevant log to a report greatly speeds up investigation. See also [Window recovery & crash self-healing](/en/guide/recovery). ::: diff --git a/docs/en/guide/recovery.md b/docs/en/guide/recovery.md index 03a9a99..73f8d13 100644 --- a/docs/en/guide/recovery.md +++ b/docs/en/guide/recovery.md @@ -26,7 +26,7 @@ The Boss Key core has **three layers of crash self-healing**, so windows stay sa ### Layer 1: crash logs -The core writes key events and panic information to log files in the `logs` folder of the data folder, rotated daily as `BossKey-YYYY-MM-DD.log`, and cleaned up automatically according to the [log retention setting](/en/guide/options) (set it to off to disable logging). **When troubleshooting, read the current day's log first.** +The core writes key events and panic information to log files in the `logs` folder of the data folder, rotated daily as `BossKey-YYYY-MM-DD.log`, and cleaned up automatically according to the [log retention setting](/en/guide/options) (set it to off to disable logging). How much gets recorded depends on the [log level](/en/guide/options), which by default keeps warnings and errors only. **When troubleshooting, read the current day's log first.** ### Layer 2: crash recovery diff --git a/docs/guide/options.md b/docs/guide/options.md index 9eb0cfb..abfbfee 100644 --- a/docs/guide/options.md +++ b/docs/guide/options.md @@ -88,6 +88,21 @@ Windows 自带控制托盘图标显隐的功能,可手动设置哪些程序的 可选值:**关闭 / 3 天 / 7 天 / 14 天 / 30 天**,默认 **7 天**。选择"关闭"则完全不记录日志。 +### 日志输出等级 + +只记录所选等级**及以上**的日志。可选值:**调试 / 信息 / 警告 / 错误**,默认 **警告**。 + +| 等级 | 记录范围 | +| --- | --- | +| 调试 | 全部内容,含每次隐藏 / 恢复的明细、热键注册结果等流水 | +| 信息 | 上述流水之外的运行里程碑,如更新后首次启动 | +| 警告(默认) | 只记警告与错误:热键注册失败、规则未匹配到窗口、检测到上次异常退出等 | +| 错误 | 只记错误:核心无法启动、配置解析失败、崩溃信息等 | + +默认的"警告"下,日常的隐藏与恢复**不会写进日志**,日志文件只留下真正需要关注的内容。反馈问题前可临时调低到"调试",复现一次问题后再调回来。选择"关闭日志保留天数"时该项不可用。 + +无论选哪一级,每次启动与正常退出都会各记一行会话标记,其中含版本号与数据目录,便于定位问题。 + ::: tip 排查问题先看日志 当程序出现异常时,`logs` 文件夹中的日志是定位问题的第一手资料。反馈问题时附上相关日志能大幅提高排查效率。相关内容另见 [窗口恢复与崩溃自愈](/guide/recovery)。 ::: diff --git a/docs/guide/recovery.md b/docs/guide/recovery.md index 79e164b..5a5310c 100644 --- a/docs/guide/recovery.md +++ b/docs/guide/recovery.md @@ -26,7 +26,7 @@ Boss Key 核心内置了**崩溃自愈三层防线**,即使程序意外崩溃 ### 第一层:崩溃日志 -核心会把关键事件与 panic 信息写入数据目录 `logs` 文件夹下的日志文件,按天切割为 `BossKey-YYYY-MM-DD.log`,并按 [日志保留天数](/guide/options#日志保留天数) 自动清理过期文件(设为 0 则关闭日志)。**排查问题时先看当天的日志。** +核心会把关键事件与 panic 信息写入数据目录 `logs` 文件夹下的日志文件,按天切割为 `BossKey-YYYY-MM-DD.log`,并按 [日志保留天数](/guide/options#日志保留天数) 自动清理过期文件(设为 0 则关闭日志)。记录多少由 [日志输出等级](/guide/options#日志输出等级) 决定,默认只记警告与错误。**排查问题时先看当天的日志。** ### 第二层:崩溃恢复 diff --git a/docs/zh-tw/dev/architecture.md b/docs/zh-tw/dev/architecture.md index 73a465d..1dca858 100644 --- a/docs/zh-tw/dev/architecture.md +++ b/docs/zh-tw/dev/architecture.md @@ -95,7 +95,7 @@ Boss-Key/ │ autostart.rs 開機自動啟動(排程工作 XML 含失敗自動重新啟動 + 登錄檔回落) │ elevation.rs 系統管理員偵測 + UAC 提升權限重新啟動 │ i18n.rs 核心使用者可見文案 catalog(通知區域選單/通知/IPC 錯誤;記錄檔不走它) -│ logging.rs 分級檔案記錄(logs/BossKey-YYYY-MM-DD.log 按日切割 + panic 掛鉤) +│ logging.rs 分級檔案記錄(按日切割 + 等級過濾 + 去識別化 + panic 掛鉤) │ recovery.rs 當機復原(意圖先行寫入 + 原子寫;快照帶開機時刻與 │ 處理程序建立時刻,跨重新開機的快照會被丟棄) │ icon.rs 程序圖示擷取(HICON → 手寫 PNG/base64 編碼) @@ -148,7 +148,7 @@ Boss-Key/ Tauri 按 `tauri.conf.json` 裡的 identifier 把 WebView2 使用者資料放在 `%LOCALAPPDATA%\cn.hanloth.bosskey.config`,不在資料目錄裡,也不由 `paths.rs` 管。安裝程式的解除安裝程式與可攜版隨附的 `scripts/cleanup.ps1` 都會清理它。 ::: -每次啟動的實際資料目錄與判定結果會寫進記錄檔開頭,排查讀寫失敗先看它。 +每次啟動的實際資料目錄與判定結果會寫進記錄檔的 `[START]` 標記,排查讀寫失敗先看它(路徑中的使用者目錄已去識別化為 `%USERPROFILE%`)。 ## 核心內部:Agent 訊息迴圈 @@ -185,12 +185,46 @@ agent 執行緒本身**不**提優先權:它做的是列舉/凍結/寫入 ## 穩定性設計(當機自癒三層防線) -1. **當機記錄**:關鍵事件與 panic 寫入[資料目錄](#資料目錄)下的 `logs/BossKey-YYYY-MM-DD.log`(按日切割,依 `log_retention_days` 保留,0 表示不記錄;release 建置丟棄 DEBUG 級)。 +1. **當機記錄**:關鍵事件與 panic 寫入[資料目錄](#資料目錄)下的 `logs/BossKey-YYYY-MM-DD.log`(按日切割,依 `log_retention_days` 保留,0 表示不記錄;依 `log_level` 過濾,預設只記 WARN 及以上,詳見[記錄分級與去識別化](#記錄分級與去識別化))。 2. **當機復原**:隱藏動作執行前先把「將要隱藏/凍結/靜音什麼」寫入 `recovery.json`(tmp + rename 原子替換),異常結束後重新啟動自動找回;快照帶開機時刻與處理程序建立時刻,跨重新開機的過期快照直接丟棄,不會對無關視窗/處理程序做復原動作。 3. **監控程式**:排程工作 `RestartOnFailure`(當機後 1 分鐘內重新啟動,最多 3 次)。release 建置 `panic = "abort"`,panic 掛鉤寫完記錄後以非零碼結束,正好觸發排程工作重新啟動。 使用者視角的說明見 [視窗復原與當機自癒](/zh-tw/guide/recovery)。 +## 記錄分級與去識別化 + +`crates/core/src/logging.rs` 提供 `debug`/`info`/`warn`/`error` 四級與兩條工作階段標記,寫入前統一去識別化。使用者在設定介面選擇的 `log_level` 是**記錄門檻**:低於它的記錄直接丟棄,預設 `warn` 即只留警告與錯誤。等級在 `reload_config` 時即時生效,無須重新啟動核心。 + +新增記錄時按下表歸類: + +| 級別 | 收什麼 | 例子 | +| --- | --- | --- | +| `error` | 功能已不可用或資料有遺失風險,使用者需要知道 | 代理視窗建立失敗、設定解析失敗回退預設、復原檔寫入失敗 | +| `warn` | 降級但仍能用,或出現了使用者會察覺的異常 | 熱鍵註冊失敗、掛鉤安裝失敗、規則未比對到視窗、偵測到上次異常結束 | +| `info` | 每次執行至多一兩條的里程碑 | 更新後首次啟動並拉起設定程式 | +| `debug` | 逐次動作的流水與自癒過程,僅排查時才需要 | 每次隱藏/還原的明細、熱鍵註冊成功、通知區域圖示補掛 | + +兩條**工作階段標記**不受等級過濾,每次執行各一條:`[START]` 記錄版本、設定 schema、生效等級與資料目錄,`[EXIT]` 記錄正常結束及其來由(關閉熱鍵/通知區域選單/設定程式下發/冒煙計時)。記錄檔末尾沒有 `[EXIT]` 即上次是當機或被強制結束;只有 `[START]` 能讓一段上傳上來的記錄說清楚它出自哪個版本。 + +隱藏與還原的記錄會帶上**觸發來源**(`Trigger`:熱鍵、滑鼠按鍵、四角、閒置、通知區域、懸浮視窗、設定程式)。使用者回報「視窗莫名其妙被隱藏」時,第一步就是分清是誰觸發的。 + +### 回報只取本次執行 + +`logging::latest_session` 從最近一個 `[START]` 起截到目前,跨零點時向前回溯一個記錄檔;設定程式的 `current_session_log` 命令即取這一段。不按固定末尾若干行取,是因為那樣既會混進上幾次執行的內容,又會漏掉本次執行開頭的版本與資料目錄。超出回報預算([`verhub::LOG_EXCERPT_MAX`])時保留首行與末尾,中間註明省略了多少行——首行是版本資訊,末尾是出錯現場,兩頭都不能丟。 + +::: warning 記錄檔可能被使用者上傳 +設定介面的意見回饋功能會把記錄送往 Verhub,因此記錄裡**不得出現視窗標題**(可能是檔名、聊天對象、網頁標題),視窗一律以「處理程序名稱 + 控制代碼 + PID」指代,視窗規則以「序號 + 處理程序名稱」指代。使用者目錄由 `logging.rs` 統一替換為 `%USERPROFILE%`,無須各呼叫點自行處理。 +::: + +### error 資訊 + +- **對象**:出問題的路徑、PID、熱鍵、管道名稱等,路徑直接 `display()`(去識別化由記錄層負責)。 +- **原因**:系統 API 用 `util::win_err`(訊息 + 十六進位錯誤碼),IO 與子處理程序錯誤直接帶 `{e}`。 +- **後果**:哪個功能因此不可用、資料是否可能遺失,而不是只說「失敗」。 +- **位置**:用 `log_error!`/`log_warn!` 巨集,自動附上 `檔案:行號`。 + +例:`重新加载配置失败,本次改动未生效,核心仍在用上一次加载的配置: <路徑> — <原因> (agent.rs:123)`。 + ## 介面語言 核心與設定程式共用 `crates/common` 的 `Lang`(`zh-CN`/`en`/`zh-TW`)與語言偏好解析,文案則各自維護: diff --git a/docs/zh-tw/dev/config-reference.md b/docs/zh-tw/dev/config-reference.md index d30e030..040e6e5 100644 --- a/docs/zh-tw/dev/config-reference.md +++ b/docs/zh-tw/dev/config-reference.md @@ -63,13 +63,10 @@ Boss Key 的設定儲存在 `config.json` 中,可攜版存在程式資料夾 | `corner_fast_only` | bool | `true` | 僅快速移動觸發 | | `allow_move_restore` | bool | `false` | 角落復原 | | `log_retention_days` | number | `7` | [記錄檔保留天數](/zh-tw/guide/options)(0 = 關閉) | +| `log_level` | string | `"warn"` | [記錄輸出等級](/zh-tw/guide/options):`debug`|`info`|`warn`|`error` | | `autostart_admin` | bool | `false` | [以系統管理員身分自動啟動](/zh-tw/guide/autostart)(僅排程工作方式生效) | | `language` | string | `"auto"` | [介面語言](/zh-tw/guide/options):`auto`|`zh-CN`|`en`|`zh-TW` | -::: info `language` 的取值與正規化 -`auto` 表示跟隨系統顯示語言。讀取時會正規化:合法的 BCP-47 標籤折疊為 `zh-CN`/`en`/`zh-TW`(如 `zh_TW`、`zh-Hant` → `zh-TW`;`en-US` → `en`),無對應翻譯的值(如 `ja-JP`)一律回落為 `auto`。核心與設定程式共用該欄位。 -::: - ::: details 舊版扁平滑鼠開關(已淘汰) `middle_button_hide`/`side_button1_hide`/`side_button2_hide` 僅用於還原序列化移轉,移轉後歸零、不再寫回檔案。請使用 `mouse` 結構。 ::: diff --git a/docs/zh-tw/guide/options.md b/docs/zh-tw/guide/options.md index 017ebae..8bb1730 100644 --- a/docs/zh-tw/guide/options.md +++ b/docs/zh-tw/guide/options.md @@ -88,6 +88,21 @@ Windows 內建控制通知區域圖示顯示與否的功能,可手動設定哪 可選值:**關閉/3 天/7 天/14 天/30 天**,預設 **7 天**。選擇「關閉」則完全不記錄。 +### 記錄輸出等級 + +只記錄所選等級**及以上**的內容。可選值:**偵錯/資訊/警告/錯誤**,預設 **警告**。 + +| 等級 | 記錄範圍 | +| --- | --- | +| 偵錯 | 全部內容,含每次隱藏/還原的明細、熱鍵註冊結果等流水 | +| 資訊 | 上述流水之外的執行里程碑,如更新後首次啟動 | +| 警告(預設) | 只記警告與錯誤:熱鍵註冊失敗、規則未比對到視窗、偵測到上次異常結束等 | +| 錯誤 | 只記錯誤:核心無法啟動、設定解析失敗、當機資訊等 | + +預設的「警告」下,日常的隱藏與還原**不會寫進記錄檔**,只留下真正需要關注的內容。回報問題前可暫時調低到「偵錯」,重現一次問題後再調回。「記錄檔保留天數」選擇關閉時此項無法使用。 + +無論選哪一級,每次啟動與正常結束都會各記一行工作階段標記,其中含版本號與資料目錄,便於定位問題。 + ::: tip 排查問題先看記錄檔 當程式出現異常時,`logs` 資料夾中的記錄檔是定位問題的第一手資料。回報問題時附上相關記錄檔能大幅提高排查效率。相關內容另見 [視窗復原與當機自癒](/zh-tw/guide/recovery)。 ::: diff --git a/docs/zh-tw/guide/recovery.md b/docs/zh-tw/guide/recovery.md index 7ea76af..58751b4 100644 --- a/docs/zh-tw/guide/recovery.md +++ b/docs/zh-tw/guide/recovery.md @@ -26,7 +26,7 @@ Boss Key 核心內建了**當機自癒三層防線**,即使程式意外當機 ### 第一層:當機記錄 -核心會把關鍵事件與 panic 資訊寫入資料目錄 `logs` 下的記錄檔,按日切割為 `BossKey-YYYY-MM-DD.log`,並依 [記錄檔保留天數](/zh-tw/guide/options) 自動清除過期檔案(設為關閉則不記錄)。**排查問題時先看當天的記錄檔。** +核心會把關鍵事件與 panic 資訊寫入資料目錄 `logs` 下的記錄檔,按日切割為 `BossKey-YYYY-MM-DD.log`,並依 [記錄檔保留天數](/zh-tw/guide/options) 自動清除過期檔案(設為關閉則不記錄)。記錄多少由 [記錄輸出等級](/zh-tw/guide/options) 決定,預設只記警告與錯誤。**排查問題時先看當天的記錄檔。** ### 第二層:當機復原