Commit Graph
14 Commits
Author SHA1 Message Date
f4b832eba5 fix(monitor): fix display mirroring recovery and UI state (#6457)
* fix(monitor): prevent mirror toggle deletion during recovery and fix UI state

* Assert the external monitor helper counts mirrors as active

The helper now asks `hyprctl monitors all -j`, so the test that pinned it
to plain `monitors` failed. A mirrored external is absent from plain
`monitors`, which reads as a disconnect and hands the mirror toggle to
recovery.

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

* Keep the mirror state line when nothing is mirrored

Piping the first mirroring monitor into the branch fed jq's test() a null
whenever no output mirrored, and jq aborts there rather than falling back
to "". The panel reads this output by line, so the missing line shifted
the focused monitor, the scale, and the display list up one, and left
mirroring reading as on whenever the external display had focus.

Select first and branch inside the pipeline, so the branch only ever sees
a monitor and the empty case falls to "" as the lines around it do.

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

* Cover the monitor panel state the shell reads by line

Nothing exercised omarchy-monitor-state, so both the mirror direction it
reported and the jq that reported it went unguarded. The panel reads the
output by line index, where a helper dying mid-script costs a line and
shifts every field below it into the wrong property without failing.

Assert the line count alongside the fields, over extended, mirrored both
directions, and clamshelled displays.

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

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:25:44 +02:00
3b08a85ad1 Recover monitors Hyprland brought up with no mode (#6701)
A monitor powered off when the machine boots — a smart strip cutting AC, the PC
coming back on its own — still answers DDC, but with a partial EDID that carries
no video modes. Hyprland takes the connector as present and brings the monitor
up at 0x0. Powering it on afterwards changes nothing: the connector never
dropped at DRM level, so no hotplug fires, nothing re-reads the EDID, and the
screen stays black until a reboot.

Only a reload re-reads it. Forcing a DRM re-probe would work too but needs root,
and the kernel's cached mode list stays empty without one, so there is nothing
cheaper to poll: the reload is both the fix and the only way to learn whether it
was needed. Poll only while a monitor is in that state, back off from three
seconds to a minute, and stop as soon as one reports a mode — the machine can
sit black all night, and powering the monitor on fires no event to stop on.

Nothing will ask again if this loop gives up, so an unreadable answer is not
taken for a healthy monitor. It is also not waited on forever: a compositor that
stays silent has gone, and with it the session and any reason to keep asking.

Reloading on our own schedule means minding the reload guard, which exists to
keep Hyprland out of package-owned config mid-transaction, and which only
disables the automatic reloads. The guard can now be asked, and recovery holds
off while a transaction is in flight.

A reload lands a monitor at 0x0 the same way a boot does, so configreloaded is
watched alongside the hotplug events. The recovery's own reload comes back
through it, and a lock keeps that from stacking a second loop. Contention waits
rather than drops: a trigger arriving while a loop is exiting is the last one
that will come, and one arriving while a loop is running is answered by its next
pass anyway.

Mirrors are dropped from `hyprctl monitors`, so the check asks for all of them
and filters the disabled ones itself. Monitors turned off on purpose sit at 0x0
too, and re-applying config would fight the user over those.

The existing poll here recovers internal panels on docked laptops and never runs
on a desktop, which is where this happens.

Reported in #6668.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:26:24 +02:00
1e7bb66556 Recover a session lock stranded by a dead shell (#6692)
* Detect a compositor session lock through one helper

omarchy-restart-shell decided whether the session was locked by looking for
"LOCK" anywhere in the hyprctl monitors payload. That works, but not for the
reason the code reads like: Hyprland reports no lock state of its own, and the
string comes from solitaryBlockedBy, the list of reasons a monitor cannot hand
a client the whole screen. An active ext-session-lock is one of those reasons.

A substring match over the whole payload also answers yes to a workspace or a
monitor description that merely spells LOCK, and locking a desktop nobody asked
to lock is the worst way to be wrong. Match the reason list itself, and put it
behind a helper now that a second caller needs the same answer.

That second caller needs a third answer too, because the reason list is not
always readable. Hyprland stops at the first reason on a monitor with no
workspace yet — one just coming back — and returns before it ever looks at the
lock, so a missing LOCK there means nothing was asked rather than nothing was
found. Neither that nor an unreachable compositor is an unlocked session, and
locks strand precisely while outputs are coming and going, so both exit 2.
Callers that only branch on success are unaffected.

The test fixture claimed the string came from a workspace name, so it was
encoding the wrong model of the compositor. It now returns what Hyprland
actually returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Retake a session lock stranded by a dead shell

ext-session-lock keeps the session locked when its client goes away — that is
the point of the protocol, so a crashing lock screen cannot expose the desktop.
The cost is that a shell which dies while locked leaves the compositor locked
with nothing left to authenticate against: Hyprland's failsafe, which takes a
TTY or another machine to clear.

Nothing carried the lock across a restart. Quickshell relaunches itself after a
crash and omarchy-restart-shell can be run by hand, but both bring back a shell
holding no lock, so the failsafe stayed up. A fresh shell never holds a lock, so
a session already locked as the lock service starts can only be that orphan:
take it back and let the user type their way out.

Asking once is not enough. These deaths happen while outputs are going away,
and the replacement shell comes up inside that same window, where there is
nothing to read a lock off. So the question is asked until the answer means
something: on a short timer while the session settles, and again when a screen
comes back, since a display asleep for hours outlasts any timer worth running
and returns through a state the compositor cannot answer for either. Once an
answer does arrive the search ends, so the timer stops and later screen changes
cost nothing.

Three ways this could lock a desktop nobody asked to lock, all closed. A lock
this shell took itself is not an orphan, including one taken while the question
was in flight — omarchy-restart-shell re-locks a fresh shell, and the answer
cannot tell whose lock it found. Recovery runs once and clears the flag, so
nothing lingers to fire after an unlock. And PAM landing late reopens the
question rather than answering it: clearing the failsafe from a TTY is the
documented way out, so a yes from before there was anything to do about it may
be stale by the time it can be acted on.

The check has to live here rather than in the launcher. Quickshell's crash
handler re-execs in place, keeping the same pid, so a supervising process never
sees the restarts that recovery matters most for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Relaunch the shell when it dies without a signal

Quickshell restarts itself after a crash, but only from its signal handlers:
SIGSEGV, SIGABRT, SIGFPE, SIGILL, SIGBUS, SIGTRAP. Qt does not always leave
that way. When the Wayland connection fails, QWaylandDisplay::checkWaylandError
calls _exit() directly, which raises no signal at all — so the crash handler
never runs, no report lands in ~/.cache/quickshell/crashes, and the desktop is
left with no bar and no explanation.

That is how #6684 ends: the lock path meets a screen with no valid Wayland
output, declines to create a lock surface for it, and the connection dies with
EINVAL. Supervise the launcher so those deaths come back.

A clean exit is deliberate — omarchy-restart-shell stops the shell over IPC and
starts its own replacement — and a signal to the supervisor means the session is
going away, so neither relaunches. Neither does a shell that outlived its
compositor, though that takes more than one unanswered query to conclude: the
shell dies while outputs are being reconfigured, which is also when a busy
compositor can miss one without being gone. A shell that cannot stay up gives
up after five tries in a minute rather than spinning.

Signals need care now that a launcher stands between the session and the shell.
Bash defers a trap until a foreground command returns, so the shell runs as a
job and the supervisor waits on it. Stopping the launcher used to stop the shell
with it, back when this script exec'd Quickshell, so the signal is passed on
rather than leaving a desktop nobody is watching. One arriving during the
backoff sleep only reaches the trap afterwards, so the flag is read again at the
top of the loop: a shutdown racing a crash would otherwise get one more
Quickshell on its way out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:29:35 +02:00
David Heinemeier HanssonandClaude Opus 5 9ddcec272d Lock the screen before suspend instead of racing logind for it
Closing the lid could suspend the machine with the session still exposed.
omarchy-sleep-lock held a delay inhibitor and waited for Quickshell to report
the session secure, but a delay inhibitor is a timer rather than a promise:
logind suspends once InhibitDelayMaxSec expires, locked or not. The default is
five seconds, and closing the lid also reconfigures displays -- exactly when
the lock service is waiting for the screen set to settle before it can secure.

The race is now off the critical path. switch:on:Lid Switch runs
omarchy-system-lid-close, which requests the lock the moment the lid closes,
before logind has decided to suspend at all, so the inhibitor window usually
finds the session already secure. A docked lid close does not suspend
(HandleLidSwitchDocked defaults to ignore) and must not lock either, since that
is clamshell mode still in use on the external display, so the handler gates on
the same closed-and-undocked pair logind itself keys on.

Suspends that never touch the lid -- idle timeout, the system menu, low battery
-- still arrive through the inhibitor, so that path gets room to work too. The
shipped drop-in raises InhibitDelayMaxSec to 15s, and the helper derives its
budget from logind's live InhibitDelayMaxUSec rather than assuming the drop-in
landed: a machine that has not reloaded logind yet, or that carries its own
override, gets a budget that fits what logind will actually allow. It leaves
logind a fifth of its own window and caps at 12s, so a hand-raised window
cannot strand a closed laptop awake in a bag.

The wait itself had three defects. Its deadline arithmetic read EPOCHREALTIME
assuming a period, so under any comma-decimal locale the subtraction parsed as
bash's comma operator and silently voided the deadline, leaving only the
attempt counter to stop it. The lock request shared the status polls' timeout
and exited on first failure, so a shell 300ms slow meant suspending unlocked;
it now has its own budget and is simply retried, since asking again is
idempotent. And a refusal the shell reports on stdout with a zero exit --
missing-pam -- read as success, burning the whole window on a lock that could
never happen.

Every call is bounded by what is left of the budget rather than by an estimate
of what the step should cost, so the deadline holds on hardware slower than
anything the constants were fitted to.

Failure is still possible and it used to be silent. It now writes to the
journal and raises a critical notification, which lands on the screen the user
unlocks into.

Incidentally, monitor-recovery-test asserted lid probing against
omarchy-hw-clamshell after that logic moved to omarchy-hw-laptop-closed. It
aborted the file under set -e, skipping the nine assertions behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:35:17 -07:00
David Heinemeier HanssonandClaude Opus 5 528a4343a9 Add omarchy-hyprland-monitor-laptop for internal display lookup
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 12:28:55 -07:00
David Heinemeier HanssonandClaude Fable 5 9314c12718 Test that the clamshell poll is gated to docked laptops
Guards the dock-gating in omarchy-hyprland-monitor-watch: the poll runs
only when omarchy-hw-laptop and an external monitor are both present, via
sync_poll_state, with the event loop fed by process substitution so it
can start/stop the poll. Behaviourally simulated (stubbed hw checks +
synthetic monitor events): the poll runs only while docked and stops on
undock; a desktop or undocked laptop never polls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:03:26 -07:00
David Heinemeier HanssonandClaude Fable 5 54d79d9d16 Fix screen flash when waking from sleep into the unlock screen
The lock service arms a 5s blank timer whenever the screen locks, and
input at the lock screen re-arms it. Closing the lid sprays pointer
noise over the lock surface, so the timer was routinely armed right
before suspend, froze mid-countdown, and fired moments after resume --
blanking the freshly woken unlock screen under the user.

Guard the timer with a wall-clock check: if far more time elapsed than
the interval, the countdown slept through a suspend, so take a fresh
run-up instead of blanking. This also blanks the lock screen 5s after
an untouched resume.

Two accomplices made the flash worse and hid the real bug:

- The clamshell watcher's 2s poll fired an unconditional global DPMS
  enable whenever no external monitor was active, relighting any blank
  within 2 seconds (lock-screen blanking never stuck on undocked
  laptops) and racing the resume modeset. Recovery now only wakes
  displays when it actually re-enables one.

- Every keystroke at the lock screen dispatched a redundant DPMS
  enable via omarchy-system-wake, forcing extra modesets in the
  fragile just-resumed DRM state. Brightness "on" now skips the
  dispatch when every active display is already lit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:42:18 -07:00
David Heinemeier HanssonandClaude Fable 5 95c1ff0901 Fix stale monitor assertion that silently halted the test suite
9914513a unified internal monitor detection but left the test grepping
for the old jq expression. Since the suite runner stops on first
failure, every test file after monitor-recovery went silently unrun --
281 assertions were being skipped while the suite looked green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:44:05 -07:00
David Heinemeier Hansson 6454e7ac8a Fix phantom display scaling by reading configured scale in clamshell 2026-06-29 08:54:10 -05:00
David Heinemeier Hansson aa7350ab60 Fix clamshell display recovery 2026-06-23 09:23:29 +02:00
David Heinemeier Hansson ee65304b92 Handle clamshell monitor hotplug 2026-06-22 21:48:35 +02:00
David Heinemeier Hansson eb3cb5225c Delay session lock during display hotplug 2026-06-22 19:44:37 +02:00
David Heinemeier Hansson 8f5336e0d5 Protect against hyprlock crash when there are no screens at all 2026-06-22 19:28:43 +02:00
David Heinemeier Hansson 8f6e815c78 Recover internal monitor before sleep
Run internal monitor toggle recovery before the sleep lock path and retry recovery after Hyprland monitor removal events. This gives dock/lid-close races time to settle before suspend or external-display reconnects leave the session without a safe internal display fallback.
2026-06-21 15:42:24 +02:00