Skip to content

Honor D-Bus idle inhibits again: screensaver no longer fires over video playback - #8452

Open
itz4blitz wants to merge 6 commits into
omacom:quattrofrom
itz4blitz:idle-inhibit-dbus
Open

Honor D-Bus idle inhibits again: screensaver no longer fires over video playback#8452
itz4blitz wants to merge 6 commits into
omacom:quattrofrom
itz4blitz:idle-inhibit-dbus

Conversation

@itz4blitz

Copy link
Copy Markdown
Contributor

Closes #6475. Closes #7220. Closes #7199.

Summary

Quattro's idle service honors only the Wayland zwp_idle_inhibit_manager_v1 protocol, and nothing has owned org.freedesktop.ScreenSaver since hypridle was replaced — so an inhibit requested over D-Bus (every browser playing a video; VLC) was dropped on the floor and the screensaver fired over playing videos.

This adds omarchy-idle-inhibit-daemon (python3 + PyGObject, already in install/omarchy-base.packages), which:

  • owns org.freedesktop.ScreenSaver on both /org/freedesktop/ScreenSaver (Chromium) and /ScreenSaver (VLC/Firefox legacy path), plus org.freedesktop.PowerManagement.Inhibit with HasInhibit/HasInhibitChanged (Chromium's second inhibit, Firefox's third backend);
  • reaps inhibits whose client left the bus (NameOwnerChanged, spec-mandated lifetime);
  • tolerates unknown UnInhibit cookies (hypridle precedent — restarted clients must not wedge);
  • refuses the KDE-legacy GetActive/ActiveChanged: they mean "screensaver blanked", which this daemon cannot honestly report;
  • caps holder strings and outstanding cookies (LimitsExceeded);
  • publishes JSON to $XDG_RUNTIME_DIR/omarchy/idle-inhibit/state — atomic rename, 0600 from birth, written before each method reply, carrying the serving pid so that any consumer can tell live state from a SIGKILLed daemon's last write with no cross-process cleanup.

The idle service reads that state through omarchy-idle-inhibit-probe (one line of JSON when the serving pid is alive; silence/empty for absence, garbage, or a dead pid — every failure mode lands on "not inhibited") and gates IdleMonitor through IdleModel.idleEnabledAfter, the same node-tested module pattern as before. An inhibit arriving mid-cycle cancels it like Stay Awake does; an already-fired lock is left alone.

Why not the obvious alternatives

Plans doc included (plans/idle-inhibit.md) with the evidence: D-Bus activation cannot work here because Chromium and VLC probe with NameHasOwner (never auto-starts) and silently skip inhibition when unowned — the daemon ships as a graphical-session.target systemd user unit instead; and a D-Bus→Wayland bridge fails on sway-class compositors that ignore inhibitors without visible surfaces.

Wiring

Unit under default/systemd/user/, enabled for fresh installs via enable-user-units.sh and existing installs via migration (with the SSH-fallback symlink precedent). Restart=on-failure — the clean-exit stand-down (names owned elsewhere) must not respawn forever. No new dependencies.

Test

All red-green, mutations proven:

  • Live-bus integration (idle-inhibit-daemon-test.sh, real dbus-run-session): both paths answer, holders stack across Chromium/VLC/PM, bus-name recorded per cookie, held-state via one persistent connection, disconnect reaping, unknown-cookie tolerance, duplicate daemon exits 0 while the survivor keeps serving, probe silent for dead-pid state, missing/torn state → empty line, orderly SIGTERM leaves a forced-empty snapshot. 23 assertions; three consecutive clean runs.
  • Model + wiring: tolerant parser cases in node (idle-test.sh); QML gate/wiring assertions in house rg -F style (idle-inhibit-wiring-test.sh).
  • Migration: idempotent rerun, live-manager and SSH fallback paths (idle-inhibit-migration-test.sh).
  • Mutations: reaping removed, probe liveness removed, signals removed, restart policy reverted, final-publish removed, publish-on-write skipped, parser overclaiming, gate ignoring inhibits — every one fails its test.

