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

src/pages/storage.rs (17.3K)

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