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 }