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") }}
-
- {{ exporting ? t("activity.exporting") : t("activity.exportBundleButton") }}
-
+
+
+ {{ t("activity.debugDataIncludedChip") }}
+
+
+ {{ exporting ? t("activity.exporting") : t("activity.exportBundleButton") }}
+
+
-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));
+});
@@ -78,6 +103,40 @@ const showTelemetryPreview = ref(false);
{{ t("settings.rules.telemetryPreviewButton") }}
+
+
+
+
+
+
+ {{ t("settings.rules.debugLogging.label") }}
+
+
+
+ {{ t("settings.rules.debugLogging.note") }}
+
+
+ {{ t("settings.rules.debugLogging.warning") }}
+
+
+ {{ t("settings.rules.debugLogging.includeInBundle") }}
+
+ {{ t("settings.rules.debugLogging.activeUntil", { time: debugLoggingExpiresAt }) }}
+
+
+
diff --git a/ui/test-support/fixtures.ts b/ui/test-support/fixtures.ts
index 780461c5..4af9dba4 100644
--- a/ui/test-support/fixtures.ts
+++ b/ui/test-support/fixtures.ts
@@ -627,6 +627,8 @@ export const SETTINGS: SettingsDto = {
proxyMode: "system",
proxyUrl: null,
pauseWhenOffline: true,
+ debugLoggingEnabled: false,
+ debugLoggingExpiresAtMs: null,
},
telemetry: {
enabled: true,