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 }