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

src/pages/system_info.rs (30.1K)

  1 use crate::app::{AppAction, PageContent, SectionContextExt};
  2 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy};
  3 use cce_ui::widget::{Label, WidgetHost, Button};
  4 
  5 
  6 #[derive(Debug, Clone)]
  7 pub struct NotificationsConfig {
  8     pub enable: bool,
  9     pub bell: String,
 10     pub duration: i32,
 11 }
 12 
 13 /// The root-owned half of the desktop's install: `/usr/bin` binaries, system
 14 /// units and `/etc/pam.d` stacks. `ccebuild install` never touches these —
 15 /// they need root — so they drift silently, and have: the greeter fix for the
 16 /// suspend/resume hang sat built but undeployed for weeks because deploying it
 17 /// meant remembering to run one command in a terminal.
 18 ///
 19 /// The plan is read with `install-system --dry-run`, which compares as the
 20 /// normal user (every target is world-readable) and prints one `name -> dest`
 21 /// line per pending change. Applying re-plans and runs the whole batch under a
 22 /// single pkexec, authenticated by the session's polkit agent
 23 /// (`cce-authenticator`) like every other privileged action in this desktop.
 24 #[derive(Clone, Default)]
 25 pub struct SysFiles {
 26     /// Whether a scan has completed; until then the section says so rather
 27     /// than claiming everything is up to date.
 28     pub scanned: bool,
 29     /// One `name -> dest` line per pending change. Empty after a scan means
 30     /// the system artifacts match the build.
 31     pub pending: Vec<String>,
 32     /// A scan or an install is in flight. Also gates the button, so a second
 33     /// click cannot start a second pkexec.
 34     pub busy: bool,
 35     /// Outcome of the last install attempt, shown until the next one.
 36     pub result: Option<Result<(), String>>,
 37 }
 38 
 39 #[derive(Clone)]
 40 pub struct SystemState {
 41     pub hostname: String,
 42     pub kernel: String,
 43     pub uptime: String,
 44     pub loaded: bool,
 45 
 46     // Hardware status
 47     pub cpu_model: String,
 48     pub cpu_usage: f32,
 49     pub cpu_cores: u32,
 50     pub gpus: Vec<String>,
 51     pub gpu_strings: Vec<String>,
 52     pub cpu_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
 53     pub cpu_usage_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
 54     pub cpu_temp_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
 55 
 56     // Power-related fields
 57 
 58     // Native layout tracking and widgets
 59     pub initialized: bool,
 60     pub sender: Option<calloop::channel::Sender<AppAction>>,
 61     pub sysfiles: SysFiles,
 62     pub hostname_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
 63     pub uptime_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
 64 
 65 
 66 
 67     pub suspend_btn: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 68     pub hibernate_btn: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 69     pub reboot_btn: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 70     pub poweroff_btn: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 71     pub force_shutdown_btn: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 72 
 73 
 74 }
 75 
 76 impl std::fmt::Debug for SystemState {
 77     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 78         f.debug_struct("SystemState")
 79             .field("hostname", &self.hostname)
 80             .field("kernel", &self.kernel)
 81             .field("uptime", &self.uptime)
 82             .field("loaded", &self.loaded)
 83             .finish()
 84     }
 85 }
 86 
 87 impl Default for SystemState {
 88     fn default() -> Self {
 89         Self {
 90             hostname: String::new(),
 91             kernel: String::new(),
 92             uptime: String::new(),
 93             loaded: false,
 94 
 95             cpu_model: String::new(),
 96             cpu_usage: 0.0,
 97             cpu_cores: 0,
 98             gpus: Vec::new(),
 99             gpu_strings: Vec::new(),
100             cpu_label: Label::new("CPU Info"),
101             cpu_usage_label: Label::new("CPU Usage"),
102             cpu_temp_label: Label::new("CPU Temp"),
103 
104 
105             initialized: false,
106             sender: None,
107             sysfiles: SysFiles::default(),
108             hostname_label: Label::new(""),
109             uptime_label: Label::new(""),
110 
111 
112 
113             suspend_btn: Button::new(0.0, 0.0, 0.0, 32.0)
114                 .with_label("Suspend")
115                 .with_bg([0.20, 0.33, 0.22, 1.0])
116                 .with_hover_bg([0.25, 0.30, 0.26, 1.0])
117                 .with_label_color([1.0, 1.0, 1.0, 1.0]),
118             hibernate_btn: Button::new(0.0, 0.0, 0.0, 32.0)
119                 .with_label("Hibernate")
120                 .with_bg([0.20, 0.33, 0.22, 1.0])
121                 .with_hover_bg([0.25, 0.30, 0.26, 1.0])
122                 .with_label_color([1.0, 1.0, 1.0, 1.0]),
123             reboot_btn: Button::new(0.0, 0.0, 0.0, 32.0)
124                 .with_label("Reboot")
125                 .with_bg([0.67, 0.20, 0.20, 1.0])
126                 .with_hover_bg([0.25, 0.30, 0.26, 1.0])
127                 .with_label_color([1.0, 1.0, 1.0, 1.0]),
128             poweroff_btn: Button::new(0.0, 0.0, 0.0, 32.0)
129                 .with_label("Power Off")
130                 .with_bg([0.67, 0.20, 0.20, 1.0])
131                 .with_hover_bg([0.25, 0.30, 0.26, 1.0])
132                 .with_label_color([1.0, 1.0, 1.0, 1.0]),
133             force_shutdown_btn: Button::new(0.0, 0.0, 0.0, 32.0)
134                 .with_label("Force Shutdown")
135                 .with_bg([0.67, 0.20, 0.20, 1.0])
136                 .with_hover_bg([0.25, 0.30, 0.26, 1.0])
137                 .with_label_color([1.0, 1.0, 1.0, 1.0]),
138         }
139     }
140 }
141 
142 #[derive(Debug, Clone)]
143 pub enum SystemMessage {
144     Refreshed(SystemInfo),
145     Suspend,
146     Hibernate,
147     Reboot,
148     PowerOff,
149     ForceShutdown,
150 
151     /// Read the pending root-owned changes (`install-system --dry-run`).
152     ScanSystemFiles,
153     SystemFilesScanned(Vec<String>),
154     /// Apply them, authenticating through the polkit agent.
155     InstallSystemFiles,
156     SystemFilesInstalled(Result<(), String>),
157 }
158 
159 // ── Helpers ─────────────────────────────────────────────────────────
160 
161 
162 
163 fn read_cpu_temp() -> Option<f32> {
164     if let Ok(entries) = std::fs::read_dir("/sys/class/hwmon") {
165         for entry in entries.filter_map(|e| e.ok()) {
166             let path = entry.path();
167             if let Ok(name) = std::fs::read_to_string(path.join("name")) {
168                 let name = name.trim();
169                 if name == "thinkpad" {
170                     if let Ok(val) = std::fs::read_to_string(path.join("temp1_input")) {
171                         if let Ok(temp_milli) = val.trim().parse::<f32>() {
172                             return Some(temp_milli / 1000.0);
173                         }
174                     }
175                 } else if name == "coretemp" {
176                     if let Ok(val) = std::fs::read_to_string(path.join("temp1_input")) {
177                         if let Ok(temp_milli) = val.trim().parse::<f32>() {
178                             return Some(temp_milli / 1000.0);
179                         }
180                     }
181                 }
182             }
183         }
184     }
185     None
186 }
187 
188 fn read_thinkpad_gpu_temp() -> Option<f32> {
189     if let Ok(entries) = std::fs::read_dir("/sys/class/hwmon") {
190         for entry in entries.filter_map(|e| e.ok()) {
191             let path = entry.path();
192             if let Ok(name) = std::fs::read_to_string(path.join("name")) {
193                 if name.trim() == "thinkpad" {
194                     for i in 1..=8 {
195                         let label_path = path.join(format!("temp{}_label", i));
196                         if let Ok(lbl) = std::fs::read_to_string(&label_path) {
197                             if lbl.trim() == "GPU" {
198                                 if let Ok(val) = std::fs::read_to_string(path.join(format!("temp{}_input", i))) {
199                                     if let Ok(temp_milli) = val.trim().parse::<f32>() {
200                                         return Some(temp_milli / 1000.0);
201                                     }
202                                 }
203                             }
204                         }
205                     }
206                 }
207             }
208         }
209     }
210     None
211 }
212 
213 async fn read_nvidia_gpu_temp() -> Option<f32> {
214     let out = tokio::process::Command::new("nvidia-smi")
215         .args(["--query-gpu=temperature.gpu", "--format=csv,noheader,nounits"])
216         .output().await.ok()?;
217     let val_str = String::from_utf8_lossy(&out.stdout);
218     val_str.trim().parse::<f32>().ok()
219 }
220 
221 /// The installed `ccebuild`, by absolute path. An app launched from the menu
222 /// gets systemd's environment rather than the session's, so `~/.local/bin` is
223 /// not reliably on PATH — the same trap the desktop-menu script avoids by
224 /// spelling out `$HOME/.local/bin/ccectl`. Falls back to the bare name so a
225 /// PATH that does have it still works.
226 fn ccebuild_path() -> std::path::PathBuf {
227     if let Ok(home) = std::env::var("HOME") {
228         let p = std::path::Path::new(&home).join(".local/bin/ccebuild");
229         if p.is_file() {
230             return p;
231         }
232     }
233     std::path::PathBuf::from("ccebuild")
234 }
235 
236 /// The `  name -> dest` lines of a dry run: one per root-owned file that
237 /// differs from the build. Anything else the script prints (the `==>` banners,
238 /// the queued root commands) is not a pending change and is dropped.
239 fn parse_pending(stdout: &str) -> Vec<String> {
240     stdout
241         .lines()
242         .filter(|l| l.starts_with("  ") && l.contains(" -> "))
243         .map(|l| l.trim().to_string())
244         .collect()
245 }
246 
247 /// Run one `ccebuild install-system` variant off the UI thread and post the
248 /// result back through the page's sender. Both calls shell out rather than
249 /// reimplementing the compare, so the plan has exactly one author — the same
250 /// reason the apply re-plans instead of trusting what the scan printed.
251 fn spawn_sysfiles<F>(sender: Option<calloop::channel::Sender<AppAction>>, args: &'static [&'static str], done: F)
252 where
253     F: FnOnce(std::io::Result<std::process::Output>) -> SystemMessage + Send + 'static,
254 {
255     let Some(tx) = sender else { return };
256     std::thread::spawn(move || {
257         let out = std::process::Command::new(ccebuild_path()).args(args).output();
258         let _ = tx.send(AppAction::SystemInfo(done(out)));
259     });
260 }
261 
262 /// Width a label needs on a button plate: the measured text plus the plate's
263 /// own 8px inset each side, and a little air. Feeds `add_row_for`, so a row of
264 /// buttons is divided by what is written on them rather than into equal
265 /// slices — "Reboot" and "Hibernate" are not the same size and a row that
266 /// pretends otherwise clips one and pads the other.
267 fn button_need(label: &str) -> f32 {
268     let (family, size) = cce_ui::layout::control_label_font_parsed();
269     cce_ui::widget::display::measure_text_width(label, &family, size) + 2.0 * cce_ui::layout::CONTROL_TEXT_INSET + 8.0
270 }
271 
272 fn spawn_systemctl(action: &str) {
273     let mut cmd = std::process::Command::new("systemctl");
274     cmd.arg(action);
275     let _ = cce_ui::process::spawn_detached(cmd);
276 }
277 
278 fn spawn_systemctl_force(action: &str) {
279     let mut cmd = std::process::Command::new("systemctl");
280     cmd.arg(action);
281     cmd.arg("-f");
282     cmd.arg("-f");
283     let _ = cce_ui::process::spawn_detached(cmd);
284 }
285 
286 #[derive(Debug, Clone, Default)]
287 pub struct SystemInfo {
288     pub hostname: String,
289     pub kernel: String,
290     pub uptime: String,
291     pub cpu_model: String,
292     pub cpu_cores: u32,
293     pub cpu_usage: f32,
294     pub gpus: Vec<String>,
295     pub gpu_strings: Vec<String>,
296 }
297 
298 pub async fn fetch_system_state() -> SystemInfo {
299     let hostname = tokio::process::Command::new("hostname")
300         .output().await.ok()
301         .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().split('.').next().map(|s| s.to_string()))
302         .unwrap_or_default();
303 
304     let kernel = tokio::process::Command::new("uname")
305         .args(["-r"]).output().await.ok()
306         .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
307         .unwrap_or_default();
308 
309     let uptime = tokio::process::Command::new("uptime")
310         .args(["-p"]).output().await.ok()
311         .map(|o| String::from_utf8_lossy(&o.stdout).trim().trim_start_matches("up ").to_string())
312         .unwrap_or_default();
313 
314     static CPU_INFO: std::sync::OnceLock<(String, u32)> = std::sync::OnceLock::new();
315     let (cpu_model, cpu_cores) = CPU_INFO.get_or_init(|| {
316         let output = std::process::Command::new("lscpu")
317             .output().ok()
318             .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
319             .unwrap_or_default();
320         let model = output.lines()
321             .find(|l| l.contains("Model name"))
322             .and_then(|l| l.split(':').nth(1))
323             .map(|s| s.trim().to_string())
324             .unwrap_or_default();
325         let cores = output.lines()
326             .find(|l| l.contains("CPU(s)"))
327             .and_then(|l| {
328                 let rest = l.split(':').nth(1).unwrap_or("").trim();
329                 rest.split_whitespace().next().and_then(|n| n.parse::<u32>().ok())
330             })
331             .unwrap_or(0);
332         (model, cores)
333     }).clone();
334 
335     let cpu_usage = {
336         let read_stat = || -> Option<(u64, u64)> {
337             let stat = std::fs::read_to_string("/proc/stat").ok()?;
338             let first = stat.lines().next()?;
339             let vals: Vec<u64> = first.split_whitespace().skip(1).filter_map(|v| v.parse().ok()).collect();
340             if vals.len() < 3 { return None; }
341             let total: u64 = vals.iter().sum();
342             let idle = vals.get(3).copied().unwrap_or(0);
343             Some((idle, total))
344         };
345         let (idle1, total1) = read_stat().unwrap_or((0, 1));
346         tokio::time::sleep(std::time::Duration::from_millis(100)).await;
347         let (idle2, total2) = read_stat().unwrap_or((0, 1));
348         let d_idle = idle2.saturating_sub(idle1);
349         let d_total = total2.saturating_sub(total1);
350         if d_total > 0 {
351             (1.0 - d_idle as f64 / d_total as f64) * 100.0
352         } else { 0.0 }
353     } as f32;
354 
355     static GPUS_INFO: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
356     let gpus = GPUS_INFO.get_or_init(|| {
357         let mut list = Vec::new();
358         if let Some(o) = std::process::Command::new("lspci").output().ok() {
359             for line in String::from_utf8_lossy(&o.stdout).lines() {
360                 if line.contains("VGA") || line.contains("3D") {
361                     if let Some(name) = line.split(':').nth(2) {
362                         let trimmed = name.trim().to_string();
363                         if !trimmed.is_empty() {
364                             list.push(trimmed);
365                         }
366                     }
367                 }
368             }
369         }
370         list
371     }).clone();
372 
373 
374     let tp_gpu_temp = read_thinkpad_gpu_temp();
375     let nv_gpu_temp = read_nvidia_gpu_temp().await;
376 
377     let gpu_strings = gpus.iter().map(|gpu_name| {
378         let temp = if gpu_name.to_lowercase().contains("nvidia") {
379             nv_gpu_temp.or(tp_gpu_temp)
380         } else {
381             tp_gpu_temp
382         };
383         let temp_str = temp.map(|t| format!("  —  {:.0}°C", t)).unwrap_or_default();
384         format!("GPU  {}{}", gpu_name, temp_str)
385     }).collect();
386 
387 
388     SystemInfo {
389         hostname,
390         kernel,
391         uptime,
392         cpu_model,
393         cpu_usage,
394         cpu_cores,
395         gpus,
396         gpu_strings,
397     }
398 }
399 
400 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
401 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
402 const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
403 const DANGER_BG: [f32; 4] = [0.67, 0.20, 0.20, 1.0];
404 const SAFE_BG: [f32; 4] = [0.20, 0.33, 0.22, 1.0];
405 const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
406 
407 pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut dyn LayoutStrategy, _ctx: &mut cce_ui::context::UiContext) -> PageContent {
408     let mut final_pc = PageContent::new();
409     let sec_w = 320.0f32;
410     // Seven, matching the add_section calls below and the seven groups
411     // section_widgets reports. The count caps the grid's column count
412     // (`n.min(cols)`), so the stale 8 only bit once the window was wide enough
413     // for eight columns — harmless, but it read as a missing eighth section.
414     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(6);
415 
416     // ── 1. System Section ──
417     builder.add_section(&mut final_pc, "System", sec_focused.first().copied().unwrap_or(false), |sec| {
418         if !state.loaded {
419             sec.text("Loading system information...", 12.0, 0.0, 14.0, TEXT_FG);
420         } else {
421             sec.text(&format!("{}  —  Linux {}", state.hostname, state.kernel), 12.0, 0.0, 14.0, TEXT_FG);
422             sec.text(&format!("Uptime: {}", state.uptime), 12.0, 0.0, 12.0, TEXT_DIM);
423         }
424     });
425 
426     // ── 2. System Actions Section ──
427     builder.add_section(&mut final_pc, "System Actions", sec_focused.get(1).copied().unwrap_or(false), |sec| {
428         let mut stack = sec.vstack(cce_ui::layout::plate_gap());
429         let act_btn_h = 32.0;
430 
431         const ACTIONS: [&str; 4] = ["Suspend", "Hibernate", "Reboot", "Power Off"];
432         let needs: Vec<f32> = ACTIONS.iter().map(|l| button_need(l)).collect();
433         stack.add_row_for(&needs, cce_ui::layout::plate_gap(), act_btn_h, |ctx, i, x, w| {
434             match i {
435                 0 => {
436                     ctx.button("Suspend", x, ctx.ay(), w, act_btn_h,
437                         SAFE_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::Suspend));
438                 }
439                 1 => {
440                     ctx.button("Hibernate", x, ctx.ay(), w, act_btn_h,
441                         SAFE_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::Hibernate));
442                 }
443                 2 => {
444                     ctx.button("Reboot", x, ctx.ay(), w, act_btn_h,
445                         DANGER_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::Reboot));
446                 }
447                 3 => {
448                     ctx.button("Power Off", x, ctx.ay(), w, act_btn_h,
449                         DANGER_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::PowerOff));
450                 }
451                 _ => {}
452             }
453         });
454 
455         stack.add_row(1, 0.0, act_btn_h, |ctx, _, x, w| {
456             ctx.button("Force Shutdown", x, ctx.ay(), w, act_btn_h,
457                 DANGER_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::ForceShutdown));
458         });
459     });
460 
461     // ── 3. CPU Section ──
462     builder.add_section(&mut final_pc, "CPU", sec_focused.get(2).copied().unwrap_or(false), |sec| {
463         if !state.loaded {
464             sec.text("Loading CPU model and utilization...", 12.0, 0.0, 12.0, TEXT_FG);
465         } else {
466             // Same strings the old Label widgets carried, stacked vertically (the
467             // grid-column widget placement overlapped them at narrow widths).
468             sec.text(&format!("CPU  {}  ({} cores)", state.cpu_model, state.cpu_cores), 12.0, 0.0, 12.0, TEXT_FG);
469             sec.text(&format!("Usage  {:.0}%", state.cpu_usage), 12.0, 0.0, 12.0, TEXT_FG);
470             let cpu_temp_text = read_cpu_temp().map(|t| format!("Temp  {:.0}°C", t)).unwrap_or_else(|| "Temp  N/A".to_string());
471             sec.text(&cpu_temp_text, 12.0, 0.0, 12.0, TEXT_FG);
472         }
473     });
474 
475     // ── 4. GPU Section ──
476     builder.add_section(&mut final_pc, "GPU", sec_focused.get(3).copied().unwrap_or(false), |sec_gpu| {
477         if !state.loaded {
478             sec_gpu.text("Loading GPU models...", 12.0, 0.0, 12.0, TEXT_FG);
479         } else {
480             for gpu_text in state.gpu_strings.iter() {
481                 sec_gpu.text(gpu_text, 12.0, 0.0, 12.0, TEXT_FG);
482             }
483         }
484     });
485 
486     // ── 5. System Files Section ──
487     // The root-owned half of the install, which `ccebuild install` cannot
488     // touch. It lives here rather than on the desktop menu so the pending
489     // changes can be READ before they are authorized: this batch can rewrite
490     // /etc/pam.d, and a flat menu button would be one misclick from replacing
491     // the greeter out of a half-built tree.
492     builder.add_section(&mut final_pc, "System Files", sec_focused.get(4).copied().unwrap_or(false), |sec| {
493         let sf = &state.sysfiles;
494         let mut stack = sec.vstack(6.0);
495 
496         let (status, color) = if sf.busy {
497             ("Checking…".to_string(), TEXT_DIM)
498         } else if !sf.scanned {
499             ("Not checked yet".to_string(), TEXT_DIM)
500         } else if sf.pending.is_empty() {
501             ("Up to date with the build".to_string(), TEXT_DIM)
502         } else {
503             (
504                 format!(
505                     "{} file{} differ{} from the build",
506                     sf.pending.len(),
507                     if sf.pending.len() == 1 { "" } else { "s" },
508                     if sf.pending.len() == 1 { "s" } else { "" },
509                 ),
510                 TEXT_FG,
511             )
512         };
513         stack.add_row(1, 0.0, 16.0, |c, _, x, _| {
514             let y = c.ay();
515             c.pc.text(&status, x, y + 4.0, 12.0, color);
516         });
517 
518         // Name the files. "2 files differ" is not enough to authorize a root
519         // install on — which one is the greeter matters.
520         for line in sf.pending.iter().take(8) {
521             let line = line.clone();
522             stack.add_row(1, 0.0, 14.0, move |c, _, x, _| {
523                 let y = c.ay();
524                 c.pc.text(&line, x + 8.0, y + 3.0, 11.0, TEXT_DIM);
525             });
526         }
527 
528         if let Some(ref res) = sf.result {
529             let (msg, col) = match res {
530                 Ok(()) => ("Installed — takes effect at next login".to_string(), TEXT_DIM),
531                 Err(e) => (format!("Failed: {}", e), DANGER_BG),
532             };
533             stack.add_row(1, 0.0, 16.0, move |c, _, x, _| {
534                 let y = c.ay();
535                 c.pc.text(&msg, x, y + 4.0, 11.0, col);
536             });
537         }
538 
539         let btn_h = 32.0;
540         let has_work = sf.scanned && !sf.pending.is_empty();
541         let busy = sf.busy;
542         let needs = [button_need("Check"), button_need("Install (root)")];
543         stack.add_row_for(&needs, cce_ui::layout::plate_gap(), btn_h, move |c, i, x, w| {
544             match i {
545                 0 => {
546                     c.button("Check", x, c.ay(), w, btn_h,
547                         SAFE_BG, BTN_HOVER, WHITE,
548                         AppAction::SystemInfo(SystemMessage::ScanSystemFiles));
549                 }
550                 1 => {
551                     // Only offered when there is something to install. The
552                     // apply re-plans anyway, so a stale-enabled button would
553                     // be a no-op rather than a hazard — but it would also put
554                     // up a root prompt for nothing.
555                     if has_work && !busy {
556                         c.button("Install (root)", x, c.ay(), w, btn_h,
557                             DANGER_BG, BTN_HOVER, WHITE,
558                             AppAction::SystemInfo(SystemMessage::InstallSystemFiles));
559                     }
560                 }
561                 _ => {}
562             }
563         });
564     });
565 
566     // ── 6. GPU Power Section ──
567 
568 
569 
570 
571     final_pc
572 }
573 
574 pub fn update(state: &mut SystemState, msg: SystemMessage, ctx: &mut cce_ui::context::UiContext) {
575     match msg {
576         SystemMessage::Refreshed(new) => {
577             state.hostname = new.hostname;
578             state.kernel = new.kernel;
579             state.uptime = new.uptime;
580             state.loaded = true;
581 
582             state.cpu_model = new.cpu_model;
583             state.cpu_usage = new.cpu_usage;
584             state.cpu_cores = new.cpu_cores;
585             state.gpus = new.gpus;
586             state.gpu_strings = new.gpu_strings.clone();
587 
588             if state.loaded {
589                 state.hostname_label.set_text(&format!("{}  —  Linux {}", state.hostname, state.kernel));
590                 state.uptime_label.set_text(&format!("Uptime: {}", state.uptime));
591 
592                 let cpu_label_text = format!("CPU  {}  ({} cores)", state.cpu_model, state.cpu_cores);
593                 let cpu_usage_text = format!("Usage  {:.0}%", state.cpu_usage);
594                 let cpu_temp_text = read_cpu_temp().map(|t| format!("Temp  {:.0}°C", t)).unwrap_or_else(|| "Temp  N/A".to_string());
595 
596                 state.cpu_label.set_text(&cpu_label_text);
597                 state.cpu_usage_label.set_text(&cpu_usage_text);
598                 state.cpu_temp_label.set_text(&cpu_temp_text);
599 
600 
601 
602                 state.hostname_label.mark_dirty(ctx);
603             }
604 
605             // First refresh doubles as the first system-files scan, so the
606             // section has an answer without the user pressing Check. Guarded
607             // on both flags because Refreshed repeats on a timer and each scan
608             // is a process spawn.
609             if !state.sysfiles.scanned && !state.sysfiles.busy {
610                 update(state, SystemMessage::ScanSystemFiles, ctx);
611             }
612         }
613         SystemMessage::Suspend => spawn_systemctl("suspend"),
614         SystemMessage::Hibernate => spawn_systemctl("hibernate"),
615         SystemMessage::Reboot => spawn_systemctl("reboot"),
616         SystemMessage::PowerOff => spawn_systemctl("poweroff"),
617         SystemMessage::ForceShutdown => spawn_systemctl_force("poweroff"),
618 
619         SystemMessage::ScanSystemFiles => {
620             state.sysfiles.busy = true;
621             spawn_sysfiles(state.sender.clone(), &["install-system", "--dry-run"], |out| {
622                 let pending = match out {
623                     Ok(o) => parse_pending(&String::from_utf8_lossy(&o.stdout)),
624                     // A scan that could not run reports nothing pending, and
625                     // `scanned` still flips — the section then says "up to
626                     // date", which is wrong but harmless, where a spinner that
627                     // never resolves would be a hang. The install button is the
628                     // real check: it re-plans and would find the work.
629                     Err(_) => Vec::new(),
630                 };
631                 SystemMessage::SystemFilesScanned(pending)
632             });
633         }
634         SystemMessage::SystemFilesScanned(pending) => {
635             state.sysfiles.pending = pending;
636             state.sysfiles.scanned = true;
637             state.sysfiles.busy = false;
638         }
639         SystemMessage::InstallSystemFiles => {
640             state.sysfiles.busy = true;
641             state.sysfiles.result = None;
642             // --pkexec explicitly rather than letting the auto path decide:
643             // this process has no TTY, so auto would pick pkexec anyway, but
644             // saying so keeps the GUI's behaviour independent of how the
645             // script guesses.
646             spawn_sysfiles(state.sender.clone(), &["install-system", "--pkexec"], |out| {
647                 let res = match out {
648                     Ok(o) if o.status.success() => Ok(()),
649                     // A cancelled or failed polkit prompt exits non-zero with
650                     // its reason on stderr; surface that rather than a code.
651                     Ok(o) => {
652                         let err = String::from_utf8_lossy(&o.stderr).trim().to_string();
653                         Err(if err.is_empty() { format!("exited {}", o.status) } else { err })
654                     }
655                     Err(e) => Err(e.to_string()),
656                 };
657                 SystemMessage::SystemFilesInstalled(res)
658             });
659         }
660         SystemMessage::SystemFilesInstalled(res) => {
661             let ok = res.is_ok();
662             state.sysfiles.result = Some(res);
663             state.sysfiles.busy = false;
664             if ok {
665                 // Re-scan rather than assuming the list is now empty: the
666                 // apply re-plans, so what it actually did is only knowable by
667                 // asking again.
668                 state.sysfiles.scanned = false;
669                 update(state, SystemMessage::ScanSystemFiles, ctx);
670             }
671         }
672     }
673 }
674 
675 
676 
677 impl crate::pages::AppPage for SystemState {
678     // Sections: [System, System Actions, CPU, GPU, System Files, Battery]
679     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
680         vec![
681             Vec::new(),
682             Vec::new(),
683             Vec::new(),
684             Vec::new(),
685             Vec::new(),
686             Vec::new(),
687         ]
688     }
689 
690     fn view(
691         &mut self,
692         cx: f32,
693         cy: f32,
694         cw: f32,
695         ch: f32,
696         root_focused: bool,
697         sec_focused: &[bool],
698         layout: &mut dyn LayoutStrategy,
699         ctx: &mut cce_ui::context::UiContext,
700     ) -> crate::app::PageContent {
701         // Phase 6u: System used to render through the WIDGET TREE (the only page that
702         // did) — its content reached the frame via the root aggregate walking
703         // Switcher → Page(AdaptiveGridLayout) → sections → widgets. With that chain
704         // dissolved, the page renders through the same immediate-mode view as every
705         // other page (this free `view` predates the flip; it was never wired up).
706         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
707     }
708 
709     fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {
710     }
711 }
712 
713 
714 
715 
716 
717 #[cfg(test)]
718 mod tests {
719     use super::*;
720 
721     /// The dry run prints pending changes, banners and the queued root
722     /// commands down one stream; only the indented `name -> dest` lines are
723     /// changes. Getting this wrong in the lenient direction would list
724     /// `install -m 644 ... -> ...` as a pending file, and in the strict
725     /// direction would hide a pending PAM stack — which is the one thing the
726     /// section exists to show before a root install is authorized.
727     #[test]
728     fn parse_pending_takes_only_the_change_lines() {
729         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";
730         assert_eq!(
731             parse_pending(out),
732             vec![
733                 "cce-display-manager -> /usr/bin/cce-display-manager".to_string(),
734                 "cce-lock -> /etc/pam.d/cce-lock".to_string(),
735             ]
736         );
737     }
738 
739     #[test]
740     fn parse_pending_is_empty_when_nothing_differs() {
741         assert!(parse_pending("==> system artifacts already up to date\n").is_empty());
742     }
743 
744     #[test]
745     fn test_view_layout_grid() {
746         let mut state = SystemState::default();
747         let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
748         let sec_focused = vec![false, false, false, false, false, false, false];
749         let mut ctx = cce_ui::context::UiContext::new();
750         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
751         assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
752     }
753 }