Skip to content

feat(hook): Wayland frontmost-window backends (wlroots + GNOME Shell)#191

Open
recchia wants to merge 13 commits into
AprilNEA:masterfrom
recchia:feat/frontmost-wayland-backends
Open

feat(hook): Wayland frontmost-window backends (wlroots + GNOME Shell)#191
recchia wants to merge 13 commits into
AprilNEA:masterfrom
recchia:feat/frontmost-wayland-backends

Conversation

@recchia

@recchia recchia commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

frontmost_bundle_id() is X11-only today, so on a Wayland session it returns
None for native windows and per-app profiles never fire (XWayland windows
aside). This adds two Wayland backends behind the existing selection, keeping
the X11 path as the universal fallback — no behavior change off Wayland, and
macOS/Windows untouched
:

  • wlrootszwlr_foreign_toplevel_management_v1 (sway, Hyprland, river, Wayfire)
  • GNOME Shell — a minimal, read-only companion extension that exports the
    focused window's WM_CLASS over D-Bus (Mutter offers no protocol/portal for this)

Implements the Wayland half of #95; complements the X11 backend from #122.

Backend selection

detect_session_kind() (XDG_SESSION_TYPE, falling back to
WAYLAND_DISPLAY/DISPLAY) sets the candidate order; the first that
initializes wins:

  • Wayland → wlroots → GNOME extension → X11/XWayland
  • X11 / unknown → X11

A candidate returns None when it can't initialize (wlr manager absent on
GNOME, extension not installed, …), so unsupported compositors fall through to
X11 exactly as today. Selection is once-per-process; landing on X11 while on
Wayland logs a hint to install the extension.

Compositor coverage

Compositor Backend Identifier
wlroots (sway, Hyprland, river, Wayfire) wlr foreign-toplevel xdg app_id (org.mozilla.firefox)
GNOME / Mutter companion extension (D-Bus) WM_CLASS (org.gnome.Nautilus, firefox_firefox)
KDE/KWin, others X11/XWayland fallback X11 WM_CLASS (XWayland windows only)

Files

  • src/linux/wlr_foreign_toplevel.rs — binds the foreign-toplevel manager,
    roundtrips per poll, returns the activated toplevel's app_id.
  • src/linux/gnome_shell.rs — blocking zbus proxy onto org.openlogi.Frontmost,
    with a per-call timeout so a stalled Shell can't wedge the poll thread.
  • gnome-shell-extension/openlogi-frontmost@openlogi.dev/ — the extension. Reads
    only global.display.focus_window.get_wm_class(); no titles, contents, input,
    or UI. Targets GNOME Shell 45–50.
  • src/linux.rs — dispatch refactored to a FrontmostSource trait + ordered
    candidate list. New deps (wayland-client, wayland-protocols-wlr, zbus)
    under the existing cfg(target_os = "linux") target.

Identifier semantics — a design call I'd like your read on

GNOME and X11 both return WM_CLASS, so a profile created on X11 carries over to
GNOME/Wayland unchanged. wlroots returns the native xdg app_id, a different
namespace
— and since profile lookup is an exact match, a profile created under
wlroots won't match one created under GNOME/X11. I return each compositor's
native identifier rather than a lossy WM_CLASS guess (stripping reverse-DNS and
re-capitalizing is wrong for many apps); reconciling the namespaces belongs in a
single normalization layer over frontmost_bundle_id(), which I left out to keep
this PR focused. Happy to add that pass, or to standardize on one identifier
across all Linux backends — which do you prefer?

Testing

Validated end-to-end on Ubuntu 26.04, GNOME Shell 50.1, Wayland, rustc 1.96:

  • Extension State: ACTIVE; gdbus call … GetFocusedWmClass returns and tracks
    the focused window's WM_CLASS.
  • cargo run --example frontmost_app -p openlogi-hook follows focus live across
    native-Wayland apps (Ptyxis → org.gnome.Ptyxis, Nautilus → org.gnome.Nautilus)
    and Firefox (firefox_firefox) — windows the X11 backend reports as None.

