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 }