lspci reads PCI config space, and the kernel resumes a runtime-suspended
device to serve that read. On a hybrid laptop the discrete GPU idles in
D3cold, so the first lspci of a Hyprland config load spends over a second
waking it — longer than the 1.5s budget Hyprland gives the whole load.
The reload then fails at whichever line runs next, which is why the error
pointed at default/hypr/apps/1password.lua rather than at nvidia.lua.
Read the vendor, class, and device IDs from sysfs instead. Those are
served from cached fields and never touch config space, so nothing wakes
up. Classify by device ID while we're here: Turing is both the first
generation with GSP firmware and the first at 0x1e00 or above, and
Maxwell opens at 0x1340, one ID past the last Kepler part. Bounding the
older detector at both ends keeps pre-Maxwell cards off the 580xx driver
that cannot drive them, and picks up the Maxwell and Pascal parts the
lspci name regex used to miss.
omarchy-hw-nvidia was also checked in without its executable bit, which
it needs now that nvidia.lua runs it.
Fixes#6660
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 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>
Coding agents refuse to persist trust for $HOME, so the keybinding and
menu launches -- which inherit the session's ~ cwd -- re-asked on every
single session. ~/Work is already the work root Omarchy creates, and
trust there sticks.
Only applies when the cwd is exactly $HOME, so the inline alias still
starts in whatever project directory it was run from.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fix unclickable tray submenus by drilling down inside the popup
Clicking a tray menu entry that has children was a silent no-op: the
row called QsMenuEntry.display(), which renders a *platform* menu, and
Quickshell refuses that unless the shell root sets `//@ pragma
UseQApplication` -- shell.qml does not. The log shows "Cannot display
PlatformMenuEntry as quickshell was not started in QApplication mode"
and nothing opens. Apps whose whole menu is submenus, like
radiotray-ng's station list, were unusable.
Adding the pragma would be the wrong fix: it switches the entire shell
from QGuiApplication to QApplication, dragging QtWidgets into the
process and changing application-class behavior for the sake of one
popup -- which would then render as an unstyled platform menu beside
omarchy's own popup styling anyway.
Instead, submenus drill down inside the existing popup. A child
QsMenuEntry inherits QsMenuHandle, so it can feed a nested QsMenuOpener
and render through the same row delegate. Each level keeps its own live
opener on a stack -- a child entry is owned by its parent opener's
model, so collapsing to a single reassigned opener would destroy the
very entry being displayed. A back header row walks out one level; at
the root the menu renders exactly as before, and items without a
DBusMenu still use the platform fallback.
* Destroy submenu openers deepest-first and reset before switching items
resetTrayMenu() destroyed openers front-to-back and only cleared
submenuStack afterward. A deeper opener's menu entry is owned by its
parent's children model, so destroying the parent first could
invalidate an entry a still-live child opener referenced. Clear the
stack before tearing anything down, then destroy deepest-first so a
child is always gone before the parent whose model owns its entry.
openTrayMenu() reassigned activeTrayItem before calling resetTrayMenu().
trayMenuOpener.menu binds to activeTrayItem.menu, so that reassignment
immediately swaps what the root opener's children expose -- invalidating
entries any live submenu opener still referenced, before resetTrayMenu()
got a chance to tear them down. Reset first, then switch items.
Thanks @Copilot for catching both.
* Defer submenu reset until the popup's fade-out actually finishes
onTrayMenuOpenChanged reset the submenu stack the instant trayMenuOpen
went false, but the popup stays visible for the whole 140ms opacity
fade (PopupCard's own visible: open || card.opacity > 0) -- dismissing
from a submenu flashed the root menu mid-fade, and could resize or
reposition the fading popup if the two have different geometry.
Moved the reset to trayMenuPopup's own onVisibleChanged, which only
fires once the fade has genuinely completed. Switching to a different
tray item is unaffected: openTrayMenu() already resets explicitly
before assigning the new item, independent of whether the popup ever
dips to invisible (rapid reopen mid-fade never does).
Thanks @Copilot for catching this.
* Ignore tray menu clicks for a beat after changing submenu level
Changing level swaps the Repeater's model, which rebuilds the row
delegates synchronously -- a fresh row lands under a cursor that hasn't
moved. Submenu clicks used to be silent no-ops, which trained users to
click them twice, so that second click now fires whatever entry took
the spot. On radiotray-ng that means an accidental station switch.
Gate row and back-header clicks for 250ms after each level change. A
deliberate follow-up click is slower than that; a double-click is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Pin the submenu back header above the scrolling menu rows
The back header lived inside the Flickable's Column, so in a submenu
taller than the 420px cap -- exactly the long station list this
drill-down exists for -- scrolling down pushed the only way back off
screen, with no Escape or right-click alternative.
Move it into a pinned Column above the Flickable and account for its
height in the popup's contentHeight.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Reset the tray menu scroll offset when the drill-down is torn down
Flickable keeps its contentY across a model swap whenever the new
content is still tall enough to hold it. A menu dismissed while
scrolled therefore reopened part-way down with its first entries off
screen: reproducible on any tray app whose root menu outgrows the
420px cap, and now reachable on every app once a long submenu has
been scrolled.
Zero the offset in resetTrayMenu(), which runs both on teardown and
before switching items.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Toni Nowak <t.nowak@ai-flow.no>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Previously, right-clicking the audio icon muted only the output. Whereas
the switch at the top of the panel mutes all audio (inputs and outputs).
Pairing the right-click to the switch seems a bit less confusing, and
also matches the behaviour with other panels (e.g. Bluetooth, Tailscale,
etc).
Opening the cheatsheet outright put a menu in front of someone who had not
asked for one, and it blocked first run until they dismissed it. Go back to a
toast that opens the same menu when clicked.
The body carries real newlines now. It was written with a literal \n, which the
card renders as the two characters rather than a line break.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sharing a file or folder over LocalSend opened a terminal to run an fzf
pick over a find of the whole home directory, which is slow on a large
home, shows no previews, and looks nothing like the rest of the desktop.
The portal chooser is already how the other pickers here ask.
The chooser has a directory mode, so folder sharing asks for one the same
way, and neither entry needs a terminal to host a picker anymore.
A chooser that never opens is told apart from nobody picking anything, so
a portal failure says so rather than passing for a cancelled share.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A pinned internal monitor rule that referenced the omarchy_monitor_scale local
had the variable name captured as the scale, so clamshell recovery fell all the
way to the hardcoded 2.
Read the config the way Hyprland writes it: a key's value is whatever sits
between the `=` and the next separator, and a bare word resolves against a local
only when one exists, so a quoted string stays a string. Comments are cut before
anything is matched, and position gets the same resolution since it had the
identical bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Select a screen region and decode the QR code in it to the clipboard, so
an otpauth:// setup code shown on screen no longer needs a phone.
The decoded value is only ever placed on the clipboard, and marked
sensitive so clipboard history skips it. Decoding is restricted to QR so
a stray barcode elsewhere on screen can't take the clipboard instead.
Co-authored-by: Hlib Kanunnikov <hlibwondertan@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
* Label the keyboard widget with the xkb language code
The label was the first word of the layout description cut to three
characters, so a US layout read ENG and a Portuguese one read POR.
xkb already pairs every layout and variant with a short language code,
which is the code GNOME shows in its own indicator. Read that table once
at startup from xkbcli list and key it by description, which is what
hyprctl reports as the active keymap, so the same layouts read EN and PT.
The code is a language rather than a country, so it stays sensible for
the layouts named after neither: Esperanto is EO, Arabic is AR, and Latin
American Spanish is ES. Layouts missing from the table keep the old
truncated description.
* Read the exotic xkb rulesets for the keyboard label
xkbcli list leaves out the exotic rulesets, so layouts like trans were
missing from the table and fell back to the truncated description: the
IPA layout read INT rather than IPA. Those layouts ship in the same
xkeyboard-config package and set just as well, so read them too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Keep the keyboard label to three characters
The brief was used verbatim while the fallback was truncated, but not
every brief is two or three characters: Burmese (Zawgyi) is my-zwg and
Shan (Zawgyi) is shn-zwg. Selecting either widened the widget past its
neighbours on the bar. Drop the script suffix and cap the brief the same
way the fallback is capped, so those read MY and SHN.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Stop an xkb brief carrying past its own block
The brief was only cleared once a description consumed it, so a block
printing a brief without one would hand its code to the next block's
description and label it wrongly rather than falling back. Nothing in
the current xkb data does that, and the option groups were skipped only
because the last layout happened to consume its brief first. Clear the
brief when a line starts a new block so the pairing is explicit, and
cover the option list the 2-space match is what keeps out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fall back when a layout description names a built-in
A custom xkb group called constructor or toString reached an inherited
member of the lookup rather than a brief, and splitting it threw a
TypeError that took the whole label binding down instead of falling back
to the truncated description. Take the lookup only when it returns a
string.
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>
The letters name a panel; the numbers count them. One is the leftmost
panel in the right section, so the number matches the icon a user would
point at: a widget with no panel of its own is passed over, and so is one
that is hiding itself.
Counting rather than naming means the hotkeys follow the bar. Rearranging
the section, or adding a widget to it, renumbers the panels with no
binding to rewrite.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A scoped window read as "Fable weekly" beside "Session" and "Weekly", so
the one row that names a model was also the one row in lowercase.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Fix theme install code execution and drop tzupdate NOPASSWD
VULN-01 (C, D, E): a malicious theme can execute arbitrary code
during install through three injection sinks:
C: colors.toml values reach a sed script unsanitized.
GNU sed's `e` flag runs the pattern space as a shell command.
D: vscode.json `.name` is interpolated into a sed replacement
string without escaping sed metacharacters.
E: keyboard.rgb content is interpolated into a python3 -c
argument without validation.
Fix C by validating keys and values in omarchy-theme-color's parser
with a character allowlist. Byte-identical output for all 22 shipped
themes.
Fix D by escaping backslash, ampersand, and slash in the theme name
before sed interpolation.
Fix E by gating on ^[0-9A-Fa-f]{6}$ before interpolation, in both
the Framework 16 and ASUS ROG keyboard scripts.
VULN-02: the tzupdate sudoers grant has no argument constraint.
tzupdate -l lets any wheel user write a root-owned symlink to any
path. Drop it; nothing has invoked tzupdate since omarchy-cmd-tzupdate
was removed. Keep timedatectl set-timezone.
* Harden keyboard and vscode theme scripts
keyboard-f16: pass hex as sys.argv instead of interpolating into
python3 -c. The hex validation gate stays as the primary defense;
argv separation is defense-in-depth per OWASP guidance.
vscode: replace sed interpolation of theme name with jq, which
handles arbitrary strings safely via --arg. Validate extension IDs
against ^[a-zA-Z0-9._-]+$ before passing to --install-extension.
* Keep VS Code settings edits JSONC-safe
settings.json is JSONC, so routing the write through jq dropped theme sync
entirely for anyone with a comment or trailing comma in the file, including
the `{ "workbench.colorTheme": "",\n}` shape Omarchy itself creates. Edit in
place again and close the injection by validating the theme label instead.
Scope the extension-id guard to the install so a malformed id no longer skips
the colorTheme write, and treat a missing descriptor field as empty rather
than the literal string "null".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Widen the accepted colors.toml value charset
The sanitizer dropped gradient angles, decimals, underscored palette
references, and paths, which vanish from --raw/--all and leave a raw
{{ placeholder }} in the generated config. Allow the punctuation real
palettes use, keep out everything sed treats as special, and say so on
stderr rather than dropping a key silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Show the per-model weekly limit Claude's usage endpoint reports
Model-scoped allowances arrive in the payload's limits array, not in the
seven_day_<model> buckets, which come back null. Read them so a window
like Fable's own weekly limit stops being spent against invisibly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Read every model-scoped window, and title it for what it is
A model can hold more than one scoped window, so keying the dedupe on the
model alone dropped whichever came second — including a fuller one that
decides the headline. The model and the window kind together make the key,
and both make the title, so one model's two rows read apart.
The panel guesses a window out of the label, and that guess cannot survive a
model name: "Opus 5 (1M context)" parses as a one-minute window and renders
as a second "Session". The collector states the title outright now and the
panel takes it, eliding a long one rather than running it into the percentage.
Scoped percentages are read on whatever scale the payload speaks, the way the
flat buckets already are, rather than assuming percentages, and a model that
names only an id still names a window worth showing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
A ping at hyprland.start answers for a machine that has not finished coming up.
Ethernet is still negotiating DHCP, so a working desktop was told to set up
Wi-Fi and offered an update it could already have run. Ask NetworkManager
instead: -s returns once it has tried every connection it could auto-activate,
which is the first moment the answer means anything, and -x then takes that
answer as it stands rather than waiting out a timeout that a laptop with
nothing to connect to would spend in silence.
The update prompt now waits for a connection rather than being phrased around
not having one. There is nothing to update against until a link lands, and one
usually does land later on the machines that started without it, so the prompt
follows the connection whenever it arrives.
That wait runs detached. It outlasts first run by design, and the keybindings
menu is on screen behind it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The welcome toast spent three lines telling you about a cheatsheet that takes
one keystroke to read, and the only way to act on it was to click the toast,
which opened that cheatsheet. Open it directly instead.
Dismissing the menu exits non-zero, since no selection was made, so the step
tolerates that rather than failing first run and retrying the whole sequence
next login.
It also goes last now. The menu blocks until it is answered, and the Wi-Fi and
update toasts are the only other things left to show, so sending those first
leaves them waiting underneath rather than behind an open menu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 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>
* Place a bar widget on a bar without the widget it names
'omarchy bar put X --after Y' refused outright when Y was not on the bar, so
migration 1786279107 failed for every user whose clock is their own clone of
omarchy.clock rather than the built-in, and took the rest of the migration
chain down with it. put is the verb a migration or an install reaches for
precisely because it cannot know what the bar it places into looks like, so it
now falls back to the widget's usual spot instead of failing. 'plugin enable',
which someone types, still says when it cannot find the target.
A clone also answers as a placement target now, whether it is the widget the
placement named or the anchor the fallback lands against: cloning the clock
leaves a bar carrying your id where omarchy.clock used to be, and a caller
naming the source means the clone that took its place, the way resolveEnabledId
already routes calls to it. So the widget sits next to that clock rather than
at the end of the section.
Fixes#6678
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Keep asking a shell that is still starting
An 'omarchy update' landing while the shell restarts failed migration
1786279107 twice over. Quickshell answers a call made before it finishes
loading with "Not ready to accept queries yet." on stdout and exits 0, so a
caller polling with a ping read a starting shell as up and then took that
sentence for the answer to its real call; report it as unreachable, which every
caller already knows how to handle, and omarchy-restart-shell stops cutting its
readiness loop short on it too.
Reading the plugin manifests is a subprocess behind that, so IPC starts
answering before the registry knows the widget it is being asked to place, and
put refused it as unknown. Say which of the two it is and let put keep asking.
Only a shell that was never there is nothing to fail over. One that never
finishes starting, one that stops responding, one too old to know the call at
all: each has to fail, since omarchy-migrate records a migration that returns 0
as done, and the widget is then never placed and never asked for again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fall back for the shell an update has not restarted yet
omarchy-update runs its migrations before omarchy-update-restart, so the shell
answering migration 1786279107 on the update that carries this fix is still the
one that shipped without it, and it refuses the placement exactly as before.
The users this is for would have watched one more update go wrong. put owns the
fallback it documents, so let the command carry it: asked again without the
neighbour the shell says it cannot find, that shell places the widget.
A restarted shell never answers this way — it falls back itself, and knows to
look for a clone of the widget the placement named, which the command cannot.
Having answered once is now remembered across both asks. A shell that speaks
and is then gone has stopped mid-request, and reading that as a machine that
never had one would leave the migration recorded as done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Wait for a shell that has not appeared yet
A shell being spawned has no socket to answer on, and nothing tells the command
a launch is under way, so a put landing in that window read the silence as a
machine without a shell and carried on — leaving the migration recorded as done
with nothing placed. Give one three seconds to turn up first. A machine that
genuinely has no shell still carries on, three seconds later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Leave a clone of the widget being put where it is
A clone is the widget it was cloned from wearing its owner's name, so a bar
carrying one already has what put is being asked to place. put only saw the
literal id, and enabling a first-party source whose clone is active is how you
switch back to the built-in — so a migration placing omarchy.keyboard-layout
would have handed a user's own copy back for the shipped one, and called it
done. Targeting learned to read a clone as its source; presence had not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Trim the comments on the bar put path
Roughly a line of comment per line of code, most of it restating what the code
and the assertion messages already say.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Following the rule as written is what put apply back in the top-level group
listing right after every command in it was hidden: the table drives that
listing on its own, with no regard for whether anything in the group is
visible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setup is where a user goes to configure something: direct boot, security
keys, hibernation. These three are not that. omarchy-apply-system is the
ISO's entry point in the target chroot, omarchy-apply-hardware is what it
calls for device quirks, and omarchy-apply-lock is called by
install/config/lockscreen-pam.sh.
apply is the verb they already used to describe themselves, and it carries
the contract: declared state under install/ converged onto the machine,
idempotent, safe to repeat.
The group gets no GROUP_DESCRIPTIONS entry on purpose. That table drives the
top-level group list on its own, so an entry would put apply back in front of
users even with every command in it hidden, the way provision already stays
out. A test covers it.
The ISO installs the runtime from the mirror it ships with, so it moves to
the new names in lockstep and no compatibility route is needed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The setup group is described as interactive setup wizards, but these three
are not that. omarchy-setup-system is the ISO's entry point in the target
chroot, omarchy-setup-hardware is what it calls for device quirks, and
omarchy-setup-lock is called by install/config/lockscreen-pam.sh. None of
them is something to browse to and run.
Hiding only affects listings, so the ISO and the install leaves keep routing
through the CLI exactly as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Apply the Broadcom Wi-Fi quirk to Macs without a T2
brcmfmac lets the Wi-Fi firmware run the WPA handshake itself, and on Apple
hardware that offload fails against an access point in WPA2/WPA3 transition
mode: the client associates, the four-way handshake never completes, and
NetworkManager reports the password as wrong. feature_disable=0x82000 turns off
the firmware supplicant and authenticator so wpa_supplicant does the handshake
in software.
That quirk already shipped, but only for Macs with a T2 chip. The bug is in the
Broadcom firmware rather than in the T2 bridge, so it was never the right thing
to gate on: a MacBookPro11,4 has BCM43602 with 2015 firmware, fails exactly this
way, and got nothing. Gate on the hardware that actually has the firmware — an
Apple machine with a Broadcom wireless part — which covers both.
Moving it out of fix-t2.sh also leaves one owner for the file. Two leaves writing
the same config would have meant the later one silently winning, decided by an
ordering in all.sh nobody would think to check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Gate the Broadcom Wi-Fi quirk on the T2 ID or a brcmfmac chip ID
Sniffing lspci for an Apple vendor with a Broadcom network controller made T2
Macs depend on a detection line they never needed: they carry a T2 PCI ID that
is always there, and the class name half of `lspci -nn` comes from the pci.ids
database. Keep their original gate untouched.
Naming the rest by DMI model does not hold up either, because the model year
does not predict the part. A MacBookPro11,4 from Mid 2015 carries a BCM43602
and needs this; a MacBookAir7,2 from Early 2015 carries a BCM4360 and does
not. Covering the lineup by name takes around twenty identifiers across four
product lines and grows every time Apple ships hardware.
The set has an exact definition already: the PCI IDs brcmfmac binds, from the
driver's own brcm_hw_ids.h. That reaches the 2016 and 2017 MacBook Pros and
the T2-less iMac19,1 and iMac19,2 that a hand-written list missed, and it
leaves out the BCM4360 Macs for free, since their out-of-tree wl driver would
never read a brcmfmac option anyway.
Matching an exact vendor:device ID also drops the piped `grep -q`, which
returns 141 under pipefail once the producer is killed by SIGPIPE (#6608). The
test runs the leaf with pipefail so the chatty lspci stub proves it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fix Macs already installed without the Broadcom Wi-Fi quirk
The quirk is written at install time, so a machine set up before it shipped
never gets it, and no pre-T2 Mac ever did. Those installs still fail the WPA
four-way handshake against an access point in WPA2/WPA3 transition mode,
which is the state the reporter had to repair by hand.
Appending leaves anything else in the config alone: modprobe reads every
options line for a module, and nothing else sets feature_disable. Only an
active options line counts as already applied, and the driver keeps the old
behaviour until it reloads, so this asks for a reboot rather than pulling
brcmfmac out from under a connection that currently works.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
The menu ranks the launcher bindings by hand, and Herdr had no rule, so it
fell into the unranked middle far from the terminal it sits beside in the
bindings. Rank it right after Tmux and shift the rest down, and give
"Show Herdr key bindings" the same treatment next to its Tmux counterpart
at the bottom.
The cache key is bumped so machines holding records from the old order
rebuild them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Forking perl to produce {"menu":"<route>"} cost more than the IPC call it
fed. jq is already a runtime dependency and emits identical JSON.
Opening the menu drops from ~42ms to ~28ms.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thumbnails were generated one ImageMagick process at a time. Queue the
missing ones and drain them across every core with vipsthumbnail, which
decodes and encodes faster and lets the per-process startup overlap.
Cold cache for a 92 wallpaper directory drops from 14.2s to 1.3s, and
the bundled theme previews from 2.0s to 0.26s.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Turn Bluetooth off with an rfkill soft block
BlueZ never persists an adapter's Powered property, so turning Bluetooth off in
the panel lasted only until the next boot. Omarchy's answer was AutoEnable=false,
which persists nothing either — it just means "never power the adapter on", so
Bluetooth came up off every boot whatever the user had chosen.
The soft block already does the job. systemd-rfkill saves every switch under
/var/lib/systemd/rfkill and restores it early on the next boot; that is the
entire purpose of the unit. Blocking also covers every controller at once, where
bluetoothctl only ever addresses the default one.
So the block becomes the state and BlueZ follows it: with AutoEnable back at its
stock default, lifting the block is enough for bluetoothd to power the adapter up
on its own. Powered still tracks the block, so the panel switch and icon read it
exactly as before. Everything that turns Bluetooth on or off goes through
omarchy-bluetooth-power, because bluetoothctl power on fails while a block is set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Carry installed machines over to the rfkill block
Existing installs have AutoEnable=false, so their adapter is down at every boot
and Powered is the only record of what the user actually wants. Read it before
anything changes, hand it to the block, then put AutoEnable back to its default
so bluetoothd can act on that block. Only the exact line Omarchy wrote is
reverted, so a hand-edited opt-out survives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Ask the power helper for a direction, not a toggle
The helper runs detached and the switch only moves once BlueZ catches up, so a
second click inside that window re-read the pre-click state and undid the first.
The panel already knows which way it wants to go, so let it say.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Read every controller and bound the power-up wait
The block hits every Bluetooth radio at once, but the state was read from a bare
bluetoothctl show, which reports the default controller only. A powered dongle
sitting behind a powered-down internal controller read as off and got blocked
along with it. Enumerate the controllers and take any powered one as on, exposed
as is-on so callers do not each reinvent the read.
The wait counted probes rather than time, so a wedged D-Bus turned a two-second
bound into roughly fifty across a full power-up. One deadline around the whole
wait holds it near nine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Change the radio through sudo in the migration
/dev/rfkill is only writable unelevated from an active graphical seat, so an
update run over SSH failed here with EACCES. Migrations run under bash -e, so
that aborted before the config revert and the marker, and aborted again on every
retry. The privilege guidance already calls for sudo on machine-wide work run
from a visible terminal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Hold the indicator peek open while the pointer is on the bar
Revealing the hidden indicators widens their section, and a section that
grows can slide a neighbouring widget under a pointer that never moved.
Collapsing the peek on that un-hover narrowed the section again, moved the
neighbour back out, and re-opened the peek, so a pointer resting in the bar
space beside a grown section stuttered the bar until it moved away.
Hold the peek while the pointer is anywhere on the bar and close it only
once the pointer has left, which keeps the reveal-on-empty-space gesture
and drops the feedback loop.
Fixes#6581
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Assert the whole-bar hover helper does what the peek depends on
The earlier assertions all held against a no-op setBarHovered, which would
leave barHovered false and let the oscillation straight back in. Pin the
assignment and the collapse re-run too, so the helper cannot be emptied
without the suite noticing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Let the delayed peek re-check collapse only, never open
The timer assigned centerSectionRevealHeld outright, so it opened the peek
from bar hover alone. A pointer resting on the left section that dipped off
the bar and returned inside 120ms left the timer pending with barHovered
true again, and the indicators revealed without the pointer ever touching
the center section.
Opening stays the center section's own gesture in setCenterSectionHovered.
The timer now only closes what that opened, and the test asserts the
invariant against the whole file rather than one helper body that never
had the offending assignment in it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Tally bar hover per monitor instead of sharing one flag
Every screen's bar wrote the same barHovered bool, last writer wins. Sliding
along the top edge from one monitor's bar to the next can deliver the enter
before the leave, leaving the flag false under a live pointer; the collapse
then fired on a peek the user was still hovering, and no further hover change
arrived to correct it until the pointer left and came back.
Counting each surface's hover makes the order irrelevant. A bar destroyed
mid-hover — unplugging a monitor — never sends a leave, so it hands its
tally back on destruction rather than holding the peek open for good.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Warping to a target window's center selects the wrong window when a smaller
one covers that center: slurp keeps highlighting the coverer, so Tab could
never leave it. Navigation now warps to the most central point that resolves
back to the target, and skips windows that hovering could not reach either.
Unbinding the picker's transient keys by name also took a same-key binding
out of the user's own config with it; the bind handles are now kept and
removed individually.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Keyboard window capture in the region picker: Ctrl+Return, Tab, arrows
Return already captures the entire focused monitor while slurp is open,
but grabbing a single window required the mouse: hover highlights a
window, a click captures it. Now the keyboard can do the same.
Ctrl+Return captures the selected window — the one under the cursor,
falling back to the focused window. Tab and Ctrl+Tab cycle that
selection through the workspace's windows in reading order, and the
arrow keys move it spatially; both warp the cursor to the target
window's center, so slurp's own hover highlight tracks the selection.
Ctrl+Return is implemented like --take-fullscreen: a layer-scoped bind
flags the intent via a marker file and dismisses slurp, and the picker
resolves the window under the cursor. On an empty workspace the pick
resolves to nothing and exits as cancelled.
Ctrl is the chord modifier because slurp reacts to held Shift by
squaring the selection's aspect ratio, which visibly reshapes the
hover highlight mid-chord. None of the keys slurp itself uses (Escape,
held Space to move a selection, held Shift) are bound, so its own
keyboard behavior is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Resolve overlapping windows the way slurp highlights them
slurp highlights the smallest box under the cursor, but the capture and
selection hit-tests returned the first geometric match in hyprctl
clients order. With a floating window over a tiled one, Ctrl+Return
could capture the window underneath the visibly highlighted one. Track
the smallest containing window instead, in both the capture resolution
and the selection-movement origin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Document the region picker's keyboard modes in its usage line
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Resolve keyboard capture and navigation from one rectangle list
Duplicate window geometry (tabbed groups, stacked floating windows) stalled
the Tab cycle on the first copy, and the smallest-window-under-cursor
tie-break was applied to two differently ordered lists, so navigation could
cycle from one window while Ctrl+Return captured another. Ctrl+Return over a
gap or an empty workspace also ignored the monitor rectangle slurp was
highlighting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Capture the highlighted window with Return, the display with Ctrl+Return
The picker highlights a window under the cursor far more often than a whole
display, so the unmodified key takes the highlighted rectangle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Mirrors the Tmux keybindings menu on SUPER + ALT + K. Herdr has no CLI
that dumps resolved bindings, so the action list and its defaults come
from `herdr --default-config`, where every action appears as a commented
assignment, and the user config overrides what it sets.
Prose in that default config can read like an assignment, as in
`# type = "popup" opens a session-modal terminal`, so a line counts only
when its value is a bare string or an array of them. Both TOML quote
characters open a string, and with no config file at all the listing
shows Herdr's own defaults rather than the Omarchy seed config Herdr
never loaded.
Mirrors the Tmux binding on SUPER + ALT + RETURN. Herdr reattaches to the
persistent session on its own, so the launcher needs no attach-or-create
dance.
The setup progress screen stacked from row one while the greeter that precedes
it was already centered, so first boot changed shape between its two frames.
Center it on the same block height the ISO install dashboard uses — logo, blank,
title, blank, bar, blank, tip — and repaint it if the VT resizes, which is the
same virtio-gpu KMS handoff the greeter already watches for.
The greeter's ttfx call never passed --xterm-colors, so it hit exactly the
failure the comment above it describes: ttfx resolves even indexed stops to
truecolor, and the console reduces 256-colour codes well but 24-bit ones badly.
The gradient was being crushed rather than rendering as the indexed palette it
was written for, and the settle colour drifted off the green logo drawn beneath
it.
--canvas-width was cols-1 to stay off the autowrap column, which held for tte.
ttfx centres its text two columns right of plain centering, so cols-1 puts the
animated logo a column off the static one and it jumps when the effect starts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Machines without @factory fell back to a degraded reset that kept the
current system and only wiped user state. Turn them away with an
explanation instead, and drop the degraded staging path.
The first-boot worker still honors a wipe-degraded marker so a reset
staged by an older version finishes its scrub rather than handing the
machine over with the seller's accounts intact.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
tmux set the outer terminal title from `set-titles-string '#h:#W'`, which
is what Hyprland reads for the group bar label. herdr had no equivalent,
so grouped terminals kept whatever the shell or ssh last set - most
visibly wrong when connected to a remote machine.
herdr now renders `ui.window_title` on the server, so mirror the tmux
line. herdr's own default matches, but setting it here keeps the group
bar correct regardless of what upstream picks as its default.
The timezone prompt falls back to `gum filter` when tzupdate can't guess a
zone, which is the common case on a first boot with no network yet. That was
the one widget in the form without a GUM_*_PADDING export, so it drew flush
left while every other step sat centered under the logo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clearing it for a "Starting Omarchy..." card only flashed another screen
before SDDM took over. The cursor now stays hidden through the handoff and
comes back only on the failure path, where the retry prompt needs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/etc/profile.d/locale.sh only runs for login shells, so bash started by
SSH or herdr's remote bridge ran in the C locale, where printf emits
\u/\U escapes literally instead of the character.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Keep the bar mapped while hidden so revealing it is instant
Hiding the bar set the panel invisible, which unmaps the layer surface and
releases the scene graph with it. Every reveal then had to rebuild all of
it: a new layer surface, a configure roundtrip, re-shaped glyphs and
re-uploaded textures, and a first frame before anything appeared.
Measured on a 2560x1440 screen, showing took 155-175ms against 20ms to
hide, and 400-595ms on the first reveal after a cold start. Splitting the
cost showed the exclusive-zone reflow was not to blame: show latency was
the same on an empty workspace as on a tiled one, and windows finished
moving ~15ms after the bar was already on screen.
Park the bar one bar-width past its anchored edge instead, and drop its
exclusion zone while hidden. The surface stays alive, so showing is only
a margin change: 10-14ms in both directions, at every bar position.
Since a hidden bar is now mapped, layer_present no longer proves the bar
is visible; the session acceptance test asserts on-screen geometry.
* Fix layer visibility checks on offset monitors
* Handle rotated outputs in layer visibility checks
* Cover hidden bar behavior in acceptance tests
---------
Co-authored-by: David Heinemeier Hansson <david@hey.com>
A 1500ms budget plus the one 100ms poll interval the trailing sleep can
overshoot is exactly 1600ms, which was the bound — but the test measures a
whole process around that, so startup pushed real runs to 1602ms. Carry
another interval. Two intervals of overshoot, the regression this guards,
still trips it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gum and ttfx ship in omarchy-base.packages and setfont comes with kbd, so
none of them needs a presence check — and every setfont call already
tolerates a failure anyway. btrfs-progs is in omarchy-other.packages and a
reset genuinely cannot proceed without it, so that one keeps its guard and
moves to the helper.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The form's 0/1/130 statuses gate both the ISO configurator and first-boot
setup, and nothing tested them. Stubs gum with scripted per-screen answers
and drives each prompt bare under `set -euo pipefail` — the shape that makes
the status capture load-bearing, since a cancelled prompt is a failing
assignment. A RETURN trap marks that the prompt returned its status rather
than the shell dying inside it; both exit identically otherwise, so that
marker is what catches a regression to a plain `status=$?`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Swap terminaltexteffects for ttfx
ttfx is a Rust port of terminaltexteffects that renders byte-identical
frames as a single dependency-free binary. Same option names, defaults,
and exit codes, so every invocation here is unchanged apart from the
command name.
The screensaver runs at --frame-rate 120 with --random-effect. On a
fullscreen canvas Python cannot hold that for the heavier effects
(beams: 14.1 ms/frame against an 8.3 ms budget, so ~71fps); ttfx renders
the same effect at 564fps. Startup drops from ~107 ms to ~1 ms, and the
base image no longer needs Python for the screensaver.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Need a migration
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The keyboard layout list, the account and hostname validation rules, and the
gum prompts that ask for them all existed twice: once in the ISO
configurator's user step, once in first-boot owner setup. Nothing kept the
copies honest, and they had already drifted — a layout removed on one side
moved English (US) onto a page boundary on the other, burying the default at
the bottom of a screen of layouts.
install/provisioning/setup-form.sh is now the only copy. The PKGBUILD's
existing `cp -a install` ships it to /usr/share/omarchy/install/provisioning/,
and the ISO build vendors that very file out of the runtime package it
bundles, so an install and the first boot that finishes it cannot offer
different layouts or accept different usernames.
Cancel handling is unified along the way, which is what made the prompts
shareable at all. Every prompt reports 0 (answered), 1 (Esc — unwind to the
start of the form), or 130 (Ctrl+C — a side channel each caller defines).
Previously Esc and Ctrl+C were indistinguishable here: both re-asked the same
field, so there was no way back to an earlier answer. Ctrl+C now offers a
confirmed reboot instead. It cannot be a SIGINT trap — gum reads Ctrl+C as a
byte in raw mode, so the shell never receives the signal — so it hangs off the
exit status.
Status capture is written as `x=$(gum ...) && status=0 || status=$?` because
this script runs under `set -e`, where a cancelled prompt is a failing
assignment that would kill setup before the status could be read.
English (US) also leads the layout list now, ahead of the other English
variants. gum choose paginates in --height-sized pages and jumps to the page
holding --selected, so an alphabetical default landed wherever the list length
happened to put it.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>