Not yet hardware-tested: the wlroots backend. It compiles and follows the
protocol spec, but I don't run a wlroots compositor — a sanity check from a
sway/Hyprland user would be welcome, or I can spin one up before merge.

Install (GNOME)

UUID=openlogi-frontmost@openlogi.dev
DEST="$HOME/.local/share/gnome-shell/extensions/$UUID"
mkdir -p "$DEST"
cp crates/openlogi-hook/gnome-shell-extension/$UUID/{metadata.json,extension.js} "$DEST"/
# Wayland can't hot-reload the shell — log out/in, then:
gnome-extensions enable "$UUID"

Open questions

  1. Extension UUID / D-Bus name use openlogi.* as placeholders — what namespace
    do you want? (constants mirrored in gnome_shell.rs.)
  2. Extension distribution: bundle-and-document (current), ship to
    extensions.gnome.org, or auto-install from the app?

Checklist

  • Linux-only (cfg(target_os = "linux")); macOS/Windows untouched.
  • Falls through to X11 when no Wayland backend initializes.
  • GNOME backend validated on real hardware (Ubuntu 26.04 / GNOME 50.1 / Wayland).
  • wlroots backend validated on a wlroots compositor (help wanted).

@greptile-apps

greptile-apps Bot commented Jun 9, 2026

Copy link
Copy Markdown

Greptile Summary

Adds two Wayland frontmost-window backends — wlr_foreign_toplevel (sway/Hyprland/river) and gnome_shell (GNOME Shell D-Bus extension) — behind a new FrontmostSource trait with ordered candidate selection, keeping X11 as the universal fallback. Also adds a companion GNOME Shell extension, centralises the app identifier in brand::APP_ID, and wires it into the GUI's WindowOptions and .desktop file.

  • linux.rs: FrontmostSource trait + detect_frontmost_source candidate loop replaces the old X11_STATE singleton; session kind is detected from XDG_SESSION_TYPE/display env vars and used to order Wayland-first candidates.
  • wlr_foreign_toplevel.rs: event-driven wlr protocol bridged to a synchronous poll via a non-blocking drain_events (25 ms cap) and timed_roundtrip for init (5 s shared deadline); reconnects automatically on compositor Finished.
  • gnome_shell.rs: blocking zbus proxy with 5 s method_timeout; probes the extension on connect and falls through to X11 if absent.

Confidence Score: 4/5

Safe for GNOME and X11 users; the wlr backend has two connection-error paths in drain_events that silently discard errors instead of signalling a reconnect, so a compositor crash leaves the backend returning a stale focused-app identifier until the process restarts.

In drain_events, both queue.dispatch_pending() and guard.read() errors are swallowed via let _. Neither sets state.finished = true, so the needs_reconnect check in frontmost_bundle_id never fires on a compositor crash — the backend quietly returns the last-known activated app forever. This gap affects only wlroots compositors (sway/Hyprland/etc.) during a hard crash; GNOME and X11 users are unaffected. The init timeout, non-blocking poll path, and Finished-triggered graceful-reload reconnect all work correctly.

crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rsdrain_events connection-error handling (the dispatch_pending and guard.read() let _ discards).

Important Files Changed

