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

commita9478683828f7233b31a7cc9d4412b4197c6a79b
parent59a5d95597
authorLucas Galante <[email protected]>
date2026-09-19 00:36
System page: install the root-owned artifacts, without a terminal

`ccebuild install` cannot touch /usr/bin, the system units or
/etc/pam.d — those need root — so they drift silently, and have. The
greeter fix for the suspend/resume hang sat built but undeployed for
weeks, because deploying it meant remembering to run one command in a
terminal that the desktop never mentions.

Give the System page a System Files section that scans on load and says
what differs, then installs it under one polkit prompt. Both halves shell
out to `ccebuild install-system` — --dry-run to read the plan, --pkexec
to apply it — rather than reimplementing the compare, so the plan has
exactly one author. The apply re-plans rather than trusting what the scan
printed, and a success re-scans rather than assuming the list is now
empty: what it actually did is only knowable by asking again.

The section NAMES the pending files rather than counting them, which is
the reason this is a page and not a button on the desktop menu. This
batch can rewrite /etc/pam.d — the live tree has a new /etc/pam.d/cce-lock
pending right now — and "2 files differ" is not something to authorize a
root install on. A flat menu entry would also be one misclick from
replacing the greeter out of a half-built tree.

A scan that cannot run reports nothing pending and still flips `scanned`:
the section then reads "up to date", which is wrong but harmless, where a
spinner that never resolves would look like a hang. The Install button is
the real check — it re-plans, and would find the work.

ccebuild is spelled by absolute path. An app launched from the menu gets
systemd's environment rather than the session's, so ~/.local/bin is not
reliably on PATH — the same trap cce-desktop-menu avoids.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/pages/system_info.rs | 243 ++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 240 insertions(+), 3 deletions(-)

diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index 5c719aa..d276446 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -10,6 +10,32 @@ pub struct NotificationsConfig {
     pub duration: i32,
 }
 
