git.lucas.co / cce-shortcuts-portal
GlobalShortcuts portal backend
git clone https://git.lucas.co/cce-shortcuts-portal.git

src/main.rs (15K)

  1 //! cce-shortcuts-portal — the `org.freedesktop.impl.portal.GlobalShortcuts`
  2 //! backend for the cce compositor.
  3 //!
  4 //! A native Wayland app cannot grab a key; it asks xdg-desktop-portal to bind
  5 //! a trigger for it, and the portal frontend hands that to whichever backend
  6 //! `~/.config/xdg-desktop-portal/cce-portals.conf` names for the interface.
  7 //! This is that backend. It owns no key handling of its own: every trigger
  8 //! is forwarded to the compositor over its control socket (`shortcut bind
  9 //! <session> <id> <trigger>`), and every press comes back as a line on the
 10 //! status socket's `shortcuts` topic, which is re-emitted here as the
 11 //! portal's `Activated` / `Deactivated` signal. See `global_shortcuts.rs`
 12 //! in cce-compositor for the other end.
 13 //!
 14 //! Bus-activated (`dbus/…cce-shortcuts.service`), so it starts on the first
 15 //! request and lives as long as the compositor's status socket does: when
 16 //! that closes the process exits, and the next request starts a fresh one.
 17 //! A fresh one begins with `shortcut clear`, so binds left by a predecessor
 18 //! that died can never keep eating chords nobody is listening for.
 19 
 20 use std::collections::HashMap;
 21 use std::sync::Arc;
 22 
 23 use serde::Serialize;
 24 use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
 25 use tokio::sync::Mutex;
 26 use zbus::object_server::SignalEmitter;
 27 use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue, SerializeDict, Type, Value};
 28 use zbus::{interface, Connection, ObjectServer};
 29 
 30 const BUS_NAME: &str = "org.freedesktop.impl.portal.desktop.cce-shortcuts";
 31 const OBJ_PATH: &str = "/org/freedesktop/portal/desktop";
 32 
 33 /// Portal request responses.
 34 const RESPONSE_OK: u32 = 0;
 35 const RESPONSE_OTHER: u32 = 2;
 36 
 37 // ── wire types ─────────────────────────────────────────────────────────────
 38 
 39 /// One shortcut as the frontend wants it back: `(sa{sv})` with the two
 40 /// documented keys.
 41 #[derive(Clone, Debug, Serialize, Type)]
 42 struct BoundShortcut(String, ShortcutProps);
 43 
 44 #[derive(Clone, Debug, SerializeDict, Type)]
 45 #[zvariant(signature = "dict")]
 46 struct ShortcutProps {
 47     description: String,
 48     trigger_description: String,
 49 }
 50 
 51 /// The `results` vardict of BindShortcuts / ListShortcuts.
 52 #[derive(Clone, Debug, SerializeDict, Type)]
 53 #[zvariant(signature = "dict")]
 54 struct ShortcutsResults {
 55     shortcuts: Vec<BoundShortcut>,
 56 }
 57 
 58 // ── state ──────────────────────────────────────────────────────────────────
 59 
 60 #[derive(Default)]
 61 struct Session {
 62     #[allow(dead_code)]
 63     app_id: String,
 64     bound: Vec<BoundShortcut>,
 65 }
 66 
 67 #[derive(Default)]
 68 struct Shared {
 69     sessions: HashMap<OwnedObjectPath, Session>,
 70 }
 71 
 72 type SharedState = Arc<Mutex<Shared>>;
 73 
 74 // ── compositor sockets ─────────────────────────────────────────────────────
 75 
 76 fn display() -> Option<String> {
 77     std::env::var("WAYLAND_DISPLAY").ok().filter(|d| !d.is_empty())
 78 }
 79 
 80 /// The control socket, as `ccectl` resolves it.
 81 fn ctl_socket_path() -> String {
 82     match display() {
 83         Some(d) => format!("/tmp/cce-{d}.sock"),
 84         None => "/tmp/cce.sock".to_string(),
 85     }
 86 }
 87 
 88 /// The status socket (`status_server::get_status_socket_path`).
 89 fn status_socket_path() -> String {
 90     match display() {
 91         Some(d) => format!("/tmp/cce-status-interface-{d}.sock"),
 92         None => "/tmp/cce-status-interface.sock".to_string(),
 93     }
 94 }
 95 
 96 /// One request/reply round on the control socket. The compositor answers
 97 /// one line and closes, so the reply is everything up to EOF.
 98 async fn ctl(cmd: &str) -> std::io::Result<String> {
 99     let mut stream = tokio::net::UnixStream::connect(ctl_socket_path()).await?;
100     stream.write_all(format!("{cmd}\n").as_bytes()).await?;
101     let mut reply = String::new();
102     stream.read_to_string(&mut reply).await?;
103     Ok(reply)
104 }
105 
106 /// Shortcut ids are app-chosen and may contain anything, but the control
107 /// socket splits its command on whitespace: percent-encode everything
108 /// outside a safe set on the way out and decode on the way back. `+` is
109 /// safe because the trigger string is built from it (`CTRL+SHIFT+space`)
110 /// and the compositor parses the trigger as written; an id keeps its `+`
111 /// too, which round-trips just the same. The compositor stores and echoes
112 /// the encoded id (`ccectl shortcut list` shows `Quick%20Access`); only
113 /// this side ever decodes.
114 fn encode_token(s: &str) -> String {
115     let mut out = String::with_capacity(s.len());
116     for b in s.bytes() {
117         if b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-' | b'~' | b'+') {
118             out.push(b as char);
119         } else {
120             out.push_str(&format!("%{b:02X}"));
121         }
122     }
123     out
124 }
125 
126 fn decode_token(s: &str) -> String {
127     let bytes = s.as_bytes();
128     let mut out = Vec::with_capacity(bytes.len());
129     let mut i = 0;
130     while i < bytes.len() {
131         if bytes[i] == b'%' && i + 2 < bytes.len() {
132             if let (Some(h), Some(l)) = (hex(bytes.get(i + 1)), hex(bytes.get(i + 2))) {
133                 out.push(h << 4 | l);
134                 i += 3;
135                 continue;
136             }
137         }
138         out.push(bytes[i]);
139         i += 1;
140     }
141     String::from_utf8_lossy(&out).into_owned()
142 }
143 
144 fn hex(b: Option<&u8>) -> Option<u8> {
145     b.and_then(|b| (*b as char).to_digit(16)).map(|d| d as u8)
146 }
147 
148 fn str_prop(props: &HashMap<String, OwnedValue>, key: &str) -> Option<String> {
149     match props.get(key).map(|v| &**v) {
150         Some(Value::Str(s)) => Some(s.to_string()),
151         _ => None,
152     }
153 }
154 
155 // ── org.freedesktop.impl.portal.GlobalShortcuts ────────────────────────────
156 
157 struct Backend {
158     shared: SharedState,
159 }
160 
161 #[interface(name = "org.freedesktop.impl.portal.GlobalShortcuts")]
162 impl Backend {
163     /// Version 1: no `ConfigureShortcuts` (that is version 2), since there
164     /// is no configuration UI — a shortcut is bound to the trigger the app
165     /// preferred or not at all.
166     #[zbus(property)]
167     fn version(&self) -> u32 {
168         1
169     }
170 
171     async fn create_session(
172         &self,
173         #[zbus(object_server)] server: &ObjectServer,
174         _handle: ObjectPath<'_>,
175         session_handle: ObjectPath<'_>,
176         app_id: String,
177         _options: HashMap<String, OwnedValue>,
178     ) -> (u32, HashMap<String, OwnedValue>) {
179         let path = OwnedObjectPath::from(session_handle);
180         log::info!("CreateSession {} for app {:?}", path, app_id);
181         self.shared.lock().await.sessions.insert(path.clone(), Session { app_id, bound: Vec::new() });
182         let obj = SessionObj { path: path.clone(), shared: self.shared.clone() };
183         if let Err(e) = server.at(&path, obj).await {
184             log::error!("exporting session object {}: {}", path, e);
185             self.shared.lock().await.sessions.remove(&path);
186             return (RESPONSE_OTHER, HashMap::new());
187         }
188         (RESPONSE_OK, HashMap::new())
189     }
190 
191     async fn bind_shortcuts(
192         &self,
193         _handle: ObjectPath<'_>,
194         session_handle: ObjectPath<'_>,
195         shortcuts: Vec<(String, HashMap<String, OwnedValue>)>,
196         _parent_window: String,
197         _options: HashMap<String, OwnedValue>,
198     ) -> (u32, ShortcutsResults) {
199         let session = OwnedObjectPath::from(session_handle);
200         if !self.shared.lock().await.sessions.contains_key(&session) {
201             log::warn!("BindShortcuts for unknown session {}", session);
202             return (RESPONSE_OTHER, ShortcutsResults { shortcuts: Vec::new() });
203         }
204         let mut bound = Vec::new();
205         for (id, props) in shortcuts {
206             let description = str_prop(&props, "description").unwrap_or_default();
207             // Without a preferred trigger there is nothing to bind to: a
208             // backend with a configuration dialog would ask the user here.
209             let Some(trigger) = str_prop(&props, "preferred_trigger") else {
210                 log::warn!("{}: shortcut {:?} has no preferred_trigger; not bound", session, id);
211                 continue;
212             };
213             let cmd = format!("shortcut bind {} {} {}", session, encode_token(&id), encode_token(&trigger));
214             match ctl(&cmd).await {
215                 Ok(reply) if reply.starts_with("ok") => {
216                     let trigger_description = reply[2..].trim().to_string();
217                     log::info!("{}: bound {:?} to {}", session, id, trigger_description);
218                     bound.push(BoundShortcut(id, ShortcutProps { description, trigger_description }));
219                 }
220                 Ok(reply) => log::warn!("{}: compositor refused {:?} ({}): {}", session, id, trigger, reply.trim()),
221                 Err(e) => log::error!("control socket {}: {}", ctl_socket_path(), e),
222             }
223         }
224         if let Some(s) = self.shared.lock().await.sessions.get_mut(&session) {
225             s.bound = bound.clone();
226         }
227         (RESPONSE_OK, ShortcutsResults { shortcuts: bound })
228     }
229 
230     async fn list_shortcuts(&self, _handle: ObjectPath<'_>, session_handle: ObjectPath<'_>) -> (u32, ShortcutsResults) {
231         let session = OwnedObjectPath::from(session_handle);
232         let shortcuts = self
233             .shared
234             .lock()
235             .await
236             .sessions
237             .get(&session)
238             .map(|s| s.bound.clone())
239             .unwrap_or_default();
240         (RESPONSE_OK, ShortcutsResults { shortcuts })
241     }
242 
243     #[zbus(signal)]
244     async fn activated(
245         emitter: &SignalEmitter<'_>,
246         session_handle: ObjectPath<'_>,
247         shortcut_id: &str,
248         timestamp: u64,
249         options: HashMap<&str, Value<'_>>,
250     ) -> zbus::Result<()>;
251 
252     #[zbus(signal)]
253     async fn deactivated(
254         emitter: &SignalEmitter<'_>,
255         session_handle: ObjectPath<'_>,
256         shortcut_id: &str,
257         timestamp: u64,
258         options: HashMap<&str, Value<'_>>,
259     ) -> zbus::Result<()>;
260 
261     #[zbus(signal)]
262     async fn shortcuts_changed(
263         emitter: &SignalEmitter<'_>,
264         session_handle: ObjectPath<'_>,
265         shortcuts: Vec<BoundShortcut>,
266     ) -> zbus::Result<()>;
267 }
268 
269 // ── org.freedesktop.impl.portal.Session ────────────────────────────────────
270 
271 /// One object per session, at the path the frontend chose. `Close` is how
272 /// the app (or its death, via the frontend) gives its chords back.
273 struct SessionObj {
274     path: OwnedObjectPath,
275     shared: SharedState,
276 }
277 
278 #[interface(name = "org.freedesktop.impl.portal.Session")]
279 impl SessionObj {
280     #[zbus(property)]
281     fn version(&self) -> u32 {
282         1
283     }
284 
285     async fn close(&self, #[zbus(connection)] conn: &Connection) {
286         log::info!("Close {}", self.path);
287         if let Err(e) = ctl(&format!("shortcut unbind {}", self.path)).await {
288             log::warn!("control socket {}: {}", ctl_socket_path(), e);
289         }
290         self.shared.lock().await.sessions.remove(&self.path);
291         // The object cannot remove itself from inside its own method call
292         // (the server holds it locked for the call), so it goes on the
293         // next turn of the loop.
294         let conn = conn.clone();
295         let path = self.path.clone();
296         tokio::spawn(async move {
297             if let Err(e) = conn.object_server().remove::<SessionObj, _>(&path).await {
298                 log::warn!("removing session object {}: {}", path, e);
299             }
300         });
301     }
302 
303     #[zbus(signal)]
304     async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
305 }
306 
307 // ── main ───────────────────────────────────────────────────────────────────
308 
309 #[tokio::main]
310 async fn main() {
311     env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
312     if let Err(e) = run().await {
313         log::error!("{e}");
314         std::process::exit(1);
315     }
316 }
317 
318 async fn run() -> Result<(), Box<dyn std::error::Error>> {
319     // A predecessor that died mid-session leaves its chords bound in the
320     // compositor; nobody will ever hear them fire, so drop them before
321     // taking the bus name. This also proves the compositor speaks the
322     // command at all: an older one answers `error: unknown command`, and
323     // then this backend must not claim to serve anything.
324     let reply = ctl("shortcut clear")
325         .await
326         .map_err(|e| format!("control socket {}: {e}", ctl_socket_path()))?;
327     if !reply.starts_with("ok") {
328         return Err(format!("compositor does not support portal shortcuts: {}", reply.trim()).into());
329     }
330 
331     // Subscribe to the press/release feed before serving, so a bind can
332     // never fire into a gap.
333     let status = tokio::net::UnixStream::connect(status_socket_path())
334         .await
335         .map_err(|e| format!("status socket {}: {e}", status_socket_path()))?;
336     let (reader, mut writer) = status.into_split();
337     writer.write_all(b"shortcuts\n").await?;
338 
339     let shared: SharedState = Arc::new(Mutex::new(Shared::default()));
340     let conn = zbus::connection::Builder::session()?
341         .name(BUS_NAME)?
342         .serve_at(OBJ_PATH, Backend { shared: shared.clone() })?
343         .build()
344         .await?;
345     log::info!("serving {} at {}", BUS_NAME, OBJ_PATH);
346 
347     let emitter = SignalEmitter::new(&conn, OBJ_PATH)?;
348     let mut lines = BufReader::new(reader).lines();
349     while let Some(line) = lines.next_line().await? {
350         let mut it = line.split_whitespace();
351         let (Some(kind), Some(session), Some(id)) = (it.next(), it.next(), it.next()) else {
352             log::warn!("unparseable status line {:?}", line);
353             continue;
354         };
355         let timestamp: u64 = it.next().and_then(|t| t.parse().ok()).unwrap_or(0);
356         let Ok(path) = ObjectPath::try_from(session) else {
357             log::warn!("bad session path in {:?}", line);
358             continue;
359         };
360         let id = decode_token(id);
361         let result = match kind {
362             "activated" => Backend::activated(&emitter, path, &id, timestamp, HashMap::new()).await,
363             "deactivated" => Backend::deactivated(&emitter, path, &id, timestamp, HashMap::new()).await,
364             other => {
365                 log::warn!("unknown shortcut event {:?}", other);
366                 Ok(())
367             }
368         };
369         if let Err(e) = result {
370             log::warn!("emitting {} for {}: {}", kind, session, e);
371         }
372     }
373     // The compositor closed the feed: every session is void with it.
374     log::info!("status socket closed; exiting");
375     Ok(())
376 }
377 
378 #[cfg(test)]
379 mod tests {
380     use super::*;
381 
382     #[test]
383     fn token_round_trip() {
384         for s in ["quick-access", "Quick Access", "a/b%c", "üñî", "", "x%2", "%"] {
385             let enc = encode_token(s);
386             assert!(!enc.contains(char::is_whitespace));
387             assert_eq!(decode_token(&enc), s);
388         }
389         assert_eq!(encode_token("CTRL+SHIFT+space"), "CTRL+SHIFT+space", "triggers pass through untouched");
390     }
391 }