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 }