system settings
git clone https://git.lucas.co/cce-system-interface.git
fix(power): never hand a pick to a root helper too old to take it
A pick on a lever or an assignment goes to /usr/bin/cce-power-apply when
`ccebuild install-system` has put one there. A copy older than modes reads
the mode name as an adapter state, prints usage and exits 2 — after the
pkexec prompt. So the pick cost an authentication and changed nothing, and
the watcher's next read quietly reverted the dropdown: the one failure
this page's optimistic-UI design has no channel for.
The system copy now has to speak the current CLI to be preferred, asked by
running it with no arguments and looking for `apply-mode` in its usage (not
by searching the binary for a string: a hit would prove freshness, a miss
proves nothing, and a false "stale" is the worse error). Otherwise the page
falls through to ~/.local/bin, which pkexec runs as root just the same.
`automation_installed()` becomes `automation_status()` with a third state
between installed and missing: a stale helper cannot parse a plan with
`mode` blocks either, so it applies nothing on plug or unplug. The Battery
section says that and names the command that fixes it, instead of claiming
the machine switches automatically.
Co-Authored-By: Claude Opus 5 <[email protected]>
src/pages/power.rs | 73 +++++++++++++++++++++++++++++++++++++++++-------------
src/power_plan.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 117 insertions(+), 22 deletions(-)
diff --git a/src/pages/power.rs b/src/pages/power.rs
index ca8a27e..d299cb8 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -29,7 +29,7 @@
//! honest, with no extra error channel.
use crate::app::{AppAction, PageContent};
-use crate::power_plan::{self, Lever, Mode, PowerPlan, Source};
+use crate::power_plan::{self, Automation, Lever, Mode, PowerPlan, Source};
use cce_ui::layout::{LayoutStrategy, PageLayoutBuilder};
use cce_ui::widget::{Adapted, Dropdown, WidgetHost};
use std::path::{Path, PathBuf};
@@ -53,10 +53,10 @@ pub struct PowerFacts {
pub ac_online: Option<bool>,
/// Which plan is in force right now, from the Mains supply.
pub source: Source,
- /// The per-source plan on disk, and whether the root side that applies
- /// it on plug/unplug (helper + udev rule) is installed.
+ /// The plan on disk, and the state of the root side that applies it on
+ /// plug/unplug (helper + udev rule).
pub plan: PowerPlan,
- pub automation: bool,
+ pub automation: Automation,
/// platform_profile choices in sysfs spelling, and the active one.
pub profiles: Vec<String>,
pub profile: String,
@@ -205,17 +205,27 @@ fn run_privileged(cmd: String) {
}
/// The helper that records and applies the plan: the system copy when
-/// `ccebuild install-system` has put it there, else the one installed beside
-/// this binary (`~/.local/bin`) — which pkexec will still run as root after
-/// the prompt, so the plan works before the root side is installed; only the
-/// automatic switching waits on it.
+/// `ccebuild install-system` has put a current one there, else the one
+/// installed beside this binary (`~/.local/bin`) — which pkexec will still
+/// run as root after the prompt, so the plan works before the root side is
+/// installed; only the automatic switching waits on it.
+///
+/// The system copy has to speak the current CLI to be preferred. A stale
+/// one there is worse than none: it takes the pkexec prompt, reads the mode
+/// name as an adapter state and exits 2, so every pick costs an
+/// authentication and changes nothing. Falling through to the local copy
+/// keeps the page working; the Battery section is where the user is told
+/// the root side is behind.
fn helper_path() -> Option<PathBuf> {
let sys = Path::new(power_plan::HELPER_SYSTEM_PATH);
- if sys.exists() {
+ if sys.exists() && power_plan::helper_speaks_modes(sys) {
return Some(sys.to_path_buf());
}
let beside = std::env::current_exe().ok()?.parent()?.join("cce-power-apply");
- beside.exists().then_some(beside)
+ if beside.exists() {
+ return Some(beside);
+ }
+ sys.exists().then(|| sys.to_path_buf())
}
/// Display form of a sysfs token: `balance_power` → "Balance Power".
@@ -321,7 +331,7 @@ pub async fn fetch_power_state() -> PowerFacts {
log::warn!("[power] {}", e);
PowerPlan::default()
});
- f.automation = power_plan::automation_installed();
+ f.automation = power_plan::automation_status();
if let Some(choices) = read_trim("/sys/firmware/acpi/platform_profile_choices") {
f.profiles = choices.split_whitespace().map(String::from).collect();
@@ -673,11 +683,19 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
if f.battery_present {
sec.spacing(10.0);
- if f.automation {
- sec.text("Switches automatically on plug and unplug.", 12.0, 0.0, 11.0, TEXT_DIM);
- } else {
- sec.text("Automatic switching is not installed:", 12.0, 0.0, 11.0, WARN);
- sec.text("System › System Files installs it.", 12.0, 0.0, 11.0, WARN);
+ match f.automation {
+ Automation::Ready => {
+ sec.text("Switches automatically on plug and unplug.", 12.0, 0.0, 11.0, TEXT_DIM);
+ }
+ Automation::Missing => {
+ sec.text("Automatic switching is not installed:", 12.0, 0.0, 11.0, WARN);
+ sec.text("System › System Files installs it.", 12.0, 0.0, 11.0, WARN);
+ }
+ Automation::Stale => {
+ sec.text("Automatic switching is out of date and", 12.0, 0.0, 11.0, WARN);
+ sec.text("applies nothing on plug or unplug.", 12.0, 0.0, 11.0, WARN);
+ sec.text("Run: ccebuild install-system", 12.0, 0.0, 11.0, WARN);
+ }
}
}
});
@@ -929,7 +947,7 @@ mod tests {
ac_online: Some(false),
source: Source::Battery,
plan,
- automation: true,
+ automation: Automation::Ready,
energy_now_uwh: Some(44_900_000.0),
energy_full_uwh: Some(74_900_000.0),
vendor: "SMP".to_string(),
@@ -1233,6 +1251,27 @@ mod tests {
paint(&mut empty, 1);
}
+ #[test]
+ fn a_stale_root_helper_is_named_on_the_page() {
+ let mut ctx = cce_ui::context::UiContext::new();
+ let mut lines = |st: &mut PowerState| -> Vec<String> {
+ let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
+ let sec_focused = vec![false; 3];
+ let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+ pc.texts.iter().map(|t| t.0.clone()).collect()
+ };
+ let mut st = loaded();
+ assert!(lines(&mut st).iter().any(|l| l.contains("Switches automatically")));
+ // A helper too old to read a plan with modes applies nothing on plug
+ // or unplug, and the page says so rather than claiming it switches.
+ st.facts.automation = Automation::Stale;
+ let out = lines(&mut st);
+ assert!(out.iter().any(|l| l.contains("out of date")), "{out:?}");
+ assert!(out.iter().any(|l| l.contains("ccebuild install-system")), "{out:?}");
+ st.facts.automation = Automation::Missing;
+ assert!(lines(&mut st).iter().any(|l| l.contains("not installed")));
+ }
+
#[test]
fn set_live_mirrors_each_lever() {
let mut f = facts();
diff --git a/src/power_plan.rs b/src/power_plan.rs
index 9114946..bc9cfeb 100644
--- a/src/power_plan.rs
+++ b/src/power_plan.rs
@@ -435,11 +435,52 @@ pub fn current_source() -> Source {
source_from_supplies(pairs.iter().map(|(k, o)| (k.as_str(), *o)))
}
-/// Whether the root-side pieces are in place: the helper at its system path
-/// and the udev rule that starts it. Without both, the page's plan is only
-/// applied when the page itself changes a lever.
-pub fn automation_installed() -> bool {
- Path::new(HELPER_SYSTEM_PATH).exists() && Path::new(UDEV_RULE_PATH).exists()
+/// The state of the root side that applies a mode on plug and unplug.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Automation {
+ /// No helper at the system path, or no udev rule to start it. The plan
+ /// is only applied when the page itself changes a lever.
+ #[default]
+ Missing,
+ /// Both installed, but the helper predates modes: it cannot parse a
+ /// plan with `mode` blocks, so it applies nothing on plug or unplug —
+ /// and it rejects the page's own `set`/`assign` calls too.
+ Stale,
+ Ready,
+}
+
+/// Whether a helper binary speaks the current, mode-shaped CLI.
+///
+/// Asked by running it with no arguments, which prints its usage and exits
+/// 2 without touching anything. Deliberately NOT a string search inside the
+/// file: a hit would prove freshness but a miss proves nothing (link-time
+/// constant merging eats literals), and a false "stale" is the worse error.
+///
+/// This exists because a stale `/usr/bin/cce-power-apply` fails in the one
+/// way nothing reports: it takes the pkexec prompt, reads the mode name as
+/// an adapter state, and exits 2 — so a pick costs the user an
+/// authentication and changes nothing.
+pub fn helper_speaks_modes(path: &Path) -> bool {
+ std::process::Command::new(path)
+ .output()
+ .is_ok_and(|out| usage_speaks_modes(&String::from_utf8_lossy(&out.stderr)))
+}
+
+/// The usage text of a helper that knows about modes names `apply-mode`;
+/// the pre-modes one lists only `apply`, `set` and `show`.
+fn usage_speaks_modes(usage: &str) -> bool {
+ usage.contains("apply-mode")
+}
+
+/// Whether the root-side pieces are in place — the helper at its system
+/// path, the udev rule that starts it, and a helper new enough to read the
+/// plan this app writes.
+pub fn automation_status() -> Automation {
+ let helper = Path::new(HELPER_SYSTEM_PATH);
+ if !helper.exists() || !Path::new(UDEV_RULE_PATH).exists() {
+ return Automation::Missing;
+ }
+ if helper_speaks_modes(helper) { Automation::Ready } else { Automation::Stale }
}
// ── Applying (root) ─────────────────────────────────────────────────────
@@ -700,6 +741,21 @@ mod tests {
assert_eq!(source_from_supplies([]), Source::Ac);
}
+ #[test]
+ fn the_usage_probe_tells_a_mode_helper_from_a_pre_modes_one() {
+ // What this binary prints today.
+ assert!(usage_speaks_modes(
+ "usage: cce-power-apply apply [ac|battery]\n cce-power-apply apply-mode <mode>\n"
+ ));
+ // What the pre-modes one printed — the copy that silently rejects
+ // every pick the page makes.
+ assert!(!usage_speaks_modes(
+ "usage: cce-power-apply apply [ac|battery]\n cce-power-apply set <ac|battery> <lever> <value|unset>\n cce-power-apply show\n"
+ ));
+ // A helper that cannot be run at all is not a helper that speaks.
+ assert!(!helper_speaks_modes(Path::new("/nonexistent/cce-power-apply")));
+ }
+
#[test]
fn keys_round_trip() {
for l in Lever::ALL {