Filename Overview
crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs New wlroots backend. Init timeout and non-blocking poll path are well-structured, and the Finished-triggered reconnect was added. Two connection-error paths in drain_events (dispatch_pending and read) still silently discard errors without setting state.finished, preventing reconnection after a compositor crash. Empty app_id is also not filtered, unlike the other two backends.
crates/openlogi-hook/src/linux/gnome_shell.rs New GNOME Shell D-Bus backend. Clean implementation: 5 s method timeout guards the FRONTMOST_SOURCE initializer, probe-call verifies the extension is present at connect time, and empty WM_CLASS is filtered before returning. No issues found.
crates/openlogi-hook/src/linux.rs Refactored to FrontmostSource trait + ordered candidate list. Session detection, candidate ordering, X11/Null sources, and FRONTMOST_SOURCE LazyLock are all correct. No issues found.
crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/extension.js Minimal GNOME Shell extension. Correctly exports GetFocusedWmClass via Gio.DBusExportedObject, handles null focus_window, and cleans up in disable(). No issues found.
crates/openlogi-core/src/brand.rs Adds APP_ID constant as the single source of truth for the Wayland xdg-toplevel app_id / X11 WM_CLASS. Matches the desktop file's StartupWMClass. No issues.
crates/openlogi-gui/src/main.rs Sets app_id on WindowOptions using brand::APP_ID so the GUI window advertises the correct Wayland xdg-toplevel app_id. No issues.
packaging/linux/desktop/openlogi.desktop Adds StartupWMClass=org.openlogi.openlogi to tie the running window back to the launcher icon. Matches brand::APP_ID. No issues.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[frontmost_bundle_id called] --> B[FRONTMOST_SOURCE LazyLock]
    B -->|first call| C[detect_frontmost_source]
    C --> D{detect_session_kind}
    D -->|Wayland| E[wlr_foreign_toplevel::candidate]
    D -->|X11 / Unknown| H[x11_candidate]
    E -->|wlr protocol found| I[WlrForeignToplevelSource\ndrain_events / reconnect on Finished]
    E -->|protocol absent| F[gnome_shell::candidate]
    F -->|extension reachable| J[GnomeShellSource\nD-Bus GetFocusedWmClass]
    F -->|extension absent| H
    H -->|DISPLAY set| K[X11Source\n_NET_ACTIVE_WINDOW + WM_CLASS]
    H -->|no display| L[NullSource → None]
    B -->|subsequent calls| M[dispatch to selected backend]
    I --> M
    J --> M
    K --> M
    L --> M
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[frontmost_bundle_id called] --> B[FRONTMOST_SOURCE LazyLock]
    B -->|first call| C[detect_frontmost_source]
    C --> D{detect_session_kind}
    D -->|Wayland| E[wlr_foreign_toplevel::candidate]
    D -->|X11 / Unknown| H[x11_candidate]
    E -->|wlr protocol found| I[WlrForeignToplevelSource\ndrain_events / reconnect on Finished]
    E -->|protocol absent| F[gnome_shell::candidate]
    F -->|extension reachable| J[GnomeShellSource\nD-Bus GetFocusedWmClass]
    F -->|extension absent| H
    H -->|DISPLAY set| K[X11Source\n_NET_ACTIVE_WINDOW + WM_CLASS]
    H -->|no display| L[NullSource → None]
    B -->|subsequent calls| M[dispatch to selected backend]
    I --> M
    J --> M
    K --> M
    L --> M
Loading

Comments Outside Diff (1)

  1. crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs, line 927-945 (link)

    P1 dispatch_pending errors also not propagated to state.finished

    drain_events already silently drops guard.read() errors (which covers the compositor-crash socket-close path). The first queue.dispatch_pending(state) call at the top of the function is also silently discarded via let _. In wayland-client, dispatch_pending returns Err when the connection is broken or a protocol error was detected from a previously-read message. Discarding this means a broken connection detected via dispatch_pending (rather than a failed read) also won't set state.finished = true and won't trigger the reconnect path. Since the two failure modes share the same fix (state.finished = true on any connection error), this is easiest to address in the same pass as the read() error handling.

    Fix in Codex Fix in Claude Code

Fix All in Codex Fix All in Claude Code

Reviews (11): Last reviewed commit: "Merge remote-tracking branch 'origin/mas..." | Re-trigger Greptile

Comment thread crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs Outdated
Comment thread crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs
Comment thread crates/openlogi-hook/src/linux.rs Outdated
Comment thread crates/openlogi-hook/src/linux.rs Outdated
@recchia

