git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit84fa49a9d0c23fbf342b0d1dda941a40717c8270
parentf61439da2f
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 14:43
feat(idle): external inhibitor leases over the control socket

`idle inhibit <token> <ttl_s> <who>` holds both idle timers off for a
holder with no surface to hang a Wayland idle-inhibitor on: the portal's
Inhibit interface and org.freedesktop.ScreenSaver, served by the new
cce-desktop-portal (Steam, browsers and video players call the latter
directly; until now nothing on a cce session bus owned it, so their
requests did nothing). `idle uninhibit <token>` ends one, `idle
inhibit-clear` drops all.

Each is a lease: it lapses unless renewed within ttl_s (at most 600), so
a holder that dies without saying so cannot keep the display on for
good. Leases merge with the Wayland inhibitors into the one holder list
`idle status` reports, as `portal:<who>`, deduplicated per holder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/cce_ctl.rs     |   2 +
 src/server/idle.rs | 175 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 176 insertions(+), 1 deletion(-)

diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 21a8da65..a51f0827 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -72,6 +72,8 @@ fn usage(name: &str, to_stderr: bool) {
     print("  idle [status]              # idle timeouts in force, idle time, inhibited and by whom (app ids), displays_off/sleeping, and the power plan's override of each (plan_*)");
     print("  idle wake|sleep|display on|display off   # act now: wake darkened outputs, lock, then run the sleep command; darken/wake outputs");
     print("  idle timeouts <display_off_s> <sleep_s>  # set the config timeouts live (0 = off); config.kdl `idle { }` on reload; a Power-plan file under /run/cce overrides either while it exists");
+    print("  idle inhibit <token> <ttl_s> <who>  # hold the idle timers off for a holder with no surface (cce-desktop-portal: portal Inhibit, org.freedesktop.ScreenSaver); lapses after ttl_s (1-600) unless renewed");
+    print("  idle uninhibit <token> | idle inhibit-clear  # end one lease now / drop every lease");
     print("  lock                       # lock the session now (cce-lock takes the prompt); replies `ok locked` once every output shows the locked scene");
     print("  status-hide-mode [true|false]");
     print("  adjust-position-mode [true|false|query]");
diff --git a/src/server/idle.rs b/src/server/idle.rs
index 34f43507..e379cf24 100644
--- a/src/server/idle.rs
+++ b/src/server/idle.rs
@@ -149,6 +149,14 @@ pub struct IdleManager {
     /// in creation order; empty when nobody does. `Option` for the same
     /// reason as `sleep_command`: null-niche safe under the zeroed init.
     inhibitors: Option<Vec<String>>,
+    /// The Wayland half of `inhibitors`, as `IdleInhibitManager` last
+    /// reported it; `external` is the other half.
+    wayland_inhibitors: Option<Vec<String>>,
+    /// External leases (see the module doc), in grant order. `Option` for
+    /// the zeroed init, like `inhibitors`.
+    external: Option<Vec<ExternalLease>>,
+    /// Fires at the earliest lease expiry; disarmed when there are none.
+    lease_timer: *mut ffi::wl_event_source,
     /// The display timeout fired and outputs were darkened by us.
     displays_off: bool,
     /// The sleep command was spawned; cleared by the next activity.
@@ -164,6 +172,46 @@ pub struct IdleManager {
     session_listening: bool,
 }
 
+/// One `idle inhibit` lease.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ExternalLease {
+    pub token: String,
+    pub who: String,
+    pub expires_ms: u64,
+}
+
+/// The longest lease one request may take: a holder that wants longer
+/// renews. Bounds how long a dead holder's lease can outlive it.
+pub const MAX_LEASE_S: u64 = 600;
+
+/// `idle inhibit <token> <ttl_s> <who...>`: the lease it asks for, or the
+/// usage error. `who` is what `idle status` reports (`steam`, `org.mozilla.firefox`).
+pub fn parse_lease(args: &[&str], now: u64) -> Result<ExternalLease, String> {
+    let usage = || "error: idle inhibit <token> <ttl_s 1-600> <who>\n".to_string();
+    let [token, ttl, who @ ..] = args else { return Err(usage()) };
+    if who.is_empty() {
+        return Err(usage());
+    }
+    let ttl: u64 = ttl.parse().map_err(|_| usage())?;
+    if ttl == 0 || ttl > MAX_LEASE_S {
+        return Err(usage());
+    }
+    Ok(ExternalLease { token: token.to_string(), who: who.join(" "), expires_ms: now + ttl * 1000 })
+}
+
+/// The inhibitor names `idle status` and the log show: the Wayland holders,
+/// then each external holder once, as `portal:<who>`.
+pub fn merge_inhibitors(wayland: &[String], external: &[ExternalLease]) -> Vec<String> {
+    let mut names: Vec<String> = wayland.to_vec();
+    for lease in external {
+        let name = format!("portal:{}", lease.who);
+        if !names.contains(&name) {
+            names.push(name);
+        }
+    }
+    names
+}
+
 fn now_ms() -> u64 {
     let ts = crate::util::timestamp();
     ts.tv_sec as u64 * 1000 + ts.tv_nsec as u64 / 1_000_000
@@ -215,6 +263,20 @@ impl IdleManager {
             self.sleep_timer = std::ptr::null_mut();
             return Err("Failed to create idle plan-poll timer");
         }
+        self.lease_timer = ffi::wl_event_loop_add_timer(
+            event_loop,
+            Some(handle_lease_expiry),
+            self as *mut IdleManager as *mut _,
+        );
+        if self.lease_timer.is_null() {
+            ffi::wl_event_source_remove(self.display_timer);
+            ffi::wl_event_source_remove(self.sleep_timer);
+            ffi::wl_event_source_remove(self.plan_timer);
+            self.display_timer = std::ptr::null_mut();
+            self.sleep_timer = std::ptr::null_mut();
+            self.plan_timer = std::ptr::null_mut();
+            return Err("Failed to create idle lease timer");
+        }
         self.display_off_ms = 0;
         self.sleep_ms = 0;
         self.cfg_display_off_ms = 0;
@@ -231,6 +293,8 @@ impl IdleManager {
         self.sleep_command = None;
         self.inhibited = false;
         self.inhibitors = None;
+        self.wayland_inhibitors = None;
+        self.external = None;
         self.displays_off = false;
         self.sleeping = false;
         self.sleep_after_lock = false;
@@ -265,6 +329,10 @@ impl IdleManager {
             ffi::wl_event_source_remove(self.plan_timer);
             self.plan_timer = std::ptr::null_mut();
         }
+        if !self.lease_timer.is_null() {
+            ffi::wl_event_source_remove(self.lease_timer);
+            self.lease_timer = std::ptr::null_mut();
+        }
         self.unwatch_plan_dir();
         if !self.lock_fallback_timer.is_null() {
             ffi::wl_event_source_remove(self.lock_fallback_timer);
@@ -412,6 +480,16 @@ impl IdleManager {
     /// log report, so a display that never darkens can be traced to the app
     /// keeping it on rather than to a bare `inhibited=true`.
     pub unsafe fn set_inhibitors(&mut self, names: Vec<String>) {
+        self.wayland_inhibitors = Some(names);
+        self.apply_inhibitors();
+    }
+
+    /// Recompute the holder list from both halves and act on a change.
+    unsafe fn apply_inhibitors(&mut self) {
+        let names = merge_inhibitors(
+            self.wayland_inhibitors.as_deref().unwrap_or(&[]),
+            self.external.as_deref().unwrap_or(&[]),
+        );
         if self.inhibitors.as_deref().unwrap_or(&[]) == names.as_slice() {
             return;
         }
@@ -545,6 +623,58 @@ impl IdleManager {
     }
 
     /// `ccectl idle` report.
+    /// Grant or renew a lease (`idle inhibit`).
+    unsafe fn grant_lease(&mut self, lease: ExternalLease) {
+        let leases = self.external.get_or_insert_with(Vec::new);
+        match leases.iter_mut().find(|l| l.token == lease.token) {
+            Some(existing) => *existing = lease,
+            None => {
+                log::info!("idle: lease {} granted to {}", lease.token, lease.who);
+                leases.push(lease);
+            }
+        }
+        self.arm_lease_timer();
+        self.apply_inhibitors();
+    }
+
+    /// End leases: one by token, or every one (`None`).
+    unsafe fn end_leases(&mut self, token: Option<&str>) {
+        if let Some(leases) = self.external.as_mut() {
+            leases.retain(|l| token.is_some_and(|t| l.token != t));
+        }
+        self.arm_lease_timer();
+        self.apply_inhibitors();
+    }
+
+    /// Drop the leases whose time is up.
+    unsafe fn expire_leases(&mut self) {
+        let now = now_ms();
+        if let Some(leases) = self.external.as_mut() {
+            leases.retain(|l| {
+                let live = l.expires_ms > now;
+                if !live {
+                    log::warn!("idle: lease {} ({}) lapsed without renewal", l.token, l.who);
+                }
+                live
+            });
+        }
+        self.arm_lease_timer();
+        self.apply_inhibitors();
+    }
+
+    unsafe fn arm_lease_timer(&mut self) {
+        if self.lease_timer.is_null() {
+            return;
+        }
+        let next = self.external.as_deref().unwrap_or(&[]).iter().map(|l| l.expires_ms).min();
+        let ms = match next {
+            // A timer of 0 disarms, so an already-due lease fires in 1 ms.
+            Some(at) => at.saturating_sub(now_ms()).clamp(1, i32::MAX as u64) as i32,
+            None => 0,
+        };
+        ffi::wl_event_source_timer_update(self.lease_timer, ms);
+    }
+
     pub fn status(&self) -> String {
         let idle_s = now_ms().saturating_sub(self.last_activity_ms) / 1000;
         format!(
@@ -582,6 +712,21 @@ impl IdleManager {
                 self.lock_then_sleep();
                 "ok\n".to_string()
             }
+            ["inhibit", rest @ ..] => match parse_lease(rest, now_ms()) {
+                Ok(lease) => {
+                    self.grant_lease(lease);
+                    "ok\n".to_string()
+                }
+                Err(e) => e,
+            },
+            ["uninhibit", token] => {
+                self.end_leases(Some(*token));
+                "ok\n".to_string()
+            }
+            ["inhibit-clear"] => {
+                self.end_leases(None);
+                "ok\n".to_string()
+            }
             ["timeouts", display, sleep] => {
                 match (display.parse::<i64>(), sleep.parse::<i64>()) {
                     (Ok(d), Ok(s)) if d >= 0 && s >= 0 => {
@@ -600,7 +745,7 @@ impl IdleManager {
                     _ => "error: idle timeouts <display_off_s> <sleep_s> (non-negative seconds, 0 = off)\n".to_string(),
                 }
             }
-            _ => "error: usage: idle [status|wake|display on|display off|sleep|timeouts <display_off_s> <sleep_s>]\n".to_string(),
+            _ => "error: usage: idle [status|wake|display on|display off|sleep|timeouts <display_off_s> <sleep_s>|inhibit <token> <ttl_s> <who>|uninhibit <token>|inhibit-clear]\n".to_string(),
         }
     }
 }
@@ -658,6 +803,12 @@ unsafe extern "C" fn handle_plan_poll(data: *mut std::ffi::c_void) -> std::os::r
     0
 }
 
+unsafe extern "C" fn handle_lease_expiry(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let idle = &mut *(data as *mut IdleManager);
+    idle.expire_leases();
+    0
+}
+
 unsafe extern "C" fn handle_display_timeout(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
     let idle = &mut *(data as *mut IdleManager);
     if !idle.inhibited && !idle.displays_off {
@@ -718,6 +869,28 @@ mod tests {
         assert_eq!(plan_field(None), "none");
     }
 
+    #[test]
+    fn a_lease_request_is_token_ttl_and_who() {
+        let lease = parse_lease(&["portal-7", "90", "steam"], 1_000).unwrap();
+        assert_eq!(lease, ExternalLease { token: "portal-7".into(), who: "steam".into(), expires_ms: 91_000 });
+        let spaced = parse_lease(&["ss-2", "30", "Firefox", "video"], 0).unwrap();
+        assert_eq!(spaced.who, "Firefox video", "who keeps its spaces");
+        for bad in [&["t", "0", "x"][..], &["t", "601", "x"], &["t", "ten", "x"], &["t", "30"], &[]] {
+            assert!(parse_lease(bad, 0).is_err(), "{bad:?} must be refused");
+        }
+    }
+
+    #[test]
+    fn external_holders_follow_the_wayland_ones_once_each() {
+        let lease = |t: &str, w: &str| ExternalLease { token: t.into(), who: w.into(), expires_ms: 0 };
+        let names = merge_inhibitors(
+            &["mpv".to_string()],
+            &[lease("a", "steam"), lease("b", "firefox"), lease("c", "steam")],
+        );
+        assert_eq!(names, ["mpv", "portal:steam", "portal:firefox"]);
+        assert!(merge_inhibitors(&[], &[]).is_empty());
+    }
+
     #[test]
     fn an_absent_plan_file_stamps_as_zero() {
         assert_eq!(plan_stamp("/nonexistent/cce/idle_display_off"), 0);