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 }