recchia commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Cross-referencing #173/#179: once that packaging lands, open question 2 here (extension distribution) has a natural answer — ship openlogi-frontmost@openlogi.dev/ as an nfpm contents: entry (system-wide path /usr/share/gnome-shell/extensions//) plus an install.sh step. Users would still need gnome-extensions enable + a session restart, but it removes the manual copy. Happy to add that as a follow-up once both PRs are in, whichever merges first.

recchia and others added 4 commits June 10, 2026 21:31
Introduces a FrontmostSource trait so display-server backends can be
selected at startup without touching callers, then ships two backends:

- wlr_foreign_toplevel: uses zwlr_foreign_toplevel_management_v1 for
  wlroots compositors (sway, Hyprland, river). Drains the event queue
  each poll (~1 Hz) and tracks per-toplevel app_id / activated state.
  Emits warn! on compositor Finished (e.g. sway config reload).
- gnome_shell: talks to a companion GNOME Shell extension over D-Bus
  (session bus, blocking proxy). Returns WM_CLASS to keep profile keys
  consistent with the X11 backend.

Backend selection order on Wayland: wlr → gnome-shell → X11/XWayland →
NullSource. X11 sessions and unknown sessions skip straight to X11.

Also adds gnome-shell-extension/ with the extension source (ESM,
targets GNOME Shell 45+) and Cargo deps wayland-client,
wayland-protocols-wlr, zbus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the compositor sends `Finished` (e.g. on swaymsg reload), the wlr
backend now tries to reopen the session on the next poll instead of
permanently disabling per-app profiles. The session (conn + queue + state)
is grouped behind a single mutex so the whole thing can be rebuilt
atomically; a failed reconnect retries at the next 1 Hz tick.

Also update two stale doc comments in linux.rs that still described the
pre-PR state (X11-only / "None until a Wayland backend is added").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The three unbounded `EventQueue::roundtrip` calls are replaced by two
deadline-aware primitives:

- `timed_roundtrip` (init path): sends `wl_display.sync`, then loops
  `flush → poll(2) → read → dispatch_pending` until the `WlCallback::Done`
  fires or `INIT_TIMEOUT = 5 s` is reached. Symmetric to
  `gnome_shell::METHOD_TIMEOUT`; both guard the `FRONTMOST_SOURCE` `LazyLock`
  initializer so a stalled compositor socket makes the candidate fall through
  instead of blocking every thread that touches frontmost.

- `drain_events` (poll path): the protocol is event-driven so no sync barrier
  is needed. Flushes outgoing writes, then does a non-blocking
  `prepare_read → poll(2, 25 ms cap) → read → dispatch_pending`. If nothing
  arrives within the cap the last known state is returned — millisecond-stale
  frontmost data is acceptable by design.

Both paths use `poll(2)` via the existing `libc` dependency with
`Instant`-based remaining-time accounting per iteration and `EINTR` retry.
A read error marks the session finished, consistent with the existing
reconnect behavior.

A small `millis_until` helper converts an `Instant` deadline to a `poll(2)`
timeout; two unit tests cover the boundary cases.

