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 }