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

src/pages/system_info.rs (36.1K)

  1 use crate::app::{button_need, form_button, form_button_fit, wrap_to_width, AppAction, PageContent};
  2 use cce_ui::context::UiContext;
  3 use cce_ui::widget::Handle;
  4 use cce_ui::layout::{PageLayoutBuilder, PageFlow};
  5 use cce_ui::widget::{Label, WidgetHostExt};
  6 
  7 
  8 #[derive(Debug, Clone)]
  9 pub struct NotificationsConfig {
 10     pub enable: bool,
 11     pub bell: String,
 12     pub duration: i32,
 13 }
 14 
 15 /// The root-owned half of the desktop's install: `/usr/bin` binaries, system
 16 /// units and `/etc/pam.d` stacks. `ccebuild install` never touches these —
 17 /// they need root — so they drift silently, and have: the greeter fix for the
 18 /// suspend/resume hang sat built but undeployed for weeks because deploying it
 19 /// meant remembering to run one command in a terminal.
 20 ///
 21 /// The plan is read with `install-system --dry-run`, which compares as the
 22 /// normal user (every target is world-readable) and prints one `name -> dest`
 23 /// line per pending change. Applying re-plans and runs the whole batch under a
 24 /// single pkexec, authenticated by the session's polkit agent
 25 /// (`cce-authenticator`) like every other privileged action in this desktop.
 26 #[derive(Clone, Default)]
 27 pub struct SysFiles {
 28     /// Whether a scan has completed; until then the section says so rather
 29     /// than claiming everything is up to date.
 30     pub scanned: bool,
 31     /// One `name -> dest` line per pending change. Empty after a scan means
 32     /// the system artifacts match the build.
 33     pub pending: Vec<String>,
 34     /// A scan or an install is in flight. Also gates the button, so a second
 35     /// click cannot start a second pkexec.
 36     pub busy: bool,
 37     /// Outcome of the last install attempt, shown until the next one.
 38     pub result: Option<Result<(), String>>,
 39 }
 40 
 41 #[derive(Clone)]
 42 pub struct SystemState {
 43     pub hostname: String,
 44     pub kernel: String,
 45     pub uptime: String,
 46     pub loaded: bool,
 47 
 48     // Hardware status
 49     pub cpu_model: String,
 50     pub cpu_usage: f32,
 51     pub cpu_cores: u32,
 52     pub gpus: Vec<String>,
 53     pub gpu_strings: Vec<String>,
 54     pub builds: Vec<InstalledBuild>,
 55     pub cpu_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
 56     pub cpu_usage_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
 57     pub cpu_temp_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
 58 
 59     // Power-related fields
 60 
 61     // Native layout tracking and widgets
 62     pub initialized: bool,
 63     pub sender: Option<calloop::channel::Sender<AppAction>>,
 64     pub sysfiles: SysFiles,
 65     pub hostname_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
 66     pub uptime_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
 67 }
 68 
 69 impl std::fmt::Debug for SystemState {
 70     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 71         f.debug_struct("SystemState")
 72             .field("hostname", &self.hostname)
 73             .field("kernel", &self.kernel)
 74             .field("uptime", &self.uptime)
 75             .field("loaded", &self.loaded)
 76             .finish()
 77     }
 78 }
 79 
 80 impl Default for SystemState {
 81     fn default() -> Self {
 82         Self {
 83             hostname: String::new(),
 84             kernel: String::new(),
 85             uptime: String::new(),
 86             loaded: false,
 87 
 88             cpu_model: String::new(),
 89             cpu_usage: 0.0,
 90             cpu_cores: 0,
 91             gpus: Vec::new(),
 92             gpu_strings: Vec::new(),
 93             builds: Vec::new(),
 94             cpu_label: Handle::none(),
 95             cpu_usage_label: Handle::none(),
 96             cpu_temp_label: Handle::none(),
 97 
 98 
 99             initialized: false,
100             sender: None,
101             sysfiles: SysFiles::default(),
102             hostname_label: Handle::none(),
103             uptime_label: Handle::none(),
104         }
105     }
106 }
107 
108 impl SystemState {
109     /// The page's state, its widgets inserted into `ctx`.
110     pub fn new(ctx: &mut UiContext) -> Self {
111         Self {
112             cpu_label: ctx.insert(Label::new("CPU Info")),
113             cpu_usage_label: ctx.insert(Label::new("CPU Usage")),
114             cpu_temp_label: ctx.insert(Label::new("CPU Temp")),
115             hostname_label: ctx.insert(Label::new("")),
116             uptime_label: ctx.insert(Label::new("")),
117             ..Self::default()
118         }
119     }
120 }
121 
122 #[derive(Debug, Clone)]
123 pub enum SystemMessage {
124     Refreshed(SystemInfo),
125     Suspend,
126     Hibernate,
127     Reboot,
128     PowerOff,
129     ForceShutdown,
130 
131     /// Read the pending root-owned changes (`install-system --dry-run`).
132     ScanSystemFiles,
133     SystemFilesScanned(Vec<String>),
134     /// Apply them, authenticating through the polkit agent.
135     InstallSystemFiles,
136     SystemFilesInstalled(Result<(), String>),
137 }
138 
139 // ── Helpers ─────────────────────────────────────────────────────────
140 
141 
142 
143 fn read_cpu_temp() -> Option<f32> {
144     if let Ok(entries) = std::fs::read_dir("/sys/class/hwmon") {
145         for entry in entries.filter_map(|e| e.ok()) {
146             let path = entry.path();
147             if let Ok(name) = std::fs::read_to_string(path.join("name")) {
148                 let name = name.trim();
149                 if name == "thinkpad" {
150                     if let Ok(val) = std::fs::read_to_string(path.join("temp1_input")) {
151                         if let Ok(temp_milli) = val.trim().parse::<f32>() {
152                             return Some(temp_milli / 1000.0);
153                         }
154                     }
155                 } else if name == "coretemp" {
156                     if let Ok(val) = std::fs::read_to_string(path.join("temp1_input")) {
157                         if let Ok(temp_milli) = val.trim().parse::<f32>() {
158                             return Some(temp_milli / 1000.0);
159                         }
160                     }
161                 }
162             }
163         }
164     }
165     None
166 }
167 
168 fn read_thinkpad_gpu_temp() -> Option<f32> {
169     if let Ok(entries) = std::fs::read_dir("/sys/class/hwmon") {
170         for entry in entries.filter_map(|e| e.ok()) {
171             let path = entry.path();
172             if let Ok(name) = std::fs::read_to_string(path.join("name")) {
173                 if name.trim() == "thinkpad" {
174                     for i in 1..=8 {
175                         let label_path = path.join(format!("temp{}_label", i));
176                         if let Ok(lbl) = std::fs::read_to_string(&label_path) {
177                             if lbl.trim() == "GPU" {
178                                 if let Ok(val) = std::fs::read_to_string(path.join(format!("temp{}_input", i))) {
179                                     if let Ok(temp_milli) = val.trim().parse::<f32>() {
180                                         return Some(temp_milli / 1000.0);
181                                     }
182                                 }
183                             }
184                         }
185                     }
186                 }
187             }
188         }
189     }
190     None
191 }
192 
193 /// `uptime -p`'s wording ("1 day, 8 hours, 13 minutes"), from seconds.
194 fn pretty_uptime(secs: u64) -> String {
195     let units = [("year", 365 * 86400), ("week", 7 * 86400), ("day", 86400), ("hour", 3600), ("minute", 60)];
196     let mut rest = secs;
197     let mut parts = Vec::new();
198     for (name, size) in units {
199         let n = rest / size;
200         rest %= size;
201         if n > 0 {
202             parts.push(format!("{n} {name}{}", if n == 1 { "" } else { "s" }));
203         }
204     }
205     if parts.is_empty() {
206         "0 minutes".to_string()
207     } else {
208         parts.join(", ")
209     }
210 }
211 
212 /// nvidia-smi's temperature, asked for only while the card is awake — the
213 /// query wakes a runtime-suspended card (processes.rs makes the same check
214 /// for its draw figure). Until 2026-10-05 the System page asked every five
215 /// seconds regardless, which kept the dGPU out of D3cold while it was open.
216 async fn read_nvidia_gpu_temp() -> Option<f32> {
217     if !crate::power_meter::dgpu_awake() {
218         return None;
219     }
220     let out = tokio::process::Command::new("nvidia-smi")
221         .args(["--query-gpu=temperature.gpu", "--format=csv,noheader,nounits"])
222         .output().await.ok()?;
223     let val_str = String::from_utf8_lossy(&out.stdout);
224     val_str.trim().parse::<f32>().ok()
225 }
226 
227 /// The installed `ccebuild`, by absolute path. An app launched from the menu
228 /// gets systemd's environment rather than the session's, so `~/.local/bin` is
229 /// not reliably on PATH — the same trap the desktop-menu script avoids by
230 /// spelling out `$HOME/.local/bin/ccectl`. Falls back to the bare name so a
231 /// PATH that does have it still works.
232 fn ccebuild_path() -> std::path::PathBuf {
233     if let Ok(home) = std::env::var("HOME") {
234         let p = std::path::Path::new(&home).join(".local/bin/ccebuild");
235         if p.is_file() {
236             return p;
237         }
238     }
239     std::path::PathBuf::from("ccebuild")
240 }
241 
242 /// The `  name -> dest` lines of a dry run: one per root-owned file that
243 /// differs from the build. Anything else the script prints (the `==>` banners,
244 /// the queued root commands) is not a pending change and is dropped.
245 fn parse_pending(stdout: &str) -> Vec<String> {
246     stdout
247         .lines()
248         .filter(|l| l.starts_with("  ") && l.contains(" -> "))
249         .map(|l| l.trim().to_string())
250         .collect()
251 }
252 
253 /// Run one `ccebuild install-system` variant off the UI thread and post the
254 /// result back through the page's sender. Both calls shell out rather than
255 /// reimplementing the compare, so the plan has exactly one author — the same
256 /// reason the apply re-plans instead of trusting what the scan printed.
257 fn spawn_sysfiles<F>(sender: Option<calloop::channel::Sender<AppAction>>, args: &'static [&'static str], done: F)
258 where
259     F: FnOnce(std::io::Result<std::process::Output>) -> SystemMessage + Send + 'static,
260 {
261     let Some(tx) = sender else { return };
262     std::thread::spawn(move || {
263         let out = std::process::Command::new(ccebuild_path()).args(args).output();
264         let _ = tx.send(AppAction::SystemInfo(done(out)));
265     });
266 }
267 
268 /// A line of text wrapped to the form's width rather than cut at it: its lines one under the
269 /// next, so one wrapped item still reads as one item.
270 fn wrapped_text(g: &mut cce_ui::layout::FormGroup<'_, '_, PageContent>, text: &str, size: f32, color: [f32; 4]) {
271     let lines = wrap_to_width(text, g.form_width(), size);
272     g.lines(lines, size, color);
273 }
274 
275 fn spawn_systemctl(action: &str) {
276     let mut cmd = std::process::Command::new("systemctl");
277     cmd.arg(action);
278     let _ = crate::spawn_detached(cmd);
279 }
280 
281 fn spawn_systemctl_force(action: &str) {
282     let mut cmd = std::process::Command::new("systemctl");
283     cmd.arg(action);
284     cmd.arg("-f");
285     cmd.arg("-f");
286     let _ = crate::spawn_detached(cmd);
287 }
288 
289 /// One compiled cce binary in `~/.local/bin` and how long it took to build.
290 #[derive(Debug, Clone, PartialEq)]
291 pub struct InstalledBuild {
292     pub name: String,
293     /// Seconds the last recorded build of this binary took: its own bin unit
294     /// plus its crate's lib and build script, not its dependencies. `None`
295     /// when no build of it went through `ccebuild`, the only thing that
296     /// records them.
297     pub seconds: Option<f32>,
298 }
299 
300 /// `ccebuild`'s build-time record, `bin<TAB>seconds<TAB>epoch` per line
301 /// (`BUILD_TIMES` in the script, filled from cargo's `--timings` reports).
302 /// Cargo keeps no build durations of its own, so a binary built by a bare
303 /// `cargo build` has no line here.
304 fn parse_build_times(text: &str) -> std::collections::HashMap<String, f32> {
305     text.lines()
306         .filter_map(|l| {
307             let mut f = l.split('\t');
308             let bin = f.next()?;
309             let secs = f.next()?.parse::<f32>().ok()?;
310             Some((bin.to_string(), secs))
311         })
312         .collect()
313 }
314 
315 /// "8.4s" / "1min 32s" — a build time, which unlike an age wants its tenths
316 /// under a minute.
317 fn format_build_duration(secs: f32) -> String {
318     // Rounded first, so 59.96 reads "1min 00s" rather than "60.0s".
319     if (secs * 10.0).round() < 600.0 {
320         format!("{:.1}s", secs)
321     } else {
322         let s = secs.round() as u64;
323         format!("{}min {:02}s", s / 60, s % 60)
324     }
325 }
326 
327 /// Every compiled `cce*` binary in `~/.local/bin` with its recorded build
328 /// time, slowest first and unrecorded ones last. Symlinks (`cce`,
329 /// `cce-settings`) are aliases, and shell scripts (`ccebuild`, `cce-shadow`)
330 /// have no build, so only ELF files count.
331 fn scan_installed_builds() -> Vec<InstalledBuild> {
332     use std::io::Read;
333     let Some(home) = std::env::var_os("HOME") else { return Vec::new() };
334     let home = std::path::Path::new(&home);
335     let bindir = home.join(".local/bin");
336     let state = std::env::var_os("XDG_STATE_HOME")
337         .map(std::path::PathBuf::from)
338         .unwrap_or_else(|| home.join(".local/state"));
339     let times = std::fs::read_to_string(state.join("cce/build-times"))
340         .map(|t| parse_build_times(&t))
341         .unwrap_or_default();
342     let Ok(entries) = std::fs::read_dir(&bindir) else { return Vec::new() };
343 
344     let mut builds: Vec<InstalledBuild> = entries
345         .filter_map(|e| e.ok())
346         .filter_map(|e| {
347             let name = e.file_name().into_string().ok()?;
348             if !name.starts_with("cce") || !e.file_type().ok()?.is_file() {
349                 return None;
350             }
351             let mut magic = [0u8; 4];
352             std::fs::File::open(e.path()).ok()?.read_exact(&mut magic).ok()?;
353             if &magic != b"\x7fELF" {
354                 return None;
355             }
356             let seconds = times.get(&name).copied();
357             Some(InstalledBuild { name, seconds })
358         })
359         .collect();
360     builds.sort_by(|a, b| match (a.seconds, b.seconds) {
361         (Some(x), Some(y)) => y.total_cmp(&x).then_with(|| a.name.cmp(&b.name)),
362         (Some(_), None) => std::cmp::Ordering::Less,
363         (None, Some(_)) => std::cmp::Ordering::Greater,
364         (None, None) => a.name.cmp(&b.name),
365     });
366     builds
367 }
368 
369 #[derive(Debug, Clone, Default)]
370 pub struct SystemInfo {
371     pub hostname: String,
372     pub kernel: String,
373     pub uptime: String,
374     pub cpu_model: String,
375     pub cpu_cores: u32,
376     pub cpu_usage: f32,
377     pub gpus: Vec<String>,
378     pub gpu_strings: Vec<String>,
379     pub builds: Vec<InstalledBuild>,
380 }
381 
382 pub async fn fetch_system_state() -> SystemInfo {
383     // The kernel's own copy, not the `hostname` binary: inetutils is not
384     // part of a base Arch install, and without it the System well opened on
385     // a bare "—  Linux …" with nothing in front of the dash.
386     let hostname = std::fs::read_to_string("/proc/sys/kernel/hostname")
387         .ok()
388         .and_then(|h| h.trim().split('.').next().map(|s| s.to_string()))
389         .unwrap_or_default();
390 
391     // Both from /proc rather than spawning `uname -r` and `uptime -p` on
392     // every refresh.
393     let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
394         .map(|k| k.trim().to_string())
395         .unwrap_or_default();
396 
397     let uptime = std::fs::read_to_string("/proc/uptime")
398         .ok()
399         .and_then(|u| u.split_whitespace().next()?.parse::<f64>().ok())
400         .map(|secs| pretty_uptime(secs as u64))
401         .unwrap_or_default();
402 
403     static CPU_INFO: std::sync::OnceLock<(String, u32)> = std::sync::OnceLock::new();
404     let (cpu_model, cpu_cores) = CPU_INFO.get_or_init(|| {
405         let output = std::process::Command::new("lscpu")
406             .output().ok()
407             .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
408             .unwrap_or_default();
409         let model = output.lines()
410             .find(|l| l.contains("Model name"))
411             .and_then(|l| l.split(':').nth(1))
412             .map(|s| s.trim().to_string())
413             .unwrap_or_default();
414         let cores = output.lines()
415             .find(|l| l.contains("CPU(s)"))
416             .and_then(|l| {
417                 let rest = l.split(':').nth(1).unwrap_or("").trim();
418                 rest.split_whitespace().next().and_then(|n| n.parse::<u32>().ok())
419             })
420             .unwrap_or(0);
421         (model, cores)
422     }).clone();
423 
424     let cpu_usage = {
425         let read_stat = || -> Option<(u64, u64)> {
426             let stat = std::fs::read_to_string("/proc/stat").ok()?;
427             let first = stat.lines().next()?;
428             let vals: Vec<u64> = first.split_whitespace().skip(1).filter_map(|v| v.parse().ok()).collect();
429             if vals.len() < 3 { return None; }
430             let total: u64 = vals.iter().sum();
431             let idle = vals.get(3).copied().unwrap_or(0);
432             Some((idle, total))
433         };
434         // Busy share since the previous refresh; the first one samples a
435         // 100 ms window, as every refresh used to.
436         static LAST: std::sync::Mutex<Option<(u64, u64)>> = std::sync::Mutex::new(None);
437         let prev = *LAST.lock().unwrap();
438         let (idle1, total1) = match prev {
439             Some(p) => p,
440             None => {
441                 let first = read_stat().unwrap_or((0, 1));
442                 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
443                 first
444             }
445         };
446         let (idle2, total2) = read_stat().unwrap_or((0, 1));
447         *LAST.lock().unwrap() = Some((idle2, total2));
448         let d_idle = idle2.saturating_sub(idle1);
449         let d_total = total2.saturating_sub(total1);
450         if d_total > 0 {
451             (1.0 - d_idle as f64 / d_total as f64) * 100.0
452         } else { 0.0 }
453     } as f32;
454 
455     static GPUS_INFO: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
456     let gpus = GPUS_INFO.get_or_init(|| {
457         let mut list = Vec::new();
458         if let Some(o) = std::process::Command::new("lspci").output().ok() {
459             for line in String::from_utf8_lossy(&o.stdout).lines() {
460                 if line.contains("VGA") || line.contains("3D") {
461                     if let Some(name) = line.split(':').nth(2) {
462                         let trimmed = name.trim().to_string();
463                         if !trimmed.is_empty() {
464                             list.push(trimmed);
465                         }
466                     }
467                 }
468             }
469         }
470         list
471     }).clone();
472 
473 
474     let tp_gpu_temp = read_thinkpad_gpu_temp();
475     let nv_gpu_temp = read_nvidia_gpu_temp().await;
476 
477     let gpu_strings = gpus.iter().map(|gpu_name| {
478         let temp = if gpu_name.to_lowercase().contains("nvidia") {
479             nv_gpu_temp.or(tp_gpu_temp)
480         } else {
481             tp_gpu_temp
482         };
483         let temp_str = temp.map(|t| format!("  —  {:.0}°C", t)).unwrap_or_default();
484         format!("GPU  {}{}", gpu_name, temp_str)
485     }).collect();
486 
487 
488     SystemInfo {
489         hostname,
490         kernel,
491         uptime,
492         cpu_model,
493         cpu_usage,
494         cpu_cores,
495         gpus,
496         gpu_strings,
497         builds: scan_installed_builds(),
498     }
499 }
500 
501 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
502 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
503 const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
504 const DANGER_BG: [f32; 4] = [0.67, 0.20, 0.20, 1.0];
505 const SAFE_BG: [f32; 4] = [0.20, 0.33, 0.22, 1.0];
506 const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
507 
508 pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
509     let mut final_pc = PageContent::new();
510     let sec_w = 320.0f32;
511     // Six, matching the add_section calls below and the six groups
512     // section_widgets reports. The count caps the grid's column count
513     // (`n.min(cols)`), so a stale count only bites once the window is wide
514     // enough for that many columns — harmless, but it reads as a missing
515     // section.
516     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(6);
517 
518     // ── 1. System Section ──
519     builder.add_section(&mut final_pc, "System", sec_focused.first().copied().unwrap_or(false), |sec| {
520         let mut form = sec.form();
521         form.column().block(|b| {
522             if !state.loaded {
523                 b.text("Loading system information...", 14.0, TEXT_FG);
524             } else {
525                 wrapped_text(b, &format!("{}  —  Linux {}", state.hostname, state.kernel), 14.0, TEXT_FG);
526                 wrapped_text(b, &format!("Uptime: {}", state.uptime), 12.0, TEXT_DIM);
527             }
528         });
529         sec.place(form, ctx);
530     });
531 
532     // ── 2. System Actions Section ──
533     builder.add_section(&mut final_pc, "System Actions", sec_focused.get(1).copied().unwrap_or(false), |sec| {
534         const ACTIONS: [(&str, [f32; 4], SystemMessage); 4] = [
535             ("Suspend", SAFE_BG, SystemMessage::Suspend),
536             ("Hibernate", SAFE_BG, SystemMessage::Hibernate),
537             ("Reboot", DANGER_BG, SystemMessage::Reboot),
538             ("Power Off", DANGER_BG, SystemMessage::PowerOff),
539         ];
540         let needs: Vec<f32> = ACTIONS.iter().map(|a| button_need(a.0)).collect();
541         let mut form = sec.form();
542         // All four on one row when their labels fit; otherwise the safe pair over the
543         // destructive pair, rather than squeezing every plate below its label.
544         let gap = cce_ui::layout::control_gap();
545         let one_row = needs.iter().sum::<f32>() + gap * (needs.len() - 1) as f32 <= form.width();
546         let rows: &[std::ops::Range<usize>] = if one_row { &[0..4] } else { &[0..2, 2..4] };
547         let mut col = form.column();
548         for range in rows {
549             col.row(|r| {
550                 for i in range.clone() {
551                     let (label, bg, msg) = ACTIONS[i].clone();
552                     form_button_fit(r, label, needs[i], (bg, BTN_HOVER, WHITE), AppAction::SystemInfo(msg));
553                 }
554             });
555         }
556         form_button(&mut col, "Force Shutdown", 0.0, (DANGER_BG, BTN_HOVER, WHITE), AppAction::SystemInfo(SystemMessage::ForceShutdown));
557         sec.place(form, ctx);
558     });
559 
560     // ── 3. CPU Section ──
561     builder.add_section(&mut final_pc, "CPU", sec_focused.get(2).copied().unwrap_or(false), |sec| {
562         let mut form = sec.form();
563         form.column().block(|b| {
564             if !state.loaded {
565                 b.text("Loading CPU model and utilization...", 12.0, TEXT_FG);
566             } else {
567                 wrapped_text(b, &format!("CPU  {}  ({} cores)", state.cpu_model, state.cpu_cores), 12.0, TEXT_FG);
568                 wrapped_text(b, &format!("Usage  {:.0}%", state.cpu_usage), 12.0, TEXT_FG);
569                 let cpu_temp_text = read_cpu_temp().map(|t| format!("Temp  {:.0}°C", t)).unwrap_or_else(|| "Temp  N/A".to_string());
570                 wrapped_text(b, &cpu_temp_text, 12.0, TEXT_FG);
571             }
572         });
573         sec.place(form, ctx);
574     });
575 
576     // ── 4. GPU Section ──
577     builder.add_section(&mut final_pc, "GPU", sec_focused.get(3).copied().unwrap_or(false), |sec| {
578         let mut form = sec.form();
579         form.column().block(|b| {
580             if !state.loaded {
581                 b.text("Loading GPU models...", 12.0, TEXT_FG);
582             } else {
583                 for gpu_text in state.gpu_strings.iter() {
584                     wrapped_text(b, gpu_text, 12.0, TEXT_FG);
585                 }
586             }
587         });
588         sec.place(form, ctx);
589     });
590 
591     // ── 5. System Files Section ──
592     // The root-owned half of the install, which `ccebuild install` cannot
593     // touch. It lives here rather than on the desktop menu so the pending
594     // changes can be READ before they are authorized: this batch can rewrite
595     // /etc/pam.d, and a flat menu button would be one misclick from replacing
596     // the greeter out of a half-built tree.
597     builder.add_section(&mut final_pc, "System Files", sec_focused.get(4).copied().unwrap_or(false), |sec| {
598         let sf = &state.sysfiles;
599         let (status, color) = if sf.busy {
600             ("Checking…".to_string(), TEXT_DIM)
601         } else if !sf.scanned {
602             ("Not checked yet".to_string(), TEXT_DIM)
603         } else if sf.pending.is_empty() {
604             ("Up to date with the build".to_string(), TEXT_DIM)
605         } else {
606             (
607                 format!(
608                     "{} file{} differ{} from the build",
609                     sf.pending.len(),
610                     if sf.pending.len() == 1 { "" } else { "s" },
611                     if sf.pending.len() == 1 { "s" } else { "" },
612                 ),
613                 TEXT_FG,
614             )
615         };
616         let mut form = sec.form();
617         let wrap_w = form.width();
618         let mut col = form.column();
619         col.block(|b| {
620             b.text(status, 12.0, color);
621             // Name the files. "2 files differ" is not enough to authorize a root
622             // install on — which one is the greeter matters. A path pair is wider
623             // than the well, and the well clips: wrapped, continuation lines
624             // indented past the first, so each file still reads as one entry.
625             let indent = cce_ui::layout::CONTROL_TEXT_INSET;
626             for line in sf.pending.iter().take(8) {
627                 let lines = wrap_to_width(line, wrap_w, 11.0);
628                 let (first, rest) = lines.split_first().map_or((String::new(), &[][..]), |(f, r)| (f.clone(), r));
629                 let rest: Vec<String> = rest.iter().flat_map(|l| wrap_to_width(l, wrap_w - indent, 11.0)).collect();
630                 b.text(first, 11.0, TEXT_DIM);
631                 if !rest.is_empty() {
632                     b.row(|r| {
633                         r.space(indent, false);
634                         r.lines(rest, 11.0, TEXT_DIM);
635                     });
636                 }
637             }
638         });
639 
640         if let Some(ref res) = sf.result {
641             let (msg, color) = match res {
642                 Ok(()) => ("Installed — takes effect at next login".to_string(), TEXT_DIM),
643                 Err(e) => (format!("Failed: {}", e), DANGER_BG),
644             };
645             wrapped_text(&mut col, &msg, 11.0, color);
646         }
647 
648         let has_work = sf.scanned && !sf.pending.is_empty();
649         let busy = sf.busy;
650         let (check_need, install_need) = (button_need("Check"), button_need("Install (root)"));
651         col.row(|r| {
652             form_button_fit(r, "Check", check_need, (SAFE_BG, BTN_HOVER, WHITE), AppAction::SystemInfo(SystemMessage::ScanSystemFiles));
653             // Only offered when there is something to install. The apply re-plans anyway,
654             // so a stale-enabled button would be a no-op rather than a hazard — but it
655             // would also put up a root prompt for nothing. Its room is kept either way, so
656             // Check does not change width.
657             if has_work && !busy {
658                 form_button_fit(r, "Install (root)", install_need, (DANGER_BG, BTN_HOVER, WHITE), AppAction::SystemInfo(SystemMessage::InstallSystemFiles));
659             } else {
660                 r.space(install_need, true);
661             }
662         });
663         sec.place(form, ctx);
664     });
665 
666     // ── 6. Builds Section ──
667     // How long each installed cce binary took to build, slowest first.
668     builder.add_section(&mut final_pc, "Builds", sec_focused.get(5).copied().unwrap_or(false), |sec| {
669         let mut form = sec.form();
670         let mut col = form.column();
671         if !state.loaded {
672             col.text("Loading installed builds...", 12.0, TEXT_FG);
673         } else if state.builds.is_empty() {
674             col.text("No cce binaries in ~/.local/bin", 12.0, TEXT_DIM);
675         } else {
676             // "—" is a binary no ccebuild build has timed (a bare `cargo
677             // build`, or one built before the timing was recorded).
678             let pairs = state
679                 .builds
680                 .iter()
681                 .map(|b| (b.name.clone(), TEXT_FG, b.seconds.map(format_build_duration).unwrap_or_else(|| "—".to_string()), TEXT_DIM))
682                 .collect();
683             crate::app::form_pairs(&mut col, 11.0, pairs);
684         }
685         sec.place(form, ctx);
686     });
687 
688     final_pc
689 }
690 
691 pub fn update(state: &mut SystemState, msg: SystemMessage, ctx: &mut cce_ui::context::UiContext) {
692     match msg {
693         SystemMessage::Refreshed(new) => {
694             state.hostname = new.hostname;
695             state.kernel = new.kernel;
696             state.uptime = new.uptime;
697             state.loaded = true;
698 
699             state.cpu_model = new.cpu_model;
700             state.cpu_usage = new.cpu_usage;
701             state.cpu_cores = new.cpu_cores;
702             state.gpus = new.gpus;
703             state.gpu_strings = new.gpu_strings.clone();
704             state.builds = new.builds;
705 
706             if state.loaded {
707                 ctx[state.hostname_label].set_text(&format!("{}  —  Linux {}", state.hostname, state.kernel));
708                 ctx[state.uptime_label].set_text(&format!("Uptime: {}", state.uptime));
709 
710                 let cpu_label_text = format!("CPU  {}  ({} cores)", state.cpu_model, state.cpu_cores);
711                 let cpu_usage_text = format!("Usage  {:.0}%", state.cpu_usage);
712                 let cpu_temp_text = read_cpu_temp().map(|t| format!("Temp  {:.0}°C", t)).unwrap_or_else(|| "Temp  N/A".to_string());
713 
714                 ctx[state.cpu_label].set_text(&cpu_label_text);
715                 ctx[state.cpu_usage_label].set_text(&cpu_usage_text);
716                 ctx[state.cpu_temp_label].set_text(&cpu_temp_text);
717 
718 
719 
720                 ctx.lend_h(state.hostname_label, |w, ctx| w.mark_dirty(ctx));
721             }
722 
723             // First refresh doubles as the first system-files scan, so the
724             // section has an answer without the user pressing Check. Guarded
725             // on both flags because Refreshed repeats on a timer and each scan
726             // is a process spawn.
727             if !state.sysfiles.scanned && !state.sysfiles.busy {
728                 update(state, SystemMessage::ScanSystemFiles, ctx);
729             }
730         }
731         SystemMessage::Suspend => spawn_systemctl("suspend"),
732         SystemMessage::Hibernate => spawn_systemctl("hibernate"),
733         SystemMessage::Reboot => spawn_systemctl("reboot"),
734         SystemMessage::PowerOff => spawn_systemctl("poweroff"),
735         SystemMessage::ForceShutdown => spawn_systemctl_force("poweroff"),
736 
737         SystemMessage::ScanSystemFiles => {
738             state.sysfiles.busy = true;
739             spawn_sysfiles(state.sender.clone(), &["install-system", "--dry-run"], |out| {
740                 let pending = match out {
741                     Ok(o) => parse_pending(&String::from_utf8_lossy(&o.stdout)),
742                     // A scan that could not run reports nothing pending, and
743                     // `scanned` still flips — the section then says "up to
744                     // date", which is wrong but harmless, where a spinner that
745                     // never resolves would be a hang. The install button is the
746                     // real check: it re-plans and would find the work.
747                     Err(_) => Vec::new(),
748                 };
749                 SystemMessage::SystemFilesScanned(pending)
750             });
751         }
752         SystemMessage::SystemFilesScanned(pending) => {
753             state.sysfiles.pending = pending;
754             state.sysfiles.scanned = true;
755             state.sysfiles.busy = false;
756         }
757         SystemMessage::InstallSystemFiles => {
758             state.sysfiles.busy = true;
759             state.sysfiles.result = None;
760             // --pkexec explicitly rather than letting the auto path decide:
761             // this process has no TTY, so auto would pick pkexec anyway, but
762             // saying so keeps the GUI's behaviour independent of how the
763             // script guesses.
764             spawn_sysfiles(state.sender.clone(), &["install-system", "--pkexec"], |out| {
765                 let res = match out {
766                     Ok(o) if o.status.success() => Ok(()),
767                     // A cancelled or failed polkit prompt exits non-zero with
768                     // its reason on stderr; surface that rather than a code.
769                     Ok(o) => {
770                         let err = String::from_utf8_lossy(&o.stderr).trim().to_string();
771                         Err(if err.is_empty() { format!("exited {}", o.status) } else { err })
772                     }
773                     Err(e) => Err(e.to_string()),
774                 };
775                 SystemMessage::SystemFilesInstalled(res)
776             });
777         }
778         SystemMessage::SystemFilesInstalled(res) => {
779             let ok = res.is_ok();
780             state.sysfiles.result = Some(res);
781             state.sysfiles.busy = false;
782             if ok {
783                 // Re-scan rather than assuming the list is now empty: the
784                 // apply re-plans, so what it actually did is only knowable by
785                 // asking again.
786                 state.sysfiles.scanned = false;
787                 update(state, SystemMessage::ScanSystemFiles, ctx);
788             }
789         }
790     }
791 }
792 
793 
794 
795 impl crate::pages::AppPage for SystemState {
796     // Sections: [System, System Actions, CPU, GPU, System Files, Builds]
797     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
798         vec![
799             Vec::new(),
800             Vec::new(),
801             Vec::new(),
802             Vec::new(),
803             Vec::new(),
804             Vec::new(),
805         ]
806     }
807 
808     fn view(
809         &mut self,
810         cx: f32,
811         cy: f32,
812         cw: f32,
813         ch: f32,
814         root_focused: bool,
815         sec_focused: &[bool],
816         layout: &mut PageFlow,
817         ctx: &mut cce_ui::context::UiContext,
818     ) -> crate::app::PageContent {
819         // Phase 6u: System used to render through the WIDGET TREE (the only page that
820         // did) — its content reached the frame via the root aggregate walking
821         // Switcher → Page(AdaptiveGridLayout) → sections → widgets. With that chain
822         // dissolved, the page renders through the same immediate-mode view as every
823         // other page (this free `view` predates the flip; it was never wired up).
824         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
825     }
826 
827     fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, _ctx: &mut UiContext) {
828     }
829 }
830 
831 
832 
833 
834 
835 #[cfg(test)]
836 mod tests {
837     use super::*;
838 
839     /// The dry run prints pending changes, banners and the queued root
840     /// commands down one stream; only the indented `name -> dest` lines are
841     /// changes. Getting this wrong in the lenient direction would list
842     /// `install -m 644 ... -> ...` as a pending file, and in the strict
843     /// direction would hide a pending PAM stack — which is the one thing the
844     /// section exists to show before a root install is authorized.
845     #[test]
846     fn parse_pending_takes_only_the_change_lines() {
847         let out = "  cce-display-manager -> /usr/bin/cce-display-manager\n                   \x20 cce-lock -> /etc/pam.d/cce-lock\n                   ==> dry run; would run as root:\n                   \n                   cp -a /usr/bin/cce-display-manager /usr/bin/cce-display-manager.bak-2026-09-19 \n                   install -m 644 /src/cce-lock /etc/pam.d/cce-lock \n";
848         assert_eq!(
849             parse_pending(out),
850             vec![
851                 "cce-display-manager -> /usr/bin/cce-display-manager".to_string(),
852                 "cce-lock -> /etc/pam.d/cce-lock".to_string(),
853             ]
854         );
855     }
856 
857     #[test]
858     fn parse_pending_is_empty_when_nothing_differs() {
859         assert!(parse_pending("==> system artifacts already up to date\n").is_empty());
860     }
861 
862     /// The record is ccebuild's TSV; a malformed line (a hand edit, a
863     /// half-written file) drops out rather than failing the whole section.
864     #[test]
865     fn parse_build_times_reads_ccebuild_lines() {
866         let t = parse_build_times("cce-mail\t83.27\t1791251542\ncce-ui\t4.1\t1791251542\nbroken line\ncce-x\tnan?\t1\n");
867         assert_eq!(t.len(), 2);
868         assert_eq!(t.get("cce-mail"), Some(&83.27));
869         assert_eq!(t.get("cce-ui"), Some(&4.1));
870     }
871 
872     #[test]
873     fn format_build_duration_keeps_tenths_under_a_minute() {
874         assert_eq!(format_build_duration(8.44), "8.4s");
875         assert_eq!(format_build_duration(92.0), "1min 32s");
876         assert_eq!(format_build_duration(59.96), "1min 00s");
877     }
878 
879     #[test]
880     fn test_view_layout_grid() {
881         let mut ui = cce_ui::context::UiContext::new();
882         let mut state = SystemState::new(&mut ui);
883         let mut layout = cce_ui::layout::PageFlow::new();
884         let sec_focused = vec![false, false, false, false, false, false, false];
885         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ui);
886         assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
887     }
888 }
889 
890 #[cfg(test)]
891 mod uptime_tests {
892     use super::pretty_uptime;
893 
894     #[test]
895     fn reads_like_uptime_p() {
896         assert_eq!(pretty_uptime(30), "0 minutes");
897         assert_eq!(pretty_uptime(60), "1 minute");
898         assert_eq!(pretty_uptime(86400 + 8 * 3600 + 13 * 60 + 5), "1 day, 8 hours, 13 minutes");
899         assert_eq!(pretty_uptime(15 * 86400), "2 weeks, 1 day");
900     }
901 }