From 5ed64021e64345dfbbb87d5bd2f53d507523d9b6 Mon Sep 17 00:00:00 2001 From: TheTouyu <1634389899@qq.com> Date: Thu, 22 Oct 2026 19:56:20 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(linux-egui):=20=E8=AE=A9=20Menu=20?= =?UTF-8?q?=E9=94=AE=E5=8F=AF=E4=BB=A5=E6=B3=A8=E5=86=8C=E4=B8=BA=E5=90=AC?= =?UTF-8?q?=E5=86=99=E7=83=AD=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fcitx5 的 Key::parse 按 X11 keysym 名做大小写敏感查表:SetCustomDictationTrigger("Menu") → sym=0xff67 成功,("menu") → invalid key 被拒。而 normalize_fcitx_primary 会把主键名 统一转小写,于是任何非修饰键的命名 keysym(Menu 等)都注册不上。 只对查表中确实首字母大写的名字还原大小写,其余(space / f5 / 单字符)维持原行为。 新增单测 menu_primary_keeps_the_case_fcitx_expects。 --- openless-all/app/linux-egui/src/settings.rs | 35 ++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/openless-all/app/linux-egui/src/settings.rs b/openless-all/app/linux-egui/src/settings.rs index 09b2473f6..7e43b9e20 100644 --- a/openless-all/app/linux-egui/src/settings.rs +++ b/openless-all/app/linux-egui/src/settings.rs @@ -467,10 +467,15 @@ fn binding_to_fcitx_key(binding: &ShortcutBinding) -> String { fn normalize_fcitx_primary(primary: &str) -> String { let trimmed = primary.trim(); - if let Some(stripped) = trimmed.strip_prefix("Key") { - stripped.to_ascii_lowercase() - } else { - trimmed.to_ascii_lowercase() + let stripped = trimmed.strip_prefix("Key").unwrap_or(trimmed); + let lower = stripped.to_ascii_lowercase(); + // fcitx5 的 Key::parse 按 X11 keysym 名做**大小写敏感**查表:实测 + // `SetCustomDictationTrigger("Menu")` → sym=0xff67 有效,`("menu")` → + // invalid key(2026-10-22)。这里只把查表中确实首字母大写的名字还原, + // 其余继续交给小写(space / f5 / 单字符都已验证可用)。 + match lower.as_str() { + "menu" => "Menu".to_string(), + _ => lower, } } @@ -603,6 +608,28 @@ mod tests { .is_err()); } + #[test] + fn menu_primary_keeps_the_case_fcitx_expects() { + // fcitx5 的 Key::parse 用 X11 keysym 名做大小写敏感查表:'Menu' 有效、 + // 'menu' 被拒(2026-10-22 实测 SetCustomDictationTrigger 返回 + // "invalid key")。主键名统一小写的旧逻辑会让 Menu 键永远注不上。 + let binding = ShortcutBinding { + primary: "Menu".into(), + modifiers: Vec::new(), + }; + assert_eq!(binding_to_fcitx_key(&binding), "Menu"); + let key_cased = ShortcutBinding { + primary: "KeyMenu".into(), + modifiers: Vec::new(), + }; + assert_eq!(binding_to_fcitx_key(&key_cased), "Menu"); + let space = ShortcutBinding { + primary: "Space".into(), + modifiers: vec!["ctrl".into(), "alt".into()], + }; + assert_eq!(binding_to_fcitx_key(&space), "Control+Alt+space"); + } + #[test] fn mouse_shortcuts_are_rejected_by_the_linux_host() { for primary in ["Mouse4", "Mouse5"] { From ac3e8f1240f71c7487badcccca2dc41a73e01644 Mon Sep 17 00:00:00 2001 From: TheTouyu <1634389899@qq.com> Date: Thu, 22 Oct 2026 19:56:20 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(core):=20=E5=BD=95=E9=9F=B3=E6=B5=81?= =?UTF-8?q?=E6=8E=89=E7=BA=BF=E6=97=B6=E7=BB=A7=E7=BB=AD=E8=BD=AC=E5=86=99?= =?UTF-8?q?=E5=B7=B2=E6=8D=95=E8=8E=B7=E9=9F=B3=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 蓝牙/虚拟输入设备在录音过程中可能报 "Device disconnected"(PipeWire 节点被挂起或 蓝牙 SCO 掉线)。此前 recording.stop() 返回错误会取消转写并丢弃已经流进 ASR 的音频, 用户看到的就是「识别到一半整段消失、既不插入也不进剪贴板」(history 记 transcribeFailed)。 改为:掉线只记警告,继续用已捕获音频完成转写;是否真的没有内容交给下游 「未收到有效音频」守卫判断。 --- .../openless-core/src/dictation_engine.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/dictation_engine.rs b/openless-all/app/crates/openless-core/src/dictation_engine.rs index 08d46eafe..4c73711ba 100644 --- a/openless-all/app/crates/openless-core/src/dictation_engine.rs +++ b/openless-all/app/crates/openless-core/src/dictation_engine.rs @@ -717,17 +717,12 @@ impl DictationEngine for PipelineDictationEngine { let archive = recording.archive(); let mut has_audio_recording = archive.as_ref().map(|archive| archive.is_available()); if let Err(error) = recording.stop().await { - demote_failed_archive( - archive.as_ref(), - context.output_target, - &mut has_audio_recording, - ) - .await; - let _ = cancel_transcription_once(&session, transcription).await; - remove_session(&sessions, session_id, &session); - let mut failure = EngineFailure::new(error, EngineFailureStage::Transcribing); - failure.has_audio_recording = has_audio_recording; - return Err(failure); + // 录音设备中途掉线(蓝牙麦欠载/节点被挂起时报 "Device disconnected") + // 不应丢掉已经流进 ASR 的内容:这里降级为警告继续转写, + // 由后续「未收到有效音频」守卫决定是否真的没有可识别内容。 + log::warn!( + "[dictation] recorder stop failed; transcribing the captured audio anyway: {error}" + ); } if context.output_target == crate::dictation_context::DictationOutputTarget::QuickNote { if let Some(archive) = archive.as_ref() { From 0c9c153e67ea2a61f7046249f3ea5fd9305033e1 Mon Sep 17 00:00:00 2001 From: TheTouyu <1634389899@qq.com> Date: Thu, 22 Oct 2026 19:56:20 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(diagnostics):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=90=AC=E5=86=99=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0=E4=B8=8E?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E8=AE=BE=E5=A4=87=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core: mark_dictation_failed 记录 code + message。此前只把 errorCode 写进 history, 日志里看不到失败发生在哪一步(录音停止 / 转写 / 润色)。 - linux-egui: 输入设备精确匹配失败时打印可选设备列表,并记录最终选用的设备名。 之前只有一句「using the default device: <填的名字>」,无法判断实际用的是哪只麦。 --- openless-all/app/crates/openless-core/src/api.rs | 7 +++++++ openless-all/app/linux-egui/src/audio.rs | 14 ++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index f5b809476..3079c6e6c 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -9220,6 +9220,13 @@ impl OpenLessBackend { } fn mark_dictation_failed(&self, session_id: SessionId, error: &BackendError) { + // 诊断:失败只写 errorCode 时无法定位是录音停止、转写还是插入哪一步出的错。 + log::warn!( + "[dictation] session {} failed: code={:?} message={}", + session_id, + error.code, + error.message + ); let mut state = self.state.write().expect("backend state lock poisoned"); if state.dictation.session_id != Some(session_id) { return; diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index 19d10b1dc..5e5d931af 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -620,21 +620,27 @@ fn select_input_device( let devices = host .input_devices() .map_err(|error| classify_audio_error("enumerate input devices", error.to_string()))?; + let mut available: Vec = Vec::new(); for device in devices { - if device.to_string() == preferred { + let name = device.to_string(); + if name == preferred { return Ok(device); } + available.push(name); } + // 设备名是精确匹配:把候选项一并打出来,省得再猜。 log::warn!( - "preferred Linux microphone was not found; using the default device: {preferred}" + "preferred Linux microphone was not found; using the default device: {preferred}; available: {available:?}" ); } - host.default_input_device().ok_or_else(|| { + let device = host.default_input_device().ok_or_else(|| { BackendError::new( BackendErrorCode::Platform, "no Linux microphone input device is available", ) - }) + })?; + log::info!("[audio] using input device: {}", device); + Ok(device) } #[cfg(target_os = "linux")] From 486143c3db518158913013dc5fbbeb5429959eaf Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Fri, 23 Oct 2026 00:28:13 +0800 Subject: [PATCH 4/8] =?UTF-8?q?test(core):=20=E5=BD=95=E9=9F=B3=E6=B5=81?= =?UTF-8?q?=20stop=20=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=8D=E7=AE=97?= =?UTF-8?q?=E8=BD=AC=E5=86=99=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stop_during_provider_preparation_releases_capture_before_finalization` 的 `stop_fails` 分支断言的是旧契约:录音流 `stop()` 报错时整次听写以 `BackendErrorCode::Platform` 失败、history 记 `transcribeFailed`。 而本 PR 要修的正是这一点 —— 蓝牙麦掉线 / PipeWire 节点被挂起时,已经流进 ASR 的音频不该丢,`stop()` 的失败降级为 warn 后继续转写。于是 CI 上 `openless-core --lib` 稳定挂在这一条(本地 3/3 复现,beta 上通过)。 行为变了,断言要跟着变:`stop_fails` 现在与成功路径结果一致,只多一条 warn。 断言改为「返回 Ok + 文本正确 + history 不记 transcribeFailed」, 并保留原有的 stop_count / archive / has_audio_recording 检查。 --- .../app/crates/openless-core/src/api.rs | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 3079c6e6c..9cc7f42bc 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -18137,18 +18137,19 @@ mod tests { Some(stopping) => stopping.await, None => backend.stop_dictation().await, }; - if stop_fails { - assert_eq!(result.unwrap_err().code, BackendErrorCode::Platform); - assert_eq!( - backend.list_history().unwrap()[0].error_code.as_deref(), - Some("transcribeFailed") - ); - } else { - let result = result.unwrap(); - assert_eq!(result.session_id, session_id); - assert_eq!(result.polished_text, "captured prefix"); - assert_eq!(transcriber.pcm(), vec![1, 0, 2, 0]); - } + // 录音流的 `stop()` 失败(蓝牙麦掉线、PipeWire 节点被挂起)不再 + // 中止这次听写:已经流进 ASR 的音频要照常转写并插入。断言因此 + // 与成功路径一致 —— 差别只剩「有没有打过那条 warn」,行为对 + // 用户不该有区别。 + let result = result.unwrap(); + assert_eq!(result.session_id, session_id); + assert_eq!(result.polished_text, "captured prefix"); + assert_eq!(transcriber.pcm(), vec![1, 0, 2, 0]); + assert_eq!( + backend.list_history().unwrap()[0].error_code.as_deref(), + None, + "a dropped capture stream must not be recorded as a transcription failure", + ); assert_eq!(recorder.stop_count(), 1); assert!(archive.lock().unwrap().as_ref().unwrap().is_available()); assert_eq!( From 0e1cd67da31b98355ddc11a870e1f4678a720c02 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Thu, 22 Oct 2026 23:44:22 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(linux-egui):=20=E9=87=8D=E5=81=9A?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=90=8E=E7=AB=AF=E2=80=94=E2=80=94=E5=8F=AF?= =?UTF-8?q?=E8=B0=83=E7=BA=A7=E5=88=AB=E3=80=81=E5=BF=83=E8=B7=B3=E9=99=8D?= =?UTF-8?q?=E5=99=AA=E3=80=81=E8=BD=AE=E8=BD=AC=E4=B8=8E=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 日志是 Linux 用户唯一能交回来的证据,但现在的实现在三处上不去: 一、**级别写死**。`LevelFilter::Info` 是常量,`RUST_LOG` 完全没读。用户报 「听写没反应」时,能做的只有重新编译一个 debug 版本给他。现在 `OPENLESS_LOG`(兼容 `RUST_LOG`)决定过滤,语法 `[,<模块>=]`, 最长前缀优先;无法识别的片段退回 `info` 而不是静默关掉。启动首行写明生效 级别与是否轮转过。 二、**心跳吃满日志**。实测一份 5 天的日志 39,111 行里 36,485 行(93%)是 `[pump] heartbeat` 与 `[ui-client] ipc round-trip`,2 MiB × 2 份的配额一天 就滚完,真正的错误全被挤出去。现在: - 泵心跳仍每 10 秒采样,但**状态变化立刻记 `info`**(窗口进程/托盘/录音 这三项的翻转才是当初「关窗后热键失效」的判据),状态没变时每 5 分钟一条 存活证明,其余走 `debug`; - IPC 往返探针(每 20 次一条)降为 `debug`。 三、**`error` 当 tracer 用**。`cloud_sync_e2ee/adapter.rs` 把 `stage=gate_open` / `stage=store_ok` / `build start` 这类进度全写 `log::error!`,于是每次正常启动都刷七行 ERROR —— 79 行 ERROR 里 66 行是它, 真错误反而看不见。这些降为 `debug`,失败分支保持 `error`。 顺带: - 轮转从「1 份 5 MiB」改成「3 份 2 MiB」,且**只有宿主轮转**(UI 窗口进程 若也改名,宿主手里的句柄会指向被改名的 inode,之后的宿主管日志就写进 `.1` 里);带单测。 - 时间戳落到本地时区(之前是 UTC,用户对着自己的操作时间找不到对应行)。 - 行格式补上模块目标,线程名与源码位置只在 debug 及以上出现。 - `scripts/egui-logging-contract.test.mjs` 守住四条:必须读 `OPENLESS_LOG`、 心跳不许无条件 `info`、IPC 探针必须 `debug`、`error` 里不许出现 `stage=`,另外拒绝形如 `api_key=`/`token=`/`password=` 的日志参数。 - `docs/linux-egui-logging.md` 写清级别语义、开关、轮转、前缀表与不记录的东西。 --- docs/index.md | 1 + docs/linux-egui-logging.md | 115 +++++++ .../src/cloud_sync_e2ee/adapter.rs | 12 +- .../app/linux-egui/src/linux_app/window.rs | 3 +- openless-all/app/linux-egui/src/logging.rs | 304 ++++++++++++++++-- openless-all/app/linux-egui/src/main.rs | 44 ++- .../scripts/egui-logging-contract.test.mjs | 101 ++++++ 7 files changed, 547 insertions(+), 33 deletions(-) create mode 100644 docs/linux-egui-logging.md create mode 100644 openless-all/app/scripts/egui-logging-contract.test.mjs diff --git a/docs/index.md b/docs/index.md index 35dc3a68d..81d1b6317 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,6 +15,7 @@ - [交接总览](linux-egui-handoff/README.md):阅读顺序、责任与复用起点。 - [01 Core 合同](linux-egui-handoff/01-core-contract.md)、[02 缺口登记](linux-egui-handoff/02-gap-register.md)(L01–L12)、[03 热键与窗口](linux-egui-handoff/03-hotkeys-and-windows.md)、[04 页面与领域](linux-egui-handoff/04-ui-domains.md)、[05 原生宿主与数据](linux-egui-handoff/05-native-host-and-data.md)、[06 事件与会话](linux-egui-handoff/06-events-and-sessions.md)、[07 验收](linux-egui-handoff/07-acceptance.md)。 +- [Linux egui 日志约定](linux-egui-logging.md):级别语义、`OPENLESS_LOG` 开关、轮转策略与不记录的内容。 - [Linux 已知缺口](linux-egui-known-gaps.md):当前仍未闭环的行为与复现路径(如 G01 纠错卡)。 ## 接口契约 diff --git a/docs/linux-egui-logging.md b/docs/linux-egui-logging.md new file mode 100644 index 000000000..a658a8272 --- /dev/null +++ b/docs/linux-egui-logging.md @@ -0,0 +1,115 @@ +# Linux egui 日志约定 + +状态:canonical;更新:2026-10-22。适用于 `openless-all/app/linux-egui`,以及被两个前端共用的 `crates/openless-core`。 + +日志是 Linux 用户唯一能交回来的证据:界面出错时他们既没有控制台,也不会跑 gdb。所以这份文件规定的不是文风,而是**「出问题时能不能从日志里读出结论」**。 + +## 1. 落在哪里 + +| 项 | 值 | +|---|---| +| 文件 | `$XDG_DATA_HOME/openless/logs/openless.log`(默认 `~/.local/share/OpenLess/logs/`) | +| 单文件上限 | 2 MiB | +| 历史份数 | `openless.log.1` … `openless.log.3`(合计 ≤ 8 MiB) | +| 轮转时机 | 进程启动时;**只有宿主轮转**,UI 窗口进程不轮转(否则宿主手里的句柄会指向被改名的 inode) | +| 终端 | 同时输出到 stderr,级别与文件一致 | +| 导出 | 设置 → 关于 → 「导出错误日志」(`export_error_log`,原子写) | + +两个进程(宿主 `openless`、界面 `openless --ui-client`)写同一个文件,排查「窗口进程怎么没了」时两端记录在同一处。 + +## 2. 级别 + +| 级别 | 用来记什么 | 判据 | +|---|---|---| +| `error` | **失败**。用户可见的能力没做到,或数据可能受损 | 「这条出现时,功能是坏的」 | +| `warn` | 降级但仍然工作:回退到默认设备、重试成功、可选能力缺失 | 「这条出现时,行为与预期不同」 | +| `info` | **状态变化**与低频存活证明(热键注册结果、模型加载、云同步相位、会话开始/结束) | 用户报「它不工作了」时,需要看到「上一次好的是哪一步」 | +| `debug` | 周期仪表:心跳、IPC 往返、逐帧/逐事件细节 | 打开后能让 `info` 的结论落到具体数值 | +| `trace` | 逐条协议/逐样本,只为复现一次具体 bug | 默认关闭,通常只用一次 | + +两条硬规则: + +1. **周期性的东西不许写 `info`。** 每 10 秒一条 = 8640 行/天,轮转后整份日志九成都是它,真正的错误被挤出去。这类日志写 `debug`,`info` 只留状态变化与低频存活证明。 + - 参考实现:`log_pump_heartbeat`(`linux-egui/src/main.rs`)——状态变化立刻记 `info`,否则每 5 分钟一条,其余 `debug`。 +2. **`error` 只写失败,不写进度。** `log::error!("[x] stage=device_id_ok")` 这类 tracer 会让每次启动看起来都像崩了,必须降到 `debug`。 + +## 3. 不用重新编译就能改级别 + +```bash +# 全开 +OPENLESS_LOG=debug openless + +# 只把 Core 的依赖图噪声打开 +OPENLESS_LOG=info,openless_core=debug openless + +# 环境变量在 systemd 启动的情况下要写进服务定义 +systemctl --user edit openless +# [Service] +# Environment=OPENLESS_LOG=debug +``` + +`OPENLESS_LOG` 优先,`RUST_LOG` 作为兼容名也认。语法: + +``` +[,=]... +``` + +- 模块前缀按 `::` 边界匹配,**最长匹配优先**(`openless_core::asr=warn` 会盖住 `openless_core=debug`)。 +- 任何无法识别的片段会让整条规则退回默认 `info`:宁可多记,不可静默少记。 +- 默认 `info`。发布版不需要用户改任何东西就能看到状态变化。 + +启动时的第一行会写明生效的过滤与是否轮转过: + +``` +2026-10-22T23:40:12.423+08:00 [INFO] openless_linux_egui::logging: Linux egui logger ready: level=debug rotated=false path=/home/x/.local/share/OpenLess/logs/openless.log +``` + +## 4. 行格式 + +``` +<本地时区 RFC3339> [] [] [] <文件:行> <消息> +``` + +- `target` 一直在(模块路径),定位「哪一层打的」不用猜。 +- 线程名与源码位置只在 `debug` 及以上出现,避免 `info` 行变长。 +- 时间戳是**用户本地时区**。历史版本写 UTC(结尾 `Z`),用户对着自己的操作时间找不到对应行。 + +消息里的固定前缀是约定,用方括号小写,便于 `grep '\[fcitx\]'`: + +| 前缀 | 范围 | +|---|---| +| `[pump]` | 宿主循环存活 | +| `[hotkey]` | 热键注册与分发 | +| `[fcitx]` | fcitx5 addon 通信 | +| `[ui-host]` / `[ui-client]` | 两个进程各自的 IPC 侧 | +| `[dictation]` | 一次听写的生命周期(Core) | +| `[insert-diagnostics]` | 文本注入 | +| `[audio]` / `[audio-cue]` / `[audio-mute]` | 采集与提示音 | +| `[style]` | 风格包 | +| `[remote-input]` | 手机/远程输入 | +| `[capture]` | 截图与选区 | + +## 5. 定位一次听写 + +同一次听写里的多行用 `session_id` 串起来(`[dictation]`、`[insert-diagnostics]` 都带)。排查顺序: + +```bash +grep 'session_id=' ~/.local/share/OpenLess/logs/openless.log* +``` + +失败原因现在会同时给出 `code` 与 `message`(见 `mark_dictation_failed`)。历史版本只有 `code`,因此「录音停止 / 转写 / 润色」这一步光看日志分不出来。 + +## 6. 不要记录的东西 + +- 任何凭据:API key、token、password、私钥、keystore 内容。日志会被用户原样贴到 issue 里。 +- 用户听写的正文。需要时可记长度或哈希(`[insert-diagnostics]` 就是这么做的:`chars=` / `hash=`)。 +- 完整 `data_dir` 之外的路径通常没必要;`data_dir` 本身要记,因为「日志写在哪」是第一个问题。 + +`scripts/egui-logging-contract.test.mjs` 会拒绝形如 `api_key=` / `token=` / `password=` 的日志格式化参数,以及 `stage=` 级别的 `error`。 + +## 7. 改动这份约定时 + +- 新增周期日志 → 必须是 `debug`,或带节流。 +- 新增 `info` → 必须能回答「状态变了什么」。 +- 新增 `error` → 必须对应一次真实的失败分支。 +- 跑 `node scripts/egui-logging-contract.test.mjs`。 diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs index f5ce14cf0..ec0167d72 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs @@ -43,7 +43,7 @@ pub(crate) fn build( } let origin = origin.origin().ascii_serialization(); let root = data_dir.join("encrypted-sync"); - log::error!( + log::debug!( "[e2ee-adapter] build start data_dir={} root={}", data_dir.display(), root.display() @@ -55,15 +55,15 @@ pub(crate) fn build( ); super::error("local_storage_unavailable") })?; - log::error!("[e2ee-adapter] stage=gate_open"); + log::debug!("[e2ee-adapter] stage=gate_open"); let gate = crate::cloud_sync_e2ee_store::gate::open_for_data_dir(data_dir).map_err(|error| { log::error!("[e2ee-adapter] reopen sync write gate failed: {error:#}"); document_error(error) })?; - log::error!("[e2ee-adapter] stage=device_id"); + log::debug!("[e2ee-adapter] stage=device_id"); let device_id = load_device_id(&root)?; - log::error!( + log::debug!( "[e2ee-adapter] stage=device_id_ok id_len={}", device_id.len() ); @@ -79,7 +79,7 @@ pub(crate) fn build( device_id, credentials.clone(), ); - log::error!("[e2ee-adapter] stage=store_new"); + log::debug!("[e2ee-adapter] stage=store_new"); let credential_store_for_service = credentials.clone(); let store = Arc::new( CoreSyncStore::new( @@ -96,7 +96,7 @@ pub(crate) fn build( document_error(error) })?, ); - log::error!("[e2ee-adapter] stage=store_ok"); + log::debug!("[e2ee-adapter] stage=store_ok"); let service = super::EncryptedSyncService::new( super::SyncServiceConfig { origin, diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index 34dd03e56..8fb6f89df 100644 --- a/openless-all/app/linux-egui/src/linux_app/window.rs +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -584,7 +584,8 @@ impl UiClientApp { let count = self.latency_samples.len(); let max = self.latency_samples.iter().copied().max().unwrap_or(0); let sum: u128 = self.latency_samples.iter().sum(); - log::info!( + // 每 20 次探针一条;它是仪表,不是事件,走 debug。 + log::debug!( "[ui-client] ipc round-trip avg={}ms max={max}ms over {count} probes (last #{sequence})", sum / count as u128 ); diff --git a/openless-all/app/linux-egui/src/logging.rs b/openless-all/app/linux-egui/src/logging.rs index ed7418812..a2c280269 100644 --- a/openless-all/app/linux-egui/src/logging.rs +++ b/openless-all/app/linux-egui/src/logging.rs @@ -1,55 +1,269 @@ +//! Linux egui 的日志后端。 +//! +//! 两个进程(宿主 `openless`、UI 窗口 `openless --ui-client`)都写同一个 +//! `$XDG_DATA/openless/logs/openless.log`,这样「窗口进程怎么没了」这类问题 +//! 两端的记录在同一处。 +//! +//! 三条约定,缺一条日志就会退化成没人看的噪声: +//! +//! 1. **级别可调,不用重新编译**。`OPENLESS_LOG`(或 `RUST_LOG`)决定过滤, +//! 例如 `OPENLESS_LOG=debug`、`OPENLESS_LOG=openless_core=debug,info`。 +//! 默认 `info` —— 发布版看不出问题的时候,用户可以只加一个环境变量重启复现。 +//! 2. **周期心跳不许占满文件**。日志是 append + 轮转,任何每 10 秒一条的 +//! 「我还在」都会在一天内吃掉整个配额,把真正的错误挤出去。心跳走 `debug`, +//! `info` 只留状态变化与低频存活证明(见 `log_pump_heartbeat`)。 +//! 3. **`error` 只写失败**,不写进度。轮转后仍有 3 份历史,够回溯一次事故。 + use std::path::{Path, PathBuf}; -const ROTATE_LIMIT_BYTES: u64 = 5 * 1024 * 1024; +/// 单文件上限。整个日志目录最多 `ROTATE_KEEP + 1` 份,即 8 MiB。 +const ROTATE_LIMIT_BYTES: u64 = 2 * 1024 * 1024; +/// 保留的历史份数:`openless.log.1` … `.3`。 +const ROTATE_KEEP: usize = 3; +/// 无法解析 `OPENLESS_LOG` 时的默认级别。 +const DEFAULT_LEVEL: log::LevelFilter = log::LevelFilter::Info; pub fn log_path(data_dir: &Path) -> PathBuf { data_dir.join("logs").join("openless.log") } +/// 环境变量名:优先 `OPENLESS_LOG`,兼容 Rust 生态惯用的 `RUST_LOG`。 +const ENV_KEYS: [&str; 2] = ["OPENLESS_LOG", "RUST_LOG"]; + pub fn init_file_logger(data_dir: &Path) -> Result { - use simplelog::{ - ColorChoice, CombinedLogger, ConfigBuilder, LevelFilter, TermLogger, TerminalMode, - WriteLogger, - }; + use simplelog::{ColorChoice, SharedLogger, TermLogger, TerminalMode, WriteLogger}; let path = log_path(data_dir); let parent = path.parent().ok_or("log path has no parent")?; std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; - rotate_if_needed(&path).map_err(|error| error.to_string())?; + // 只有宿主轮转:UI 窗口进程若是也有资格改名,宿主手里的文件句柄会指向被 + // 改名的 inode,之后的宿主管日志就写进 `.1` 里了。 + let rotated = if is_ui_client_process() { + false + } else { + rotate_if_needed(&path).map_err(|error| error.to_string())? + }; let file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&path) .map_err(|error| error.to_string())?; - let config = ConfigBuilder::new().set_time_format_rfc3339().build(); - CombinedLogger::init(vec![ + + let filter = FilterSpec::from_env(); + let config = base_config(); + let sinks: Vec> = vec![ + // 终端上也用同一份过滤结果,级别再放宽到 Trace 交给 FilterSpec 决定。 TermLogger::new( - LevelFilter::Info, + log::LevelFilter::Trace, config.clone(), TerminalMode::Mixed, ColorChoice::Auto, ), - WriteLogger::new(LevelFilter::Info, config, file), - ]) - .map_err(|error| error.to_string())?; - log::info!("Linux egui file logger ready: {}", path.display()); + WriteLogger::new(log::LevelFilter::Trace, config, file), + ]; + + let logger = FilteredLogger { + sinks, + spec: filter.clone(), + }; + log::set_max_level(filter.max_level()); + log::set_boxed_logger(Box::new(logger)).map_err(|error| error.to_string())?; + + log::info!( + "Linux egui logger ready: level={} rotated={rotated} path={}", + filter.describe(), + path.display() + ); Ok(path) } -fn rotate_if_needed(path: &Path) -> std::io::Result<()> { +fn base_config() -> simplelog::Config { + use simplelog::{ConfigBuilder, LevelFilter, ThreadLogMode}; + // simplelog 的 builder 方法是 `&mut self` 风格,先持有再链式调用。 + let mut builder = ConfigBuilder::new(); + builder.set_time_format_rfc3339(); + // 模块名一直在,定位「哪一层的日志」不用猜。 + // simplelog 的判据是 `config.target <= record.level()`,级别越小越宽松, + // 所以「始终打印」要设在 Error(最严格端),设成 Trace 反而不打印。 + builder.set_target_level(LevelFilter::Error); + // 线程与源码位置只在 debug 及以上出现,避免 info 行变长。 + builder.set_thread_level(LevelFilter::Debug); + builder.set_thread_mode(ThreadLogMode::Names); + builder.set_location_level(LevelFilter::Debug); + // 用户在 +08:00,日志却是 UTC 的 `Z`:让时间戳落到本地时区。 + // 判定不出来(Err)就保持 UTC,不影响其余配置。 + let _ = builder.set_time_offset_to_local(); + builder.build() +} + +/// 日志过滤规则:一个默认级别 + 若干 `模块前缀=级别` 覆盖。 +#[derive(Clone)] +struct FilterSpec { + default: log::LevelFilter, + overrides: Vec<(String, log::LevelFilter)>, +} + +impl FilterSpec { + fn from_env() -> Self { + for key in ENV_KEYS { + if let Ok(value) = std::env::var(key) { + if let Some(spec) = Self::parse(&value) { + return spec; + } + } + } + Self { + default: DEFAULT_LEVEL, + overrides: Vec::new(), + } + } + + /// 解析 `debug` / `info,openless_core=debug` / `OPENLESS_LOG=*`。 + /// 任何一条无法理解的片段都退回默认级别:宁可多记,不可静默少记。 + fn parse(value: &str) -> Option { + let mut default = None; + let mut overrides = Vec::new(); + for piece in value.split(',') { + let piece = piece.trim(); + if piece.is_empty() { + continue; + } + let (target, level) = match piece.split_once('=') { + Some((target, level)) => (Some(target.trim()), level.trim()), + None => (None, piece), + }; + let level = parse_level(level)?; + match target { + Some(target) if !target.is_empty() && target != "*" => { + overrides.push((target.to_string(), level)); + } + _ => default = Some(level), + } + } + Some(Self { + default: default.unwrap_or(DEFAULT_LEVEL), + overrides, + }) + } + + fn level_for(&self, target: &str) -> log::LevelFilter { + // 最长匹配优先:`openless_core::asr` 比 `openless_core` 更具体。 + let mut best: Option<(usize, log::LevelFilter)> = None; + for (prefix, level) in &self.overrides { + if target == prefix || target.starts_with(&format!("{prefix}::")) { + let len = prefix.len(); + if best.is_none_or(|(best_len, _)| len >= best_len) { + best = Some((len, *level)); + } + } + } + best.map(|(_, level)| level).unwrap_or(self.default) + } + + fn max_level(&self) -> log::LevelFilter { + self.overrides + .iter() + .map(|(_, level)| *level) + .chain(std::iter::once(self.default)) + .max() + .unwrap_or(DEFAULT_LEVEL) + } + + fn describe(&self) -> String { + let overrides = self + .overrides + .iter() + .map(|(target, level)| format!("{target}={}", level_label(*level))) + .collect::>(); + if overrides.is_empty() { + level_label(self.default).to_string() + } else { + format!("{} ({})", level_label(self.default), overrides.join(",")) + } + } +} + +fn parse_level(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "off" => Some(log::LevelFilter::Off), + "error" => Some(log::LevelFilter::Error), + "warn" | "warning" => Some(log::LevelFilter::Warn), + "info" => Some(log::LevelFilter::Info), + "debug" => Some(log::LevelFilter::Debug), + "trace" => Some(log::LevelFilter::Trace), + _ => None, + } +} + +fn level_label(level: log::LevelFilter) -> &'static str { + match level { + log::LevelFilter::Off => "off", + log::LevelFilter::Error => "error", + log::LevelFilter::Warn => "warn", + log::LevelFilter::Info => "info", + log::LevelFilter::Debug => "debug", + log::LevelFilter::Trace => "trace", + } +} + +/// 把 `FilterSpec` 套在所有 sink 前面。simplelog 自身只支持单一级别, +/// 按模块区分级别得在它外面做一层。 +struct FilteredLogger { + sinks: Vec>, + spec: FilterSpec, +} + +impl log::Log for FilteredLogger { + fn enabled(&self, metadata: &log::Metadata) -> bool { + metadata.level() <= self.spec.level_for(metadata.target()) + } + + fn log(&self, record: &log::Record) { + if !self.enabled(record.metadata()) { + return; + } + for sink in &self.sinks { + sink.log(record); + } + } + + fn flush(&self) { + for sink in &self.sinks { + sink.flush(); + } + } +} + +/// UI 窗口进程由宿主拉起,参数里带 `--ui-client`。 +fn is_ui_client_process() -> bool { + std::env::args().any(|arg| arg == "--ui-client") +} + +/// 按 `.log` → `.1` → … → `.ROTATE_KEEP` 依次后移,最老的一份被丢弃。 +/// 返回是否真的轮转过。 +fn rotate_if_needed(path: &Path) -> std::io::Result { let Ok(metadata) = std::fs::metadata(path) else { - return Ok(()); + return Ok(false); }; if metadata.len() <= ROTATE_LIMIT_BYTES { - return Ok(()); + return Ok(false); } - let archive = path.with_file_name("openless.log.1"); - match std::fs::remove_file(&archive) { + let archive = |index: usize| path.with_file_name(format!("openless.log.{index}")); + // 先把最老的删掉,再逐级改名,最后把当前文件挪成 `.1`。 + match std::fs::remove_file(archive(ROTATE_KEEP)) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error), } - std::fs::rename(path, archive) + for index in (1..ROTATE_KEEP).rev() { + match std::fs::rename(archive(index), archive(index + 1)) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + std::fs::rename(path, archive(1))?; + Ok(true) } pub fn export_error_log(source: &Path, destination: &Path) -> Result<(), crate::DesktopError> { @@ -80,4 +294,56 @@ mod tests { assert_eq!(std::fs::read(destination).unwrap(), b"diagnostic\n"); std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn parses_plain_and_per_module_levels() { + let spec = FilterSpec::parse("debug").unwrap(); + assert_eq!(spec.level_for("openless_core"), log::LevelFilter::Debug); + + let spec = FilterSpec::parse("info,openless_core=debug").unwrap(); + assert_eq!(spec.level_for("openless_core::asr"), log::LevelFilter::Debug); + assert_eq!(spec.level_for("openless_linux_egui"), log::LevelFilter::Info); + + // 更具体的模块前缀优先。 + let spec = FilterSpec::parse("openless_core=debug,openless_core::asr=warn").unwrap(); + assert_eq!( + spec.level_for("openless_core::asr::volcengine"), + log::LevelFilter::Warn + ); + assert_eq!(spec.level_for("openless_core::api"), log::LevelFilter::Debug); + } + + #[test] + fn rejects_garbage_instead_of_silencing_everything() { + assert!(FilterSpec::parse("loud").is_none()); + } + + #[test] + fn rotates_into_numbered_archives_and_drops_the_oldest() { + let root = std::env::temp_dir().join(format!( + "openless-linux-log-rotate-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("openless.log"); + std::fs::write(&path, vec![b'x'; (ROTATE_LIMIT_BYTES + 1) as usize]).unwrap(); + std::fs::write(root.join("openless.log.1"), b"one").unwrap(); + std::fs::write(root.join("openless.log.2"), b"two").unwrap(); + std::fs::write(root.join("openless.log.3"), b"three").unwrap(); + + assert!(rotate_if_needed(&path).unwrap()); + + assert!(!path.exists(), "当前文件应被移走"); + assert_eq!(std::fs::read(root.join("openless.log.1")).unwrap().len(), (ROTATE_LIMIT_BYTES + 1) as usize); + assert_eq!(std::fs::read(root.join("openless.log.2")).unwrap(), b"one"); + assert_eq!(std::fs::read(root.join("openless.log.3")).unwrap(), b"two"); + assert!(!root.join("openless.log.4").exists(), "超出保留份数的文件应被删除"); + + // 小于上限时不轮转。 + std::fs::write(&path, b"small").unwrap(); + assert!(!rotate_if_needed(&path).unwrap()); + assert!(path.exists()); + + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 6daf7ac10..ab60081b7 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -7,13 +7,17 @@ mod style_icon; #[cfg(target_os = "linux")] mod ui; -#[cfg(target_os = "linux")] mod linux_app { mod history; use history::HistoryCache; mod settings_save; mod window; use settings_save::SettingsSave; + /// 泵心跳的采样间隔:热键消费停没停,十秒内一定看得出来。 + const PUMP_HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); + /// 状态没有变化时,`info` 级心跳的最低频率(存活证明,不为排查细节服务)。 + const PUMP_HEARTBEAT_INFO_INTERVAL: std::time::Duration = std::time::Duration::from_secs(300); + use std::future::Future; use std::sync::mpsc; use std::sync::Arc; @@ -667,8 +671,13 @@ mod linux_app { vocab_suggestions_checked: std::time::Instant, tray: Option, exit_requested: bool, - /// 上次打「泵心跳」日志的时间。 + /// 上次采样「泵心跳」的时间(每 10 秒一次,与日志级别无关)。 last_pump_heartbeat: std::time::Instant, + /// 上次把心跳写进 `info` 的时间。心跳是每 10 秒一次的存在性证明, + /// 全写 `info` 会在一天内吃满日志配额,把真正的错误挤出去。 + last_pump_heartbeat_info: std::time::Instant, + /// 上次写 `info` 时的状态快照;状态一变就立刻记一条。 + last_pump_heartbeat_snapshot: [bool; 4], window: WindowState, /// 本帧从 UI 收到、待宿主执行的动作(按到达顺序)。 pending_ui_actions: Vec, @@ -903,6 +912,8 @@ mod linux_app { tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), + last_pump_heartbeat_info: std::time::Instant::now(), + last_pump_heartbeat_snapshot: [false; 4], window: WindowState::new(window_should_be_open), pending_ui_actions: Vec::new(), pending_local_hotkeys: Vec::new(), @@ -1022,6 +1033,8 @@ mod linux_app { tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), + last_pump_heartbeat_info: std::time::Instant::now(), + last_pump_heartbeat_snapshot: [false; 4], window: WindowState::new(window_should_be_open), pending_ui_actions: Vec::new(), pending_local_hotkeys: Vec::new(), @@ -4190,25 +4203,42 @@ mod linux_app { /// current-thread 运行时,`spawn` 的任务只在别处 `block_on` 时才被推进, /// 当作后台泵用就是「写完看着对、最小化后照样死」(实测心跳会在窗口 /// 收走的那一刻停)。线程只做一件事:`request_repaint()` 把事件循环戳醒。 - /// 泵心跳:宿主循环每 10s 一条。窗口关掉之后这条心跳**不能**停 —— + /// 泵心跳:宿主循环每 10s 采样一次。窗口关掉之后这条心跳**不能**停 —— /// 停了就说明热键消费与弹窗拉起也没在跑(这正是当初「关窗后热键失效」 - /// 的判据),用户/支持可以直接看日志确认。 + /// 的判据)。 + /// + /// 采样归采样,写日志要节流:每 10 秒一条 `info` 是 8640 行/天,轮转后 + /// 整份日志九成以上都是它,真正的错误反而被挤出去。所以**状态变化立刻 + /// 记一条 `info`**(这才是判据),状态没变时只每 + /// [`PUMP_HEARTBEAT_INFO_INTERVAL`] 留一条存活证明,其余走 `debug`。 fn log_pump_heartbeat(&mut self, ctx: &egui::Context) { self.tick_vocab_suggestions(); - if self.last_pump_heartbeat.elapsed() < std::time::Duration::from_secs(10) { + if self.last_pump_heartbeat.elapsed() < PUMP_HEARTBEAT_INTERVAL { return; } self.last_pump_heartbeat = std::time::Instant::now(); // 宿主没有窗口,所以心跳只看「窗口进程还在不在」—— // 这正是关窗后必须继续为 true 的那一项能力。 let _ = ctx; - log::info!( - "[pump] heartbeat window_process={} window_wanted={} tray={} recording={}", + let snapshot = [ self.window.child.is_some(), self.window.should_be_open, self.tray.is_some(), self.recording_phase_active, + ]; + let changed = snapshot != self.last_pump_heartbeat_snapshot; + let due = self.last_pump_heartbeat_info.elapsed() >= PUMP_HEARTBEAT_INFO_INTERVAL; + let message = format!( + "[pump] heartbeat window_process={} window_wanted={} tray={} recording={}", + snapshot[0], snapshot[1], snapshot[2], snapshot[3], ); + if changed || due { + self.last_pump_heartbeat_info = std::time::Instant::now(); + self.last_pump_heartbeat_snapshot = snapshot; + log::info!("{message}"); + } else { + log::debug!("{message}"); + } } /// 当前生效的热键配置(本地匹配与去重都用它)。 diff --git a/openless-all/app/scripts/egui-logging-contract.test.mjs b/openless-all/app/scripts/egui-logging-contract.test.mjs new file mode 100644 index 000000000..9d0212d0e --- /dev/null +++ b/openless-all/app/scripts/egui-logging-contract.test.mjs @@ -0,0 +1,101 @@ +// Contract test for the Linux egui logging backend. +// +// Logs are the only thing a user can hand us when the Linux host misbehaves, so +// the backend has to satisfy three properties that are easy to lose in a +// refactor: +// +// 1. The level filter is reachable without a rebuild (`OPENLESS_LOG`). +// 2. Periodic liveness chatter never sits at `info`: a 10 s heartbeat is 8,640 +// lines a day and pushed every real message out of the rotated files. +// 3. `error` means failure. A `stage=` progress tracer logged at `error` makes +// every startup look broken. +// +// The Rust sources are read as text: this test runs in the Linux contract job +// and must not need a compiled tree. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const read = (relative) => readFileSync(join(appRoot, relative), 'utf8'); + +const logging = read('linux-egui/src/logging.rs'); +const main = read('linux-egui/src/main.rs'); +const window = read('linux-egui/src/linux_app/window.rs'); +const adapter = read('crates/openless-core/src/cloud_sync_e2ee/adapter.rs'); + +// 1. Level filter from the environment, not a constant baked in at build time. +assert.match( + logging, + /"OPENLESS_LOG"/, + 'logging.rs must read OPENLESS_LOG so a user can turn on debug without a rebuild', +); +assert.match( + logging, + /"RUST_LOG"/, + 'logging.rs must keep honouring RUST_LOG for people who expect the Rust convention', +); +assert.match( + logging, + /log::set_boxed_logger/, + 'logging.rs must install a logger that applies the parsed filter', +); +assert.ok( + !/LevelFilter::Info,\s*\n\s*config\.clone\(\),\s*\n\s*TerminalMode/.test(logging), + 'the terminal sink must not be hard-wired to Info any more', +); + +// 2. Rotation keeps more than a single archive, otherwise one busy day erases +// the incident we are trying to diagnose. +assert.match( + logging, + /const ROTATE_KEEP: usize = [2-9]/, + 'logging.rs must keep at least two log archives', +); + +// 3. The pump heartbeat is throttled: state changes at `info`, the rest at +// `debug`, plus a low-frequency liveness line. +assert.match( + main, + /const PUMP_HEARTBEAT_INFO_INTERVAL[\s\S]{0,80}from_secs\(\d{2,}\)/, + 'the pump heartbeat needs an info-level interval of at least tens of seconds', +); +assert.ok( + !/log::info!\(\s*\n?\s*"\[pump\] heartbeat/.test(main), + 'the 10 s pump heartbeat must not be logged at info unconditionally', +); +assert.match( + main, + /log::debug!\("\{message\}"\)/, + 'the throttled heartbeat must fall back to debug', +); + +// 4. The IPC probe is a gauge, not an event. +assert.ok( + !/log::info!\(\s*\n?\s*"\[ui-client\] ipc round-trip/.test(window), + 'the IPC round-trip probe must stay at debug', +); + +// 5. No progress tracer disguised as a failure. +assert.ok( + !/log::error!\(\s*"\[e2ee-adapter\] stage=/.test(adapter), + 'the e2ee adapter must not log its stage tracers at error level', +); +assert.ok( + !/log::error!\(\s*\n\s*"\[e2ee-adapter\] build start/.test(adapter), + 'the e2ee adapter must not log its build start marker at error level', +); + +// 6. Nothing that looks like a secret may be formatted into a log line. +const logCalls = [...`${main}\n${window}\n${adapter}`.matchAll(/log::(?:error|warn|info|debug|trace)!\(([\s\S]{0,400}?)\);/g)]; +for (const [, body] of logCalls) { + assert.ok( + !/\b(?:api_key|apikey|access_token|refresh_token|client_secret|password|private_key)\s*=/i.test( + body, + ), + `a log line formats what looks like a secret: ${body.trim().slice(0, 120)}`, + ); +} + +console.log('egui-logging contract test passed'); From c43b673949f3be11279eec8b43ff2eae4f0b150a Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Fri, 23 Oct 2026 00:13:29 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat(linux-egui):=20=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E7=BA=A7=E5=88=AB=E5=8F=AF=E8=AE=BE=E3=80=81=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=B8=A6=E5=8E=86=E5=8F=B2=E3=80=81=E8=A1=A5=E4=B8=8A=E5=85=AD?= =?UTF-8?q?=E5=A4=84=E7=9B=B2=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第二轮,把上一版日志「能看」提升到「能定位」。 一、**级别可在设置里改**(设置 → 高级 → 调试 → 日志级别) - 写 `/logs/level`,重启生效,界面提示;`info`/`debug`/`trace` 三档。 - 宿主本地设置,不进 Core 偏好:不上云、不影响 Tauri 与移动端,也不动 `contract/backend-2.0.json`。 - 优先级:`OPENLESS_LOG`/`RUST_LOG` → 设置文件 → 默认 `info`。环境变量仍然 最高,临时排查不该被上一次的界面选择挡住。 二、**导出不再只有当前文件** `export_error_logs` 按 `.3` → `.2` → `.1` → 当前的时间正序拼接,带分隔头, 并在文件头写明版本、系统架构、生效级别与导出时间 —— 用户报的问题几乎总是 发生在轮转之后,只导 `openless.log` 恰好丢掉现场。默认文件名改成 `openless-diagnostics.log`,避免误以为和那个单文件是一回事。 三、**周期噪声再降一档** | 日志 | 以前 | 现在 | |---|---|---| | `[transcript] partial emitted` | 每个流式分片一条 info | debug | | `[hotkey] local edge` | 每次按键一条 info | debug | | `[ui-client] actions from the renderer` | 每批动作一条 info | debug | 四、**补上六处盲区** | 位置 | 新增 | 以前查不到什么 | |---|---|---| | `audio.rs` | `[audio] capture started backend=… device=… format=… rate=… channels=…` | 「录到的全是静音」:成功路径原本一条日志都没有,不知道落到哪个后端/哪只麦 | | `audio.rs` | `[audio] no input backend could open a stream (tried […])` | 三个后端全失败时的总括 | | core `api.rs` | `[dictation] start session_id=… target=… asr=… insert=… translate=…` | 「按了热键没反应」与「录上了没识别」在日志里长得一样 | | core `dictation_engine.rs` | `[asr] session_id=… provider=… elapsed_ms=… chars=…` | 「听写很慢」:分不清慢在 ASR 还是润色 | | `main.rs` | `[startup] single-instance lock acquired` / `another instance owns the lock; forwarded…` | 「双击图标没反应」原本完全无痕 | | `settings_save.rs` | `[settings] saved request=…` / `failed: …` | 设置改了没保存,卡在哪一段无从判断 | 五、测试 - `logging::tests` 6 项:级别解析(含最长前缀优先)、垃圾输入退回默认、 轮转链(`.4` 被丢弃)、导出含全部历史且时间正序、级别持久化。 - `scripts/egui-logging-contract.test.mjs` 扩到 9 组断言:级别必须可持久化、 导出必须含历史、capture 成功必须记后端与格式、听写起点与单实例结果必须 留痕、streaming partial 与按键 edge 不许在 info。 - `docs/linux-egui-logging.md` 更新:设置入口、导出行为、新增的「一次听写在 日志里长什么样」串读示例。 --- docs/linux-egui-logging.md | 34 +++- .../app/crates/openless-core/src/api.rs | 11 ++ .../openless-core/src/dictation_engine.rs | 16 +- openless-all/app/linux-egui/src/audio.rs | 14 ++ openless-all/app/linux-egui/src/i18n.rs | 15 ++ openless-all/app/linux-egui/src/lib.rs | 5 +- .../linux-egui/src/linux_app/settings_save.rs | 46 ++++- .../app/linux-egui/src/linux_app/window.rs | 4 +- openless-all/app/linux-egui/src/logging.rs | 163 +++++++++++++++++- openless-all/app/linux-egui/src/main.rs | 27 ++- .../linux-egui/src/ui/frontend/settings.rs | 13 ++ .../linux-egui/src/ui/frontend/view_model.rs | 6 + .../scripts/egui-logging-contract.test.mjs | 61 +++++++ 13 files changed, 395 insertions(+), 20 deletions(-) diff --git a/docs/linux-egui-logging.md b/docs/linux-egui-logging.md index a658a8272..ec38ec237 100644 --- a/docs/linux-egui-logging.md +++ b/docs/linux-egui-logging.md @@ -13,7 +13,8 @@ | 历史份数 | `openless.log.1` … `openless.log.3`(合计 ≤ 8 MiB) | | 轮转时机 | 进程启动时;**只有宿主轮转**,UI 窗口进程不轮转(否则宿主手里的句柄会指向被改名的 inode) | | 终端 | 同时输出到 stderr,级别与文件一致 | -| 导出 | 设置 → 关于 → 「导出错误日志」(`export_error_log`,原子写) | +| 导出 | 设置 → 高级 → 调试 → 「导出错误日志」(`export_error_logs`,原子写) | +| 导出内容 | 当前 `openless.log` **加全部轮转历史**(`.3`→`.2`→`.1`→当前,时间正序),文件头写明版本、系统架构、生效级别与导出时间 | 两个进程(宿主 `openless`、界面 `openless --ui-client`)写同一个文件,排查「窗口进程怎么没了」时两端记录在同一处。 @@ -33,7 +34,15 @@ - 参考实现:`log_pump_heartbeat`(`linux-egui/src/main.rs`)——状态变化立刻记 `info`,否则每 5 分钟一条,其余 `debug`。 2. **`error` 只写失败,不写进度。** `log::error!("[x] stage=device_id_ok")` 这类 tracer 会让每次启动看起来都像崩了,必须降到 `debug`。 -## 3. 不用重新编译就能改级别 +## 3. 设置里也能改 + +设置 → 高级 → 调试 → **日志级别**(`info` / `debug` / `trace`): + +- 选完写进 `/logs/level`,**重启 OpenLess 生效**(界面会提示)。 +- 这是宿主本地设置,不进 Core 偏好,因此不上云、不影响 Tauri 与移动端。 +- 环境变量优先于这里的设置:临时排查不该被上一次的界面选择挡住。 + +## 4. 不用重新编译也能改级别 ```bash # 全开 @@ -64,7 +73,7 @@ systemctl --user edit openless 2026-10-22T23:40:12.423+08:00 [INFO] openless_linux_egui::logging: Linux egui logger ready: level=debug rotated=false path=/home/x/.local/share/OpenLess/logs/openless.log ``` -## 4. 行格式 +## 5. 行格式 ``` <本地时区 RFC3339> [] [] [] <文件:行> <消息> @@ -89,7 +98,20 @@ systemctl --user edit openless | `[remote-input]` | 手机/远程输入 | | `[capture]` | 截图与选区 | -## 5. 定位一次听写 +## 6. 一次听写在日志里长什么样 + +``` +[dictation] start session_id=… target=Dictation asr=dashscope insert=true translate=false silence_after_ms=None +[audio] capture started backend=pipewire device=… format=F32 rate=48000Hz channels=1 +[transcript] partial emitted session=… ← 仅 debug +[asr] session_id=… provider=dashscope label=… elapsed_ms=1840 chars=42 +[asr-diagnostics] session_id=… asr_chars=42 asr_hash=… corrected_chars=42 … +[polish-diagnostics] session_id=… stage=decision uses_polisher=true … +[insert-diagnostics] session_id=… attempt=1 operation=… chars=42 hash=… +[dictation] session … failed: code=… message=… ← 失败时(warn) +``` + +## 7. 定位一次听写 同一次听写里的多行用 `session_id` 串起来(`[dictation]`、`[insert-diagnostics]` 都带)。排查顺序: @@ -99,7 +121,7 @@ grep 'session_id=' ~/.local/share/OpenLess/logs/openless.log* 失败原因现在会同时给出 `code` 与 `message`(见 `mark_dictation_failed`)。历史版本只有 `code`,因此「录音停止 / 转写 / 润色」这一步光看日志分不出来。 -## 6. 不要记录的东西 +## 8. 不要记录的东西 - 任何凭据:API key、token、password、私钥、keystore 内容。日志会被用户原样贴到 issue 里。 - 用户听写的正文。需要时可记长度或哈希(`[insert-diagnostics]` 就是这么做的:`chars=` / `hash=`)。 @@ -107,7 +129,7 @@ grep 'session_id=' ~/.local/share/OpenLess/logs/openless.log* `scripts/egui-logging-contract.test.mjs` 会拒绝形如 `api_key=` / `token=` / `password=` 的日志格式化参数,以及 `stage=` 级别的 `error`。 -## 7. 改动这份约定时 +## 9. 改动这份约定时 - 新增周期日志 → 必须是 `debug`,或带节流。 - 新增 `info` → 必须能回答「状态变了什么」。 diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 9cc7f42bc..c83930d33 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -7692,6 +7692,17 @@ impl OpenLessBackend { context }; + // 一次听写的起点。日志从「按下热键」到第一条识别结果之间原本是空白, + // 于是「按了没反应」与「录上了但没识别」在日志里长得一样。 + log::info!( + "[dictation] start session_id={} target={:?} asr={} insert={} translate={} silence_after_ms={:?}", + session_id, + context.output_target, + context.asr.provider_type, + context.insertion.enabled, + context.polish.translation_active, + context.recording.silence_after_ms, + ); if let Err(error) = self .deps .host_actions diff --git a/openless-all/app/crates/openless-core/src/dictation_engine.rs b/openless-all/app/crates/openless-core/src/dictation_engine.rs index 4c73711ba..979d2ca64 100644 --- a/openless-all/app/crates/openless-core/src/dictation_engine.rs +++ b/openless-all/app/crates/openless-core/src/dictation_engine.rs @@ -947,7 +947,17 @@ impl DictationEngine for PipelineDictationEngine { } } }; - let asr_ms = Some(asr_started.elapsed().as_millis() as u64); + let asr_elapsed_ms = asr_started.elapsed().as_millis() as u64; + let asr_ms = Some(asr_elapsed_ms); + // 「听写很慢」时要能一句话看出慢在 ASR 还是慢在润色。 + log::info!( + "[asr] session_id={} provider={} label={:?} elapsed_ms={} chars={}", + session_id, + context.asr.provider_type, + asr_call_label, + asr_elapsed_ms, + transcript.text.chars().count(), + ); if session.cancelled.load(Ordering::Acquire) { if !context.recording.archive_successful_recording { discard_ephemeral_archive(archive.as_ref(), &mut has_audio_recording).await; @@ -2162,7 +2172,9 @@ struct TranscriptProgressForwarder { impl TextStreamSink for TranscriptProgressForwarder { fn publish(&self, chunk: TextStreamChunk) -> Result<(), BackendError> { - log::info!( + // 流式转写每来一块就一条;它属于逐事件仪表,走 debug, + // 否则一次长听写就能把 info 层刷满。 + log::debug!( "[transcript] partial emitted session={} offset={} chars={}", self.session_id, chunk.offset, diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index 5e5d931af..adb75bc7a 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -485,6 +485,11 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { stream } Err(error) => { + log::error!( + "[audio] no input backend could open a stream (tried {:?}): {}", + audio_backend_order(), + error.message + ); drop(frame_tx); let _ = processor.finish(); let _ = startup.send(Err(error)); @@ -540,6 +545,15 @@ fn try_start_audio_stream( .play() .map_err(|error| classify_audio_error("start input stream", error.to_string()))?; accepting.store(true, std::sync::atomic::Ordering::Release); + // 「录到的全是静音」这类问题,第一个要回答的就是:落到哪个后端、哪只 + // 设备、什么采样格式。以前只有失败时才有日志,成功时什么都看不到。 + log::info!( + "[audio] capture started backend={backend} device={} format={:?} rate={}Hz channels={}", + device, + supported.sample_format(), + supported.sample_rate(), + usize::from(supported.channels()), + ); Ok(stream) }; let initial_error = match device.default_input_config() { diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 29bc7c752..13ca6fe9e 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -1468,6 +1468,7 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "status.channel_saved", text: row("渠道配置已保存", "管道設定已儲存", "Channel configuration saved", "チャネル設定を保存しました", "채널 설정 저장됨") }, Msg { key: "status.secret_cleared", text: row("渠道 Secret 已清除", "管道 Secret 已清除", "Channel Secret cleared", "チャネルの Secret を消去しました", "채널 Secret 지워짐") }, Msg { key: "status.provider_validated", text: row("Provider 验证通过({} ms)", "Provider 驗證通過({} ms)", "Provider validated ({} ms)", "Provider 検証成功({} ms)", "Provider 검증 통과({}ms)") }, + Msg { key: "status.log_level_saved", text: row("日志级别已保存,重启 OpenLess 后生效", "日誌等級已儲存,重新啟動 OpenLess 後生效", "Log level saved; restart OpenLess to apply", "ログレベルを保存しました。OpenLess の再起動で反映されます", "로그 레벨을 저장했습니다. OpenLess를 다시 시작하면 적용됩니다") }, Msg { key: "status.export_log_done", text: row("错误日志已导出", "錯誤日誌已匯出", "Error log exported", "エラーログをエクスポートしました", "오류 로그 내보냄") }, Msg { key: "status.hotkey_handled", text: row("已处理快捷键", "已處理快速鍵", "Hotkey handled", "ホットキーを処理しました", "단축키 처리됨") }, Msg { key: "status.launch_handled", text: row("已处理启动请求", "已處理啟動請求", "Launch request handled", "起動要求を処理しました", "시작 요청 처리됨") }, @@ -3794,6 +3795,20 @@ pub const CATALOG: &[Msg] = &[ "Shift+Insert (xterm / urxvt)", ), }, + Msg { + key: "settings.debug.log_level_label", + text: row("日志级别", "日誌等級", "Log level", "ログレベル", "로그 레벨"), + }, + Msg { + key: "settings.debug.log_level_desc", + text: row( + "debug 记录心跳与 IPC 细节,排查问题时更有用;选择后需重启 OpenLess 生效。", + "debug 記錄心跳與 IPC 細節,排查問題時更有用;選擇後需重新啟動 OpenLess 生效。", + "debug records heartbeats and IPC details, which is what support needs; restart OpenLess to apply.", + "debug はハートビートと IPC の詳細を記録します(調査向け)。OpenLess の再起動で反映されます。", + "debug는 하트비트와 IPC 세부 정보를 기록합니다(문제 해결용). OpenLess를 다시 시작해야 적용됩니다.", + ), + }, Msg { key: "settings.recording.record_audio_for_debug_label", text: row( diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index d0bdb47e3..765508486 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -95,7 +95,10 @@ pub use local_hotkeys::{ next_local_press_id, plugin_event_hotkey, style_pack_raw, translation_hotkey_event, HotkeyDeduplicator, LocalHotkey, LocalHotkeyEdge, LocalHotkeyEdgeKind, HOTKEY_DEDUPE_WINDOW, }; -pub use logging::{export_error_log, init_file_logger, log_path}; +pub use logging::{ + export_error_log, export_error_logs, init_file_logger, level_name, log_path, read_log_level, + write_log_level, +}; pub use popup::{ force_x11_for, popup_command, read_jsonl, run_popup, write_jsonl, ApplyOutcome as PopupApplyOutcome, CapsulePopupState, CapsuleSuggestion, HostToPopup, diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index d7e83d1b4..6bb556f03 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -137,6 +137,12 @@ impl OpenLessEguiApp { self.snapshot = Some(backend.snapshot()); } self.hydrate_text_fields = true; + // 设置写入是「用户改了东西但看起来没保存」类问题的分界线: + // 成功一条 debug,失败一条 warn(带字段数),不问就不知道卡在哪一段。 + match &result { + Ok(()) => log::debug!("[settings] saved request={request}"), + Err(error) => log::warn!("[settings] save request={request} failed: {error}"), + } match result { Ok(()) => { self.status = tr_l10n(self.lang, "status.settings_saved").to_string(); @@ -247,10 +253,40 @@ impl OpenLessEguiApp { field: frontend::view_model::SettingsComboField, index: usize, ) { + // 日志级别是宿主本地设置(`/logs/level`),不经过 Core 偏好, + // 因此要放在下面 `self.preferences` 的守卫之前 —— 否则偏好还没加载时 + // 连日志级别都改不了。 + if matches!(field, frontend::view_model::SettingsComboField::LogLevel) { + let Some(backend) = self.backend() else { + return; + }; + let level = ["info", "debug", "trace"] + .get(index) + .copied() + .unwrap_or("info"); + let data_dir = backend.config().data_dir.clone(); + match openless_linux_egui::write_log_level(&data_dir, level) { + Ok(parsed) => { + self.frontend_vm.settings.log_level = index; + log::info!( + "[logging] level set to {} (takes effect on the next start)", + openless_linux_egui::level_name(parsed) + ); + self.status = tr_l10n(self.lang, "status.log_level_saved").to_string(); + } + Err(error) => { + log::warn!("[logging] failed to persist the level: {error}"); + self.status = error.to_string(); + } + } + return; + } let Some(preferences) = self.preferences.as_mut() else { return; }; match field { + // 已在函数开头处理(不走 Core 偏好)。 + frontend::view_model::SettingsComboField::LogLevel => {} frontend::view_model::SettingsComboField::Theme => { preferences.theme_mode = match index { 0 => openless_core::shared_types::ThemeMode::System, @@ -673,13 +709,15 @@ impl OpenLessEguiApp { } frontend::view_model::SettingsActionField::ExportDiagnostics => { if let Some(backend) = self.backend() { - let source = openless_linux_egui::log_path(&backend.config().data_dir); + let data_dir = backend.config().data_dir.clone(); let lang = self.lang; self.spawn(async move { let destination = tokio::task::spawn_blocking(|| { rfd::FileDialog::new() - .add_filter("Log", &["log"]) - .set_file_name("openless.log") + // 导出的是「当前 + 全部轮转历史」,默认名字别再叫 openless.log, + // 否则用户会以为是那个单文件。 + .add_filter("Log", &["log", "txt"]) + .set_file_name("openless-diagnostics.log") .save_file() }) .await @@ -696,7 +734,7 @@ impl OpenLessEguiApp { ) })?; tokio::task::spawn_blocking(move || { - openless_linux_egui::export_error_log(&source, &destination) + openless_linux_egui::export_error_logs(&data_dir, &destination) }) .await .map_err(|error| { diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index 8fb6f89df..fe2ba3712 100644 --- a/openless-all/app/linux-egui/src/linux_app/window.rs +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -208,7 +208,7 @@ impl OpenLessEguiApp { self.pending_ui_actions.push(*action); } WindowToHost::Hotkey { sequence, edge } => { - log::info!("[hotkey] local edge from the UI window #{sequence}: {edge:?}"); + log::debug!("[hotkey] local edge from the UI window #{sequence}: {edge:?}"); self.pending_local_hotkeys .push((std::time::Instant::now(), edge)); } @@ -803,7 +803,7 @@ impl eframe::App for UiClientApp { } } if ui_debug_enabled() && !actions.is_empty() { - log::info!("[ui-client] actions from the renderer: {actions:?}"); + log::debug!("[ui-client] actions from the renderer: {actions:?}"); } self.dispatch(actions, &ctx); self.ping_if_due(); diff --git a/openless-all/app/linux-egui/src/logging.rs b/openless-all/app/linux-egui/src/logging.rs index a2c280269..0c6544b4e 100644 --- a/openless-all/app/linux-egui/src/logging.rs +++ b/openless-all/app/linux-egui/src/logging.rs @@ -22,6 +22,9 @@ const ROTATE_LIMIT_BYTES: u64 = 2 * 1024 * 1024; const ROTATE_KEEP: usize = 3; /// 无法解析 `OPENLESS_LOG` 时的默认级别。 const DEFAULT_LEVEL: log::LevelFilter = log::LevelFilter::Info; +/// 设置页写入的级别文件名,位于 `/logs/level`。 +/// 环境变量仍然优先:临时排查不该被上一次的界面设置挡住。 +const LEVEL_FILE_NAME: &str = "level"; pub fn log_path(data_dir: &Path) -> PathBuf { data_dir.join("logs").join("openless.log") @@ -49,7 +52,7 @@ pub fn init_file_logger(data_dir: &Path) -> Result { .open(&path) .map_err(|error| error.to_string())?; - let filter = FilterSpec::from_env(); + let filter = FilterSpec::resolve(data_dir); let config = base_config(); let sinks: Vec> = vec![ // 终端上也用同一份过滤结果,级别再放宽到 Trace 交给 FilterSpec 决定。 @@ -104,7 +107,8 @@ struct FilterSpec { } impl FilterSpec { - fn from_env() -> Self { + /// 优先级:`OPENLESS_LOG`/`RUST_LOG` → 设置里存的级别 → `info`。 + fn resolve(data_dir: &Path) -> Self { for key in ENV_KEYS { if let Ok(value) = std::env::var(key) { if let Some(spec) = Self::parse(&value) { @@ -112,6 +116,12 @@ impl FilterSpec { } } } + if let Some(level) = read_log_level(data_dir) { + return Self { + default: level, + overrides: Vec::new(), + }; + } Self { default: DEFAULT_LEVEL, overrides: Vec::new(), @@ -195,6 +205,33 @@ fn parse_level(value: &str) -> Option { } } +/// 设置页保存的日志级别;文件不存在或内容无法识别时返回 `None`。 +pub fn read_log_level(data_dir: &Path) -> Option { + let path = data_dir.join("logs").join(LEVEL_FILE_NAME); + let raw = std::fs::read_to_string(path).ok()?; + parse_level(raw.trim()) +} + +/// 把级别写进 `/logs/level`,下次启动生效。 +pub fn write_log_level(data_dir: &Path, level: &str) -> std::io::Result { + let Some(parsed) = parse_level(level) else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unknown log level: {level}"), + )); + }; + let dir = data_dir.join("logs"); + std::fs::create_dir_all(&dir)?; + crate::atomic_save(&dir.join(LEVEL_FILE_NAME), level_name(parsed).as_bytes()) + .map_err(|error| std::io::Error::other(error.to_string()))?; + Ok(parsed) +} + +/// 级别的稳定名称:文件名、设置项与日志首行都用它,避免各自拼写。 +pub fn level_name(level: log::LevelFilter) -> &'static str { + level_label(level) +} + fn level_label(level: log::LevelFilter) -> &'static str { match level { log::LevelFilter::Off => "off", @@ -266,6 +303,84 @@ fn rotate_if_needed(path: &Path) -> std::io::Result { Ok(true) } +/// 导出「当前日志 + 全部轮转历史」到单个文件。 +/// +/// 只导出 `openless.log` 时,用户报的问题几乎总是发生在轮转之后 —— 现场刚好被 +/// 切进 `.1` 而导出里没有。这里按 `.3` → `.2` → `.1` → 当前的顺序拼接(时间 +/// 正序),每段加分隔头,并在最前面写明版本、系统、生效级别与导出时间, +/// 省掉「你用的哪个版本」这一轮往返。 +pub fn export_error_logs(data_dir: &Path, destination: &Path) -> Result<(), crate::DesktopError> { + let current = log_path(data_dir); + let mut out = String::new(); + out.push_str("# OpenLess Linux diagnostics\n"); + out.push_str(&format!("version: {}\n", env!("OPENLESS_APP_VERSION"))); + out.push_str(&format!( + "os: {} {}\n", + std::env::consts::OS, + std::env::consts::ARCH + )); + out.push_str(&format!( + "level: {}\n", + level_name( + std::env::var("OPENLESS_LOG") + .ok() + .and_then(|value| FilterSpec::parse(&value)) + .map_or_else( + || read_log_level(data_dir).unwrap_or(DEFAULT_LEVEL), + |spec| spec.default + ) + ) + )); + out.push_str(&format!( + "exported_at: {}\n\n", + time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()) + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default() + )); + + let mut exported = 0usize; + for index in (1..=ROTATE_KEEP).rev() { + let path = current.with_file_name(format!("openless.log.{index}")); + if append_log_section(&mut out, &path, &format!("openless.log.{index}"))? { + exported += 1; + } + } + if append_log_section(&mut out, ¤t, "openless.log")? { + exported += 1; + } + if exported == 0 { + return Err(crate::DesktopError::Io { + operation: "read error log", + source: std::io::Error::new(std::io::ErrorKind::NotFound, "no log file yet"), + }); + } + crate::atomic_save(destination, out.as_bytes()).map(|_| ()) +} + +fn append_log_section( + out: &mut String, + path: &Path, + label: &str, +) -> Result { + match std::fs::read(path) { + Ok(bytes) => { + out.push_str(&format!("===== {label} =====\n")); + out.push_str(&String::from_utf8_lossy(&bytes)); + if !out.ends_with('\n') { + out.push('\n'); + } + out.push('\n'); + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(crate::DesktopError::Io { + operation: "read error log", + source, + }), + } +} + pub fn export_error_log(source: &Path, destination: &Path) -> Result<(), crate::DesktopError> { let bytes = std::fs::read(source).map_err(|source| crate::DesktopError::Io { operation: "read error log", @@ -295,6 +410,50 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn exports_every_rotated_file_with_a_header() { + let root = std::env::temp_dir().join(format!( + "openless-linux-log-export-all-{}", + uuid::Uuid::new_v4().simple() + )); + let logs = root.join("logs"); + std::fs::create_dir_all(&logs).unwrap(); + std::fs::write(logs.join("openless.log"), b"newest\n").unwrap(); + std::fs::write(logs.join("openless.log.1"), b"older\n").unwrap(); + std::fs::write(logs.join("openless.log.2"), b"oldest\n").unwrap(); + let destination = root.join("diagnostics.log"); + + export_error_logs(&root, &destination).unwrap(); + let text = std::fs::read_to_string(&destination).unwrap(); + + assert!(text.starts_with("# OpenLess Linux diagnostics\n")); + assert!(text.contains("version: ")); + // 时间正序:`.2` 在 `.1` 之前,当前文件在最后。 + let oldest = text.find("oldest").unwrap(); + let older = text.find("older").unwrap(); + let newest = text.find("newest").unwrap(); + assert!(oldest < older && older < newest, "sections must be chronological"); + assert!(text.contains("===== openless.log.2 =====")); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn persists_the_level_for_the_next_start() { + let root = std::env::temp_dir().join(format!( + "openless-linux-log-level-{}", + uuid::Uuid::new_v4().simple() + )); + assert_eq!(read_log_level(&root), None); + assert_eq!( + write_log_level(&root, "debug").unwrap(), + log::LevelFilter::Debug + ); + assert_eq!(read_log_level(&root), Some(log::LevelFilter::Debug)); + assert!(write_log_level(&root, "loud").is_err()); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn parses_plain_and_per_module_levels() { let spec = FilterSpec::parse("debug").unwrap(); diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index ab60081b7..930b1293c 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -2133,7 +2133,7 @@ mod linux_app { } }, PopupSupervisorEvent::Message(PopupToHost::Hotkey { edge, .. }) => { - log::info!("[hotkey] local edge from the {kind:?} panel: {edge:?}"); + log::debug!("[hotkey] local edge from the {kind:?} panel: {edge:?}"); self.pending_local_hotkeys .push((std::time::Instant::now(), edge)); } @@ -5690,6 +5690,15 @@ mod linux_app { // 文本型设置行只在偏好载入 / 外部变更时回灌一次,避免把输入中的 // 内容每帧弹回旧值。 let hydrate_text = std::mem::replace(&mut self.hydrate_text_fields, false); + // 日志级别是宿主本地文件(不在 Core 偏好里),要在借用 vm 之前读。 + let log_level_index = self + .backend() + .and_then(|backend| openless_linux_egui::read_log_level(&backend.config().data_dir)) + .map_or(0, |level| match level { + log::LevelFilter::Debug => 1, + log::LevelFilter::Trace => 2, + _ => 0, + }); let vm = &mut self.frontend_vm; @@ -5870,6 +5879,8 @@ mod linux_app { s.launch_at_login = prefs.launch_at_login; s.streaming_save_clipboard = prefs.streaming_insert_save_clipboard; s.record_audio_for_debug = prefs.record_audio_for_debug; + // 日志级别是宿主本地文件,不在 Core 偏好里;读不到就按 info 显示。 + s.log_level = log_level_index; if hydrate_text { s.history_max_entries = prefs .history_max_entries @@ -8019,8 +8030,18 @@ mod linux_app { ) .map_err(|error| error.to_string())? { - SingleInstanceRole::Primary(broker) => Ok(BrokerAcquisition::Primary(broker)), - SingleInstanceRole::Forwarded => Ok(BrokerAcquisition::Forwarded), + SingleInstanceRole::Primary(broker) => { + log::info!("[startup] single-instance lock acquired; running as primary"); + Ok(BrokerAcquisition::Primary(broker)) + } + SingleInstanceRole::Forwarded => { + // 「双击图标没反应」最常见的原因就是这一条:意图被已有实例接管, + // 本进程直接退出。没有这行日志时,用户和我们看到的都是「什么都没发生」。 + log::info!( + "[startup] another instance owns the lock; forwarded the launch intent to it and exiting" + ); + Ok(BrokerAcquisition::Forwarded) + } } } diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 5dcd97f23..493a62327 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -3210,6 +3210,19 @@ fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec/logs/level`, + /// 重启后生效;不是 Core 偏好,所以不上云、不影响 Tauri。 + LogLevel, } #[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] @@ -1201,6 +1204,8 @@ pub struct SettingsFields { #[serde(default)] pub llm_thinking_enabled: bool, pub record_audio_for_debug: bool, + /// 日志级别:0 = info,1 = debug,2 = trace。宿主本地设置,不进 Core 偏好。 + pub log_level: usize, pub history_max_entries: String, /// 风格直达快捷键(可录制/停用/移除)。 pub style_pack_hotkeys: Vec, @@ -1268,6 +1273,7 @@ impl Default for SettingsFields { capsule_transcript_font_size: 14, llm_thinking_enabled: false, record_audio_for_debug: false, + log_level: 0, history_max_entries: String::new(), style_pack_hotkeys: Vec::new(), polish_context_window: String::new(), diff --git a/openless-all/app/scripts/egui-logging-contract.test.mjs b/openless-all/app/scripts/egui-logging-contract.test.mjs index 9d0212d0e..d9088054e 100644 --- a/openless-all/app/scripts/egui-logging-contract.test.mjs +++ b/openless-all/app/scripts/egui-logging-contract.test.mjs @@ -98,4 +98,65 @@ for (const [, body] of logCalls) { ); } + +// 7. The level chosen in the UI has to outlive the process, and the export has to +// carry the rotated history - otherwise the file the user sends us is exactly +// the one that no longer contains the incident. +const settingsSave = read('linux-egui/src/linux_app/settings_save.rs'); +assert.match( + logging, + /pub fn write_log_level/, + 'logging.rs must persist the level chosen in the settings UI', +); +assert.match( + logging, + /pub fn read_log_level/, + 'logging.rs must read the persisted level at startup', +); +assert.match( + settingsSave, + /write_log_level\(/, + 'the settings handler must persist the log level', +); +assert.match( + settingsSave, + /export_error_logs\(/, + 'the export action must include the rotated logs, not only the current file', +); +assert.match( + logging, + /pub fn export_error_logs/, + 'logging.rs must provide the multi-file export', +); + +// 8. The gaps that used to make a report unusable: no capture line on success, no +// dictation start, no single-instance outcome. +assert.match( + read('linux-egui/src/audio.rs'), + /"\[audio\] capture started backend=/, + 'capture start must name the backend, device and format that actually won', +); +assert.match( + read('crates/openless-core/src/api.rs'), + /"\[dictation\] start session_id=/, + 'the dictation start boundary must be logged (hotkey pressed but nothing happened)', +); +assert.match( + main, + /"\[startup\] another instance owns the lock/, + 'a forwarded second launch must leave a trace, otherwise "clicking the icon does nothing" is invisible', +); + +// 9. Per-event chatter must not sit at info. +assert.ok( + !/log::info!\("\[transcript\] partial emitted/.test( + read('crates/openless-core/src/dictation_engine.rs'), + ), + 'each streaming partial must not be logged at info', +); +assert.ok( + !/log::info!\("\[hotkey\] local edge/.test(main), + 'per-keystroke hotkey edges must stay at debug', +); + console.log('egui-logging contract test passed'); From 9f5a3260e512f985c7718b120c5d9df11d31ea97 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Fri, 23 Oct 2026 00:32:07 +0800 Subject: [PATCH 7/8] =?UTF-8?q?test(linux-egui):=20=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E6=B6=88=E6=81=AF=E6=94=B9=E8=8B=B1=E6=96=87?= =?UTF-8?q?=EF=BC=8C=E5=88=AB=E8=A7=A6=E5=8F=91=E6=9C=AC=E5=9C=B0=E5=8C=96?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localization_contract 会把 linux-egui 源码里的裸简体中文当成用户可见文案拦下来。 我上一版在 logging 的轮转单测里写了中文断言消息("当前文件应被移走"),CI 报 two new raw Simplified-Chinese literals。断言消息改成英文即可。 --- openless-all/app/linux-egui/src/logging.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openless-all/app/linux-egui/src/logging.rs b/openless-all/app/linux-egui/src/logging.rs index 0c6544b4e..45cc000b9 100644 --- a/openless-all/app/linux-egui/src/logging.rs +++ b/openless-all/app/linux-egui/src/logging.rs @@ -492,11 +492,11 @@ mod tests { assert!(rotate_if_needed(&path).unwrap()); - assert!(!path.exists(), "当前文件应被移走"); + assert!(!path.exists(), "the current log must have been moved aside"); assert_eq!(std::fs::read(root.join("openless.log.1")).unwrap().len(), (ROTATE_LIMIT_BYTES + 1) as usize); assert_eq!(std::fs::read(root.join("openless.log.2")).unwrap(), b"one"); assert_eq!(std::fs::read(root.join("openless.log.3")).unwrap(), b"two"); - assert!(!root.join("openless.log.4").exists(), "超出保留份数的文件应被删除"); + assert!(!root.join("openless.log.4").exists(), "archives beyond ROTATE_KEEP must be dropped"); // 小于上限时不轮转。 std::fs::write(&path, b"small").unwrap(); From 273d0aba6e0fdd53854112ab7aa628b0fda8ffe4 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Fri, 23 Oct 2026 18:15:47 +0800 Subject: [PATCH 8/8] fix(linux-egui): preserve effective logging diagnostics --- openless-all/app/linux-egui/src/logging.rs | 276 ++++++++++++++++-- openless-all/app/linux-egui/src/main.rs | 149 +++++++++- .../scripts/egui-logging-contract.test.mjs | 35 ++- 3 files changed, 421 insertions(+), 39 deletions(-) diff --git a/openless-all/app/linux-egui/src/logging.rs b/openless-all/app/linux-egui/src/logging.rs index 45cc000b9..e24752798 100644 --- a/openless-all/app/linux-egui/src/logging.rs +++ b/openless-all/app/linux-egui/src/logging.rs @@ -15,6 +15,10 @@ //! 3. **`error` 只写失败**,不写进度。轮转后仍有 3 份历史,够回溯一次事故。 use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +// 导出只报告已安装的规则,不把待重启的设置误当成生效配置。 +static ACTIVE_FILTER: OnceLock = OnceLock::new(); /// 单文件上限。整个日志目录最多 `ROTATE_KEEP + 1` 份,即 8 MiB。 const ROTATE_LIMIT_BYTES: u64 = 2 * 1024 * 1024; @@ -69,12 +73,13 @@ pub fn init_file_logger(data_dir: &Path) -> Result { sinks, spec: filter.clone(), }; - log::set_max_level(filter.max_level()); log::set_boxed_logger(Box::new(logger)).map_err(|error| error.to_string())?; + log::set_max_level(filter.max_level()); + let description = filter.describe(); + let _ = ACTIVE_FILTER.set(filter); log::info!( - "Linux egui logger ready: level={} rotated={rotated} path={}", - filter.describe(), + "Linux egui logger ready: level={description} rotated={rotated} path={}", path.display() ); Ok(path) @@ -111,9 +116,10 @@ impl FilterSpec { fn resolve(data_dir: &Path) -> Self { for key in ENV_KEYS { if let Ok(value) = std::env::var(key) { - if let Some(spec) = Self::parse(&value) { - return spec; - } + return Self::parse(&value).unwrap_or(Self { + default: DEFAULT_LEVEL, + overrides: Vec::new(), + }); } } if let Some(level) = read_log_level(data_dir) { @@ -321,15 +327,10 @@ pub fn export_error_logs(data_dir: &Path, destination: &Path) -> Result<(), crat )); out.push_str(&format!( "level: {}\n", - level_name( - std::env::var("OPENLESS_LOG") - .ok() - .and_then(|value| FilterSpec::parse(&value)) - .map_or_else( - || read_log_level(data_dir).unwrap_or(DEFAULT_LEVEL), - |spec| spec.default - ) - ) + ACTIVE_FILTER + .get() + .map(FilterSpec::describe) + .unwrap_or_else(|| "unknown (logger not initialized)".to_string()) )); out.push_str(&format!( "exported_at: {}\n\n", @@ -393,6 +394,221 @@ pub fn export_error_log(source: &Path, destination: &Path) -> Result<(), crate:: mod tests { use super::*; + fn run_in_child(name: &str) -> bool { + const CHILD_ENV: &str = "OPENLESS_LOG_TEST_CHILD"; + if std::env::var(CHILD_ENV).as_deref() == Ok(name) { + return false; + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + &format!("logging::tests::{name}"), + "--nocapture", + "--test-threads=1", + ]) + .env(CHILD_ENV, name) + .env_remove("OPENLESS_LOG_RESTART_ROOT") + .env_remove("OPENLESS_LOG") + .env_remove("RUST_LOG") + .output() + .expect("launch isolated logging regression"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "isolated logging regression failed: {}\n{stdout}\n{stderr}", + output.status + ); + assert!( + stdout.contains("test result: ok. 1 passed; 0 failed"), + "{stdout}" + ); + true + } + + fn temp_dir() -> PathBuf { + let root = std::env::temp_dir().join(format!("openless-log-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(root.join("logs")).unwrap(); + root + } + + #[test] + fn resolves_configuration_in_priority_order() { + if run_in_child("resolves_configuration_in_priority_order") { + return; + } + let cases = [ + (None, None, None, "info"), + (None, None, Some("debug"), "debug"), + (None, None, Some("loud"), "info"), + (Some("debug"), Some("off"), Some("trace"), "debug"), + ( + Some("debug,openless_core=loud"), + Some("off"), + Some("off"), + "info", + ), + (Some("loud"), None, Some("off"), "info"), + (None, Some("loud"), Some("off"), "info"), + (None, Some("debug"), Some("off"), "debug"), + (Some("off"), Some("debug"), Some("trace"), "off"), + (None, Some("off"), Some("debug"), "off"), + (Some(""), Some("off"), Some("debug"), "info"), + ]; + for (openless, rust, saved, expected) in cases { + let root = temp_dir(); + for (key, value) in [("OPENLESS_LOG", openless), ("RUST_LOG", rust)] { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + if let Some(saved) = saved { + std::fs::write(root.join("logs/level"), saved).unwrap(); + } + assert_eq!( + FilterSpec::resolve(&root).describe(), + expected, + "OPENLESS_LOG={openless:?}, RUST_LOG={rust:?}, saved={saved:?}" + ); + std::fs::remove_dir_all(root).unwrap(); + } + } + + fn exported_text(root: &Path) -> String { + log::logger().flush(); + let destination = root.join("diagnostics.log"); + export_error_logs(root, &destination).unwrap(); + std::fs::read_to_string(destination).unwrap() + } + + #[test] + fn exports_the_active_rust_log_filter() { + if run_in_child("exports_the_active_rust_log_filter") { + return; + } + let root = temp_dir(); + write_log_level(&root, "off").unwrap(); + std::env::set_var("RUST_LOG", "debug"); + init_file_logger(&root).unwrap(); + log::debug!("rust-log-debug-probe"); + let text = exported_text(&root); + assert!(text.lines().any(|line| line == "level: debug"), "{text}"); + assert!(text.contains("rust-log-debug-probe")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn exports_module_overrides_and_filters_their_boundaries() { + if run_in_child("exports_module_overrides_and_filters_their_boundaries") { + return; + } + let root = temp_dir(); + std::env::set_var( + "OPENLESS_LOG", + "info,openless_core=debug,openless_core::asr=warn", + ); + init_file_logger(&root).unwrap(); + log::debug!(target: "openless_core::api", "core-debug-probe"); + log::debug!(target: "openless_core::asr", "asr-debug-probe"); + log::debug!(target: "openless_core_extra", "lookalike-debug-probe"); + log::warn!(target: "openless_core::asr", "asr-warn-probe"); + let text = exported_text(&root); + assert!( + text.lines() + .any(|line| line == "level: info (openless_core=debug,openless_core::asr=warn)"), + "{text}" + ); + assert!(text.contains("core-debug-probe")); + assert!(text.contains("asr-warn-probe")); + assert!(!text.contains("asr-debug-probe")); + assert!(!text.contains("lookalike-debug-probe")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn exports_the_active_level_until_restart() { + const NAME: &str = "exports_the_active_level_until_restart"; + const ROOT_ENV: &str = "OPENLESS_LOG_RESTART_ROOT"; + if run_in_child(NAME) { + return; + } + if let Some(root) = std::env::var_os(ROOT_ENV) { + let root = PathBuf::from(root); + init_file_logger(&root).unwrap(); + log::debug!("restarted-debug-probe"); + let text = exported_text(&root); + assert!(text.lines().any(|line| line == "level: debug"), "{text}"); + assert!(text.contains("restarted-debug-probe")); + return; + } + let root = temp_dir(); + write_log_level(&root, "info").unwrap(); + init_file_logger(&root).unwrap(); + write_log_level(&root, "debug").unwrap(); + log::debug!("pending-debug-probe"); + let text = exported_text(&root); + assert!(text.lines().any(|line| line == "level: info"), "{text}"); + assert!(!text.contains("pending-debug-probe")); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "logging::tests::exports_the_active_level_until_restart", + "--nocapture", + "--test-threads=1", + ]) + .env("OPENLESS_LOG_TEST_CHILD", NAME) + .env(ROOT_ENV, &root) + .env_remove("OPENLESS_LOG") + .env_remove("RUST_LOG") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stdout}\n{stderr}"); + assert!( + stdout.contains("test result: ok. 1 passed; 0 failed"), + "{stdout}" + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn exports_existing_logs_without_an_initialized_logger() { + if run_in_child("exports_existing_logs_without_an_initialized_logger") { + return; + } + let root = temp_dir(); + std::fs::write(log_path(&root), "existing-log-probe\n").unwrap(); + write_log_level(&root, "debug").unwrap(); + let text = exported_text(&root); + assert!( + text.lines() + .any(|line| line == "level: unknown (logger not initialized)"), + "{text}" + ); + assert!(text.contains("existing-log-probe")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn failed_initialization_preserves_the_active_filter() { + if run_in_child("failed_initialization_preserves_the_active_filter") { + return; + } + let root = temp_dir(); + std::env::set_var("OPENLESS_LOG", "debug"); + init_file_logger(&root).unwrap(); + std::env::set_var("OPENLESS_LOG", "info"); + assert!(init_file_logger(&root).is_err()); + assert_eq!(log::max_level(), log::LevelFilter::Debug); + log::debug!("still-debug-after-failed-init"); + let text = exported_text(&root); + assert!(text.lines().any(|line| line == "level: debug"), "{text}"); + assert!(text.contains("still-debug-after-failed-init")); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn exports_the_current_log_with_atomic_save() { let root = std::env::temp_dir().join(format!( @@ -432,7 +648,10 @@ mod tests { let oldest = text.find("oldest").unwrap(); let older = text.find("older").unwrap(); let newest = text.find("newest").unwrap(); - assert!(oldest < older && older < newest, "sections must be chronological"); + assert!( + oldest < older && older < newest, + "sections must be chronological" + ); assert!(text.contains("===== openless.log.2 =====")); std::fs::remove_dir_all(root).unwrap(); @@ -460,8 +679,14 @@ mod tests { assert_eq!(spec.level_for("openless_core"), log::LevelFilter::Debug); let spec = FilterSpec::parse("info,openless_core=debug").unwrap(); - assert_eq!(spec.level_for("openless_core::asr"), log::LevelFilter::Debug); - assert_eq!(spec.level_for("openless_linux_egui"), log::LevelFilter::Info); + assert_eq!( + spec.level_for("openless_core::asr"), + log::LevelFilter::Debug + ); + assert_eq!( + spec.level_for("openless_linux_egui"), + log::LevelFilter::Info + ); // 更具体的模块前缀优先。 let spec = FilterSpec::parse("openless_core=debug,openless_core::asr=warn").unwrap(); @@ -469,7 +694,10 @@ mod tests { spec.level_for("openless_core::asr::volcengine"), log::LevelFilter::Warn ); - assert_eq!(spec.level_for("openless_core::api"), log::LevelFilter::Debug); + assert_eq!( + spec.level_for("openless_core::api"), + log::LevelFilter::Debug + ); } #[test] @@ -493,10 +721,16 @@ mod tests { assert!(rotate_if_needed(&path).unwrap()); assert!(!path.exists(), "the current log must have been moved aside"); - assert_eq!(std::fs::read(root.join("openless.log.1")).unwrap().len(), (ROTATE_LIMIT_BYTES + 1) as usize); + assert_eq!( + std::fs::read(root.join("openless.log.1")).unwrap().len(), + (ROTATE_LIMIT_BYTES + 1) as usize + ); assert_eq!(std::fs::read(root.join("openless.log.2")).unwrap(), b"one"); assert_eq!(std::fs::read(root.join("openless.log.3")).unwrap(), b"two"); - assert!(!root.join("openless.log.4").exists(), "archives beyond ROTATE_KEEP must be dropped"); + assert!( + !root.join("openless.log.4").exists(), + "archives beyond ROTATE_KEEP must be dropped" + ); // 小于上限时不轮转。 std::fs::write(&path, b"small").unwrap(); diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 930b1293c..63fe56c4c 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -4626,6 +4626,7 @@ mod linux_app { let mut actions = Vec::new(); native.host_actions().drain(|action| actions.push(action)); for intent in launch_intents { + log::info!("[ui-host] launch intent from the user: {intent:?}"); // CLI 的 ToggleQa 与「按一次 QA 热键」等价(Tauri // `dispatch_cli_intent` 把 ToggleQa 直接转给 `handle_qa_hotkey_pressed`), // 所以它既不拉起主窗口,也不走 Core 的 ToggleQa(那条是“切换录音”)。 @@ -4637,7 +4638,6 @@ mod linux_app { self.toggle_qa_panel(); continue; } - log::info!("[ui-host] launch intent from the user: {intent:?}"); launch_intent_window_requested = true; let host = Arc::clone(&host); self.spawn(async move { @@ -8030,18 +8030,8 @@ mod linux_app { ) .map_err(|error| error.to_string())? { - SingleInstanceRole::Primary(broker) => { - log::info!("[startup] single-instance lock acquired; running as primary"); - Ok(BrokerAcquisition::Primary(broker)) - } - SingleInstanceRole::Forwarded => { - // 「双击图标没反应」最常见的原因就是这一条:意图被已有实例接管, - // 本进程直接退出。没有这行日志时,用户和我们看到的都是「什么都没发生」。 - log::info!( - "[startup] another instance owns the lock; forwarded the launch intent to it and exiting" - ); - Ok(BrokerAcquisition::Forwarded) - } + SingleInstanceRole::Primary(broker) => Ok(BrokerAcquisition::Primary(broker)), + SingleInstanceRole::Forwarded => Ok(BrokerAcquisition::Forwarded), } } @@ -10367,8 +10357,9 @@ Internal flags (set by OpenLess itself, not for regular use): let tray_available = tray.is_some(); let native = (|| { let mut config = backend_config()?; - if let Err(error) = openless_linux_egui::init_file_logger(&config.data_dir) { - eprintln!("OpenLess file logger unavailable: {error}"); + match openless_linux_egui::init_file_logger(&config.data_dir) { + Ok(_) => log::info!("[startup] single-instance lock acquired; running as primary"), + Err(error) => eprintln!("OpenLess file logger unavailable: {error}"), } if let Some(error) = tray_error { // 没有托盘 ⇒ 关窗就退出:这是用户行为差异,必须写进启动日志。 @@ -10517,6 +10508,134 @@ Internal flags (set by OpenLess itself, not for regular use): } } + #[test] + fn startup_logging_records_forwarded_intents_without_rotating_again() { + use std::io::Write; + + const CHILD_ENV: &str = "OPENLESS_STARTUP_LOG_TEST_CHILD"; + const TEST: &str = "linux_app::tests::startup_logging_records_forwarded_intents_without_rotating_again"; + if std::env::var_os(CHILD_ENV).as_deref() != Some(std::ffi::OsStr::new("1")) { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST, "--nocapture", "--test-threads=1"]) + .env(CHILD_ENV, "1") + .env_remove("OPENLESS_LOG") + .env_remove("RUST_LOG") + .output() + .expect("launch isolated startup logging regression"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stdout}\n{stderr}"); + assert!( + stdout.contains("test result: ok. 1 passed; 0 failed"), + "{stdout}" + ); + return; + } + let root = tempfile::tempdir().unwrap(); + for key in [ + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_RUNTIME_DIR", + ] { + std::env::set_var(key, root.path()); + } + let runtime_dir = root.path().join("runtime"); + let data_dir = root.path().join("data"); + std::fs::create_dir_all(&runtime_dir).unwrap(); + std::fs::create_dir_all(data_dir.join("logs")).unwrap(); + let path = openless_linux_egui::log_path(&data_dir); + let old_log = vec![b'o'; 2 * 1024 * 1024 + 1]; + std::fs::write(&path, &old_log).unwrap(); + let args = vec!["openless".to_string()]; + let primary = match acquire_broker(&runtime_dir, &args).unwrap() { + BrokerAcquisition::Primary(broker) => Arc::new(broker), + BrokerAcquisition::Forwarded => panic!("the first launch must be primary"), + }; + openless_linux_egui::init_file_logger(&data_dir).unwrap(); + assert!(std::fs::read_to_string(&path) + .unwrap() + .contains("Linux egui logger ready:")); + + let mut app = fixture_app(false); + let host_actions = Arc::new(openless_linux_egui::LinuxHostActions::default()); + let mut dependencies = openless_core::BackendDependencies::unsupported(); + dependencies.host_actions = host_actions.clone(); + dependencies.credential_store = + Arc::new(openless_core::InMemoryCredentialStore::default()); + dependencies.services.remote_input = Arc::new( + openless_core::RemoteInputService::new( + Arc::new(openless_core::testing::RecordingRemoteInputRuntime::default()), + 8443, + "en", + ) + .unwrap(), + ); + let backend = Arc::new( + openless_core::OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.clone(), + ..Default::default() + }, + dependencies, + ) + .unwrap(), + ); + let runtime = openless_linux_egui::LinuxBackendRuntime { + backend: backend.clone(), + host_actions, + settings_runtime: Arc::new(openless_core::settings::NoopSettingsRuntime), + }; + app.native = Some( + app.tokio + .block_on(LinuxNativeRuntime::start( + runtime, + Some(primary.clone()), + None, + )) + .unwrap(), + ); + app.snapshot = Some(backend.snapshot()); + app.preferences = Some(backend.get_preferences()); + // 只测试 QA 的收起分支,不创建真实弹窗或启动录音。 + app.qa_visible = true; + + let mut active = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + active.write_all(&vec![b'x'; 2 * 1024 * 1024 + 1]).unwrap(); + active.write_all(b"\nactive-file-marker\n").unwrap(); + drop(active); + for args in [ + args, + vec!["openless".to_string(), "--toggle-qa".to_string()], + ] { + assert!(matches!( + acquire_broker(&runtime_dir, &args).unwrap(), + BrokerAcquisition::Forwarded + )); + } + app.poll(&egui::Context::default()); + log::logger().flush(); + let text = std::fs::read_to_string(&path).unwrap(); + for intent in ["ShowMain", "Cli(ToggleQa)"] { + let message = format!("[ui-host] launch intent from the user: {intent}"); + assert_eq!(text.matches(&message).count(), 1, "{message}"); + } + assert!(text.contains("active-file-marker")); + assert!(app.window.should_be_open); + assert!(!app.qa_visible); + assert_eq!( + std::fs::read(data_dir.join("logs/openless.log.1")).unwrap(), + old_log + ); + assert!(!data_dir.join("logs/openless.log.2").exists()); + assert!(!data_dir.join("logs/openless.log.3").exists()); + drop(app); + drop(primary); + } + #[test] fn an_idle_popup_submission_reaches_core_and_displays_failure() { let data_dir = tempfile::tempdir().unwrap(); diff --git a/openless-all/app/scripts/egui-logging-contract.test.mjs b/openless-all/app/scripts/egui-logging-contract.test.mjs index d9088054e..1ff1eb13e 100644 --- a/openless-all/app/scripts/egui-logging-contract.test.mjs +++ b/openless-all/app/scripts/egui-logging-contract.test.mjs @@ -141,10 +141,39 @@ assert.match( /"\[dictation\] start session_id=/, 'the dictation start boundary must be logged (hotkey pressed but nothing happened)', ); +const brokerStart = main.indexOf(' fn acquire_broker('); +const brokerEnd = main.indexOf(' impl Drop for OpenLessEguiApp', brokerStart); +assert.ok(brokerStart >= 0 && brokerEnd > brokerStart, 'find the single-instance acquisition function'); +assert.doesNotMatch( + main.slice(brokerStart, brokerEnd), + /log::(?:info|warn|error)!/, + 'single-instance acquisition runs before logger initialization; it must not emit lost startup logs', +); +const runStart = main.indexOf(' pub fn run() -> Result<(), String>'); +const runEnd = main.indexOf(' #[cfg(test)]', runStart); +assert.ok(runStart >= 0 && runEnd > runStart, 'find the production startup function'); +const startup = main.slice(runStart, runEnd); +const acquire = startup.indexOf('acquire_broker('); +const forwardedExit = startup.indexOf('BrokerAcquisition::Forwarded => return Ok(())'); +const loggerInit = startup.indexOf('init_file_logger('); +const primaryLog = startup.indexOf('[startup] single-instance lock acquired'); +assert.ok( + acquire >= 0 && forwardedExit > acquire && loggerInit > forwardedExit && primaryLog > loggerInit, + 'only the primary may initialize/rotate logs, and its role must be logged after initialization', +); assert.match( - main, - /"\[startup\] another instance owns the lock/, - 'a forwarded second launch must leave a trace, otherwise "clicking the icon does nothing" is invisible', + startup, + /match openless_linux_egui::init_file_logger\([^)]*\)\s*\{\s*Ok\([^)]*\)\s*=>\s*(?:\{\s*)?log::info!/, + 'the primary startup line must only be emitted after successful logger initialization', +); +const intentLoopStart = main.indexOf(' for intent in launch_intents {'); +const intentLoopEnd = main.indexOf(' // 本地边沿先于插件信号处理', intentLoopStart); +assert.ok(intentLoopStart >= 0 && intentLoopEnd > intentLoopStart, 'find the primary intent-consumption loop'); +const intentLoop = main.slice(intentLoopStart, intentLoopEnd); +const receivedLog = intentLoop.indexOf('[ui-host] launch intent from the user:'); +assert.ok( + receivedLog >= 0 && receivedLog < intentLoop.indexOf('if matches!('), + 'the primary must log forwarded intents before any dispatch branch, including ToggleQa', ); // 9. Per-event chatter must not sit at info.