An independent adversarial review pass ran between first implementation and this final form; its four majors/minors (restart flap, phantom inhibit after SIGKILL+takeover, inverted GetActive semantics, publish-failure hangs) are all addressed above, plus nits (cookie wraparound, truncation).

One note for merge ordering: ExecStart=/usr/bin/omarchy-idle-inhibit-daemon needs the omarchy-pkgs install lines (bin + unit) merged alongside.

…, omacom#7220, omacom#7199)

Quattro's idle service honors only the Wayland idle-inhibit protocol, and
nothing has owned org.freedesktop.ScreenSaver since hypridle was replaced,
so an inhibit requested over D-Bus — every browser playing a video, VLC —
was dropped and the screensaver fired over the playing video.

omarchy-idle-inhibit-daemon owns org.freedesktop.ScreenSaver (both object
paths: Chromium calls /org/freedesktop/ScreenSaver, VLC and Firefox the
legacy /ScreenSaver) and org.freedesktop.PowerManagement, reaps inhibits
whose client left the bus, and publishes its state as JSON to the runtime
dir. The idle service reads that state through a tolerant parser (a daemon
that is down reads as no inhibit, never as a permanent one) and holds its
idle cycle off while any inhibit is held, like stay-awake. D-Bus activation
is rejected with evidence — Chromium and VLC probe with NameHasOwner,
which never auto-starts — so the daemon ships as a graphical-session
service, enabled for existing installs by migration. No new dependencies:
python-gobject is already in the base package set.

plans/idle-inhibit.md records the design and the rejected approaches.
Create the temp file at 0600 in the same step that writes it: a plain
open() honors the umask first, which under a permissive umask left a
window where holder names were world-readable. Refuse to run without
XDG_RUNTIME_DIR rather than invent a state directory under $HOME or /tmp
— a predictable home/tmp path is the exact class the diagnostics fixes
removed. Quiet the PyGObject register_object deprecation notice that
would otherwise land in the journal on every start.
Four findings from the independent review, each fixed test-first:

- Restart=on-failure: standing down when the names are owned elsewhere is
  a clean exit 0, and Restart=always restarts on clean exits too — the
  stand-down respawned forever on machines with their own screensaver
  daemon.
- Phantom inhibits: a SIGKILL mid-inhibit left the state file claiming
  holds nobody had, and no code path could clean it up. Snapshots now
  carry the serving pid, and the probe (bin/omarchy-idle-inhibit-probe)
  falls silent for a dead pid — daemon death self-heals without any
  cross-process coordination. An orderly stop still republishes an empty
  snapshot first.
- GetActive/ActiveChanged dropped: they mean "screensaver blanked", which
  this daemon cannot honestly report — emitting them for inhibit-held
  state was exactly inverted. HasInhibitChanged stays; it has unambiguous
  inhibit semantics and Clight-class clients listen for it.
- A publish failure can no longer hang a D-Bus caller: handlers always
  answer, failures land in stderr. Oversized app/reason strings are
  truncated, outstanding inhibits capped at LimitsExceeded, and the
  uint32 cookie counter wraps instead of overflowing GLib's variant.
The SIGTERM test caught a bug the review missed: a Python signal handler
never runs while the C mainloop sits inside loop.run(), so the daemon
died by default disposition and the final publish never happened.
Registered through GLibUnix.signal_add instead — verified firing via
mutation. stop_serving now also writes a forced-empty snapshot rather
than republishing the live table: the holder client is usually still
connected while systemd stops us, so an honest final claim is nothing.
Copilot AI balanced review requested due to automatic review settings August 26, 2026 19:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds D-Bus idle-inhibit support so browser and media playback can suppress Omarchy’s Quickshell idle timers.

Changes:

  • Adds a D-Bus inhibit daemon and state probe.
  • Gates idle handling on external inhibit state.
  • Adds systemd rollout, migration, documentation, and tests.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 10 out of 12 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
