diff --git a/Cargo.lock b/Cargo.lock index 3c266000..c50a42a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4835,8 +4835,11 @@ dependencies = [ "openlogi-inject", "thiserror 2.0.18", "tracing", + "wayland-client", + "wayland-protocols-wlr", "windows-sys 0.61.2", "x11rb", + "zbus", ] [[package]] diff --git a/crates/openlogi-core/src/brand.rs b/crates/openlogi-core/src/brand.rs index 0c36d944..17d09bc7 100644 --- a/crates/openlogi-core/src/brand.rs +++ b/crates/openlogi-core/src/brand.rs @@ -14,6 +14,15 @@ pub const HELP_URL: &str = "https://github.com/AprilNEA/OpenLogi#readme"; /// The "latest release" page. pub const RELEASES_URL: &str = "https://github.com/AprilNEA/OpenLogi/releases/latest"; +/// The application identifier: the Wayland xdg-toplevel `app_id` (and X11 +/// `WM_CLASS`) the GUI advertises, the root of the macOS bundle-id family +/// (`org.openlogi.agent`, `org.openlogi.openlogi.dev`), and the value the Linux +/// `.desktop` file pins as `StartupWMClass`. Defined once here so the window the +/// compositor sees, the launcher that groups it, and the frontmost backend that +/// self-identifies OpenLogi can never disagree. The `.desktop` file carries its +/// own literal copy (it can't reference Rust) — keep the two in sync. +pub const APP_ID: &str = "org.openlogi.openlogi"; + /// The release page for a specific version tag (e.g. the running build). #[must_use] pub fn release_tag_url(version: &str) -> String { diff --git a/crates/openlogi-gui/src/main.rs b/crates/openlogi-gui/src/main.rs index 1a97ee25..e2c96924 100644 --- a/crates/openlogi-gui/src/main.rs +++ b/crates/openlogi-gui/src/main.rs @@ -59,7 +59,7 @@ use gpui::{ WindowBounds, WindowOptions, px, }; use gpui_component::{ActiveTheme, Root, Theme, ThemeMode}; -use openlogi_core::brand::DeeplinkCommand; +use openlogi_core::brand::{APP_ID, DeeplinkCommand}; use openlogi_core::config::Config; use openlogi_core::device::{DeviceInventory, DeviceModelInfo}; use tracing::{info, warn}; @@ -547,6 +547,13 @@ fn main_window_options(cx: &mut gpui::App) -> WindowOptions { let bounds = Bounds::centered(None, Size::new(px(1100.), px(750.)), cx); WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), + // Advertise a Wayland xdg-toplevel app_id (and X11 WM_CLASS). Without it + // the window ships no app_id, so GNOME's `get_wm_class()` returns empty + // and our own `gnome_shell` frontmost backend reports OpenLogi as `None` + // (and the dash can't group the window under its launcher icon). The id + // is the shared `brand::APP_ID`, matching the desktop file's + // `StartupWMClass` and the macOS bundle-id family. + app_id: Some(APP_ID.into()), // Min height keeps the buttons tab's mouse model above its scale floor // (`MODEL_MIN_H` + the chrome/padding reserve) so its side labels never // overlap; below this the model can't shrink further without crowding. diff --git a/crates/openlogi-hook/Cargo.toml b/crates/openlogi-hook/Cargo.toml index a82c8314..98c0f513 100644 --- a/crates/openlogi-hook/Cargo.toml +++ b/crates/openlogi-hook/Cargo.toml @@ -17,7 +17,10 @@ tracing = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] evdev = "0.13" libc = "0.2" +wayland-client = "0.31" +wayland-protocols-wlr = { version = "0.3", features = ["client"] } x11rb = "0.13" +zbus = "5" [target.'cfg(target_os = "linux")'.dev-dependencies] ctrlc = "3" diff --git a/crates/openlogi-hook/gnome-shell-extension/README.md b/crates/openlogi-hook/gnome-shell-extension/README.md new file mode 100644 index 00000000..e58cd26a --- /dev/null +++ b/crates/openlogi-hook/gnome-shell-extension/README.md @@ -0,0 +1,59 @@ +# OpenLogi Frontmost Window — GNOME Shell extension + +GNOME (Mutter) does not let ordinary clients see which window is focused on +Wayland, and it implements neither `wlr-foreign-toplevel` nor a focused-window +portal. This minimal extension bridges that gap: it exports the WM_CLASS of the +focused window over D-Bus so OpenLogi's `gnome-shell` frontmost backend can +drive per-app mouse-profile switching. + +It reads only `global.display.focus_window.get_wm_class()`. No titles, no window +contents, no input, no UI. + +## D-Bus surface + +- name: `org.openlogi.Frontmost` +- path: `/org/openlogi/Frontmost` +- method: `GetFocusedWmClass() -> s` (empty string when nothing is focused) + +## Install + +```sh +UUID=openlogi-frontmost@openlogi.dev +DEST="$HOME/.local/share/gnome-shell/extensions/$UUID" +mkdir -p "$DEST" +cp metadata.json extension.js "$DEST"/ +``` + +On Wayland the shell cannot be reloaded in place, so **log out and back in** to +let GNOME pick up the newly added extension, then enable it: + +```sh +gnome-extensions enable "$UUID" +gnome-extensions info "$UUID" # State should be ACTIVE +``` + +## Verify + +```sh +# Introspect the service: +busctl --user introspect org.openlogi.Frontmost /org/openlogi/Frontmost + +# Focus a window, then query it: +gdbus call --session \ + -d org.openlogi.Frontmost \ + -o /org/openlogi/Frontmost \ + -m org.openlogi.Frontmost.GetFocusedWmClass +``` + +If `gdbus call` prints the focused window's WM_CLASS, OpenLogi's GNOME backend +will pick it up automatically the next time the hook starts. + +## Notes + +- The `shell-version` list in `metadata.json` covers GNOME 45–50. Newer GNOME + releases may need an added entry; the API used here (`Gio.DBusExportedObject`, + `global.display.focus_window`, `Meta.Window.get_wm_class`) has been stable + across these versions. +- The extension name/UUID and the D-Bus name (`org.openlogi.*`) are placeholders + that should track the project's namespace; if they change, update the matching + constants in `crates/openlogi-hook/src/linux/gnome_shell.rs`. diff --git a/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/extension.js b/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/extension.js new file mode 100644 index 00000000..a0f9e4a8 --- /dev/null +++ b/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/extension.js @@ -0,0 +1,55 @@ +// OpenLogi Frontmost Window — GNOME Shell extension. +// +// Exports a tiny D-Bus service that returns the WM_CLASS of the currently +// focused window. OpenLogi's `gnome_shell` frontmost backend polls this to +// drive per-app mouse-profile switching on GNOME Wayland, where the focused +// window is otherwise not visible to ordinary clients. +// +// It reads only `global.display.focus_window.get_wm_class()` — no titles, no +// window contents, no input. ESM module style; targets GNOME Shell 45+. + +import Gio from 'gi://Gio'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +const DBUS_NAME = 'org.openlogi.Frontmost'; +const DBUS_PATH = '/org/openlogi/Frontmost'; +const DBUS_INTERFACE = ` + + + + + + +`; + +export default class OpenLogiFrontmostExtension extends Extension { + enable() { + this._dbus = Gio.DBusExportedObject.wrapJSObject(DBUS_INTERFACE, this); + this._dbus.export(Gio.DBus.session, DBUS_PATH); + this._nameId = Gio.bus_own_name_on_connection( + Gio.DBus.session, + DBUS_NAME, + Gio.BusNameOwnerFlags.NONE, + null, + null); + } + + disable() { + if (this._nameId) { + Gio.bus_unown_name(this._nameId); + this._nameId = 0; + } + if (this._dbus) { + this._dbus.unexport(); + this._dbus = null; + } + } + + // D-Bus method org.openlogi.Frontmost.GetFocusedWmClass. + GetFocusedWmClass() { + const win = global.display.focus_window; + if (!win) + return ''; + return win.get_wm_class() || ''; + } +} diff --git a/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/metadata.json b/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/metadata.json new file mode 100644 index 00000000..068f8493 --- /dev/null +++ b/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "openlogi-frontmost@openlogi.dev", + "name": "OpenLogi Frontmost Window", + "description": "Exposes the focused window's WM_CLASS over D-Bus so OpenLogi can switch per-app mouse profiles on GNOME Wayland. Read-only; no UI, no window contents.", + "shell-version": ["45", "46", "47", "48", "49", "50"], + "url": "https://github.com/AprilNEA/OpenLogi", + "version": 1 +} diff --git a/crates/openlogi-hook/src/linux.rs b/crates/openlogi-hook/src/linux.rs index a36913bc..0ef718c6 100644 --- a/crates/openlogi-hook/src/linux.rs +++ b/crates/openlogi-hook/src/linux.rs @@ -379,70 +379,217 @@ fn device_thread( // ── frontmost_bundle_id ────────────────────────────────────────────────────── -struct X11State { +// The frontmost-app reader is backend-driven so that Wayland support can be +// added without touching callers. Exactly one backend is selected at startup +// from the session environment (see `detect_frontmost_source`) and cached in +// `FRONTMOST_SOURCE` for the process lifetime. The X11, wlr-foreign-toplevel, +// and gnome-shell backends are all available; see `wayland_candidates`. + +mod gnome_shell; +mod wlr_foreign_toplevel; + +/// A backend that reports which application is currently frontmost. +/// +/// Implementations are display-server / desktop specific. The string returned +/// by `frontmost_bundle_id` is compared against per-app profile keys by exact +/// match (`openlogi_core::Config::effective_bindings`), so its exact form +/// matters and is backend-specific. The X11 and gnome-shell backends both +/// return the `WM_CLASS` class component (e.g. "Firefox"); the wlr backend +/// returns the xdg-shell `app_id` (e.g. "org.mozilla.firefox"). These two +/// namespaces do not map onto each other by any simple string rule, so a +/// per-app profile created under wlroots will not match under GNOME/X11 and +/// vice versa. This is a known limitation: reconciling it needs a canonical-id +/// scheme or per-profile aliases rather than naive normalization, and is +/// deliberately out of scope for the backends themselves. +trait FrontmostSource: Send + Sync { + /// Opaque identifier of the frontmost application, or `None` when there is + /// no frontmost window or it cannot be read. + fn frontmost_bundle_id(&self) -> Option; + + /// Short backend identifier, for diagnostics / logging only. + fn name(&self) -> &'static str; +} + +/// Frontmost backend backed by X11 `_NET_ACTIVE_WINDOW` + `WM_CLASS`. +/// +/// Works on an X11 session, and on a Wayland session for XWayland windows; +/// native Wayland windows are invisible through this path and yield `None`. +struct X11Source { conn: RustConnection, root: Window, net_active_window: Atom, } -static X11_STATE: LazyLock> = LazyLock::new(|| { - let (conn, screen_num) = RustConnection::connect(None) - .map_err(|e| debug!("X11 not available, frontmost_bundle_id will return None: {e}")) - .ok()?; - let root = conn.setup().roots[screen_num].root; - let net_active_window = conn - .intern_atom(false, b"_NET_ACTIVE_WINDOW") - .ok()? - .reply() - .ok()? - .atom; - Some(X11State { - conn, - root, - net_active_window, - }) -}); +impl X11Source { + /// Connect to the X server and resolve the `_NET_ACTIVE_WINDOW` atom. + /// Returns `None` when no X display is reachable (a Wayland session without + /// XWayland, or `$DISPLAY` unset). + fn connect() -> Option { + let (conn, screen_num) = RustConnection::connect(None) + .map_err(|e| debug!("X11 not available, frontmost will return None: {e}")) + .ok()?; + let root = conn.setup().roots[screen_num].root; + let net_active_window = conn + .intern_atom(false, b"_NET_ACTIVE_WINDOW") + .ok()? + .reply() + .ok()? + .atom; + Some(Self { + conn, + root, + net_active_window, + }) + } +} + +impl FrontmostSource for X11Source { + fn frontmost_bundle_id(&self) -> Option { + // _NET_ACTIVE_WINDOW on the root window holds the focused window's XID. + let window: Window = self + .conn + .get_property( + false, + self.root, + self.net_active_window, + AtomEnum::WINDOW, + 0, + 1, + ) + .ok()? + .reply() + .ok()? + .value32()? + .next()?; + if window == 0 { + return None; + } + + // WM_CLASS is instance_name\0class_name\0; the class component is more + // stable across window instances and is what profiles should key on + // (e.g. "Firefox", not "Navigator"). + let wm = WmClass::get(&self.conn, window) + .ok()? + .reply_unchecked() + .ok()??; + std::str::from_utf8(wm.class()) + .ok() + .filter(|s| !s.is_empty()) + .map(str::to_owned) + } + + fn name(&self) -> &'static str { + "x11" + } +} + +/// Fallback used when no backend is available (e.g. a pure Wayland session +/// before any Wayland backend lands). Always reports `None`, so per-app +/// profile switching simply no-ops rather than erroring. +struct NullSource; + +impl FrontmostSource for NullSource { + fn frontmost_bundle_id(&self) -> Option { + None + } + + fn name(&self) -> &'static str { + "null" + } +} + +/// Coarse classification of the graphical session, used to order the frontmost +/// backend candidates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SessionKind { + X11, + Wayland, + Unknown, +} + +/// Classify the session from the environment. `XDG_SESSION_TYPE` is +/// authoritative when set to `x11` or `wayland`; otherwise fall back to the +/// presence of `WAYLAND_DISPLAY` / `DISPLAY`. +fn detect_session_kind() -> SessionKind { + if let Ok(kind) = std::env::var("XDG_SESSION_TYPE") { + match kind.as_str() { + "wayland" => return SessionKind::Wayland, + "x11" => return SessionKind::X11, + _ => {} + } + } + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + SessionKind::Wayland + } else if std::env::var_os("DISPLAY").is_some() { + SessionKind::X11 + } else { + SessionKind::Unknown + } +} + +/// A backend constructor: returns the backend if it can initialize on this +/// system, or `None` to fall through to the next candidate. +type Candidate = fn() -> Option>; + +fn x11_candidate() -> Option> { + X11Source::connect().map(|s| Box::new(s) as Box) +} + +/// Wayland-native frontmost backends, in priority order: the wlroots +/// foreign-toplevel protocol (sway, Hyprland, river, …) and the GNOME Shell +/// D-Bus extension (Mutter). AT-SPI remains a future fallback. Compositors that +/// support none of these fall through to the X11/XWayland path (which resolves +/// XWayland windows, `None` for native Wayland apps). +fn wayland_candidates() -> Vec { + vec![wlr_foreign_toplevel::candidate, gnome_shell::candidate] +} + +/// Pick the frontmost backend for this session, trying each candidate in order +/// and keeping the first that initializes. Called once, lazily, per process. +fn detect_frontmost_source() -> Box { + let session = detect_session_kind(); + debug!("frontmost: session kind = {session:?}"); + + let mut candidates: Vec = match session { + SessionKind::Wayland => wayland_candidates(), + SessionKind::X11 | SessionKind::Unknown => Vec::new(), + }; + // X11 / XWayland: the primary path on an X11 session and the universal + // fallback everywhere else. + candidates.push(x11_candidate); + + for candidate in candidates { + if let Some(source) = candidate() { + debug!("frontmost: using '{}' backend", source.name()); + // On Wayland, landing on the X11 backend means no native Wayland + // frontmost source was available, so native Wayland windows will + // report None (only XWayland windows resolve). Hint at the fix. + if session == SessionKind::Wayland && source.name() == "x11" { + debug!( + "frontmost: on Wayland but using the X11/XWayland backend; \ + native Wayland windows will report None. Install the OpenLogi \ + GNOME Shell extension (GNOME) or use a wlroots compositor." + ); + } + return source; + } + } + + debug!("frontmost: no usable backend; frontmost_bundle_id will return None"); + Box::new(NullSource) +} + +static FRONTMOST_SOURCE: LazyLock> = + LazyLock::new(detect_frontmost_source); -/// Return the X11 `WM_CLASS` class component of the currently active window, -/// e.g. `"Firefox"` or `"Code"`. +/// Return an opaque identifier of the currently frontmost application, or +/// `None` when unavailable. Dispatches to the backend chosen at startup. /// -/// Returns `None` when there is no active window, when the X11 display is -/// unavailable (Wayland-only session without XWayland), or on read error. -/// Native Wayland windows are not visible through this path. +/// On an X11 session this is the `WM_CLASS` class component (e.g. "Firefox"). +/// On a Wayland session the wlr-foreign-toplevel or gnome-shell backend is used +/// when available; XWayland windows fall back to the X11 backend. pub(crate) fn frontmost_bundle_id() -> Option { - let state = X11_STATE.as_ref()?; - - // _NET_ACTIVE_WINDOW on the root window holds the focused window's XID. - let window: Window = state - .conn - .get_property( - false, - state.root, - state.net_active_window, - AtomEnum::WINDOW, - 0, - 1, - ) - .ok()? - .reply() - .ok()? - .value32()? - .next()?; - if window == 0 { - return None; - } - - // WM_CLASS is instance_name\0class_name\0; the class component is more - // stable across window instances and is what profiles should key on - // (e.g. "Firefox", not "Navigator"). - let wm = WmClass::get(&state.conn, window) - .ok()? - .reply_unchecked() - .ok()??; - std::str::from_utf8(wm.class()) - .ok() - .filter(|s| !s.is_empty()) - .map(str::to_owned) + FRONTMOST_SOURCE.frontmost_bundle_id() } #[cfg(test)] diff --git a/crates/openlogi-hook/src/linux/gnome_shell.rs b/crates/openlogi-hook/src/linux/gnome_shell.rs new file mode 100644 index 00000000..8e14c434 --- /dev/null +++ b/crates/openlogi-hook/src/linux/gnome_shell.rs @@ -0,0 +1,97 @@ +//! Frontmost backend for GNOME Shell (Wayland and X11), via a small companion +//! GNOME Shell extension that exports the focused window's WM_CLASS over D-Bus. +//! +//! GNOME (Mutter) implements neither wlr-foreign-toplevel nor any portal for +//! the focused window, and `org.gnome.Shell.Eval` is disabled by default, so a +//! privileged GNOME Shell extension is the only way to read the focused window +//! on a GNOME Wayland session. The extension lives in `gnome-shell-extension/` +//! in this crate and must be installed and enabled for this backend to +//! activate. When it is absent, [`GnomeShellSource::connect`] fails and backend +//! selection falls through to the next candidate (XWayland via X11). +//! +//! The extension returns the WM_CLASS — not the `.desktop` id — so the +//! identifier matches the X11 backend's, keeping per-app profile keys +//! consistent across X11, XWayland, and GNOME Wayland sessions. +//! +//! Only the session-bus connection is held in the backend; a lightweight proxy +//! is built per poll (no extra D-Bus traffic beyond the method call itself). + +use std::time::Duration; + +use tracing::debug; +use zbus::blocking::Connection; +use zbus::blocking::connection::Builder; +use zbus::proxy; + +use super::FrontmostSource; + +/// Cap on every D-Bus call to the extension. Without it, a stalled GNOME Shell +/// would block the polling thread forever (the probe runs inside the +/// `FRONTMOST_SOURCE` initializer, so a stall there would block every thread +/// that touches it). +const METHOD_TIMEOUT: Duration = Duration::from_secs(5); + +/// D-Bus proxy for the OpenLogi GNOME Shell extension. Only the blocking proxy +/// is generated (`gen_async = false`), matching the synchronous poll contract. +#[proxy( + interface = "org.openlogi.Frontmost", + default_service = "org.openlogi.Frontmost", + default_path = "/org/openlogi/Frontmost", + gen_async = false +)] +trait Frontmost { + /// WM_CLASS of the focused window, or "" when nothing is focused. + #[zbus(name = "GetFocusedWmClass")] + fn get_focused_wm_class(&self) -> zbus::Result; +} + +/// Frontmost backend talking to the OpenLogi GNOME Shell extension over the +/// session bus. +struct GnomeShellSource { + conn: Connection, +} + +impl GnomeShellSource { + fn connect() -> Option { + let conn = Builder::session() + .map_err(|e| debug!("gnome-shell: no session bus: {e}")) + .ok()? + .method_timeout(METHOD_TIMEOUT) + .build() + .map_err(|e| debug!("gnome-shell: connection build failed: {e}")) + .ok()?; + // Probe reachability: a successful call (even an empty result) means the + // OpenLogi extension is installed and exporting the service. An error + // means it is absent/disabled, so this backend must not be selected. + let proxy = FrontmostProxy::new(&conn) + .map_err(|e| debug!("gnome-shell: proxy build failed: {e}")) + .ok()?; + proxy + .get_focused_wm_class() + .map_err(|e| debug!("gnome-shell: OpenLogi extension not reachable: {e}")) + .ok()?; + Some(Self { conn }) + } +} + +impl FrontmostSource for GnomeShellSource { + fn frontmost_bundle_id(&self) -> Option { + let proxy = FrontmostProxy::new(&self.conn) + .map_err(|e| debug!("gnome-shell: proxy build failed: {e}")) + .ok()?; + let wm_class = proxy + .get_focused_wm_class() + .map_err(|e| debug!("gnome-shell: poll failed (extension gone or bus down?): {e}")) + .ok()?; + (!wm_class.is_empty()).then_some(wm_class) + } + + fn name(&self) -> &'static str { + "gnome-shell" + } +} + +/// Candidate constructor registered in [`super::wayland_candidates`]. +pub(super) fn candidate() -> Option> { + GnomeShellSource::connect().map(|s| Box::new(s) as Box) +} diff --git a/crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs b/crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs new file mode 100644 index 00000000..99f25662 --- /dev/null +++ b/crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs @@ -0,0 +1,474 @@ +//! Frontmost backend using the wlroots `zwlr_foreign_toplevel_management_v1` +//! protocol. +//! +//! The manager hands out one handle per toplevel window; each handle reports +//! its `app_id` and a `state` set. The frontmost window is the toplevel whose +//! state set contains `activated`, and its `app_id` is what we return. +//! +//! Note on the returned identifier: this is the xdg-shell `app_id` (e.g. +//! "org.mozilla.firefox", "Alacritty", "foot"), which is a *different namespace* +//! from the `WM_CLASS` returned by the X11 and gnome-shell backends (e.g. +//! "Firefox"). Because profile lookup is an exact match, a per-app profile +//! created under wlroots will not match under GNOME/X11 and vice versa. We +//! deliberately return the native `app_id` rather than a lossy WM_CLASS +//! approximation (stripping reverse-DNS and capitalizing guesses wrong for many +//! apps); reconciling the two namespaces belongs in a single normalization +//! layer, not here. See the `FrontmostSource` trait doc in `linux.rs`. +//! +//! This protocol is implemented by wlroots-based compositors (sway, Hyprland, +//! river, Wayfire, …). GNOME (Mutter) and KDE (KWin) do not advertise it, so +//! [`connect`](WlrForeignToplevelSource::connect) returns `None` there and the +//! caller falls through to the next backend candidate. +//! +//! ## Dispatch model +//! +//! The protocol is event-driven, but the [`super::FrontmostSource`] contract is +//! a synchronous poll (~1 Hz from `openlogi-gui::app_watcher`). Two primitives +//! bridge that gap: +//! +//! - **`drain_events`** (poll path) — flushes pending writes, then attempts a +//! non-blocking `prepare_read` + `read` with a short 25 ms `poll(2)` cap. +//! If nothing arrives in time the last known state is returned unchanged; +//! millisecond-stale frontmost data is acceptable by design. +//! +//! - **`timed_roundtrip`** (init path) — sends `wl_display.sync`, then loops +//! `flush` → `poll(2)` → `read` → `dispatch_pending` until the sync callback +//! fires or `INIT_TIMEOUT` (5 s) expires. If the deadline is hit the candidate +//! returns `None` so backend selection falls through — the same contract as +//! every other backend. +//! +//! Both helpers use `poll(2)` via the `libc` crate (already a Linux dependency) +//! with `Instant`-based remaining-time accounting and `EINTR` retry. + +use std::collections::HashMap; +use std::os::unix::io::AsRawFd; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use tracing::{debug, info, warn}; +use wayland_client::backend::ObjectId; +use wayland_client::protocol::wl_callback; +use wayland_client::protocol::wl_registry::{self, WlRegistry}; +use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, event_created_child}; +use wayland_protocols_wlr::foreign_toplevel::v1::client::zwlr_foreign_toplevel_handle_v1::{ + self, ZwlrForeignToplevelHandleV1, +}; +use wayland_protocols_wlr::foreign_toplevel::v1::client::zwlr_foreign_toplevel_manager_v1::{ + self, ZwlrForeignToplevelManagerV1, +}; + +use super::FrontmostSource; + +/// Highest protocol version this backend understands. The events it relies on +/// (`app_id`, `state`, `done`, `closed`) exist since v1, so binding is capped +/// here to stay within what `wayland-protocols-wlr` generates. +const MANAGER_MAX_VERSION: u32 = 3; + +/// Deadline for the two `wl_display.sync` round-trips in `Session::open`. +/// Mirrors `gnome_shell::METHOD_TIMEOUT`: both guard the `FRONTMOST_SOURCE` +/// `LazyLock` initializer against a stalled compositor socket. +const INIT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Maximum time the poll-path drain will wait for new Wayland events. Stale +/// frontmost data within this window is acceptable by design. +const POLL_CAP_MS: u64 = 25; + +/// Accumulated per-toplevel data. wlr sends individual property events and then +/// a `done` marking a consistent snapshot, so updates are staged in `pending_*` +/// and committed on `done`. +#[derive(Default)] +struct Toplevel { + app_id: Option, + activated: bool, + pending_app_id: Option, + pending_activated: bool, +} + +/// Dispatch state: the bound manager plus the toplevels seen so far. +#[derive(Default)] +struct State { + manager: Option, + toplevels: HashMap, + /// Set when the compositor sends `finished`; triggers a reconnect on the + /// next poll instead of permanently disabling the backend. + finished: bool, + /// Flipped to `true` by the `wl_callback::Done` handler; used by + /// `timed_roundtrip` to detect that the sync echo arrived. + sync_done: bool, +} + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &WlRegistry, + event: wl_registry::Event, + (): &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + && interface == ZwlrForeignToplevelManagerV1::interface().name + { + let version = version.min(MANAGER_MAX_VERSION); + let manager = + registry.bind::(name, version, qh, ()); + state.manager = Some(manager); + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ZwlrForeignToplevelManagerV1, + event: zwlr_foreign_toplevel_manager_v1::Event, + (): &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zwlr_foreign_toplevel_manager_v1::Event::Toplevel { toplevel } => { + state.toplevels.insert(toplevel.id(), Toplevel::default()); + } + zwlr_foreign_toplevel_manager_v1::Event::Finished => { + // The compositor is reloading or restarting. Mark the session + // finished; the next poll will reconnect automatically. + warn!( + "wlr-foreign-toplevel: compositor sent Finished — \ + will reconnect on next poll" + ); + state.finished = true; + state.manager = None; + } + _ => {} + } + } + + // The `toplevel` event creates a new handle object; tell the backend to + // route its events to this same `State` with `()` user data. + event_created_child!(State, ZwlrForeignToplevelManagerV1, [ + zwlr_foreign_toplevel_manager_v1::EVT_TOPLEVEL_OPCODE => (ZwlrForeignToplevelHandleV1, ()), + ]); +} + +impl Dispatch for State { + fn event( + state: &mut Self, + handle: &ZwlrForeignToplevelHandleV1, + event: zwlr_foreign_toplevel_handle_v1::Event, + (): &(), + _: &Connection, + _: &QueueHandle, + ) { + use zwlr_foreign_toplevel_handle_v1::Event; + + let id = handle.id(); + match event { + Event::AppId { app_id } => { + if let Some(toplevel) = state.toplevels.get_mut(&id) { + toplevel.pending_app_id = Some(app_id); + } + } + Event::State { state: states } => { + let activated = is_activated(&states); + if let Some(toplevel) = state.toplevels.get_mut(&id) { + toplevel.pending_activated = activated; + } + } + Event::Done => { + if let Some(toplevel) = state.toplevels.get_mut(&id) { + // app_id is sent only when it changes, and a compositor may + // emit State + Done before the first AppId. Committing + // `pending_app_id` unconditionally would clobber a known id + // (or the initial one) with None, so only overwrite when a + // value is actually pending. `activated` defaults to false, + // which is the correct state for a window that sent none. + if toplevel.pending_app_id.is_some() { + toplevel.app_id = toplevel.pending_app_id.clone(); + } + toplevel.activated = toplevel.pending_activated; + } + } + Event::Closed => { + state.toplevels.remove(&id); + handle.destroy(); + } + // Title, output enter/leave, and parent are not needed for frontmost. + _ => {} + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wl_callback::WlCallback, + event: wl_callback::Event, + (): &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_callback::Event::Done { .. } = event { + state.sync_done = true; + } + } +} + +/// The `state` event carries a `wl_array` of native-endian `u32` state values. +/// A toplevel is frontmost iff the `activated` value is present in that set. +fn is_activated(states: &[u8]) -> bool { + use zwlr_foreign_toplevel_handle_v1::State; + + states.chunks_exact(4).any(|chunk| { + let value = u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + State::try_from(value).is_ok_and(|s| s == State::Activated) + }) +} + +/// Returns the milliseconds remaining until `deadline`, clamped to `[0, i32::MAX]` +/// for use as a `libc::poll` timeout. Returns 0 when the deadline has passed. +fn millis_until(deadline: Instant) -> i32 { + i32::try_from( + deadline + .saturating_duration_since(Instant::now()) + .as_millis() + .min(i32::MAX as u128), + ) + .unwrap_or(i32::MAX) +} + +/// Calls `poll(2)` on `fd` (waiting for `POLLIN | POLLERR`) with a deadline. +/// Retries on `EINTR` with the remaining time. Returns `true` if the fd became +/// readable, `false` on timeout or error. +fn poll_fd(fd: libc::c_int, deadline: Instant) -> bool { + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN | libc::POLLERR, + revents: 0, + }; + loop { + let timeout_ms = millis_until(deadline); + if timeout_ms == 0 { + return false; + } + let r = unsafe { libc::poll(&raw mut pfd, 1, timeout_ms) }; + if r > 0 { + return true; + } + if r == 0 { + return false; + } + // r < 0 — check errno + let e = unsafe { *libc::__errno_location() }; + if e != libc::EINTR { + return false; + } + // EINTR: retry with remaining deadline + } +} + +/// Sends `wl_display.sync` and spins `flush → poll → read → dispatch_pending` +/// until the sync callback fires or `deadline` is reached. Returns `true` on +/// success, `false` on timeout or connection error. +fn timed_roundtrip( + conn: &Connection, + queue: &mut EventQueue, + state: &mut State, + deadline: Instant, +) -> bool { + state.sync_done = false; + let qh = queue.handle(); + conn.display().sync(&qh, ()); + + loop { + if queue.flush().is_err() { + return false; + } + if queue.dispatch_pending(state).is_err() { + return false; + } + if state.sync_done { + return true; + } + if millis_until(deadline) == 0 { + return false; + } + + match queue.prepare_read() { + None => { + // Events are already buffered; loop back to dispatch. + } + Some(guard) => { + let fd = guard.connection_fd().as_raw_fd(); + if !poll_fd(fd, deadline) { + // Timed out or error — candidate falls through. + return false; + } + if guard.read().is_err() { + return false; + } + } + } + } +} + +/// Drains pending compositor events without blocking longer than `POLL_CAP_MS`. +/// Used on every frontmost poll. Stale data within the cap is acceptable by +/// design; errors are silently ignored so the last known state is returned. +fn drain_events(queue: &mut EventQueue, state: &mut State) { + let _ = queue.flush(); + let _ = queue.dispatch_pending(state); + + let deadline = Instant::now() + Duration::from_millis(POLL_CAP_MS); + match queue.prepare_read() { + None => { + // Already had buffered events; dispatch_pending above handled them. + } + Some(guard) => { + let fd = guard.connection_fd().as_raw_fd(); + if poll_fd(fd, deadline) { + let _ = guard.read(); + let _ = queue.dispatch_pending(state); + } + // If poll timed out, guard is dropped here and we return stale state. + } + } +} + +/// One live Wayland session: connection + event queue + dispatch state. +/// +/// Grouping all three behind a single mutex means the whole session can be +/// dropped and rebuilt atomically when the compositor sends `Finished`. +struct Session { + // Held for RAII — even though `Connection` is Arc-backed, keeping an + // explicit handle here ensures the connection outlives the queue. + _conn: Connection, + queue: EventQueue, + state: State, +} + +impl Session { + /// Open a fresh connection, bind the manager, and do the initial two + /// timed round-trips to populate the toplevel list. Returns `None` when + /// the compositor doesn't advertise the protocol, the connection fails, + /// or either round-trip exceeds `INIT_TIMEOUT`. + fn open() -> Option { + let conn = Connection::connect_to_env() + .map_err(|e| debug!("wlr-foreign-toplevel: no Wayland connection: {e}")) + .ok()?; + let mut queue = conn.new_event_queue(); + let qh = queue.handle(); + + // Registering the registry triggers `global` events on the first + // round-trip, where the manager is bound if the compositor advertises it. + let _registry = conn.display().get_registry(&qh, ()); + let mut state = State::default(); + let deadline = Instant::now() + INIT_TIMEOUT; + + if !timed_roundtrip(&conn, &mut queue, &mut state, deadline) { + debug!("wlr-foreign-toplevel: registry round-trip timed out or failed"); + return None; + } + if state.manager.is_none() { + debug!("wlr-foreign-toplevel: compositor does not advertise the protocol"); + return None; + } + + // Second round-trip: receive the initial toplevel list and properties, + // so the first poll already has the active window. + if !timed_roundtrip(&conn, &mut queue, &mut state, deadline) { + debug!("wlr-foreign-toplevel: initial toplevel round-trip timed out or failed"); + return None; + } + + Some(Self { + _conn: conn, + queue, + state, + }) + } +} + +/// Wayland frontmost backend. Holds the session behind a mutex so the whole +/// connection can be rebuilt on compositor restart without touching callers. +struct WlrForeignToplevelSource { + // Active session, or `None` when the last reconnect attempt failed. + // The mutex bridges the event-driven Wayland runtime to the synchronous + // poll contract; the session is only ever touched here, at ~1 Hz. + session: Mutex>, +} + +impl WlrForeignToplevelSource { + fn connect() -> Option { + Session::open().map(|s| Self { + session: Mutex::new(Some(s)), + }) + } +} + +impl FrontmostSource for WlrForeignToplevelSource { + fn frontmost_bundle_id(&self) -> Option { + let mut guard = self.session.lock().ok()?; + + // Reconnect when the compositor sent `Finished` (compositor reload / + // restart) or when a prior reconnect attempt failed. + let needs_reconnect = guard.as_ref().is_none_or(|s| s.state.finished); + if needs_reconnect { + *guard = Session::open(); + if guard.is_some() { + info!("wlr-foreign-toplevel: reconnected"); + } else { + debug!("wlr-foreign-toplevel: reconnect pending, retrying next poll"); + } + } + + let Session { queue, state, .. } = guard.as_mut()?; + drain_events(queue, state); + if state.finished { + // `Finished` arrived during this drain; reconnect on the next call. + return None; + } + + state + .toplevels + .values() + .find(|toplevel| toplevel.activated) + .and_then(|toplevel| toplevel.app_id.clone()) + } + + fn name(&self) -> &'static str { + "wlr-foreign-toplevel" + } +} + +/// Candidate constructor registered in [`super::wayland_candidates`]. +pub(super) fn candidate() -> Option> { + WlrForeignToplevelSource::connect().map(|s| Box::new(s) as Box) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::millis_until; + + #[test] + fn millis_until_elapsed_deadline_is_zero() { + // `deadline` is captured before the call; by the time `millis_until` + // reads `Instant::now()` the deadline is at or before now, so + // `saturating_duration_since` returns `Duration::ZERO` → 0 ms. + let deadline = Instant::now(); + assert_eq!(millis_until(deadline), 0); + } + + #[test] + fn millis_until_future_deadline_is_positive() { + let future = Instant::now() + Duration::from_secs(10); + let ms = millis_until(future); + assert!(ms > 0 && ms <= 10_000); + } +} diff --git a/packaging/linux/desktop/openlogi.desktop b/packaging/linux/desktop/openlogi.desktop index 35117cad..59c0d298 100644 --- a/packaging/linux/desktop/openlogi.desktop +++ b/packaging/linux/desktop/openlogi.desktop @@ -5,6 +5,10 @@ Comment=Logitech HID++ device control — remap buttons, DPI, SmartShift Exec=openlogi-gui Icon=openlogi Terminal=false +# Ties the running window (Wayland xdg-toplevel app_id / X11 WM_CLASS) back to +# this launcher so GNOME groups it under the OpenLogi icon instead of a generic +# one. Must match `openlogi_core::brand::APP_ID`, the value the GUI advertises. +StartupWMClass=org.openlogi.openlogi Categories=Settings;HardwareSettings; Keywords=logitech;mouse;hid;remap;dpi; StartupNotify=true