Skip to content

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

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

feat(hook): Wayland frontmost-window backends (wlroots + GNOME Shell)#191
recchia wants to merge 17 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. Also
    reconnects on a genuine connection error during the poll-path drain (socket
    closed by a compositor crash), not just on a graceful Finished event —
    fixed per Greptile's review.
  • 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 — resolved

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.

Decision: keep returning each compositor's native identifier rather than a lossy
WM_CLASS guess (stripping reverse-DNS and re-capitalizing is wrong for many
apps). No normalization layer is added in this PR; the cross-namespace mismatch
is documented as a known, deliberate limitation on the FrontmostSource trait
in linux.rs (a canonical-id scheme or per-profile aliases would be the right
fix, as a separate follow-up if it's ever needed in practice).

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.

wlroots backend validated on sway 1.11 (headless): frontmost_bundle_id()
correctly returned Some("foot") and Some("org.gnome.TextEditor") as focus
switched between two Wayland windows — matching each window's native xdg app_id
exactly as reported by swaymsg -t get_tree.

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?
    Resolved: settled on org.openlogi.*
    throughout — D-Bus name/path (org.openlogi.Frontmost /
    /org/openlogi/Frontmost), extension UUID
    (openlogi-frontmost@openlogi.dev), and the app identifier
    (brand::APP_ID = "org.openlogi.openlogi", added later in this PR) all
    agree. No placeholders remain.
  2. Still open — extension distribution: bundle-and-document (current),
    ship to extensions.gnome.org, or auto-install from the app? feat(linux): install artifacts and docs #173/feat(linux): nfpm .deb/.rpm packaging and CI release job #179
    (nfpm packaging) have since merged, which was the blocker for the
    bundle-and-document follow-up (shipping
    openlogi-frontmost@openlogi.dev/ as an nfpm contents: entry under
    /usr/share/gnome-shell/extensions/<UUID>/), but that follow-up hasn't
    been done yet — packaging/linux/nfpm.yaml currently has no entry for the
    extension. Happy to add it here or as a separate follow-up PR once we
    agree on distribution.

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 (sway 1.11, headless).
  • Reconnects on wlr compositor crash (socket close), not just a graceful Finished.
  • Synced with master as of 2026-07-03 — clean, no conflicts.

@greptile-apps

greptile-apps Bot commented Jun 9, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds two Wayland-native frontmost-window backends — a zwlr_foreign_toplevel_management_v1 backend for wlroots compositors (sway, Hyprland, river, Wayfire) and a GNOME Shell D-Bus backend via a minimal companion extension — resolving the long-standing gap where frontmost_bundle_id() returned None for native Wayland windows. The change is Linux-only and non-breaking: macOS/Windows are untouched, and the X11 path remains the universal fallback.

  • FrontmostSource trait + ordered candidate list (linux.rs): clean abstraction that selects the first initializable backend per session kind; NullSource ensures graceful no-op when nothing works.
  • wlr backend (wlr_foreign_toplevel.rs): non-blocking 25ms poll drain, shared 5s deadline across both init round-trips, reconnect-on-Finished/crash; connection errors in every code path correctly set state.finished.
  • GNOME backend (gnome_shell.rs): 5s method timeout on the session-bus connection guards the polling thread; probe-on-connect correctly falls through when the extension is absent.
  • brand::APP_ID centralizes the Wayland app_id / X11 WM_CLASS string, eliminating the previous literal "openlogi" drift between main.rs, windows/mod.rs, and the .desktop file.

Confidence Score: 5/5

Safe to merge — all Wayland code is Linux-only, falls through gracefully when backends are unavailable, and the previous round of review findings are all addressed in this revision.

The three blocking concerns from the prior review — no timeout on the Wayland poll path, compositor-crash socket errors silently swallowed, and per-call dispatch_pending errors not propagating to state.finished — are all resolved. drain_events now correctly sets state.finished = true on both the early-exit path and the read path. The two timed_roundtrip calls in Session::open share a single deadline, keeping the total init exposure inside INIT_TIMEOUT. No new issues were found.

No files require special attention. The core dispatch logic in wlr_foreign_toplevel.rs received the most scrutiny across both review rounds and looks correct.

Important Files Changed

Filename Overview
crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs New wlroots foreign-toplevel backend: non-blocking poll drain with 25ms cap, shared 5s deadline across both init round-trips, and reconnect logic on Finished/crash. Connection errors in both flush/dispatch_pending and read paths propagate to state.finished correctly.
crates/openlogi-hook/src/linux/gnome_shell.rs GNOME Shell D-Bus backend: METHOD_TIMEOUT guards the poll thread; probe-on-connect correctly falls through when the extension is absent. Lightweight proxy-per-poll is intentional by design and inherits the connection's method timeout.
crates/openlogi-hook/src/linux.rs Refactored frontmost dispatch to FrontmostSource trait with ordered candidate list. Backend selection, NullSource fallback, and session detection logic are correct.
crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/extension.js Minimal ESM extension: exports exactly GetFocusedWmClass via session bus, correct null check on focus_window, proper cleanup in disable(). Targets GNOME Shell 45+.
crates/openlogi-core/src/brand.rs Adds APP_ID = 'org.openlogi.openlogi' as a single source of truth for the Wayland xdg-toplevel app_id / X11 WM_CLASS.
packaging/linux/desktop/openlogi.desktop Adds StartupWMClass=org.openlogi.openlogi to tie the running window to the launcher. Matches APP_ID.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[frontmost_bundle_id called] --> B{FRONTMOST_SOURCE initialized?}
    B -- No --> C[detect_frontmost_source]
    B -- Yes --> Z[dispatch to cached backend]
    C --> D[detect_session_kind]
    D --> E{XDG_SESSION_TYPE?}
    E -- wayland --> F[candidates: wlr → gnome-shell → x11]
    E -- x11 --> G[candidates: x11]
    E -- unset --> H{WAYLAND_DISPLAY set?}
    H -- yes --> F
    H -- no --> O[try x11_candidate]
    G --> O
    F --> K[try wlr_foreign_toplevel::candidate]
    K -- Some --> L[WlrForeignToplevelSource selected]
    K -- None --> M[try gnome_shell::candidate]
    M -- Some --> N[GnomeShellSource selected]
    M -- None --> O
    O -- Some --> P[X11Source selected]
    O -- None --> Q[NullSource]
    L --> Z
    N --> Z
    P --> Z
    Q --> Z
    Z --> R{backend type?}
    R -- wlr --> S[drain_events 25ms cap / reconnect on state.finished]
    R -- gnome-shell --> T[D-Bus GetFocusedWmClass 5s timeout]
    R -- x11 --> U[_NET_ACTIVE_WINDOW + WM_CLASS]
    R -- null --> V[return None]
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 initialized?}
    B -- No --> C[detect_frontmost_source]
    B -- Yes --> Z[dispatch to cached backend]
    C --> D[detect_session_kind]
    D --> E{XDG_SESSION_TYPE?}
    E -- wayland --> F[candidates: wlr → gnome-shell → x11]
    E -- x11 --> G[candidates: x11]
    E -- unset --> H{WAYLAND_DISPLAY set?}
    H -- yes --> F
    H -- no --> O[try x11_candidate]
    G --> O
    F --> K[try wlr_foreign_toplevel::candidate]
    K -- Some --> L[WlrForeignToplevelSource selected]
    K -- None --> M[try gnome_shell::candidate]
    M -- Some --> N[GnomeShellSource selected]
    M -- None --> O
    O -- Some --> P[X11Source selected]
    O -- None --> Q[NullSource]
    L --> Z
    N --> Z
    P --> Z
    Q --> Z
    Z --> R{backend type?}
    R -- wlr --> S[drain_events 25ms cap / reconnect on state.finished]
    R -- gnome-shell --> T[D-Bus GetFocusedWmClass 5s timeout]
    R -- x11 --> U[_NET_ACTIVE_WINDOW + WM_CLASS]
    R -- null --> V[return None]
Loading

Reviews (14): Last reviewed commit: "style(hook): apply cargo fmt to drain_ev..." | 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 4 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>
# Conflicts:
#	crates/openlogi-gui/src/main.rs
recchia and others added 2 commits July 3, 2026 10:55
drain_events silently ignored errors from flush/dispatch_pending/read, so a
compositor crash (abrupt socket close) left the backend returning stale
state forever instead of reconnecting, unlike a graceful Finished event.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <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