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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
|
||||
1191
Cargo.lock
generated
Normal file
1191
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal 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
96
src/main.rs
Normal 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
378
src/sni_dbus.rs
Normal 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
357
src/sni_proxy.rs
Normal 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
263
src/tray_manager.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user