bin/omarchy-idle-inhibit-daemon Serves inhibit APIs and publishes state.
bin/omarchy-idle-inhibit-probe Validates daemon state for Quickshell.
default/systemd/user/omarchy-idle-inhibit.service Runs the daemon per graphical session.
install/user/first-run/enable-user-units.sh Enables the service for new users.
migrations/1787769449.sh Enables it on existing installations.
shell/plugins/services/idle/Service.qml Integrates external inhibits into idle handling.
shell/plugins/services/idle/IdleModel.js Parses and gates inhibit state.
plans/idle-inhibit.md Documents the design and rollout.
test/shell.d/idle-test.sh Tests model parsing and gating.
test/shell.d/idle-inhibit-wiring-test.sh Checks Quickshell wiring.
test/shell.d/idle-inhibit-migration-test.sh Tests service migration behavior.
test/shell.d/idle-inhibit-daemon-test.sh Exercises the D-Bus daemon contract.
Suppressed comments (1)

plans/idle-inhibit.md:191

  • The wiring summary specifies both After=graphical-session.target and Restart=always, but the daemon must be ordered before that target and the unit deliberately uses Restart=on-failure. Update this summary alongside the unit so it records the actual pre-target D-Bus ordering and restart policy.
- `default/systemd/user/omarchy-idle-inhibit.service` — house pattern
  (`After=graphical-session.target`, `WantedBy=graphical-session.target`,
  `Restart=always`, `ExecStart=/usr/bin/omarchy-idle-inhibit-daemon`; the
  PKGBUILD install line lives in omarchy-pkgs and is called out in the PR).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

# Browsers and players request idle inhibition over D-Bus
# (org.freedesktop.ScreenSaver); the daemon must own the names before any of
# them probe, which means starting with the graphical session, not on demand.
After=graphical-session.target

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After=graphical-session.target did start us after autostarted browsers could already NameHasOwner-probe. Unit now uses After=dbus.socket / Requires=dbus.socket and keeps WantedBy=graphical-session.target so default target ordering starts us before the target is reached — same pattern as omarchy-sleep-lock.service. Migration test asserts the dbus ordering and fails if the graphical-session After= comes back.

