Files
xembed-sni-proxy/src/sni_proxy.rs
vikingowl 548adb6bbb 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
2026-03-02 15:00:26 +01:00

358 lines
11 KiB
Rust

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)
}