Skip to content

Commit 15ad9cf

Browse files
committed
Low disk space warning, configurable in Settings
- The space poller now carries a permanent backend-owned watcher on the boot volume (dedupes with pane watchers, so no extra statfs when a pane already shows it) and a hysteresis detector: fires once when free space crosses below the threshold, re-arms only after recovering 1 point above it. - New `low-disk-space` event + `set_low_disk_space_config` live-apply command; startup seeds from `settings.json`. - Two new settings under Behavior > File system watching > Low disk space: mode (In-app | macOS notification | Off, default In-app) and threshold percent (default 5, range 1-50). Not FDA-gated (statfs needs no TCC). - In-app mode shows a persistent WARN toast (per-volume dedup id) with a "Disable these notifications" action that flips the mode off and deep-links to the Settings sub-group. macOS mode sends a native notification. - Extracted the macOS notification permission flow from the downloads bridge into shared `lib/notifications/macos-notification-permission.ts`. - Tests: Rust hysteresis unit tests, FE bridge/mode/toast tests + a11y, section render contract updates.
1 parent 3b07b0f commit 15ad9cf

29 files changed

Lines changed: 1307 additions & 117 deletions

apps/desktop/src-tauri/src/ipc.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ pub fn builder() -> Builder<tauri::Wry> {
248248
crate::space_poller::watch_volume_space,
249249
crate::space_poller::unwatch_volume_space,
250250
crate::space_poller::set_disk_space_threshold,
251+
crate::space_poller::set_low_disk_space_config,
251252
#[cfg(target_os = "macos")]
252253
crate::commands::volumes::list_volumes,
253254
#[cfg(target_os = "macos")]

apps/desktop/src-tauri/src/ipc_collectors.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ pub(crate) fn collect_cross_platform_types(types: &mut Types) -> Vec<Function> {
121121
crate::space_poller::watch_volume_space,
122122
crate::space_poller::unwatch_volume_space,
123123
crate::space_poller::set_disk_space_threshold,
124+
crate::space_poller::set_low_disk_space_config,
124125
crate::commands::crash_reporter::check_pending_crash_report,
125126
crate::commands::crash_reporter::dismiss_crash_report,
126127
crate::commands::crash_reporter::send_crash_report,

apps/desktop/src-tauri/src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,9 +569,13 @@ pub fn run() {
569569
file_system::set_filter_safe_save_artifacts(saved_settings.filter_safe_save_artifacts.unwrap_or(true));
570570
file_system::set_smb_concurrency(saved_settings.smb_concurrency.unwrap_or(10) as usize);
571571

572-
// Initialize disk space poller (live status bar updates)
572+
// Initialize disk space poller (live status bar updates + low-disk-space warning)
573573
space_poller::init(app.handle());
574574
space_poller::set_threshold_mb(saved_settings.disk_space_change_threshold_mb.unwrap_or(1));
575+
space_poller::configure_low_disk_space(
576+
saved_settings.low_disk_space_enabled(),
577+
saved_settings.low_disk_space_threshold_percent.unwrap_or(5),
578+
);
575579
space_poller::start();
576580

577581
// Upgrade existing SMB mounts to direct smb2 connections (background, non-blocking).

apps/desktop/src-tauri/src/settings/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ Settings {
3030
direct_smb_connection: Option<bool>, // from "network.directSmbConnection"
3131
mtp_enabled: Option<bool>, // from "fileOperations.mtpEnabled"
3232
disk_space_change_threshold_mb: Option<u64>, // from "advanced.diskSpaceChangeThreshold"
33+
low_disk_space_notifications: Option<String>, // from "behavior.fileSystemWatching.lowDiskSpaceNotifications"; `low_disk_space_enabled()` maps any mode but "off" (or missing) to enabled
34+
low_disk_space_threshold_percent: Option<u64>, // from "behavior.fileSystemWatching.lowDiskSpaceThresholdPercent" (default 5)
3335
max_log_storage_mb: Option<u64>, // from "advanced.maxLogStorageMb"
3436
error_reports_enabled: Option<bool>, // from "updates.errorReports" (Flow B opt-in, default off)
3537
network_enabled: Option<bool>, // from "network.enabled" (default on; off renders picker as "Network (disabled)")

apps/desktop/src-tauri/src/settings/loader.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ pub struct Settings {
5656
pub mtp_enabled: Option<bool>,
5757
#[serde(alias = "advanced.diskSpaceChangeThreshold", default)]
5858
pub disk_space_change_threshold_mb: Option<u64>,
59+
#[serde(alias = "behavior.fileSystemWatching.lowDiskSpaceNotifications", default)]
60+
pub low_disk_space_notifications: Option<String>,
61+
#[serde(alias = "behavior.fileSystemWatching.lowDiskSpaceThresholdPercent", default)]
62+
pub low_disk_space_threshold_percent: Option<u64>,
5963
#[serde(alias = "network.smbConcurrency", default)]
6064
pub smb_concurrency: Option<u16>,
6165
#[serde(alias = "advanced.maxLogStorageMb", default)]
@@ -78,6 +82,14 @@ fn default_show_hidden() -> bool {
7882
true
7983
}
8084

85+
impl Settings {
86+
/// Whether the low-disk-space warning is on: any mode except `"off"`.
87+
/// A missing key means the registry default (`"in-app"`), so enabled.
88+
pub fn low_disk_space_enabled(&self) -> bool {
89+
self.low_disk_space_notifications.as_deref() != Some("off")
90+
}
91+
}
92+
8193
impl Default for Settings {
8294
fn default() -> Self {
8395
Self {
@@ -93,6 +105,8 @@ impl Default for Settings {
93105
filter_safe_save_artifacts: None,
94106
mtp_enabled: None,
95107
disk_space_change_threshold_mb: None,
108+
low_disk_space_notifications: None,
109+
low_disk_space_threshold_percent: None,
96110
smb_concurrency: None,
97111
max_log_storage_mb: None,
98112
error_reports_enabled: None,
@@ -149,6 +163,13 @@ fn parse_settings(contents: &str) -> Result<Settings, serde_json::Error> {
149163
let filter_safe_save_artifacts = json.get("advanced.filterSafeSaveArtifacts").and_then(|v| v.as_bool());
150164
let mtp_enabled = json.get("fileOperations.mtpEnabled").and_then(|v| v.as_bool());
151165
let disk_space_change_threshold_mb = json.get("advanced.diskSpaceChangeThreshold").and_then(|v| v.as_u64());
166+
let low_disk_space_notifications = json
167+
.get("behavior.fileSystemWatching.lowDiskSpaceNotifications")
168+
.and_then(|v| v.as_str())
169+
.map(String::from);
170+
let low_disk_space_threshold_percent = json
171+
.get("behavior.fileSystemWatching.lowDiskSpaceThresholdPercent")
172+
.and_then(|v| v.as_u64());
152173
let smb_concurrency = json
153174
.get("network.smbConcurrency")
154175
.and_then(|v| v.as_u64())
@@ -174,6 +195,8 @@ fn parse_settings(contents: &str) -> Result<Settings, serde_json::Error> {
174195
filter_safe_save_artifacts,
175196
mtp_enabled,
176197
disk_space_change_threshold_mb,
198+
low_disk_space_notifications,
199+
low_disk_space_threshold_percent,
177200
smb_concurrency,
178201
max_log_storage_mb,
179202
error_reports_enabled,

apps/desktop/src-tauri/src/space_poller.rs

Lines changed: 210 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,24 @@
66
//!
77
//! Poll intervals are per-volume-type via `Volume::space_poll_interval()`:
88
//! local volumes poll every 2 s, network/MTP every 5 s.
9-
10-
use log::{debug, warn};
9+
//!
10+
//! Also owns the low-disk-space warning: a permanent, backend-owned watcher on
11+
//! the boot volume (so the check works even when neither pane shows it) feeds
12+
//! a hysteresis detector that emits a `low-disk-space` event when free space
13+
//! crosses below the user-configured percent threshold. The poll loop already
14+
//! deduplicates by volume id, so a pane watching the boot volume shares the
15+
//! same single `statfs` per tick with the permanent watcher.
16+
17+
use log::{debug, info, warn};
1118
use serde::Serialize;
1219
use std::collections::HashMap;
13-
use std::sync::atomic::{AtomicU64, Ordering};
20+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
1421
use std::sync::{Mutex, OnceLock};
1522
use std::time::Duration;
1623
use tauri::{AppHandle, Emitter};
1724

1825
use crate::file_system::get_volume_manager;
26+
use crate::file_system::volume::DEFAULT_VOLUME_ID;
1927

2028
/// Global app handle for emitting events.
2129
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
@@ -33,6 +41,30 @@ static LAST_SPACE: OnceLock<Mutex<HashMap<String, CachedSpace>>> = OnceLock::new
3341
/// Change threshold in bytes. Updated at runtime from settings.
3442
static THRESHOLD_BYTES: AtomicU64 = AtomicU64::new(1_048_576); // 1 MB default
3543

44+
/// Whether the low-disk-space warning is on. Mirrors the
45+
/// `behavior.fileSystemWatching.lowDiskSpaceNotifications` setting
46+
/// (`true` for any mode but "off"; the registry default is "in-app").
47+
static LOW_SPACE_ENABLED: AtomicBool = AtomicBool::new(true);
48+
49+
/// Free-space percent threshold for the low-disk-space warning. Mirrors the
50+
/// `behavior.fileSystemWatching.lowDiskSpaceThresholdPercent` setting.
51+
static LOW_SPACE_THRESHOLD_PERCENT: AtomicU64 = AtomicU64::new(5);
52+
53+
/// Hysteresis state: `true` means the detector may fire on the next crossing
54+
/// below the threshold. Disarmed after firing; re-armed once free space climbs
55+
/// back above threshold + [`LOW_SPACE_REARM_MARGIN_PERCENT`].
56+
static LOW_SPACE_ARMED: AtomicBool = AtomicBool::new(true);
57+
58+
/// Re-arm margin in percentage points. Without it, free space oscillating
59+
/// around the exact threshold (a download writing and deleting temp files)
60+
/// would fire a warning on every dip.
61+
const LOW_SPACE_REARM_MARGIN_PERCENT: f64 = 1.0;
62+
63+
/// Watcher id for the permanent backend-owned boot-volume entry. Lives in the
64+
/// same `WATCHED` map as the pane watchers so the dedup-by-volume-id logic
65+
/// merges it with a pane that happens to show the boot volume.
66+
const LOW_SPACE_BOOT_WATCHER_ID: &str = "low-space:boot";
67+
3668
/// Default poll interval for volumes not registered in VolumeManager.
3769
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
3870

@@ -61,6 +93,17 @@ struct VolumeSpaceChangedPayload {
6193
available_bytes: u64,
6294
}
6395

96+
/// Payload for the `low-disk-space` Tauri event.
97+
#[derive(Clone, Serialize)]
98+
#[serde(rename_all = "camelCase")]
99+
struct LowDiskSpacePayload {
100+
volume_id: String,
101+
total_bytes: u64,
102+
available_bytes: u64,
103+
free_percent: f64,
104+
threshold_percent: u64,
105+
}
106+
64107
/// Stores the app handle. Call once during setup.
65108
pub fn init(app: &AppHandle) {
66109
let _ = APP_HANDLE.set(app.clone());
@@ -73,6 +116,27 @@ pub fn set_threshold_mb(mb: u64) {
73116
THRESHOLD_BYTES.store(mb.saturating_mul(1_048_576), Ordering::Relaxed);
74117
}
75118

119+
/// Applies the low-disk-space warning config (at startup and live from Settings).
120+
///
121+
/// Registers or removes the permanent boot-volume watcher so the extra
122+
/// `statfs` goes away entirely when the warning is off. Always re-arms the
123+
/// detector: a changed threshold should re-evaluate against the current free
124+
/// space on the next poll.
125+
pub fn configure_low_disk_space(enabled: bool, threshold_percent: u64) {
126+
LOW_SPACE_ENABLED.store(enabled, Ordering::Relaxed);
127+
LOW_SPACE_THRESHOLD_PERCENT.store(threshold_percent.clamp(1, 99), Ordering::Relaxed);
128+
LOW_SPACE_ARMED.store(true, Ordering::Relaxed);
129+
if enabled {
130+
watch(
131+
LOW_SPACE_BOOT_WATCHER_ID.to_string(),
132+
DEFAULT_VOLUME_ID.to_string(),
133+
"/".to_string(),
134+
);
135+
} else {
136+
unwatch(LOW_SPACE_BOOT_WATCHER_ID);
137+
}
138+
}
139+
76140
/// Registers (or updates) a watcher for live space updates.
77141
///
78142
/// `watcher_id` is typically a pane ID ("left"/"right"). Multiple watchers
@@ -125,6 +189,13 @@ pub fn set_disk_space_threshold(mb: u64) {
125189
set_threshold_mb(mb);
126190
}
127191

192+
/// Updates the low-disk-space warning config at runtime (from settings).
193+
#[tauri::command]
194+
#[specta::specta]
195+
pub fn set_low_disk_space_config(enabled: bool, threshold_percent: u64) {
196+
configure_low_disk_space(enabled, threshold_percent);
197+
}
198+
128199
/// The core loop. Ticks every second; each volume is polled at its own cadence.
129200
async fn poll_loop() {
130201
let mut tick: u64 = 0;
@@ -184,6 +255,13 @@ async fn poll_loop() {
184255
_ => continue, // timeout or no data: skip this tick
185256
};
186257

258+
// The low-space check sees every fetch, not just the ones that
259+
// pass the change-threshold gate below: a slow leak smaller than
260+
// the 1 MB emit threshold must still trip the warning.
261+
if volume_id == DEFAULT_VOLUME_ID {
262+
check_low_space(&volume_id, &space);
263+
}
264+
187265
if exceeds_threshold(&volume_id, &space, threshold) {
188266
update_cache(&volume_id, &space);
189267
emit(&volume_id, &space);
@@ -192,6 +270,64 @@ async fn poll_loop() {
192270
}
193271
}
194272

273+
/// Runs the hysteresis detector on a fresh boot-volume space fetch and emits
274+
/// `low-disk-space` when the free percent crosses below the threshold.
275+
fn check_low_space(volume_id: &str, space: &CachedSpace) {
276+
if !LOW_SPACE_ENABLED.load(Ordering::Relaxed) {
277+
return;
278+
}
279+
let threshold = LOW_SPACE_THRESHOLD_PERCENT.load(Ordering::Relaxed);
280+
let free = free_percent(space.total_bytes, space.available_bytes);
281+
let armed = LOW_SPACE_ARMED.load(Ordering::Relaxed);
282+
let (new_armed, fire) = low_space_transition(armed, free, threshold as f64);
283+
LOW_SPACE_ARMED.store(new_armed, Ordering::Relaxed);
284+
if fire {
285+
emit_low_disk_space(volume_id, space, free, threshold);
286+
}
287+
}
288+
289+
/// Free space as a percent of total. Treats an unknown total (0) as not-low
290+
/// so a bogus fetch can't fire a false warning.
291+
fn free_percent(total_bytes: u64, available_bytes: u64) -> f64 {
292+
if total_bytes == 0 {
293+
return 100.0;
294+
}
295+
available_bytes as f64 / total_bytes as f64 * 100.0
296+
}
297+
298+
/// The pure hysteresis step: `(armed, free, threshold)` → `(new_armed, fire)`.
299+
///
300+
/// Fires exactly once per crossing below the threshold; re-arms only after
301+
/// free space recovers above threshold + [`LOW_SPACE_REARM_MARGIN_PERCENT`],
302+
/// so oscillation around the boundary can't re-fire.
303+
fn low_space_transition(armed: bool, free_percent: f64, threshold_percent: f64) -> (bool, bool) {
304+
if armed && free_percent < threshold_percent {
305+
return (false, true);
306+
}
307+
if !armed && free_percent >= threshold_percent + LOW_SPACE_REARM_MARGIN_PERCENT {
308+
return (true, false);
309+
}
310+
(armed, false)
311+
}
312+
313+
fn emit_low_disk_space(volume_id: &str, space: &CachedSpace, free_percent: f64, threshold_percent: u64) {
314+
let Some(app) = APP_HANDLE.get() else { return };
315+
let payload = LowDiskSpacePayload {
316+
volume_id: volume_id.to_string(),
317+
total_bytes: space.total_bytes,
318+
available_bytes: space.available_bytes,
319+
free_percent,
320+
threshold_percent,
321+
};
322+
info!(
323+
"low-disk-space: {} at {:.1}% free ({} of {} bytes), threshold {}%",
324+
volume_id, free_percent, space.available_bytes, space.total_bytes, threshold_percent
325+
);
326+
if let Err(e) = app.emit("low-disk-space", &payload) {
327+
warn!("Failed to emit low-disk-space: {}", e);
328+
}
329+
}
330+
195331
/// Fetches space info for a filesystem path using the platform API.
196332
/// Used as a fallback when the volume is not registered in VolumeManager.
197333
fn fetch_space_for_path(path: &str) -> Option<CachedSpace> {
@@ -259,3 +395,74 @@ fn emit(volume_id: &str, space: &CachedSpace) {
259395
warn!("Failed to emit volume-space-changed: {}", e);
260396
}
261397
}
398+
399+
#[cfg(test)]
400+
mod tests {
401+
use super::*;
402+
403+
#[test]
404+
fn fires_once_when_crossing_below_threshold() {
405+
let (armed, fire) = low_space_transition(true, 4.9, 5.0);
406+
assert!(!armed);
407+
assert!(fire);
408+
}
409+
410+
#[test]
411+
fn does_not_fire_above_threshold() {
412+
let (armed, fire) = low_space_transition(true, 5.0, 5.0);
413+
assert!(armed);
414+
assert!(!fire);
415+
}
416+
417+
#[test]
418+
fn does_not_refire_while_disarmed() {
419+
let (armed, fire) = low_space_transition(false, 3.0, 5.0);
420+
assert!(!armed);
421+
assert!(!fire);
422+
}
423+
424+
#[test]
425+
fn stays_disarmed_inside_rearm_margin() {
426+
// Recovered above the threshold but not past the margin: no re-arm,
427+
// so a dip back under 5% can't fire again.
428+
let (armed, fire) = low_space_transition(false, 5.5, 5.0);
429+
assert!(!armed);
430+
assert!(!fire);
431+
}
432+
433+
#[test]
434+
fn rearms_past_the_margin_then_fires_on_next_crossing() {
435+
let (armed, fire) = low_space_transition(false, 6.0, 5.0);
436+
assert!(armed);
437+
assert!(!fire);
438+
let (armed, fire) = low_space_transition(armed, 4.0, 5.0);
439+
assert!(!armed);
440+
assert!(fire);
441+
}
442+
443+
#[test]
444+
fn oscillation_around_threshold_fires_once() {
445+
// 5.2 → 4.8 → 5.2 → 4.8: one warning, not two.
446+
let mut armed = true;
447+
let mut fires = 0;
448+
for free in [5.2, 4.8, 5.2, 4.8] {
449+
let (next, fire) = low_space_transition(armed, free, 5.0);
450+
armed = next;
451+
if fire {
452+
fires += 1;
453+
}
454+
}
455+
assert_eq!(fires, 1);
456+
}
457+
458+
#[test]
459+
fn free_percent_handles_zero_total() {
460+
// Unknown total must read as not-low (no false warning on a bogus fetch).
461+
assert_eq!(free_percent(0, 0), 100.0);
462+
}
463+
464+
#[test]
465+
fn free_percent_computes_fraction() {
466+
assert!((free_percent(1000, 50) - 5.0).abs() < f64::EPSILON);
467+
}
468+
}

apps/desktop/src/lib/downloads/CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ Backend counterpart: [`src-tauri/src/downloads/CLAUDE.md`](../../../src-tauri/sr
3333
- `'both'` → both.
3434
- `'neither'` → no-op.
3535

36-
The macOS native path also asks the OS for permission the first time a session needs it. On denial we surface a single
37-
INFO toast with a stable dedup id; we DON'T flip the user's setting and we DON'T retry. The user can re-enable in System
38-
Settings whenever; their preference stays put.
36+
The macOS native path asks the OS for permission via the shared `$lib/notifications/macos-notification-permission.ts`
37+
helper (also used by `lib/low-disk-space/`): session-cached answer, a single INFO toast with a stable dedup id on
38+
denial, no retries, and we DON'T flip the user's setting. The user can re-enable in System Settings whenever; their
39+
preference stays put.
3940

4041
## Snapshot-at-creation rule
4142

0 commit comments

Comments
 (0)