Comment thread shell/plugins/services/idle/Service.qml Outdated
Comment on lines +368 to +372
// line and stays silent when the serving pid is gone — silence is the
// consumer's "not inhibited", so a dead daemon cannot pin idle off.
command: [root.omarchyPath + "/bin/omarchy-idle-inhibit-probe", root.externalInhibitStateDir + "/state"]
stdout: SplitParser {
onRead: function(line) { root.applyExternalInhibitState(line) }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SplitParser.onRead does not fire on a zero-byte probe, so the last applied inhibit stuck. Two halves: the probe now emits one empty line for a dead pid (same as missing/torn), and the Process tracks whether that run produced stdout and applies empty state from onExited if it did not.

# cannot pin the idle timers off; no bystander cleanup or coordination is
# involved.

stop_daemon

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous path sent SIGTERM via stop_daemon against an already-uninhibited snapshot, so it never exercised SIGKILL-mid-inhibit. It now creates a live holder, SIGKILLs the daemon, asserts the file still claims that holder and the dead serving pid, then asserts the probe emits one empty line.

Comment on lines +368 to +383
held=$(python3 - <<'PY'
import time
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib

bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
res = bus.call_sync(
"org.freedesktop.ScreenSaver", "/ScreenSaver", "org.freedesktop.ScreenSaver",
"Inhibit", GLib.Variant("(ss)", ("term.client", "hold across sigterm")),
GLib.VariantType("(u)"), Gio.DBusCallFlags.NONE, -1, None)
print(res.unpack()[0], flush=True)
time.sleep(600)
PY
) &
holder_term_pid=$!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

var=$(python3 ...) & made $! the assignment subshell. Both the SIGKILL and SIGTERM holders now run python3 directly in the background, same as the stacked-holder client earlier in the file.

Comment thread plans/idle-inhibit.md Outdated
Comment on lines +175 to +177
- **Daemon crashes**: the bus name drops; the state file goes stale. The
service's tolerant parser does not treat a stale file as a held inhibit, and
`Restart=always` brings the name back. Chromium/VLC re-probe per video.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unit is Restart=on-failure on purpose: clean stand-down (names owned elsewhere, exit 0) must not respawn. Plan rev 2 records that in both the crash edge-case and the wiring summary.

Comment thread plans/idle-inhibit.md Outdated
Comment on lines +7 to +10
Quattro replaced `hypridle` with the Quickshell idle service. hypridle registered
`org.freedesktop.ScreenSaver` on the session bus; the Quickshell service does
not, and `IdleMonitor { respectInhibitors: true }` honors only the Wayland
`zwp_idle_inhibit_manager_v1` protocol. An application that requests its inhibit

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflowed to full lines, breaks only at headings and list items.

Comment thread plans/idle-inhibit.md Outdated
Comment on lines +203 to +209
- **Daemon, over a real bus** (`dbus-run-session` + `busctl`): inhibit returns
a cookie and flips the state file; both object paths answer; a second sender
stacks; `UnInhibit` drops one; unknown cookie tolerated; a sender that exits
without uninhibiting is reaped via `NameOwnerChanged`; the name-loss case
exits 0; `HasInhibit`/`GetActive` agree with the state file; the state file
is 0600 and always complete JSON under an inhibit/uninhibit storm; the
initial "nothing inhibited" snapshot exists without any caller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The live-bus test requires GetActive to be absent (KDE-legacy screensaver-blanked semantics this daemon cannot honestly report). Test plan now describes that absence check; HasInhibit is what agrees with the state file.

After=graphical-session.target started us after autostarted browsers could already NameHasOwner-probe. After=dbus.socket matches sleep-lock.

SplitParser.onRead never fires on a zero-byte probe (dead pid), so the last applied inhibit stuck. The probe now emits an empty line for a dead pid, and the Process applies empty state from onExited when that run produced no stdout. The SIGKILL live-bus case now actually SIGKILLs a held daemon.

Co-authored-by: Cursor <cursoragent@cursor.com>
@itz4blitz

Copy link
Copy Markdown
Contributor Author

Lockstep packaging: omarchy-pkgs install line for the unit is in omacom/omarchy-pkgs#210 (omarchy-settings and omarchy-settings-dev). The daemon/probe binaries are already covered by the omarchy package's bin/* glob; the unit is the piece systemd would otherwise never find. test/shell.d/config-test.sh now asserts that install -Dm644 mapping.

itz4blitz added a commit to itz4blitz/omarchy-pkgs that referenced this pull request Aug 26, 2026
Stable is pinned to v4.0.1, which predates the unit, and settings-dev tracks quattro, which also lacks it until omacom/omarchy#8452 merges. An unconditional install aborts package() on those trees. Gate on the file so this recipe can land ahead of the upstream commit pin.

Co-authored-by: Cursor <cursoragent@cursor.com>
@belspectre

Copy link
Copy Markdown

Experiencing the exact same issue on Omarchy 4.0.1 where Firefox video playback fails to inhibit idle and triggers screen lock / screensaver. Looking forward to having this merged!

@Suzu1Dev

Copy link
Copy Markdown

Confirmed on my machine.

Environment:

  • Omarchy 4.0.0.r1930.g002c70a-1
  • Hyprland 0.56.2
  • Quickshell 0.3.1
  • Zen Browser 1.21.16b, native Wayland

Before the workaround, Zen reported MPRIS Playing, but Hyprland showed
inhibitingIdle: false. The journal also repeatedly reported that
org.freedesktop.ScreenSaver had no owner, and the screensaver still started
after 150 seconds during YouTube playback.

I tested a user-space adaptation of this PR using a cloned idle plugin plus the
D-Bus daemon/probe. Calls to both ScreenSaver object paths and the
PowerManagement interface correctly disabled Omarchy idle, and
UnInhibit/client disconnect immediately restored the original 150/300 second
screensaver and lock timers.

This confirms both the regression described here and the proposed D-Bus bridge
approach on this setup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants