The wildcard granted passwordless root for timedatectl set-timezone plus any trailing arguments, so -H/--host and -M/--machine reached the SSH and machine transports as root. Systemd 261 guards argv injection into ssh, but -H still drives root's SSH client at an attacker-chosen host, and the transport resolves its helper through PATH; only Defaults secure_path stands between that and a planted ssh running as root. Match the argument with an anchored POSIX ERE that admits exactly one timezone token (no whitespace, no leading-dash segment, no traversal component), so no second argument and no option can ever match. The sole caller, omarchy-menu-timezone, passes one list-timezones value and is unaffected.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
(cherry picked from commit 0ae1694830)
Backport of the FIDO2 authfile fix (PR #7904 by @mdisec, merged to quattro as
23dab9ec) onto the v4-0-1 release branch.
pamu2fcfg wrote to /tmp/fido2 and the registration was then moved into place
with sudo mv. Any other local user can pre-create /tmp/fido2, and rename(2) does
not dereference the final component, so the privileged move installed the
attacker's symlink itself as pam_u2f's global authfile -- a file consulted by
sufficient lines in /etc/pam.d/sudo and /etc/pam.d/polkit-1.
The same move also carried the staged file's ownership into /etc, so on every
install to date /etc/fido2/fido2 is owned by the invoking user at mode 0644.
That needs no attacker, no race and no second account: anything running as that
uid can append its own credential and satisfy the machine's sudo prompt without
knowing the password.
The setup now creates a unique staging file as root beside the final authfile
and pipes pamu2fcfg into it, so root never reopens a caller-owned pathname,
rejects failed or empty enrollment output, publishes with an atomic mv -Tf,
cleans the exact staging file after every failure, refuses non-regular authfile
states, and installs root:root 0644. A migration repairs machines set up by
earlier versions by replacing the inode rather than chowning in place: a process
that already holds a writable descriptor on the legacy user-owned mapping keeps
it, so the repair has to leave that inode behind where PAM no longer reads it.
Clean cherry-pick: all six files are byte-identical to quattro, so merging
v4-0-1 into quattro resolves without a conflict. The migration's timestamp
(1787494718) is older than others already on this branch, which is harmless:
omarchy-migrate marks each migration by filename rather than tracking a
watermark, so this one runs on every machine that has not run this exact file.
test/shell passes: 195 files, including the three this adds -- the setup's
staging and failure paths, the migration's repair and unrepairable states, and
the removal's symlink handling. test/cli passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the input-device name fix (PR #8129 by @acrogenesis, merged to
quattro as 9285b19d) onto the v4-0-1 release branch.
Hyprland input-device and monitor names come from USB descriptors and hyprctl
output, so they are attacker-influenceable, yet the toggle and monitor commands
interpolated them straight into hyprctl eval and into generated Lua that
Hyprland re-executes on every reload. XF86TouchpadToggle is bound with
locked = true, so a malicious USB name reached Lua execution from the lock
screen as the logged-in user, and a persisted disable made it run on every
start. Publicly reported by Jorrit Jongma / Chainfire.
The disable is no longer executable Lua anywhere. The device name is stored as
plain-text data in a *-disabled-name sidecar and read back by a packaged module,
default/hypr/disabled-input-device.lua, on every reload; the live hyprctl eval
Lua-quotes the name and rejects control characters outright. The reload loader
excludes the two legacy filenames, so a leftover generated *-disabled.lua on a
not-yet-migrated install can never be sourced as code again, and a migration
recovers the device name from it and deletes it, sanitizing installs that ran
the vulnerable version. All four monitor scripts validate an output name against
a plain-connector-name pattern before writing it as Lua, closing the same latent
pattern in the siblings, and paths.lua treats a set-but-empty XDG_STATE_HOME as
unset to match the bash side.
Clean cherry-pick: all fourteen files are byte-identical to quattro, so merging
v4-0-1 into quattro resolves without a conflict. This branch ships no leftover
*-disabled.lua template of its own -- the tracked "disabled" files are the same
two quattro has -- so the migration is the only path that has to sanitize
anything here.
test/shell passes: 192 files, including the three this adds. The toggle suite's
public-PoC case passes here, as do the monitor scripts' accept/reject cases and
the XDG path cases. test/cli passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the DNS PATH pin (PR #8172 by @mdisec, merged to quattro as
4637735a) onto the v4-0-1 release branch.
omarchy dev link prepends a user-writable checkout's bin/ to sudo's secure_path
so privileged Omarchy commands resolve to the development versions, and that
reaches the subprocesses they launch too. This branch carries the same
passwordless grant -- etc/sudoers.d/omarchy-dns lets wheel run
/usr/bin/omarchy-dns Cloudflare, Google and DHCP without a password -- so the
packaged script ran as root while resolving bare helpers (dirname, install,
tee, rm, nmcli, systemctl, awk) through the caller's secure_path. Write access
to a dev checkout became arbitrary root execution, with no password prompt in
the way.
Pin PATH to trusted system directories once EUID is 0. The restriction lands
only after elevation, so the unprivileged wrapper phase keeps the caller's PATH
and can still find sudo or pkexec; every helper the privileged half uses is a
system utility, so it needs nothing from the checkout.
Clean cherry-pick: both files are byte-identical to quattro, so merging v4-0-1
into quattro resolves without a conflict. Verified by mutation: with the pin
removed, test/shell.d/dns-sudoers-test.sh fails at the poisoned-helper case;
restored, all four of its cases pass. test/shell (189 files) and test/cli pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the TOC renumbering (PR #8089 by @samirpokharel, merged to quattro
as b86d4505) onto the v4-0-1 release branch.
Dropping manual/43-extra-themes.md shifted every chapter after it down by one,
on this branch as much as on quattro. The TOC rode along on the old numbers, so
its last ten links pointed at files that are not there: resolving every
manual/*.md link in this branch's README before the change found ten that do not
exist, from 43-extra-themes.md through 52-unattended-installs.md. After it, all
of them resolve.
Clean cherry-pick: the TOC now matches quattro's, so merging v4-0-1 into quattro
resolves without a conflict. test/shell passes: 189 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the Taildrop toast fix (PR #7953, merged to quattro as 7e469f96)
onto the v4-0-1 release branch.
A delivery can land hours after it was sent, and the toast announcing it was
expiring after five seconds -- so a file that arrived while nobody was at the
machine was gone from the screen before anyone could click it open. Critical
urgency is what the shell reads as a popup that lives until it is clicked or
dismissed, the same thing omarchy-crash-watch uses to keep its click-to-diagnose
toast around.
The mechanism is already on this branch: omarchy-notification-send parses
options that follow the two positionals, and NotificationLogic.js gives a
critical popup duration 0, which never expires.
One conflict, in test/shell.d/notification-send-test.sh. #7953 added coverage
for the trailing-option path in the notify-send form it had then; #7926, which
merged after it on quattro and is already backported here, rewrote that file for
the Notify D-Bus form and carried the same coverage across. This branch
therefore already asserts what #7953 added -- an urgency and a glyph after the
description reach the call, and the urgency is set once -- so the branch's
version stands and the resolved file is unchanged.
Verified by mutation: with -u critical removed, test/shell.d/tailscale-receive-
test.sh fails at once; restored, it passes, including the new assertion that
every announcement waits to be answered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the shared URL check (PR #8174, merged to quattro as 68ab12f7) onto
the v4-0-1 release branch, on top of the #8067 backport it follows.
omarchy-theme-install and omarchy-plugin-add both clone a URL a stranger can
choose, and each carried its own copy of the rule that refuses a git option or a
transport helper before cloning. The rule now lives in omarchy-git-url-check and
both callers ask it: two copies of a security check drift, and the second copy
arrived four months after the first only because someone went looking for it.
That rule was also enforcing half of what it described. <helper>::<address> is
one of two shapes git resolves a remote helper from -- it also runs
git-remote-<scheme> for <scheme>://<address> whenever the scheme is not one it
connects itself, so ext::sh -c id and ext://sh -c id reach the same helper while
only the first was refused. Nothing exploitable follows on a stock system:
protocol.ext.allow defaults to never, and the :// spelling hands git-remote-ext
a command name it cannot exec. But a third-party helper installed on PATH is
reachable through the scheme form alone, and a guard is worth more when it
enforces the rule it states.
The :// shape cannot be refused the way :: is, because it is also how every
legitimate URL arrives, so the scheme is checked against the transports git
still connects itself: ssh git git+ssh ssh+git http https ftp ftps file.
git+ssh and ssh+git are on that list because they are spelled like a helper and
read as plain ssh; leaving them off would refuse a URL that clones today. ext
and fd are off it deliberately. A single colon is always scp-style ssh and a
bare path is always a path, so neither needs constraining.
The check fails closed: the callers read a non-zero status as a refusal, so a
missing omarchy-git-url-check refuses the URL rather than waving it through.
Clean cherry-pick on top of the #8067 backport: every file is byte-identical to
quattro, so merging v4-0-1 into quattro resolves without a conflict. test/shell
passes: 189 files, including the one this adds. test/cli passes, so the new
command's metadata is well-formed. Exercised the check here: https, scp-style,
ssh, git+ssh and scp-style IPv6 all pass, while ext:: ext:// fd:: fd:// and a
leading-dash form are refused, as is an uppercase EXT:// spelling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the sudoless Docker follow-up (PR #8098, merged to quattro as
06a3dbca) onto the v4-0-1 release branch, on top of the #8056 and #8080
backports it follows.
Group membership only takes effect on a fresh session, and in practice a logout
or newgrp is not enough -- only a reboot reliably applies it. The setup and
remove commands now flag the reboot and offer to do it right away with a gum
confirm, the same shape as the GPU toggle, and their notices say "after a
reboot" instead of pointing at logout or newgrp. The existing-user migration
reuses the removal command inside omarchy update, so it passes
OMARCHY_DEFER_REBOOT to skip the prompt there and lets omarchy-update-restart
handle the reboot once the whole update has finished.
Setup > Security showed Sudoless Docker under both Setup and Remove, and the
guards tested the running session's groups, which do not change until the
reboot: after enabling sudoless Docker the menu still offered Setup, the one
action that could no longer do anything, while Remove stayed hidden. Add
omarchy-sudo-docker as the single answer to both questions that differ in that
window -- by default whether this session can reach the socket, which is what
decides if a command must elevate, and with --configured whether the account is
set up for it, which is what the menu and the toggles need. It succeeds when
sudo is needed, so the Setup entry appears while sudoless Docker is off and
Remove once it is on. lazydocker and the Windows VM keep prompting until the
reboot lands.
Clean cherry-pick on top of the earlier backports: every file is byte-identical
to quattro except default/omarchy/omarchy-menu.jsonc, which merged into this
branch's menu and whose two Sudoless Docker lines match quattro exactly.
test/shell passes: 188 files, including the two this adds. test/cli passes, so
the new command's metadata is well-formed. Exercised the helper here: with no
socket it reports sudo is needed, and --configured answers from the account's
groups.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the plugin-add URL guard (PR #8067 by @bastidotnet, merged to
quattro as 30471bf3) onto the v4-0-1 release branch.
omarchy-plugin-add cloned a user-supplied git URL without the transport-helper
guard omarchy-theme-install already applies. That guard arrived with #7884,
which is on this branch, but it never touched plugin-add -- so the sibling
command still leaned entirely on git's own protocol.ext.allow=never to keep a
URL like ext::sh -c <cmd> from running a command at clone time.
Stock systems are unaffected: Omarchy sets no protocol.* override, so the git
default holds and there is no live exploit here. This is defense in depth --
it closes the gap #7884 left in the sibling path and drops a silent dependency
on a default the project does not control. Reject ext::/fd:: and leading-dash
forms; https, ssh, scp-style and token-auth URLs still clone, including an
scp-style IPv6 host, which carries :: of its own.
Clean cherry-pick: both files are byte-identical to quattro, so merging v4-0-1
into quattro resolves without a conflict. The guard reads the same as the one
already in bin/omarchy-theme-install on this branch. test/shell passes: 186
files, with the plugin-add suite's new cases all running here -- including the
pty-driven prompt case, which is the only path that reaches the guard's
leading-dash arm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the keypress fix (PR #8082, merged to quattro as 5d3299fb) onto the
v4-0-1 release branch.
gum 2.0 runs a spun command without the terminal attached, so the gum spin --
read -n 1 that held the presentation terminal open returned at once. Every menu
command that ended in a failure took its window down with it before the error
could be read, which is how a failed update looked like a terminal that just
quit.
Read the key directly. gum's own terminal query replies are still sitting on the
tty when the spinner stops, so drain those first or they answer the prompt on
the user's behalf. The /dev/tty node is there whether or not a terminal is
behind it, so open it rather than test for it -- the existence check passed on a
headless run and left both reads failing with "No such device or address" -- and
prompt on the terminal rather than stdout, so a caller that redirects us does
not leave the user waiting on a prompt they were never shown.
The green dot reads better than the globe did, so the provisioning notice uses
it too and drops its spinner along the way.
Clean cherry-pick: both files are byte-identical to quattro, so merging v4-0-1
into quattro resolves without a conflict. Exercised against the installed gum
2.0.0: headless it exits 0 at once, and on a pty it prints the prompt and
returns on one keypress. test/shell (186 files) and test/cli pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the reboot flag (PR #8080, merged to quattro as 1565919c) onto the
v4-0-1 release branch, on top of the #8056 backport it follows.
Group membership is fixed at login, so removing (or adding) the docker group
does not take effect in the running session. The existing-user migration and the
Setup > Security toggles now call omarchy-state set reboot-required, so
omarchy-update-restart prompts for the reboot that actually applies the change
and the bar shows it pending. A plain log out and back in still works.
The migration test now exercises the real removal command and omarchy-state
rather than a stub, asserting the reboot flag is set on removal and left alone
when the user is already out of the group.
Clean cherry-pick on top of the #8056 backport: all three files are
byte-identical to quattro, so merging v4-0-1 into quattro resolves without a
conflict. omarchy-state and omarchy-update-restart are unchanged on this branch
and read the same ~/.local/state/omarchy/reboot-required marker. test/shell
passes: 186 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the docker group removal (PR #8056, merged to quattro as b5ded31e)
onto the v4-0-1 release branch.
The docker group is root-equivalent: anything in it can docker run -v /:/host
and rewrite the host as root with no password. On a single-user box that is not
an escalation -- the owner is already a wheel user -- but it hands any code
running as the user, a rogue plugin or a poisoned dependency, a silent,
headless, passwordless path to root that sudo's password prompt would otherwise
gate.
Stop granting the group by default. The daemon still runs, the Docker TUI and
the Windows VM reach it through a polkit prompt, and the plain docker CLI runs
under sudo. Sudoless Docker becomes a warned opt-in under Setup > Security, and
no automatic path re-grants it: install and first-boot provisioning never record
or apply the group, and the Quattro upgrade no longer adds it. A migration takes
existing installs out of the group, reusing omarchy-remove-security-sudoless-
docker so the change and its notice have one source of truth.
The Windows VM keeps needing the root daemon for a privileged container, so it
runs without the group without becoming a new way in: the compose moves to a
root-owned directory written only by an elevated, input-validated writer, volume
paths are rebuilt from $HOME on migration rather than trusted from the
user-writable legacy file, the privileged sub-action is checked against an
allowlist before dispatch, pkexec elevates a verified root-owned command path,
mount sources are refused when they are or resolve through a symlink, and the
guest password moves to a private 0600 per-user file instead of a
world-readable compose. Existing installs auto-migrate the VM without a
redownload.
Two files had diverged from quattro and were resolved by hand:
bin/omarchy-windows-vm -- v4-0-1 still carries the "Starting Windows VM" toast
that #7585 dropped on quattro, and the new start path has no user-side status
check to hang it on: after this change the user cannot inspect the container
without privilege, which is the whole point. Took quattro's version. #7585's
reason holds here too -- the shell shows its own "Launching Windows…" OSD until
the RDP window appears (shell/services/AppLibrary.qml) -- and the failure
notification stays. The file is now byte-identical to quattro.
manual/28-windows-vm.md -- took the new paragraph on the root-owned compose,
without the neighbouring OEM-key paragraph, which documents omarchy windows key,
a command quattro has and this branch does not.
test/shell passes here: 186 files, including the three this adds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the omarchy-sudo-reset removal (PR #8046, merged to quattro as
d99d4fc6) onto the v4-0-1 release branch, so 4.0.1 stops shipping the command.
Nothing in the repository called omarchy-sudo-reset, and its one line
interpolated an environment-supplied $USER into a string handed to a root
shell: su -c "faillock --reset --user $USER". $USER is an environment variable
rather than a kernel-supplied identity, so whatever set it before the command
ran chose the rest of what root's shell executed. That is not a way past PAM on
its own -- su still has to authenticate -- but the installer sets root's
password to the user's own, so the prompt this raises is one the user answers
by habit.
It bought little for that. Omarchy sets deny=10 unlock_time=120 in
/etc/pam.d/system-auth and in the lock screen's PAM stack, so a lockout takes
ten wrong passwords to reach and clears itself two minutes later, and
manual/45-troubleshooting.md documents the root-TTY reset for anyone who would
rather not wait. That reset is unaffected by this change.
The sudo group keeps keepalive and passwordless, so GROUP_DESCRIPTIONS[sudo] is
unchanged and omarchy sudo reset falls through to the router's unknown-command
path. No migration is needed: bin/omarchy-* ships as files in the omarchy
package, so an upgrade drops what the package no longer contains.
Clean cherry-pick: the file was byte-identical to quattro's pre-image and
nothing else on this branch referenced it, so merging v4-0-1 into quattro
resolves without a conflict. test/shell and test/cli pass here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the synaptic touchpad quirk fix (PR #7236, merged to quattro as
20400bad) onto the v4-0-1 release branch, so 4.0.1 installs stop dying on it.
install/hardware/fix-synaptic-touchpad.sh calls modprobe against the running
kernel. On 4.0 the only thing that runs it is the ISO finalizer, inside
arch-chroot, where uname -r still names the live ISO's kernel while
/lib/modules holds the target's. The live ISO always boots linux-t2 and the
configurator gives every machine that is not a T2 Mac stock linux, so the two
never match: modprobe exits 1 with "Module psmouse not found in directory
/lib/modules/<live kernel>". run_logged returns that status and
omarchy-apply-hardware runs under set -euo pipefail, so a fresh install stops
on the first machine with a device named "synaptics" and no psmouse loaded --
reported from a ThinkPad in #6985, but nothing about it is Lenovo-specific.
Ask modprobe itself, with -qn, whether it can resolve psmouse for the running
kernel before asking it to load one, and warn instead of failing when it
declines for any other reason. The chroot mismatch becomes an unresolvable
module, skipped in silence. An optional touchpad improvement must not be able
to halt an install.
This does not make InterTouch reach the installed system: a module loaded into
the live kernel is gone at reboot, so on 4.0 this script has never applied
anything to an installed machine -- it only ever aborted installs. Persisting
it would mean writing options psmouse synaptics_intertouch=1 to
/etc/modprobe.d, which forces the SMBus transport past the kernel's own
allowlist on any touchpad merely named "synaptics". That is a
hardware-behaviour change on a wide class of machines, left as a separate
decision on quattro.
Clean cherry-pick: both files are byte-identical to #7236, so merging v4-0-1
into quattro resolves without a conflict. test/shell.d/synaptic-touchpad-test.sh
passes here, as does the rest of test/shell (183 files).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
Backport of the notification click-command hardening (PR #7926, merged to
quattro as 43bfe9b9) onto the v4-0-1 release branch. Click actions are argv
vectors run without a shell, omarchy-notification-send calls the Notify D-Bus
method directly via busctl instead of notify-send, and --exec takes the command
as rest-of-line words. Excludes docs/notifications.md, which does not exist on
v4-0-1.
* Stop an installed theme from shipping code
`omarchy theme install <url>` clones a stranger's git repository into ~/.config/omarchy/themes, and omarchy-theme-set then copied that whole directory into the staged theme. Most of the files in a staged theme are code rather than colour: Hyprland requires hyprland.lua and gum_env.lua from it at login, Neovim loads neovim.lua at startup, and alacritty.toml, kitty.conf, foot.ini and ghostty.conf each name the program the terminal launches. Installing a theme was the same act as running its author's code, and nothing on disk distinguishes an installed theme from one the user wrote.
Stage only what a theme needs in order to be a theme: colors.toml, light.mode, the preview and unlock images, and image files under backgrounds/. Everything else is ignored, named on stderr, and generated from default/themed/*.tpl instead. Symlinks are never followed, because in an untrusted theme they point wherever the author chose. A theme older than colors.toml keeps its palette: its alacritty.toml is read for colours in a scratch directory and only the resulting colors.toml is staged, so the terminal config never lands.
The filter belongs in omarchy-theme-set rather than in omarchy-theme-install because staging is the choke point. It also covers themes installed before this change, themes copied in by hand, and files a theme gains later through `omarchy theme update`.
First-party themes under $OMARCHY_PATH/themes are unaffected. Per-theme overrides of a generated file are no longer available to user themes; the template at ~/.config/omarchy/themed/<file>.tpl replaces that, and icons.theme is the one setting with no replacement.
🤖 Generated by Opus 5 in Claude Code.
* Stop a theme URL or name being read as an option or a path
Three paths in the theme commands took an attacker-shaped string straight into git, into basename, or into rm.
`git clone "$REPO_URL"` passes the URL as the first positional argument, so a URL beginning with a dash is parsed as an option instead and the destination path becomes what git tries to clone. Pass `--` before the URL so a URL is always a URL. git also treats `<helper>::<address>` as a remote helper to run; git's own protocol.allow default already refuses `ext::`, so rejecting that shape here is a second line rather than the fix, and it keeps holding if that default ever moves. The helper name is a bare word at the very start of the URL, which is what the guard matches: an scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git carries `::` of its own and still clones.
`basename "$REPO_PATH" .git` has the same problem one step later, after the scp-style prefix has been stripped: `host:-s/foo.git` leaves basename reading `-s` as an option and returning `.git` as the theme name. Take the name with `--`.
That name is then joined into a path that is about to be `rm -rf`'d, so a repo whose basename came out as `..` would take ~/.config/omarchy with it. omarchy-theme-remove had the same shape from its own argument, and omarchy-theme-set's sed/tr normalization does not stop a name containing a slash. Reject empty, anything starting with a dot, and anything containing `/` in all three, before the name reaches a path.
🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.
Co-Authored-By: Codex XHigh <codex@openai.com>
* Re-stage the current theme for installs that already applied one
Dropping a theme's code at staging time only takes effect the next time a theme is staged. An install that already applied an extra theme keeps that theme's hyprland.lua, gum_env.lua, neovim.lua and terminal configs in ~/.local/state/omarchy/current/theme, which Hyprland requires at login and the terminals include at launch, and nothing forces a theme change — so for those installs the fix would arrive whenever the user next happened to switch themes, which may be never.
Re-stage once through omarchy-theme-refresh. First-party themes stage identically, so the cost for everyone else is a single retint during an update they are already running.
🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.
Co-Authored-By: Codex XHigh <codex@openai.com>
* Stop a theme's unlock image republishing a file it points at
omarchy-plymouth-set-by-theme reads unlock.png straight out of ~/.config/omarchy/themes, which is an installed theme's own directory and outside the staging filter, and hands the path to omarchy-plymouth-set. That path was copied twice into world-readable /usr/share — once by the user into the Plymouth theme, and once by `sudo cp` into the SDDM theme. A symlink there was followed both times, so a theme could name a file it cannot read and have root publish it.
Refuse a symlinked logo, and copy the staged logo to SDDM instead of rereading the caller's path as root. The staged copy is made by the user, so nothing privileged opens a path the caller chose.
🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.
Co-Authored-By: Codex XHigh <codex@openai.com>
* Limit only what an installed theme could run
Two corrections to the rule this branch introduced, both narrowing it to what it was actually for.
It applied to every theme under ~/.config/omarchy/themes, which swept up themes the user wrote themselves. Their machine, their file: a theme they wrote is theirs to fill however they like, and Omarchy's own themes were never in scope. Only a theme that came from someone else needs limiting, and the repo already knows which those are — omarchy-theme-extras calls a theme with a `.git` directory an extra and a symlink someone's working copy, because that is what `omarchy theme install` leaves behind when it clones. Use the same test.
It was also an allowlist, which dropped files that carry nothing but colour and left theme authors worse off for no gain. Drop only what can run: any `*.lua`, since Hyprland requires a theme's hyprland.lua and gum_env.lua at login and Neovim loads neovim.lua at startup; the four terminal configs, since each names the program the terminal launches; and vscode.json, whose extension field reaches `code --install-extension` and a VS Code extension is arbitrary JavaScript. Everything else an installed theme ships is kept, so btop.theme, chromium.theme, helix.toml, icons.theme, keyboard.rgb and shell.toml go back to being the theme's to set.
Symlinks are still dropped, now at any depth rather than only where an allowlist happened to look.
A denylist is wrong the moment someone adds a template and does not think about it, so the decision is forced rather than remembered: the test fails on any default/themed/*.tpl whose output is recorded as neither code nor colour, and a new terminal or a new Lua-loading editor cannot be added without classifying it.
What this does not cover, and is written down in docs/theming.md rather than implied: a theme shipped as an archive and unpacked by hand looks exactly like one the user wrote. `omarchy theme install` only takes git URLs, so the supported path is always filtered, but this marks where a theme came from and is not a sandbox.
🤖 Generated by Opus 5 in Claude Code.
* Fix what the review found
Four things, all confirmed against the source before changing anything.
The migration failed permanently when the active theme had been removed. `omarchy theme remove` deletes the directory without repointing theme.name, so the name survives, the staged copy survives, and omarchy-theme-refresh exits 1 because neither source directory exists — leaving the migration pending forever and the stale staged Lua exactly where it was, which is the one thing it existed to remove. Seed the default theme in that case: there is nothing to re-stage from, and the removal should have left a working theme behind anyway.
The staging test skipped the strict-mode header that docs/testing.md makes the contract for every shell test. Adding it means the patterns that fail on purpose have to stop being bare `cmd && fail` compounds, which errexit reads as the script itself failing; the mutations were re-run afterwards to confirm the assertions still fire rather than the run dying early and looking like something else.
The guards in omarchy-theme-install and omarchy-theme-remove had no coverage — they were checked by hand and left that way. theme-install-guards-test.sh stubs git and the themes directory and proves an option-shaped URL, a transport helper, and a name that would climb out all stop before git or rm runs, that a dash inside the path no longer becomes a basename option, and that an ordinary URL still clones and applies.
The new docs/theming.md prose was hard-wrapped, which AGENTS.md forbids for docs/. Unwrapped. The rest of that file is wrapped from before and is left alone rather than churned through this change.
🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh and Copilot.
---------
Co-authored-by: Codex XHigh <codex@openai.com>
(cherry picked from commit ef6d9e6605)
* Stop a video title from becoming the Download Video play command
The host parsed yt-dlp's after_move line as title plus path, so a newline in page metadata could forge the path. Clicking the toast then handed that value to mpv as options. Print only the real file, ignore anything that is not inside the download dir, and invoke mpv with --.
* Refuse downloads whose video title contains control characters
The hoodie page still offered a real hidden clip, so yt-dlp saved it even after the play-action fix. A title with newlines is not a legitimate name; abort before the download and tell the user it was refused.
* Test the forged record in the order yt-dlp emits it
The records ran forged-first and good-last, so the assertion measured recovery after
bad records rather than preservation of an already-captured path when a forged record
arrives afterwards. That is the shape a hostile title actually produces, because a
title ending in a newline closes its own record and leaves the genuine path on a line
the loop ignores. As written the assertion passed with resolve_download_file replaced
by a no-op.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Resolve the download path without dropping a trailing newline
Command substitution strips trailing newlines, so an in-directory symlink pointing at
a regular file whose name ends in one canonicalised to a different path -- which may
itself exist -- and that path then passed the containment check and reached ffmpeg and
the click command. Reading realpath's NUL-terminated output keeps the name intact, and
a resolved path carrying a control character is refused outright.
Not reachable through a yt-dlp download, since --restrict-filenames strips control
characters from the name it writes; it is the helper's contract that was wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
* Accept a download directory that resolves to /
realpath returns "/" for the root directory, which made the containment pattern "//*"
and rejected every file saved directly under it, so the host reported a failed download
after saving the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
* Stop refusing a download because its title has control characters
The gate cannot tell a hostile title from a legitimate one. --print emits one line per
extracted video and --no-playlist does not collapse a multi_video result, so a page
holding two clips arrives as two titles separated by a newline and is refused exactly
like a forged record would be.
It also guaranteed nothing it was read as guaranteeing. The simulate run and the
download run are separate fetches, so a site is free to answer them differently, and
the check never constrained the metadata the download actually used.
What stands between a record and the click command is resolve_download_file, which is
untouched here. Leaving a check that refuses valid pages while securing nothing invites
the path validation to be relaxed later on the strength of it. A gate that would work
is possible -- --print '%(title)j' encodes each title as JSON on its own line, which
separates a newline in the metadata from a newline between videos -- but it belongs
with a use for the title rather than as a bare refusal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
* Disarm the legacy exec-before-download hook too
--no-exec clears the modern --exec map but leaves --exec-before-download stored
separately, and yt-dlp restores it as a before_dl postprocessor, so a hook configured
in the user's yt-dlp config still ran during the download this host drives.
The accompanying test runs download_url itself against stubbed tools. Everything else
in this file exercises the helpers in isolation, which left the invocation uncovered:
restoring the title to the record template, or dropping --no-exec or the trailing --,
passed every assertion here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
* Toast the page title again instead of the saved filename
Deriving the toast text from the sanitised filename cost the title it was meant to
show: "My Great Clip" arrived as "My_Great_Clip [My_Great_Clip]". The title is safe as
notification text -- it is an argv element, never part of a command -- so the only
question was getting it out of yt-dlp without reopening the record forgery.
It now comes from the download run, so it describes the file that was actually saved,
and it is printed as %(title)j. JSON-encoding is what makes that safe: a newline or tab
in page metadata becomes an escape sequence inside one quoted string rather than a
record boundary, so a title can no longer split itself across lines. The decoder keeps
only what precedes the first control character, refuses anything notify-send would read
as an option, and leaves the filename-derived title as the fallback when a page offers
nothing usable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Name the saved file after the page title
The download landed as "My_Great_Clip [My_Great_Clip].mp4" when the page called it
"My Great Clip". --restrict-filenames was carrying more weight than it earns here: it
folds spaces to underscores and strips non-ASCII, which is what mangles the name, and
it is not what keeps the record stream safe. yt-dlp removes control characters from a
filename either way -- a newline becomes a space, tabs and DEL and NUL are dropped --
so a path printed after the move is still only ever one line, which is the property
resolve_download_file depends on.
Dropping the [%(id)s] suffix is the other half of matching the title, and it trades
away the uniqueness that suffix bought: two videos sharing a title now share a name,
and yt-dlp skips a download whose file already exists, so the second one toasts as a
failure. Restoring the suffix is a one-line change if that trade is the wrong way
round.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Omabot <omabot@omarchy.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
(cherry picked from commit b71c60fe30)
* Only offer Update > Extra Themes when there is one
omarchy-theme-update pulls the themes under ~/.config/omarchy/themes that came from a git clone, so on a machine that has never installed one by hand the row opens a terminal that prints nothing and closes. Guard it with the same predicates the command itself applies, since a row that shows over a symlinked theme or a worktree's `.git` file is the same dead end in a narrower shape, and pin the two to each other in the guard test.
Co-Authored-By: Codex XHigh <noreply@openai.com>
* Extract the Extra Themes guard into omarchy-theme-extras
The row's `when:` and omarchy-theme-update each carried their own idea of which themes came from a git clone, and the two only matched because a test held them together. Name it once instead: omarchy-theme-extras lists those directories and exits nonzero when there are none, so the row asks exactly the command its action runs. Living in a script also puts the glob out of reach of whatever shopt a login shell left set for the guard batch.
Co-Authored-By: Codex XHigh <noreply@openai.com>
---------
Co-authored-by: Codex XHigh <noreply@openai.com>
(cherry picked from commit 13a969e1ab)
* Switch back to the packaged quickshell now that 0.3.1 kills synchronously
Omarchy shipped the quickshell-git build for a single fix: 0.3.0's `kill` returned before the instance had exited, so the kill loop in omarchy-restart-shell could race a dying shell. Upstream 0.3.1 ships that fix, which makes extra/quickshell the better package to be on again — signed, versioned, and not rebuilt from a moving branch on every update.
The migration swaps unconditionally instead of first checking which version the mirror offers. A machine left holding quickshell-git while the shipped package list names quickshell has no way to reconcile the two: omarchy-reinstall-pkgs installs that list with --needed, which does not skip a name that is not installed, and the conflict it then walks into has no answer under --noconfirm. A mirror that is briefly behind installs 0.3.0 instead and the next upgrade carries it to 0.3.1, which is much the cheaper way to be wrong.
🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.
Co-Authored-By: Codex XHigh <codex@openai.com>
* Drop the quickshell version note from the shell restart loop
The comment qualified the kill loop as needing 0.3.1 or newer, but omarchy-restart-shell ships in the same package upgrade that brings quickshell along, so a machine running this code already has the version the loop depends on. The caveat could never be false where it was read, which left it as version archaeology rather than something the code could not say for itself.
🤖 Generated by Opus 5 in Claude Code.
---------
Co-authored-by: Codex XHigh <codex@openai.com>
(cherry picked from commit 2c593dbbaa)
With the default scale = "auto", sync_internal_scale read the config,
rejected "auto" as non-numeric, fell back to the hardcoded default 2,
and force-applied it whenever the compositor's auto resolution differed.
Since the script runs from omarchy-system-wake after every idle cycle,
the panel flapped between 2 and auto's own value (1.5666667 on a 198 DPI
panel) on every wake/reload pair.
A config without a usable number -- "auto", or an expression only
Hyprland's Lua can evaluate -- delegates the scale to the compositor:
whatever it resolved for the enabled panel is the configured scale, so
there is nothing to correct. Recovery of a disabled panel is unchanged
and still re-enables it with the remembered scale, falling back to the
historical default 2.
Fixes#7265. Also the scale-revert half of #7301.
Claude-Session: https://claude.ai/code/session_01L4Z6GimYhR1Kpsir24VAPF
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b63616422f)
grant_covers re-implemented etc/sudoers.d/omarchy-dns in bash -- one of
the three providers, and %wheel -- but never asked whether the rule was
installed. It ships in the etc/ tree that omarchy-settings copies, so
every machine still on an older settings package answers yes to a grant
it does not have. require_root then execs into sudo with no way back,
and the panel's one-click toggle dies on a password prompt it has no
terminal to show.
Ask sudo instead. `sudo -l` alone reports whether a command is
permitted, which the blanket %wheel rule answers yes to for everything,
but the long listing prints the matched entry's tags -- !authenticate is
the grant and nothing else. It runs nothing, and under -n it prompts for
nothing, so a machine without the rule falls through to polkit and gets
a prompt on screen.
The provider list and the wheel check go away with it; sudo owns that
policy now, and it stays right if the rule is ever edited or removed.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1e70cca144)
* Switch DNS providers without a password prompt
The network panel and the menu run omarchy-dns from a process with no
terminal, so require_root reached for pkexec and put a polkit password
prompt in front of what is meant to be a one-click toggle.
Grant %wheel passwordless sudo for the three stock providers and take
that path whenever the grant covers the invocation. Custom stays out of
the grant: it points the machine at servers the caller supplies, and it
already runs in a terminal that can ask.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Pick the elevation path without asking sudo
The `sudo -n -l` probe answered the wrong question. It reports whether a
command is permitted, not whether it is passwordless, and the %wheel rule
every Omarchy install ships permits everything -- `sudo -n -l /usr/bin/rm
-rf /tmp/x` exits 0. So the probe passed for Custom too, and the exec
below it ran `sudo -n`, which fails outright with no terminal and no way
back to pkexec.
Decide from what the sudoers rule actually says instead: sudo when there
is a terminal to type into, or when the resolved path and the provider
are both ones the rule names. Everything else keeps going through polkit.
Pin a root-owned PATH once elevated, too. `omarchy dev link` puts a
user-writable checkout ahead of sudo's secure_path for every command, so
a passwordless grant on a script that resolves nmcli, tee, and install
through PATH would otherwise hand root to whoever can write there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Keep users outside %wheel on the polkit path
The rule grants %wheel, so path and provider alone do not mean sudo will
take it. A user outside the group was sent to sudo anyway, and with no
terminal to answer the prompt that is a dead end -- polkit at least
offers to authenticate as somebody else.
Two holes in the test alongside it: it accepted any file containing the
expected rule, so a second, argument-free line would have widened the
grant unnoticed, and run as root it would have sailed past the stubs and
rewritten the host's own DNS config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Elevate the system install, whatever copy was invoked
The rule names /usr/bin/omarchy-dns, so a dev-linked checkout handed sudo
a path nothing could match and fell back to a polkit prompt. Re-exec the
packaged path instead: the privileged half is the system install
everywhere, the grant matches everywhere, and the path comparison and the
PATH pinning that existed to work around the checkout both go away.
Dev-linked checkouts run their own unprivileged half and the installed
one as root, which is the trade for not carrying a second code path.
---------
Co-authored-by: Omabot <david@hey.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 3765e8010b)
The background and the lock screen are drawn by Quickshell, so they decode
through Qt, which ships handlers for png, jpeg and gif but not webp. Any
third-party theme shipping a .webp background comes up blank without the
qt6-imageformats plugin, and extra themes are their own repositories.
Only the dependency is taken here. The background conversion that came with
it re-encodes all 79 bundled backgrounds, which 4.0.1 has no need of.
(cherry picked from commit a4219f8fbeb85ee7a2bc0e14eda9d38cc85d3d0d, dependency only)
* Switch to the mise-bin package
mise-bin carries mise's own release artifacts from the Omarchy repo --
PGO+BOLT-optimized on x86_64, glibc-native on both arches -- instead of
Arch's mise, and tracks jdx/mise releases directly.
Existing installs need a migration because the two packages conflict, and
omarchy-pkg-add cannot make the swap: pacman answers its own conflict
question with No under --noconfirm and fails the transaction. --ask=4
answers that one question, so mise-bin replaces mise in a single
transaction -- which is also what keeps omarchy-zsh and omarchy-fish, both
of which depend on mise, satisfied through the swap by its provides.
* Guard the swap with a conditional instead of an early exit
Two-path control flow takes an if, per the style guide; the early exit only made the swap line unreachable from a distance.
(cherry picked from commit 262d6f0681)
The dial already grouped its digits for the locale above 10, but below 10 it
went through toFixed, which hardcodes a dot. A German desktop therefore read
9.5 and 1.235 off the same dial, switching decimal convention halfway up the
scale.
Send both branches through the locale. A reading is a measurement rather than
interface text, so its separators follow the system's number conventions even
though the interface itself stays English.
English output is unchanged.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 9f109a7f3e)
The calendar grid's header row and the week-start toggle label were the only
text in the shell that followed the system locale, so a German desktop drew
MO DI MI over an interface that is English everywhere else. Nothing chose that;
they were the only two places reading day names off Qt.locale().
Take them from en_US instead. Where the week starts still follows the locale:
that is a regional convention rather than a translation, and it stays
overridable through weekStartDay.
Dropping the trailing-period strip with it, since that existed only for the
locales this no longer renders.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 25bee1d2a3)
omarchy-plugin-clone only takes the source id as the first argument, but the
menu passed --edit ahead of it, so the id fell through to the unknown-option
branch and every clone from Setup > Plugins failed.
Closes#6913
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit ff4e92e63b)
Bar widgets propagate their composed press-and-hold down to the center gesture
area without handing over the grab, so the gesture area started a bar move and
then received neither a release nor a cancel to end it. The move ghost stayed on
screen for the rest of the session. Ignore the gesture unless we hold the press.
Closes#6881
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit c0a20e2e43)
mise use -g announces the resolved tool on stdout, so every wrapped command
prepended a "tools:" line to its own output. That corrupts anything speaking a
protocol over stdout, such as codex app-server. Pass --quiet, which keeps errors
on stderr and preserves the exit status.
The obsolete-wrapper check in the agent migration matched the generated command
verbatim, so loosen it to match the package instead of the flags.
Closes#6908
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1c3da94906)
A 3.8.3 migration appended the section header with one backslash too many, so
sed wrote the literal characters instead of a newline plus the header. foot
rejects the line and stops reading the rest of the file. The later text-binding
migration matches the header with grep -qxF, misses the broken line, and appends
a second section, leaving the config broken across the Quattro upgrade.
Closes#6903
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit b5bb8dac05)
Hyprland reaps its own children, so os.execute() gets ECHILD from waitpid and
never sees an exit status. Every call reported failure, which meant the NVIDIA
env detection in hypr/nvidia.lua never set NVD_BACKEND, LIBVA_DRIVER_NAME or
__GLX_VENDOR_LIBRARY_NAME. Read a marker off stdout instead.
Closes#6914
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 0965ac2e4f)
* Wait for the current Windows boot before connecting RDP
docker logs retains output across stop/start, so grepping the whole log
matched "Windows started successfully" from an earlier boot and returned
immediately, firing xfreerdp3 while the guest was still booting. Anchor the
scan to the container's current StartedAt, and run it even when the container
was already running, since the image restarts the guest in place on reboot.
* Skip Kerberos when connecting to the Windows VM
FreeRDP 3 attempts Kerberos before NTLM for NLA, and Arch's stock
/etc/krb5.conf declares default_realm = ATHENA.MIT.EDU, so every launch tries
to reach MIT's KDC. Off the network each attempt blocks ~23s and xfreerdp3
sits in CLOSE-WAIT without drawing a window, which reads as the VM failing to
start. Point FreeRDP at a realm-less krb5 config so it falls through to NTLM,
which is what the local Windows account uses anyway.
* Re-read the container start time on every readiness poll
A failed docker inspect left STARTED_AT empty, and docker logs drops the
--since filter when it is, putting the scan back on the whole retained log
and its stale success line. Sampling per poll also keeps the window on the
current boot if the container restarts mid-wait.
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>
(cherry picked from commit 75b99f7fd4)
* Launch claude and codex agents with auto-review instead of full bypass
Claude's auto permission mode and codex's --approve-for-me both run
unattended without prompting, but keep automatic review (and codex's
workspace-write sandbox) instead of skipping approval entirely. Grok
stays on bypassPermissions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Match the bash aliases to the agent launcher's auto-review modes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit dd9dee417f)
* Host the manual's images in the repo under manual/images
Replaces all learn.omacom.io/manual.omakub.org hotlinks with local webp
files capped at 1600px wide (~20MB total), so the manual is fully
self-contained. Theme and unlock previews are converted from the
canonical themes/*/preview.png files. A handful of illustration shots
are interim conversions of the current images, pending retakes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Retake the illustration screenshots fresh at 3x scaling
Navigation, clipboard history, notices, tmux layouts, prompt, and About
reshot on a clean workspace at 3.13x (1920x1080 logical on 6K) across
ten themes: Tokyo Night, Catppuccin, Gruvbox, Kanagawa, Everforest,
Nord, Osaka Jade, Rose Pine, Matte Black, and Ristretto.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drop the extra themes chapter from the manual
The community theme gallery will live elsewhere. Removes the chapter
and its 114 gallery images, renumbers the following chapters, and
repoints the two references (theme installs still work via
Install > Style > Theme in the menu).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Reference the shipped theme previews instead of copying them
The themes chapter now points straight at themes/*/preview.png and
preview-unlock.png, so previews can never drift from the source and
manual/images drops from 6.3MB to 3.5MB.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Restore preinstalls from the menu, and drop the Omacom apps with them
Remove Preinstalls missed omacut, omacalc, and omawrite, so the three Omacom
apps survived an opt-out that was supposed to clear the desk.
Opting out was also one-way. Install > Preinstalls now puts everything back:
the shipped .desktop launchers and mise stubs via omarchy-refresh-applications,
the dropped packages via pacman, and the opt-out marker deleted so the
preinstalled keybindings return on reload. The two menu entries guard on the
marker, so exactly one of them is ever visible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Trim the preinstall lists to what quattro actually ships
Remove Preinstalls was still dropping typora, spotify, 1password, 1password-cli,
signal-desktop, opencode, claude-code, and github-cli. None of those are in
omarchy-base.packages anymore: typora gave way to omawrite, the services moved
to on-demand menu installs, and the agent CLIs are mise-managed. Removing them
took out apps the user had deliberately installed, and restoring them would have
put back what we no longer ship.
Both lists are now the same twelve packages, all of them in omarchy-base.packages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Keep the opt-out marker when a restore fails
omarchy-pkg-add exits non-zero when pacman cannot install a package, but the
restore ran straight past it, cleared the marker, and reloaded Hyprland. That
reported success and brought back keybindings for apps that never arrived. The
marker now falls last, behind a check on the transaction.
The new test also pins the two lists to each other and to omarchy-base.packages,
which is the drift that let retired packages linger in the removal list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Pacman answers its own conflict question with No under --noconfirm, so one
retired package can stop every update after it. Which package to drop is a
decision rather than a cleanup, so run the upgrade again with pacman asking
when there is a terminal to answer on, and report instead when -y promised
not to ask.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>