+/// The root-owned half of the desktop's install: `/usr/bin` binaries, system
+/// units and `/etc/pam.d` stacks. `ccebuild install` never touches these —
+/// they need root — so they drift silently, and have: the greeter fix for the
+/// suspend/resume hang sat built but undeployed for weeks because deploying it
+/// meant remembering to run one command in a terminal.
+///
+/// The plan is read with `install-system --dry-run`, which compares as the
+/// normal user (every target is world-readable) and prints one `name -> dest`
+/// line per pending change. Applying re-plans and runs the whole batch under a
+/// single pkexec, authenticated by the session's polkit agent
+/// (`cce-authenticator`) like every other privileged action in this desktop.
+#[derive(Clone, Default)]
+pub struct SysFiles {
+    /// Whether a scan has completed; until then the section says so rather
+    /// than claiming everything is up to date.
+    pub scanned: bool,
+    /// One `name -> dest` line per pending change. Empty after a scan means
+    /// the system artifacts match the build.
+    pub pending: Vec<String>,
+    /// A scan or an install is in flight. Also gates the button, so a second
+    /// click cannot start a second pkexec.
+    pub busy: bool,
+    /// Outcome of the last install attempt, shown until the next one.
+    pub result: Option<Result<(), String>>,
+}
+
 #[derive(Clone)]
 pub struct SystemState {
     pub hostname: String,
@@ -32,6 +58,7 @@ pub struct SystemState {
     // Native layout tracking and widgets
     pub initialized: bool,
     pub sender: Option<calloop::channel::Sender<AppAction>>,
+    pub sysfiles: SysFiles,
     pub hostname_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
     pub uptime_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
 
@@ -77,6 +104,7 @@ impl Default for SystemState {
 
             initialized: false,
             sender: None,
+            sysfiles: SysFiles::default(),
             hostname_label: Label::new(""),
             uptime_label: Label::new(""),
 
@@ -120,6 +148,12 @@ pub enum SystemMessage {
     PowerOff,
     ForceShutdown,
 
+    /// Read the pending root-owned changes (`install-system --dry-run`).
+    ScanSystemFiles,
+    SystemFilesScanned(Vec<String>),
+    /// Apply them, authenticating through the polkit agent.
+    InstallSystemFiles,
+    SystemFilesInstalled(Result<(), String>),
 }
 
 // ── Helpers ─────────────────────────────────────────────────────────
@@ -184,6 +218,47 @@ async fn read_nvidia_gpu_temp() -> Option<f32> {
     val_str.trim().parse::<f32>().ok()
 }
 
+/// The installed `ccebuild`, by absolute path. An app launched from the menu
+/// gets systemd's environment rather than the session's, so `~/.local/bin` is
+/// not reliably on PATH — the same trap the desktop-menu script avoids by
+/// spelling out `$HOME/.local/bin/ccectl`. Falls back to the bare name so a
+/// PATH that does have it still works.
+fn ccebuild_path() -> std::path::PathBuf {
+    if let Ok(home) = std::env::var("HOME") {
+        let p = std::path::Path::new(&home).join(".local/bin/ccebuild");
+        if p.is_file() {
+            return p;
+        }
+    }
+    std::path::PathBuf::from("ccebuild")
+}
+
+/// The `  name -> dest` lines of a dry run: one per root-owned file that
+/// differs from the build. Anything else the script prints (the `==>` banners,
+/// the queued root commands) is not a pending change and is dropped.
+fn parse_pending(stdout: &str) -> Vec<String> {
+    stdout
+        .lines()
+        .filter(|l| l.starts_with("  ") && l.contains(" -> "))
+        .map(|l| l.trim().to_string())
+        .collect()
+}
+
+/// Run one `ccebuild install-system` variant off the UI thread and post the
+/// result back through the page's sender. Both calls shell out rather than
+/// reimplementing the compare, so the plan has exactly one author — the same
+/// reason the apply re-plans instead of trusting what the scan printed.
+fn spawn_sysfiles<F>(sender: Option<calloop::channel::Sender<AppAction>>, args: &'static [&'static str], done: F)
+where
+    F: FnOnce(std::io::Result<std::process::Output>) -> SystemMessage + Send + 'static,
+{
+    let Some(tx) = sender else { return };
+    std::thread::spawn(move || {
+        let out = std::process::Command::new(ccebuild_path()).args(args).output();
+        let _ = tx.send(AppAction::SystemInfo(done(out)));
+    });
+}
+
 fn spawn_systemctl(action: &str) {
     let mut cmd = std::process::Command::new("systemctl");
     cmd.arg(action);
@@ -326,7 +401,7 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
     // section_widgets reports. The count caps the grid's column count
     // (`n.min(cols)`), so the stale 8 only bit once the window was wide enough
     // for eight columns — harmless, but it read as a missing eighth section.
-    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(5);
+    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(6);
 
     // ── 1. System Section ──
     builder.add_section(&mut final_pc, "System", sec_focused.first().copied().unwrap_or(false), |sec| {
@@ -396,7 +471,84 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
         }
     });
 
-    // ── 5. CPU Governor Section ──
+    // ── 5. System Files Section ──
+    // The root-owned half of the install, which `ccebuild install` cannot
+    // touch. It lives here rather than on the desktop menu so the pending
+    // changes can be READ before they are authorized: this batch can rewrite
+    // /etc/pam.d, and a flat menu button would be one misclick from replacing
+    // the greeter out of a half-built tree.
+    builder.add_section(&mut final_pc, "System Files", sec_focused.get(4).copied().unwrap_or(false), |sec| {
+        let sf = &state.sysfiles;
+        let mut stack = sec.vstack(6.0);
+
+        let (status, color) = if sf.busy {
+            ("Checking…".to_string(), TEXT_DIM)
+        } else if !sf.scanned {
+            ("Not checked yet".to_string(), TEXT_DIM)
+        } else if sf.pending.is_empty() {
+            ("Up to date with the build".to_string(), TEXT_DIM)
+        } else {
+            (
+                format!(
+                    "{} file{} differ{} from the build",
+                    sf.pending.len(),
+                    if sf.pending.len() == 1 { "" } else { "s" },
+                    if sf.pending.len() == 1 { "s" } else { "" },
+                ),
+                TEXT_FG,
+            )
+        };
+        stack.add_row(1, 0.0, 16.0, |c, _, x, _| {
+            let y = c.ay();
+            c.pc.text(&status, x, y + 4.0, 12.0, color);
+        });
+
+        // Name the files. "2 files differ" is not enough to authorize a root
+        // install on — which one is the greeter matters.
+        for line in sf.pending.iter().take(8) {
+            let line = line.clone();
+            stack.add_row(1, 0.0, 14.0, move |c, _, x, _| {
+                let y = c.ay();
+                c.pc.text(&line, x + 8.0, y + 3.0, 11.0, TEXT_DIM);
+            });
+        }
+
+        if let Some(ref res) = sf.result {
+            let (msg, col) = match res {
+                Ok(()) => ("Installed — takes effect at next login".to_string(), TEXT_DIM),
+                Err(e) => (format!("Failed: {}", e), DANGER_BG),
+            };
+            stack.add_row(1, 0.0, 16.0, move |c, _, x, _| {
+                let y = c.ay();
+                c.pc.text(&msg, x, y + 4.0, 11.0, col);
+            });
+        }
+
+        let btn_h = 32.0;
+        let has_work = sf.scanned && !sf.pending.is_empty();
+        let busy = sf.busy;
+        stack.add_row(2, 8.0, btn_h, move |c, i, x, w| {
+            match i {
+                0 => {
+                    c.button("Check", x, c.ay(), w, btn_h,
+                        SAFE_BG, BTN_HOVER, WHITE,
+                        AppAction::SystemInfo(SystemMessage::ScanSystemFiles));
+                }
+                1 => {
+                    // Only offered when there is something to install. The
+                    // apply re-plans anyway, so a stale-enabled button would
+                    // be a no-op rather than a hazard — but it would also put
+                    // up a root prompt for nothing.
+                    if has_work && !busy {
+                        c.button("Install (root)", x, c.ay(), w, btn_h,
+                            DANGER_BG, BTN_HOVER, WHITE,
+                            AppAction::SystemInfo(SystemMessage::InstallSystemFiles));
+                    }
+                }
+                _ => {}
+            }
+        });
+    });
 
     // ── 6. GPU Power Section ──
 
@@ -436,6 +588,14 @@ pub fn update(state: &mut SystemState, msg: SystemMessage, ctx: &mut cce_ui::con
 
                 state.hostname_label.mark_dirty(ctx);
             }
+
+            // First refresh doubles as the first system-files scan, so the
+            // section has an answer without the user pressing Check. Guarded
+            // on both flags because Refreshed repeats on a timer and each scan
+            // is a process spawn.
+            if !state.sysfiles.scanned && !state.sysfiles.busy {
+                update(state, SystemMessage::ScanSystemFiles, ctx);
+            }
         }
         SystemMessage::Suspend => spawn_systemctl("suspend"),
         SystemMessage::Hibernate => spawn_systemctl("hibernate"),
@@ -443,13 +603,66 @@ pub fn update(state: &mut SystemState, msg: SystemMessage, ctx: &mut cce_ui::con
         SystemMessage::PowerOff => spawn_systemctl("poweroff"),
         SystemMessage::ForceShutdown => spawn_systemctl_force("poweroff"),
 
+        SystemMessage::ScanSystemFiles => {
+            state.sysfiles.busy = true;
+            spawn_sysfiles(state.sender.clone(), &["install-system", "--dry-run"], |out| {
+                let pending = match out {
+                    Ok(o) => parse_pending(&String::from_utf8_lossy(&o.stdout)),
+                    // A scan that could not run reports nothing pending, and
+                    // `scanned` still flips — the section then says "up to
+                    // date", which is wrong but harmless, where a spinner that
+                    // never resolves would be a hang. The install button is the
+                    // real check: it re-plans and would find the work.
+                    Err(_) => Vec::new(),
+                };
+                SystemMessage::SystemFilesScanned(pending)
+            });
+        }
+        SystemMessage::SystemFilesScanned(pending) => {
+            state.sysfiles.pending = pending;
+            state.sysfiles.scanned = true;
+            state.sysfiles.busy = false;
+        }
+        SystemMessage::InstallSystemFiles => {
+            state.sysfiles.busy = true;
+            state.sysfiles.result = None;
+            // --pkexec explicitly rather than letting the auto path decide:
+            // this process has no TTY, so auto would pick pkexec anyway, but
+            // saying so keeps the GUI's behaviour independent of how the
+            // script guesses.
+            spawn_sysfiles(state.sender.clone(), &["install-system", "--pkexec"], |out| {
+                let res = match out {
+                    Ok(o) if o.status.success() => Ok(()),
+                    // A cancelled or failed polkit prompt exits non-zero with
+                    // its reason on stderr; surface that rather than a code.
+                    Ok(o) => {
+                        let err = String::from_utf8_lossy(&o.stderr).trim().to_string();
+                        Err(if err.is_empty() { format!("exited {}", o.status) } else { err })
+                    }
+                    Err(e) => Err(e.to_string()),
+                };
+                SystemMessage::SystemFilesInstalled(res)
+            });
+        }
+        SystemMessage::SystemFilesInstalled(res) => {
+            let ok = res.is_ok();
+            state.sysfiles.result = Some(res);
+            state.sysfiles.busy = false;
+            if ok {
+                // Re-scan rather than assuming the list is now empty: the
+                // apply re-plans, so what it actually did is only knowable by
+                // asking again.
+                state.sysfiles.scanned = false;
+                update(state, SystemMessage::ScanSystemFiles, ctx);
+            }
+        }
     }
 }
 
 
 
 impl crate::pages::AppPage for SystemState {
-    // Sections: [System, System Actions, CPU, GPU, Battery]
+    // Sections: [System, System Actions, CPU, GPU, System Files, Battery]
     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
         vec![
             Vec::new(),
@@ -457,6 +670,7 @@ impl crate::pages::AppPage for SystemState {
             Vec::new(),
             Vec::new(),
             Vec::new(),
+            Vec::new(),
         ]
     }
 
@@ -491,6 +705,29 @@ impl crate::pages::AppPage for SystemState {
 mod tests {
     use super::*;
 
+    /// The dry run prints pending changes, banners and the queued root
+    /// commands down one stream; only the indented `name -> dest` lines are
+    /// changes. Getting this wrong in the lenient direction would list
+    /// `install -m 644 ... -> ...` as a pending file, and in the strict
+    /// direction would hide a pending PAM stack — which is the one thing the
+    /// section exists to show before a root install is authorized.
+    #[test]
+    fn parse_pending_takes_only_the_change_lines() {
+        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";
+        assert_eq!(
+            parse_pending(out),
+            vec![
+                "cce-display-manager -> /usr/bin/cce-display-manager".to_string(),
+                "cce-lock -> /etc/pam.d/cce-lock".to_string(),
+            ]
+        );
+    }
+
+    #[test]
+    fn parse_pending_is_empty_when_nothing_differs() {
+        assert!(parse_pending("==> system artifacts already up to date\n").is_empty());
+    }
+
     #[test]
     fn test_view_layout_grid() {
         let mut state = SystemState::default();