git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

src/pages/storage.rs (17.7K)

  1 use crate::app::{AppAction, PageContent};
  2 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy};
  3 use std::fs;
  4 
  5 /// What the background poll produces — the fetched numbers only, never the
  6 /// widgets. `Refreshed` carries this rather than a whole `StorageState` so a
  7 /// refresh cannot clobber `backup_button` (the notifications-page pattern; the
  8 /// old `*state = new` would have swapped the live widget for a fresh one with a
  9 /// different id, mid-frame, every ten seconds).
 10 #[derive(Debug, Clone)]
 11 pub struct StorageInfo {
 12     pub disk_total: f64,
 13     pub disk_used: f64,
 14     pub ram_total: f64,
 15     pub ram_used: f64,
 16     pub last_backup_time: String,
 17     pub backup_size: String,
 18     pub error_message: Option<String>,
 19 }
 20 
 21 #[derive(Debug, Clone)]
 22 pub struct StorageState {
 23     pub disk_total: f64,
 24     pub disk_used: f64,
 25     pub ram_total: f64,
 26     pub ram_used: f64,
 27     pub loaded: bool,
 28 
 29     // Backup states
 30     pub backup_loaded: bool,
 31     pub backup_in_progress: bool,
 32     pub last_backup_time: String,
 33     pub backup_size: String,
 34     pub error_message: Option<String>,
 35     /// Retained so it can hold keyboard focus: ctrl+i descends into the Full
 36     /// System Backup section and lands here, and Enter/Space runs the backup.
 37     pub backup_button: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 38 }
 39 
 40 impl Default for StorageState {
 41     fn default() -> Self {
 42         Self {
 43             disk_total: 0.0,
 44             disk_used: 0.0,
 45             ram_total: 0.0,
 46             ram_used: 0.0,
 47             loaded: false,
 48             backup_loaded: false,
 49             backup_in_progress: false,
 50             last_backup_time: "Never".to_string(),
 51             backup_size: "0 B".to_string(),
 52             error_message: None,
 53             backup_button: cce_ui::widget::Button::new(0.0, 0.0, 0.0, 32.0)
 54                 // Flat fill + border, not the SDF bevel: PageContent's RenderTarget
 55                 // has no `bevel`, so a raised plate silently draws nothing here
 56                 // (the label renders, the plate does not). The border path is also
 57                 // what carries the keyboard focus ring.
 58                 .with_raised(false)
 59                 .with_label("Run Backup")
 60                 .with_bg(BTN_BG)
 61                 .with_hover_bg(BTN_HOVER)
 62                 .with_label_color(WHITE),
 63         }
 64     }
 65 }
 66 
 67 #[derive(Debug, Clone)]
 68 pub enum StorageMessage {
 69     Refreshed(StorageInfo),
 70     StartBackup,
 71     BackupFinished(Result<(String, String), String>),
 72 }
 73 
 74 /// Where the privileged helper records the last backup's outcome. Passed to
 75 /// the script as an argument rather than hardcoded on both sides: the script
 76 /// runs as root under pkexec, which scrubs the environment, so it cannot
 77 /// resolve this itself — and when both sides did hardcode it they drifted
 78 /// apart across the app's renames (the script wrote
 79 /// `~/.config/clear-system-interface/`, this read `cce-settings/`, and neither
 80 /// path existed).
 81 fn status_path() -> String {
 82     cce_ui::config::cce_config_dir()
 83         .join("backup_status.txt")
 84         .to_string_lossy()
 85         .into_owned()
 86 }
 87 
 88 /// Parse the status file the privileged helper writes. Split out from the read
 89 /// so the format — the one contract shared across the pkexec boundary — can be
 90 /// tested against the exact bytes `scripts/backup-system.sh` emits.
 91 ///
 92 /// `splitn(2, '=')` and not `split('=').nth(1)`: an error message is free text
 93 /// and may well contain an `=`, which the latter silently truncated.
 94 fn parse_backup_status(content: &str) -> (String, String, Option<String>) {
 95     let mut last_backup = "Never".to_string();
 96     let mut size = "0 B".to_string();
 97     let mut err_msg = None;
 98 
 99     for line in content.lines() {
100         let trimmed = line.trim();
101         let Some((key, val)) = trimmed.split_once('=') else { continue };
102         let val = val.trim().to_string();
103         match key.trim() {
104             "last_backup_time" => last_backup = val,
105             "backup_size" => size = val,
106             "error_message" => {
107                 if !val.is_empty() {
108                     err_msg = Some(val);
109                 }
110             }
111             _ => {}
112         }
113     }
114 
115     (last_backup, size, err_msg)
116 }
117 
118 pub fn read_backup_status() -> (String, String, Option<String>) {
119     parse_backup_status(&fs::read_to_string(status_path()).unwrap_or_default())
120 }
121 
122 pub async fn fetch_storage_state() -> StorageInfo {
123     let disk_output = tokio::process::Command::new("df")
124         .args(["-BG", "/"])
125         .output().await.ok()
126         .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
127         .unwrap_or_default();
128     let (disk_total, disk_used) = parse_disk(&disk_output);
129 
130     let mem_output = tokio::process::Command::new("free")
131         .args(["-b"])
132         .output().await.ok()
133         .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
134         .unwrap_or_default();
135     let (ram_total, ram_used) = parse_mem(&mem_output);
136 
137     let (last_backup, size, err) = read_backup_status();
138 
139     StorageInfo {
140         disk_total,
141         disk_used,
142         ram_total,
143         ram_used,
144         last_backup_time: last_backup,
145         backup_size: size,
146         error_message: err,
147     }
148 }
149 
150 pub async fn run_backup() -> Result<(String, String), String> {
151     // Runs the SAME helper the nightly restic-backup.timer runs, so this
152     // button means "run tonight's backup now" rather than a second, different
153     // backup. It used to invoke backup-system.sh under pkexec, which archived
154     // only the root btrfs subvolume — /home has its own st_dev, so
155     // `tar --one-file-system` stopped there and none of the user's data was in
156     // it — and then wrote its result into the same status file, so the page
157     // reported an OS-only tarball as if it were the nightly job.
158     //
159     // No pkexec: restic backs up the user's own files as the user, so the
160     // privilege prompt bought nothing and made unattended runs impossible.
161     // `$CCE_PREFIX/bin`, else `~/.local/bin` — ccebuild's own BINDIR, which is
162     // where it installs every crate's scripts/.
163     let helper = std::env::var("CCE_PREFIX")
164         .map(std::path::PathBuf::from)
165         .unwrap_or_else(|_| {
166             std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local")
167         })
168         .join("bin")
169         .join("restic-backup.sh");
170     let output = tokio::process::Command::new(helper)
171         .arg(status_path())
172         .output()
173         .await
174         .map_err(|e| format!("Failed to run backup script: {}", e))?;
175         
176     if !output.status.success() {
177         // Retrieve any specific error message written to the status file by the script
178         let (_, _, err_msg) = read_backup_status();
179         if let Some(msg) = err_msg {
180             return Err(msg);
181         }
182         let err = String::from_utf8_lossy(&output.stderr).to_string();
183         return Err(format!("Backup process failed: {}", err));
184     }
185     
186     let (last_backup, size, _) = read_backup_status();
187     Ok((last_backup, size))
188 }
189 
190 fn parse_disk(info: &str) -> (f64, f64) {
191     for line in info.lines().skip(1) {
192         let parts: Vec<&str> = line.split_whitespace().collect();
193         if parts.len() >= 4 {
194             let total = parts[1].trim_end_matches('G').parse::<f64>().unwrap_or(0.0);
195             let used = parts[2].trim_end_matches('G').parse::<f64>().unwrap_or(0.0);
196             return (total, used);
197         }
198     }
199     (0.0, 0.0)
200 }
201 
202 fn parse_mem(info: &str) -> (f64, f64) {
203     for line in info.lines() {
204         if line.starts_with("Mem:") {
205             let parts: Vec<&str> = line.split_whitespace().collect();
206             if parts.len() >= 3 {
207                 let total = parts[1].parse::<f64>().unwrap_or(0.0) / 1_073_741_824.0;
208                 let used = parts[2].parse::<f64>().unwrap_or(0.0) / 1_073_741_824.0;
209                 return (total, used);
210             }
211         }
212     }
213     (0.0, 0.0)
214 }
215 
216 const LABEL_FG: [f32; 4] = [0.56, 0.83, 0.56, 1.0];
217 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
218 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
219 const RED: [f32; 4] = [1.0, 0.33, 0.33, 1.0];
220 const GREEN: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
221 const BTN_BG: [f32; 4] = [0.20, 0.40, 0.65, 1.0];
222 const BTN_HOVER: [f32; 4] = [0.28, 0.50, 0.78, 1.0];
223 const BTN_DISABLED: [f32; 4] = [0.15, 0.18, 0.22, 1.0];
224 const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
225 
226 pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut dyn LayoutStrategy, ctx: &mut cce_ui::context::UiContext) -> PageContent {
227     let mut final_pc = PageContent::new();
228     let sec_w = 320.0f32;
229     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(3);
230 
231     // Section 1: Local Storage
232     builder.add_section(&mut final_pc, "Local Storage", sec_focused.first().copied().unwrap_or(false), |sec| {
233         let sec_w = sec.cw;
234         if !state.loaded {
235             sec.text("Loading storage usage...", 12.0, 0.0, 12.0, TEXT_FG);
236         } else {
237             let disk_pct = if state.disk_total > 0.0 {
238                 state.disk_used / state.disk_total * 100.0
239             } else {
240                 0.0
241             };
242 
243             sec.text("Disk", 12.0, 0.0, 12.0, LABEL_FG);
244             sec.text(
245                 &format!("{:.0} / {:.0} GiB  ({:.0}%)", state.disk_used, state.disk_total, disk_pct),
246                 100.0, 0.0, 12.0, TEXT_FG,
247             );
248 
249             let bar_w = sec_w - 2.0 * crate::app::section_margin();
250             let yt = sec.ay();
251             let disk_bar_x = sec.ax(crate::app::section_margin());
252             let mut disk_bar = cce_ui::widget::UsageBar::new((disk_pct as f32 / 100.0).min(1.0))
253                 .with_colors([0.36, 0.60, 0.36, 1.0], [0.15, 0.15, 0.25, 1.0]);
254             render_widget(sec.pc, &mut disk_bar, disk_bar_x, yt, bar_w, 8.0, ctx);
255         }
256     });
257 
258     // Section 2: Memory
259     builder.add_section(&mut final_pc, "Memory", sec_focused.get(1).copied().unwrap_or(false), |sec| {
260         let sec_w = sec.cw;
261         if !state.loaded {
262             sec.text("Loading memory usage...", 12.0, 0.0, 12.0, TEXT_FG);
263         } else {
264             let ram_pct = if state.ram_total > 0.0 {
265                 state.ram_used / state.ram_total * 100.0
266             } else {
267                 0.0
268             };
269 
270             sec.text("RAM", 12.0, 0.0, 12.0, LABEL_FG);
271             sec.text(
272                 &format!("{:.1} / {:.1} GiB  ({:.0}%)", state.ram_used, state.ram_total, ram_pct),
273                 100.0, 0.0, 12.0, TEXT_FG,
274             );
275 
276             let bar_w = sec_w - 2.0 * crate::app::section_margin();
277             let yt = sec.ay();
278             let ram_bar_x = sec.ax(crate::app::section_margin());
279             let mut ram_bar = cce_ui::widget::UsageBar::new((ram_pct as f32 / 100.0).min(1.0))
280                 .with_colors([0.50, 0.50, 0.65, 1.0], [0.15, 0.15, 0.25, 1.0]);
281             render_widget(sec.pc, &mut ram_bar, ram_bar_x, yt, bar_w, 8.0, ctx);
282         }
283     });
284 
285     // Section 2: Full System Backup
286     builder.add_section(&mut final_pc, "Full System Backup", sec_focused.get(2).copied().unwrap_or(false), |sec| {
287         if !state.backup_loaded {
288             sec.text("Loading backup state...", 12.0, 0.0, 12.0, TEXT_DIM);
289         } else {
290             // Status Row
291             sec.text("Backup Status", 12.0, 0.0, 12.0, LABEL_FG);
292             let status_text = if state.backup_in_progress { "Backing up..." } else { "Idle" };
293             let status_color = if state.backup_in_progress { GREEN } else { TEXT_FG };
294             sec.text(status_text, 120.0, 0.0, 12.0, status_color);
295 
296             // Last Backup Row
297             sec.text("Last Backup", 12.0, 0.0, 12.0, LABEL_FG);
298             sec.text(&state.last_backup_time, 120.0, 0.0, 12.0, TEXT_FG);
299 
300             // Backup Size Row
301             sec.text("Archive Size", 12.0, 0.0, 12.0, LABEL_FG);
302             sec.text(&state.backup_size, 120.0, 0.0, 12.0, TEXT_FG);
303 
304             // Target Directories Row
305             sec.text("Backup Targets", 12.0, 0.0, 12.0, LABEL_FG);
306             sec.text("Entire Filesystem (/)  [Preserving attributes]", 120.0, 0.0, 12.0, TEXT_DIM);
307 
308             // Destination Archive Row
309             sec.text("Destination", 12.0, 0.0, 12.0, LABEL_FG);
310             sec.text("USB Drive (/mnt/usb or /run/media/...)", 120.0, 0.0, 12.0, TEXT_DIM);
311 
312             // Error message if present
313             if let Some(ref err) = state.error_message {
314                 sec.text("Error:", 12.0, 0.0, 12.0, RED);
315                 sec.text(err, 60.0, 0.0, 11.0, RED);
316             }
317 
318             // Action Button
319             let mut stack = sec.vstack(cce_ui::layout::plate_gap());
320             let btn_h = 32.0;
321             
322             // Retained widget rather than an immediate `sec.button`, so it can hold
323             // keyboard focus. Its label/colours are re-synced each frame from the
324             // backup state, the way cce-mail drives its retained btn_unread.
325             let (btn_label, bg, hover) = if state.backup_in_progress {
326                 ("Backing up...", BTN_DISABLED, BTN_DISABLED)
327             } else {
328                 ("Run Backup", BTN_BG, BTN_HOVER)
329             };
330             state.backup_button.set_label(btn_label);
331             state.backup_button.bg = Some(bg);
332             state.backup_button.hover_bg = Some(hover);
333 
334             let btn = &mut state.backup_button;
335             stack.add_row(1, 0.0, btn_h, |sctx, _, x, w| {
336                 let y = sctx.ay();
337                 render_widget(sctx.pc, btn, x, y, w, btn_h, ctx);
338             });
339         }
340     });
341 
342     final_pc
343 }
344 
345 pub fn update(state: &mut StorageState, msg: StorageMessage) {
346     match msg {
347         StorageMessage::Refreshed(new) => {
348             // Field-wise, so the retained button and an in-flight backup survive.
349             state.disk_total = new.disk_total;
350             state.disk_used = new.disk_used;
351             state.ram_total = new.ram_total;
352             state.ram_used = new.ram_used;
353             state.last_backup_time = new.last_backup_time;
354             state.backup_size = new.backup_size;
355             state.error_message = new.error_message;
356             state.loaded = true;
357             state.backup_loaded = true;
358         }
359         StorageMessage::StartBackup => {
360             state.backup_in_progress = true;
361             state.error_message = None;
362         }
363         StorageMessage::BackupFinished(res) => {
364             state.backup_in_progress = false;
365             match res {
366                 Ok((date, size)) => {
367                     state.last_backup_time = date;
368                     state.backup_size = size;
369                     state.error_message = None;
370                 }
371                 Err(err) => {
372                     state.error_message = Some(err);
373                 }
374             }
375         }
376     }
377 }
378 
379 impl crate::pages::AppPage for StorageState {
380     // Sections: [Local Storage, Memory, Full System Backup] — no evented widgets.
381     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
382         // Only the third section (Full System Backup) has anything focusable;
383         // the first two are read-only readouts, so ctrl+i there has no target.
384         // Gated on `backup_loaded` to mirror the view: the button is only painted
385         // (and so only registered) in that branch, and an id reported here while
386         // unregistered is a dead root the router drops with a warning.
387         let backup = if self.backup_loaded {
388             vec![self.backup_button.id()]
389         } else {
390             Vec::new()
391         };
392         vec![Vec::new(), Vec::new(), backup]
393     }
394 
395     fn view(
396         &mut self,
397         cx: f32,
398         cy: f32,
399         cw: f32,
400         ch: f32,
401         _root_focused: bool,
402         sec_focused: &[bool],
403         layout: &mut dyn LayoutStrategy,
404         ctx: &mut cce_ui::context::UiContext,
405     ) -> crate::app::PageContent {
406         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
407     }
408 
409     fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
410         // Mouse click and Enter/Space on the focused button both land here.
411         if self.backup_button.take_click() && !self.backup_in_progress {
412             actions.push(AppAction::Storage(StorageMessage::StartBackup));
413         }
414     }
415 }
416 
417 #[cfg(test)]
418 mod tests {
419     use super::*;
420 
421     /// Byte-for-byte what `scripts/backup-system.sh` writes on a failed run
422     /// (its `write_status` heredoc). This pins the one contract that crosses
423     /// the pkexec boundary — the two sides previously drifted onto different
424     /// paths AND different spellings without anything noticing.
425     #[test]
426     fn parses_the_helpers_failure_status() {
427         let written = "last_backup_time = Never\nbackup_size = 0 B\nerror_message = No external drive mounted at /mnt/usb or /run/media/lsgalante/*\n";
428         let (time, size, err) = parse_backup_status(written);
429         assert_eq!(time, "Never");
430         assert_eq!(size, "0 B");
431         assert_eq!(err.as_deref(), Some("No external drive mounted at /mnt/usb or /run/media/lsgalante/*"));
432     }
433 
434     #[test]
435     fn parses_the_helpers_success_status() {
436         // A success run writes an EMPTY error_message; that must read as None,
437         // not as Some(""), or the page reports a failure after a good backup.
438         let written = "last_backup_time = 2026-08-23 11:04:12\nbackup_size = 41G\nerror_message = \n";
439         let (time, size, err) = parse_backup_status(written);
440         assert_eq!(time, "2026-08-23 11:04:12");
441         assert_eq!(size, "41G");
442         assert_eq!(err, None);
443     }
444 
445     #[test]
446     fn an_error_message_may_contain_an_equals_sign() {
447         // `split('=').nth(1)` truncated at the second '='; free text can hold one.
448         let (_, _, err) = parse_backup_status("error_message = tar: bad option --foo=bar\n");
449         assert_eq!(err.as_deref(), Some("tar: bad option --foo=bar"));
450     }
451 
452     #[test]
453     fn a_missing_status_file_reads_as_never() {
454         let (time, size, err) = parse_backup_status("");
455         assert_eq!((time.as_str(), size.as_str(), err), ("Never", "0 B", None));
456     }
457 }