git.lucas.co / cce-desktop-portal
Settings and Inhibit portal backends
git clone https://git.lucas.co/cce-desktop-portal.git

src/inhibit.rs (13.3K)

  1 //! Keeping the display awake for apps that ask over D-Bus rather than with a
  2 //! Wayland `idle-inhibit` surface: the portal's `Inhibit` interface (what
  3 //! sandboxed and portal-aware apps call) and `org.freedesktop.ScreenSaver`
  4 //! (what Steam, browsers and video players call directly).
  5 //!
  6 //! Before this, the portal's Inhibit went to xdg-desktop-portal-gtk, which
  7 //! hands it to `org.gnome.SessionManager` or `org.freedesktop.ScreenSaver` —
  8 //! and nothing on a cce session bus owns either, so every request was
  9 //! accepted and did nothing.
 10 //!
 11 //! Every holder becomes a **lease** in the compositor (`idle inhibit <token>
 12 //! <ttl_s> <who>` on the control socket; `idle.rs` in cce-compositor). Leases
 13 //! lapse unless renewed, so this process renews every live one well inside
 14 //! the ttl; if it dies, the compositor drops them within [`LEASE_TTL_S`]
 15 //! instead of keeping the display on for good. A holder ends with its request
 16 //! being closed (portal), `UnInhibit` (ScreenSaver), or its D-Bus connection
 17 //! going away (both — a crashed player releases what it held).
 18 
 19 use std::collections::HashMap;
 20 use std::sync::Arc;
 21 
 22 use tokio::io::{AsyncReadExt, AsyncWriteExt};
 23 use tokio::sync::{Mutex, Notify};
 24 use zbus::message::Header;
 25 use zbus::names::{OwnedUniqueName, UniqueName};
 26 use zbus::object_server::SignalEmitter;
 27 use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue, Value};
 28 use zbus::{interface, Connection, ObjectServer};
 29 
 30 /// A lease's lifetime in the compositor, and how often live ones are renewed.
 31 pub const LEASE_TTL_S: u64 = 60;
 32 const RENEW_EVERY: std::time::Duration = std::time::Duration::from_secs(20);
 33 
 34 /// Portal Inhibit flags (the spec's bitmask). Only these two are about the
 35 /// idle timers; logout and user-switch have nothing to hold in cce.
 36 const FLAG_SUSPEND: u32 = 4;
 37 const FLAG_IDLE: u32 = 8;
 38 
 39 // ── compositor control socket ──────────────────────────────────────────────
 40 
 41 fn ctl_socket_path() -> String {
 42     match std::env::var("WAYLAND_DISPLAY").ok().filter(|d| !d.is_empty()) {
 43         Some(d) => format!("/tmp/cce-{d}.sock"),
 44         None => "/tmp/cce.sock".to_string(),
 45     }
 46 }
 47 
 48 /// One request/reply round; the compositor answers one line and closes.
 49 pub async fn ctl(cmd: &str) -> std::io::Result<String> {
 50     let mut stream = tokio::net::UnixStream::connect(ctl_socket_path()).await?;
 51     stream.write_all(format!("{cmd}\n").as_bytes()).await?;
 52     let mut reply = String::new();
 53     stream.read_to_string(&mut reply).await?;
 54     Ok(reply)
 55 }
 56 
 57 /// The control socket splits on whitespace and reads one line: a holder's
 58 /// name keeps its words but loses anything that could end the line.
 59 pub fn sanitize_who(who: &str) -> String {
 60     let cleaned: String = who.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
 61     let cleaned = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
 62     if cleaned.is_empty() { "unknown".to_string() } else { cleaned }
 63 }
 64 
 65 // ── the registry ───────────────────────────────────────────────────────────
 66 
 67 #[derive(Clone, Debug)]
 68 struct Holder {
 69     who: String,
 70     /// The D-Bus connection that asked; its disappearance ends the hold.
 71     owner: Option<OwnedUniqueName>,
 72 }
 73 
 74 #[derive(Default)]
 75 pub struct Registry {
 76     holders: HashMap<String, Holder>,
 77     next_cookie: u32,
 78     /// The compositor refused `idle inhibit` (too old): logged once, and
 79     /// every hold is still tracked so a newer compositor gets them on renew.
 80     unsupported_logged: bool,
 81 }
 82 
 83 #[derive(Clone)]
 84 pub struct Inhibitor {
 85     reg: Arc<Mutex<Registry>>,
 86     wake: Arc<Notify>,
 87 }
 88 
 89 impl Inhibitor {
 90     pub fn new() -> Self {
 91         Inhibitor { reg: Arc::new(Mutex::new(Registry::default())), wake: Arc::new(Notify::new()) }
 92     }
 93 
 94     async fn grant(&self, token: String, who: String, owner: Option<OwnedUniqueName>) {
 95         let who = sanitize_who(&who);
 96         log::info!("inhibit {token} for {who}");
 97         self.reg.lock().await.holders.insert(token.clone(), Holder { who: who.clone(), owner });
 98         self.send_lease(&token, &who).await;
 99         self.wake.notify_one();
100     }
101 
102     async fn release(&self, token: &str) {
103         if self.reg.lock().await.holders.remove(token).is_some() {
104             log::info!("release {token}");
105             if let Err(e) = ctl(&format!("idle uninhibit {token}")).await {
106                 log::warn!("control socket: {e}");
107             }
108         }
109     }
110 
111     async fn send_lease(&self, token: &str, who: &str) {
112         match ctl(&format!("idle inhibit {token} {LEASE_TTL_S} {who}")).await {
113             Ok(reply) if reply.starts_with("ok") => {}
114             Ok(reply) => {
115                 let mut reg = self.reg.lock().await;
116                 if !reg.unsupported_logged {
117                     reg.unsupported_logged = true;
118                     log::warn!("compositor refused the lease ({}); it predates external inhibitors — they take effect after a restart into a newer cce-fx", reply.trim());
119                 }
120             }
121             Err(e) => log::warn!("control socket {}: {e}", ctl_socket_path()),
122         }
123     }
124 
125     /// Renew every live lease well inside its ttl; sleep while there are none.
126     pub async fn renew_loop(self) {
127         loop {
128             let live: Vec<(String, String)> =
129                 self.reg.lock().await.holders.iter().map(|(t, h)| (t.clone(), h.who.clone())).collect();
130             if live.is_empty() {
131                 self.wake.notified().await;
132                 continue;
133             }
134             tokio::time::sleep(RENEW_EVERY).await;
135             for (token, who) in live {
136                 // Released while we slept: renewing would resurrect it.
137                 if self.reg.lock().await.holders.contains_key(&token) {
138                     self.send_lease(&token, &who).await;
139                 }
140             }
141         }
142     }
143 
144     /// Drop every hold a vanished D-Bus connection left behind.
145     pub async fn owner_gone(&self, name: &UniqueName<'_>) {
146         let tokens: Vec<String> = self
147             .reg
148             .lock()
149             .await
150             .holders
151             .iter()
152             .filter(|(_, h)| h.owner.as_ref().is_some_and(|o| o.as_str() == name.as_str()))
153             .map(|(t, _)| t.clone())
154             .collect();
155         for token in tokens {
156             log::info!("{name} left the bus");
157             self.release(&token).await;
158         }
159     }
160 
161     async fn next_cookie(&self) -> u32 {
162         let mut reg = self.reg.lock().await;
163         reg.next_cookie = reg.next_cookie.wrapping_add(1).max(1);
164         reg.next_cookie
165     }
166 }
167 
168 /// The portal request path is unique per request; a lease token must be one
169 /// whitespace-free word, so it is the path with its slashes swapped.
170 pub fn portal_token(handle: &str) -> String {
171     format!("portal{}", handle.replace('/', "-"))
172 }
173 
174 // ── org.freedesktop.impl.portal.Inhibit ────────────────────────────────────
175 
176 pub struct InhibitPortal {
177     pub inhibitor: Inhibitor,
178 }
179 
180 #[interface(name = "org.freedesktop.impl.portal.Inhibit")]
181 impl InhibitPortal {
182     #[zbus(property, name = "version")]
183     fn version(&self) -> u32 {
184         3
185     }
186 
187     async fn inhibit(
188         &self,
189         #[zbus(object_server)] server: &ObjectServer,
190         handle: ObjectPath<'_>,
191         app_id: String,
192         _window: String,
193         flags: u32,
194         options: HashMap<String, OwnedValue>,
195     ) {
196         let reason = match options.get("reason").map(|v| &**v) {
197             Some(Value::Str(s)) => s.to_string(),
198             _ => String::new(),
199         };
200         let token = portal_token(handle.as_str());
201         log::info!("portal Inhibit {handle} app={app_id:?} flags={flags} reason={reason:?}");
202         if flags & (FLAG_IDLE | FLAG_SUSPEND) != 0 {
203             let who = if app_id.is_empty() { "portal-app".to_string() } else { app_id.clone() };
204             // No owner to watch: the caller is the portal frontend, not the
205             // app, and the frontend closes the request when the app goes.
206             self.inhibitor.grant(token.clone(), who, None).await;
207         }
208         let request = RequestObj { token, inhibitor: self.inhibitor.clone(), path: handle.clone().into() };
209         if let Err(e) = server.at(&handle, request).await {
210             log::warn!("exporting request {handle}: {e}");
211         }
212     }
213 
214     async fn create_monitor(
215         &self,
216         #[zbus(object_server)] server: &ObjectServer,
217         #[zbus(connection)] conn: &Connection,
218         _handle: ObjectPath<'_>,
219         session_handle: ObjectPath<'_>,
220         app_id: String,
221         _window: String,
222     ) -> u32 {
223         log::info!("CreateMonitor {session_handle} for {app_id:?}");
224         let path: OwnedObjectPath = session_handle.clone().into();
225         if let Err(e) = server.at(&path, MonitorSession { path: path.clone() }).await {
226             log::warn!("exporting monitor {path}: {e}");
227             return 2;
228         }
229         // The initial state: running, screensaver off. cce has no
230         // query-end, so it never changes from here.
231         let conn = conn.clone();
232         tokio::spawn(async move {
233             if let Ok(emitter) = SignalEmitter::new(&conn, "/org/freedesktop/portal/desktop") {
234                 let state = HashMap::from([
235                     ("screensaver-active", Value::from(false)),
236                     ("session-state", Value::from(1u32)),
237                 ]);
238                 let _ = InhibitPortal::state_changed(&emitter, path.as_ref(), state).await;
239             }
240         });
241         0
242     }
243 
244     async fn query_end_response(&self, _session_handle: ObjectPath<'_>) {}
245 
246     #[zbus(signal)]
247     async fn state_changed(emitter: &SignalEmitter<'_>, session_handle: ObjectPath<'_>, state: HashMap<&str, Value<'_>>) -> zbus::Result<()>;
248 }
249 
250 /// `org.freedesktop.impl.portal.Request` at the handle the frontend chose:
251 /// its `Close` is how the app (or the frontend, when the app exits) ends the
252 /// inhibition.
253 struct RequestObj {
254     token: String,
255     inhibitor: Inhibitor,
256     path: OwnedObjectPath,
257 }
258 
259 #[interface(name = "org.freedesktop.impl.portal.Request")]
260 impl RequestObj {
261     async fn close(&self, #[zbus(connection)] conn: &Connection) {
262         self.inhibitor.release(&self.token).await;
263         remove_later::<RequestObj>(conn, self.path.clone());
264     }
265 }
266 
267 struct MonitorSession {
268     path: OwnedObjectPath,
269 }
270 
271 #[interface(name = "org.freedesktop.impl.portal.Session")]
272 impl MonitorSession {
273     #[zbus(property, name = "version")]
274     fn version(&self) -> u32 {
275         1
276     }
277 
278     async fn close(&self, #[zbus(connection)] conn: &Connection) {
279         remove_later::<MonitorSession>(conn, self.path.clone());
280     }
281 
282     #[zbus(signal)]
283     async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
284 }
285 
286 /// An object cannot remove itself inside its own method call (the server
287 /// holds it for the call), so it goes on the next turn of the loop.
288 fn remove_later<I: zbus::object_server::Interface>(conn: &Connection, path: OwnedObjectPath) {
289     let conn = conn.clone();
290     tokio::spawn(async move {
291         let _ = conn.object_server().remove::<I, _>(&path).await;
292     });
293 }
294 
295 // ── org.freedesktop.ScreenSaver ────────────────────────────────────────────
296 
297 pub struct ScreenSaver {
298     pub inhibitor: Inhibitor,
299 }
300 
301 #[interface(name = "org.freedesktop.ScreenSaver")]
302 impl ScreenSaver {
303     async fn inhibit(&self, #[zbus(header)] header: Header<'_>, application_name: String, reason_for_inhibit: String) -> u32 {
304         let cookie = self.inhibitor.next_cookie().await;
305         let owner = header.sender().map(|s| OwnedUniqueName::from(s.to_owned()));
306         log::info!("ScreenSaver.Inhibit {cookie} app={application_name:?} reason={reason_for_inhibit:?} from {owner:?}");
307         self.inhibitor.grant(format!("screensaver-{cookie}"), application_name, owner).await;
308         cookie
309     }
310 
311     async fn un_inhibit(&self, cookie: u32) {
312         self.inhibitor.release(&format!("screensaver-{cookie}")).await;
313     }
314 
315     /// Activity, as if the user touched the input: resets both idle timers
316     /// and wakes darkened displays. Older players call this on a timer
317     /// instead of inhibiting.
318     async fn simulate_user_activity(&self) {
319         if let Err(e) = ctl("idle wake").await {
320             log::warn!("control socket: {e}");
321         }
322     }
323 
324     async fn lock(&self) {
325         if let Err(e) = ctl("lock").await {
326             log::warn!("control socket: {e}");
327         }
328     }
329 
330     fn get_active(&self) -> bool {
331         false
332     }
333 
334     fn get_active_time(&self) -> u32 {
335         0
336     }
337 
338     fn get_session_idle_time(&self) -> u32 {
339         0
340     }
341 
342     #[zbus(signal)]
343     async fn active_changed(emitter: &SignalEmitter<'_>, active: bool) -> zbus::Result<()>;
344 }
345 
346 #[cfg(test)]
347 mod tests {
348     use super::*;
349 
350     #[test]
351     fn who_is_one_line_of_words() {
352         assert_eq!(sanitize_who("Steam"), "Steam");
353         assert_eq!(sanitize_who("  Firefox \n video\tplayback "), "Firefox video playback");
354         assert_eq!(sanitize_who("\n\t"), "unknown");
355     }
356 
357     #[test]
358     fn portal_tokens_are_one_word() {
359         let t = portal_token("/org/freedesktop/portal/desktop/request/1_42/t0k3n");
360         assert!(!t.contains(char::is_whitespace) && !t.contains('/'));
361         assert_eq!(t, "portal-org-freedesktop-portal-desktop-request-1_42-t0k3n");
362     }
363 }