feat: initial implementation of xembed-sni-proxy

Lightweight Rust binary that bridges XEmbed tray icons (Wine/Proton)
to StatusNotifierItem D-Bus objects for Waybar on Wayland compositors.

- Claim _NET_SYSTEM_TRAY_S0 selection and handle dock requests
- Per-icon container with MANUAL composite redirect (invisible on XWayland)
- Pixel capture via get_image with BGRA→ARGB conversion
- SNI D-Bus interface with IconPixmap, Activate, ContextMenu
- Minimal com.canonical.dbusmenu stub for Waybar right-click support
- XTest fake_input click injection (works on XWayland unlike send_event)
- Dynamic icon size detection from client geometry
- Graceful shutdown with selection release and proxy cleanup
This commit is contained in:
2026-03-02 15:00:26 +01:00
commit 548adb6bbb
7 changed files with 2303 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target

1191
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "xembed-sni-proxy"
version = "0.1.0"
edition = "2021"
description = "Lightweight XEmbed-to-SNI proxy for Wayland compositors"
[dependencies]
x11rb = { version = "0.13", features = ["composite", "damage", "xtest"] }
zbus = { version = "5", default-features = false, features = ["tokio"] }
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[profile.release]
lto = true
strip = true

96
src/main.rs Normal file
View File

@@ -0,0 +1,96 @@
mod sni_dbus;
mod sni_proxy;
mod tray_manager;
use std::os::fd::AsRawFd;
use tokio::io::unix::AsyncFd;
use tokio::io::Interest;
use tokio::signal::unix::{signal, SignalKind};
use tokio::sync::mpsc;
use tracing::{error, info};
use x11rb::connection::Connection;
use x11rb::rust_connection::RustConnection;
use tray_manager::TrayManager;
/// Commands sent from D-Bus method handlers back to the X11 event loop.
#[derive(Debug)]
pub enum DbusCommand {
/// Inject a click on a tray icon's client window.
Click {
client_window: u32,
x: i32,
y: i32,
button: u8,
},
}
/// Newtype wrapper to give the X11 stream fd a 'static-compatible lifetime for AsyncFd.
struct XFd(std::os::fd::OwnedFd);
impl AsRawFd for XFd {
fn as_raw_fd(&self) -> std::os::fd::RawFd {
self.0.as_raw_fd()
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "xembed_sni_proxy=info".into()),
)
.init();
let (conn, screen_num) = RustConnection::connect(None)?;
info!("connected to X11 display, screen {screen_num}");
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<DbusCommand>();
let mut manager = TrayManager::new(&conn, screen_num, cmd_tx)?;
manager.claim_selection()?;
// Wrap the X11 fd for async polling.
// We dup() the fd so AsyncFd owns it independently of the connection.
use std::os::fd::AsFd;
let owned_fd = conn.stream().as_fd().try_clone_to_owned()?;
let async_fd = AsyncFd::with_interest(XFd(owned_fd), Interest::READABLE)?;
let mut sigint = signal(SignalKind::interrupt())?;
let mut sigterm = signal(SignalKind::terminate())?;
info!("entering event loop");
loop {
tokio::select! {
// X11 events
guard = async_fd.readable() => {
let mut guard = guard?;
// Drain all pending X11 events.
while let Some(event) = conn.poll_for_event()? {
if let Err(e) = manager.handle_event(&event).await {
error!("event handling error: {e}");
}
}
guard.clear_ready();
}
// D-Bus commands (click injection)
Some(cmd) = cmd_rx.recv() => {
manager.handle_dbus_command(&cmd);
}
// Graceful shutdown
_ = sigint.recv() => {
info!("received SIGINT, shutting down");
break;
}
_ = sigterm.recv() => {
info!("received SIGTERM, shutting down");
break;
}
}
}
manager.shutdown().await;
info!("clean exit");
Ok(())
}

378
src/sni_dbus.rs Normal file
View File

