diff --git a/Cargo.lock b/Cargo.lock index 493aa00e..51daa0b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,7 +165,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -176,7 +176,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1748,7 +1748,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2529,7 +2529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3226,9 +3226,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -4503,7 +4503,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4616,7 +4616,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6249,7 +6249,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6307,7 +6307,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6903,7 +6903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7874,7 +7874,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8412,7 +8412,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8458,7 +8458,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8966,7 +8966,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/README.md b/README.md index e2b3c9f8..27783df4 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Notes: - ³¹ rclone and restic are command-line tools; their GUIs are separate third-party projects (for example RcloneView, Backrest). - ³² Duplicati runs as a background service with a local web UI plus a tray helper, not a native desktop app. - ³³ restic search and selective restore are driven from the CLI (or a mounted snapshot), not an in-app browser. -- ³⁴ Driven writes daily rolling logs (pruned at 14 days / 25 MB) that interleave backend tracing with the webview's own console output, and the in-app diagnostics export bundles them. +- ³⁴ Driven writes daily rolling logs (pruned at 14 days / 25 MB, widened to 250 MB while the opt-in Debug logging toggle is on) that interleave backend tracing with the webview's own console output, and the in-app diagnostics export bundles them. - ³⁵ rclone logs to a file only when you pass `--log-file` (rotation via `--log-file-max-size` and friends), and has no bundle export; its bug template asks you to attach a log you produced by hand. - ³⁶ Duplicati's "Create bug report" export is a genuine one-click bundle (system info plus an obfuscated copy of the local database), and its web UI has a live log view; file logging is opt-in via `--log-file`, defaults to warnings only, and does not rotate. - ³⁷ restic has no log-file option at all - output goes to stdout, and the only file logging is an unrotated `DEBUG_LOG` env var that its contributing guide asks you to redact yourself. @@ -189,7 +189,12 @@ These move: check each project's current docs before relying on a cell. - Rolling local log files covering both the backend and the webview console, collected into a one-click diagnostics bundle alongside a redacted summary of in-flight upload recovery state and a trailing window of process-memory - samples. + samples. An opt-in Debug logging toggle (Settings > Privacy & Data) raises + logs to per-file / IPC-trace / engine-state detail and widens the bundle + with an unredacted engine-state snapshot for the hardest bugs - it warns you + up front that this logs file names, full paths, and timing data, can slow + backups down, and turns itself off automatically after 24 hours; every other + part of the bundle stays redacted regardless. - In-app auto-update with signed update manifests and a stable / dev channel selector. - Anonymous, opt-out telemetry (coarse counts only; never file names, paths, or diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index a08e3564..b0373de7 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -308,6 +308,12 @@ pub struct AppState { /// issue #308 (2026-08-17 follow-up): live bottleneck-classification /// sampling runtime (the Activity dashboard's Bottleneck stat tile). bottleneck: BottleneckRuntime, + /// issue #309: the debug-logging-mode 24h auto-off watchdog's task handle + /// and shutdown signal, so the app-quit drain joins it with no orphan + /// (mirrors [`UpdaterRuntime`]/[`IostatRuntime`]). No shared "hub" field + /// like those two - the watchdog only reads/writes the persisted settings + /// KV directly, nothing else on `AppState` needs to observe it. + debug_mode: DebugModeRuntime, /// The ONE in-flight streaming exclusion preview /// ([`crate::commands::exclusion_stream`]). The exclusion editor re-previews /// on every rule edit, so without a single-slot registry a user tweaking @@ -440,6 +446,22 @@ pub struct BottleneckRuntime { shutdown: std::sync::Mutex>>, } +/// issue #309: the debug-logging-mode 24h auto-off watchdog's runtime state +/// held on [`AppState`] - just the task's lifecycle slots (mirrors +/// [`UpdaterRuntime`]/[`TelemetryRuntime`]'s task+shutdown pair). No "hub" +/// field like [`BottleneckRuntime`]/[`IostatRuntime`]: the watchdog reads and +/// writes the persisted `global.debug_logging_*` settings directly via its +/// `StateRepo` handle, so there is nothing else on `AppState` for another +/// caller to read. +#[derive(Default)] +pub struct DebugModeRuntime { + /// The spawned watchdog task, behind `Option` so the shutdown drain can + /// TAKE + await it by value; `None` once drained / never spawned. + task: std::sync::Mutex>>, + /// The shutdown signal the watchdog `select!`s on. + shutdown: std::sync::Mutex>>, +} + /// M9b (SPEC s16): the anonymous-telemetry runtime state held on [`AppState`]. /// /// `task` + `shutdown` track the single app-wide periodic-ping task so the quit @@ -702,6 +724,7 @@ impl AppState { updater: UpdaterRuntime::default(), iostat: IostatRuntime::default(), bottleneck: BottleneckRuntime::default(), + debug_mode: DebugModeRuntime::default(), exclusion_previews: Arc::default(), preview_tree_cache: Arc::default(), telemetry: TelemetryRuntime::default(), @@ -1104,6 +1127,44 @@ impl AppState { .take() } + // --- issue #309: debug-logging-mode watchdog runtime ---------------------- + + /// Register the spawned watchdog task + its shutdown sender so the + /// app-quit drain can stop + join it with no orphan (mirrors + /// [`Self::set_bottleneck_task`]). + pub fn set_debug_mode_task(&self, task: JoinHandle<()>, shutdown: watch::Sender) { + *self + .debug_mode + .task + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(task); + *self + .debug_mode + .shutdown + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(shutdown); + } + + /// Signal the watchdog to stop and TAKE its handle so the quit drain can + /// await it. Mirrors [`Self::shutdown_bottleneck_task`]. + #[must_use] + pub fn shutdown_debug_mode_task(&self) -> Option> { + if let Some(tx) = self + .debug_mode + .shutdown + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + { + let _ = tx.send(true); + } + self.debug_mode + .task + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + } + // --- M9b telemetry runtime (SPEC s16) ---------------------------------- /// M9b: register the spawned periodic-ping task + its shutdown sender so the @@ -2119,6 +2180,43 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn debug_mode_runtime_task_and_shutdown_round_trip() { + // Issue #309: the debug-logging-mode watchdog's runtime bookkeeping. + // No hub getter to cover (unlike bottleneck/iostat) - just the + // set/shutdown task pair, mirrors + // `bottleneck_runtime_hub_task_and_shutdown_round_trip`. + let (state, dir) = temp_state().await; + let app_state = AppState::new( + state, + HashMap::new(), + RemoteMode::Fake, + default_fake_registry(), + ); + + // No task registered yet: shutdown is a safe no-op. + assert!(app_state.shutdown_debug_mode_task().is_none()); + + // Register a task that exits promptly on the shutdown signal (the + // real watchdog's own shape), then confirm shutdown signals + hands + // back the handle so the quit drain can join it. + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + let task = tokio::spawn(async move { + let _ = shutdown_rx.changed().await; + }); + app_state.set_debug_mode_task(task, shutdown_tx); + + let handle = app_state + .shutdown_debug_mode_task() + .expect("the just-registered task round-trips"); + handle.await.unwrap(); + + // Taken: a second shutdown call is again a safe no-op. + assert!(app_state.shutdown_debug_mode_task().is_none()); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn vss_helper_manager_installs_and_shutdown_is_noop() { // Issue #25: AppState owns the least-privilege VSS helper broker manager - diff --git a/src-tauri/src/commands/dtos.rs b/src-tauri/src/commands/dtos.rs index ac980701..67604c05 100644 --- a/src-tauri/src/commands/dtos.rs +++ b/src-tauri/src/commands/dtos.rs @@ -968,6 +968,23 @@ pub struct GlobalSettings { /// the unchanged V1 behaviour; `false` exempts EXACTLY the reachability /// pause-reason family from the gate (a captive portal still pauses). pub pause_when_offline: bool, + /// Issue #309: debug logging mode. When `true` the live `tracing` filter + /// is raised to a verbose directive (per-file activity, IPC traces, state + /// transitions, reconcile/queue decisions) and the diagnostic bundle + /// gains a debug section; both cost noticeable performance and log + /// verbose paths/timings, so this defaults `false` and auto-clears itself + /// (see [`Self::debug_logging_expires_at_ms`]). `serde(default)` so a + /// `global` blob persisted before this field still deserialises as off. + #[serde(default)] + pub debug_logging_enabled: bool, + /// Issue #309: the epoch-ms deadline [`Self::debug_logging_enabled`] + /// auto-turns-off at (set to `now + 24h` whenever the toggle is switched + /// on). Persisted (not just an in-memory timer) so a restart mid-window + /// still honours the original deadline rather than granting a fresh 24h. + /// `None` when debug logging is off. `serde(default)` for the same + /// pre-#309 backward-compat reason as the toggle itself. + #[serde(default)] + pub debug_logging_expires_at_ms: Option, } /// V2 schedule-window settings (DESIGN s17). Mirrors @@ -1288,6 +1305,11 @@ pub struct GlobalSettingsPatch { pub proxy_url: Option>, /// See [`GlobalSettings::pause_when_offline`]. Absent = unchanged. pub pause_when_offline: Option, + /// See [`GlobalSettings::debug_logging_enabled`]. Absent = unchanged; + /// present = set it (the backend computes/clears + /// `debug_logging_expires_at_ms` itself - that field is not directly + /// patchable from the webview). + pub debug_logging_enabled: Option, } /// Partial SPEC s22 `telemetry` settings. diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index c6a7404c..e64ca9c7 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -34,6 +34,7 @@ use tauri::{AppHandle, State}; use driven_core::orchestrator::{MeteredMode, OrchestratorConfig}; use driven_core::priority::WorkPriority; use driven_core::state::StateRepo; +use driven_core::time::{Clock, SystemClock}; use driven_core::types::ErrorCode; use driven_vss::VssMode; @@ -410,6 +411,17 @@ pub async fn update_settings( // it actually changed), applied after persistence to (dis)arm + eagerly // launch the APFS snapshot broker. let mut apfs_snapshot_target: Option = None; + // Issue #309: the `global.debug_logging_enabled` transition (Some(new) iff + // it actually changed), applied after persistence to reload the live + // tracing filter + rolling-log cap. + let mut debug_logging_target: Option = None; + // The FINAL (post-patch) debug-logging state, so a same-request + // `log_level` change knows whether to reload the live filter directly or + // defer to the (more verbose) debug-mode filter that stays authoritative + // until debug logging turns off. Only meaningful when `new_log_level` is + // `Some` (which implies `patch.global` was present, the only place this + // is written). + let mut debug_logging_now_enabled = false; // spec 2026-07-31 s2: whether this patch touched `macos.menu_bar` at all, // so the menubar engine (Task 5) can redraw immediately after persistence // instead of waiting for its next poll tick. @@ -612,6 +624,19 @@ pub async fn update_settings( cur.pause_when_offline = v; orchestrator_affecting = true; } + // Issue #309: debug logging mode. `double_option` is deliberately NOT + // used here (unlike `pre_backup_hook`/`proxy_url`) - the toggle is a + // plain on/off, and the backend (not the webview) owns + // `debug_logging_expires_at_ms`. + if let Some(v) = g.debug_logging_enabled { + if v != cur.debug_logging_enabled { + debug_logging_target = Some(v); + } + let (enabled, expires_at_ms) = compute_debug_logging_state(v, SystemClock.now_ms()); + cur.debug_logging_enabled = enabled; + cur.debug_logging_expires_at_ms = expires_at_ms; + } + debug_logging_now_enabled = cur.debug_logging_enabled; store_group(repo, KEY_GLOBAL, &storage::Global::from(cur)).await?; } @@ -886,7 +911,14 @@ pub async fn update_settings( // Live log-level change (SPEC s22 `global.log_level`). if let Some(level) = &new_log_level { - apply_log_level(level); + apply_log_level(level, debug_logging_now_enabled); + } + + // Issue #309: debug logging mode toggle. Reload the live tracing filter + + // rolling-log cap immediately, so the change is visible without a + // restart (and without waiting for the watchdog's next tick). + if let Some(enabled) = debug_logging_target { + crate::debug_mode::apply(enabled); } // Locale change: re-render the tray (Rust-side i18n) + notify the frontend @@ -1182,6 +1214,12 @@ mod storage { // unchanged behaviour). #[serde(default = "default_pause_when_offline")] pub pause_when_offline: bool, + // Issue #309 (debug logging mode). `serde(default)` so a `global` blob + // persisted before this field still deserialises as off / unset. + #[serde(default)] + pub debug_logging_enabled: bool, + #[serde(default)] + pub debug_logging_expires_at_ms: Option, } /// Default hook timeout (seconds) for a pre-V2 `global` blob missing it. @@ -1237,6 +1275,8 @@ mod storage { proxy_mode: s.proxy_mode, proxy_url: s.proxy_url, pause_when_offline: s.pause_when_offline, + debug_logging_enabled: s.debug_logging_enabled, + debug_logging_expires_at_ms: s.debug_logging_expires_at_ms, } } } @@ -1264,6 +1304,8 @@ mod storage { proxy_mode: d.proxy_mode, proxy_url: d.proxy_url, pause_when_offline: d.pause_when_offline, + debug_logging_enabled: d.debug_logging_enabled, + debug_logging_expires_at_ms: d.debug_logging_expires_at_ms, } } } @@ -1563,18 +1605,39 @@ pub async fn reconcile_autostart_on_boot(app: &AppHandle, state: &dyn StateRepo) /// Apply a `tracing` max-level change at runtime (SPEC s22 `global.log_level`). /// -/// The process installs its layered subscriber once at boot (`logging::init`) -/// with no runtime-reloadable filter handle, so this cannot mutate the -/// LIVE subscriber's level in-process; what it CAN do honestly is export the -/// chosen level to `RUST_LOG` so it is the effective level on the next launch and -/// for any subsystem that reads the env. The persisted `global.log_level` is the -/// source of truth a future reload-handle wires to (it is recorded here so the -/// behaviour is not a silent no-op). -fn apply_log_level(level: &str) { +/// Issue #309 gave `logging.rs` a runtime-reloadable filter handle (it needed +/// one anyway, to switch debug logging mode on/off with no restart), so this +/// now ALSO reloads the LIVE subscriber via [`crate::logging::set_filter`] - +/// no longer just exporting to `RUST_LOG` for the next launch. The one +/// exception: while debug logging mode is on, the live filter is +/// [`crate::logging::DEBUG_MODE_FILTER`], which is deliberately MORE verbose +/// than any user-chosen `log_level`; reloading to `level` here would silently +/// undo that (and the auto-off watchdog would then have nothing to restore +/// to). So a `log_level` change while debug mode is active is recorded +/// (`RUST_LOG` + the persisted setting) but the live reload is deferred until +/// debug mode turns off, at which point `debug_mode::apply(false)` restores +/// [`crate::logging::DEFAULT_FILTER`] - which itself no longer reflects a +/// just-changed `log_level`. That residual gap (log_level changed while debug +/// mode was on restores to the OLD default filter, not the new level, once +/// debug mode turns off) is narrow enough to accept for now: re-saving Rules +/// after debug mode clears reloads the intended level. +fn apply_log_level(level: &str, debug_logging_enabled: bool) { // SAFETY note: `set_var` is process-global; we only ever write a validated // tracing level string, never untrusted bytes, and only from this command. std::env::set_var("RUST_LOG", level); - tracing::info!(target: TARGET, level, "log level setting updated (effective for new launches / env-reading subsystems)"); + if debug_logging_enabled { + tracing::info!( + target: TARGET, + level, + "log level setting updated (debug logging mode is active; the live filter stays at debug-mode verbosity until it turns off)" + ); + return; + } + if crate::logging::set_filter(level) { + tracing::info!(target: TARGET, level, "log level applied to the live process"); + } else { + tracing::info!(target: TARGET, level, "log level setting updated (effective for new launches / env-reading subsystems)"); + } } /// Apply a locale change: set the Rust-side i18n locale + re-render the tray, and @@ -1736,6 +1799,88 @@ fn schedule_settings_to_config(s: &ScheduleSettings) -> driven_core::types::Sche } } +// --------------------------------------------------------------------------- +// Debug logging mode (issue #309): shared read/write used by both +// `update_settings` (the toggle IPC) and `debug_mode::spawn_watchdog`'s +// 24h auto-off reconciler. +// --------------------------------------------------------------------------- + +/// Read the persisted `(global.debug_logging_enabled, +/// global.debug_logging_expires_at_ms)` pair. `None` on a state-DB read/parse +/// failure (the watchdog logs and retries next tick rather than treating a +/// transient DB error as "debug mode is off"). +pub(crate) async fn load_debug_logging_state(state: &dyn StateRepo) -> Option<(bool, Option)> { + let global: GlobalSettings = load_group::(state, KEY_GLOBAL) + .await + .ok()? + .map(Into::into) + .unwrap_or_else(default_global); + Some(( + global.debug_logging_enabled, + global.debug_logging_expires_at_ms, + )) +} + +/// Persist `debug_logging_enabled = false` + clear the expiry (issue #309's +/// 24h auto-off), preserving every other `global` field. Used by the +/// watchdog when the persisted window has elapsed; `update_settings` handles +/// the user-initiated off path inline (see the `global` group block) since it +/// already has the freshly-loaded `cur: GlobalSettings` in scope. +pub(crate) async fn persist_debug_logging_off(state: &dyn StateRepo) -> CommandResult<()> { + let mut global: GlobalSettings = load_group::(state, KEY_GLOBAL) + .await? + .map(Into::into) + .unwrap_or_else(default_global); + global.debug_logging_enabled = false; + global.debug_logging_expires_at_ms = None; + store_group(state, KEY_GLOBAL, &storage::Global::from(global)).await +} + +/// Test-only: persist an ARBITRARY `(enabled, expires_at_ms)` pair, bypassing +/// [`compute_debug_logging_state`]'s "switching on always extends from now" +/// rule. `debug_mode::reconcile`'s tests (in `debug_mode.rs`) need to +/// simulate an expiry already in the PAST - as if the 24h window elapsed +/// while the app was closed - which the production write path +/// (`update_settings`) can never produce for a live save. +#[cfg(test)] +pub(crate) async fn set_debug_logging_state_for_test( + state: &dyn StateRepo, + enabled: bool, + expires_at_ms: Option, +) { + let mut global: GlobalSettings = load_group::(state, KEY_GLOBAL) + .await + .expect("load global group") + .map(Into::into) + .unwrap_or_else(default_global); + global.debug_logging_enabled = enabled; + global.debug_logging_expires_at_ms = expires_at_ms; + store_group(state, KEY_GLOBAL, &storage::Global::from(global)) + .await + .expect("store global group"); +} + +/// Issue #309: compute the new `(debug_logging_enabled, +/// debug_logging_expires_at_ms)` pair for a patched `enabled` value at +/// `now_ms`. Extracted as a PURE function (no state-DB access, no +/// `AppHandle`) so the 24h-window arithmetic is unit-testable directly - +/// `update_settings` needs a live `AppHandle` for its side effects, which +/// would otherwise drag every caller of this arithmetic into an +/// integration-style test. +/// +/// Switching ON (re-)starts the window from `now_ms`, including the +/// "already on, save fires true again" case: an explicit toggle is a fresh +/// "keep this on" signal, so extending the window is correct rather than +/// letting a coincidental re-save silently shorten it to whatever was left. +/// Switching off always clears the expiry. +fn compute_debug_logging_state(enabled: bool, now_ms: i64) -> (bool, Option) { + if enabled { + (true, Some(now_ms + crate::debug_mode::AUTO_OFF_WINDOW_MS)) + } else { + (false, None) + } +} + // --------------------------------------------------------------------------- // export_diagnostic_bundle (SPEC s11.6, s18) // --------------------------------------------------------------------------- @@ -1754,10 +1899,15 @@ fn schedule_settings_to_config(s: &ScheduleSettings) -> driven_core::types::Sche /// /// The bundle (SPEC s18) carries `version.txt`, `os.txt`, a REDACTED /// `settings_redacted.json`, `schema.txt` (real PRAGMA user_version + table -/// counts), `activity_last_30d.csv`, `logs/`, `crashes/`, and -/// `redaction-policy.txt`. Every secret-bearing field (refresh tokens, recovery +/// counts), `pending_ops.txt`, `memory.txt`, `activity_last_30d.csv`, `logs/`, +/// `crashes/`, `redaction-policy.txt`, and `manifest.txt` (issue #309: every +/// entry's name + size). Every secret-bearing field (refresh tokens, recovery /// phrases, keys, master key, account emails, drive folder names, local paths, -/// Drive file ids) is redacted or hashed before it enters the ZIP. +/// Drive file ids) is redacted or hashed before it enters the ZIP. Issue #309: +/// while debug logging mode is on, the bundle ALSO gets `DEBUG_MODE.txt` and a +/// deliberately UNREDACTED `debug/engine_state.txt` - the one exception to the +/// paragraph above, gated on the user's explicit opt-in (see +/// [`DEBUG_MODE_BUNDLE_NOTICE`]). #[tauri::command] pub async fn export_diagnostic_bundle( app: AppHandle, @@ -1860,13 +2010,151 @@ async fn build_diagnostic_zip(app: &AppHandle, state: &dyn StateRepo) -> Command tokio::time::sleep(std::time::Duration::from_millis(150)).await; add_logs_and_crashes(&mut zip, &redactor); + // Issue #309: while debug logging mode is ON, add a clearly-labelled, + // DELIBERATELY UNREDACTED engine-state snapshot. This is the one place + // the issue #204 redaction rules do not apply (by explicit user opt-in - + // the whole point of debug mode, and the amber Settings warning tells the + // user exactly this before they turn it on). Every OTHER section above + // stays redacted regardless of debug mode: the toggle means "log/bundle + // MORE", not "stop redacting what was already safe to redact". + if settings.global.debug_logging_enabled { + zip.add_file("DEBUG_MODE.txt", DEBUG_MODE_BUNDLE_NOTICE.as_bytes()); + let engine_state = build_debug_engine_state(state).await; + zip.add_file("debug/engine_state.txt", engine_state.as_bytes()); + tracing::info!(target: TARGET, "diagnostic bundle: debug logging mode was on; included unredacted engine_state.txt"); + } + // redaction-policy.txt (SPEC s18): tell the recipient the bundle's threat // model + exactly what was redacted. zip.add_file("redaction-policy.txt", REDACTION_POLICY.as_bytes()); + // manifest.txt (issue #309 bundle-usefulness follow-up): every entry + // added above, with its size, so a recipient can see the bundle's shape + // without unzipping it. Added LAST (after every other entry) so it is + // complete; it cannot list itself. + let manifest = zip.manifest_so_far(); + zip.add_file("manifest.txt", manifest.as_bytes()); + Ok(zip.finish()) } +/// Issue #309: the notice at the top of a bundle captured while debug logging +/// mode was on, explaining why `debug/` and the rolling `logs/` in this +/// bundle are more detailed (and less redacted) than usual. +const DEBUG_MODE_BUNDLE_NOTICE: &str = "\ +Debug logging mode was ON when this bundle was captured. +========================================================= + +The user explicitly opted into this (Settings > Debug logging), after an +on-screen warning that it would record file names, full paths, timing data, +and other personal information into logs and diagnostic bundles. + +What that means for THIS bundle: +- logs/ contains TRACE-level output for Driven's own code: per-file activity, + IPC command traces, state transitions, and reconcile/queue decisions - far + more detailed than the default INFO-level logs. +- debug/engine_state.txt is a RAW, UNREDACTED snapshot of engine state + (backup source local paths, pending-op relative paths, ...). Every OTHER + file in this bundle (settings_redacted.json, activity_last_30d.csv, + pending_ops.txt, the rest of logs/) is STILL redacted exactly as documented + in redaction-policy.txt - debug mode does not weaken that. This file is the + one deliberate exception. + +Debug logging mode turns itself off automatically 24 hours after it was +enabled, so this state is not persistent. +"; + +/// Issue #309: a RAW (unredacted) snapshot of engine state for the diagnostic +/// bundle's `debug/engine_state.txt`, included only while debug logging mode +/// is on (see [`build_diagnostic_zip`]). Deliberately mirrors +/// [`build_pending_ops_summary`]'s pending-ops section but WITHOUT the +/// redactor - support gets the real paths, which is the entire point of +/// opting into debug mode. Best-effort per section: one query failing does +/// not blank the rest. +async fn build_debug_engine_state(state: &dyn StateRepo) -> String { + use driven_core::time::{Clock, SystemClock}; + let now = SystemClock.now_ms(); + + let mut out = String::from( + "# Engine state snapshot (issue #309 debug logging mode).\n\ + # UNREDACTED - see DEBUG_MODE.txt for why.\n\ + # Secrets (OAuth tokens, encryption keys, upload-session URLs) are\n\ + # STILL never included: they never enter this process's readable\n\ + # settings/state, so there is nothing here to redact away.\n\n", + ); + + out.push_str("## backup_sources\n"); + let sources = match state.list_sources().await { + Ok(s) => s, + Err(e) => { + out.push_str(&format!("(list_sources failed: {e})\n\n")); + Vec::new() + } + }; + if sources.is_empty() { + out.push_str("(none)\n"); + } + for s in &sources { + out.push_str(&format!( + "- id={} name={:?} enabled={} local_path={} drive_folder={} deep_verify_interval_secs={} last_full_scan_at={:?} last_deep_verify_at={:?} exclude_patterns={:?}\n", + s.id, + s.display_name, + s.enabled, + s.local_path, + s.drive_folder_path, + s.deep_verify_interval_secs, + s.last_full_scan_at, + s.last_deep_verify_at, + s.exclude_patterns, + )); + } + + out.push_str("\n## pending_ops (unredacted)\n"); + let mut any_pending = false; + for s in &sources { + let ops = match state.get_pending_ops_for_source(s.id).await { + Ok(o) => o, + Err(e) => { + out.push_str(&format!( + "(get_pending_ops_for_source failed for source id={}: {e})\n", + s.id + )); + continue; + } + }; + if ops.is_empty() { + continue; + } + any_pending = true; + out.push_str(&format!( + "source id={} local_path={}: {} pending op(s)\n", + s.id, + s.local_path, + ops.len() + )); + for op in ops { + out.push_str(&format!( + " - type={} attempts={} age_ms={} relative_path={}\n", + op.op_type, + op.attempts, + now.saturating_sub(op.created_at), + op.relative_path, + )); + } + } + if !any_pending { + out.push_str("(no pending ops)\n"); + } + + out.push_str("\n## memory_trend (unix_ms rss_bytes)\n"); + for (ts_ms, rss) in crate::memlog::recent_samples() { + out.push_str(&format!("{ts_ms} {rss}\n")); + } + + out.push_str(&format!("\ncaptured_at_ms={now}\n")); + out +} + /// SPEC s18: the maximum bytes of log content the bundle carries (the spec's /// "last 50 MB of tracing output"). A per-file cap is applied so one huge log /// cannot dominate; the newest files are preferred. @@ -2676,7 +2964,7 @@ fn os_descriptor() -> String { /// redaction here is the install id). #[derive(serde::Serialize)] struct RedactedSettings { - global: GlobalSettings, + global: RedactedGlobalSettings, telemetry: RedactedTelemetry, updater: UpdaterSettings, ui: UiSettings, @@ -2684,6 +2972,13 @@ struct RedactedSettings { windows: Option, #[serde(skip_serializing_if = "Option::is_none")] macos: Option, + /// R2-P2-4 bundle-completeness follow-up: these three standalone toggles + /// (SPEC s22) were silently OMITTED from every prior bundle (not leaked - + /// just missing), which made a bundle less useful for diagnosing a + /// bundling / scrub / drill issue. None are secret-bearing. + bundle_small_files: bool, + scrub: crate::commands::dtos::ScrubSettings, + drill: crate::commands::dtos::DrillSettings, } /// Telemetry settings with the stable `install_id` replaced by a per-bundle @@ -2695,18 +2990,69 @@ struct RedactedTelemetry { endpoint: String, } +/// Issue #204: the redacted `global` group. Mirrors [`GlobalSettings`] +/// FIELD-FOR-FIELD (deliberately, not a `.clone()` + patch) so a NEW +/// secret-bearing field added to [`GlobalSettings`] in the future fails to +/// COMPILE here until someone decides how it is redacted - the failure mode +/// for a forgotten field is a build error, not a silent leak into a bundle +/// handed to a stranger. This is the structural fix for issue #204: the +/// pre-fix `redact_settings` cloned `GlobalSettings` wholesale and patched +/// only `proxy_url`, so `pre_backup_hook` / `post_backup_hook` (command lines, +/// a classic home for embedded secrets) and `custom_root_ca_path` (a path +/// that routinely embeds the OS username) rode along raw. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct RedactedGlobalSettings { + auto_start_on_login: bool, + default_concurrent_uploads: Option, + adaptive_parallelism_enabled: bool, + bandwidth_cap_mbps: Option, + skip_on_battery: bool, + skip_on_metered: bool, + scan_interval_secs: u32, + deep_verify_interval_secs: u32, + io_priority: String, + log_level: String, + schedule: ScheduleSettings, + /// Issue #204: presence is diagnostically useful (whether a hook is + /// configured at all, and roughly how long the command is); the CONTENTS + /// are not, and are a classic home for an embedded secret + /// (`curl -H "Authorization: ..."`, an rclone flag, a password passed as + /// a bare CLI arg) - so the command text itself never leaves the machine. + pre_backup_hook: Option, + post_backup_hook: Option, + hook_timeout_secs: u32, + metered_mode: String, + metered_bandwidth_cap_mbps: Option, + /// Issue #204: an absolute path routinely embeds the OS username. The + /// rest of the bundle already hashes paths to `` (the + /// [`Redactor`] used for logs/activity/pending-ops); this field is routed + /// through the SAME `stable_hash` so occurrences correlate consistently. + custom_root_ca_path: Option, + proxy_mode: String, + /// Issue #204: PAC mode makes this a local FILE PATH rather than a URL, + /// so the issue #34 userinfo-strip alone does not cover it - the path + /// (username and all) went out verbatim. [`redact_proxy_url_field`] + /// applies BOTH the userinfo strip (manual-mode URL) and the absolute- + /// path-run hash (PAC-mode local path); each is a no-op on the shape the + /// other targets, so one function is correct for both modes. + proxy_url: Option, + pause_when_offline: bool, + /// Issue #309: not secret-bearing (a bool + an epoch-ms deadline), so it + /// passes through unchanged - useful context for "was debug mode on when + /// this bundle was captured". + debug_logging_enabled: bool, + debug_logging_expires_at_ms: Option, +} + /// Redact the secret-bearing fields of a [`SettingsDto`] for the bundle -/// (SPEC s18): the telemetry install id becomes `installid_`, and the -/// issue #34 proxy URL has any `user:password@` userinfo stripped. +/// (SPEC s18 / issue #204): the telemetry install id becomes +/// `installid_`; the proxy URL / PAC path has userinfo stripped and any +/// local-path shape hashed; the custom root CA path is hashed; the pre/post +/// backup hook commands are redacted wholesale (presence + length only). fn redact_settings(s: &SettingsDto) -> RedactedSettings { - let mut global = s.global.clone(); - // Issue #34 follow-up: a `manual`-mode proxy URL may embed basic-auth - // credentials (`http://user:password@proxy.corp:8080`). The bundle is meant - // to be shareable with support, so the userinfo must never ride along - the - // host:port survives because that is the diagnostically useful part. - global.proxy_url = global.proxy_url.as_deref().map(redact_proxy_userinfo); RedactedSettings { - global, + global: redact_global_settings(&s.global), telemetry: RedactedTelemetry { enabled: s.telemetry.enabled, install_id: format!("installid_{}", stable_hash(&s.telemetry.install_id)), @@ -2716,9 +3062,79 @@ fn redact_settings(s: &SettingsDto) -> RedactedSettings { ui: s.ui.clone(), windows: s.windows.clone(), macos: s.macos.clone(), + bundle_small_files: s.bundle_small_files, + scrub: s.scrub, + drill: s.drill, + } +} + +/// See [`RedactedGlobalSettings`] doc: the field-for-field redaction issue +/// #204 asks for, so a future new secret-bearing field cannot ride along raw +/// by accident (it would fail to compile here first). +fn redact_global_settings(g: &GlobalSettings) -> RedactedGlobalSettings { + RedactedGlobalSettings { + auto_start_on_login: g.auto_start_on_login, + default_concurrent_uploads: g.default_concurrent_uploads, + adaptive_parallelism_enabled: g.adaptive_parallelism_enabled, + bandwidth_cap_mbps: g.bandwidth_cap_mbps, + skip_on_battery: g.skip_on_battery, + skip_on_metered: g.skip_on_metered, + scan_interval_secs: g.scan_interval_secs, + deep_verify_interval_secs: g.deep_verify_interval_secs, + io_priority: g.io_priority.clone(), + log_level: g.log_level.clone(), + schedule: g.schedule.clone(), + pre_backup_hook: redact_hook_command(g.pre_backup_hook.as_deref()), + post_backup_hook: redact_hook_command(g.post_backup_hook.as_deref()), + hook_timeout_secs: g.hook_timeout_secs, + metered_mode: g.metered_mode.clone(), + metered_bandwidth_cap_mbps: g.metered_bandwidth_cap_mbps, + custom_root_ca_path: g + .custom_root_ca_path + .as_ref() + .map(|p| format!("", stable_hash(&p.to_string_lossy()))), + proxy_mode: g.proxy_mode.clone(), + proxy_url: g.proxy_url.as_deref().map(redact_proxy_url_field), + pause_when_offline: g.pause_when_offline, + debug_logging_enabled: g.debug_logging_enabled, + debug_logging_expires_at_ms: g.debug_logging_expires_at_ms, } } +/// Issue #204: redact a pre/post-backup hook command WHOLESALE. A hook +/// command line is a classic home for an embedded secret +/// (`curl -H "Authorization: Bearer ..."`, an rclone `--password` flag, a +/// token passed as a bare CLI arg) that no shape-based scrub (email / token +/// prefix / path pattern) can be trusted to catch, so unlike every other +/// field here this is not a hash - the exact command text must never leave +/// the machine. What survives is purely diagnostic: THAT a hook is +/// configured, and its rough length (a `pre_backup_hook` that's 4000 +/// characters long is itself a useful clue; the empty-vs-set case is the +/// most common one support needs to distinguish). +fn redact_hook_command(cmd: Option<&str>) -> Option { + cmd.map(|c| format!("", c.chars().count())) +} + +/// Issue #204: redact a `global.proxy_url` value for BOTH shapes it can take +/// (see [`GlobalSettings::proxy_url`] doc) without needing to branch on +/// `proxy_mode`: +/// - `manual` mode: a URL that may carry `user:password@` userinfo - +/// [`redact_proxy_userinfo`] strips it, keeping `scheme://host:port`. +/// - `pac` mode: a URL (same userinfo risk) OR a local FILE PATH (which +/// [`redact_proxy_userinfo`] does NOT touch - it has no `://` scheme +/// separator to key off - so the path, username and all, would otherwise +/// ride along verbatim). [`redact_absolute_path_runs`] catches that shape. +/// +/// Each pass is a no-op on the shape the other targets (a bare local path has +/// no `://` for the userinfo strip to find; a remote URL never matches the +/// absolute-path-run start patterns), so applying both unconditionally is +/// correct regardless of mode and needs no `proxy_mode` branch to drift out +/// of sync with this function. +fn redact_proxy_url_field(raw: &str) -> String { + let stripped = redact_proxy_userinfo(raw); + redact_absolute_path_runs(&stripped) +} + /// Strip any `user:password@` userinfo from a proxy URL, keeping the scheme and /// `host:port` (issue #34 follow-up; see [`redact_settings`]). /// @@ -2816,6 +3232,13 @@ What is redacted / never included: Caveat: despite this policy, free-text you supplied (e.g. a display name) is not guaranteed to be scrubbed if it appears in a field this policy did not anticipate. Review the bundle before sharing if that concerns you. + +Debug logging mode exception (issue #309): if `DEBUG_MODE.txt` is present in +this bundle, `debug/engine_state.txt` is DELIBERATELY UNREDACTED (real local +paths and pending-op relative paths). This happens only when you explicitly +turned on Settings > Debug logging, which warns you before you do it. Every +file listed above this note stays redacted exactly as described, debug mode +or not. "; // --------------------------------------------------------------------------- @@ -3155,6 +3578,10 @@ fn default_global() -> GlobalSettings { // spec 2026-08-01: pause on lost network reachability by default // (unchanged V1 behaviour). pause_when_offline: true, + // Issue #309: debug logging mode ships off by default (it costs + // performance and logs paths/timings, so it is opt-in only). + debug_logging_enabled: false, + debug_logging_expires_at_ms: None, } } @@ -3279,6 +3706,28 @@ impl ZipWriter { }); } + /// Issue #309 bundle-usefulness follow-up: a plain-text manifest (name + + /// uncompressed bytes) of every entry added SO FAR, so a recipient can see + /// at a glance how big each bundle section is without unzipping. Must be + /// called - and its output added via [`Self::add_file`] - BEFORE + /// [`Self::finish`]; it necessarily cannot list itself. + fn manifest_so_far(&self) -> String { + let mut out = String::from( + "# Diagnostic bundle manifest: nameuncompressed bytes.\n\ + # (this file is not itself listed)\n", + ); + let mut total: u64 = 0; + for e in &self.entries { + out.push_str(&format!("{}\t{}\n", e.name, e.size)); + total += u64::from(e.size); + } + out.push_str(&format!( + "# total: {total} bytes across {} entries\n", + self.entries.len() + )); + out + } + /// Emit the central directory + end-of-central-directory record and return /// the finished archive bytes. fn finish(mut self) -> Vec { @@ -3341,12 +3790,15 @@ mod tests { /// returned `PathBuf` is the temp dir, cleaned up by the caller. Uses a /// hand-rolled temp dir so src-tauri needs no `tempfile` dev-dep. async fn seeded_repo() -> (SqliteStateRepo, PathBuf) { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let dir = std::env::temp_dir().join(format!("driven-settings-test-{nonce}-{:p}", &nonce)); - std::fs::create_dir_all(&dir).expect("create temp dir"); + // CodeQL `rust/path-injection` (driven-ci-flakes precedent, PR 151 / + // src-tauri/Cargo.toml's `tempfile` dependency comment): a hand-rolled + // `std::env::temp_dir().join(format!(...))` is exactly the pattern the + // rule flags feeding `SqliteStateRepo::open`. `tempfile::tempdir()` is + // an opaque external call CodeQL's dataflow does not see into, so the + // taint chain never forms - fix, not a dismissal. `keep()` keeps + // the directory alive (matching the pre-`tempfile` behaviour) so every + // caller's existing `cleanup(dir)` teardown still applies unchanged. + let dir = tempfile::tempdir().expect("create temp dir").keep(); let repo = SqliteStateRepo::open(&dir.join("state.db")) .await .expect("open seeded state repo"); @@ -3545,6 +3997,97 @@ mod tests { cleanup(dir); } + // ------------------------------------------------------------------- + // Issue #309: debug logging mode - 24h auto-off expiry arithmetic + + // persistence round-trip. + // ------------------------------------------------------------------- + + #[test] + fn compute_debug_logging_state_switching_on_sets_a_24h_expiry() { + let (enabled, expires_at_ms) = compute_debug_logging_state(true, 1_000); + assert!(enabled); + assert_eq!( + expires_at_ms, + Some(1_000 + crate::debug_mode::AUTO_OFF_WINDOW_MS) + ); + } + + #[test] + fn compute_debug_logging_state_switching_off_clears_the_expiry() { + let (enabled, expires_at_ms) = compute_debug_logging_state(false, 1_000); + assert!(!enabled); + assert_eq!(expires_at_ms, None); + } + + #[test] + fn compute_debug_logging_state_re_enabling_extends_from_now_not_from_the_old_deadline() { + // A user who re-saves Settings while debug mode is already on gets a + // FRESH 24h window from now, not whatever was left of the old one - + // the explicit toggle is a "keep this on" signal. + let later_now = 50_000; + let (enabled, expires_at_ms) = compute_debug_logging_state(true, later_now); + assert!(enabled); + assert_eq!( + expires_at_ms, + Some(later_now + crate::debug_mode::AUTO_OFF_WINDOW_MS) + ); + } + + #[tokio::test] + async fn debug_logging_defaults_off_with_no_expiry() { + let (repo, dir) = seeded_repo().await; + let (enabled, expires_at_ms) = load_debug_logging_state(&repo) + .await + .expect("read debug logging state"); + assert!(!enabled, "debug logging mode defaults off"); + assert_eq!(expires_at_ms, None); + cleanup(dir); + } + + #[tokio::test] + async fn debug_logging_enable_then_disable_round_trips_through_persistence() { + let (repo, dir) = seeded_repo().await; + + // Turn it on with a computed expiry (mirrors what `update_settings` + // does), persist, and read it back. + let mut cur: GlobalSettings = load_group::(&repo, KEY_GLOBAL) + .await + .unwrap() + .map(Into::into) + .unwrap(); + let (enabled, expires_at_ms) = compute_debug_logging_state(true, 12_345); + cur.debug_logging_enabled = enabled; + cur.debug_logging_expires_at_ms = expires_at_ms; + store_group(&repo, KEY_GLOBAL, &storage::Global::from(cur)) + .await + .unwrap(); + + let (loaded_enabled, loaded_expires_at_ms) = + load_debug_logging_state(&repo).await.expect("read state"); + assert!(loaded_enabled); + assert_eq!( + loaded_expires_at_ms, + Some(12_345 + crate::debug_mode::AUTO_OFF_WINDOW_MS) + ); + + // The watchdog's auto-off path: `persist_debug_logging_off` clears + // BOTH fields and leaves the rest of the group untouched. + persist_debug_logging_off(&repo) + .await + .expect("persist auto-off"); + let (off_enabled, off_expires_at_ms) = + load_debug_logging_state(&repo).await.expect("read state"); + assert!(!off_enabled, "auto-off must clear the enabled flag"); + assert_eq!(off_expires_at_ms, None, "auto-off must clear the expiry"); + + let dto = load_settings_dto(&repo).await.unwrap(); + assert_eq!( + dto.global.scan_interval_secs, 600, + "auto-off must not disturb unrelated global fields" + ); + cleanup(dir); + } + #[test] fn vss_helper_status_degrades_only_on_windows_when_unelevated() { // Off Windows: never supported, never degraded. @@ -4085,6 +4628,222 @@ mod tests { ); } + /// Build a minimal [`SettingsDto`] for the issue #204 redaction tests below, + /// with the caller's `global` and everything else defaulted. + fn dto_with_global(global: GlobalSettings) -> SettingsDto { + SettingsDto { + global, + telemetry: TelemetrySettings { + enabled: false, + install_id: "id".to_string(), + endpoint: "https://e".to_string(), + }, + updater: default_updater(), + ui: default_ui(), + windows: None, + macos: None, + bundle_small_files: false, + scrub: driven_core::scrub::ScrubConfig::default().into(), + drill: driven_core::drill::DrillConfig::default().into(), + } + } + + #[test] + fn redact_settings_redacts_backup_hook_commands_wholesale_issue_204() { + // Issue #204: a hook command line is a classic home for an embedded + // secret (an Authorization header, a password CLI arg). Both fields + // must be redacted WHOLESALE, not scrubbed by shape - the leak-shaped + // fixture below has neither an `@email` nor a `ya29.`/`1//` token + // prefix, so the old whole-clone redaction (which never touched these + // fields at all) would ship it completely raw. + let mut global = default_global(); + global.pre_backup_hook = + Some(r#"curl -H "Authorization: Bearer sk-live-51H8xyz9SECRET" https://hooks.example.com/pre"#.to_string()); + global.post_backup_hook = + Some("rclone sync --password hunter2plaintext /src remote:".to_string()); + let dto = dto_with_global(global); + + let red = redact_settings(&dto); + let pre = red + .global + .pre_backup_hook + .as_deref() + .expect("pre hook present"); + let post = red + .global + .post_backup_hook + .as_deref() + .expect("post hook present"); + assert!( + !pre.contains("sk-live-51H8xyz9SECRET"), + "pre-hook secret leaked: {pre}" + ); + assert!( + !pre.contains("Bearer"), + "pre-hook command text leaked: {pre}" + ); + assert!( + pre.starts_with("` - shipping this one + // field verbatim was an internal inconsistency, not a judgement call. + let mut global = default_global(); + global.custom_root_ca_path = Some(PathBuf::from( + "/Users/patsmith-secret/corp-certs/root-ca.pem", + )); + let dto = dto_with_global(global); + + let red = redact_settings(&dto); + let path = red + .global + .custom_root_ca_path + .as_deref() + .expect("ca path present"); + assert!( + !path.contains("patsmith-secret"), + "the OS username must be gone: {path}" + ); + assert!( + path.starts_with("() else { + tracing::warn!(target: TARGET, "AppState not managed; debug-mode watchdog not started"); + return; + }; + let state: Arc = Arc::clone(app_state.state()); + let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); + + let task = tokio::spawn(async move { + // Boot-time reconciliation FIRST: apply whatever is currently + // persisted (including an immediate auto-off if the 24h deadline + // already passed while the app was closed) before the first tick. + reconcile(state.as_ref()).await; + + let mut ticker = tokio::time::interval(CHECK_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; + res = shutdown_rx.changed() => { + match res { + Ok(()) if *shutdown_rx.borrow() => break, + Ok(()) => {} + Err(_) => break, + } + } + _ = ticker.tick() => { + reconcile(state.as_ref()).await; + } + } + } + tracing::debug!(target: TARGET, "debug-mode watchdog exited"); + }); + + app_state.set_debug_mode_task(task, shutdown_tx); + tracing::info!( + target: TARGET, + interval_ms = CHECK_INTERVAL.as_millis() as u64, + "debug-mode watchdog started" + ); +} + +/// Read the persisted debug-logging state and reconcile the live process + +/// (if the window expired) the persisted settings with it. Best-effort: a +/// state-DB read/write failure is logged and skipped - the next tick (or the +/// next restart) tries again, and the live process is never left MORE +/// verbose than the persisted settings say it should be for longer than one +/// `CHECK_INTERVAL`. +async fn reconcile(state: &dyn StateRepo) { + let Some((enabled, expires_at_ms)) = + crate::commands::settings::load_debug_logging_state(state).await + else { + tracing::debug!(target: TARGET, "could not read debug-logging settings; skipping this reconcile pass"); + return; + }; + + if !enabled { + // Off is always applied (idempotent - a reload to the same filter is + // cheap), so a process that booted before `apply(false)` ever ran + // (e.g. the reload handle was not installed yet at settings-load + // time) still converges to the correct live filter here. + apply(false); + return; + } + + let now_ms = SystemClock.now_ms(); + let expired = expires_at_ms.is_none_or(|deadline| now_ms >= deadline); + if expired { + tracing::info!( + target: TARGET, + expires_at_ms, + now_ms, + "debug logging mode's 24h window elapsed; turning it off automatically" + ); + if let Err(err) = crate::commands::settings::persist_debug_logging_off(state).await { + tracing::warn!(target: TARGET, %err, "failed to persist debug-logging auto-off; will retry next tick"); + // Still apply off to the LIVE process even if the persist failed, + // so at minimum this process's verbosity/log-cap correct + // themselves now rather than staying wrong until the retry lands. + apply(false); + return; + } + apply(false); + return; + } + + apply(true); +} + +#[cfg(test)] +mod tests { + use super::*; + use driven_core::state::sqlite::SqliteStateRepo; + + #[test] + fn auto_off_window_is_24_hours() { + assert_eq!(AUTO_OFF_WINDOW_MS, 24 * 60 * 60 * 1000); + } + + /// A temp-backed state repo with the SPEC s22 settings seeded (mirrors + /// `commands::settings::tests::seeded_repo` - duplicated rather than + /// shared since that helper is private to its module and this module has + /// no other reason to depend on it). + async fn seeded_repo() -> (SqliteStateRepo, std::path::PathBuf) { + // CodeQL `rust/path-injection` (driven-ci-flakes precedent, PR 151 / + // src-tauri/Cargo.toml's `tempfile` dependency comment): a hand-rolled + // `std::env::temp_dir().join(format!(...))` is exactly the pattern the + // rule flags feeding `SqliteStateRepo::open`. `tempfile::tempdir()` is + // an opaque external call CodeQL's dataflow does not see into, so the + // taint chain never forms - fix, not a dismissal. `keep()` keeps + // the directory alive (matching the pre-`tempfile` behaviour) so the + // caller's existing `cleanup(dir)` teardown still applies. + let dir = tempfile::tempdir().expect("create temp dir").keep(); + let repo = SqliteStateRepo::open(&dir.join("state.db")) + .await + .expect("open seeded state repo"); + (repo, dir) + } + + fn cleanup(dir: std::path::PathBuf) { + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn reconcile_is_a_no_op_when_debug_logging_was_never_enabled() { + let (repo, dir) = seeded_repo().await; + // Must not panic / error on the default (off, no expiry) state - this + // is the path every normal boot takes. + reconcile(&repo).await; + let (enabled, expires_at_ms) = crate::commands::settings::load_debug_logging_state(&repo) + .await + .expect("read state"); + assert!(!enabled); + assert_eq!(expires_at_ms, None); + cleanup(dir); + } + + #[tokio::test] + async fn reconcile_auto_turns_off_an_expired_window() { + let (repo, dir) = seeded_repo().await; + // Simulate "debug mode was switched on a long time ago" - an expiry + // far in the past, as if the app had been closed past the 24h + // deadline (issue #309: "turns itself off automatically after 24 + // hours" must hold even across a restart, not just while running). + crate::commands::settings::set_debug_logging_state_for_test(&repo, true, Some(1)).await; + + reconcile(&repo).await; + + let (enabled, expires_at_ms) = crate::commands::settings::load_debug_logging_state(&repo) + .await + .expect("read state"); + assert!(!enabled, "an expired window must be turned off"); + assert_eq!(expires_at_ms, None, "the expiry must be cleared with it"); + cleanup(dir); + } + + #[tokio::test] + async fn reconcile_turns_off_a_missing_expiry_defensively() { + // `enabled=true` with `expires_at_ms=None` should never happen through + // the normal write path (compute_debug_logging_state always sets an + // expiry when enabling), but a corrupted / hand-edited DB row could + // produce it - fail closed to OFF rather than staying verbose forever + // with no deadline. + let (repo, dir) = seeded_repo().await; + crate::commands::settings::set_debug_logging_state_for_test(&repo, true, None).await; + + reconcile(&repo).await; + + let (enabled, _) = crate::commands::settings::load_debug_logging_state(&repo) + .await + .expect("read state"); + assert!(!enabled, "a missing expiry must fail closed to off"); + cleanup(dir); + } + + #[tokio::test] + async fn reconcile_keeps_a_still_valid_window_enabled() { + let (repo, dir) = seeded_repo().await; + // An expiry far in the future - the window has not elapsed yet. + let far_future_ms = SystemClock.now_ms() + AUTO_OFF_WINDOW_MS; + crate::commands::settings::set_debug_logging_state_for_test( + &repo, + true, + Some(far_future_ms), + ) + .await; + + reconcile(&repo).await; + + let (enabled, expires_at_ms) = crate::commands::settings::load_debug_logging_state(&repo) + .await + .expect("read state"); + assert!(enabled, "a still-valid window must stay on"); + assert_eq!( + expires_at_ms, + Some(far_future_ms), + "reconcile must not touch a still-valid expiry" + ); + cleanup(dir); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7f5a2e80..3065c523 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,6 +30,10 @@ mod bottleneck_hub; // `DialogToken`) against the real implementation. pub mod commands; mod crypto_provider_impl; +// Issue #309: debug logging mode's POLICY half (persisted 24h auto-off +// watchdog + applying the toggle to the live process). `logging.rs` owns the +// MECHANISM (the reloadable tracing filter + dynamic log-file cap). +mod debug_mode; mod drill_probe; // NOTE: the "run elevated" module was removed pre-V1 (2026-06-25). It implemented // WHOLE-APP elevation (a /RL HIGHEST Task Scheduler logon task + a UAC restart), @@ -287,6 +291,9 @@ struct ShutdownHandles { iostat: Option>, /// issue #308: the 1 Hz bottleneck-classification sampler, if it was started. bottleneck: Option>, + /// issue #309: the debug-logging-mode 24h auto-off watchdog, if it was + /// started. + debug_mode: Option>, } /// Signal every shutdown-able task and TAKE its handle, synchronously. @@ -324,6 +331,8 @@ fn take_shutdown_handles(app: &tauri::AppHandle) -> Option { iostat: state.shutdown_iostat_task(), // issue #308: the bottleneck-classification sampler, same shape again. bottleneck: state.shutdown_bottleneck_task(), + // issue #309: the debug-logging-mode watchdog, same shape again. + debug_mode: state.shutdown_debug_mode_task(), }) } @@ -358,6 +367,7 @@ async fn drain_shutdown_handles(handles: ShutdownHandles) { telemetry, iostat, bottleneck, + debug_mode, } = handles; // R3-P1-1: drive ALL per-account shutdowns concurrently. Each @@ -419,6 +429,12 @@ async fn drain_shutdown_handles(handles: ShutdownHandles) { tracing::info!(target: "driven::app", "bottleneck sampler drained (no orphan)"); } + // issue #309: drain the debug-logging-mode watchdog the same way. + if let Some(handle) = debug_mode { + drain_restore_handle(handle).await; + tracing::info!(target: "driven::app", "debug logging mode watchdog drained (no orphan)"); + } + // Stop the cosmetic tray syncing-spinner LAST - AFTER every orchestrator // is dropped (so the per-account event bridges' broadcasts are closed and // no further `StateChanged` can drive `apply_state` -> restart the @@ -728,6 +744,11 @@ pub fn run() { // issue #308: the live bottleneck-classification sampler // behind the Activity dashboard's Bottleneck stat tile. bottleneck_hub::spawn_sampler(&handle); + // Issue #309: reconcile the live process against the persisted + // debug-logging-mode state (auto-off if the 24h window already + // elapsed while the app was closed) and start the watchdog that + // keeps re-checking while the app runs. + debug_mode::spawn_watchdog(&handle); // M9b R2-P2-3 (SPEC s16): record an `update_applied` activity row // when the running version differs from the last-recorded one, so // the telemetry `update_applied` aggregate is driven by a real diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs index b14b232e..401f4e93 100644 --- a/src-tauri/src/logging.rs +++ b/src-tauri/src/logging.rs @@ -9,9 +9,16 @@ //! [`init`] now installs a LAYERED subscriber: the original stdout `fmt` layer //! (unchanged, so `cargo tauri dev` still prints) plus a DAILY-rolling file layer //! writing `driven.YYYY-MM-DD.log` into [`log_dir`]. [`prune`] bounds that -//! directory (14 days / 25 MB) so an always-on background daemon cannot fill the +//! directory (14 days / 25 MB, widened to 250 MB while issue #309 debug +//! logging mode is on) so an always-on background daemon cannot fill the //! user's disk. //! +//! Issue #309 (debug logging mode): the filter layer is wrapped in a +//! [`tracing_subscriber::reload`] layer, so [`set_filter`] can raise/restore +//! the LIVE process's verbosity at runtime (no restart) when the user flips +//! the Settings toggle. `debug_mode.rs` owns the policy (persisted expiry, +//! 24h auto-off watchdog); this module owns the mechanism. +//! //! This module is also the ONE place the log directory is resolved. The panic //! hook (crash dumps, SPEC s17) and the diagnostic-bundle collector (SPEC s18) //! both call [`log_dir`] rather than re-deriving the path - they previously @@ -19,19 +26,115 @@ //! which is why the bundle collected nothing even when crash dumps existed. use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::OnceLock; use std::time::Duration; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::layer::SubscriberExt as _; use tracing_subscriber::util::SubscriberInitExt as _; -use tracing_subscriber::{fmt, EnvFilter, Layer as _}; +use tracing_subscriber::{fmt, reload, EnvFilter, Layer as _, Registry}; const TARGET: &str = "driven::logging"; /// Default level filter when `RUST_LOG` is unset. `info` keeps the file useful /// for field diagnosis without the per-file debug spam a backup run produces. -const DEFAULT_FILTER: &str = "info"; +pub const DEFAULT_FILTER: &str = "info"; + +/// Issue #309 (debug logging mode): the directive applied to the LIVE filter +/// while debug logging is on. Driven's own crates go to `trace` (per-file +/// activity, IPC command traces, state transitions, reconcile/queue +/// decisions - the exact detail issue #309 asks for); everything else +/// (reqwest, hyper, boa's PAC engine, ...) stays at `info` so the log is still +/// dominated by Driven's own signal rather than drowned in dependency noise. +/// +/// Two directive families are needed to cover every `tracing::` call site in +/// this workspace: most calls pass an explicit `target: TARGET` string that +/// is a hand-written `"driven::..."` / `"driven_tls::..."` path (covered by +/// the `driven=trace` / `driven_tls=trace` directives below); calls with no +/// explicit target default to `module_path!()`, i.e. the crate's actual +/// (underscored) library name (the `driven_core=trace`, `driven_s3=trace`, +/// ... directives). +pub const DEBUG_MODE_FILTER: &str = "info,\ +driven=trace,\ +driven_app_lib=trace,\ +driven_core=trace,\ +driven_s3=trace,\ +driven_sftp=trace,\ +driven_drive=trace,\ +driven_tls=trace,\ +driven_vss=trace,\ +driven_backend=trace,\ +driven_net=trace,\ +driven_crypto=trace,\ +driven_remote=trace,\ +driven_localfs=trace,\ +driven_power=trace,\ +driven_diskstat=trace,\ +driven_apfs=trace,\ +driven_rclone=trace"; + +/// Default rolling-log total-byte cap (see [`MAX_LOG_TOTAL_BYTES`] doc). Kept +/// as the fallback [`effective_max_log_total_bytes`] restores on debug-mode +/// off. +const DEFAULT_MAX_LOG_TOTAL_BYTES: u64 = 25 * 1024 * 1024; + +/// Issue #309: the rolling-log cap while debug logging mode is on. Debug mode +/// raises the filter to `trace` on every Driven crate, which produces far +/// more log volume per day than the steady-state 25 MB budget can hold - a +/// 250 MB budget (SPEC/issue #309 "rotating cap ~250 MB") keeps a full day of +/// verbose logs available for the diagnostic bundle without letting an +/// abandoned toggle fill the user's disk indefinitely (the 24h auto-off in +/// `debug_mode.rs` is the other half of that guarantee). +const DEBUG_MODE_MAX_LOG_TOTAL_BYTES: u64 = 250 * 1024 * 1024; + +/// The live rolling-log total-byte cap [`prune`] enforces, switched between +/// [`DEFAULT_MAX_LOG_TOTAL_BYTES`] and [`DEBUG_MODE_MAX_LOG_TOTAL_BYTES`] by +/// [`set_debug_log_cap`]. An `AtomicU64` (not a plain const) because debug +/// logging mode is a runtime toggle, not a compile-time choice. +static MAX_LOG_TOTAL_BYTES: AtomicU64 = AtomicU64::new(DEFAULT_MAX_LOG_TOTAL_BYTES); + +/// Widen or restore the rolling-log cap for debug logging mode (issue #309). +/// Best-effort/instant: the next [`prune`] pass (at boot, or from the +/// `debug_mode` watchdog) enforces the new budget; nothing is deleted here. +pub fn set_debug_log_cap(debug_enabled: bool) { + let cap = if debug_enabled { + DEBUG_MODE_MAX_LOG_TOTAL_BYTES + } else { + DEFAULT_MAX_LOG_TOTAL_BYTES + }; + MAX_LOG_TOTAL_BYTES.store(cap, Ordering::Relaxed); +} + +/// The live rolling-log total-byte cap (see [`MAX_LOG_TOTAL_BYTES`]). +fn effective_max_log_total_bytes() -> u64 { + MAX_LOG_TOTAL_BYTES.load(Ordering::Relaxed) +} + +/// The process's reloadable tracing filter handle (issue #309), installed by +/// [`init`]. `None` until `init` runs (or if a subscriber was already +/// installed by a double-init race / a test harness), in which case +/// [`set_filter`] is a documented no-op rather than a panic - the previous +/// filter keeps running. +static FILTER_HANDLE: OnceLock> = OnceLock::new(); + +/// Reload the LIVE tracing filter to `directive` (an `EnvFilter` directive +/// string, e.g. `"info"` or [`DEBUG_MODE_FILTER`]). +/// +/// Returns `false` (and leaves the previous filter running) when the reload +/// handle is not installed yet, `directive` fails to parse, or the reload +/// itself errors (the subscriber was replaced from under us) - every case +/// fails closed to "still logging at the old level" rather than silently +/// going filter-less. +pub fn set_filter(directive: &str) -> bool { + let Some(handle) = FILTER_HANDLE.get() else { + return false; + }; + let Ok(new_filter) = EnvFilter::try_new(directive) else { + return false; + }; + handle.reload(new_filter).is_ok() +} /// Filename prefix of a rolling log file. `tracing-appender` joins prefix, the /// rotation date, and the suffix with `.`, so files are `driven.2026-07-25.log`. @@ -43,11 +146,6 @@ const LOG_SUFFIX: &str = "log"; /// "it broke sometime last week, send me a bundle". const MAX_LOG_AGE: Duration = Duration::from_secs(14 * 24 * 60 * 60); -/// Retention: total bytes the rolling logs may occupy. Beyond this the OLDEST -/// files are deleted first. 25 MB is a fraction of the SPEC s18 bundle's 50 MB -/// log budget, so a full log dir still fits in one bundle. -const MAX_LOG_TOTAL_BYTES: u64 = 25 * 1024 * 1024; - /// Keeps the `tracing-appender` non-blocking writer's worker thread alive. /// /// `tracing_appender::non_blocking` hands back a [`WorkerGuard`] whose `Drop` @@ -76,6 +174,14 @@ pub fn init() { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILTER)); + // Issue #309: wrap the filter in a reload layer so `set_filter` can raise + // / restore it at runtime with no restart. The handle is parked in + // `FILTER_HANDLE` BEFORE `try_init` so it is available the instant the + // subscriber is live; a failed `try_init` below leaves it set but unused + // (harmless - `set_filter` would just reload a filter nothing reads). + let (filter_layer, filter_handle) = reload::Layer::new(filter); + let _ = FILTER_HANDLE.set(filter_handle); + // The original stdout layer, unchanged: `cargo tauri dev` and any // console-attached launch keep printing exactly as before. let stdout_layer = fmt::layer(); @@ -100,7 +206,7 @@ pub fn init() { // nothing to report - reporting would need the subscriber that did not // install. if tracing_subscriber::registry() - .with(filter) + .with(filter_layer) .with(stdout_layer) .with(file_layer) .try_init() @@ -330,7 +436,11 @@ pub fn prune(dir: &std::path::Path) { }); } - for name in plan_prune(&files, MAX_LOG_AGE.as_secs(), MAX_LOG_TOTAL_BYTES) { + for name in plan_prune( + &files, + MAX_LOG_AGE.as_secs(), + effective_max_log_total_bytes(), + ) { let path = dir.join(&name); if let Err(e) = std::fs::remove_file(&path) { tracing::debug!(target: TARGET, file = %path.display(), error = %e, "log retention: could not delete stale log"); diff --git a/ui/e2e-visual/__screenshots__/linux/dark/settings.spec.ts/privacy.png b/ui/e2e-visual/__screenshots__/linux/dark/settings.spec.ts/privacy.png index 1095abe9..34abc1fe 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/dark/settings.spec.ts/privacy.png and b/ui/e2e-visual/__screenshots__/linux/dark/settings.spec.ts/privacy.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/backup-running.png b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/backup-running.png index 0f28745c..10502cf5 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/backup-running.png and b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/backup-running.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/gate-battery.png b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/gate-battery.png index 3c75f6a8..3fdd66d3 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/gate-battery.png and b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/gate-battery.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/gate-offline.png b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/gate-offline.png index 45de7a43..b1208cbe 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/gate-offline.png and b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/gate-offline.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/idle.png b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/idle.png index 51bca919..763ef7f1 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/idle.png and b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/idle.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/paused-indefinite.png b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/paused-indefinite.png index ba924f44..b66931f8 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/paused-indefinite.png and b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/paused-indefinite.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/paused-timed.png b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/paused-timed.png index 5cc2ea8d..3f398006 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/paused-timed.png and b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/paused-timed.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/update-banner.png b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/update-banner.png index 51bca919..763ef7f1 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/update-banner.png and b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/update-banner.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/work-queue-busy.png b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/work-queue-busy.png index 7da49795..289be588 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/work-queue-busy.png and b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/work-queue-busy.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/work-queue-empty.png b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/work-queue-empty.png index 3f493007..4cb64eed 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/work-queue-empty.png and b/ui/e2e-visual/__screenshots__/linux/dark/shell.spec.ts/work-queue-empty.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/light/settings.spec.ts/privacy.png b/ui/e2e-visual/__screenshots__/linux/light/settings.spec.ts/privacy.png index a39bcda1..5bb2cc6e 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/light/settings.spec.ts/privacy.png and b/ui/e2e-visual/__screenshots__/linux/light/settings.spec.ts/privacy.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/backup-running.png b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/backup-running.png index 866fa658..89c01f75 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/backup-running.png and b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/backup-running.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/gate-battery.png b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/gate-battery.png index 1708c897..135713b8 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/gate-battery.png and b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/gate-battery.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/gate-offline.png b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/gate-offline.png index 6305034b..13ec3582 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/gate-offline.png and b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/gate-offline.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/idle.png b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/idle.png index 5e5d73f3..5f2743bf 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/idle.png and b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/idle.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/paused-indefinite.png b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/paused-indefinite.png index 0438882a..d5ec562d 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/paused-indefinite.png and b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/paused-indefinite.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/paused-timed.png b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/paused-timed.png index bfb1a80c..64e31d13 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/paused-timed.png and b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/paused-timed.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/update-banner.png b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/update-banner.png index 5e5d73f3..5f2743bf 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/update-banner.png and b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/update-banner.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/work-queue-busy.png b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/work-queue-busy.png index c63992d1..22f81acf 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/work-queue-busy.png and b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/work-queue-busy.png differ diff --git a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/work-queue-empty.png b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/work-queue-empty.png index b1e8dee9..84fed25b 100644 Binary files a/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/work-queue-empty.png and b/ui/e2e-visual/__screenshots__/linux/light/shell.spec.ts/work-queue-empty.png differ diff --git a/ui/src/__tests__/activity-debug-chip.test.ts b/ui/src/__tests__/activity-debug-chip.test.ts new file mode 100644 index 00000000..963a94ba --- /dev/null +++ b/ui/src/__tests__/activity-debug-chip.test.ts @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { mount, flushPromises } from "@vue/test-utils"; + +import { i18n } from "../i18n"; + +// Issue #309: the "Debug data included" chip next to Activity's export- +// diagnostic-bundle button, shown only while debug logging mode is on +// (mirrors the mockup's amber chip). Activity loads its own settings +// snapshot (via ensureSettingsLoaded) since it doesn't otherwise depend on +// the Settings view having been visited first. + +const invokeMock = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args), +})); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async () => () => undefined), +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), + save: vi.fn(), +})); +vi.mock("vue-router", () => ({ + useRouter: () => ({ push: vi.fn() }), + useRoute: () => ({ params: {} }), +})); + +import Activity from "../views/Activity.vue"; +import type { SettingsDto } from "../ipc/types"; + +const CHIP = '[data-testid="activity-debug-data-chip"]'; + +function makeSettings(debugLoggingEnabled: boolean): SettingsDto { + return { + global: { + autoStartOnLogin: false, + defaultConcurrentUploads: null, + adaptiveParallelismEnabled: true, + bandwidthCapMbps: null, + skipOnBattery: true, + skipOnMetered: true, + scanIntervalSecs: 600, + deepVerifyIntervalSecs: 604800, + ioPriority: "low", + logLevel: "info", + schedule: { + enabled: false, + startMinute: 0, + endMinute: 0, + days: [true, true, true, true, true, true, true], + utcOffsetMinutes: 0, + }, + preBackupHook: null, + postBackupHook: null, + hookTimeoutSecs: 60, + meteredMode: "pause", + meteredBandwidthCapMbps: null, + customRootCaPath: null, + proxyMode: "system", + proxyUrl: null, + pauseWhenOffline: true, + debugLoggingEnabled, + debugLoggingExpiresAtMs: debugLoggingEnabled ? Date.now() + 60_000 : null, + }, + telemetry: { enabled: true, installId: "id", endpoint: "https://example.test/ping" }, + updater: { channel: "stable", checkIntervalSecs: 21600 }, + ui: { trayLeftClickOpens: "activity", locale: "en-US", colorMode: "system" }, + windows: null, + macos: null, + bundleSmallFiles: false, + scrub: { enabled: true, intervalSecs: 604800, sliceSize: 500, deepSample: 0 }, + drill: { enabled: true, intervalSecs: 2592000, sampleSize: 3 }, + }; +} + +function stubBackend(debugLoggingEnabled: boolean): void { + invokeMock.mockImplementation(async (cmd: string) => { + switch (cmd) { + case "get_settings": + return makeSettings(debugLoggingEnabled); + case "query_activity": + return { + entries: [], + total: 0, + limit: 100, + hasMore: false, + nextBeforeTs: null, + nextBeforeId: null, + }; + case "distinct_activity_event_types": + return ["upload_done"]; + case "activity_summary": + return { + bytesToday: 0, + bytesWeek: 0, + fileStatusCounts: [], + throughputWindowBytes: 0, + throughputWindowFiles: 0, + throughputWindowMs: 60_000, + }; + case "activity_throughput_series": + return { bytes: [], files: [] }; + case "list_sources": + return []; + default: + return undefined; + } + }); +} + +beforeEach(() => { + setActivePinia(createPinia()); + invokeMock.mockReset(); +}); + +describe("Activity debug-data chip (issue #309)", () => { + it("is absent when debug logging mode is off", async () => { + stubBackend(false); + const wrapper = mount(Activity, { global: { plugins: [i18n] } }); + await flushPromises(); + + expect(wrapper.find(CHIP).exists()).toBe(false); + + wrapper.unmount(); + }); + + it("is shown next to the export button when debug logging mode is on", async () => { + stubBackend(true); + const wrapper = mount(Activity, { global: { plugins: [i18n] } }); + await flushPromises(); + + const chip = wrapper.get(CHIP); + expect(chip.text()).toBe(i18n.global.t("activity.debugDataIncludedChip")); + // Sits next to the export button (same flex row). + expect( + chip.element.parentElement?.querySelector('[data-testid="activity-export-bundle"]') + ).toBeTruthy(); + + wrapper.unmount(); + }); +}); diff --git a/ui/src/__tests__/settings-components.test.ts b/ui/src/__tests__/settings-components.test.ts index cab2873d..4923009b 100644 --- a/ui/src/__tests__/settings-components.test.ts +++ b/ui/src/__tests__/settings-components.test.ts @@ -112,6 +112,8 @@ function makeSettings(over: Partial = {}): SettingsDto { proxyMode: "system", proxyUrl: null, pauseWhenOffline: true, + debugLoggingEnabled: false, + debugLoggingExpiresAtMs: null, }, telemetry: { enabled: true, diff --git a/ui/src/__tests__/settings-nav.test.ts b/ui/src/__tests__/settings-nav.test.ts index a334aa5b..531111fd 100644 --- a/ui/src/__tests__/settings-nav.test.ts +++ b/ui/src/__tests__/settings-nav.test.ts @@ -56,6 +56,8 @@ function makeSettings(over: Partial = {}): SettingsDto { proxyMode: "system", proxyUrl: null, pauseWhenOffline: true, + debugLoggingEnabled: false, + debugLoggingExpiresAtMs: null, }, telemetry: { enabled: true, diff --git a/ui/src/__tests__/settings-pages.test.ts b/ui/src/__tests__/settings-pages.test.ts index e6260ff9..9a0c3801 100644 --- a/ui/src/__tests__/settings-pages.test.ts +++ b/ui/src/__tests__/settings-pages.test.ts @@ -91,6 +91,8 @@ function makeSettings(over: Partial = {}): SettingsDto { proxyMode: "system", proxyUrl: null, pauseWhenOffline: true, + debugLoggingEnabled: false, + debugLoggingExpiresAtMs: null, }, telemetry: { enabled: true, @@ -1504,6 +1506,67 @@ describe("PrivacyPage", () => { await flushPromises(); expect(wrapper.find('[data-testid="telemetry-preview-modal"]').exists()).toBe(false); }); + + it("issue #309: debug logging warning is always shown, and toggling it on patches global.debugLoggingEnabled", async () => { + invokeMock.mockImplementation((cmd: string, args: unknown) => { + if (cmd === "get_settings") return Promise.resolve(makeSettings()); + if (cmd === "update_settings") { + const patch = (args as { patch: { global?: Record } }).patch; + return Promise.resolve( + makeSettings({ global: { ...makeSettings().global, ...patch.global } }) + ); + } + return Promise.resolve(undefined); + }); + const wrapper = mount(PrivacyPage, { global: globalMountOptions }); + await flushPromises(); + + // The warning is unmissable: present BEFORE the toggle is ever switched + // on, not gated on the enabled state. + const toggle = wrapper.get('[data-testid="debug-logging-toggle"]'); + expect((toggle.element as HTMLInputElement).checked).toBe(false); + expect(wrapper.get('[data-testid="debug-logging-warning"]').text()).toBe( + i18n.global.t("settings.rules.debugLogging.warning") + ); + + await toggle.setValue(true); + await flushPromises(); + + expect(invokeMock).toHaveBeenCalledWith("update_settings", { + patch: { global: { debugLoggingEnabled: true } }, + }); + // The backend-computed expiry round-trips back into the toggle's checked + // state (the store replaces the snapshot with the command's response). + expect( + (wrapper.get('[data-testid="debug-logging-toggle"]').element as HTMLInputElement).checked + ).toBe(true); + }); + + it("issue #309: while debug logging is on, the bundle note shows the persisted expiry", async () => { + const expiresAt = Date.UTC(2026, 7, 18, 12, 0, 0); + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "get_settings") + return Promise.resolve( + makeSettings({ + global: { + ...makeSettings().global, + debugLoggingEnabled: true, + debugLoggingExpiresAtMs: expiresAt, + }, + }) + ); + return Promise.resolve(undefined); + }); + const wrapper = mount(PrivacyPage, { global: globalMountOptions }); + await flushPromises(); + + expect( + (wrapper.get('[data-testid="debug-logging-toggle"]').element as HTMLInputElement).checked + ).toBe(true); + expect(wrapper.get('[data-testid="debug-logging-setting"]').text()).toContain( + i18n.global.t("settings.rules.debugLogging.includeInBundle") + ); + }); }); describe("AdvancedPage", () => { diff --git a/ui/src/__tests__/settings-stores.test.ts b/ui/src/__tests__/settings-stores.test.ts index ee616716..aa91b5f7 100644 --- a/ui/src/__tests__/settings-stores.test.ts +++ b/ui/src/__tests__/settings-stores.test.ts @@ -86,6 +86,8 @@ function makeSettings(over: Partial = {}): SettingsDto { proxyMode: "system", proxyUrl: null, pauseWhenOffline: true, + debugLoggingEnabled: false, + debugLoggingExpiresAtMs: null, }, telemetry: { enabled: true, diff --git a/ui/src/ipc/types.ts b/ui/src/ipc/types.ts index a856727d..e712fa36 100644 --- a/ui/src/ipc/types.ts +++ b/ui/src/ipc/types.ts @@ -423,6 +423,16 @@ export interface GlobalSettings { * true). Turning it off is meant for LAN-only / local-folder destinations * that don't need internet reachability to back up. */ pauseWhenOffline: boolean; + /** Issue #309: debug logging mode. When true the live log filter is raised + * to a verbose directive (per-file activity, IPC traces, state + * transitions, reconcile/queue decisions) and diagnostic bundles gain a + * debug section; both cost noticeable performance and log paths/timings, + * so this defaults false and auto-clears itself (see + * debugLoggingExpiresAtMs). */ + debugLoggingEnabled: boolean; + /** Issue #309: epoch-ms deadline debugLoggingEnabled auto-turns-off at (set + * to now + 24h whenever the toggle is switched on). null when off. */ + debugLoggingExpiresAtMs: number | null; } export interface TelemetrySettings { @@ -672,6 +682,9 @@ export interface GlobalSettingsPatch { proxyUrl?: string | null; /** Pause-banner spec (2026-08-01): present = set pause-while-offline. */ pauseWhenOffline?: boolean; + /** Issue #309: present = set debug logging mode. The backend computes/ + * clears debugLoggingExpiresAtMs itself - it is not directly patchable. */ + debugLoggingEnabled?: boolean; } /** Issue #34: result of validating a candidate custom root CA PEM file. */ diff --git a/ui/src/locales/en-US.json b/ui/src/locales/en-US.json index c7a445e9..9a7f8e0b 100644 --- a/ui/src/locales/en-US.json +++ b/ui/src/locales/en-US.json @@ -544,6 +544,13 @@ "telemetryLabel": "Send anonymous usage stats", "telemetryNote": "Anonymous usage stats only - no file names or contents are ever sent. One click to disable.", "telemetryPreviewButton": "Preview data", + "debugLogging": { + "label": "Debug logging", + "note": "Records detailed per-file activity, IPC traces, and full engine state snapshots to help diagnose problems.", + "warning": "Debug logs and diagnostic bundles will include file names, full paths, timing data, and other personal information. Backups may run noticeably slower. Turns itself off automatically after 24 hours.", + "includeInBundle": "Diagnostic bundles will include this debug data while it's on.", + "activeUntil": "Debug logging is on until {time}." + }, "hooks": { "title": "Backup hooks", "preLabel": "Before each backup (shell command)", @@ -709,6 +716,7 @@ "exportBundleButton": "Export diagnostic bundle", "exporting": "Exporting...", "exportedTo": "Saved diagnostic bundle to {path}", + "debugDataIncludedChip": "Debug data included", "summary": { "bytesToday": "Uploaded today", "bytesWeek": "Uploaded this week", diff --git a/ui/src/views/Activity.vue b/ui/src/views/Activity.vue index bfe363a2..0662f972 100644 --- a/ui/src/views/Activity.vue +++ b/ui/src/views/Activity.vue @@ -20,8 +20,10 @@ import { useActivityStore, } from "../stores/activity"; import { formatBytes as formatByteCount } from "../stores/formatBytes"; +import { useSettingsStore } from "../stores/settings"; import { useSourcesStore } from "../stores/sources"; import { useToastsStore } from "../stores/toasts"; +import { ensureSettingsLoaded } from "./settings/shared"; import type { ActivityEntry, ActivityLevel, FileStateStatus } from "../ipc/types"; // Activity dashboard (SPEC s11.4; DESIGN s8.3). A live tail (subscribes to @@ -40,6 +42,13 @@ const iostat = useIostatStore(); const bottleneck = useBottleneckStore(); const sources = useSourcesStore(); const toasts = useToastsStore(); +// Issue #309: the settings snapshot, read-only here, just for the "Debug data +// included" chip below - Activity does not otherwise depend on Settings, so +// this loads its own snapshot on mount rather than assuming the Settings +// view has already run. +const settings = useSettingsStore(); +ensureSettingsLoaded(); +const debugLoggingEnabled = computed(() => settings.settings?.global.debugLoggingEnabled ?? false); // Design-system class strings (DRIVEN UI). Defined once so every control in this // view stays visually consistent with the rest of the app: teal accent, dark-mode @@ -300,16 +309,24 @@ onUnmounted(() => { {{ t("activity.subtitle") }}

- +
+ + {{ t("activity.debugDataIncludedChip") }} + + +

-import { ref } from "vue"; +import { computed, ref } from "vue"; import { useI18n } from "vue-i18n"; import TelemetryPreviewModal from "../../components/TelemetryPreviewModal.vue"; @@ -9,7 +9,7 @@ import { cardCls, ensureSettingsLoaded } from "./shared"; // Privacy settings page (SDD 2026-08-02 settings-sidebar-ia, task 2). Moved // verbatim out of Settings.vue's telemetry card - the single home for // telemetry now that About.vue's duplicate copy is removed (task 5). -const { t } = useI18n(); +const { t, locale } = useI18n(); const settings = useSettingsStore(); ensureSettingsLoaded(); @@ -32,6 +32,31 @@ async function setTelemetryEnabled(event: Event): Promise { // modal, available regardless of the current enabled state - a privacy- // conscious user inspects it BEFORE opting in. const showTelemetryPreview = ref(false); + +// Issue #309: debug logging mode. Uses the generic `patch` (not a dedicated +// command like telemetry's) - there is no in-flight send to cancel, and the +// backend fully owns computing/clearing `debugLoggingExpiresAtMs` from this +// one boolean, so a plain SettingsPatch round-trip is enough. +async function setDebugLoggingEnabled(event: Event): Promise { + const checked = (event.target as HTMLInputElement).checked; + try { + await settings.patch({ global: { debugLoggingEnabled: checked } }); + } catch { + // errorCode is set on the store and surfaced as the banner; swallow so + // the toggle's @change handler never escapes as an unhandled rejection. + } +} + +// DESIGN s8.7: locale-aware, never a hand-rolled English formatter (matches +// Activity.vue's dateTimeFormatter). +const debugLoggingExpiresAt = computed(() => { + const ms = settings.settings?.global.debugLoggingExpiresAtMs; + if (!ms) return null; + return new Intl.DateTimeFormat(locale.value, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(ms)); +});