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