@@ -0,0 +1,378 @@
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, info, warn};
use zbus::object_server::SignalEmitter;
use zbus::zvariant::{ObjectPath, Value};
use zbus::{connection::Builder, interface, Connection};
use crate::DbusCommand;
/// Icon pixel data in SNI format: (width, height, ARGB_bytes).
type IconPixmap = (i32, i32, Vec<u8>);
/// Shared state for a single SNI icon, updated from the X11 side.
struct SniState {
icon_pixmap: IconPixmap,
client_window: u32,
cmd_tx: mpsc::UnboundedSender<DbusCommand>,
}
// ---------------------------------------------------------------------------
// StatusNotifierItem interface
// ---------------------------------------------------------------------------
struct StatusNotifierItem {
state: Arc<Mutex<SniState>>,
}
#[interface(name = "org.kde.StatusNotifierItem")]
impl StatusNotifierItem {
#[zbus(property)]
async fn category(&self) -> &str {
"ApplicationStatus"
}
#[zbus(property)]
async fn id(&self) -> String {
let state = self.state.lock().await;
format!("xembed-proxy-{:x}", state.client_window)
}
#[zbus(property)]
async fn title(&self) -> &str {
"XEmbed Tray Icon"
}
#[zbus(property)]
async fn status(&self) -> &str {
"Active"
}
#[zbus(property)]
async fn window_id(&self) -> u32 {
let state = self.state.lock().await;
state.client_window
}
#[zbus(property, name = "IconName")]
async fn icon_name(&self) -> &str {
""
}
#[zbus(property, name = "IconPixmap")]
async fn icon_pixmap(&self) -> Vec<(i32, i32, Vec<u8>)> {
let state = self.state.lock().await;
vec![state.icon_pixmap.clone()]
}
#[zbus(property, name = "OverlayIconName")]
async fn overlay_icon_name(&self) -> &str {
""
}
#[zbus(property, name = "OverlayIconPixmap")]
async fn overlay_icon_pixmap(&self) -> Vec<(i32, i32, Vec<u8>)> {
vec![]
}
#[zbus(property, name = "AttentionIconName")]
async fn attention_icon_name(&self) -> &str {
""
}
#[zbus(property, name = "AttentionIconPixmap")]
async fn attention_icon_pixmap(&self) -> Vec<(i32, i32, Vec<u8>)> {
vec![]
}
#[zbus(property, name = "AttentionMovieName")]
async fn attention_movie_name(&self) -> &str {
""
}
#[zbus(property, name = "ToolTip")]
async fn tool_tip(&self) -> (&str, Vec<(i32, i32, Vec<u8>)>, &str, &str) {
("", vec![], "", "")
}
#[zbus(property, name = "IconThemePath")]
async fn icon_theme_path(&self) -> &str {
""
}
#[zbus(property, name = "Menu")]
async fn menu(&self) -> ObjectPath<'_> {
ObjectPath::try_from("/MenuBar").unwrap()
}
#[zbus(property, name = "ItemIsMenu")]
async fn item_is_menu(&self) -> bool {
false
}
async fn activate(&self, x: i32, y: i32) {
self.send_click(x, y, 1);
}
async fn secondary_activate(&self, x: i32, y: i32) {
self.send_click(x, y, 2);
}
async fn context_menu(&self, x: i32, y: i32) {
self.send_click(x, y, 3);
}
async fn scroll(&self, _delta: i32, _orientation: &str) {}
#[zbus(signal)]
async fn new_icon(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
#[zbus(signal)]
async fn new_title(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
#[zbus(signal)]
async fn new_status(emitter: &SignalEmitter<'_>, status: &str) -> zbus::Result<()>;
}
impl StatusNotifierItem {
fn send_click(&self, x: i32, y: i32, button: u8) {
let state = self.state.clone();
tokio::spawn(async move {
let s = state.lock().await;
let _ = s.cmd_tx.send(DbusCommand::Click {
client_window: s.client_window,
x,
y,
button,
});
});
}
}
// ---------------------------------------------------------------------------
// Minimal com.canonical.dbusmenu interface
// ---------------------------------------------------------------------------
// Waybar uses dbusmenu for right-click context menus — it never calls
// ContextMenu() on the SNI item. We implement a stub that injects button-3
// into the Wine app when Waybar is about to show the popup. Wine then opens
// its own native X11 context menu at the cursor position.
/// com.canonical.dbusmenu layout item: (id, properties, children)
type MenuItem = (
i32,
std::collections::HashMap<String, Value<'static>>,
Vec<Value<'static>>,
);
struct DbusMenu {
state: Arc<Mutex<SniState>>,
}
#[interface(name = "com.canonical.dbusmenu")]
impl DbusMenu {
async fn about_to_show(&self, _id: i32) -> bool {
false
}
/// Waybar calls AboutToShowGroup on right-click. We inject button-3
/// into the Wine app so it opens its native X11 context menu.
async fn about_to_show_group(&self, _ids: Vec<i32>) -> (Vec<i32>, Vec<i32>) {
debug!("AboutToShowGroup: injecting right-click");
let s = self.state.lock().await;
let _ = s.cmd_tx.send(DbusCommand::Click {
client_window: s.client_window,
x: 0,
y: 0,
button: 3,
});
(vec![], vec![])
}
/// Waybar calls GetLayout at registration to probe the menu.
/// Return an empty invisible root — Wine handles menus natively.
async fn get_layout(
&self,
_parent_id: i32,
_recursion_depth: i32,
_property_names: Vec<String>,
) -> (u32, MenuItem) {
let mut props = std::collections::HashMap::new();
props.insert("visible".to_string(), Value::from(false));
let root: MenuItem = (0, props, vec![]);
(1, root)
}
async fn get_group_properties(
&self,
_ids: Vec<i32>,
_property_names: Vec<String>,
) -> Vec<(i32, std::collections::HashMap<String, Value<'static>>)> {
vec![]
}
async fn get_property(&self, _id: i32, _name: &str) -> Value<'static> {
Value::new(String::new())
}
async fn event(
&self,
_id: i32,
_event_id: &str,
_data: Value<'_>,
_timestamp: u32,
) {
}
async fn event_group(
&self,
_events: Vec<(i32, String, Value<'_>, u32)>,
) -> Vec<i32> {
vec![]
}
// --- Signals (required by the interface) ---
#[zbus(signal)]
async fn items_properties_updated(
emitter: &SignalEmitter<'_>,
updated_props: &[(i32, std::collections::HashMap<&str, Value<'_>>)],
removed_props: &[(i32, Vec<&str>)],
) -> zbus::Result<()>;
#[zbus(signal)]
async fn layout_updated(
emitter: &SignalEmitter<'_>,
revision: u32,
parent: i32,
) -> zbus::Result<()>;
#[zbus(signal)]
async fn item_activation_requested(
emitter: &SignalEmitter<'_>,
id: i32,
timestamp: u32,
) -> zbus::Result<()>;
// --- Properties ---
#[zbus(property)]
async fn version(&self) -> u32 {
3
}
#[zbus(property, name = "TextDirection")]
async fn text_direction(&self) -> &str {
"ltr"
}
#[zbus(property)]
async fn status(&self) -> &str {
"normal"
}
#[zbus(property, name = "IconThemePath")]
async fn icon_theme_path(&self) -> Vec<String> {
vec![]
}
}
// ---------------------------------------------------------------------------
// Handle + registration
// ---------------------------------------------------------------------------
pub struct SniHandle {
connection: Connection,
state: Arc<Mutex<SniState>>,
}
impl SniHandle {
pub async fn update_icon(&self, icon_data: IconPixmap) {
{
let mut state = self.state.lock().await;
state.icon_pixmap = icon_data;
}
let iface_ref = self
.connection
.object_server()
.interface::<_, StatusNotifierItem>("/StatusNotifierItem")
.await;
match iface_ref {
Ok(iface) => {
let emitter = iface.signal_emitter();
if let Err(e) = StatusNotifierItem::new_icon(&emitter).await {
debug!("failed to emit NewIcon: {e}");
}
if let Err(e) = iface.get().await.icon_pixmap_changed(&emitter).await {
debug!("failed to emit icon_pixmap property change: {e}");
}
}
Err(e) => {
debug!("could not get interface ref: {e}");
}
}
}
pub async fn shutdown(self) {
debug!("D-Bus connection for SNI icon shutting down");
}
}
pub async fn register_sni(
client_window: u32,
initial_icon: IconPixmap,
cmd_tx: mpsc::UnboundedSender<DbusCommand>,
) -> Result<SniHandle, Box<dyn std::error::Error>> {
let state = Arc::new(Mutex::new(SniState {
icon_pixmap: initial_icon,
client_window,
cmd_tx,
}));
let sni = StatusNotifierItem {
state: state.clone(),
};
let dbusmenu = DbusMenu {
state: state.clone(),
};
let connection = Builder::session()?
.serve_at("/StatusNotifierItem", sni)?
.serve_at("/MenuBar", dbusmenu)?
.build()
.await?;
let bus_name = connection
.unique_name()
.map(|n| n.to_string())
.unwrap_or_default();
info!("registered SNI on D-Bus as {bus_name} for client 0x{client_window:x}");
register_with_watcher(&connection, &bus_name).await;
Ok(SniHandle { connection, state })
}
async fn register_with_watcher(connection: &Connection, bus_name: &str) {
let result: Result<(), _> = connection
.call_method(
Some("org.kde.StatusNotifierWatcher"),
"/StatusNotifierWatcher",
Some("org.kde.StatusNotifierWatcher"),
"RegisterStatusNotifierItem",
&bus_name,
)
.await
.map(|_| ());
match result {
Ok(()) => {
info!("registered {bus_name} with StatusNotifierWatcher");
}
Err(e) => {
warn!("could not register with StatusNotifierWatcher (is Waybar running?): {e}");
}
}
}

357
src/sni_proxy.rs Normal file
View File

@@ -0,0 +1,357 @@
use tokio::sync::mpsc;
use tracing::{debug, info};
use x11rb::connection::Connection;
use x11rb::protocol::composite::ConnectionExt as _;
use x11rb::protocol::damage::{self, ConnectionExt as _};
use x11rb::protocol::xproto::*;
use x11rb::protocol::xtest::ConnectionExt as _;
use x11rb::rust_connection::RustConnection;
use x11rb::CURRENT_TIME;
use crate::sni_dbus::{self, SniHandle};
use crate::tray_manager::Atoms;
use crate::DbusCommand;
const DEFAULT_ICON_SIZE: u16 = 64;
// XEMBED message constants.
const XEMBED_EMBEDDED_NOTIFY: u32 = 0;
const XEMBED_VERSION: u32 = 0;
pub struct SniProxy {
conn: *const RustConnection,
container: Window,
client_window: Window,
icon_size: u16,
damage: Option<damage::Damage>,
sni_handle: SniHandle,
}
// Safety: RustConnection is Send+Sync, and we only use it on the single-threaded runtime.
unsafe impl Send for SniProxy {}
/// Determine the icon size for a client window.
/// Reads the client's actual geometry and WM_NORMAL_HINTS to find its preferred size.
/// Falls back to DEFAULT_ICON_SIZE, overridable via TRAY_ICON_SIZE env var.
fn determine_icon_size(conn: &RustConnection, client_window: Window) -> u16 {
// Env var override takes priority.
if let Ok(val) = std::env::var("TRAY_ICON_SIZE") {
if let Ok(size) = val.parse::<u16>() {
if size > 0 {
debug!("using TRAY_ICON_SIZE={size} from environment");
return size;
}
}
}
// Try reading the client's current geometry.
if let Some(geom) = conn
.get_geometry(client_window)
.ok()
.and_then(|c| c.reply().ok())
{
let size = geom.width.max(geom.height);
// Only trust it if it's a reasonable icon size (not 1x1 placeholder).
if size >= 8 {
debug!(
"client 0x{client_window:x} native size {w}x{h}, using {size}",
w = geom.width,
h = geom.height,
);
return size;
}
}
DEFAULT_ICON_SIZE
}
impl SniProxy {
pub async fn new(
conn: &RustConnection,
screen_num: usize,
client_window: Window,
atoms: &Atoms,
cmd_tx: mpsc::UnboundedSender<DbusCommand>,
) -> Result<Self, Box<dyn std::error::Error>> {
let screen = &conn.setup().roots[screen_num];
let icon_size = determine_icon_size(conn, client_window);
info!("icon size for 0x{client_window:x}: {icon_size}x{icon_size}");
// Create container window for the client. KDE's approach: use MANUAL
// composite redirect so the X server never renders the window on screen —
// pixels only exist in the offscreen pixmap. Plus stack below + opacity 0
// as defense in depth.
let container = conn.generate_id()?;
conn.create_window(
0,
container,
screen.root,
-2000,
-2000,
icon_size,
icon_size,
0,
WindowClass::INPUT_OUTPUT,
screen.root_visual,
&CreateWindowAux::default()
.override_redirect(1)
.background_pixel(0),
)?;
// Stack below all other windows.
conn.configure_window(
container,
&ConfigureWindowAux::default().stack_mode(StackMode::BELOW),
)?;
// Map the container (must be mapped for the client to be viewable).
conn.map_window(container)?;
// Reparent client into our container.
conn.reparent_window(client_window, container, 0, 0)?;
// Resize client to fill container.
conn.configure_window(
client_window,
&ConfigureWindowAux::default()
.width(u32::from(icon_size))
.height(u32::from(icon_size)),
)?;
// Map the client window.
conn.map_window(client_window)?;
// MANUAL redirect: the window is NOT rendered on screen by the X server.
// Pixels only go to the offscreen composite pixmap, which we read via
// get_image. This is how KDE's xembedsniproxy hides the container.
conn.composite_redirect_window(
client_window,
x11rb::protocol::composite::Redirect::MANUAL,
)?;
// Create damage tracking on the client.
let damage_id = conn.generate_id()?;
conn.damage_create(
damage_id,
client_window,
damage::ReportLevel::NON_EMPTY,
)?;
// Send XEMBED_EMBEDDED_NOTIFY to the client.
let event = ClientMessageEvent::new(
32,
client_window,
atoms.xembed,
[
CURRENT_TIME,
XEMBED_EMBEDDED_NOTIFY,
0, // detail
container,
XEMBED_VERSION,
],
);
conn.send_event(false, client_window, EventMask::NO_EVENT, event)?;
conn.flush()?;
info!(
"created proxy: client=0x{client_window:x} container=0x{container:x} damage={damage_id}"
);
// Do initial icon capture.
let icon_data = capture_icon(conn, client_window, icon_size);
// Spawn a D-Bus connection for this icon.
let sni_handle =
sni_dbus::register_sni(client_window, icon_data, cmd_tx).await?;
let proxy = Self {
conn: conn as *const RustConnection,
container,
client_window,
icon_size,
damage: Some(damage_id),
sni_handle,
};
Ok(proxy)
}
fn conn(&self) -> &RustConnection {
// Safety: lifetime tied to TrayManager which holds the connection.
unsafe { &*self.conn }
}
pub fn container(&self) -> Window {
self.container
}
pub fn damage_id(&self) -> Option<u32> {
self.damage
}
/// Re-capture the icon pixels and emit a NewIcon D-Bus signal.
pub async fn update_icon(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn();
// Subtract damage so we get notified again next time.
if let Some(damage_id) = self.damage {
conn.damage_subtract(damage_id, 0u32, 0u32)?;
}
let icon_data = capture_icon(conn, self.client_window, self.icon_size);
self.sni_handle.update_icon(icon_data).await;
Ok(())
}
/// Inject a click using XTest fake_input. On XWayland, send_event synthetic
/// events aren't delivered to Wine apps. XTest generates events
/// indistinguishable from real hardware input.
///
/// Because the container lives at (-2000, -2000), XWayland has no valid
/// Wayland surface for it and pointer warping is a no-op. We temporarily
/// move the container to a real screen position, inject the click, then
/// move it back.
pub fn inject_click(
&self,
_x: i32,
_y: i32,
button: u8,
) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn();
let root = conn.setup().roots[0].root;
// Save current pointer position.
let pointer = conn.query_pointer(root)?.reply()?;
// Move container to the cursor position so XWayland has a valid
// surface for pointer input, and so Wine opens menus at the cursor.
let half = i32::from(self.icon_size) / 2;
conn.configure_window(
self.container,
&ConfigureWindowAux::default()
.x(i32::from(pointer.root_x) - half)
.y(i32::from(pointer.root_y) - half)
.stack_mode(StackMode::ABOVE),
)?;
// Round-trip: ensure XWayland has processed the configure before
// we try to warp the pointer to the new surface position.
conn.get_input_focus()?.reply()?;
// Warp pointer to the center of the container (the Wayland surface).
let half = (self.icon_size / 2) as i16;
conn.warp_pointer(0u32, self.container, 0, 0, 0, 0, half, half)?;
// XTest fake button press + release.
conn.xtest_fake_input(
BUTTON_PRESS_EVENT,
button,
CURRENT_TIME,
root,
0,
0,
0,
)?;
conn.xtest_fake_input(
BUTTON_RELEASE_EVENT,
button,
CURRENT_TIME,
root,
0,
0,
0,
)?;
// Warp pointer back to original position.
conn.warp_pointer(0u32, root, 0, 0, 0, 0, pointer.root_x, pointer.root_y)?;
// Move container back off-screen and re-lower it.
conn.configure_window(
self.container,
&ConfigureWindowAux::default()
.x(-2000)
.y(-2000)
.stack_mode(StackMode::BELOW),
)?;
conn.flush()?;
debug!(
"xtest button {button} on 0x{:x} via container (pointer restored to {}, {})",
self.client_window, pointer.root_x, pointer.root_y
);
Ok(())
}
pub async fn shutdown(self) {
let conn = self.conn();
if let Some(damage_id) = self.damage {
let _ = conn.damage_destroy(damage_id);
}
let _ = conn.reparent_window(
self.client_window,
conn.setup().roots[0].root,
0,
0,
);
let _ = conn.destroy_window(self.container);
let _ = conn.flush();
self.sni_handle.shutdown().await;
info!("proxy for 0x{:x} shut down", self.client_window);
}
}
/// Capture the client window's pixels and convert BGRA → ARGB (network byte order).
/// Returns (width, height, argb_data) or transparent fallback on failure.
fn capture_icon(conn: &RustConnection, window: Window, icon_size: u16) -> (i32, i32, Vec<u8>) {
let size = i32::from(icon_size);
let cookie = match conn.get_image(
ImageFormat::Z_PIXMAP,
window,
0,
0,
icon_size,
icon_size,
!0, // all planes
) {
Ok(c) => c,
Err(e) => {
debug!("get_image request failed for 0x{window:x}: {e}");
return (size, size, vec![0u8; (size * size * 4) as usize]);
}
};
let reply = match cookie.reply() {
Ok(reply) => reply,
Err(e) => {
debug!("get_image failed for 0x{window:x}: {e}");
return (size, size, vec![0u8; (size * size * 4) as usize]);
}
};
let data = &reply.data;
let pixel_count = (size * size) as usize;
let mut argb = Vec::with_capacity(pixel_count * 4);
// X11 ZPixmap on little-endian: bytes are B, G, R, A (or X).
// SNI IconPixmap expects ARGB in network byte order (big-endian): A, R, G, B.
for i in 0..pixel_count {
let off = i * 4;
if off + 3 < data.len() {
let b = data[off];
let g = data[off + 1];
let r = data[off + 2];
let a = data[off + 3];
argb.push(a);
argb.push(r);
argb.push(g);
argb.push(b);
} else {
argb.extend_from_slice(&[0, 0, 0, 0]);
}
}
(size, size, argb)
}

263
src/tray_manager.rs Normal file
View File

@@ -0,0 +1,263 @@
use std::collections::HashMap;
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
use x11rb::connection::{Connection, RequestConnection};
use x11rb::protocol::composite::ConnectionExt as _;
use x11rb::protocol::damage::{self, ConnectionExt as _};
use x11rb::protocol::xproto::*;
use x11rb::protocol::Event;
use x11rb::rust_connection::RustConnection;
use x11rb::CURRENT_TIME;
use crate::sni_proxy::SniProxy;
use crate::DbusCommand;
/// X atoms we intern once at startup.
pub struct Atoms {
pub net_system_tray_s0: Atom,
pub net_system_tray_opcode: Atom,
pub manager: Atom,
pub xembed_info: Atom,
pub xembed: Atom,
}
impl Atoms {
fn intern(conn: &RustConnection) -> Result<Self, Box<dyn std::error::Error>> {
let cookies = (
conn.intern_atom(false, b"_NET_SYSTEM_TRAY_S0")?,
conn.intern_atom(false, b"_NET_SYSTEM_TRAY_OPCODE")?,
conn.intern_atom(false, b"MANAGER")?,
conn.intern_atom(false, b"_XEMBED_INFO")?,
conn.intern_atom(false, b"_XEMBED")?,
);
Ok(Self {
net_system_tray_s0: cookies.0.reply()?.atom,
net_system_tray_opcode: cookies.1.reply()?.atom,
manager: cookies.2.reply()?.atom,
xembed_info: cookies.3.reply()?.atom,
xembed: cookies.4.reply()?.atom,
})
}
}
const SYSTEM_TRAY_REQUEST_DOCK: u32 = 0;
pub struct TrayManager<'a> {
conn: &'a RustConnection,
screen_num: usize,
owner_window: Window,
atoms: Atoms,
proxies: HashMap<Window, SniProxy>,
damage_to_client: HashMap<damage::Damage, Window>,
cmd_tx: mpsc::UnboundedSender<DbusCommand>,
}
impl<'a> TrayManager<'a> {
pub fn new(
conn: &'a RustConnection,
screen_num: usize,
cmd_tx: mpsc::UnboundedSender<DbusCommand>,
) -> Result<Self, Box<dyn std::error::Error>> {
// Verify extensions are available.
conn.composite_query_version(0, 4)?.reply()?;
let damage_reply = conn.damage_query_version(1, 1)?.reply()?;
debug!(
"damage extension v{}.{}",
damage_reply.major_version, damage_reply.minor_version
);
// Just verify the extension is present (we don't need the event base
// since x11rb parses DamageNotify into a proper Event variant).
conn.extension_information(damage::X11_EXTENSION_NAME)?
.ok_or("damage extension not available")?;
let atoms = Atoms::intern(conn)?;
// Create an invisible owner window for the selection.
let screen = &conn.setup().roots[screen_num];
let owner_window = conn.generate_id()?;
conn.create_window(
0, // depth: copy from parent
owner_window,
screen.root,
-1,
-1,
1,
1,
0,
WindowClass::INPUT_ONLY,
screen.root_visual,
&CreateWindowAux::default(),
)?;
conn.flush()?;
Ok(Self {
conn,
screen_num,
owner_window,
atoms,
proxies: HashMap::new(),
damage_to_client: HashMap::new(),
cmd_tx,
})
}
pub fn claim_selection(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let screen = &self.conn.setup().roots[self.screen_num];
self.conn.set_selection_owner(
self.owner_window,
self.atoms.net_system_tray_s0,
CURRENT_TIME,
)?;
// Verify we actually got the selection.
let owner = self
.conn
.get_selection_owner(self.atoms.net_system_tray_s0)?
.reply()?
.owner;
if owner != self.owner_window {
return Err(
"failed to claim _NET_SYSTEM_TRAY_S0 — another tray host is running".into(),
);
}
// Broadcast MANAGER client message so existing clients know about us.
let event = ClientMessageEvent::new(
32,
screen.root,
self.atoms.manager,
[
CURRENT_TIME,
self.atoms.net_system_tray_s0,
self.owner_window,
0,
0,
],
);
self.conn.send_event(
false,
screen.root,
EventMask::STRUCTURE_NOTIFY,
event,
)?;
self.conn.flush()?;
info!("claimed _NET_SYSTEM_TRAY_S0 selection");
Ok(())
}
pub async fn handle_event(
&mut self,
event: &Event,
) -> Result<(), Box<dyn std::error::Error>> {
match event {
Event::ClientMessage(ev) => {
if ev.type_ == self.atoms.net_system_tray_opcode && ev.format == 32 {
let opcode = ev.data.as_data32()[1];
let client_window = ev.data.as_data32()[2];
if opcode == SYSTEM_TRAY_REQUEST_DOCK && client_window != 0 {
info!("dock request from window 0x{client_window:x}");
self.dock(client_window).await?;
}
}
}
Event::DestroyNotify(ev) => {
if let Some(proxy) = self.proxies.remove(&ev.window) {
info!("client 0x{:x} destroyed, removing proxy", ev.window);
if let Some(damage_id) = proxy.damage_id() {
self.damage_to_client.remove(&damage_id);
}
proxy.shutdown().await;
}
}
Event::ReparentNotify(ev) => {
// Client reparented away from our container — treat as undock.
if let Some(proxy) = self.proxies.get(&ev.window) {
if ev.parent != proxy.container() {
info!("client 0x{:x} reparented away, removing proxy", ev.window);
let proxy = self.proxies.remove(&ev.window).unwrap();
if let Some(damage_id) = proxy.damage_id() {
self.damage_to_client.remove(&damage_id);
}
proxy.shutdown().await;
}
}
}
Event::DamageNotify(ev) => {
if let Some(&client) = self.damage_to_client.get(&ev.damage) {
if let Some(proxy) = self.proxies.get_mut(&client) {
proxy.update_icon().await?;
}
}
}
_ => {}
}
Ok(())
}
async fn dock(
&mut self,
client_window: Window,
) -> Result<(), Box<dyn std::error::Error>> {
if self.proxies.contains_key(&client_window) {
warn!("duplicate dock request for 0x{client_window:x}, ignoring");
return Ok(());
}
// Subscribe to StructureNotify on the client so we get DestroyNotify / ReparentNotify.
self.conn.change_window_attributes(
client_window,
&ChangeWindowAttributesAux::default().event_mask(EventMask::STRUCTURE_NOTIFY),
)?;
let proxy = SniProxy::new(
self.conn,
self.screen_num,
client_window,
&self.atoms,
self.cmd_tx.clone(),
)
.await?;
if let Some(damage_id) = proxy.damage_id() {
self.damage_to_client.insert(damage_id, client_window);
}
self.proxies.insert(client_window, proxy);
Ok(())
}
pub fn handle_dbus_command(&mut self, cmd: &DbusCommand) {
match cmd {
DbusCommand::Click {
client_window,
x,
y,
button,
} => {
if let Some(proxy) = self.proxies.get(client_window) {
if let Err(e) = proxy.inject_click(*x, *y, *button) {
warn!("click injection failed: {e}");
}
}
}
}
}
pub async fn shutdown(mut self) {
info!("shutting down, cleaning up {} proxies", self.proxies.len());
for (_, proxy) in self.proxies.drain() {
proxy.shutdown().await;
}
let _ = self.conn.set_selection_owner(
0u32, // None — release selection
self.atoms.net_system_tray_s0,
CURRENT_TIME,
);
let _ = self.conn.destroy_window(self.owner_window);
let _ = self.conn.flush();
}
}