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 }