Compositor death and reconnect behavior are unchanged from the prior commit.
Runtime validation on a wlroots compositor is still pending (this machine
runs GNOME/Mutter, which doesn't advertise the protocol).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@recchia recchia force-pushed the feat/frontmost-wayland-backends branch from fd81844 to 4a078ec Compare June 11, 2026 00:31
recchia and others added 2 commits June 10, 2026 23:04
cargo generate-lockfile during the rebase upgraded gpui to cafbf4b5
(HEAD of zed), which broke gpui-component. Restore master's lockfile
(gpui at eb2223c0) — all new deps from the branch are already present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs
Previously each timed_roundtrip call created its own Instant::now() +
INIT_TIMEOUT, allowing Session::open() to block for up to 2×INIT_TIMEOUT
(10 s) — double the stated guard. A single shared deadline keeps the total
wall-clock exposure within INIT_TIMEOUT regardless of how many round-trips
are needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@recchia

recchia commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

End-to-end verification of the GNOME Wayland path on Ubuntu 26.04 (GNOME Shell 50.1, native Wayland session):

Setup

  • This branch merged onto latest master in a scratch worktree: clean merge, cargo check/clippy clean, full workspace test suite green (225 passed / 0 failed)
  • GNOME extension from this PR installed per its README, ACTIVE after re-login; GetFocusedWmClass answers over D-Bus
  • Device access via the scoped udev rule from Add Linux port support and packaging #233 (uaccess on Logitech event nodes + uinput) — no input group needed

Live run (debug agent, OPENLOGI_LOG=...openlogi_hook=debug):

frontmost: session kind = Wayland
hook started on /dev/input/event13        # MX Master 3S (Bolt)
hook started on /dev/input/event15        # MX Keys (keyboard) pointer subdevice
frontmost: using 'gnome-shell' backend
frontmost app changed current=Some("org.gnome.Ptyxis")   last=None
frontmost app changed current=Some("org.gnome.Nautilus") last=Some("org.gnome.Ptyxis")
frontmost app changed current=Some("org.gnome.Ptyxis")   last=Some("org.gnome.Nautilus")
  • Backend candidate order behaves as designed: wlr-foreign-toplevel correctly falls through on Mutter, gnome-shell wins, X11 fallback untouched
  • Focus changes (terminal → Nautilus → terminal) propagate through the foreground watcher within its 1s poll
  • Mouse stayed fully usable while grabbed — uinput pass-through working; clean grab release on SIGTERM
  • One observation, matching the namespace caveat documented in linux.rs: on GNOME Wayland, Mutter reports WM_CLASS in reverse-DNS app-id form (org.gnome.Nautilus), so it coincides with the wlr backend's app_id namespace for GNOME apps — the WM_CLASS↔app_id mismatch will mostly bite for non-GNOME/legacy apps

Works as advertised on GNOME Wayland.

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

recchia and others added 2 commits June 14, 2026 12:19
The GUI shipped no xdg-toplevel app_id (X11 WM_CLASS), so on GNOME
Wayland Mutter's get_wm_class() returned empty for our own window —
the gnome_shell frontmost backend then reported OpenLogi as None, and
the dash couldn't group the window under its launcher icon.

Set app_id to the bundle identifier (org.openlogi.openlogi) in
main_window_options, and add a matching StartupWMClass to the Linux
desktop entry so GNOME ties the running window back to the launcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@recchia

recchia commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the app_id gap in 4900500.

Issue: the GUI shipped no Wayland xdg-toplevel app_id (X11 WM_CLASS), so on GNOME Wayland Mutter's get_wm_class() returned empty for our own window. The gnome_shell frontmost backend then reported OpenLogi as None, and the dash couldn't group the window under its launcher icon.

Fix:

  • Set app_id: Some("org.openlogi.openlogi") in main_window_options (matches the bundle identifier).
  • Added a matching StartupWMClass=org.openlogi.openlogi to the Linux desktop entry so GNOME ties the running window back to the launcher.

Verified on GNOME Wayland: querying the extension while OpenLogi is focused now returns org.openlogi.openlogi on every poll (was ""), and the foreground_app watcher tracks it correctly:

frontmost app changed current=Some("org.openlogi.openlogi") last=Some("org.gnome.Ptyxis")

OpenLogi's own window is now a stable frontmost key instead of None.

recchia and others added 2 commits June 16, 2026 00:02
The Wayland app_id / X11 WM_CLASS was a bare literal in main_window_options
and the .desktop StartupWMClass — two copies that could drift, and a merge
hazard against the Linux-port PR which sets its own value. Define it once as
openlogi_core::brand::APP_ID and reference it from the GUI; the .desktop file
keeps a documented literal copy since it can't import Rust.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant