git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

src/bin/cce-xembed-tray/sni.rs (12.1K)

  1 //! The D-Bus half: one StatusNotifierItem per docked icon.
  2 //!
  3 //! Each item gets its own session-bus connection and registers by object
  4 //! PATH, so the watcher records the connection's unique name as the item's
  5 //! address. That is what the status bar's watcher drops items on
  6 //! (`NameOwnerChanged` for a vanishing unique name), so closing an item's
  7 //! connection is the whole of unregistering it.
  8 
  9 use std::collections::HashMap;
 10 use std::sync::Arc;
 11 
 12 use tokio::sync::mpsc::{UnboundedReceiver, WeakUnboundedSender};
 13 use tokio_stream::StreamExt;
 14 
 15 use crate::x11::{ClickPoint, SniImage, TrayEvent, XHandle};
 16 
 17 const WATCHER: &str = "org.kde.StatusNotifierWatcher";
 18 const ITEM_PATH: &str = "/StatusNotifierItem";
 19 
 20 struct Item {
 21     icon: u32,
 22     title: String,
 23     image: SniImage,
 24     at: ClickPoint,
 25     x: Arc<XHandle>,
 26 }
 27 
 28 impl Item {
 29     /// `screen`: the host's click point, when it gave one (see
 30     /// `XHandle::click`).
 31     fn click(&self, button: u8, screen: Option<(i32, i32)>) {
 32         if let Err(e) = self.x.click(self.icon, button, self.at, screen) {
 33             log::warn!("click on {:#x} failed: {e}", self.icon);
 34         }
 35     }
 36 }
 37 
 38 #[zbus::interface(name = "org.kde.StatusNotifierItem")]
 39 impl Item {
 40     #[zbus(property)]
 41     fn category(&self) -> &str {
 42         "ApplicationStatus"
 43     }
 44 
 45     #[zbus(property)]
 46     fn id(&self) -> String {
 47         format!("xembed-{:x}", self.icon)
 48     }
 49 
 50     #[zbus(property)]
 51     fn title(&self) -> &str {
 52         &self.title
 53     }
 54 
 55     #[zbus(property)]
 56     fn status(&self) -> &str {
 57         "Active"
 58     }
 59 
 60     #[zbus(property)]
 61     fn window_id(&self) -> u32 {
 62         0
 63     }
 64 
 65     #[zbus(property)]
 66     fn icon_name(&self) -> &str {
 67         ""
 68     }
 69 
 70     #[zbus(property)]
 71     fn icon_pixmap(&self) -> Vec<SniImage> {
 72         vec![self.image.clone()]
 73     }
 74 
 75     #[zbus(property)]
 76     fn tool_tip(&self) -> (String, Vec<SniImage>, String, String) {
 77         (String::new(), Vec::new(), self.title.clone(), String::new())
 78     }
 79 
 80     /// Always false, and there is deliberately no `Menu` property: an
 81     /// XEmbed icon draws its own menu, which a right-click (ContextMenu)
 82     /// reaches. The bar fetches a D-Bus menu instead whenever `Menu` exists.
 83     #[zbus(property)]
 84     fn item_is_menu(&self) -> bool {
 85         false
 86     }
 87 
 88     fn activate(&self, x: i32, y: i32) {
 89         self.click(1, Some((x, y)));
 90     }
 91 
 92     fn secondary_activate(&self, x: i32, y: i32) {
 93         self.click(2, Some((x, y)));
 94     }
 95 
 96     fn context_menu(&self, x: i32, y: i32) {
 97         self.click(3, Some((x, y)));
 98     }
 99 
100     fn scroll(&self, delta: i32, orientation: &str) {
101         let horizontal = orientation.eq_ignore_ascii_case("horizontal");
102         let button = match (horizontal, delta > 0) {
103             (false, true) => 5,
104             (false, false) => 4,
105             (true, true) => 7,
106             (true, false) => 6,
107         };
108         self.click(button, None);
109     }
110 
111     #[zbus(signal)]
112     async fn new_icon(ctxt: &zbus::SignalContext<'_>) -> zbus::Result<()>;
113 
114     #[zbus(signal)]
115     async fn new_title(ctxt: &zbus::SignalContext<'_>) -> zbus::Result<()>;
116 
117     #[zbus(signal)]
118     async fn new_tool_tip(ctxt: &zbus::SignalContext<'_>) -> zbus::Result<()>;
119 }
120 
121 /// An icon the X side reported. It is published once it has pixels: an
122 /// icon that has not drawn yet would be an empty slot in the bar.
123 enum Entry {
124     Pending { title: String, at: ClickPoint },
125     Published(zbus::Connection),
126 }
127 
128 async fn register(conn: &zbus::Connection) {
129     let reply = conn
130         .call_method(Some(WATCHER), "/StatusNotifierWatcher", Some(WATCHER), "RegisterStatusNotifierItem", &(ITEM_PATH,))
131         .await;
132     if let Err(e) = reply {
133         // No watcher yet: the NameOwnerChanged arm registers everything
134         // once one appears.
135         log::debug!("register with {WATCHER} failed: {e}");
136     }
137 }
138 
139 async fn publish(x: &Arc<XHandle>, icon: u32, title: String, image: SniImage, at: ClickPoint) -> zbus::Result<zbus::Connection> {
140     let item = Item { icon, title, image, at, x: x.clone() };
141     let conn = zbus::ConnectionBuilder::session()?.serve_at(ITEM_PATH, item)?.build().await?;
142     register(&conn).await;
143     log::info!("published {icon:#x} as {}", conn.unique_name().map(|n| n.to_string()).unwrap_or_default());
144     Ok(conn)
145 }
146 
147 async fn update(conn: &zbus::Connection, image: Option<SniImage>, title: Option<String>) -> zbus::Result<()> {
148     let iface = conn.object_server().interface::<_, Item>(ITEM_PATH).await?;
149     {
150         let mut item = iface.get_mut().await;
151         if let Some(image) = image.clone() {
152             item.image = image;
153         }
154         if let Some(title) = title.clone() {
155             item.title = title;
156         }
157     }
158     let ctxt = iface.signal_context();
159     if image.is_some() {
160         Item::new_icon(ctxt).await?;
161     }
162     if title.is_some() {
163         Item::new_title(ctxt).await?;
164         Item::new_tool_tip(ctxt).await?;
165     }
166     Ok(())
167 }
168 
169 /// Delays after docking at which an icon whose name was only a fallback
170 /// (no titled app window yet — the app is often still starting) is looked
171 /// up again. Bounded: an app that never titles a window keeps the fallback.
172 const TITLE_RETRIES_SECS: &[u64] = &[2, 5, 10, 30];
173 
174 /// Look the icon's name up again on the retry schedule, feeding a settled
175 /// one back through the event queue as an ordinary title change.
176 fn retry_title(x: Arc<XHandle>, icon: u32, fallback: String, events: WeakUnboundedSender<TrayEvent>) {
177     tokio::spawn(async move {
178         let mut waited = 0;
179         for &at in TITLE_RETRIES_SECS {
180             tokio::time::sleep(std::time::Duration::from_secs(at - waited)).await;
181             waited = at;
182             let x = x.clone();
183             let Ok(title) = tokio::task::spawn_blocking(move || x.title(icon)).await else {
184                 return;
185             };
186             if title.settled {
187                 if title.text != fallback {
188                     log::info!("named {icon:#x} {:?} after {at}s", title.text);
189                     if let Some(events) = events.upgrade() {
190                         let _ = events.send(TrayEvent::Title { icon, title: title.text });
191                     }
192                 }
193                 return;
194             }
195         }
196     });
197 }
198 
199 /// The compositor's status socket, as the bar finds it.
200 fn status_socket_path() -> String {
201     let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
202     let primary = format!("/tmp/cce-status-interface-{display}.sock");
203     if std::path::Path::new(&primary).exists() {
204         primary
205     } else {
206         format!("/tmp/cce-status-{display}.sock")
207     }
208 }
209 
210 /// Follow the compositor's `clickaway` topic — a press that landed on no
211 /// X11 surface while an X11 popup was up — and close the popup a forwarded
212 /// click opened (`XHandle::dismiss_popup`). Reconnects when the compositor
213 /// goes away; a compositor without the topic simply never sends a line.
214 fn follow_click_aways(x: Arc<XHandle>) {
215     tokio::spawn(async move {
216         use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
217         loop {
218             if let Ok(mut stream) = tokio::net::UnixStream::connect(status_socket_path()).await {
219                 if stream.write_all(b"clickaway\n").await.is_ok() {
220                     let mut lines = BufReader::new(stream).lines();
221                     while let Ok(Some(_press)) = lines.next_line().await {
222                         let x = x.clone();
223                         let _ = tokio::task::spawn_blocking(move || x.dismiss_popup()).await;
224                     }
225                 }
226             }
227             tokio::time::sleep(std::time::Duration::from_secs(2)).await;
228         }
229     });
230 }
231 
232 /// Mirror the X side's icons onto the bus until it stops (its channel
233 /// closes) or the process is told to exit; then hand the icons back.
234 /// `requeue` feeds the same channel, for the late title lookups. It is
235 /// weak so that the X side dropping its sender still closes the channel:
236 /// that is how this loop learns the X side has stopped.
237 pub async fn run(
238     x: Arc<XHandle>,
239     mut events: UnboundedReceiver<TrayEvent>,
240     requeue: WeakUnboundedSender<TrayEvent>,
241 ) -> zbus::Result<()> {
242     let control = zbus::Connection::session().await?;
243     let dbus = zbus::fdo::DBusProxy::new(&control).await?;
244     let mut watcher_owner = dbus.receive_name_owner_changed_with_args(&[(0, WATCHER)]).await?;
245     let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
246         .map_err(|e| zbus::Error::Failure(e.to_string()))?;
247     let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
248         .map_err(|e| zbus::Error::Failure(e.to_string()))?;
249 
250     follow_click_aways(x.clone());
251 
252     let mut entries: HashMap<u32, Entry> = HashMap::new();
253     loop {
254         tokio::select! {
255             event = events.recv() => {
256                 let Some(event) = event else { break };
257                 match event {
258                     TrayEvent::Docked { icon, title, title_settled, image, at } => {
259                         if !title_settled {
260                             retry_title(x.clone(), icon, title.clone(), requeue.clone());
261                         }
262                         let entry = match image {
263                             Some(image) => match publish(&x, icon, title.clone(), image, at).await {
264                                 Ok(conn) => Entry::Published(conn),
265                                 Err(e) => {
266                                     log::warn!("publishing {icon:#x} failed: {e}");
267                                     Entry::Pending { title, at }
268                                 }
269                             },
270                             None => Entry::Pending { title, at },
271                         };
272                         entries.insert(icon, entry);
273                     }
274                     TrayEvent::Image { icon, image } => match entries.remove(&icon) {
275                         Some(Entry::Pending { title, at }) => {
276                             let entry = match publish(&x, icon, title.clone(), image, at).await {
277                                 Ok(conn) => Entry::Published(conn),
278                                 Err(e) => {
279                                     log::warn!("publishing {icon:#x} failed: {e}");
280                                     Entry::Pending { title, at }
281                                 }
282                             };
283                             entries.insert(icon, entry);
284                         }
285                         Some(Entry::Published(conn)) => {
286                             if let Err(e) = update(&conn, Some(image), None).await {
287                                 log::warn!("icon update for {icon:#x} failed: {e}");
288                             }
289                             entries.insert(icon, Entry::Published(conn));
290                         }
291                         None => {}
292                     },
293                     TrayEvent::Title { icon, title } => match entries.get_mut(&icon) {
294                         Some(Entry::Pending { title: t, .. }) => *t = title,
295                         Some(Entry::Published(conn)) => {
296                             if let Err(e) = update(conn, None, Some(title)).await {
297                                 log::warn!("title update for {icon:#x} failed: {e}");
298                             }
299                         }
300                         None => {}
301                     },
302                     // Dropping the connection releases its unique name,
303                     // which is what removes the item from the bar.
304                     TrayEvent::Undocked { icon } => {
305                         entries.remove(&icon);
306                     }
307                 }
308             }
309             Some(change) = watcher_owner.next() => {
310                 // A (re)started status bar: register every item with it.
311                 let appeared = change.args().map(|a| a.new_owner.is_some()).unwrap_or(false);
312                 if appeared {
313                     for entry in entries.values() {
314                         if let Entry::Published(conn) = entry {
315                             register(conn).await;
316                         }
317                     }
318                 }
319             }
320             _ = sigterm.recv() => break,
321             _ = sigint.recv() => break,
322         }
323     }
324     x.release(entries.keys().copied());
325     Ok(())
326 }