Compare commits

...
Author SHA1 Message Date
Mehmet INCEandClaude Opus 5 13f18b2cb7 [Security] Stop the FIDO2 setup staging its authfile at a predictable /tmp path (backport of #7904)
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
2026-08-25 12:12:38 +02:00
Adrian RangelandClaude Opus 5 2e989e35e5 [Security] Stop USB device names from being executed as Hyprland Lua (backport of #8129)
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
2026-08-25 11:07:35 +02:00
Mehmet INCEandClaude Opus 5 c6d676f23c Pin trusted PATH in privileged DNS helper (backport of #8172)
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
2026-08-25 09:44:35 +02:00
Sameer PokharelandClaude Opus 5 0f836c0deb Renumber the README manual TOC after Extra themes was dropped (backport of #8089)
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
2026-08-25 09:41:59 +02:00
OmarchybotandClaude Opus 5 07adef8a53 Let a received Taildrop file wait to be answered (backport of #7953)
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
2026-08-25 09:39:50 +02:00
OmarchybotandClaude Opus 5 e713ff3166 Share the git URL check, and refuse the transports Omarchy does not clone from (backport of #8174)
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
2026-08-25 09:13:35 +02:00
OmarchybotandClaude Opus 5 c7af36d0aa Offer to reboot when toggling sudoless Docker; show only the relevant menu entry (backport of #8098)
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
2026-08-25 09:02:57 +02:00
BastiandClaude Opus 5 b3028f9bd9 Guard plugin-add against git transport-helper URLs (backport of #8067)
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
2026-08-25 08:32:58 +02:00
David Heinemeier HanssonandClaude Opus 5 0f4abeec8e Wait for the keypress ourselves instead of asking gum to (backport of #8082)
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
2026-08-24 19:58:40 +02:00
OmarchybotandClaude Opus 5 7b89780810 Flag a reboot when the docker group changes (backport of #8080)
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
2026-08-24 19:56:19 +02:00
OmarchybotandClaude Opus 5 c0b593b349 Don't put the user in the docker group; make it opt-in (backport of #8056)
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
2026-08-24 19:54:01 +02:00
OmarchybotandClaude Opus 5 7fa32bb98c Remove the sudo lockout reset command (backport of #8046)
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
2026-08-24 15:50:12 +02:00
OmarchybotandClaude Opus 5 2b1d3c4606 Stop a psmouse quirk from failing every install (backport of #7236)
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
2026-08-24 15:37:29 +02:00
Ryan Hughes 286b8c2b1c Run notification click actions as safe argv (backport of #7926)
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.
2026-08-23 19:56:59 -04:00
Omarchybot f2a2973de8 Stop an installed theme from running code (#7884)
* 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)
2026-08-23 19:12:29 +02:00
Adrian Rangel 64c5c02f55 [Security] Stop a video title from becoming the Download Video play command (#7847)
* 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)
2026-08-23 19:11:58 +02:00
Omarchybot d0aefc1f79 Only offer Update > Extra Themes when there is one (#7775)
* 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)
2026-08-23 19:11:58 +02:00
Omarchybot 1626405638 Switch back to the packaged quickshell now that 0.3.1 kills synchronously (#7769)
* 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)
2026-08-23 19:11:58 +02:00
Matthias Nitsch 9b192256e7 Leave an auto-scaled internal panel alone in clamshell recovery (#7581)
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)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson 2487fbcce1 Fall back to polkit when the DNS sudoers grant is missing (#7492)
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)
2026-08-23 19:11:58 +02:00
Omarchybot 9434f220f0 Switch DNS providers without a password prompt (#7472)
* 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)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson 9e79b24342 Decode webp in the shell (#7477)
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)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson 6245965cb6 Decode mixed UTF-16 clipboard text (#7466)
* Decode mixed UTF-16 clipboard text

* Harden UTF-16 clipboard detection

* Finish UTF-16 decoder hardening

(cherry picked from commit 238021cd67)
2026-08-23 19:11:58 +02:00
Omarchybot f6b4183e4f Switch to the mise-bin package (#7244)
* 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)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson 39c2797d3b Decode UTF-16 clipboard text (#7249)
(cherry picked from commit 006460ad57)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson 8b979e0c4c Fix passwordless OWE Wi-Fi handling (#7238)
(cherry picked from commit 1c1116b626)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson 5c2fb68c0a Format the whole speed test dial for the locale (#6994)
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)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson 2d3826aff3 Keep the calendar's day names in English (#6988)
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)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson eb201d0bd3 Fix Clone Plugin failing with "unknown clone option" (#6942)
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)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson ac1bdbfbf5 Stop the bar sticking in move mode after a press-and-hold on a widget (#6943)
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)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson 9f7cdf6983 Keep mise wrappers from writing to stdout (#6940)
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)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson 1867dcaa50 Repair foot.ini configs holding a literal \n[text-bindings] line (#6938)
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)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson 33d7363c33 Fix o.shell_succeeds() always returning false inside Hyprland
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)
2026-08-23 19:11:58 +02:00
Alonso David De León Rodarte 84336759cc Fix Windows VM launch never opening an RDP window (#6893)
* 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)
2026-08-23 19:11:58 +02:00
David Heinemeier Hansson 5f0704a0f8 Launch claude and codex agents with auto-review instead of full bypass (#7001)
* 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)
2026-08-23 19:11:58 +02:00
120 changed files with 5657 additions and 485 deletions

No files matched your search

+9 -10
View File
@@ -61,19 +61,18 @@ its screenshots are also hosted.
- [Prompt](manual/40-prompt.md) - [Prompt](manual/40-prompt.md)
- [Branding](manual/41-branding.md) - [Branding](manual/41-branding.md)
- [Common tweaks](manual/42-common-tweaks.md) - [Common tweaks](manual/42-common-tweaks.md)
- [Extra themes](manual/43-extra-themes.md) - [Making your own theme](manual/43-making-your-own-theme.md)
- [Making your own theme](manual/44-making-your-own-theme.md)
**The Rest** **The Rest**
- [Mac support](manual/45-mac-support.md) - [Mac support](manual/44-mac-support.md)
- [Troubleshooting](manual/46-troubleshooting.md) - [Troubleshooting](manual/45-troubleshooting.md)
- [FAQ](manual/47-faq.md) - [FAQ](manual/46-faq.md)
- [System snapshots](manual/48-system-snapshots.md) - [System snapshots](manual/47-system-snapshots.md)
- [Security](manual/49-security.md) - [Security](manual/48-security.md)
- [Omarchy on...](manual/50-omarchy-on.md) - [Omarchy on...](manual/49-omarchy-on.md)
- [Dual Boot Install](manual/51-dual-boot-install.md) - [Dual Boot Install](manual/50-dual-boot-install.md)
- [Unattended Installs](manual/52-unattended-installs.md) - [Unattended Installs](manual/51-unattended-installs.md)
## License ## License
+1 -1
View File
@@ -1,7 +1,7 @@
[Desktop Entry] [Desktop Entry]
Version=1.0 Version=1.0
Name=Docker Name=Docker
Exec=xdg-terminal-exec --app-id=TUI.tile -e lazydocker Exec=xdg-terminal-exec --app-id=TUI.tile -e omarchy-launch-docker-tui
Terminal=false Terminal=false
Type=Application Type=Application
Icon=docker Icon=docker
+7 -3
View File
@@ -74,12 +74,16 @@ crush)
command=(crush --yolo) command=(crush --yolo)
fi fi
;; ;;
claude | grok) claude)
command=("$agent" --permission-mode bypassPermissions) command=(claude --permission-mode auto)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
grok)
command=(grok --permission-mode bypassPermissions)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt") [[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;; ;;
codex) codex)
command=(codex --dangerously-bypass-approvals-and-sandbox) command=(codex --approve-for-me)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt") [[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;; ;;
omp) omp)
+1 -1
View File
@@ -228,7 +228,7 @@ stop_screenrecording() {
omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \ omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \
-t 10000 --image "${preview:-$filename}" \ -t 10000 --image "${preview:-$filename}" \
--exec "$(printf 'mpv %q' "$filename")" --exec mpv -- "$filename"
# The shell loads the thumbnail into memory when the toast appears and never # The shell loads the thumbnail into memory when the toast appears and never
# re-reads the file, so the preview only has to outlive that load -- not the # re-reads the file, so the preview only has to outlive that load -- not the
+1 -1
View File
@@ -70,7 +70,7 @@ case "$PROCESSING" in
# notification outage must not report the capture itself as failed. # notification outage must not report the capture itself as failed.
omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \ omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \
--image "$FILEPATH" \ --image "$FILEPATH" \
--exec "$(printf '%q %q' "$SCREENSHOT_EDITOR" "$FILEPATH")" || true --exec "$SCREENSHOT_EDITOR" "$FILEPATH" || true
;; ;;
copy) copy)
grim -g "$SELECTION" - | wl-copy --type image/png grim -g "$SELECTION" - | wl-copy --type image/png
+67 -13
View File
@@ -25,6 +25,51 @@ valid_url() {
[[ $1 =~ ^https?:// ]] [[ $1 =~ ^https?:// ]]
} }
# A printed path is only usable if it is a regular file inside DOWNLOAD_DIR.
# Forged records (leading-dash mpv options, paths with control chars, or
# anything that escaped the download directory) must not reach the click command.
resolve_download_file() {
local candidate=$1 file_real dir_real
[[ -n $candidate ]] || return 1
[[ $candidate != *$'\n'* && $candidate != *$'\r'* && $candidate != *$'\t'* ]] || return 1
[[ -f $candidate ]] || return 1
# Read to a NUL: command substitution strips trailing newlines, which would
# resolve a name ending in one to a different file that may well exist.
IFS= read -r -d '' file_real < <(realpath -ze -- "$candidate") || return 1
IFS= read -r -d '' dir_real < <(realpath -ze -- "$DOWNLOAD_DIR") || return 1
[[ $file_real != *$'\n'* && $file_real != *$'\r'* && $file_real != *$'\t'* ]] || return 1
# Trim the slash so a download directory of "/" still leaves a usable prefix.
[[ $file_real == "${dir_real%/}"/* ]] || return 1
printf '%s' "$file_real"
}
# yt-dlp prints the title JSON-encoded, so a newline or tab in page metadata is an
# escape sequence rather than a record boundary. This is toast text, never a command.
decode_title() {
local decoded
decoded=$(jq -r 'if type == "string" then . else empty end' <<<"$1" 2>/dev/null) || return 1
decoded=${decoded%%[[:cntrl:]]*} # keep what a person would read, drop the forgery
[[ -n $decoded && $decoded != -* ]] || return 1
printf '%s' "$decoded"
}
title_from_file() {
local name=${1##*/}
name=${name%.*}
name=${name//[$'\n\r\t']/}
if [[ -z $name || $name == -* ]]; then
printf '%s' "Video"
else
printf '%s' "$name"
fi
}
# Drive the Quickshell OSD — a single overlay that updates in place (like the # Drive the Quickshell OSD — a single overlay that updates in place (like the
# volume/brightness bar), so download progress never stacks like notifications. # volume/brightness bar), so download progress never stacks like notifications.
osd_progress() { osd_progress() {
@@ -41,16 +86,19 @@ download_url() {
mkdir -p "$DOWNLOAD_DIR" mkdir -p "$DOWNLOAD_DIR"
# Don't show anything until yt-dlp confirms there's actually a video to grab. # Don't show anything until yt-dlp confirms there's actually a video to grab.
if ! yt-dlp --no-playlist --simulate --quiet --no-warnings "$url" >/dev/null 2>&1; then if ! yt-dlp --no-playlist --simulate --quiet --no-warnings --no-exec --no-exec-before-download -- "$url" >/dev/null 2>&1; then
omarchy-notification-send -u critical -g 󰅖 "No video found for download" "$url" omarchy-notification-send -u critical -g 󰅖 "No video found for download" "$url"
exit 0 exit 0
fi fi
osd_progress 0 osd_progress 0
# Stream the download: OMARCHY_PROG carries the percent (drives the OSD), # Stream the download: OMARCHY_PROG carries the percent (drives the OSD), and
# OMARCHY_FILE (printed only after a successful move) carries title + path. # OMARCHY_FILE and OMARCHY_TITLE (printed only after a successful move) carry the
local line rest pct intpct last="" er nowms lastms=0 title="" filepath="" # path and the title. The title is JSON-encoded so metadata cannot forge a record,
# and the file is named after it: yt-dlp strips control characters from a filename
# with or without --restrict-filenames, so a record is still only ever one line.
local line pct intpct last="" er nowms lastms=0 title="" filepath="" resolved
while IFS= read -r line; do while IFS= read -r line; do
case $line in case $line in
OMARCHY_PROG*) OMARCHY_PROG*)
@@ -67,22 +115,26 @@ download_url() {
osd_progress "$intpct" osd_progress "$intpct"
;; ;;
OMARCHY_FILE*) OMARCHY_FILE*)
rest=${line#OMARCHY_FILE$'\t'} resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue
title=${rest%%$'\t'*} filepath=$resolved
filepath=${rest#*$'\t'} ;;
OMARCHY_TITLE*)
title=$(decode_title "${line#OMARCHY_TITLE$'\t'}") || title=""
;; ;;
esac esac
done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --restrict-filenames --no-simulate \ done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --no-simulate \
--quiet --no-warnings --progress --newline \ --quiet --no-warnings --no-exec --no-exec-before-download --progress --newline \
--progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \ --progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \
--paths "$DOWNLOAD_DIR" -o '%(title)s [%(id)s].%(ext)s' \ --paths "$DOWNLOAD_DIR" -o '%(title)s.%(ext)s' \
--print $'after_move:OMARCHY_FILE\t%(title)s\t%(filepath)s' \ --print $'after_move:OMARCHY_FILE\t%(filepath)s' \
"$url" 2>&1) --print $'after_move:OMARCHY_TITLE\t%(title)j' \
-- "$url" 2>&1)
osd_close osd_close
# after_move only prints on a successful download+move, so a captured path == success. # after_move only prints on a successful download+move, so a captured path == success.
if [[ -n $filepath ]]; then if [[ -n $filepath ]]; then
[[ -n $title ]] || title=$(title_from_file "$filepath")
((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact ((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact
# Square, center-cropped thumbnail so the notification preview isn't stretched. # Square, center-cropped thumbnail so the notification preview isn't stretched.
@@ -94,9 +146,11 @@ download_url() {
# Best-effort: the download already succeeded, and under `set -e` a failed # Best-effort: the download already succeeded, and under `set -e` a failed
# toast would exit before the thumbnail cleanup below is ever scheduled. # toast would exit before the thumbnail cleanup below is ever scheduled.
# `--` keeps mpv from parsing a leading-dash filename as an option; the path
# is one discrete argument, so it never reaches a shell.
omarchy-notification-send -g 󰄬 "Download complete" "$title" \ omarchy-notification-send -g 󰄬 "Download complete" "$title" \
-t 10000 --image "${preview:-$filepath}" \ -t 10000 --image "${preview:-$filepath}" \
--exec "$(printf 'mpv %q' "$filepath")" || true --exec mpv -- "$filepath" || true
# The shell loads the thumbnail into memory when the toast appears and never # The shell loads the thumbnail into memory when the toast appears and never
# re-reads the file, so the preview only has to outlive that load, not the # re-reads the file, so the preview only has to outlive that load, not the
+7 -7
View File
@@ -24,9 +24,7 @@ readonly ignore_pattern=${OMARCHY_CRASH_IGNORE:-}
declare -A last_notified declare -A last_notified
announce() { announce() {
local comm=$1 pid=$2 exe=$3 signal=$4 exec_command local comm=$1 pid=$2 exe=$3 signal=$4
exec_command=$(printf 'omarchy-agent-crash %q %q %q %q' "$pid" "$comm" "$exe" "$signal")
# The shell owns org.freedesktop.Notifications, so a shell crash takes the # The shell owns org.freedesktop.Notifications, so a shell crash takes the
# notification server down with it and a toast sent into that gap is lost. # notification server down with it and a toast sent into that gap is lost.
@@ -35,14 +33,16 @@ announce() {
omarchy-notification-wait || return 1 omarchy-notification-wait || return 1
# --exec rather than a libnotify action: the shell runs clicks from its own # --exec rather than a libnotify action: the shell runs clicks from its own
# omarchy-exec hint and never emits ActionInvoked. Keeps the default # hint and never emits ActionInvoked. Keeps the default "omarchy-action" app
# "omarchy-action" app name too, the only one shouldBypassDnd() lets through. # name, the only one shouldBypassDnd() lets through. Crash details ride as
# discrete argv words, so a hostile process name can't be reparsed as a
# command. --exec consumes the rest of the line, so it comes last.
omarchy-notification-send \ omarchy-notification-send \
--urgency critical \ --urgency critical \
--glyph "$CRASH_GLYPH" \ --glyph "$CRASH_GLYPH" \
--exec "$exec_command" \
"Process crashed: $comm" \ "Process crashed: $comm" \
"Click to diagnose with AI" "Click to diagnose with AI" \
--exec omarchy-agent-crash "$pid" "$comm" "$exe" "$signal"
} }
# -n 0 so a restart does not re-announce crashes already dealt with. # -n 0 so a restart does not re-announce crashes already dealt with.
+32 -7
View File
@@ -6,6 +6,18 @@
set -euo pipefail set -euo pipefail
# Whenever this runs as root — invoked directly through the passwordless
# sudoers rule, or re-execed by require_root below — sudo's secure_path decides
# where a bare helper resolves, and a dev link (etc/sudoers.d/omarchy-dev-path)
# prepends a user-writable checkout bin/ to it. Every helper this script calls
# by bare name (dirname, install, tee, rm, nmcli, systemctl, awk) is a system
# tool, never an omarchy-* command, so pin PATH to trusted system directories
# and keep root from resolving one out of that checkout. The unprivileged
# wrapper phase keeps the caller's PATH so it can still find sudo/pkexec.
if (( EUID == 0 )); then
export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin
fi
NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf
provider_from_arg() { provider_from_arg() {
@@ -28,19 +40,32 @@ provider_from_arg() {
esac esac
} }
self_path() { # The path etc/sudoers.d/omarchy-dns names. The privileged half always runs from
readlink -f "$OMARCHY_PATH/bin/${BASH_SOURCE[0]##*/}" # there rather than from whichever copy was invoked, so the rule matches even
# where $OMARCHY_PATH points at a checkout.
PACKAGED_PATH=/usr/bin/omarchy-dns
# True when sudo would run this exact command without stopping for a password.
# `sudo -l` on its own reports whether a command is permitted, which the blanket
# %wheel rule answers yes to for everything; the long listing prints the matched
# entry's tags, so !authenticate is the grant in etc/sudoers.d/omarchy-dns and
# nothing else. Listing runs nothing and, under -n, prompts for nothing, so a
# machine whose omarchy-settings predates that file falls through to polkit
# instead of dying on a password prompt it has no terminal to show.
sudo_grants_passwordless() {
sudo -n -l -l "$PACKAGED_PATH" "$@" 2>/dev/null | grep -q '!authenticate'
} }
require_root() { require_root() {
if (( EUID == 0 )); then if (( EUID == 0 )); then
return return
fi elif [[ -t 0 ]] || sudo_grants_passwordless "$@"; then
# A terminal can carry sudo's own password prompt. Without one, sudo is
if [[ -t 0 ]]; then # right only where the grant reaches; polkit can at least put a prompt on
exec sudo "$(self_path)" "$@" # screen, and offer to authenticate as someone else.
exec sudo "$PACKAGED_PATH" "$@"
else else
exec pkexec "$(self_path)" "$@" exec pkexec "$PACKAGED_PATH" "$@"
fi fi
} }
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# omarchy:summary=Check that a git URL names a repository, not a transport helper
# omarchy:args=<git-url>
# omarchy:hidden=true
set -euo pipefail
# git picks a remote helper -- an executable it runs at clone time -- out of a URL
# in exactly two shapes, and no others: `<helper>::<address>`, and
# `<scheme>://<address>` for any scheme git does not handle itself. A single
# colon is always scp-style ssh, and a bare path is always a path; neither can
# reach a helper. So constraining those two shapes covers the whole surface.
#
# The `::` shape is refused outright, because no helper reachable that way is one
# a theme or plugin URL has business naming, and `ext::` runs a shell command.
# The `://` shape cannot be refused the same way, since it is also how every
# legitimate URL arrives -- so it is allowlisted instead. The list is the
# transports git still connects itself, `git+ssh` and `ssh+git` included: those
# two are spelled like a helper but are read as plain ssh. `ext` and `fd` are
# left out deliberately -- git ships a helper for each, and `ext` runs whatever
# command the URL carries.
TRANSPORTS=(ssh git git+ssh ssh+git http https ftp ftps file)
fail() {
echo "omarchy-git-url-check: $*" >&2
exit 1
}
url="${1-}"
if [[ -z $url ]]; then
fail "a git URL is required"
fi
if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then
fail "'$url' names a git option or transport helper, not a repository."
fi
if [[ $url =~ ^([A-Za-z0-9][A-Za-z0-9+.-]*):// ]]; then
scheme="${BASH_REMATCH[1]}"
for transport in "${TRANSPORTS[@]}"; do
if [[ $scheme == "$transport" ]]; then
exit 0
fi
done
fail "'$url' names the '$scheme' transport, which Omarchy does not clone from."
fi
+32 -2
View File
@@ -11,6 +11,14 @@ MONITOR_LUA="$HOME/.config/hypr/monitors.lua"
INTERNAL=$(omarchy-hyprland-monitor-laptop) INTERNAL=$(omarchy-hyprland-monitor-laptop)
# INTERNAL is written into generated Lua and hyprctl eval/dispatch below, so a
# name that is not a plain connector string could execute on the next reload.
# Names come from hyprctl; a user-created headless output can carry anything.
if [[ -n $INTERNAL && ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "Refusing unsafe internal monitor name" >&2
exit 1
fi
valid_scale() { valid_scale() {
[[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]
} }
@@ -118,9 +126,17 @@ remember_internal_scale() {
store_internal_scale "$scale" store_internal_scale "$scale"
} }
# $1 is the configured scale when the caller has already read it, so one sync
# does not parse the config twice.
read_monitor_scale() { read_monitor_scale() {
local scale local scale
scale=$(configured_monitor_scale)
if (( $# )); then
scale="$1"
else
scale=$(configured_monitor_scale)
fi
if valid_scale "$scale"; then if valid_scale "$scale"; then
echo "$scale" echo "$scale"
return return
@@ -159,11 +175,25 @@ enable_internal_output() {
sync_internal_scale() { sync_internal_scale() {
[[ -n $INTERNAL ]] || return 0 [[ -n $INTERNAL ]] || return 0
local configured_scale
local desired_scale local desired_scale
local active_scale local active_scale
desired_scale=$(read_monitor_scale) configured_scale=$(configured_monitor_scale)
active_scale=$(current_internal_scale) active_scale=$(current_internal_scale)
# A config without a usable number -- the default "auto", or an expression
# only Hyprland's Lua can evaluate -- delegates the scale to the compositor,
# so whatever it resolved for the enabled panel IS the configured scale.
# There is no number to correct it toward: substituting one makes the scale
# flap between that number and the compositor's own value on every idle-wake.
# Only a panel that is off altogether still gets a hand below, from the
# remembered scale.
if ! valid_scale "$configured_scale" && valid_scale "$active_scale"; then
return 0
fi
desired_scale=$(read_monitor_scale "$configured_scale")
scales_match "$active_scale" "$desired_scale" && return 0 scales_match "$active_scale" "$desired_scale" && return 0
enable_internal_output "$desired_scale" enable_internal_output "$desired_scale"
+7
View File
@@ -28,6 +28,13 @@ off() {
exit 1 exit 1
fi fi
# The name is written into generated Lua below, so only a plain connector
# name may pass; anything else could execute on the next reload.
if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name"
exit 1
fi
if ! omarchy-hyprland-monitor-external-active; then if ! omarchy-hyprland-monitor-external-active; then
omarchy-notification-send -g 󰍹 "Can't disable the only active display" omarchy-notification-send -g 󰍹 "Can't disable the only active display"
exit 1 exit 1
@@ -22,6 +22,15 @@ on() {
exit 1 exit 1
fi fi
# Both names are written into generated Lua below, so only plain connector
# names may pass; a user-created headless output can carry any name.
for output in "$INTERNAL" "$EXTERNAL"; do
if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then
omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name"
exit 1
fi
done
omarchy-hyprland-toggle $DISABLE_TOGGLE off omarchy-hyprland-toggle $DISABLE_TOGGLE off
if omarchy-hyprland-toggle-disabled $TOGGLE; then if omarchy-hyprland-toggle-disabled $TOGGLE; then
+8
View File
@@ -80,6 +80,14 @@ set_scale() {
local width="$(echo "$monitor_info" | jq -r '.width')" local width="$(echo "$monitor_info" | jq -r '.width')"
local height="$(echo "$monitor_info" | jq -r '.height')" local height="$(echo "$monitor_info" | jq -r '.height')"
local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')" local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')"
# active_monitor is written into the Lua string eval'd below, so only a plain
# connector name may pass; a hostile output name could execute otherwise.
if [[ ! $active_monitor =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "Refusing unsafe monitor name" >&2
exit 1
fi
local new_scale="$(clean_scale "$requested_scale" "$width" "$height")" local new_scale="$(clean_scale "$requested_scale" "$width" "$height")"
# GTK only honors integer GDK_SCALE values, so persist the nearest whole # GTK only honors integer GDK_SCALE values, so persist the nearest whole
# factor even when the monitor scale itself is fractional. # factor even when the monitor scale itself is fractional.
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# omarchy:summary=Open the Docker TUI (lazydocker) with access to the Docker daemon
# omarchy:hidden=true
# By default the install user is NOT in the docker group: membership is
# root-equivalent (a container can bind-mount / and rewrite the host as root),
# so a single process running as the user could otherwise escalate to root with
# no prompt. lazydocker needs the root-owned Docker socket, so when the group is
# absent, gate that access behind a polkit prompt. If the user has opted into
# sudoless Docker (omarchy-setup-security-sudoless-docker), the socket is already
# reachable, so run lazydocker directly — omarchy-sudo-docker answers that for
# this session, so the prompt stays until the reboot that grants the group.
# pkexec sanitizes the environment, so carry TERM through for the TUI to render
# and run lazydocker from root's PATH.
if omarchy-sudo-docker; then
exec pkexec /usr/bin/env TERM="${TERM:-xterm-256color}" lazydocker
else
exec lazydocker
fi
+1 -1
View File
@@ -38,7 +38,7 @@ id=$(cut -f2 <<<"$selection")
if [[ $1 == "clone" ]]; then if [[ $1 == "clone" ]]; then
omarchy-launch-floating-terminal-with-presentation \ omarchy-launch-floating-terminal-with-presentation \
"omarchy-plugin-clone --edit $(printf '%q' "$id")" "omarchy-plugin-clone $(printf '%q' "$id") --edit"
elif [[ $1 == "remove" ]]; then elif [[ $1 == "remove" ]]; then
omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove $(printf '%q' "$id")" omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove $(printf '%q' "$id")"
else else
+1 -1
View File
@@ -41,7 +41,7 @@ fi
# The shell keeps the click command with the toast, so this oneshot can hand the # The shell keeps the click command with the toast, so this oneshot can hand the
# invitation over and exit instead of staying activated until it is answered. # invitation over and exit instead of staying activated until it is answered.
omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" "$message" \ omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" "$message" \
--exec "omarchy-launch-floating-terminal-with-presentation omarchy-migrate" && exit 0 --exec omarchy-launch-floating-terminal-with-presentation omarchy-migrate && exit 0
# Reached when the notification could not be handed off, so fall back to telling # Reached when the notification could not be handed off, so fall back to telling
# the user in the terminal. # the user in the terminal.
+1 -1
View File
@@ -22,7 +22,7 @@ rm -f "$HOME/.local/bin/$command"
cat >"$HOME/.local/bin/$command" <<EOF cat >"$HOME/.local/bin/$command" <<EOF
#!/bin/bash #!/bin/bash
export MISE_MINIMUM_RELEASE_AGE=0 export MISE_MINIMUM_RELEASE_AGE=0
mise use -g "$package" || exit 1 mise use -g --quiet "$package" || exit 1
exec mise x "$package" -- "$bin" "\$@" exec mise x "$package" -- "$bin" "\$@"
EOF EOF
+151 -63
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Send an Omarchy desktop notification # omarchy:summary=Send an Omarchy desktop notification
# omarchy:args=[--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options] # omarchy:args=[--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]
# omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌 # omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌
set -euo pipefail set -euo pipefail
@@ -11,61 +11,75 @@ description=""
glyph= glyph=
urgency="low" urgency="low"
app_name="omarchy-action" app_name="omarchy-action"
app_icon=""
image= image=
exec_command= expire_timeout=-1
args=() replaces_id=0
print_id=0
exec_args=()
exec_present=0
parsed_option_args=0 parsed_option_args=0
usage() {
echo "Usage: omarchy-notification-send [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]" >&2
}
# Recognize a known option, in both `--flag value` and `--flag=value` forms.
# Returns 1 for anything unrecognized so the caller can decide (headline, or a
# hard error in option position).
parse_omarchy_option() { parse_omarchy_option() {
case $1 in local opt val nargs
-g | --glyph) if [[ $1 == --?*=* ]]; then
if (($# < 2)); then opt=${1%%=*}
echo "Missing value for $1" >&2 val=${1#*=}
exit 1 nargs=1
fi else
glyph=$2 opt=$1
parsed_option_args=2 val=${2-}
nargs=2
fi
# -p/--print-id is a flag; it takes no value.
if [[ $opt == -p || $opt == --print-id ]]; then
print_id=1
parsed_option_args=1
return 0 return 0
fi
case $opt in
-g | --glyph | -u | --urgency | --app-name | -i | --icon | --image | -r | --replace-id | -t | --expire-time) ;;
*) return 1 ;;
esac
if ((nargs == 2)) && (($# < 2)); then
echo "Missing value for $opt" >&2
exit 1
fi
case $opt in
-g | --glyph) glyph=$val ;;
-u | --urgency) urgency=$val ;;
--app-name) app_name=$val ;;
-i | --icon) app_icon=$val ;;
--image) image=$val ;;
-r | --replace-id)
[[ $val =~ ^[0-9]+$ ]] || {
echo "Invalid $opt value (numeric id expected): $val" >&2
exit 1
}
replaces_id=$val
;; ;;
-u | --urgency) -t | --expire-time)
if (($# < 2)); then [[ $val =~ ^-?[0-9]+$ ]] || {
echo "Missing value for $1" >&2 echo "Invalid $opt value (milliseconds expected): $val" >&2
exit 1 exit 1
fi }
urgency="$2" expire_timeout=$val
parsed_option_args=2
return 0
;;
--app-name)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
app_name=$2
parsed_option_args=2
return 0
;;
--image)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
image=$2
parsed_option_args=2
return 0
;;
--exec)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
exec_command=$2
parsed_option_args=2
return 0
;; ;;
esac esac
return 1 parsed_option_args=$nargs
return 0
} }
while (($# > 0)); do while (($# > 0)); do
@@ -77,47 +91,121 @@ while (($# > 0)); do
done done
if (($# < 1)); then if (($# < 1)); then
echo "Usage: omarchy-notification-send [--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options]" usage
exit 1 exit 1
fi fi
headline=$1 headline=$1
shift shift
if (($# > 0)) && [[ $1 != -* ]]; then # The description is the next positional, taken as text even when it begins with
# a dash — a body like "-50% off" or a negative number is content, not options.
# Only a recognized option flag or --exec in that slot is not the description.
known_flag() {
case $1 in
-g | --glyph | -u | --urgency | --app-name | -i | --icon | -t | --expire-time | --image | -r | --replace-id | -p | --print-id | --exec) return 0 ;;
--glyph=* | --urgency=* | --app-name=* | --icon=* | --expire-time=* | --image=* | --replace-id=*) return 0 ;;
esac
return 1
}
if (($# > 0)) && ! known_flag "$1"; then
description=$1 description=$1
shift shift
fi fi
while (($# > 0)); do while (($# > 0)); do
if parse_omarchy_option "$@"; then if [[ $1 == "--exec" ]]; then
# --exec consumes the rest of the line as the click command's argv. The
# caller's shell already tokenized those words into discrete arguments, and
# the shell runs them as-is (never re-parsed), so untrusted data in an
# argument is only ever one argument and can never become a command.
# Detected only here, after the headline/description positionals are
# captured, so an untrusted headline that is literally "--exec" is taken as
# text and can't be mistaken for the delimiter. --exec therefore comes last.
shift
exec_args=("$@")
exec_present=1
break
elif parse_omarchy_option "$@"; then
shift "$parsed_option_args" shift "$parsed_option_args"
else else
args+=("$1") echo "Unknown option: $1" >&2
shift usage
exit 1
fi fi
done done
# Tag as a user-action toast so it pops through DND. case $urgency in
args+=("-a" "$app_name" "-u" "$urgency") low) urgency_byte=0 ;;
normal) urgency_byte=1 ;;
critical) urgency_byte=2 ;;
*)
echo "Unknown urgency: $urgency (use low, normal, or critical)" >&2
exit 1
;;
esac
# a{sv} hints, as busctl triples (key, variant type, value). urgency is a byte;
# the rest are strings. The click command rides here as omarchy-exec-argv, built
# only from --exec below.
hints=(urgency y "$urgency_byte")
if [[ -n $glyph ]]; then if [[ -n $glyph ]]; then
args+=("--hint=string:omarchy-glyph:$glyph") hints+=(omarchy-glyph s "$glyph")
fi fi
if [[ -n $image ]]; then if [[ -n $image ]]; then
args+=("--hint=string:image-path:$image") hints+=(image-path s "$image")
fi fi
# The shell runs the click command itself, from a copy it keeps alongside the if ((exec_present)); then
# on-screen popup. A libnotify action would instead keep this process blocked if ((${#exec_args[@]} == 0)) || [[ -z ${exec_args[0]} ]]; then
# until the click, and die unanswered whenever the shell restarts underneath it. echo "--exec needs a command: --exec <program> [args...]" >&2
if [[ -n $exec_command ]]; then exit 1
args+=("--hint=string:omarchy-exec:$exec_command") fi
# A single word with a space is almost always a whole command passed as one
# quoted string — which would run a program literally named that. Splitting it
# ourselves is exactly the injection we avoid, so reject it and point at the
# unquoted form instead.
if ((${#exec_args[@]} == 1)) && [[ ${exec_args[0]} == *[[:space:]]* ]]; then
echo "--exec takes the command as separate words, not one quoted string." >&2
echo "Write: --exec ${exec_args[0]}" >&2
exit 1
fi
# NUL-delimit into jq so every byte survives as data: jq's own --args would eat
# a bare "--", and a newline in an arg must not split the vector.
exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]')
hints+=(omarchy-exec-argv s "$exec_argv_json")
fi fi
if [[ -n $description ]]; then hint_count=$((${#hints[@]} / 3))
notify-send "${args[@]}" "$headline" "$description"
# Call org.freedesktop.Notifications.Notify directly — never notify-send. Its
# argv parsing is the surface that reinterprets a relayed headline like
# `--hint=…` or `-rf` as options or hints; busctl takes each value as one typed
# D-Bus parameter instead, and the leading `--` keeps a dash-leading value
# (headline, description, a negative timeout) positional rather than a busctl
# option. So the summary and body are strings that can never become a hint, and
# omarchy-exec-argv is set only from --exec.
#
# Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body,
# actions (empty), hints, expire_timeout. replaces_id (from -r) updates a toast
# in place; -p prints the returned id so a caller can reuse it.
notify_cmd=(
busctl --user -- call
org.freedesktop.Notifications /org/freedesktop/Notifications
org.freedesktop.Notifications Notify susssasa{sv}i
"$app_name" "$replaces_id" "$app_icon" "$headline" "$description"
0
"$hint_count" "${hints[@]}"
"$expire_timeout"
)
if ((print_id)); then
# busctl prints the UINT32 return as "u <id>"; emit just the id.
out=$("${notify_cmd[@]}")
printf '%s\n' "${out##* }"
else else
notify-send "${args[@]}" "$headline" "${notify_cmd[@]}" >/dev/null
fi fi
+7
View File
@@ -93,6 +93,13 @@ if [[ -z $url ]]; then
[[ -n $url ]] || fail "a git URL is required" [[ -n $url ]] || fail "a git URL is required"
fi fi
# Refuse a URL that names a git option or a transport helper before cloning, so
# an untrusted URL cannot run a command before the plugin is validated or
# enabled. The check is shared with omarchy-theme-install and explains itself; a
# missing checker leaves this non-zero, which refuses the URL rather than
# cloning it.
omarchy-git-url-check "$url" || exit 1
if (( ! ASSUME_YES )); then if (( ! ASSUME_YES )); then
cat >&2 <<WARN cat >&2 <<WARN
+10 -1
View File
@@ -34,6 +34,15 @@ if [[ ! -f $logo_path ]]; then
exit 1 exit 1
fi fi
# omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from
# ~/.config/omarchy/themes, where an installed theme can make it a symlink to
# anything. The copies below land in world-readable /usr/share, so following one
# would republish whatever it points at.
if [[ -L $logo_path ]]; then
echo "Logo file is a symlink, which is not accepted: $logo_path" >&2
exit 1
fi
bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}') bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}')
bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}') bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}')
bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}') bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}')
@@ -72,7 +81,7 @@ sed \
-e "s/#ffffff/#$text_hex/g" \ -e "s/#ffffff/#$text_hex/g" \
"$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null "$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null
sudo cp "$logo_path" "$sddm_dir/logo.png" sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"
for asset in bullet.png entry.png lock.png; do for asset in bullet.png entry.png lock.png; do
sudo cp "$staging_dir/$asset" "$sddm_dir/$asset" sudo cp "$staging_dir/$asset" "$sddm_dir/$asset"
done done
+7 -1
View File
@@ -141,7 +141,8 @@ say() {
notice() { notice() {
clear_logo clear_logo
echo echo
gum spin --spinner "pulse" --title "$1" -- sleep "${2:-2}" printf '%*s\033[32m● \033[0m%s\n' "$PADDING_LEFT" '' "$1"
sleep "${2:-2}"
echo echo
} }
@@ -676,6 +677,11 @@ user_groups() {
if [[ -f $PROVISIONING_DIR/groups ]]; then if [[ -f $PROVISIONING_DIR/groups ]]; then
while IFS= read -r group; do while IFS= read -r group; do
[[ -n $group ]] || continue [[ -n $group ]] || continue
# Never grant docker at first boot, even if an older install recorded it
# (or a factory snapshot predating the opt-in default carries it): the
# docker group is root-equivalent. It is opt-in via
# omarchy-setup-security-sudoless-docker.
[[ $group == "docker" ]] && continue
getent group "$group" >/dev/null || continue getent group "$group" >/dev/null || continue
[[ ",$groups," == *",$group,"* ]] || groups+=",$group" [[ ",$groups," == *",$group,"* ]] || groups+=",$group"
done <"$PROVISIONING_DIR/groups" done <"$PROVISIONING_DIR/groups"
+7 -2
View File
@@ -24,9 +24,14 @@ echo -e "\e[32mRemoving FIDO2 device from authentication.\n\e[0m"
remove_pam_config remove_pam_config
if [[ -d /etc/fido2 ]]; then authdir=/etc/fido2
# -d follows symlinks, so a dangling link at /etc/fido2 would survive this and
# a later setup would install the authfile through it. rm -rf on a symlink
# removes the link itself, never the directory it points at.
if [[ -e $authdir || -L $authdir ]]; then
echo "Removing FIDO2 configuration..." echo "Removing FIDO2 configuration..."
sudo rm -rf /etc/fido2 sudo rm -rf "$authdir"
fi fi
echo "Removing FIDO2 packages..." echo "Removing FIDO2 packages..."
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# omarchy:summary=Disable sudoless Docker by removing your user from the docker group
# omarchy:requires-sudo=true
set -e
# Ask about the configured groups, not this session's: right after enabling,
# sudoless Docker is on for the account even though the running session still
# needs a prompt, and this command is what turns it back off.
if omarchy-sudo-docker --configured; then
echo "Sudoless Docker is not enabled: $USER is not in the docker group."
exit 0
fi
echo "Removing $USER from the docker group..."
sudo gpasswd -d "$USER" docker >/dev/null
# Group membership is only re-read by a fresh session, and in practice logging
# out or newgrp isn't enough — only a reboot reliably applies it. Record it so a
# later `omarchy update` still prompts (omarchy-update-restart reads this), then
# offer to do it now.
omarchy-state set reboot-required
echo ""
echo "Sudoless Docker DISABLED. Docker access goes through a polkit/sudo prompt"
echo "again: the Docker TUI (Super + Shift + D) and the Windows VM ask when they"
echo "need it, and the plain 'docker' CLI runs under sudo. It takes effect after a reboot."
echo ""
# The migration reuses this command during 'omarchy update' and defers the
# reboot to omarchy-update-restart, so it doesn't cut the update short.
if [[ -z ${OMARCHY_DEFER_REBOOT:-} ]] && gum confirm "Reboot now to apply?"; then
omarchy-system-reboot
fi
-1
View File
@@ -63,7 +63,6 @@ relock_session() {
# Each kill stops the oldest matching instance and only returns once it has # Each kill stops the oldest matching instance and only returns once it has
# fully exited, so the no-duplicate launch below can't race a dying shell. # fully exited, so the no-duplicate launch below can't race a dying shell.
# Requires our quickshell-git build; 0.3.0's kill returns immediately.
while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; do :; done while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; do :; done
# Spawn from Hyprland so the shell inherits the canonical session environment, # Spawn from Hyprland so the shell inherits the canonical session environment,
+71 -4
View File
@@ -4,6 +4,7 @@
# omarchy:requires-sudo=true # omarchy:requires-sudo=true
set -e set -e
set -o pipefail
check_fido2_hardware() { check_fido2_hardware() {
@@ -50,13 +51,79 @@ if ! check_fido2_hardware; then
fi fi
# Create the pamu2fcfg file # Create the pamu2fcfg file
if [[ ! -f /etc/fido2/fido2 ]]; then authdir=/etc/fido2
sudo mkdir -p /etc/fido2 authfile=/etc/fido2/fido2
# install -d follows a symlink here and applies the mode and ownership to
# whatever it points at, so the credential would be staged and published inside
# the link target and that directory reopened to root:root 755. This is the
# threat omarchy-remove-security-fido2 already names on its side.
if [[ -L $authdir || ( -e $authdir && ! -d $authdir ) ]]; then
echo -e "\e[31m\n$authdir is not a FIDO2 configuration directory.\e[0m"
echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again."
exit 1
fi
# -f follows symlinks, so the already-registered check below reads a symlinked
# authfile as a registration and leaves it in place, and is false for a
# directory, so it tries to register over one. Only a regular file is a valid
# pam_u2f authfile.
if [[ -L $authfile || ( -e $authfile && ! -f $authfile ) ]]; then
echo -e "\e[31m\n$authfile is not a FIDO2 registration file.\e[0m"
echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again."
exit 1
fi
if [[ ! -f $authfile ]]; then
sudo install -d -m 755 -o root -g root "$authdir"
echo -e "\e[32m\nLet's setup your device by confirming on the device now.\e[0m" echo -e "\e[32m\nLet's setup your device by confirming on the device now.\e[0m"
echo -e "Touch your FIDO2 key when it lights up...\n" echo -e "Touch your FIDO2 key when it lights up...\n"
if pamu2fcfg >/tmp/fido2; then # A unique sibling created by root cannot be replaced by another process
sudo mv /tmp/fido2 /etc/fido2/fido2 # running as this user. Stream pamu2fcfg into it instead of asking root to
# reopen a caller-owned path: an observed temporary name could otherwise be
# replaced with a symlink before the privileged copy. The final rename is
# atomic, and -T refuses a directory at the destination. Mode 644 keeps the
# root-owned global authfile readable when pam_u2f uses openasuser; only root
# can still rewrite it.
stage=""
# mktemp's output is an operand for four privileged commands below, one of
# them an rm. Take only the name this script asked for rather than whatever
# came back on stdout.
safe_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
cleanup_stage() {
local status=$?
if safe_stage_path "$stage"; then
sudo rm -f -- "$stage" || true
fi
return "$status"
}
trap cleanup_stage EXIT
stage=$(sudo mktemp "$authfile.new.XXXXXX")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
echo -e "\e[31m\nCould not create a safe staging file beside $authfile.\e[0m"
exit 1
fi
if pamu2fcfg | sudo tee "$stage" >/dev/null && [[ -s $stage ]]; then
sudo chmod 644 "$stage"
sudo mv -Tf "$stage" "$authfile"
stage=""
trap - EXIT
echo -e "\e[32mFIDO2 device registered successfully!\e[0m" echo -e "\e[32mFIDO2 device registered successfully!\e[0m"
else else
echo -e "\e[31m\nFIDO2 registration failed. Please try again.\e[0m" echo -e "\e[31m\nFIDO2 registration failed. Please try again.\e[0m"
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# omarchy:summary=Enable sudoless Docker by adding your user to the docker group (root-equivalent!)
# omarchy:requires-sudo=true
set -e
# Ask about the configured groups, not this session's: once enabled it stays
# enabled for the account even before the reboot that lets this session use it.
if ! omarchy-sudo-docker --configured; then
echo "Sudoless Docker is already enabled: $USER is in the docker group."
echo "To disable it again, run: omarchy-remove-security-sudoless-docker"
exit 0
fi
echo ""
echo "⚠️ WARNING: Enabling sudoless Docker adds you to the 'docker' group."
echo ""
echo "The Docker daemon runs as root, so membership in the docker group is"
echo "equivalent to passwordless root. Any process running as your user could"
echo "then run, for example:"
echo ""
echo " docker run -v /:/host alpine # full read/write of the host, as root"
echo ""
echo "and take over the machine with no password prompt. A single rogue script,"
echo "dependency, or plugin running as you is enough. It is convenient for"
echo "development, but it removes the protection Omarchy keeps by default, where"
echo "Docker access goes through a polkit/sudo prompt."
echo ""
if gum confirm "Enable sudoless Docker? This gives anything running as you passwordless root."; then
sudo usermod -aG docker "$USER"
# A new docker group membership is only picked up by a fresh session, and in
# practice logging out or newgrp isn't enough — only a reboot reliably applies
# it. Record it so a later `omarchy update` still prompts
# (omarchy-update-restart reads this), then offer to do it now.
omarchy-state set reboot-required
echo ""
echo "Sudoless Docker ENABLED. It takes effect after a reboot."
echo "To disable it again: Setup > Security > Sudoless Docker."
echo ""
# The migration reuses this command during 'omarchy update' and defers the
# reboot to omarchy-update-restart, so it doesn't cut the update short.
if [[ -z ${OMARCHY_DEFER_REBOOT:-} ]] && gum confirm "Reboot now to apply?"; then
omarchy-system-reboot
fi
else
echo "Aborted. No changes made. Docker access still goes through a prompt."
fi
+15 -3
View File
@@ -1,6 +1,18 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Display a "Done!" message with a spinner and wait for user to press any key. # omarchy:summary=Display a "Done!" message and wait for user to press any key.
echo # The device node is there whether or not a terminal is behind it, so opening
gum spin --spinner "globe" --title "Done! Press any key to close..." -- bash -c 'read -n 1 -s' # it is the only test that means anything.
: 2>/dev/null <>/dev/tty || exit 0
# gum 2.0 no longer lets a spun command read the terminal, so the wait has to
# happen here. Its own query replies are still queued on the tty; drop those or
# they answer the keypress for the user.
while read -rsn 1 -t 0.1 _ </dev/tty; do :; done
# Prompt on the terminal rather than stdout, or a caller that redirects us
# leaves the user waiting on a prompt they were never shown.
printf '\n\033[32m● \033[0mDone! Press any key to close...' >/dev/tty
read -rsn 1 </dev/tty
echo >/dev/tty
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# omarchy:summary=Succeed when Docker needs sudo, fail when it can be used directly
# omarchy:args=[--configured]
# omarchy:examples=omarchy-sudo-docker && echo "needs sudo" | omarchy-sudo-docker --configured
# omarchy:hidden=true
# The docker group is root-equivalent, so Omarchy leaves users out of it by
# default and reaches the daemon through a prompt instead. Everything that has
# to make that choice asks here rather than testing group membership itself.
#
# Two questions, because they have different answers between toggling sudoless
# Docker and the reboot that applies it (group membership is fixed when the
# session is created):
#
# (default) Does Docker need sudo *right now*? Answered by whether this
# process can actually reach the socket, which is what decides
# if a command must elevate. Still true in the window after
# sudoless Docker is enabled but before the reboot.
# --configured Will it need sudo once the account's groups take effect?
# Answered from the account's configured groups, so the menu
# offers the toggle that can actually change state.
#
# Succeeds (exit 0) when sudo is needed, so it reads as `if omarchy-sudo-docker`.
DOCKER_SOCKET="${OMARCHY_DOCKER_SOCKET:-/var/run/docker.sock}"
case "${1:-}" in
--configured)
# An account in the docker group will not need sudo after the next login.
id -nG "$USER" 2>/dev/null | grep -qw docker && exit 1
exit 0
;;
"")
# A socket we can write is a daemon we can drive without elevating. A missing
# socket counts as needing sudo: reaching it means starting it as root anyway.
[[ -w $DOCKER_SOCKET ]] && exit 1
exit 0
;;
*)
echo "Usage: omarchy-sudo-docker [--configured]" >&2
exit 2
;;
esac
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
# omarchy:summary=Reset the sudo lockout/faillock for the current user.
su -c "faillock --reset --user $USER"
+2 -2
View File
@@ -59,7 +59,7 @@ claim_path() {
announce() { announce() {
local path="$1" local path="$1"
local name="${path##*/}" local name="${path##*/}"
local args=("Received $name" "Saved to ${dir/#$HOME/~}") local args=("Received $name" "Saved to ${dir/#$HOME/~}" -u critical)
case "${name,,}" in case "${name,,}" in
*.png | *.jpg | *.jpeg | *.gif | *.webp | *.avif | *.bmp | *.tif | *.tiff) *.png | *.jpg | *.jpeg | *.gif | *.webp | *.avif | *.bmp | *.tif | *.tiff)
@@ -72,7 +72,7 @@ announce() {
# Announcing is best-effort: the file is already delivered, and under `set -e` # Announcing is best-effort: the file is already delivered, and under `set -e`
# a notification outage would otherwise kill the long-running receiver. # a notification outage would otherwise kill the long-running receiver.
omarchy-notification-send "${args[@]}" --exec "$(printf 'xdg-open %q' "$path")" || true omarchy-notification-send "${args[@]}" --exec xdg-open "$path" || true
} }
deliver() { deliver() {
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# omarchy:summary=List the user-installed themes that came from a git clone
# omarchy:examples=omarchy theme extras
# Exits nonzero when there are none, so a caller can ask whether any exist
# without reading the list. A symlinked theme is someone's working copy and a
# `.git` file is a worktree pointing elsewhere; neither is ours to pull.
status=1
for theme in ~/.config/omarchy/themes/*; do
[[ ! -L $theme && -d $theme/.git ]] || continue
echo "$theme"
status=0
done
exit $status
+18 -4
View File
@@ -16,24 +16,38 @@ if [[ -z $REPO_URL ]]; then
exit 1 exit 1
fi fi
# Refuse a URL that names a git option or a transport helper before cloning. The
# check is shared with omarchy-plugin-add and explains itself; a missing checker
# leaves this non-zero, which refuses the URL rather than cloning it.
omarchy-git-url-check "$REPO_URL" || exit 1
THEMES_DIR="$HOME/.config/omarchy/themes" THEMES_DIR="$HOME/.config/omarchy/themes"
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path # Strip user@host: prefix from scp-style SSH URLs so basename sees just the path
REPO_PATH="$REPO_URL" REPO_PATH="$REPO_URL"
[[ $REPO_PATH != *"://"* && $REPO_PATH == *:*/* ]] && REPO_PATH="${REPO_PATH#*:}" [[ $REPO_PATH != *"://"* && $REPO_PATH == *:*/* ]] && REPO_PATH="${REPO_PATH#*:}"
THEME_NAME=$(basename "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]') THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]')
THEME_PATH="$THEMES_DIR/$THEME_NAME" THEME_PATH="$THEMES_DIR/$THEME_NAME"
# The name comes from the URL and is joined into a path that is about to be
# removed, so a repo called `..` would take ~/.config/omarchy with it. A leading
# dot is refused with it: `host:-s/foo.git` leaves basename with `.git`.
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
echo "Error: '$REPO_URL' does not give a usable theme name."
exit 1
fi
# Remove existing theme if present # Remove existing theme if present
if [[ -d $THEME_PATH ]]; then if [[ -d $THEME_PATH ]]; then
rm -rf "$THEME_PATH" rm -rf "$THEME_PATH"
fi fi
# Clone the repo directly to ~/.config/omarchy/themes # Clone the repo directly to ~/.config/omarchy/themes
if ! git clone "$REPO_URL" "$THEME_PATH"; then if ! git clone -- "$REPO_URL" "$THEME_PATH"; then
echo "Error: Failed to clone theme repo." echo "Error: Failed to clone theme repo."
exit 1 exit 1
fi fi
# Apply the new theme with omarchy-theme-set # Apply the new theme with omarchy-theme-set, which stages only the files an
omarchy-theme-set $THEME_NAME # extra theme is allowed to contribute and names anything it dropped.
omarchy-theme-set "$THEME_NAME"
+3 -2
View File
@@ -21,8 +21,9 @@ fi
THEMES_DIR="$HOME/.config/omarchy/themes" THEMES_DIR="$HOME/.config/omarchy/themes"
THEME_PATH="$THEMES_DIR/$THEME_NAME" THEME_PATH="$THEMES_DIR/$THEME_NAME"
# Ensure a theme was set # Ensure a theme was set, and that the name cannot climb out of THEMES_DIR
if [[ -z $THEME_NAME ]]; then # on its way into the rm below.
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
exit 1 exit 1
fi fi
+130 -2
View File
@@ -17,6 +17,19 @@ THEME_SET_LOCK="${XDG_RUNTIME_DIR:-/tmp}/omarchy-theme-set.lock"
USER_THEMES_PATH="$HOME/.config/omarchy/themes" USER_THEMES_PATH="$HOME/.config/omarchy/themes"
OMARCHY_THEMES_PATH="$OMARCHY_PATH/themes" OMARCHY_THEMES_PATH="$OMARCHY_PATH/themes"
# What a theme installed from a git repo may not ship, because these run code.
# Hyprland requires a theme's hyprland.lua and gum_env.lua at login and Neovim
# loads its neovim.lua at startup, so no .lua from such a theme is staged at all.
# Each terminal config names the program the terminal launches, and vscode.json
# names an extension omarchy-theme-set-vscode installs, a VS Code extension being
# arbitrary JavaScript. Everything else a theme ships is colour and is kept.
#
# Adding a template for another terminal, or for another editor that loads Lua,
# means adding it here. test/shell.d/theme-staging-test.sh fails on a generated
# theme file that is neither denied here nor recorded there as colour-only.
INSTALLED_THEME_DENIED=(alacritty.toml foot.ini ghostty.conf kitty.conf vscode.json)
IGNORED_THEME_FILES=()
run_parallel() { run_parallel() {
local pid local pid
local pids=() local pids=()
@@ -122,12 +135,121 @@ set_theme_background() {
ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK" ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK"
} }
is_denied_installed_file() {
local name="$1"
local denied
[[ $name == *.lua ]] && return 0
for denied in "${INSTALLED_THEME_DENIED[@]}"; do
[[ $name == "$denied" ]] && return 0
done
return 1
}
# Themes older than colors.toml still get their palette, but their
# alacritty.toml never reaches the staged theme: an Alacritty config names the
# program the terminal launches.
stage_installed_colors_from_alacritty() {
local source="$1"
local scratch
if [[ -f $NEXT_THEME_PATH/colors.toml ]]; then
return
fi
if [[ ! -f $source/alacritty.toml || -L $source/alacritty.toml ]]; then
return
fi
scratch=$(mktemp -d)
cp "$source/alacritty.toml" "$scratch/alacritty.toml"
omarchy-theme-colors-from-alacritty "$scratch"
if [[ -f $scratch/colors.toml ]]; then
cp "$scratch/colors.toml" "$NEXT_THEME_PATH/colors.toml"
fi
rm -rf "$scratch"
}
# Copies a directory without ever following a symlink: in an installed theme one
# points wherever the theme author chose, which is how an unlock.png becomes a
# copy of any file the session can read.
stage_installed_dir() {
local source="$1"
local dest="$2"
local entry name
mkdir -p "$dest"
for entry in "$source"/*; do
[[ -e $entry && ! -L $entry ]] || continue
name=${entry##*/}
if [[ -d $entry ]]; then
stage_installed_dir "$entry" "$dest/$name"
else
cp "$entry" "$dest/$name"
fi
done
}
# `omarchy theme install` clones into ~/.config/omarchy/themes, so a .git
# directory there means the contents came from a stranger and are held to the
# list above. A directory the user wrote themselves, and a symlink to their own
# working copy, are theirs to fill however they like -- the same distinction
# omarchy-theme-extras draws when it decides which themes it may pull.
theme_came_from_a_repo() {
local source="$1"
[[ ! -L $source && -d $source/.git ]]
}
stage_installed_theme() {
local source="$1"
local entry name
[[ -d $source ]] || return 0
for entry in "$source"/*; do
[[ -e $entry ]] || continue
name=${entry##*/}
if [[ -L $entry ]] || is_denied_installed_file "$name"; then
case "${name,,}" in
readme* | license* | changelog* | *.md | *.txt) ;;
*) IGNORED_THEME_FILES+=("$name") ;;
esac
elif [[ -d $entry ]]; then
stage_installed_dir "$entry" "$NEXT_THEME_PATH/$name"
else
cp "$entry" "$NEXT_THEME_PATH/$name"
fi
done
stage_installed_colors_from_alacritty "$source"
}
report_ignored_theme_files() {
(( ${#IGNORED_THEME_FILES[@]} > 0 )) || return 0
echo "Ignored in $USER_THEMES_PATH/$THEME_NAME: ${IGNORED_THEME_FILES[*]}" >&2
echo "A theme installed from a git repo cannot supply Lua, a terminal config, or vscode.json." >&2
}
THEME_NAME=$(echo "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-') THEME_NAME=$(echo "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
THEME_HEADLESS=0 THEME_HEADLESS=0
if [[ ${OMARCHY_THEME_HEADLESS:-} == "1" || ${OMARCHY_THEME_OFFLINE:-} == "1" ]]; then if [[ ${OMARCHY_THEME_HEADLESS:-} == "1" || ${OMARCHY_THEME_OFFLINE:-} == "1" ]]; then
THEME_HEADLESS=1 THEME_HEADLESS=1
fi fi
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
echo "Invalid theme name: $1"
exit 1
fi
if [[ ! -d $OMARCHY_THEMES_PATH/$THEME_NAME ]] && [[ ! -d $USER_THEMES_PATH/$THEME_NAME ]]; then if [[ ! -d $OMARCHY_THEMES_PATH/$THEME_NAME ]] && [[ ! -d $USER_THEMES_PATH/$THEME_NAME ]]; then
echo "Theme '$THEME_NAME' does not exist" echo "Theme '$THEME_NAME' does not exist"
exit 1 exit 1
@@ -143,9 +265,15 @@ flock 9
rm -rf "$NEXT_THEME_PATH" rm -rf "$NEXT_THEME_PATH"
mkdir -p "$NEXT_THEME_PATH" mkdir -p "$NEXT_THEME_PATH"
# Copy official theme first, then overlay user customizations on top # Copy official theme first, then overlay the user's theme on top
cp -r "$OMARCHY_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null cp -r "$OMARCHY_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null
cp -r "$USER_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null
if theme_came_from_a_repo "$USER_THEMES_PATH/$THEME_NAME"; then
stage_installed_theme "$USER_THEMES_PATH/$THEME_NAME"
report_ignored_theme_files
else
cp -r "$USER_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null
fi
# Generate colors.toml from alacritty.toml if theme is missing colors.toml # Generate colors.toml from alacritty.toml if theme is missing colors.toml
if [[ ! -f $NEXT_THEME_PATH/colors.toml && -f $NEXT_THEME_PATH/alacritty.toml ]]; then if [[ ! -f $NEXT_THEME_PATH/colors.toml && -f $NEXT_THEME_PATH/alacritty.toml ]]; then
+5 -5
View File
@@ -2,9 +2,9 @@
# omarchy:summary=Update user-installed git themes # omarchy:summary=Update user-installed git themes
for dir in ~/.config/omarchy/themes/*/; do mapfile -t themes < <(omarchy-theme-extras)
if [[ -d $dir ]] && [[ ! -L ${dir%/} ]] && [[ -d $dir/.git ]]; then
echo "Updating: $(basename "$dir")" for theme in "${themes[@]}"; do
git -C "$dir" pull echo "Updating: $(basename "$theme")"
fi git -C "$theme" pull
done done
+40 -14
View File
@@ -7,44 +7,70 @@
KIND="${1:-}" KIND="${1:-}"
ACTION="${2:-toggle}" ACTION="${2:-toggle}"
usage() {
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
}
case "$KIND" in case "$KIND" in
touchpad) LABEL="Touchpad" ICON="touchpad" ;; touchpad) LABEL="Touchpad" ICON="touchpad" ;;
touchscreen) LABEL="Touchscreen" ICON="touch" ;; touchscreen) LABEL="Touchscreen" ICON="touch" ;;
*) *)
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2 usage
exit 1 exit 1
;; ;;
esac esac
# Hyprland sources this directory on reload, so the disabled state survives restarts # The persisted disable is the device name stored as plain data; on every
STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled.lua" # reload default/hypr/disabled-input-device.lua reads it back and disables the
# device. Names come from USB descriptors and must not be interpolated into
# shell or Lua. The path is hardcoded to ~/.local/state like the sibling
# toggle tools, so it keeps working when XDG_STATE_HOME diverges.
NAME_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled-name"
device="$("omarchy-hw-$KIND")" device="$("omarchy-hw-$KIND")"
if [[ -z $device ]]; then require_device() {
echo "No $KIND device found" >&2 if [[ -z $device ]]; then
exit 1 echo "No $KIND device found" >&2
fi exit 1
fi
if [[ $device == *[[:cntrl:]]* ]]; then
echo "Invalid $KIND device name" >&2
exit 1
fi
}
apply_device() {
local enabled=$1
local quoted=${device//\\/\\\\}
quoted=${quoted//\"/\\\"}
hyprctl eval "hl.device({ name = \"$quoted\", enabled = $enabled })" >/dev/null
}
enable() { enable() {
hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null # Clear the persisted state before requiring a usable device, so a device
rm -f "$STATE_FILE" # that stops reporting a valid name can never wedge the disable in place.
rm -f "$NAME_FILE"
require_device
apply_device true
omarchy-osd -i "$ICON" -m "$LABEL enabled" omarchy-osd -i "$ICON" -m "$LABEL enabled"
} }
disable() { disable() {
hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null require_device
mkdir -p "$(dirname "$STATE_FILE")" apply_device false
printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE" mkdir -p "$(dirname "$NAME_FILE")"
printf '%s\n' "$device" >"$NAME_FILE"
omarchy-osd -i "$ICON" -m "$LABEL disabled" omarchy-osd -i "$ICON" -m "$LABEL disabled"
} }
case "$ACTION" in case "$ACTION" in
on) enable ;; on) enable ;;
off) disable ;; off) disable ;;
toggle) if [[ -f $STATE_FILE ]]; then enable; else disable; fi ;; toggle) if [[ -f $NAME_FILE ]]; then enable; else disable; fi ;;
*) *)
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2 usage
exit 1 exit 1
;; ;;
esac esac
+3 -3
View File
@@ -1317,9 +1317,9 @@ apply_system_transition() {
printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \ printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \
as_root tee /usr/lib/chromium/initial_preferences >/dev/null as_root tee /usr/lib/chromium/initial_preferences >/dev/null
if getent group docker >/dev/null; then # Deliberately do NOT add the user to the docker group. That group is
as_root usermod -aG docker "$target_user" # root-equivalent, so it is opt-in now (Setup > Security > Sudoless Docker);
fi # re-granting it on upgrade would silently undo that default for everyone.
if [[ -f /usr/bin/powerprofilesctl ]]; then if [[ -f /usr/bin/powerprofilesctl ]]; then
as_root sed -i '/env python3/ c\#!/bin/python3' /usr/bin/powerprofilesctl || true as_root sed -i '/env python3/ c\#!/bin/python3' /usr/bin/powerprofilesctl || true
+487 -117
View File
@@ -4,11 +4,371 @@
# omarchy:args=<install|remove|launch|stop|status> [options] # omarchy:args=<install|remove|launch|stop|status> [options]
# omarchy:requires-sudo=true # omarchy:requires-sudo=true
COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" # The Windows VM runs a privileged container (KVM, /dev/net/tun, NET_ADMIN), so
# it needs the root-owned Docker daemon. By default the user is NOT in the docker
# group (that group is root-equivalent), so Docker access is gated behind a
# single polkit prompt. If the user opted into sudoless Docker
# (omarchy-setup-security-sudoless-docker), the socket is reachable directly and
# no prompt appears.
#
# The compose file lives in a root-owned directory and is only ever written by
# the elevated, input-validated write_compose action below. That is the whole
# point: a root-invoked `docker compose up` must never consume a file that a
# process running as the user could have rewritten to bind-mount / into the
# container. Earlier versions kept it under ~/.config/windows, which a rogue
# process could edit and then trigger a privileged bring-up of — a file-swap
# path to root. Do not move it back under $HOME.
RUNTIME_DIR="${OMARCHY_WINDOWS_DIR:-/var/lib/omarchy/windows}"
COMPOSE_FILE="$RUNTIME_DIR/docker-compose.yml"
LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml"
# The guest password lives in the root-owned compose (readable by root and the
# docker group), but RDP needs it as the user. Keep a private copy here, 0600 in
# the user's own config, so the plaintext password is never world-readable.
CREDENTIALS_FILE="$HOME/.config/windows/credentials"
IMAGE="dockurr/windows"
CONTAINER="omarchy-windows"
# --- privilege helpers -------------------------------------------------------
# True when this session can reach the Docker socket directly (sudoless Docker
# on and in effect). Asking about the socket rather than the configured groups
# keeps the prompt in place through the window where sudoless Docker is enabled
# but the reboot that grants the group has not happened yet.
docker_needs_sudo() { omarchy-sudo-docker; }
# The command to hand pkexec for the privileged re-exec. pkexec runs whatever
# executable it is given (after authorization) and only shows the path in the
# prompt — it does NOT require the target to be root-owned. So resolve to the
# packaged command and refuse to elevate anything a non-root user could have
# written: a PATH-injected shim, or a user-owned dev checkout. Without this, a
# prompt the user grants for the trusted helper could run an attacker's binary
# as root. Fails closed (empty output) when no trustworthy target is found.
priv_target() {
local candidate owner mode
for candidate in /usr/bin/omarchy-windows-vm "$(type -P omarchy-windows-vm 2>/dev/null)"; do
[[ -n $candidate && -x $candidate ]] || continue
owner=$(stat -Lc '%u' "$candidate" 2>/dev/null) || continue
mode=$(stat -Lc '%a' "$candidate" 2>/dev/null) || continue
[[ $owner == "0" ]] || continue
((8#$mode & 022)) && continue # writable by group or other -> reject
printf '%s\n' "$candidate"
return 0
done
return 1
}
# Run a privileged VM action. write_compose always elevates (the compose is
# root-owned); the daemon operations run directly when sudoless Docker is on and
# otherwise behind a polkit prompt. The stock org.freedesktop.policykit.exec
# policy is auth_admin (not auth_admin_keep), so each elevated action prompts:
# a launch asks once to start and, unless authorization is still cached by the
# agent, again to stop.
priv() {
local action="$1"
shift
if [[ $action != write_compose ]] && ! docker_needs_sudo; then
"__priv_$action" "$@"
return
fi
local target
target=$(priv_target) || {
echo "omarchy-windows-vm: refusing to run a non-root-owned command as root" >&2
return 1
}
pkexec "$target" __priv "$action" "$@"
}
dc() { docker-compose -f "$COMPOSE_FILE" "$@"; }
# --- validation (shared by the user-side prompts and the root-side writer) ----
valid_ram() { [[ $1 =~ ^[0-9]{1,3}G$ ]]; }
valid_cores() { [[ $1 =~ ^[0-9]{1,2}$ ]] && ((10#$1 >= 1)); }
valid_disk() { [[ $1 =~ ^[0-9]{1,4}G$ ]]; }
valid_username() { [[ $1 =~ ^[A-Za-z0-9_-]{1,20}$ ]]; }
valid_tz() { [[ $1 =~ ^[A-Za-z0-9_/.+-]{1,64}$ ]]; }
valid_path() {
[[ $1 =~ ^/[A-Za-z0-9._/-]+$ ]] || return 1
# Reject non-normalized paths: a . or .. component canonicalizes at mount time
# (e.g. /./ or /a/../ -> /), which would bind-mount a sensitive directory —
# host / included — into the guest. Volumes must be given already-normalized.
case "$1" in
*//* | */./* | */../* | */. | */..) return 1 ;;
esac
return 0
}
valid_password() { [[ $1 =~ ^[[:print:]]{1,64}$ ]]; }
# The only privileged sub-actions __priv may dispatch. A bash command name
# containing a slash is executed as a path, so validating the action here — not
# just interpolating it into "__priv_${action}" — is what stops an action like
# ../tmp/evil from running an arbitrary file as root.
valid_priv_action() {
case "$1" in
write_compose | up | up_wait | down | status | remove) return 0 ;;
*) return 1 ;;
esac
}
# --- privileged actions (run as root via pkexec, or directly when sudoless) ---
# Reads KEY=VALUE lines on stdin, re-validates every field, and writes the
# compose atomically as root. Re-validation here is the security boundary: the
# writer refuses rather than emit a compose an attacker could have influenced.
# Only these fixed keys are honored; image, container name, devices, caps, and
# port bindings are hard-coded and never taken from input.
__priv_write_compose() {
local ram cores disk username password tz storage shared key value
while IFS='=' read -r key value; do
case "$key" in
RAM) ram="$value" ;;
CORES) cores="$value" ;;
DISK) disk="$value" ;;
USERNAME) username="$value" ;;
PASSWORD) password="$value" ;;
TZ) tz="$value" ;;
STORAGE) storage="$value" ;;
SHARED) shared="$value" ;;
esac
done
valid_ram "$ram" || { echo "invalid RAM: $ram" >&2; exit 2; }
valid_cores "$cores" || { echo "invalid CPU cores: $cores" >&2; exit 2; }
valid_disk "$disk" || { echo "invalid disk size: $disk" >&2; exit 2; }
valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; }
valid_password "$password" || { echo "invalid password" >&2; exit 2; }
valid_tz "$tz" || tz="UTC"
valid_path "$storage" || { echo "invalid storage path: $storage" >&2; exit 2; }
valid_path "$shared" || { echo "invalid shared path: $shared" >&2; exit 2; }
# Neutralize anything in the password that could be misread when the compose
# is parsed. Two layers apply, in this order at parse time: docker compose
# variable interpolation over the raw text ($VAR / $$), then YAML parsing of
# the double-quoted scalar. Encode for the inner layer first (backslash, then
# double-quote) and the interpolation layer last ($ -> $$), so a password
# containing " \ or $ reaches the guest verbatim. unescape() reverses this in
# the opposite order for the RDP credentials.
local esc_password=${password//\\/\\\\}
esc_password=${esc_password//\"/\\\"}
esc_password=${esc_password//\$/\$\$}
mkdir -p "$RUNTIME_DIR"
chmod 0755 "$RUNTIME_DIR" 2>/dev/null || true
chown root:root "$RUNTIME_DIR" 2>/dev/null || true
local tmp
tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX")
cat >"$tmp" <<EOF
services:
windows:
image: $IMAGE
container_name: $CONTAINER
environment:
VERSION: "11"
RAM_SIZE: "$ram"
CPU_CORES: "$cores"
DISK_SIZE: "$disk"
USERNAME: "$username"
PASSWORD: "$esc_password"
TZ: "$tz"
ARGUMENTS: "-rtc base=localtime,clock=host,driftfix=slew"
devices:
- /dev/kvm
- /dev/net/tun
cap_add:
- NET_ADMIN
ports:
- 127.0.0.1:8006:8006
- 127.0.0.1:3389:3389/tcp
- 127.0.0.1:3389:3389/udp
volumes:
- $storage:/storage
- $shared:/shared
restart: "no"
stop_grace_period: 2m
EOF
# Readable by root and the docker group only. In sudoless mode the user is in
# the docker group and runs docker-compose against this file directly; in the
# default mode the elevated helper (root) reads it, and other local users
# cannot read the guest password. chown falls back to root:root if the docker
# group somehow does not exist (sudoless mode could not be enabled anyway).
chmod 0640 "$tmp" 2>/dev/null || true
chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" 2>/dev/null || true
mv -f "$tmp" "$COMPOSE_FILE"
}
# Read the host source of a bind mount out of the compose (e.g. /storage).
# valid_path kept a ':' out of the stored path, so splitting on it is safe.
get_mount_source() {
sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1
}
# Refuse to bring the VM up if a bind-mount source is a symlink, or reached
# through one. valid_path keeps a traversal string like /./ out of the compose,
# but a symlink planted at ~/.windows or ~/Windows would redirect the privileged
# mount just the same — docker follows it — and a string check cannot see that.
# So verify the real directories here, as root, immediately before the mount. A
# source that does not exist is fine: docker creates it as a plain directory.
assert_mounts_safe() {
local mnt src real
for mnt in /storage /shared; do
src=$(get_mount_source "$mnt")
[[ -n $src ]] || {
echo "omarchy-windows-vm: missing $mnt mount source in the compose" >&2
return 1
}
if [[ -L $src ]]; then
echo "omarchy-windows-vm: refusing to start — $src is a symlink; the VM mount source must be a real directory" >&2
return 1
fi
if [[ -e $src ]]; then
[[ -d $src ]] || {
echo "omarchy-windows-vm: refusing to start — $src is not a directory" >&2
return 1
}
real=$(realpath "$src" 2>/dev/null)
[[ $real == "$src" ]] || {
echo "omarchy-windows-vm: refusing to start — $src resolves through a symlink to $real" >&2
return 1
}
fi
done
}
__priv_up() { assert_mounts_safe && dc up -d; }
__priv_down() { dc down; }
# Bring the VM up and wait until the guest reports it is ready, all under a
# single elevation so the readiness poll does not prompt on every iteration.
__priv_up_wait() {
assert_mounts_safe || return 1
local status
status=$(docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null)
if [[ $status != "running" ]]; then
dc up -d || return 1
fi
# docker logs persists across restarts, so anchor the scan to the current
# start time; an empty --since would match a stale "started successfully".
local started_at count=0
while true; do
started_at=$(docker inspect --format='{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null)
if [[ -n $started_at ]] && docker logs --since "$started_at" "$CONTAINER" 2>&1 | grep -qi "windows started successfully"; then
return 0
fi
sleep 2
((++count > 60)) && {
echo "Timeout: Windows VM did not report ready within 2 minutes" >&2
return 1
}
done
}
# Print the status (empty if the container does not exist) and always succeed,
# so a non-zero exit from priv status means the elevation itself failed
# (authorization declined) rather than "no such container".
__priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; }
__priv_remove() {
dc down 2>/dev/null || true
docker rmi "$IMAGE" 2>/dev/null || true
rm -f "$COMPOSE_FILE"
rmdir "$RUNTIME_DIR" 2>/dev/null || true
}
# --- config helpers ----------------------------------------------------------
# Feed the collected settings to the elevated writer.
write_compose() {
local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" storage="$7" shared="$8"
printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \
"$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared" |
priv write_compose
}
# Reverse, in the opposite order, the escaping __priv_write_compose applied to
# the password: undo the interpolation layer ($$ -> $) first, then the YAML
# layer (\" -> ", then \\ -> \).
unescape() {
local v=$1
v=${v//\$\$/\$}
v=${v//\\\"/\"}
v=${v//\\\\/\\}
printf '%s' "$v"
}
# Store the RDP credentials privately for the user (0600) so the plaintext
# password is not world-readable. The password is one validated printable line
# (no newline), so plain KEY=VALUE is safe.
write_credentials() {
local username="$1" password="$2" old_umask
mkdir -p "$(dirname "$CREDENTIALS_FILE")"
old_umask=$(umask)
umask 077
printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$CREDENTIALS_FILE"
chmod 600 "$CREDENTIALS_FILE" 2>/dev/null || true
umask "$old_umask"
}
# Read one field from the private credentials file; IFS on the first = keeps a
# password that itself contains =.
read_credential() {
local want="$1" key value
[[ -f $CREDENTIALS_FILE ]] || return 1
while IFS='=' read -r key value; do
[[ $key == "$want" ]] && {
printf '%s' "$value"
return 0
}
done <"$CREDENTIALS_FILE"
return 1
}
read_compose_value() {
local key="$1" file="$2"
sed -n "s/.*${key}: \"\(.*\)\"/\1/p" "$file" | head -n1
}
# Older installs kept the compose under ~/.config/windows. Carry those settings
# into the root-owned location (preserving the VM's data via the same volume
# paths) so an upgrade does not strand or re-download an existing VM.
migrate_legacy_compose() {
[[ -f $COMPOSE_FILE ]] && return 0
[[ -f $LEGACY_COMPOSE_FILE ]] || return 1
echo "Migrating Windows VM configuration to $COMPOSE_FILE ..."
local ram cores disk username password tz storage shared
ram=$(read_compose_value RAM_SIZE "$LEGACY_COMPOSE_FILE")
cores=$(read_compose_value CPU_CORES "$LEGACY_COMPOSE_FILE")
disk=$(read_compose_value DISK_SIZE "$LEGACY_COMPOSE_FILE")
username=$(read_compose_value USERNAME "$LEGACY_COMPOSE_FILE")
password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE")
tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE")
# The VM's data always lived in the user's own ~/.windows and ~/Windows; the
# old compose only ever recorded those. Reconstruct them from $HOME (trusted —
# this runs as the user) rather than reading host paths back from a file a
# rogue process could have rewritten to bind-mount, say, / into the guest.
storage="$HOME/.windows"
shared="$HOME/Windows"
[[ -z $tz ]] && tz="UTC"
if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared"; then
echo "Could not migrate the existing configuration automatically." >&2
echo "Re-run: omarchy-windows-vm install" >&2
return 1
fi
write_credentials "$username" "$password"
rm -f "$LEGACY_COMPOSE_FILE"
}
# --- prerequisites -----------------------------------------------------------
check_prerequisites() { check_prerequisites() {
local DISK_SIZE_GB=${1:-64} local DISK_SIZE_GB=${1:-64}
local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead
# Check for KVM support # Check for KVM support
if [[ ! -e /dev/kvm ]]; then if [[ ! -e /dev/kvm ]]; then
@@ -26,7 +386,7 @@ check_prerequisites() {
# Check disk space # Check disk space
AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}')
if (( AVAILABLE_SPACE < REQUIRED_SPACE )); then if ((AVAILABLE_SPACE < REQUIRED_SPACE)); then
echo "❌ Insufficient disk space!" echo "❌ Insufficient disk space!"
echo " Available: ${AVAILABLE_SPACE}GB" echo " Available: ${AVAILABLE_SPACE}GB"
echo " Required: ${REQUIRED_SPACE}GB (${DISK_SIZE_GB}GB disk + 10GB for Windows image)" echo " Required: ${REQUIRED_SPACE}GB (${DISK_SIZE_GB}GB disk + 10GB for Windows image)"
@@ -34,6 +394,8 @@ check_prerequisites() {
fi fi
} }
# --- commands ----------------------------------------------------------------
install_windows() { install_windows() {
# Set up trap to handle Ctrl+C # Set up trap to handle Ctrl+C
trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT
@@ -43,10 +405,9 @@ install_windows() {
omarchy-pkg-add freerdp openbsd-netcat gum omarchy-pkg-add freerdp openbsd-netcat gum
mkdir -p "$HOME/.windows" mkdir -p "$HOME/.windows"
mkdir -p "$HOME/.config/windows"
mkdir -p "$HOME/.local/share/applications" mkdir -p "$HOME/.local/share/applications"
cat << EOF | tee "$HOME/.local/share/applications/windows-vm.desktop" > /dev/null cat <<EOF | tee "$HOME/.local/share/applications/windows-vm.desktop" >/dev/null
[Desktop Entry] [Desktop Entry]
Name=Windows Name=Windows
Comment=Start Windows VM via Docker and connect with RDP Comment=Start Windows VM via Docker and connect with RDP
@@ -70,7 +431,7 @@ EOF
RAM_OPTIONS="" RAM_OPTIONS=""
for size in 2 4 8 16 32 64; do for size in 2 4 8 16 32 64; do
if (( size <= TOTAL_RAM_GB )); then if ((size <= TOTAL_RAM_GB)); then
RAM_OPTIONS="$RAM_OPTIONS ${size}G" RAM_OPTIONS="$RAM_OPTIONS ${size}G"
fi fi
done done
@@ -91,16 +452,16 @@ EOF
exit 1 exit 1
fi fi
if ! [[ $SELECTED_CORES =~ ^[0-9]+$ ]] || (( SELECTED_CORES < 1 )) || (( SELECTED_CORES > TOTAL_CORES )); then if ! valid_cores "$SELECTED_CORES" || ((SELECTED_CORES > TOTAL_CORES)); then
echo "Invalid input. Using default: 2 cores" echo "Invalid input. Using default: 2 cores"
SELECTED_CORES=2 SELECTED_CORES=2
fi fi
AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}')
MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image
# Check if we have enough space for minimum # Check if we have enough space for minimum
if (( MAX_DISK_GB < 32 )); then if ((MAX_DISK_GB < 32)); then
echo "❌ Insufficient disk space for Windows VM!" echo "❌ Insufficient disk space for Windows VM!"
echo " Available: ${AVAILABLE_SPACE}GB" echo " Available: ${AVAILABLE_SPACE}GB"
echo " Minimum required: 42GB (32GB disk + 10GB for Windows image)" echo " Minimum required: 42GB (32GB disk + 10GB for Windows image)"
@@ -109,7 +470,7 @@ EOF
DISK_OPTIONS="" DISK_OPTIONS=""
for size in 32 64 128 256 512; do for size in 32 64 128 256 512; do
if (( size <= MAX_DISK_GB )); then if ((size <= MAX_DISK_GB)); then
DISK_OPTIONS="$DISK_OPTIONS ${size}G" DISK_OPTIONS="$DISK_OPTIONS ${size}G"
fi fi
done done
@@ -139,6 +500,10 @@ EOF
if [[ -z $USERNAME ]]; then if [[ -z $USERNAME ]]; then
USERNAME="docker" USERNAME="docker"
fi fi
if ! valid_username "$USERNAME"; then
echo "Invalid username (use letters, digits, - or _, up to 20 chars). Using default: docker"
USERNAME="docker"
fi
PASSWORD=$(gum input --placeholder="Password (Press enter to use default: admin)" --password --header="Enter Windows password:") PASSWORD=$(gum input --placeholder="Password (Press enter to use default: admin)" --password --header="Enter Windows password:")
if [[ -z $PASSWORD ]]; then if [[ -z $PASSWORD ]]; then
@@ -147,6 +512,11 @@ EOF
else else
PASSWORD_DISPLAY="(user-defined)" PASSWORD_DISPLAY="(user-defined)"
fi fi
if ! valid_password "$PASSWORD"; then
echo "Invalid password (printable characters, up to 64). Using default: admin"
PASSWORD="admin"
PASSWORD_DISPLAY="(default)"
fi
# Display configuration summary # Display configuration summary
gum style \ gum style \
@@ -170,38 +540,19 @@ EOF
exit 1 exit 1
fi fi
mkdir -p $HOME/Windows mkdir -p "$HOME/Windows"
# Create docker-compose.yml in user config directory local tz
cat << EOF | tee "$COMPOSE_FILE" > /dev/null tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC)
services:
windows: # Write the root-owned compose from the validated settings (one prompt if
image: dockurr/windows # sudoless Docker is off), then bring the stack up.
container_name: omarchy-windows write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \
environment: "$USERNAME" "$PASSWORD" "$tz" "$HOME/.windows" "$HOME/Windows" || {
VERSION: "11" echo "❌ Failed to write the Windows VM configuration."
RAM_SIZE: "$SELECTED_RAM" exit 1
CPU_CORES: "$SELECTED_CORES" }
DISK_SIZE: "$SELECTED_DISK" write_credentials "$USERNAME" "$PASSWORD"
USERNAME: "$USERNAME"
PASSWORD: "$PASSWORD"
TZ: "$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC)"
ARGUMENTS: "-rtc base=localtime,clock=host,driftfix=slew"
devices:
- /dev/kvm
- /dev/net/tun
cap_add:
- NET_ADMIN
ports:
- 127.0.0.1:8006:8006
- 127.0.0.1:3389:3389/tcp
- 127.0.0.1:3389:3389/udp
volumes:
- $HOME/.windows:/storage
- $HOME/Windows:/shared
restart: "no"
stop_grace_period: 2m
EOF
echo "" echo ""
echo "Starting Windows VM installation..." echo "Starting Windows VM installation..."
@@ -210,14 +561,12 @@ EOF
echo "Monitor installation progress at: http://127.0.0.1:8006" echo "Monitor installation progress at: http://127.0.0.1:8006"
echo "" echo ""
# Start docker-compose with user's config
echo "Starting Windows VM with docker-compose..." echo "Starting Windows VM with docker-compose..."
if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then if ! priv up; then
echo "❌ Failed to start Windows VM!" echo "❌ Failed to start Windows VM!"
echo " Common issues:" echo " Common issues:"
echo " - Docker daemon not running: sudo systemctl start docker" echo " - Docker daemon not running: sudo systemctl start docker"
echo " - Port already in use: check if another VM is running" echo " - Port already in use: check if another VM is running"
echo " - Permission issues: make sure you're in the docker group"
exit 1 exit 1
fi fi
@@ -237,7 +586,6 @@ EOF
echo "Once finished, launch 'Windows' via Super + Space" echo "Once finished, launch 'Windows' via Super + Space"
echo "" echo ""
echo "To stop the VM: omarchy-windows-vm stop" echo "To stop the VM: omarchy-windows-vm stop"
echo "To change resources: ~/.config/windows/docker-compose.yml"
echo "" echo ""
} }
@@ -249,11 +597,12 @@ remove_windows() {
echo "Removing Windows VM..." echo "Removing Windows VM..."
docker-compose -f "$COMPOSE_FILE" down 2>/dev/null || true migrate_legacy_compose 2>/dev/null || true
if [[ -f $COMPOSE_FILE ]]; then
priv remove || true
fi
docker rmi dockurr/windows 2>/dev/null || echo "Image already removed or not found" rm -f "$HOME/.local/share/applications/windows-vm.desktop"
rm "$HOME/.local/share/applications/windows-vm.desktop"
rm -rf "$HOME/.config/windows" rm -rf "$HOME/.config/windows"
rm -rf "$HOME/.windows" rm -rf "$HOME/.windows"
@@ -267,49 +616,30 @@ launch_windows() {
KEEP_ALIVE=true KEEP_ALIVE=true
fi fi
# Check if config exists if ! migrate_legacy_compose; then
if [[ ! -f $COMPOSE_FILE ]]; then if [[ ! -f $COMPOSE_FILE ]]; then
echo "Windows VM not configured. Please run: omarchy-windows-vm install" echo "Windows VM not configured. Please run: omarchy-windows-vm install"
exit 1 exit 1
fi
fi fi
# Extract credentials from compose file # RDP credentials come from the private per-user file. Fall back to the compose
WIN_USER=$(grep "USERNAME:" "$COMPOSE_FILE" | sed 's/.*USERNAME: "\(.*\)"/\1/') # only when it is readable (sudoless mode), reversing the writer's escaping.
WIN_PASS=$(grep "PASSWORD:" "$COMPOSE_FILE" | sed 's/.*PASSWORD: "\(.*\)"/\1/') WIN_USER=$(read_credential USERNAME) || WIN_USER=""
WIN_PASS=$(read_credential PASSWORD) || WIN_PASS=""
# Use defaults if not found if [[ -z $WIN_USER || -z $WIN_PASS ]] && [[ -r $COMPOSE_FILE ]]; then
[[ -z $WIN_USER ]] && WIN_USER=$(unescape "$(read_compose_value USERNAME "$COMPOSE_FILE")")
[[ -z $WIN_PASS ]] && WIN_PASS=$(unescape "$(read_compose_value PASSWORD "$COMPOSE_FILE")")
fi
[[ -z $WIN_USER ]] && WIN_USER="docker" [[ -z $WIN_USER ]] && WIN_USER="docker"
[[ -z $WIN_PASS ]] && WIN_PASS="admin" [[ -z $WIN_PASS ]] && WIN_PASS="admin"
# Check if container is already running echo "Starting Windows VM (this may prompt for authorization)..."
CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null) if ! priv up_wait; then
echo "❌ Failed to start Windows VM!"
if [[ $CONTAINER_STATUS != "running" ]]; then echo " Try checking: omarchy-windows-vm status"
echo "Starting Windows VM..." omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM"
exit 1
# Send desktop notification
omarchy-notification-send -g  "Starting Windows VM" "This can take 15-30 seconds" -t 15000
if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then
echo "❌ Failed to start Windows VM!"
echo " Try checking: omarchy-windows-vm status"
echo " View logs: docker logs omarchy-windows"
omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM"
exit 1
fi
echo "Waiting for Windows VM to start..."
WAIT_COUNT=0
until docker logs omarchy-windows 2>&1 | grep -qi "windows started successfully"; do
sleep 2
WAIT_COUNT=$((WAIT_COUNT + 1))
if (( WAIT_COUNT > 60 )); then # 2 minutes timeout
echo ""
echo "❌ Timeout: Windows VM failed to start within 2 minutes"
echo " Check logs: docker logs omarchy-windows"
exit 1
fi
done
fi fi
# Build the connection info # Build the connection info
@@ -329,14 +659,27 @@ To stop: omarchy-windows-vm stop"
"" \ "" \
"$LIFECYCLE" "$LIFECYCLE"
# FreeRDP 3 tries Kerberos before NTLM for NLA, and krb5 ships /etc/krb5.conf
# as the MIT sample with default_realm = ATHENA.MIT.EDU. Every connect then
# goes looking for MIT's KDC: with internet up it fails fast, but off the
# network each attempt blocks ~23s and no RDP window is ever drawn. The VM
# authenticates against a local Windows account, so point FreeRDP at a
# realm-less config and let it fall straight through to NTLM.
KRB5_CONF="$HOME/.config/windows/krb5.conf"
mkdir -p "$(dirname "$KRB5_CONF")"
if [[ ! -f $KRB5_CONF ]]; then
printf '[libdefaults]\n dns_lookup_kdc = false\n dns_lookup_realm = false\n' >"$KRB5_CONF"
fi
export KRB5_CONFIG="$KRB5_CONF"
# Detect display scale from Hyprland # Detect display scale from Hyprland
HYPR_SCALE=$(hyprctl monitors -j | jq -r '.[] | select (.focused == true) | .scale') HYPR_SCALE=$(hyprctl monitors -j | jq -r '.[] | select (.focused == true) | .scale')
SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}') SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}')
RDP_SCALE="" RDP_SCALE=""
if (( SCALE_PERCENT >= 170 )); then if ((SCALE_PERCENT >= 170)); then
RDP_SCALE="/scale:180" RDP_SCALE="/scale:180"
elif (( SCALE_PERCENT >= 130 )); then elif ((SCALE_PERCENT >= 130)); then
RDP_SCALE="/scale:140" RDP_SCALE="/scale:140"
fi fi
# If scale is less than 130%, don't set any scale (use default 100) # If scale is less than 130%, don't set any scale (use default 100)
@@ -348,8 +691,12 @@ To stop: omarchy-windows-vm stop"
if [[ $KEEP_ALIVE = "false" ]]; then if [[ $KEEP_ALIVE = "false" ]]; then
echo "" echo ""
echo "RDP session closed. Stopping Windows VM..." echo "RDP session closed. Stopping Windows VM..."
docker-compose -f "$COMPOSE_FILE" down if priv down; then
echo "Windows VM stopped." echo "Windows VM stopped."
else
echo "⚠️ Could not stop the Windows VM (authorization declined?)."
echo " It may still be running. Stop it with: omarchy-windows-vm stop"
fi
else else
echo "" echo ""
echo "RDP session closed. Windows VM is still running." echo "RDP session closed. Windows VM is still running."
@@ -358,24 +705,34 @@ To stop: omarchy-windows-vm stop"
} }
stop_windows() { stop_windows() {
migrate_legacy_compose 2>/dev/null || true
if [[ ! -f $COMPOSE_FILE ]]; then if [[ ! -f $COMPOSE_FILE ]]; then
echo "Windows VM not configured." echo "Windows VM not configured."
exit 1 exit 1
fi fi
echo "Stopping Windows VM..." echo "Stopping Windows VM..."
docker-compose -f "$COMPOSE_FILE" down if priv down; then
echo "Windows VM stopped." echo "Windows VM stopped."
else
echo "⚠️ Could not stop the Windows VM (authorization declined?). It may still be running."
exit 1
fi
} }
status_windows() { status_windows() {
migrate_legacy_compose 2>/dev/null || true
if [[ ! -f $COMPOSE_FILE ]]; then if [[ ! -f $COMPOSE_FILE ]]; then
echo "Windows VM not configured." echo "Windows VM not configured."
echo "To set up: omarchy-windows-vm install" echo "To set up: omarchy-windows-vm install"
exit 1 exit 1
fi fi
CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null) if ! CONTAINER_STATUS=$(priv status); then
echo "Could not query the Windows VM (authorization declined?)."
echo "To try again: omarchy-windows-vm status"
exit 1
fi
if [[ -z $CONTAINER_STATUS ]]; then if [[ -z $CONTAINER_STATUS ]]; then
echo "Windows VM container not found." echo "Windows VM container not found."
@@ -421,28 +778,41 @@ show_usage() {
# Main command dispatcher # Main command dispatcher
case "$1" in case "$1" in
install) __priv)
install_windows ((EUID == 0)) || {
;; echo "omarchy-windows-vm __priv must run as root" >&2
remove)
remove_windows
;;
launch|start)
launch_windows "$2"
;;
stop|down)
stop_windows
;;
status)
status_windows
;;
help|--help|-h|"")
show_usage
;;
*)
echo "Unknown command: $1" >&2
echo "" >&2
show_usage >&2
exit 1 exit 1
;; }
action="$2"
shift 2
valid_priv_action "$action" || {
echo "omarchy-windows-vm: unknown privileged action" >&2
exit 1
}
"__priv_${action}" "$@"
;;
install)
install_windows
;;
remove)
remove_windows
;;
launch | start)
launch_windows "$2"
;;
stop | down)
stop_windows
;;
status)
status_windows
;;
help | --help | -h | "")
show_usage
;;
*)
echo "Unknown command: $1" >&2
echo "" >&2
show_usage >&2
exit 1
;;
esac esac
@@ -4,4 +4,4 @@
# To put it into use, remove .sample from this file name. # To put it into use, remove .sample from this file name.
# Example: Show the name of the font that was just set. # Example: Show the name of the font that was just set.
# notify-send -u low "New font" "Your new font is $1" # omarchy-notification-send -u low "New font" "Your new font is $1"
@@ -6,5 +6,5 @@
weather=$(omarchy-weather-status 2>/dev/null) || true weather=$(omarchy-weather-status 2>/dev/null) || true
if [[ -n $weather && $weather != "Weather unavailable" ]]; then if [[ -n $weather && $weather != "Weather unavailable" ]]; then
notify-send -u low "$weather" omarchy-notification-send -u low "$weather"
fi fi
@@ -4,4 +4,4 @@
# To put it into use, remove .sample from this file name. # To put it into use, remove .sample from this file name.
# Example: Show notification after the system has been updated. # Example: Show notification after the system has been updated.
# notify-send -u low "Update Performed" "Your system is now up to date" # omarchy-notification-send -u low "Update Performed" "Your system is now up to date"
@@ -4,4 +4,4 @@
# To put it into use, remove .sample from this file name. # To put it into use, remove .sample from this file name.
# Example: Show the name of the theme that was just set. # Example: Show the name of the theme that was just set.
# notify-send -u low "New theme" "Your new theme is $1" # omarchy-notification-send -u low "New theme" "Your new theme is $1"
+23
View File
@@ -22,11 +22,34 @@ omarchy theme install <url> # Install from git repo
Additional user backgrounds for any theme (stock or custom) go in Additional user backgrounds for any theme (stock or custom) go in
`~/.config/omarchy/backgrounds/<theme-slug>/`. `~/.config/omarchy/backgrounds/<theme-slug>/`.
## What a Theme Installed From a Repo May Not Contain
A theme the user wrote by hand in `~/.config/omarchy/themes` is unrestricted, as
are Omarchy's own themes. From a theme cloned by `omarchy theme install`, Omarchy
drops only what runs code: any `*.lua` (Hyprland requires a theme's
`hyprland.lua` and `gum_env.lua` at login, Neovim loads `neovim.lua` at startup),
the terminal configs `alacritty.toml`, `foot.ini`, `ghostty.conf` and
`kitty.conf` (each names the program the terminal launches), and `vscode.json`
(names a VS Code extension to install). Those are regenerated from `colors.toml`
through `$OMARCHY_PATH/default/themed/*.tpl`, and named on stderr.
Everything else a cloned theme ships is kept, including `btop.theme`,
`chromium.theme`, `helix.toml`, `icons.theme`, `keyboard.rgb` and `shell.toml`.
Omarchy tells a cloned theme from the user's own by the `.git` directory a clone
leaves behind.
To change how Omarchy themes an app for every theme, write the template rather
than the theme: `~/.config/omarchy/themed/<config-name>.tpl` overrides the
built-in one. See `docs/theming.md` in the Omarchy repo.
## Customizing a Stock Theme ## Customizing a Stock Theme
Never edit stock themes under `/usr/share/omarchy/themes/` — changes are lost Never edit stock themes under `/usr/share/omarchy/themes/` — changes are lost
on update. Two safe options: on update. Two safe options:
Both write into `~/.config/omarchy/themes`, where a theme the user wrote is
unrestricted — the list above applies only to a theme cloned from a repo.
**Overlay (preferred for small tweaks):** create a user theme directory with **Overlay (preferred for small tweaks):** create a user theme directory with
the SAME slug containing only the files you want to change. When the theme is the SAME slug containing only the files you want to change. When the theme is
applied, the stock theme is copied first and your files win on top: applied, the stock theme is copied first and your files win on top:
+2 -2
View File
@@ -45,8 +45,8 @@ alias ....='cd ../../..'
# Tools # Tools
alias a='omarchy-agent --inline' alias a='omarchy-agent --inline'
alias c='opencode --auto' alias c='opencode --auto'
alias cx='printf "\033[2J\033[3J\033[H" && claude --permission-mode bypassPermissions' alias cx='printf "\033[2J\033[3J\033[H" && claude --permission-mode auto'
alias cy='codex -s danger-full-access -a never' alias cy='codex --approve-for-me'
alias d='docker' alias d='docker'
alias r='rails' alias r='rails'
alias t='tmux attach || tmux new -s Work' alias t='tmux attach || tmux new -s Work'
+1 -1
View File
@@ -13,7 +13,7 @@ if o.preinstalled_bindings_enabled() then
o.bind("SUPER + CTRL + RETURN", "Herdr", { omarchy = "terminal-herdr" }) o.bind("SUPER + CTRL + RETURN", "Herdr", { omarchy = "terminal-herdr" })
o.bind("SUPER + SHIFT + M", "Music", { omarchy = "spotify" }) o.bind("SUPER + SHIFT + M", "Music", { omarchy = "spotify" })
o.bind("SUPER + SHIFT + ALT + M", "Music TUI", { tui = "cliamp", focus = true }) o.bind("SUPER + SHIFT + ALT + M", "Music TUI", { tui = "cliamp", focus = true })
o.bind("SUPER + SHIFT + D", "Docker", { tui = "lazydocker" }) o.bind("SUPER + SHIFT + D", "Docker", { tui = "omarchy-launch-docker-tui" })
o.bind("SUPER + SHIFT + G", "Signal", { omarchy = "signal" }) o.bind("SUPER + SHIFT + G", "Signal", { omarchy = "signal" })
o.bind("SUPER + SHIFT + O", "Obsidian", { launch = "obsidian", focus = "^obsidian$" }) o.bind("SUPER + SHIFT + O", "Obsidian", { launch = "obsidian", focus = "^obsidian$" })
o.bind("SUPER + SHIFT + W", "Omawrite", { launch = "omawrite" }) o.bind("SUPER + SHIFT + W", "Omawrite", { launch = "omawrite" })
+21
View File
@@ -0,0 +1,21 @@
-- Disable a Hyprland input device whose name was stored as data, not Lua.
-- Device names come from USB descriptors and must never be loaded as code.
local paths = require("default.hypr.paths")
return function(kind)
-- Hardcoded to ~/.local/state to match omarchy-toggle-input-device and the
-- sibling bash toggle tools, which all write there regardless of
-- XDG_STATE_HOME.
local file = io.open(paths.home .. "/.local/state/omarchy/toggles/hypr/" .. kind .. "-disabled-name", "r")
if not file then
return
end
local name = file:read("*l")
file:close()
if name and name ~= "" then
hl.device({ name = name, enabled = false })
end
end
+14 -3
View File
@@ -18,9 +18,20 @@ local function file_exists(path)
return false return false
end end
-- Hyprland reaps its own children, so os.execute() can't retrieve an exit status
-- from inside the compositor. Read a marker off stdout instead.
function o.shell_succeeds(command) function o.shell_succeeds(command)
local ok, _, code = os.execute(command .. " >/dev/null 2>&1") -- Subshell, so the redirection covers every command rather than binding to
return ok == true or ok == 0 or code == 0 -- the last one and letting an earlier one write its own OK into the pipe.
local pipe = io.popen("( " .. command .. " ) >/dev/null 2>&1 && echo OK")
if not pipe then
return false
end
local output = pipe:read("*a") or ""
pipe:close()
return output:find("OK", 1, true) ~= nil
end end
function o.cmd_present(command) function o.cmd_present(command)
@@ -125,7 +136,7 @@ function o.bind_toggle(keys, description, toggle, options)
end end
function o.notify(message) function o.notify(message)
return "notify-send -u low " .. shell_quote(message) return "omarchy-notification-send -u low " .. shell_quote(message)
end end
function o.window(match, rules) function o.window(match, rules)
+13 -3
View File
@@ -4,9 +4,19 @@
local home = os.getenv("HOME") local home = os.getenv("HOME")
-- A variable that is set but empty means "unset" (XDG Base Directory spec);
-- bash's ${VAR:-fallback} in the sibling tools treats it the same way.
local function env_or(name, fallback)
local value = os.getenv(name)
if value == nil or value == "" then
return fallback
end
return value
end
return { return {
home = home, home = home,
config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config"), config_home = env_or("XDG_CONFIG_HOME", home .. "/.config"),
state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state"), state_home = env_or("XDG_STATE_HOME", home .. "/.local/state"),
omarchy_path = os.getenv("OMARCHY_PATH") or "/usr/share/omarchy", omarchy_path = env_or("OMARCHY_PATH", "/usr/share/omarchy"),
} }
+14 -8
View File
@@ -4,6 +4,8 @@
-- Pass a module prefix for normal package.path modules, e.g. -- Pass a module prefix for normal package.path modules, e.g.
-- require_all.files(paths.omarchy_path .. "/default/hypr/apps", "default.hypr.apps") -- require_all.files(paths.omarchy_path .. "/default/hypr/apps", "default.hypr.apps")
-- Pass nil as the prefix when the directory itself has been added to package.path. -- Pass nil as the prefix when the directory itself has been added to package.path.
-- Pass options.exclude as a set of base names (without ".lua") to skip; a legacy
-- file that must never be loaded as code stays on disk for a migration to remove.
local M = {} local M = {}
@@ -12,19 +14,23 @@ local function shell_quote(path)
end end
function M.files(dir, module_prefix, options) function M.files(dir, module_prefix, options)
local exclude = options and options.exclude or {}
local handle = io.popen("find " .. shell_quote(dir) .. " -maxdepth 1 -type f -name '*.lua' -printf '%f\\n' 2>/dev/null | sort") local handle = io.popen("find " .. shell_quote(dir) .. " -maxdepth 1 -type f -name '*.lua' -printf '%f\\n' 2>/dev/null | sort")
if handle then if handle then
for filename in handle:lines() do for filename in handle:lines() do
local module = filename:gsub("%.lua$", "") local name = filename:gsub("%.lua$", "")
if module_prefix then if not exclude[name] then
module = module_prefix .. "." .. module local module = name
end if module_prefix then
module = module_prefix .. "." .. module
end
if options and options.reload then if options and options.reload then
package.loaded[module] = nil package.loaded[module] = nil
end end
require(module) require(module)
end
end end
handle:close() handle:close()
end end
+15 -1
View File
@@ -4,6 +4,20 @@ local require_all = require("default.hypr.require_all")
local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr" local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr"
package.path = toggles_dir .. "/?.lua;" .. package.path package.path = toggles_dir .. "/?.lua;" .. package.path
require_all.files(toggles_dir, nil, { reload = true }) -- touchpad-disabled.lua / touchscreen-disabled.lua were generated Lua in older
-- versions and could carry an injected USB device name. They must never be loaded
-- as code again: exclude them so a not-yet-migrated install cannot execute a
-- leftover payload on reload. The migration recovers the name and deletes them.
require_all.files(toggles_dir, nil, {
reload = true,
exclude = {
["touchpad-disabled"] = true,
["touchscreen-disabled"] = true,
},
})
local disabled_input_device = require("default.hypr.disabled-input-device")
disabled_input_device("touchpad")
disabled_input_device("touchscreen")
require("default.hypr.workspace-layouts") require("default.hypr.workspace-layouts")
+3 -1
View File
@@ -173,6 +173,7 @@
"setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"},
"setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"}, "setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"},
"setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"}, "setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"},
"setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"},
"setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""}, "setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""},
"setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"}, "setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"},
"setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"}, "setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"},
@@ -283,6 +284,7 @@
"remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"}, "remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"},
"remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"},
"remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"}, "remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"},
"remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"},
"remove.browser.chrome": {"icon":"","label":"Chrome","when":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser chrome'"}, "remove.browser.chrome": {"icon":"","label":"Chrome","when":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser chrome'"},
"remove.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser edge'"}, "remove.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser edge'"},
"remove.browser.brave": {"icon":"","label":"Brave","when":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser brave'"}, "remove.browser.brave": {"icon":"","label":"Brave","when":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser brave'"},
@@ -326,7 +328,7 @@
"update.omarchy": {"icon":"","iconFont":"omarchy","label":"Omarchy","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update"}, "update.omarchy": {"icon":"","iconFont":"omarchy","label":"Omarchy","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update"},
"update.channel": {"icon":"󰔫","label":"Channel"}, "update.channel": {"icon":"󰔫","label":"Channel"},
"update.config": {"icon":"","label":"Config","title":"Reset to default"}, "update.config": {"icon":"","label":"Config","title":"Reset to default"},
"update.themes": {"icon":"󰸌","label":"Extra Themes","action":"omarchy-launch-floating-terminal-with-presentation omarchy-theme-update"}, "update.themes": {"icon":"󰸌","label":"Extra Themes","when":"omarchy-theme-extras","action":"omarchy-launch-floating-terminal-with-presentation omarchy-theme-update"},
"update.process": {"icon":"","label":"Process","title":"Restart"}, "update.process": {"icon":"","label":"Process","title":"Restart"},
"update.hardware": {"icon":"󰇅","label":"Hardware","title":"Restart"}, "update.hardware": {"icon":"󰇅","label":"Hardware","title":"Restart"},
"update.firmware": {"icon":"","label":"Firmware","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update-firmware"}, "update.firmware": {"icon":"","label":"Firmware","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update-firmware"},
+39 -1
View File
@@ -6,13 +6,22 @@ Omarchy themes live under `themes/<name>/` in the source tree (installed at
`colors.toml`; Omarchy generates the active theme files from `colors.toml`; Omarchy generates the active theme files from
`default/themed/*.tpl` when `omarchy-theme-set <name>` runs. `default/themed/*.tpl` when `omarchy-theme-set <name>` runs.
Beyond `colors.toml` and hand-written config overrides, a first-party theme can
ship `backgrounds/` (users overlay their own via
`~/.config/omarchy/backgrounds/<name>/`; the active image is the
`~/.local/state/omarchy/current/background` symlink), `preview.png` and
`preview-unlock.png` for the theme switcher, `icons.theme`, `keyboard.rgb`,
`unlock.png`, and a `light.mode` marker file.
A theme installed from a git repo is held to a much shorter list; see [What an installed theme may not ship](#what-an-installed-theme-may-not-ship).
## Theme activation flow ## Theme activation flow
`omarchy-theme-set <name>` builds a clean staging directory at `omarchy-theme-set <name>` builds a clean staging directory at
`~/.local/state/omarchy/current/next-theme`: `~/.local/state/omarchy/current/next-theme`:
1. Copy the first-party theme from `themes/<name>/`. 1. Copy the first-party theme from `themes/<name>/`.
2. Overlay any user theme files from `~/.config/omarchy/themes/<name>/`. 2. Overlay `~/.config/omarchy/themes/<name>/`, in full when the user wrote it and filtered when it came from a git repo, naming anything it dropped on stderr.
3. If needed, generate `colors.toml` from `alacritty.toml`. 3. If needed, generate `colors.toml` from `alacritty.toml`.
4. Run `omarchy-theme-set-templates` to render templates into the staging 4. Run `omarchy-theme-set-templates` to render templates into the staging
theme. theme.
@@ -28,6 +37,34 @@ User templates in `~/.config/omarchy/themed/*.tpl` are processed before the
built-in templates. If a user template has the same output filename as a built-in templates. If a user template has the same output filename as a
built-in template, the built-in output is skipped. built-in template, the built-in output is skipped.
After activation, `omarchy-theme-set` fires the `theme-set` hook
(`~/.config/omarchy/hooks/theme-set*`, theme name in `$1`) and dispatches a
parallel retint of running apps — terminals, Hyprland, btop, browser, editors,
and the rest of the `post_theme_commands` list in `bin/omarchy-theme-set`.
Making a new app follow theme changes means adding its restart/retint command
to that list. Runs serialize on a `flock`, so scripted theme changes queue
instead of racing.
## What an installed theme may not ship
`themes/<name>/` in this repo is Omarchy's own code and is trusted. So is a theme the user wrote by hand in `~/.config/omarchy/themes/<name>/`: it is their machine and their file, and both stage in full.
`omarchy theme install <url>` is different. It clones a stranger's git repo straight into that same directory, so the contents are whatever the theme author pushed. `omarchy-theme-set` tells the two apart the way `omarchy-theme-extras` already does — a `.git` directory means it was cloned, while a plain directory or a symlink to a working copy is the user's own — and from a cloned one it drops only what can run code:
- any `*.lua` — Hyprland `require`s a theme's `hyprland.lua` and `gum_env.lua` at login, and Neovim loads its `neovim.lua` at startup
- `alacritty.toml`, `foot.ini`, `ghostty.conf`, `kitty.conf` — each names the program the terminal launches
- `vscode.json` — names the extension `omarchy-theme-set-vscode` installs, and a VS Code extension is arbitrary JavaScript
Symlinks are dropped with them, at any depth; in a cloned theme they point wherever the theme author chose. Everything a cloned theme ships that is colour is kept, including files Omarchy would otherwise have generated — `btop.theme`, `chromium.theme`, `helix.toml`, `shell.toml`, `icons.theme`, `keyboard.rgb` and the rest — so a theme can still say exactly how it wants each app to look. What is dropped gets generated from `default/themed/*.tpl` instead, and is named on stderr.
A denylist is only right while it is maintained. Adding a template for another terminal, or for another editor that loads Lua, means adding it to `INSTALLED_THEME_DENIED` in `bin/omarchy-theme-set`; `test/shell.d/theme-staging-test.sh` fails on any `default/themed/*.tpl` whose output is recorded as neither code nor colour, so a new template cannot be added without that decision being made.
A theme predating `colors.toml` is not left without a palette: its `alacritty.toml` is read through `omarchy-theme-colors-from-alacritty` into a scratch directory and only the resulting `colors.toml` is staged, so the colors survive and the terminal config does not.
The restriction lives in `omarchy-theme-set` rather than in `omarchy-theme-install` on purpose. Filtering at staging also covers themes installed before the rule existed and files a theme gains later through `omarchy theme update`.
What this does not cover: a theme distributed as an archive rather than a git repo, extracted into `~/.config/omarchy/themes/` by hand, is indistinguishable from one the user wrote and stages in full. `omarchy theme install` only takes git URLs, so the supported path is always filtered, but the check is a statement about where a theme came from and not a sandbox.
## `colors.toml` ## `colors.toml`
`colors.toml` provides the palette keys used by templates. Keys are grouped `colors.toml` provides the palette keys used by templates. Keys are grouped
@@ -335,6 +372,7 @@ local active_border_color = { colors = { "rgba(33ccffee)", "rgba(00ff99ee)" }, a
## Adding or overriding theme files ## Adding or overriding theme files
- Add palette values to `themes/<name>/colors.toml`. - Add palette values to `themes/<name>/colors.toml`.
- Hand-written overrides work everywhere except a `.lua`, a terminal config or a `vscode.json` in a theme cloned from a git repo; see [What an installed theme may not ship](#what-an-installed-theme-may-not-ship).
- Prefer generated files when the theme can be expressed with templates. - Prefer generated files when the theme can be expressed with templates.
- Add a hand-written file in `themes/<name>/` only when that theme needs to - Add a hand-written file in `themes/<name>/` only when that theme needs to
override the generated output entirely. override the generated output entirely.
+5
View File
@@ -0,0 +1,5 @@
# Switching between the stock providers is a one-click toggle in the network
# panel, so it must not stop for a password. Custom is deliberately absent: it
# points the machine at servers the caller supplies, and it already runs in a
# terminal that can ask.
%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-dns Cloudflare, /usr/bin/omarchy-dns Google, /usr/bin/omarchy-dns DHCP
+16 -10
View File
@@ -1,10 +1,16 @@
# Record the docker group for provisioning first-boot user creation and factory reset, # The Docker daemon runs as root and its socket is root-owned, so membership in
# then grant it directly when the install user already exists (deferred-provisioning # the docker group is equivalent to passwordless root: any process in it can
# installs create the user at first boot instead). # `docker run -v /:/host` and rewrite the host as root. We therefore do NOT add
provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" # the install user to the docker group by default, so a single rogue process
mkdir -p "$provisioning_dir" # running as the user cannot silently escalate to root.
grep -qxF docker "$provisioning_dir/groups" 2>/dev/null || echo docker >>"$provisioning_dir/groups" #
# The daemon is still enabled (docker.socket, in enable-services.sh) for system
if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then # use. The Docker TUI (Super + Shift + D) and the Windows VM reach it through a
usermod -aG docker "$OMARCHY_INSTALL_USER" # polkit prompt, and the plain `docker` CLI runs under sudo. Users who want the
fi # convenience back can opt in, behind a warning, with:
#
# omarchy-setup-security-sudoless-docker (Setup > Security > Sudoless Docker)
#
# Nothing to do here now that the group is no longer granted, but the file stays
# as the recorded home of this decision and a hook for future daemon config.
:
+23 -4
View File
@@ -1,6 +1,25 @@
# Enable Synaptics InterTouch for confirmed touchpads if not already loaded # Enable Synaptics InterTouch for confirmed touchpads if not already loaded
#
if grep -qi synaptics /proc/bus/input/devices \ # Only when modprobe can resolve psmouse for the running kernel, and never
&& ! lsmod | grep -q '^psmouse'; then # fatally. Installs run this under arch-chroot, where uname -r still names the
modprobe psmouse synaptics_intertouch=1 # live ISO's kernel while /lib/modules holds the target's -- the two differ on
# every machine that is not a T2 Mac, so modprobe failed with "Module psmouse
# not found in directory /lib/modules/<live kernel>" and took the whole install
# down with it. Asking modprobe itself first (-qn resolves a module without
# loading it) keeps that mismatch quiet, and the warning below covers every
# other reason it declines. An optional touchpad improvement must not be able to
# halt an install.
#
# Loading a module into the live kernel does nothing for the installed system
# either way, so this only takes effect when it runs on the booted machine.
# Persisting the switch instead would mean writing options psmouse
# synaptics_intertouch=1 to /etc/modprobe.d, which forces the SMBus transport
# past the kernel's own allowlist on every touchpad merely named "synaptics" in
# /proc/bus/input/devices -- a wider change than this one, and not one to make
# blind.
if grep -qi synaptics "${OMARCHY_SYNAPTIC_INPUT_DEVICES:-/proc/bus/input/devices}" \
&& ! lsmod | grep -q '^psmouse' \
&& modprobe -qn psmouse; then
modprobe psmouse synaptics_intertouch=1 ||
echo "Warning: could not enable Synaptics InterTouch on psmouse" >&2
fi fi
+3 -2
View File
@@ -78,7 +78,7 @@ lua51
luarocks luarocks
man-db man-db
mariadb-libs mariadb-libs
mise mise-bin
moonlight-qt moonlight-qt
mpv mpv
mpv-mpris mpv-mpris
@@ -108,7 +108,8 @@ python-poetry-core
ttfx ttfx
qemu-user-static-binfmt qemu-user-static-binfmt
qrencode qrencode
quickshell-git qt6-imageformats
quickshell
ripgrep ripgrep
ruby ruby
tensaku tensaku
+1 -1
View File
@@ -5,5 +5,5 @@ set -e
if omarchy-done ensure voxtype-install-invitation; then if omarchy-done ensure voxtype-install-invitation; then
omarchy-notification-send -u critical -g  "Install Dictation with Voxtype" \ omarchy-notification-send -u critical -g  "Install Dictation with Voxtype" \
"Click to install voice dictation for Omarchy." \ "Click to install voice dictation for Omarchy." \
--exec "omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install" --exec omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install
fi fi
+1 -1
View File
@@ -7,5 +7,5 @@ set -e
if [[ -z $(omarchy-default-agent) ]] && omarchy-done ensure agent-setup-invitation; then if [[ -z $(omarchy-default-agent) ]] && omarchy-done ensure agent-setup-invitation; then
omarchy-notification-send -u critical -g 󰚩 "Set your default agent" \ omarchy-notification-send -u critical -g 󰚩 "Set your default agent" \
"Let your favorite agent help with Omarchy." \ "Let your favorite agent help with Omarchy." \
--exec "omarchy menu summon setup.default.agent" --exec omarchy menu summon setup.default.agent
fi fi
@@ -8,5 +8,5 @@ if omarchy-hw-fingerprint && [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]] &&
omarchy-done ensure fingerprint-setup-invitation; then omarchy-done ensure fingerprint-setup-invitation; then
omarchy-notification-send -u critical -g 󰈷 "Setup Fingerprint Reader" \ omarchy-notification-send -u critical -g 󰈷 "Setup Fingerprint Reader" \
"Enable sudo and unlocking with your fingerprint." \ "Enable sudo and unlocking with your fingerprint." \
--exec "omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint" --exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint
fi fi
+2 -2
View File
@@ -1,11 +1,11 @@
notify_update() { notify_update() {
omarchy-notification-send -u critical -g  "Update System" "Click to update the system." \ omarchy-notification-send -u critical -g  "Update System" "Click to update the system." \
--exec "omarchy-launch-floating-terminal-with-presentation omarchy-update" --exec omarchy-launch-floating-terminal-with-presentation omarchy-update
} }
notify_wifi() { notify_wifi() {
omarchy-notification-send -u critical -g 󰖩 "Setup Wi-Fi" "Click to configure the wireless network." \ omarchy-notification-send -u critical -g 󰖩 "Setup Wi-Fi" "Click to configure the wireless network." \
--exec "omarchy-shell shell toggle omarchy.network" --exec omarchy-shell shell toggle omarchy.network
} }
announce_network() { announce_network() {
+1 -1
View File
@@ -21,7 +21,7 @@ From the terminal, the same switches are `omarchy toggle <thing>`. Run `omarchy
| Suspend | — | `omarchy toggle suspend` | | Suspend | — | `omarchy toggle suspend` |
| Hybrid GPU | — | `omarchy toggle hybrid gpu` | | Hybrid GPU | — | `omarchy toggle hybrid gpu` |
The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled state is written back out as a small Lua file that Hyprland sources on startup. The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled device's name is saved to a small state file that Hyprland reads on startup to disable it again.
The Toggle menu also carries a few things that aren't `omarchy toggle` commands but behave the same: battery percentage in the bar, workspace layout (`Super + L`), window gaps (`Super + Shift + Backspace`), and the 1-window square aspect (`Super + Ctrl + Backspace`). The Toggle menu also carries a few things that aren't `omarchy toggle` commands but behave the same: battery percentage in the bar, workspace layout (`Super + L`), window gaps (`Super + Shift + Backspace`), and the 1-window square aspect (`Super + Ctrl + Backspace`).
+4 -2
View File
@@ -18,9 +18,11 @@ To install, say, Ruby, you'd run `mise use -g ruby`, which will both install Rub
## Docker ## Docker
[Docker](https://www.docker.com/) hardly needs any introduction. It allows you to run isolated containers, and Omarchy installs everything needed to run it well. This includes Docker itself, [Docker Compose](https://docs.docker.com/compose/), and the user group changes needed for you to run Docker as the normal user and not as root. [Docker](https://www.docker.com/) hardly needs any introduction. It allows you to run isolated containers, and Omarchy installs everything needed to run it well, including Docker itself and [Docker Compose](https://docs.docker.com/compose/).
Remember to checkout the Lazydocker command to manage your containers in a cool TUI using `Super + Shift + D`. By default your user is *not* in the `docker` group. That group is effectively passwordless root — anything in it can `docker run -v /:/host` and take over the machine — so a single rogue script or dependency running as you would otherwise be one command away from root. So on the command line you run Docker with `sudo` (`sudo docker ps`, `sudo docker compose up`), and the graphical tools that talk to the daemon — the Docker TUI on `Super + Shift + D` and the Windows VM — ask for authorization when they need it. If you want the convenience of a groupless setup back and understand the tradeoff, enable it from **Setup > Security > Sudoless Docker** (or run `omarchy-setup-security-sudoless-docker`), which adds you to the `docker` group after a warning; then plain `docker` and the `d` alias work without `sudo` again.
Remember to checkout the Lazydocker command to manage your containers in a cool TUI using `Super + Shift + D`; it asks for authorization the first time unless you have enabled sudoless Docker.
You can setup the common databases for local development in Docker using _Install > Development > Docker DB_ in the Omarchy menu. You can setup the common databases for local development in Docker using _Install > Development > Docker DB_ in the Omarchy menu.
+1 -1
View File
@@ -36,6 +36,6 @@ There's no GPU passthrough with this setup, so it's not suitable for gaming or v
The version installed is Windows 11 Pro, unactivated. You'll need your own license key to use the gated features. The version installed is Windows 11 Pro, unactivated. You'll need your own license key to use the gated features.
You can change the resource allocation later by editing `~/.config/windows/docker-compose.yml`, which is also where you'd mount a USB device. See all the options on [the Dockur Windows project](https://github.com/dockur/windows). You can change the resource allocation later by re-running `omarchy-windows-vm install`, which rewrites the VM's configuration from your answers. The compose file itself now lives at `/var/lib/omarchy/windows/docker-compose.yml` and is owned by root — that is deliberate, so a process running as you cannot rewrite it and have the privileged bring-up mount your whole disk into the container. If you need to hand-edit it (for example to mount a USB device), edit it with `sudo` and see all the options on [the Dockur Windows project](https://github.com/dockur/windows).
To get rid of the whole thing, use _Remove > Windows_ from the Omarchy menu. That deletes the VM's disk and all its data, so make sure anything you care about is out of `~/Windows` first. To get rid of the whole thing, use _Remove > Windows_ from the Omarchy menu. That deletes the VM's disk and all its data, so make sure anything you care about is out of `~/Windows` first.
+1 -1
View File
@@ -36,7 +36,7 @@ SSH is off until you turn it on with _Setup > Security > SSHD_, which starts the
[Tailscale](https://tailscale.com/) is a mesh VPN that makes reaching all your computers and servers over the internet simple and secure. Install it with _Install > Service > Tailscale_. [Tailscale](https://tailscale.com/) is a mesh VPN that makes reaching all your computers and servers over the internet simple and secure. Install it with _Install > Service > Tailscale_.
That gives you a Tailscale panel in the bar, which connects and disconnects the tailnet, switches accounts, and picks an exit node — your own machines and Mullvad regions both show up in the list. It also browses your machines, and that's where Taildrop lives: select a machine and press `s` to send it files, or `c`, `n`, and `d` to copy its IP, name, or full DNS name. The terminal equivalent is `omarchy tailscale send <machine> [file...]`, and files sent to you land in `~/Downloads` automatically. That gives you a Tailscale panel in the bar, which connects and disconnects the tailnet, switches accounts, and picks an exit node — your own machines and Mullvad regions both show up in the list. It also browses your machines, and that's where Taildrop lives: select a machine and press `s` to send it files, or `c`, `n`, and `d` to copy its IP, name, or full DNS name. The terminal equivalent is `omarchy tailscale send <machine> [file...]`, and files sent to you land in `~/Downloads` automatically. The notification announcing an arrival waits until you click it open or dismiss it, so a file that turns up while you're away from the machine is still there to answer when you get back.
Installing it also adds a web app for the Tailscale admin console. Installing it also adds a web app for the Tailscale admin console.
+12
View File
@@ -6,6 +6,16 @@ The main file you have to tweak is `colors.toml`. That defines the color set tha
You can also use the included Aether application to create a new theme using a lovely GUI interface to play with colors and search for backgrounds. Just start it via the apps menu on `Super + Alt + Space`. You can also use the included Aether application to create a new theme using a lovely GUI interface to play with colors and search for backgrounds. Just start it via the apps menu on `Super + Alt + Space`.
### What an installed theme can contain
A theme you write yourself in `~/.config/omarchy/themes` can contain whatever you like — it's your machine and your file, and Omarchy applies all of it.
A theme you install from someone else's repo with `omarchy theme install` keeps everything that's colour, and loses the handful of files that would run code on your machine: any `.lua` file, the terminal configs (`alacritty.toml`, `foot.ini`, `ghostty.conf`, `kitty.conf`), and `vscode.json`. A theme's `hyprland.lua` is Lua your compositor runs at login, a terminal config names the program your terminal starts, and `vscode.json` names a VSCode extension to install. Installing someone's theme should change what your desktop looks like, never what it runs.
Everything else still works exactly as the theme author wrote it — `btop.theme`, `chromium.theme`, `helix.toml`, `icons.theme`, `shell.toml`, the backgrounds and the previews are all kept. Only what was dropped gets regenerated from `colors.toml` on your machine.
Omarchy tells the two apart by whether the theme has its own git repo inside it, which is what `omarchy theme install` leaves behind when it clones. So a theme you wrote stays yours, and one you pulled off the internet stays colours.
### Light mode ### Light mode
If you're making a light mode theme, set `mode = "light"` at the top of your `colors.toml`. Then it'll automatically be paired with light mode for all the apps. (The old way of dropping an empty file called `light.mode` in the root of your theme still works too.) If you're making a light mode theme, set `mode = "light"` at the top of your `colors.toml`. Then it'll automatically be paired with light mode for all the apps. (The old way of dropping an empty file called `light.mode` in the root of your theme still works too.)
@@ -28,4 +38,6 @@ There's a fully commented `alacritty.toml.tpl.sample` in that folder to copy fro
If you want to distribute your theme so others can use it, you need to put it on a public git server, like GitHub. Then people can install it using _Install > Style > Theme_ in the Omarchy menu using that URL. It's recommended that you follow the naming convention of `omarchy-[themename]-theme`, as the theme will show correctly as just `[themename]` in the theme selection menu after installation. If you want to distribute your theme so others can use it, you need to put it on a public git server, like GitHub. Then people can install it using _Install > Style > Theme_ in the Omarchy menu using that URL. It's recommended that you follow the naming convention of `omarchy-[themename]-theme`, as the theme will show correctly as just `[themename]` in the theme selection menu after installation.
Remember that once it's installed from a repo, any `.lua`, terminal config or `vscode.json` it ships is dropped, so don't build the theme around those.
You can have your theme added to [the extra themes page](https://omarchy.org/themes/) by sending a pull request to [the omarchy-site repo](https://github.com/omacom-io/omarchy-site). You can have your theme added to [the extra themes page](https://omarchy.org/themes/) by sending a pull request to [the omarchy-site repo](https://github.com/omacom-io/omarchy-site).
+1 -1
View File
@@ -4,6 +4,6 @@ if [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then
omarchy-mise-install github:can1357/oh-my-pi omp omarchy-mise-install github:can1357/oh-my-pi omp
omarchy-mise-install npm:@xai-official/grok grok omarchy-mise-install npm:@xai-official/grok grok
omarchy-mise-install crush omarchy-mise-install crush
elif [[ -f $HOME/.local/bin/omp ]] && grep -Fq 'mise use -g "oh-my-pi"' "$HOME/.local/bin/omp"; then elif [[ -f $HOME/.local/bin/omp ]] && grep -Eq 'mise use -g .*"oh-my-pi"' "$HOME/.local/bin/omp"; then
rm -f "$HOME/.local/bin/omp" rm -f "$HOME/.local/bin/omp"
fi fi
+18
View File
@@ -0,0 +1,18 @@
echo "Remove the literal \\n[text-bindings] line an earlier migration wrote into foot.ini"
# A 3.8.3 migration appended the section header with `sed -i '$a\\\n[text-bindings]'`,
# one backslash too many, so GNU sed wrote a backslash and an "n" as two ordinary
# characters ahead of the section name, leaving the single line \n[text-bindings]
# where a blank line and a [text-bindings] header belong. foot rejects it with
# "syntax error: key/value pair has no value" and stops reading the rest of the file.
#
# The later text-binding migration looks for the header with `grep -qxF`, which the
# literal line doesn't match, so it appends a second real [text-bindings] section and
# leaves the broken line in place. Removing the line is what actually repairs the
# config; the duplicate section that migration added is valid and harmless.
foot_config="$HOME/.config/foot/foot.ini"
if [[ -f $foot_config ]] && grep -qxF '\n[text-bindings]' "$foot_config"; then
sed -i '/^\\n\[text-bindings\]$/d' "$foot_config"
fi
+17
View File
@@ -0,0 +1,17 @@
echo "Switch mise to the mise-bin package from the Omarchy repo"
# mise-bin carries mise's own release artifacts -- PGO+BOLT-optimized on x86_64,
# glibc-native on both arches -- and takes over from Arch's mise, which it both
# provides and conflicts with.
#
# The swap has to happen in one transaction. Removing mise first breaks
# omarchy-zsh and omarchy-fish, which depend on it; the provides is what keeps
# them satisfied when mise-bin lands in the same transaction that drops mise.
#
# omarchy-pkg-add cannot do that swap: pacman answers its own conflict question
# with No under --noconfirm and fails the whole transaction ("unresolvable
# package conflicts detected"). --ask=4 is that one question, answered yes.
if omarchy-pkg-missing mise-bin; then
sudo pacman -S --noconfirm --ask=4 mise-bin
fi
+3
View File
@@ -0,0 +1,3 @@
echo "Add webp decoding to the shell"
omarchy-pkg-add qt6-imageformats
+9
View File
@@ -0,0 +1,9 @@
echo "Switch back to the packaged quickshell now that 0.3.1 kills synchronously"
# 0.3.1 fixes `kill` returning before the instance has exited, which is the only
# reason Omarchy shipped the git build.
if omarchy-pkg-present quickshell-git; then
# One transaction with --ask 4 so pacman accepts replacing the conflicting
# quickshell-git in place; packages depending on quickshell stay satisfied.
sudo pacman -S --noconfirm --ask 4 quickshell
fi
+25
View File
@@ -0,0 +1,25 @@
echo "Re-stage the current theme so an installed theme's code is dropped"
# A theme installed from a repo could ship hyprland.lua, gum_env.lua, neovim.lua
# and terminal configs, and they were copied straight into the staged theme that
# Hyprland requires at login and the terminals include at launch. Staging drops
# them now, but an install that already applied such a theme keeps the staged
# copies until something changes the theme, which may be never. Re-stage once so
# the fix reaches the themes already in place rather than only the next one.
theme_name_path="$HOME/.local/state/omarchy/current/theme.name"
[[ -s $theme_name_path ]] || exit 0
theme_name=$(<"$theme_name_path")
# A theme removed while it was still current leaves theme.name naming it and the
# staged copy behind, so there is nothing left to re-stage from and those staged
# files are exactly the ones this is here to drop. Seed the default instead,
# which is where a fresh install starts and what the removal should have left.
if [[ ! -d $OMARCHY_PATH/themes/$theme_name && ! -d $HOME/.config/omarchy/themes/$theme_name ]]; then
echo "Theme '$theme_name' no longer exists; applying the default instead"
omarchy-theme-set "Tokyo Night"
exit 0
fi
omarchy-theme-refresh
+116
View File
@@ -0,0 +1,116 @@
echo "Take ownership of the FIDO2 authfile so it cannot be rewritten without root"
authfile="/etc/fido2/fido2"
# omarchy-migrate records this migration as complete whenever it exits zero, so
# a line printed here scrolls past once in the update terminal and is never
# shown again. The states below cannot be repaired without deciding what to do
# with a file we do not own, and they are exactly the ones where the authfile
# may already be under someone else's control, so say so where it outlives the
# scrollback as well.
report_unrepairable() {
echo " $1"
echo " $2"
omarchy-notification-send -u critical -g  "FIDO2 authfile needs attention" "$1 $2" || true
}
# Nothing to repair on any machine that never set FIDO2 up, which is almost all
# of them. Checked before any sudo so those machines never see a password
# prompt. -L as well as -e: a dangling symlink is invisible to -e.
if [[ ! -L $authfile && ! -e $authfile ]]; then
# Absence and "cannot look" are the same answer to the tests above. The old
# setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the
# caller's umask and sudoers' 0022, so anyone registering under `umask 077`
# left it mode 0700 with the user-owned authfile still inside. Escalate for
# that case alone -- a machine that never set FIDO2 up has no directory here
# and still reaches exit 0 without a password prompt. Not through a symlink:
# chmod would act on whatever it points at.
authdir=${authfile%/*}
if [[ -L $authdir || ! -d $authdir || -x $authdir ]]; then
exit 0
fi
# Ask root whether a registration is behind it before touching the directory
# itself. An aborted setup that left an empty 0700 directory, or one an
# administrator deliberately keeps private, must not have its mode widened
# and its group and special bits discarded for a repair it does not need.
if ! sudo test -e "$authfile" && ! sudo test -L "$authfile"; then
exit 0
fi
sudo chmod 755 "$authdir"
fi
# The old privileged move could install a symlink here if its fixed staging path
# was redirected. Reported, not repaired: chown follows symlinks and would take
# ownership of the target instead, and removing it would strip sudo and polkit
# from anyone whose only credential is the token.
if [[ -L $authfile ]]; then
report_unrepairable "$authfile is a symlink, not a regular file." \
"Leaving it alone. If you did not create it, remove it and re-run Setup > Security > Fido2."
exit 0
fi
# A directory or a device here is no more ours to rewrite than a symlink is,
# and changing a directory's mode would alter an object we do not own.
if [[ ! -f $authfile ]]; then
report_unrepairable "$authfile is not a regular file." \
"Leaving it alone. Remove it and re-run Setup > Security > Fido2."
exit 0
fi
# Migration state is per-user, so every account re-runs this. The file's own
# ownership is the state check: the second account finds the repair already
# done and exits without escalating.
owner=$(stat -c %U "$authfile" 2>/dev/null) || owner=""
group=$(stat -c %G "$authfile" 2>/dev/null) || group=""
mode=$(stat -c %a "$authfile" 2>/dev/null) || mode=""
if [[ $owner == "root" && $group == "root" && $mode == "644" ]]; then
exit 0
fi
# Setup used to `mv` this in from /tmp, which carried the invoking user's
# ownership into /etc. Root ownership stops that user from rewriting their own
# PAM credential without root. Mode 644 keeps the public credential mapping
# readable when pam_u2f opens an absolute authfile as the authenticating user.
#
# Rename a fresh copy over the path rather than chowning in place. A descriptor
# opened while the file was still the user's own stays writable on that inode
# through any later chmod or chown, since permission is checked at open(2), and
# pam_u2f resolving the path would keep landing on it. Replacing the inode
# leaves that descriptor writing to a file nothing reads.
stage=""
safe_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
cleanup_stage() {
local status=$?
if safe_stage_path "$stage"; then
sudo rm -f -- "$stage" || true
fi
return "$status"
}
trap cleanup_stage EXIT
stage=$(sudo mktemp "$authfile.new.XXXXXX")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
echo " Could not create a safe staging file beside $authfile."
exit 1
fi
sudo install -T -m 644 -o root -g root "$authfile" "$stage"
sudo mv -Tf "$stage" "$authfile"
stage=""
trap - EXIT
+22
View File
@@ -0,0 +1,22 @@
echo "Move this install to the opt-in docker group default (the group is root-equivalent)"
# The docker group grants passwordless root (a container can bind-mount / and
# rewrite the host), so Omarchy no longer puts users in it by default. Bring
# existing installs in line: remove this user from the group if present. The
# change applies after a reboot, so it stays reachable until then. Anyone who
# wants passwordless docker back can opt in, behind a warning, with
# Setup > Security > Sudoless Docker. Reuses the removal command so there is one
# source of truth for the privileged change and its notice; DEFER_REBOOT keeps
# it from prompting mid-update — omarchy-update-restart handles the reboot once
# the whole update has finished.
if id -nG "$USER" | grep -qw docker; then
OMARCHY_DEFER_REBOOT=1 omarchy-remove-security-sudoless-docker
fi
# The Docker app entry copied into ~/.local/share/applications used to run
# lazydocker directly; it now needs the wrapper that prompts for daemon access
# (or runs directly under sudoless Docker). Refresh just that file.
dest="$HOME/.local/share/applications/Docker.desktop"
if [[ -f $dest ]]; then
cp "$OMARCHY_PATH/applications/Docker.desktop" "$dest"
fi
+39
View File
@@ -0,0 +1,39 @@
echo "Store Hyprland input-device names as data instead of generated Lua"
# omarchy-toggle-input-device used to interpolate hyprctl device names into
# hyprctl eval and a generated Lua file. Those names come from USB descriptors,
# so recover the plain device name as data and delete the generated Lua. A name
# that could have broken out of the old Lua string literal is discarded, not
# trusted. The old script wrote to ~/.local/state regardless of XDG_STATE_HOME.
toggles_dir="$HOME/.local/state/omarchy/toggles/hypr"
reapply=0
for kind in touchpad touchscreen; do
state_file="$toggles_dir/$kind-disabled.lua"
name_file="$toggles_dir/$kind-disabled-name"
[[ -f $state_file ]] || continue
if [[ ! -f $name_file && -r $state_file ]]; then
old=$(<"$state_file")
pattern='^hl\.device\(\{ name = "([^"\\[:cntrl:]]+)", enabled = false \}\)$'
if [[ $old =~ $pattern ]]; then
printf '%s\n' "${BASH_REMATCH[1]}" >"$name_file"
fi
fi
rm -f "$state_file"
if [[ -f $name_file ]]; then
reapply=1
fi
done
# The package hook reloads Hyprland before migrations run, so this session has
# already dropped the disable: the generated Lua is no longer loaded and the
# name file did not exist yet to replace it. Reload once more now that it does,
# or the device the user switched off stays on until their next login.
if (( reapply )); then
hyprctl reload >/dev/null 2>&1 || true
fi
+9
View File
@@ -54,6 +54,15 @@ QtObject {
Quickshell.execDetached(["bash", "-lc", command]) Quickshell.execDetached(["bash", "-lc", command])
} }
// Run an argv vector without a shell interpreting it: the constant `exec "$@"`
// means the args only ever land in positional parameters, which bash expands
// without re-tokenizing — so untrusted data ($(id), a filename) stays literal.
// The login shell (-l) keeps the PATH/session env GUI targets (tensaku, mpv,
// xdg-open) need. Prefer this over execDetached for anything built from input.
function execArgv(argv) {
Quickshell.execDetached(["bash", "-lc", 'exec "$@"', "bash"].concat(argv))
}
function isPlainObject(value) { function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) return value !== null && typeof value === "object" && !Array.isArray(value)
} }
+7 -1
View File
@@ -369,7 +369,13 @@ PanelWindow {
Text { Text {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: dial.reading < 10 ? dial.reading.toFixed(1) : Math.round(dial.reading).toLocaleString(Qt.locale(), 'f', 0) // Both branches go through the locale: a reading is a measurement, so
// its separators follow the system's number conventions rather than the
// interface language. toFixed would have hardcoded a dot below 10 while
// everything above it was already grouped for the locale.
text: dial.reading < 10
? dial.reading.toLocaleString(Qt.locale(), 'f', 1)
: Math.round(dial.reading).toLocaleString(Qt.locale(), 'f', 0)
color: root.onScrim color: root.onScrim
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.display font.pixelSize: Style.font.display
+3
View File
@@ -1417,6 +1417,9 @@ Item {
} }
onPressAndHold: function(mouse) { onPressAndHold: function(mouse) {
// A widget above us propagates its composed press-and-hold down here without
// ever handing over the grab, so we'd get no release or cancel to end the move.
if (!gestureArea.pressed) return
startDrag(mouse.x, mouse.y) startDrag(mouse.x, mouse.y)
} }
+44 -1
View File
@@ -43,7 +43,50 @@ emit_image() {
} }
emit_text() { emit_text() {
jq -cRs 'select(length > 0) | {type:"text", text:.}' perl -MEncode=decode,FB_CROAK,LEAVE_SRC -MJSON::PP=encode_json -0777 -e '
my $raw = <STDIN>;
exit unless length $raw;
my $encoding;
my $heuristic_encoding = 0;
if ($raw =~ /^(?:\xFF\xFE|\xFE\xFF)/) {
$encoding = "UTF-16";
} elsif (length($raw) % 2 == 0 && index($raw, "\0") >= 0) {
my $units = length($raw) / 2;
my $nuls = $raw =~ tr/\0/\0/;
# Neither byte lane can reach the padding threshold when the entire
# payload contains fewer NULs than that, so avoid two full string passes.
if ($nuls * 4 >= $units * 3) {
my $even_bytes = $raw;
$even_bytes =~ s/(.)./$1/sg;
my $even_nuls = $even_bytes =~ tr/\0/\0/;
undef $even_bytes;
my $odd_bytes = $raw;
$odd_bytes =~ s/.(.)/$1/sg;
my $odd_nuls = $odd_bytes =~ tr/\0/\0/;
# BOM-less UTF-16 is indistinguishable from NUL-separated bytes. Decode
# only when at least three quarters of the code units have consistent
# padding and fewer than one quarter have NULs in the opposite byte.
if ($odd_nuls * 4 >= $units * 3 && $even_nuls * 4 < $units) {
$encoding = "UTF-16LE";
$heuristic_encoding = 1;
} elsif ($even_nuls * 4 >= $units * 3 && $odd_nuls * 4 < $units) {
$encoding = "UTF-16BE";
$heuristic_encoding = 1;
}
}
}
my $text = $encoding ? eval { decode($encoding, $raw, FB_CROAK | LEAVE_SRC) } : undef;
if ($heuristic_encoding && defined($text) && $text =~ /[\x00-\x08\x0E-\x1A\x1C-\x1F]/) {
$text = undef;
}
$text = decode("UTF-8", $raw) unless defined $text;
print "{\"type\":\"text\",\"text\":", encode_json($text), "}\n";
'
} }
case "${1:-}" in case "${1:-}" in
@@ -57,14 +57,39 @@ function glyphFromHints(hints) {
return stringHint(hints, "omarchy-glyph") return stringHint(hints, "omarchy-glyph")
} }
// Shell command to run when the card is clicked, sent by // The click action: a JSON argv string from omarchy-notification-send
// omarchy-notification-send --exec. Carrying the action as data means it // --exec. Carried as data so a toast restored after a shell restart stays
// travels with the popup through the persistence files, so a toast restored // clickable (a libnotify action can't — its sender is gone). Run via
// after a shell restart clicks through exactly like a live one. A libnotify // Util.execArgv as bash positional parameters, never a shell string, so
// action can't: its sender is still waiting on an id from a server generation // attacker-controlled values (a title, a filename) can't become commands.
// that no longer exists. function execArgvFromHints(hints) {
function execFromHints(hints) { return stringHint(hints, "omarchy-exec-argv")
return stringHint(hints, "omarchy-exec") }
// Validate a persisted omarchy-exec-argv into a runnable argv, or null. This is
// a STRUCTURAL check only: it fails closed on a malformed hint (non-array, a
// non-string or empty program, or a leading-dash program that argv would read as
// an option). It does not judge intent — a well-formed ["bash","-c",…] is
// accepted. WHICH senders may set this hint is a separate boundary: any
// session-bus process can, by the freedesktop protocol's design (see
// docs/notifications.md), which is equivalent to same-uid code execution.
function parseExecArgv(value) {
var text = String(value || "")
if (!text) return null
var parsed
try {
parsed = JSON.parse(text)
} catch (e) {
return null
}
if (!Array.isArray(parsed) || parsed.length === 0) return null
for (var i = 0; i < parsed.length; i++) {
if (typeof parsed[i] !== "string") return null
}
if (!parsed[0] || parsed[0].charAt(0) === "-") return null
return parsed
} }
function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) { function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) {
@@ -85,7 +110,7 @@ function snapshotOf(notification, timestamp) {
body: n.body || "", body: n.body || "",
image: n.image || "", image: n.image || "",
glyph: glyphFromHints(n.hints), glyph: glyphFromHints(n.hints),
exec: execFromHints(n.hints), execArgv: execArgvFromHints(n.hints),
urgency: n.urgency, urgency: n.urgency,
expireTimeout: expireTimeout, expireTimeout: expireTimeout,
timestamp: timestamp === undefined ? Date.now() : timestamp timestamp: timestamp === undefined ? Date.now() : timestamp
@@ -94,7 +119,7 @@ function snapshotOf(notification, timestamp) {
// Everything the popup card draws, and therefore everything an in-place // Everything the popup card draws, and therefore everything an in-place
// update has to write through to the row and its file. // update has to write through to the row and its file.
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "exec", "urgency", "expireTimeout"] var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "urgency", "expireTimeout"]
function popupRoles() { function popupRoles() {
return POPUP_ROLES return POPUP_ROLES
@@ -136,7 +161,7 @@ function historyEntry(value, normalUrgency) {
body: e.body || "", body: e.body || "",
image: e.image || "", image: e.image || "",
glyph: e.glyph || "", glyph: e.glyph || "",
exec: e.exec || "", execArgv: e.execArgv || "",
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency, urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
expireTimeout: 0, expireTimeout: 0,
timestamp: e.timestamp || 0 timestamp: e.timestamp || 0
@@ -346,7 +371,8 @@ if (typeof module !== "undefined") {
isEphemeralApp: isEphemeralApp, isEphemeralApp: isEphemeralApp,
stringHint: stringHint, stringHint: stringHint,
glyphFromHints: glyphFromHints, glyphFromHints: glyphFromHints,
execFromHints: execFromHints, execArgvFromHints: execArgvFromHints,
parseExecArgv: parseExecArgv,
shouldRenderCompactGlyph: shouldRenderCompactGlyph, shouldRenderCompactGlyph: shouldRenderCompactGlyph,
snapshotOf: snapshotOf, snapshotOf: snapshotOf,
popupRoles: popupRoles, popupRoles: popupRoles,
+12 -11
View File
@@ -353,18 +353,19 @@ Item {
} }
// Run the popup's click action, then dismiss. Omarchy's own toasts carry the // Run the popup's click action, then dismiss. Omarchy's own toasts carry the
// action as a command in the `exec` role (see execFromHints), which the // action as an argv vector in the `execArgv` role (see execArgvFromHints),
// persistence files preserve, so restored toasts stay clickable. Third-party // which the persistence files preserve, so restored toasts stay clickable.
// clients register a libnotify action under the canonical identifier // Third-party clients register a libnotify action under the canonical
// "default" instead; that one only works while the sender is still live. // identifier "default" instead; that one only works while the sender is live.
function invokePopupDefault(index) { function invokePopupDefault(index) {
if (index < 0 || index >= popupModel.count) return if (index < 0 || index >= popupModel.count) return
var entry = popupModel.get(index) var entry = popupModel.get(index)
var command = entry ? String(entry.exec || "") : ""
if (command) { // Run the argv (via Util.execArgv, no shell interpretation). Detached so it
// Detached so the launched command outlives the shell process, which the // outlives the shell, which installer toasts depend on: they restart it.
// installer toasts depend on: they restart the shell as their first act. var argv = NotificationLogic.parseExecArgv(entry ? entry.execArgv : "")
Util.execDetached(command) if (argv) {
Util.execArgv(argv)
dismissPopup(index) dismissPopup(index)
return return
} }
@@ -665,7 +666,7 @@ Item {
body: row.body, body: row.body,
image: row.image, image: row.image,
glyph: row.glyph || "", glyph: row.glyph || "",
exec: row.exec || "", execArgv: row.execArgv || "",
urgency: row.urgency, urgency: row.urgency,
timestamp: row.timestamp timestamp: row.timestamp
}, imagesDir).entry) }, imagesDir).entry)
@@ -689,7 +690,7 @@ Item {
body: "", body: "",
image: "", image: "",
glyph: "󰂚", glyph: "󰂚",
exec: "", execArgv: "",
urgency: NotificationUrgency.Low, urgency: NotificationUrgency.Low,
expireTimeout: 0, expireTimeout: 0,
timestamp: Date.now() timestamp: Date.now()
+7 -4
View File
@@ -64,7 +64,11 @@ Panel {
// convention. Clicking the grid's "W" heading writes the choice back to // convention. Clicking the grid's "W" heading writes the choice back to
// shell.json. // shell.json.
readonly property int weekStart: Model.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek) readonly property int weekStart: Model.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek)
readonly property string nextWeekStartLabel: Qt.locale().dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat) // The interface is English throughout, so day names are not taken from the
// system locale. Where the week starts still is: that is a regional
// convention rather than a translation, and it stays overridable above.
readonly property var labelLocale: Qt.locale("en_US")
readonly property string nextWeekStartLabel: labelLocale.dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat)
readonly property var weekdays: Model.weekdayOrder(weekStart) readonly property var weekdays: Model.weekdayOrder(weekStart)
readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey) readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey)
@@ -213,10 +217,9 @@ Panel {
setWeekStart(Model.toggledWeekStart(root.weekStart)) setWeekStart(Model.toggledWeekStart(root.weekStart))
} }
// Locale short day names, trimmed of the trailing period some locales // English short day names, matching the rest of the interface.
// carry ("man." -> "MAN") so the header row stays a clean band of caps.
function weekdayLabel(weekday) { function weekdayLabel(weekday) {
return String(Qt.locale().dayName(weekday, Locale.ShortFormat)).replace(/\.$/, "").toUpperCase() return String(labelLocale.dayName(weekday, Locale.ShortFormat)).toUpperCase()
} }
SystemClock { SystemClock {
+24 -13
View File
@@ -299,8 +299,17 @@ function wifiSectionTitle(wifiNetworks, index) {
return "" return ""
} }
function isProtected(security, openSecurity) { // OWE (Enhanced Open) encrypts traffic without authenticating the user, so it
return security !== openSecurity // has no credentials to collect. The panel's lock is a credentials-required
// affordance, so OWE should neither show it nor open its attached prompt.
function requiresCredentials(security, openSecurity, oweSecurity) {
// Only explicit passwordless types bypass the prompt. Unknown security
// stays credentialed as the conservative fallback.
return security !== openSecurity && security !== oweSecurity
}
function canForgetNetwork(network) {
return !!(network && network.known && !network.connected)
} }
// The password arrives on stdin and reaches nmcli through the scriptable // The password arrives on stdin and reaches nmcli through the scriptable
@@ -316,10 +325,10 @@ var enterpriseConnectScript =
" && nmcli connection up uuid \"$u\"" + " && nmcli connection up uuid \"$u\"" +
" || { nmcli connection delete uuid \"$u\" >/dev/null 2>&1; false; }" " || { nmcli connection delete uuid \"$u\" >/dev/null 2>&1; false; }"
function networkFailureReason(reason, reasons) { function networkFailureReason(reason, needsCredentials, reasons) {
var r = reasons || {} var r = reasons || {}
if (reason === r.NoSecrets) return "Passphrase required" if (needsCredentials && reason === r.NoSecrets) return "Passphrase required"
if (reason === r.WifiAuthTimeout) return "Wrong password" if (needsCredentials && reason === r.WifiAuthTimeout) return "Wrong password"
if (reason === r.WifiNetworkLost) return "Network lost" if (reason === r.WifiNetworkLost) return "Network lost"
if (reason === r.WifiClientDisconnected) return "Disconnected" if (reason === r.WifiClientDisconnected) return "Disconnected"
if (reason === r.WifiClientFailed) return "Connection failed" if (reason === r.WifiClientFailed) return "Connection failed"
@@ -327,14 +336,15 @@ function networkFailureReason(reason, reasons) {
} }
// Whether a failed connect should reopen the passphrase prompt. NoSecrets // Whether a failed connect should reopen the passphrase prompt. NoSecrets
// always means credentials are missing. An auth timeout on a protected // means credentials are missing only for a network that actually uses them.
// network means the saved passphrase is wrong (the same profile a first // An auth timeout on such a network means the saved passphrase is wrong (the
// failed attempt leaves behind as "known"), so the user needs a chance to // same profile a first failed attempt leaves behind as "known"), so the user
// re-enter it -- connectWithPsk overwrites the stored PSK on submit. // needs a chance to re-enter it -- connectWithPsk overwrites the stored PSK on
function shouldRepromptPassphrase(reason, isProtected, reasons) { // submit.
function shouldRepromptPassphrase(reason, needsCredentials, reasons) {
var r = reasons || {} var r = reasons || {}
if (reason === r.NoSecrets) return true if (!needsCredentials) return false
return !!isProtected && reason === r.WifiAuthTimeout return reason === r.NoSecrets || reason === r.WifiAuthTimeout
} }
if (typeof module !== "undefined") { if (typeof module !== "undefined") {
@@ -361,7 +371,8 @@ if (typeof module !== "undefined") {
wifiRow: wifiRow, wifiRow: wifiRow,
sortWifiRows: sortWifiRows, sortWifiRows: sortWifiRows,
wifiSectionTitle: wifiSectionTitle, wifiSectionTitle: wifiSectionTitle,
isProtected: isProtected, requiresCredentials: requiresCredentials,
canForgetNetwork: canForgetNetwork,
enterpriseConnectScript: enterpriseConnectScript, enterpriseConnectScript: enterpriseConnectScript,
networkFailureReason: networkFailureReason, networkFailureReason: networkFailureReason,
shouldRepromptPassphrase: shouldRepromptPassphrase shouldRepromptPassphrase: shouldRepromptPassphrase
+28 -26
View File
@@ -398,7 +398,7 @@ Panel {
} }
function canForgetNetwork(net) { function canForgetNetwork(net) {
return !!(net && net.known && isProtected(net.security) && !net.connected) return Model.canForgetNetwork(net)
} }
function canShareNetwork(net) { function canShareNetwork(net) {
@@ -417,8 +417,8 @@ Panel {
} }
// Enter/Space on the highlighted row. Mirrors row-click semantics: // Enter/Space on the highlighted row. Mirrors row-click semantics:
// connected → disconnect, protected-unknown → password prompt, // connected → disconnect, credentials-required/unknown → prompt,
// open/known → connect. // passwordless/known → connect.
function activateSelected() { function activateSelected() {
if (busy || selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return if (busy || selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return
var net = wifiNetworks[selectedIndex] var net = wifiNetworks[selectedIndex]
@@ -428,8 +428,8 @@ Panel {
// connectedWifiNetwork when handed null, so a row left stale by scan churn // connectedWifiNetwork when handed null, so a row left stale by scan churn
// would otherwise tear down whatever is connected now instead. // would otherwise tear down whatever is connected now instead.
if (net.connected) { disconnectRow(net.ssid); return } if (net.connected) { disconnectRow(net.ssid); return }
if (isProtected(net.security) && !net.known) { openPasswordPrompt(net.ssid); return } if (requiresCredentials(net.security) && !net.known) { openPasswordPrompt(net.ssid); return }
connectKnown(net.ssid) connectDirectly(net.ssid)
} }
// Bar pill state, derived from the native NetworkManager service so the // Bar pill state, derived from the native NetworkManager service so the
@@ -680,8 +680,8 @@ Panel {
root.close() root.close()
} }
function isProtected(security) { function requiresCredentials(security) {
return Model.isProtected(security, WifiSecurityType.Open) return Model.requiresCredentials(security, WifiSecurityType.Open, WifiSecurityType.Owe)
} }
function openPasswordPrompt(ssid) { function openPasswordPrompt(ssid) {
@@ -735,18 +735,18 @@ Panel {
if (!network || actionKind === "" || actionSsid !== (network.name || "")) return if (!network || actionKind === "" || actionSsid !== (network.name || "")) return
actionTimeout.stop() actionTimeout.stop()
failureSsid = actionSsid failureSsid = actionSsid
failureReason = networkFailureReason(reason) failureReason = networkFailureReason(reason, requiresCredentials(network.security))
actionSsid = "" actionSsid = ""
actionKind = "" actionKind = ""
refresh() refresh()
} }
function networkFailureReason(reason) { function networkFailureReason(reason, needsCredentials) {
return Model.networkFailureReason(reason, connectionFailReasons) return Model.networkFailureReason(reason, needsCredentials, connectionFailReasons)
} }
function shouldRepromptPassphrase(reason, isProtected) { function shouldRepromptPassphrase(reason, needsCredentials) {
return Model.shouldRepromptPassphrase(reason, isProtected, connectionFailReasons) return Model.shouldRepromptPassphrase(reason, needsCredentials, connectionFailReasons)
} }
function checkActionCompletion(network) { function checkActionCompletion(network) {
@@ -756,7 +756,7 @@ Panel {
else if (actionKind === "forget" && !network.known && !network.stateChanging) clearNetworkAction() else if (actionKind === "forget" && !network.known && !network.stateChanging) clearNetworkAction()
} }
function connectKnown(ssid) { function connectDirectly(ssid) {
runNetworkAction("connect", networkForSsid(ssid), function(network) { network.connect() }) runNetworkAction("connect", networkForSsid(ssid), function(network) { network.connect() })
} }
@@ -1587,8 +1587,8 @@ Panel {
} }
// A single Wi-Fi network entry. Collapses to a one-line pill normally; // A single Wi-Fi network entry. Collapses to a one-line pill normally;
// expands inline to a passphrase prompt when the user picks a protected // expands inline to a passphrase prompt when the user picks a network that
// network we don't have credentials for. Clicking a connected row // requires credentials we do not have. Clicking a connected row
// disconnects. // disconnects.
component NetworkRow: CursorSurface { component NetworkRow: CursorSurface {
id: row id: row
@@ -1597,14 +1597,14 @@ Panel {
readonly property bool isConnected: net && net.connected readonly property bool isConnected: net && net.connected
readonly property bool isKnown: !!(net && net.known) readonly property bool isKnown: !!(net && net.known)
readonly property bool isProtected: net ? root.isProtected(net.security) : false readonly property bool requiresCredentials: net ? root.requiresCredentials(net.security) : false
readonly property bool isEnterprise: net readonly property bool isEnterprise: net
? (net.security === WifiSecurityType.Wpa2Eap || net.security === WifiSecurityType.WpaEap) ? (net.security === WifiSecurityType.Wpa2Eap || net.security === WifiSecurityType.WpaEap)
: false : false
readonly property bool canForgetFromLock: isKnown && isProtected && !isConnected readonly property bool canForget: root.canForgetNetwork(net)
readonly property bool isSelected: root.focusSection === "wifi" && root.selectedIndex === index readonly property bool isSelected: root.focusSection === "wifi" && root.selectedIndex === index
readonly property bool forgetFocused: isSelected && root.wifiActionFocused && canForgetFromLock readonly property bool forgetFocused: isSelected && root.wifiActionFocused && canForget
readonly property bool forgetVisible: canForgetFromLock && (forgetFocused || rightMouse.containsMouse) readonly property bool forgetVisible: canForget && (!requiresCredentials || forgetFocused || rightMouse.containsMouse)
hasCursor: root.cursorActive && isSelected && !root.wifiActionFocused hasCursor: root.cursorActive && isSelected && !root.wifiActionFocused
current: isConnected current: isConnected
@@ -1631,7 +1631,7 @@ Panel {
// failNetworkAction, which clears the action state. // failNetworkAction, which clears the action state.
var ours = root.actionKind === "connect" && root.actionSsid === (row.net.ssid || "") var ours = root.actionKind === "connect" && root.actionSsid === (row.net.ssid || "")
root.failNetworkAction(root.networkForSsid(row.net.ssid), reason) root.failNetworkAction(root.networkForSsid(row.net.ssid), reason)
if (ours && root.shouldRepromptPassphrase(reason, row.isProtected)) root.openPasswordPrompt(row.net.ssid) if (ours && root.shouldRepromptPassphrase(reason, row.requiresCredentials)) root.openPasswordPrompt(row.net.ssid)
} }
function onConnectedChanged() { function onConnectedChanged() {
if (row.net) root.checkActionCompletion(root.networkForSsid(row.net.ssid)) if (row.net) root.checkActionCompletion(root.networkForSsid(row.net.ssid))
@@ -1692,11 +1692,11 @@ Panel {
root.disconnectRow(row.net.ssid) root.disconnectRow(row.net.ssid)
return return
} }
if (row.isProtected && !row.isKnown) { if (row.requiresCredentials && !row.isKnown) {
root.openPasswordPrompt(row.net.ssid) root.openPasswordPrompt(row.net.ssid)
return return
} }
root.connectKnown(row.net.ssid) root.connectDirectly(row.net.ssid)
} }
} }
@@ -1719,11 +1719,12 @@ Panel {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
// Shows a lock glyph for protected networks. Known disconnected // The right edge shows a lock for networks that require credentials and
// networks reveal the forget action when hovering that right edge. // reveals Forget on hover. Known passwordless networks show Forget
// directly rather than reserving an invisible or misleading target.
Item { Item {
id: rightAction id: rightAction
visible: row.isProtected visible: row.requiresCredentials || row.canForget
width: Style.space(22) width: Style.space(22)
implicitHeight: lockIndicator.implicitHeight implicitHeight: lockIndicator.implicitHeight
anchors.right: parent.right anchors.right: parent.right
@@ -1731,6 +1732,7 @@ Panel {
Text { Text {
id: lockIndicator id: lockIndicator
visible: row.requiresCredentials || row.forgetVisible
width: parent.width width: parent.width
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
@@ -1754,7 +1756,7 @@ Panel {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
enabled: row.canForgetFromLock && !root.busy enabled: row.canForget && !root.busy
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onContainsMouseChanged: if (containsMouse) { root.cursorActive = true; root.focusSection = "wifi"; root.selectedIndex = row.index; root.wifiActionFocused = true } onContainsMouseChanged: if (containsMouse) { root.cursorActive = true; root.focusSection = "wifi"; root.selectedIndex = row.index; root.wifiActionFocused = true }
onClicked: if (row.net) root.forget(row.net) onClicked: if (row.net) root.forget(row.net)
+14 -2
View File
@@ -65,8 +65,20 @@ verify_services() {
} }
verify_runtime_tools() { verify_runtime_tools() {
timeout 20 docker info >/dev/null 2>&1 || fail "Docker is usable by the desktop user" # Docker access is intentionally NOT granted to the desktop user: the docker
pass "Docker is usable by the desktop user" # group is root-equivalent, so a rogue process running as the user could
# otherwise `docker run -v /:/host` its way to passwordless root. The daemon is
# still enabled (docker.socket, checked in verify_services) and reached through
# a polkit/sudo prompt; opting into sudoless Docker is a separate, warned step.
command -v docker >/dev/null 2>&1 || fail "Docker CLI is installed"
! id -nG | grep -qw docker || fail "desktop user must not be in the docker group"
# The group name being absent is not sufficient — a world-writable socket or an
# ACL would still hand the user the root daemon. Prove it is actually
# unreachable without elevation.
if timeout 10 docker info >/dev/null 2>&1; then
fail "desktop user must not reach the Docker daemon without elevation"
fi
pass "Docker is installed but unreachable by the desktop user without elevation"
nvim --headless '+qa' >/dev/null 2>&1 || fail "Neovim starts headlessly" nvim --headless '+qa' >/dev/null 2>&1 || fail "Neovim starts headlessly"
pass "Neovim starts headlessly" pass "Neovim starts headlessly"
+3 -1
View File
@@ -18,10 +18,12 @@ mkdir -p "$(dirname "$hook_path")"
cat >"$test_bin/omarchy-notification-send" <<'EOF' cat >"$test_bin/omarchy-notification-send" <<'EOF'
#!/bin/bash #!/bin/bash
echo notification >>"$TEST_LOG" echo notification >>"$TEST_LOG"
exec_args=()
while (($# > 0)); do while (($# > 0)); do
[[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG" if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi
shift shift
done done
((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG"
EOF EOF
chmod +x "$test_bin/omarchy-notification-send" chmod +x "$test_bin/omarchy-notification-send"
+11
View File
@@ -14,6 +14,17 @@ if rg -q 'barMoveSettling|barMoveSettleTimer' "$ROOT/shell/plugins/bar/Bar.qml";
fi fi
pass "bar move outline has no post-release settling state" pass "bar move outline has no post-release settling state"
# A widget above the gesture area propagates its composed press-and-hold down
# without handing over the grab, so the resulting move gets neither a release
# nor a cancel and the ghost stays up for the session. Only the grabbing area
# reports pressed, which is what separates the two, so the guard has to stay
# ahead of the drag.
if ! perl -0ne 'exit(/onPressAndHold:\s*function[^{]*\{[^}]*?\bpressed\b[^}]*?\bstartDrag\b/s ? 0 : 1)' \
"$ROOT/shell/plugins/bar/Bar.qml"; then
fail "bar move ignores a press-and-hold the gesture area does not hold the press for"
fi
pass "bar move ignores a press-and-hold propagated from a widget above"
run_node_test <<'JS' run_node_test <<'JS'
const fs = require('fs') const fs = require('fs')
const bar = requireFromRoot('shell/plugins/bar/BarModel.js') const bar = requireFromRoot('shell/plugins/bar/BarModel.js')
+2 -3
View File
@@ -9,7 +9,6 @@ raw_command_checks=$(rg -l 'command -v' "$ROOT/bin" \
[[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks" [[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks"
pass "bin commands use command helpers" pass "bin commands use command helpers"
raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" \ raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" || true)
| rg -v '/omarchy-notification-send$' || true) [[ -z $raw_notifications ]] || fail "bin commands use the notification helper, never notify-send" "$raw_notifications"
[[ -z $raw_notifications ]] || fail "bin commands use the notification helper" "$raw_notifications"
pass "bin commands use the notification helper" pass "bin commands use the notification helper"
+199
View File
@@ -50,3 +50,202 @@ bash -c '
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "javascript:alert(1)" "$ROOT" && ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "javascript:alert(1)" "$ROOT" &&
fail "yt-dlp native host rejects non-web URLs" fail "yt-dlp native host rejects non-web URLs"
pass "yt-dlp native host rejects non-web URLs" pass "yt-dlp native host rejects non-web URLs"
host_fn() {
OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="${download_dir:-$TMPDIR}" bash -c '
source "$1"
shift
"$@"
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "$@"
}
download_dir="$TMPDIR/videos"
mkdir -p "$download_dir" "$TMPDIR/outside"
good_file="$download_dir/clip [id].mp4"
printf 'x' >"$good_file"
printf 'x' >"$TMPDIR/outside/secret"
ln -s "$TMPDIR/outside/secret" "$download_dir/escape.mp4"
resolved=$(host_fn resolve_download_file "$good_file")
expected=$(realpath -e -- "$good_file")
[[ $resolved == "$expected" ]] ||
fail "yt-dlp native host accepts a regular file in the download dir" "$resolved"
pass "yt-dlp native host accepts a regular file in the download dir"
host_fn resolve_download_file "--include=not-a-file" &&
fail "yt-dlp native host rejects a forged mpv option as the download path"
pass "yt-dlp native host rejects a forged mpv option as the download path"
host_fn resolve_download_file "$TMPDIR/outside/secret" &&
fail "yt-dlp native host rejects a path outside the download dir"
pass "yt-dlp native host rejects a path outside the download dir"
host_fn resolve_download_file "$download_dir/escape.mp4" &&
fail "yt-dlp native host rejects a symlink that escapes the download dir"
pass "yt-dlp native host rejects a symlink that escapes the download dir"
host_fn resolve_download_file $'clip.mp4\nOMARCHY_FILE\t--include=not-a-file' &&
fail "yt-dlp native host rejects a path containing control characters"
pass "yt-dlp native host rejects a path containing control characters"
newline_target="$download_dir/target"$'\n'
printf 'x' >"$newline_target"
# The decoy is the point: dropping the trailing newline lands on a real, different
# file, so a resolver that strips it resolves to the wrong one instead of failing.
printf 'x' >"$download_dir/target"
ln -s "target"$'\n' "$download_dir/newline-link.mp4"
host_fn resolve_download_file "$download_dir/newline-link.mp4" &&
fail "yt-dlp native host rejects a symlink whose target name ends in a newline"
pass "yt-dlp native host rejects a symlink whose target name ends in a newline"
ln -s / "$TMPDIR/root-link"
root_resolved=$(download_dir="$TMPDIR/root-link" host_fn resolve_download_file /etc/passwd)
[[ $root_resolved == "/etc/passwd" ]] ||
fail "yt-dlp native host accepts a file when the download dir resolves to /" "$root_resolved"
pass "yt-dlp native host accepts a file when the download dir resolves to /"
title=$(host_fn title_from_file "$good_file")
[[ $title == "clip [id]" ]] || fail "yt-dlp native host titles the toast from the filename" "$title"
pass "yt-dlp native host titles the toast from the filename"
decoded_title=$(host_fn decode_title '"My Great Clip"')
[[ $decoded_title == "My Great Clip" ]] ||
fail "yt-dlp native host shows the page title on the toast" "$decoded_title"
pass "yt-dlp native host shows the page title on the toast"
forged_title=$(host_fn decode_title '"Clip\nOMARCHY_FILE\tPlay me\t--include=not-a-file"')
[[ $forged_title == "Clip" ]] ||
fail "yt-dlp native host keeps only the readable part of a forged title" "$forged_title"
pass "yt-dlp native host keeps only the readable part of a forged title"
host_fn decode_title '"--include=not-a-file"' &&
fail "yt-dlp native host refuses a leading-dash title the notifier would reject as an option"
pass "yt-dlp native host refuses a leading-dash title the notifier would reject as an option"
host_fn decode_title 'null' &&
fail "yt-dlp native host refuses a title that is not a JSON string"
pass "yt-dlp native host refuses a title that is not a JSON string"
dash_title=$(host_fn title_from_file "$download_dir/--include.mp4")
[[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to the notifier" "$dash_title"
pass "yt-dlp native host does not pass a leading-dash title to the notifier"
# The click action passes the path as a discrete --exec argument (asserted
# end-to-end below against the real download); `--` keeps mpv from parsing a
# leading-dash filename as an option.
parse_script="$TMPDIR/parse-ytdlp-lines.sh"
cat >"$parse_script" <<'EOF'
source "$1"
filepath=""
while IFS= read -r line; do
case $line in
OMARCHY_FILE*)
resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue
filepath=$resolved
;;
esac
done
printf '%s' "$filepath"
EOF
# A title that ends in a newline closes its own record, so the forged record is the
# last one and the real path lands on a line the loop ignores.
poisoned=$(
printf '%s\n' \
$'OMARCHY_FILE\t'"$good_file" \
$'OMARCHY_FILE\tPlay me\t--include=not-a-file' \
$'\t'"$good_file" |
OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="$download_dir" bash "$parse_script" "$ROOT/bin/omarchy-chromium-ytdlp-host"
)
[[ $poisoned == "$expected" ]] ||
fail "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record" "$poisoned"
pass "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record"
# Everything above tests the helpers in isolation. Drive the real download_url with
# stubbed tools so the yt-dlp invocation and the toast's click command are covered
# too: a record template that carried the title again would pass every test above.
fake_root="$TMPDIR/fake"
mkdir -p "$fake_root/bin"
fake_dir="$TMPDIR/fake-videos"
mkdir -p "$fake_dir"
fake_file="$fake_dir/Real_Clip [id].mp4"
printf 'x' >"$fake_file"
ytdlp_argv="$TMPDIR/ytdlp-argv"
notify_argv="$TMPDIR/notify-argv"
cat >"$fake_root/bin/yt-dlp" <<'EOF'
#!/bin/bash
printf '%s\n' "$*" >>"$YTDLP_ARGV_LOG"
for arg in "$@"; do
if [[ $arg == "--no-simulate" ]]; then
printf 'OMARCHY_FILE\t%s\n' "$YTDLP_FAKE_FILE"
[[ -n ${YTDLP_SKIP_TITLE:-} ]] || printf 'OMARCHY_TITLE\t%s\n' '"My Great Clip"'
exit 0
fi
done
exit 0
EOF
cat >"$fake_root/bin/omarchy-notification-send" <<'EOF'
#!/bin/bash
printf '%s\n' "$*" >>"$NOTIFY_ARGV_LOG"
EOF
for stub in omarchy-osd omarchy-shell ffmpeg; do
printf '#!/bin/bash\nexit 0\n' >"$fake_root/bin/$stub"
done
chmod +x "$fake_root/bin/"*
YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \
OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \
bash -c '
source "$1"
download_url "$2"
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1
(($(grep -c -- '--no-exec-before-download' "$ytdlp_argv") == 2)) ||
fail "yt-dlp native host disarms configured exec hooks on both yt-dlp runs" "$(cat "$ytdlp_argv")"
pass "yt-dlp native host disarms configured exec hooks on both yt-dlp runs"
# The stub answers with a title record whatever it is asked for, so assert the request
# as well as the reply: without this the template could be dropped and nothing notice.
grep -qF -- $'OMARCHY_TITLE\t%(title)j' "$ytdlp_argv" ||
fail "yt-dlp native host asks for the title JSON-encoded" "$(cat "$ytdlp_argv")"
pass "yt-dlp native host asks for the title JSON-encoded"
grep -qF -- "-o %(title)s.%(ext)s" "$ytdlp_argv" ||
fail "yt-dlp native host names the saved file after the page title" "$(cat "$ytdlp_argv")"
pass "yt-dlp native host names the saved file after the page title"
grep -q -- '--restrict-filenames' "$ytdlp_argv" &&
fail "yt-dlp native host keeps spaces in the saved filename" "$(cat "$ytdlp_argv")"
pass "yt-dlp native host keeps spaces in the saved filename"
grep -qF -- $'OMARCHY_FILE\t%(title)s' "$ytdlp_argv" &&
fail "yt-dlp native host never prints the title into the file record" "$(cat "$ytdlp_argv")"
pass "yt-dlp native host never prints the title into the file record"
grep -q -- "--exec mpv -- " "$notify_argv" ||
fail "yt-dlp native host builds the click command as mpv -- <path>" "$(cat "$notify_argv")"
pass "yt-dlp native host builds the click command as mpv -- <path>"
grep -qF -- "Download complete My Great Clip" "$notify_argv" ||
fail "yt-dlp native host toasts the page title, not the sanitised filename" "$(cat "$notify_argv")"
pass "yt-dlp native host toasts the page title, not the sanitised filename"
# A page that gives no usable title falls back to the filename rather than an empty toast.
: >"$notify_argv"
: >"$ytdlp_argv"
YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \
YTDLP_SKIP_TITLE=1 OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \
bash -c '
source "$1"
download_url "$2"
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1
grep -qF -- "Download complete Real_Clip [id]" "$notify_argv" ||
fail "yt-dlp native host falls back to the filename when no title record arrives" "$(cat "$notify_argv")"
pass "yt-dlp native host falls back to the filename when no title record arrives"
+87
View File
@@ -292,6 +292,93 @@ capture_output=$(printf 'closing app copy' | WL_PASTE_TEXT="stale read" XDG_RUNT
[[ $capture_output == '{"type":"text","text":"closing app copy"}' ]] || fail "clipboard capture records watched text from stdin" [[ $capture_output == '{"type":"text","text":"closing app copy"}' ]] || fail "clipboard capture records watched text from stdin"
pass "clipboard capture records watched text from stdin" pass "clipboard capture records watched text from stdin"
capture_output=$(printf '%s' 'UTF-16 clipboard - fixed' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
[[ $capture_output == '{"type":"text","text":"UTF-16 clipboard - fixed"}' ]] || fail "clipboard capture decodes UTF-16LE text"
pass "clipboard capture decodes UTF-16LE text"
capture_output=$(printf '%s' 'https://example.com/image — preview …' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
[[ $capture_output == '{"type":"text","text":"https://example.com/image — preview …"}' ]] || fail "clipboard capture decodes mostly ASCII UTF-16LE text with Unicode punctuation"
pass "clipboard capture decodes mostly ASCII UTF-16LE text with Unicode punctuation"
capture_output=$(printf '%s' 'Text with 日本 and 😀' | iconv -f UTF-8 -t UTF-16BE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
[[ $capture_output == '{"type":"text","text":"Text with 日本 and 😀"}' ]] || fail "clipboard capture decodes mostly ASCII UTF-16BE text with Unicode characters"
pass "clipboard capture decodes mostly ASCII UTF-16BE text with Unicode characters"
capture_output=$(printf '%s' 'ABC—' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
[[ $capture_output == '{"type":"text","text":"ABC—"}' ]] || fail "clipboard capture decodes UTF-16LE text at the padding threshold"
pass "clipboard capture decodes UTF-16LE text at the padding threshold"
printf '%s' 'ABCDEFGH————' | iconv -f UTF-8 -t UTF-16LE >"$TMPDIR/below-utf16-threshold"
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/below-utf16-threshold")
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/below-utf16-threshold")
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves UTF-16LE text below the padding threshold undecoded" "expected: $expected\nactual: $capture_output"
pass "clipboard capture leaves UTF-16LE text below the padding threshold undecoded"
printf '%s' 'ABCĀ' | iconv -f UTF-8 -t UTF-16LE >"$TMPDIR/opposite-nul-threshold"
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/opposite-nul-threshold")
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/opposite-nul-threshold")
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves UTF-16LE text at the opposite-byte NUL threshold undecoded" "expected: $expected\nactual: $capture_output"
pass "clipboard capture leaves UTF-16LE text at the opposite-byte NUL threshold undecoded"
capture_output=$(printf 'a\fb' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
[[ $capture_output == '{"type":"text","text":"a\fb"}' ]] || fail "clipboard capture preserves UTF-16LE form feeds"
pass "clipboard capture preserves UTF-16LE form feeds"
# BOM-less UTF-16LE "A" is byte-identical to UTF-8 "A\0". The padding
# heuristic intentionally resolves that ambiguity as UTF-16.
capture_output=$(printf 'A' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
[[ $capture_output == '{"type":"text","text":"A"}' ]] || fail "clipboard capture decodes exact NUL-padded UTF-16LE text"
pass "clipboard capture decodes exact NUL-padded UTF-16LE text"
capture_output=$(printf 'BE text' | iconv -f UTF-8 -t UTF-16BE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
[[ $capture_output == '{"type":"text","text":"BE text"}' ]] || fail "clipboard capture decodes exact NUL-padded UTF-16BE text"
pass "clipboard capture decodes exact NUL-padded UTF-16BE text"
capture_output=$({ printf '\377\376'; printf '%s' 'Little endian 日本 😀' | iconv -f UTF-8 -t UTF-16LE; } | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
[[ $capture_output == '{"type":"text","text":"Little endian 日本 😀"}' ]] || fail "clipboard capture decodes BOM-tagged UTF-16LE text"
pass "clipboard capture decodes BOM-tagged UTF-16LE text"
capture_output=$({ printf '\376\377'; printf '%s' 'Big endian 日本 😀' | iconv -f UTF-8 -t UTF-16BE; } | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
[[ $capture_output == '{"type":"text","text":"Big endian 日本 😀"}' ]] || fail "clipboard capture decodes BOM-tagged UTF-16BE text"
pass "clipboard capture decodes BOM-tagged UTF-16BE text"
assert_ambiguous_utf16_falls_back() {
local description="$1" value="$2" expected capture_output
printf '%s' "$value" | iconv -f UTF-8 -t UTF-16LE >"$TMPDIR/ambiguous-utf16"
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/ambiguous-utf16")
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/ambiguous-utf16")
[[ $capture_output == "$expected" ]] || fail "$description" "expected: $expected\nactual: $capture_output"
pass "$description"
}
assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 punctuation undecoded" '—'
assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 CJK undecoded" '日本'
assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 surrogate pairs undecoded" '😀'
printf 'foo\0bar\0' >"$TMPDIR/nul-separated-utf8"
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/nul-separated-utf8")
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/nul-separated-utf8")
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves sparse NUL-separated UTF-8 undecoded" "expected: $expected\nactual: $capture_output"
pass "clipboard capture leaves sparse NUL-separated UTF-8 undecoded"
printf 'Hello\0\0\0\0\0\0\0\0\0\0\0' >"$TMPDIR/nul-padded-utf8"
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/nul-padded-utf8")
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/nul-padded-utf8")
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves NUL-padded UTF-8 undecoded" "expected: $expected\nactual: $capture_output"
pass "clipboard capture leaves NUL-padded UTF-8 undecoded"
printf '\001\000\001\000\001\000\001\000' >"$TMPDIR/ambiguous-control-text"
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/ambiguous-control-text")
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/ambiguous-control-text")
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves endian-ambiguous control text undecoded" "expected: $expected\nactual: $capture_output"
pass "clipboard capture leaves endian-ambiguous control text undecoded"
printf '\377\376\075\330' >"$TMPDIR/malformed-utf16"
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/malformed-utf16")
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/malformed-utf16")
[[ $capture_output == "$expected" ]] || fail "clipboard capture falls back from malformed UTF-16" "expected: $expected\nactual: $capture_output"
pass "clipboard capture falls back from malformed UTF-16"
capture_output=$(printf 'png-data' | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" image/png) capture_output=$(printf 'png-data' | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" image/png)
image_path=$(jq -r '.path' <<<"$capture_output") image_path=$(jq -r '.path' <<<"$capture_output")
jq -e '.type == "image" and .mime == "image/png" and (.capturedAt | type == "string")' <<<"$capture_output" >/dev/null || fail "clipboard capture records watched png images" jq -e '.type == "image" and .mime == "image/png" and (.capturedAt | type == "string")' <<<"$capture_output" >/dev/null || fail "clipboard capture records watched png images"
+23 -5
View File
@@ -105,7 +105,7 @@ assert_lazy_stub() {
"$test_home/.local/bin/$command" --version "$test_home/.local/bin/$command" --version
mapfile -t mise_calls <"$mise_history" mapfile -t mise_calls <"$mise_history"
[[ ${mise_calls[0]} == "use -g $package" && ${mise_calls[1]} == "x $package -- $command --version" ]] || [[ ${mise_calls[0]} == "use -g --quiet $package" && ${mise_calls[1]} == "x $package -- $command --version" ]] ||
fail "$command lazy stub preserves its mise package" fail "$command lazy stub preserves its mise package"
} }
@@ -138,6 +138,24 @@ source "$ROOT/migrations/1785617047.sh" >/dev/null
source "$ROOT/migrations/1785846769.sh" >/dev/null source "$ROOT/migrations/1785846769.sh" >/dev/null
[[ ! -s $stub_log ]] || fail "agent migrations respect the preinstall opt-out" [[ ! -s $stub_log ]] || fail "agent migrations respect the preinstall opt-out"
[[ ! -e $test_home/.local/bin/omp ]] || fail "agent migration removes the obsolete Oh My Pi wrapper after opt-out" [[ ! -e $test_home/.local/bin/omp ]] || fail "agent migration removes the obsolete Oh My Pi wrapper after opt-out"
# The matcher has to catch a bare oh-my-pi wrapper from either generation of the
# installer, and leave a wrapper built on the fully qualified package alone.
for obsolete_form in 'mise use -g "oh-my-pi"' 'mise use -g --quiet "oh-my-pi"'; do
printf '#!/bin/bash\n%s || exit 1\n' "$obsolete_form" >"$test_home/.local/bin/omp"
chmod +x "$test_home/.local/bin/omp"
source "$ROOT/migrations/1785846769.sh" >/dev/null
[[ ! -e $test_home/.local/bin/omp ]] ||
fail "agent migration removes a wrapper built on [$obsolete_form]"
done
printf '#!/bin/bash\nmise use -g --quiet "%s" || exit 1\n' "$omp_package" >"$test_home/.local/bin/omp"
chmod +x "$test_home/.local/bin/omp"
source "$ROOT/migrations/1785846769.sh" >/dev/null
[[ -e $test_home/.local/bin/omp ]] ||
fail "agent migration keeps a wrapper built on $omp_package"
rm -f "$test_home/.local/bin/omp"
rm "$test_home/.local/state/omarchy/preinstalls-removed" rm "$test_home/.local/state/omarchy/preinstalls-removed"
pass "agent migrations install working wrappers without overriding the preinstall opt-out" pass "agent migrations install working wrappers without overriding the preinstall opt-out"
@@ -351,8 +369,8 @@ assert_bypass() {
assert_launch pi pi "Review this project" assert_launch pi pi "Review this project"
assert_launch omp omp --auto-approve -- "Review this project" assert_launch omp omp --auto-approve -- "Review this project"
assert_launch opencode opencode --auto --prompt "Review this project" assert_launch opencode opencode --auto --prompt "Review this project"
assert_launch claude claude --permission-mode bypassPermissions -- "Review this project" assert_launch claude claude --permission-mode auto -- "Review this project"
assert_launch codex codex --dangerously-bypass-approvals-and-sandbox -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project"
assert_launch crush crush run "Review this project" assert_launch crush crush run "Review this project"
assert_launch grok grok --permission-mode bypassPermissions -- "Review this project" assert_launch grok grok --permission-mode bypassPermissions -- "Review this project"
assert_launch gemini gemini --yolo --prompt-interactive "Review this project" assert_launch gemini gemini --yolo --prompt-interactive "Review this project"
@@ -362,8 +380,8 @@ pass "agent launcher adapts initial prompts for every supported agent"
assert_bypass pi pi assert_bypass pi pi
assert_bypass omp omp --auto-approve assert_bypass omp omp --auto-approve
assert_bypass opencode opencode --auto assert_bypass opencode opencode --auto
assert_bypass claude claude --permission-mode bypassPermissions assert_bypass claude claude --permission-mode auto
assert_bypass codex codex --dangerously-bypass-approvals-and-sandbox assert_bypass codex codex --approve-for-me
assert_bypass crush crush --yolo assert_bypass crush crush --yolo
assert_bypass grok grok --permission-mode bypassPermissions assert_bypass grok grok --permission-mode bypassPermissions
assert_bypass gemini gemini --yolo assert_bypass gemini gemini --yolo
+161
View File
@@ -0,0 +1,161 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
dns="$ROOT/bin/omarchy-dns"
sudoers_file="$ROOT/etc/sudoers.d/omarchy-dns"
rule='%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-dns Cloudflare, /usr/bin/omarchy-dns Google, /usr/bin/omarchy-dns DHCP'
# Exactly one rule, matched whole. A second line -- or the same command with its
# arguments dropped, which sudoers reads as "any arguments" -- would widen the
# grant while leaving this line in place.
rules=$(grep -vE '^[[:space:]]*(#|$)' "$sudoers_file")
[[ $rules == "$rule" ]] ||
fail "dns sudoers file carries exactly the stock-provider rule and nothing else" "got: $rules"
if command -v visudo >/dev/null; then
visudo -cf "$sudoers_file" >/dev/null || fail "dns sudoers rule parses"
fi
grep -Fx 'PACKAGED_PATH=/usr/bin/omarchy-dns' "$dns" >/dev/null ||
fail "omarchy-dns elevates the path the sudoers rule names"
# `sudo -l` on its own answers whether a command is permitted, not whether it
# is passwordless, and Omarchy ships a blanket %wheel rule that permits
# everything. Only the long listing prints the matched entry's tags.
grep -E 'sudo -n -l -l' "$dns" >/dev/null ||
fail "omarchy-dns reads the grant from the long sudo listing"
pass "dns sudoers rule is scoped to the stock providers"
# The privileged half runs as root under sudo's secure_path, and a dev link
# (etc/sudoers.d/omarchy-dev-path) prepends a user-writable checkout bin/ to it.
# Every helper the script calls by bare name -- dirname, install, tee, nmcli,
# systemctl, awk -- is a system tool, so once it holds root the script pins PATH
# to trusted system directories and never resolves one of them out of the
# checkout. The unprivileged wrapper phase keeps the caller's PATH, which is why
# the pin is gated on EUID rather than set unconditionally.
grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$dns" ||
fail "omarchy-dns pins PATH to trusted system directories when it holds root"
# require_root carries its own `(( EUID == 0 ))`, so matching that text alone
# would pass with the pin deleted. Anchor on the unindented guard and require the
# pin to be the line it opens.
gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$dns" || true)
[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] ||
fail "omarchy-dns gates the trusted-PATH pin on holding root"
# The no-argument path only reads DNS config, so exercise the privileged phase
# directly when the suite is root and as namespaced root otherwise. This reaches
# tr while EUID is 0 without giving an ordinary test run any host privileges.
root_runner=()
if (( EUID != 0 )); then
root_runner=(unshare --user --map-root-user)
fi
# A sandbox or a hardened kernel can refuse unprivileged user namespaces, and
# the non-graphical suites have to stay green on any machine -- a skip is a
# passing test. Only the runtime probe needs the namespace; the static checks
# above and the elevation checks below run either way.
if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then
poison_dir=$(mktemp -d)
poison_ran="$poison_dir/ran"
for helper in tr awk dirname install tee; do
cat >"$poison_dir/$helper" <<SH
#!/bin/bash
printf 'x' >"$poison_ran"
exec "/usr/bin/$helper" "\$@"
SH
chmod +x "$poison_dir/$helper"
done
if ! PATH="$poison_dir:$PATH" "${root_runner[@]}" bash "$dns" </dev/null >/dev/null 2>&1; then
rm -rf "$poison_dir"
fail "root omarchy-dns failed its read-only trusted-PATH probe"
fi
if [[ -e $poison_ran ]]; then
rm -rf "$poison_dir"
fail "root omarchy-dns resolved a bare helper from the front of PATH instead of a trusted system path"
fi
rm -rf "$poison_dir"
pass "root omarchy-dns resolves system helpers from a trusted PATH, not the invocation PATH"
else
pass "no unprivileged user namespace; skipping the root trusted-PATH probe"
fi
# require_root returns immediately for root, so the stubs below would not stand
# between the script and the host's real NetworkManager and resolved config.
if (( EUID == 0 )); then
pass "running as root; skipping the elevation checks, which would rewrite this machine's DNS"
exit 0
fi
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
mkdir -p "$stub_bin"
# pkexec stands in for the exec at the end of require_root, so the DNS writes
# below it never run and real pkexec is never reached.
cat >"$stub_bin/pkexec" <<'SH'
#!/bin/bash
printf 'pkexec %s\n' "$*" >"$ELEVATION_LOG"
SH
chmod +x "$stub_bin/pkexec"
# The sudo stub plays both parts: it answers the passwordless probe from
# STUB_GRANTED, the providers etc/sudoers.d/omarchy-dns covers on this machine,
# and logs the elevation otherwise. STUB_GRANTED empty stands for an install
# whose omarchy-settings predates the file.
cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
if [[ $1 == -n && $2 == -l ]]; then
for granted in ${STUB_GRANTED-Cloudflare Google DHCP}; do
[[ ${!#} == "$granted" ]] || continue
echo " Options: !authenticate"
exit 0
done
echo " Matched: ${!#}"
exit 0
fi
printf 'sudo %s\n' "$*" >"$ELEVATION_LOG"
SH
chmod +x "$stub_bin/sudo"
elevation_for() {
: >"$test_tmp/elevation"
ELEVATION_LOG="$test_tmp/elevation" \
PATH="$stub_bin:$PATH" \
bash "$dns" "$1" </dev/null >/dev/null
cat "$test_tmp/elevation"
}
for provider in Cloudflare Google DHCP; do
elevation=$(elevation_for "$provider")
[[ $elevation == "sudo /usr/bin/omarchy-dns $provider" ]] ||
fail "omarchy-dns takes the passwordless sudo grant for $provider without a terminal" "got: $elevation"
done
pass "omarchy-dns elevates the stock providers through sudo, not polkit"
# A dev-linked checkout elevates the packaged path like everyone else, rather
# than handing sudo a path no rule can name and losing the grant.
dev_linked=$(OMARCHY_PATH="$test_tmp/checkout" elevation_for Cloudflare)
[[ $dev_linked == "sudo /usr/bin/omarchy-dns Cloudflare" ]] ||
fail "omarchy-dns elevates the system install wherever OMARCHY_PATH points" "got: $dev_linked"
custom=$(elevation_for Custom)
[[ $custom == "pkexec /usr/bin/omarchy-dns Custom" ]] ||
fail "omarchy-dns leaves Custom on the polkit path, since no sudoers rule covers it" "got: $custom"
# The grant is what makes sudo passwordless, so its absence -- an install still
# on an older omarchy-settings, or a user outside %wheel the rule cannot match
# -- has to route to polkit. Betting on sudo here leaves the panel's one-click
# toggle execing into a password prompt it has no terminal to show.
ungranted=$(STUB_GRANTED="" elevation_for Cloudflare)
[[ $ungranted == "pkexec /usr/bin/omarchy-dns Cloudflare" ]] ||
fail "omarchy-dns falls back to polkit where the sudoers grant is not installed" "got: $ungranted"
pass "omarchy-dns falls back to polkit wherever the grant does not reach"
@@ -0,0 +1,89 @@
#!/bin/bash
#
# The docker-group opt-in migration must remove an existing install's user from
# the root-equivalent docker group (only when they are in it), flag a reboot so
# the group change actually takes effect (group membership is fixed at login),
# refresh the stale Docker launcher entry, and stay idempotent on reruns.
#
# The real omarchy-remove-security-sudoless-docker and omarchy-state run here
# (from the repo bin); only the privileged/system calls are stubbed, so the whole
# chain — including the reboot flag — is exercised.
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
migration="$ROOT/migrations/1787580187.sh"
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
home="$test_dir/home"
omarchy_path="$test_dir/omarchy"
stub_bin="$test_dir/bin"
mkdir -p "$home/.local/share/applications" "$omarchy_path/applications" "$stub_bin"
printf 'NEW-LAUNCHER\n' >"$omarchy_path/applications/Docker.desktop"
printf 'OLD-LAUNCHER\n' >"$home/.local/share/applications/Docker.desktop"
# id reports a controllable group set; sudo just drops the prefix; gpasswd
# records its call instead of touching the real system.
cat >"$stub_bin/id" <<'STUB'
#!/bin/bash
printf '%s\n' "${STUB_GROUPS:-wheel input}"
STUB
cat >"$stub_bin/sudo" <<'STUB'
#!/bin/bash
exec "$@"
STUB
cat >"$stub_bin/gpasswd" <<'STUB'
#!/bin/bash
echo "$@" >>"${GPASSWD_CALLS:?}"
STUB
# gum confirm always says yes, and reboot records that it fired: the migration
# must still NOT reboot (it defers to omarchy-update-restart), so neither should
# be reached.
cat >"$stub_bin/gum" <<'STUB'
#!/bin/bash
[[ $1 == confirm ]] && exit 0
exit 0
STUB
cat >"$stub_bin/omarchy-system-reboot" <<'STUB'
#!/bin/bash
touch "${REBOOT_CALLED:?}"
STUB
chmod +x "$stub_bin/id" "$stub_bin/sudo" "$stub_bin/gpasswd" "$stub_bin/gum" "$stub_bin/omarchy-system-reboot"
reboot_flag="$home/.local/state/omarchy/reboot-required"
gpasswd_calls="$test_dir/gpasswd-calls"
reboot_called="$test_dir/reboot-called"
launcher="$home/.local/share/applications/Docker.desktop"
run_migration() {
rm -f "$gpasswd_calls" "$reboot_flag" "$reboot_called"
HOME="$home" OMARCHY_PATH="$omarchy_path" USER="tester" STUB_GROUPS="$1" \
GPASSWD_CALLS="$gpasswd_calls" REBOOT_CALLED="$reboot_called" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
bash -euo pipefail "$migration" >/dev/null 2>&1
}
# In the docker group: user removed, reboot flagged, launcher refreshed.
run_migration "wheel input docker" || fail "migration runs when the user is in the docker group"
grep -q -- "-d tester docker" "$gpasswd_calls" || fail "migration removes the user from the docker group"
[[ -f $reboot_flag ]] || fail "migration flags a reboot so the group change takes effect"
[[ ! -f $reboot_called ]] || fail "migration must defer the reboot (not reboot mid-update)"
[[ $(cat "$launcher") == "NEW-LAUNCHER" ]] || fail "migration refreshes the stale Docker launcher entry"
pass "migration removes the group, flags a reboot, and refreshes the launcher"
# Not in the docker group (fresh install, or already migrated): nothing changes.
printf 'OLD-LAUNCHER\n' >"$launcher"
run_migration "wheel input" || fail "migration runs when the user is not in the docker group"
[[ ! -f $gpasswd_calls ]] || fail "migration must not touch the group when the user is not in it"
[[ ! -f $reboot_flag ]] || fail "migration must not flag a reboot when nothing changed"
[[ $(cat "$launcher") == "NEW-LAUNCHER" ]] || fail "migration still refreshes the launcher when the group is already absent"
pass "migration is a no-op on the group and reboot when already out"
# No launcher entry present: the refresh is skipped without error.
rm -f "$launcher"
run_migration "wheel input" || fail "migration tolerates a missing launcher entry"
[[ ! -e $launcher ]] || fail "migration does not create a launcher entry that was not there"
pass "migration skips the launcher refresh when no entry exists"
+3 -1
View File
@@ -31,10 +31,12 @@ chmod +x "$test_bin/omarchy-hw-fingerprint"
cat >"$test_bin/omarchy-notification-send" <<'EOF' cat >"$test_bin/omarchy-notification-send" <<'EOF'
#!/bin/bash #!/bin/bash
echo notification >>"$TEST_LOG" echo notification >>"$TEST_LOG"
exec_args=()
while (($# > 0)); do while (($# > 0)); do
[[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG" if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi
shift shift
done done
((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG"
EOF EOF
chmod +x "$test_bin/omarchy-notification-send" chmod +x "$test_bin/omarchy-notification-send"
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
set -euo pipefail
# omarchy-git-url-check decides which URLs omarchy-theme-install and
# omarchy-plugin-add are willing to hand to `git clone`. git resolves a remote
# helper -- a program it runs at clone time -- from exactly two URL shapes,
# `<helper>::<address>` and `<scheme>://<address>`, so those are the two shapes
# asserted here, alongside every legitimate form a user is likely to paste.
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
check() {
"$ROOT/bin/omarchy-git-url-check" "$@" 2>&1
}
# `<helper>::<address>`, the shape that runs a program. `ext::` is the dangerous
# one: git runs the rest as a shell command once protocol.ext.allow permits it.
for url in "ext::sh -c id" "fd::0,1" "gcrypt::x" "a+b::x" "a.b::x" "a-b::x" "1::x"; do
output=$(check "$url") &&
fail "omarchy-git-url-check refuses the transport helper '$url'" "$output"
grep -qF "names a git option or transport helper" <<<"$output" ||
fail "omarchy-git-url-check names the helper rejection for '$url'" "$output"
done
pass "a <helper>::<address> URL is refused"
# `<scheme>://<address>`, the shape #8067 left open: git looks up
# git-remote-<scheme> for any scheme it does not implement itself, so an
# allowlist is the only form of this check that holds.
for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x" "zzz://a" "ZZZ://a" "HTTPS://github.com/a/b"; do
output=$(check "$url") &&
fail "omarchy-git-url-check refuses the '$url' transport" "$output"
grep -qF "which Omarchy does not clone from" <<<"$output" ||
fail "omarchy-git-url-check names the transport rejection for '$url'" "$output"
done
pass "a <scheme>://<address> URL outside git's own transports is refused"
# A leading dash is an option to git, not a URL.
for url in "-x" "--upload-pack=touch /tmp/pwned" "-oProxyCommand=x"; do
output=$(check "$url") &&
fail "omarchy-git-url-check refuses the option '$url'" "$output"
done
pass "a URL shaped like a git option is refused"
output=$(check "") && fail "omarchy-git-url-check refuses an empty URL" "$output"
output=$(check) && fail "omarchy-git-url-check refuses a missing URL" "$output"
pass "an empty URL is refused"
# Everything a user actually pastes. The scp-style forms carry a single colon,
# which git never reads as a helper, and the IPv6 host carries `::` inside
# brackets rather than at the start.
for url in \
"https://github.com/acme/omarchy-weather.git" \
"http://example.com/a/b.git" \
"https://user:token@github.com/acme/repo.git" \
"ssh://git@github.com/acme/repo.git" \
"ssh://git@[2001:db8::1]:22/org/repo.git" \
"git://example.com/repo.git" \
"git+ssh://git@example.com/acme/repo.git" \
"ssh+git://git@example.com/acme/repo.git" \
"ftp://example.com/repo.git" \
"ftps://example.com/repo.git" \
"file:///home/me/repo" \
"git@github.com:acme/repo.git" \
"git@[2001:db8::1]:org/repo.git" \
"host:-s/foo.git" \
"/home/me/repo" \
"./repo" \
"../repo" \
"repo"; do
output=$(check "$url") ||
fail "omarchy-git-url-check accepts the legitimate URL '$url'" "$output"
done
pass "the URL forms a user pastes are accepted"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command lua
run_paths() {
lua - <<'LUA'
package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path
local paths = require("default.hypr.paths")
assert(paths.config_home == os.getenv("EXPECTED_CONFIG"), "config_home: " .. paths.config_home)
assert(paths.state_home == os.getenv("EXPECTED_STATE"), "state_home: " .. paths.state_home)
LUA
}
HOME="/home/test-user" OMARCHY_PATH="$ROOT" \
XDG_CONFIG_HOME= XDG_STATE_HOME= \
EXPECTED_CONFIG="/home/test-user/.config" EXPECTED_STATE="/home/test-user/.local/state" \
run_paths
pass "empty XDG path variables fall back to their defaults"
HOME="/home/test-user" OMARCHY_PATH="$ROOT" \
XDG_CONFIG_HOME="/custom/config" XDG_STATE_HOME="/custom/state" \
EXPECTED_CONFIG="/custom/config" EXPECTED_STATE="/custom/state" \
run_paths
pass "set XDG path variables are honored"
+79
View File
@@ -189,3 +189,82 @@ printf "survived\n"' 2>/dev/null)
[[ $errexit_result == $'hit:c:1\nmiss:c:0\nsurvived' ]] || [[ $errexit_result == $'hit:c:1\nmiss:c:0\nsurvived' ]] ||
fail "guard batch survives a failing reader under errexit" "got: $errexit_result" fail "guard batch survives a failing reader under errexit" "got: $errexit_result"
pass "guard batch survives a reader that exits nonzero under errexit" pass "guard batch survives a reader that exits nonzero under errexit"
# Update > Extra Themes runs omarchy-theme-update, which pulls the themes that
# came from a git clone and skips everything else, so the guard has to answer
# for the same set: a row that appears over a symlinked theme or a worktree's
# `.git` file opens a terminal that prints nothing and closes. Both sides ask
# omarchy-theme-extras today; the shapes below are what would tell us if one
# of them stopped.
themes_guard=$(node -e '
const fs = require("fs")
const path = require("path")
const menu = require(path.join(process.env.ROOT, "shell/plugins/menu/MenuModel.js"))
const items = menu.parseMenuJsonc(fs.readFileSync(path.join(process.env.ROOT, "default/omarchy/omarchy-menu.jsonc"), "utf8"))
process.stdout.write(items.find(item => item.id === "update.themes").when)
')
cat >"$stub_dir/git" <<'STUB'
#!/bin/bash
: "${GIT_CALLS:=/dev/null}"
{ printf '<%s>' "$@"; printf '\n'; } >>"$GIT_CALLS"
STUB
chmod +x "$stub_dir/git"
# The updater names each theme it pulls, so what it printed is what the row
# would have been for. Run the guard the way the batch does, braces and all,
# and say which shapes are meant to show it rather than only that the two
# agree: they read the same command now, and agreement alone would hold even
# if both went wrong together.
assert_themes_guard_agrees() {
local description="$1" home="$2" expected="$3"
local guarded=0 updated=0
HOME="$home" PATH="$ROOT/bin:$PATH" bash -e -c "{ $themes_guard; } >/dev/null 2>&1" || guarded=$?
[[ -n $(HOME="$home" PATH="$ROOT/bin:$stub_dir:$PATH" "$ROOT/bin/omarchy-theme-update" 2>/dev/null) ]] || updated=1
((guarded == expected)) || fail "$description" "$home: guard=$guarded expected=$expected"
((updated == expected)) || fail "$description" "$home: update=$updated expected=$expected"
}
themes_home=$(mktemp -d)
trap 'rm -rf "$stub_dir" "$themes_home"' EXIT
# A theme copied by hand has nothing to pull, a symlinked one is someone's
# working copy, and a `.git` file is a worktree living elsewhere.
mkdir -p "$themes_home/missing"
mkdir -p "$themes_home/empty/.config/omarchy/themes"
mkdir -p "$themes_home/copied/.config/omarchy/themes/handmade"
mkdir -p "$themes_home/cloned/.config/omarchy/themes/tokyo-night/.git"
mkdir -p "$themes_home/linked/.config/omarchy/themes" "$themes_home/checkout/.git"
ln -s "$themes_home/checkout" "$themes_home/linked/.config/omarchy/themes/in-progress"
mkdir -p "$themes_home/worktree/.config/omarchy/themes/branch"
printf 'gitdir: /elsewhere\n' >"$themes_home/worktree/.config/omarchy/themes/branch/.git"
for shape in missing:1 empty:1 copied:1 cloned:0 linked:1 worktree:1; do
assert_themes_guard_agrees \
"Extra Themes shows exactly when omarchy-theme-update has something to pull" \
"$themes_home/${shape%:*}" "${shape#*:}"
done
pass "Extra Themes shows exactly when omarchy-theme-update has something to pull"
# Which themes get pulled, not just that something did: a name with a space in
# it is the one that goes missing the moment a path is split rather than passed
# whole, and it would still print an Updating: line on its way to the wrong
# directory.
many="$themes_home/many/.config/omarchy/themes"
mkdir -p "$many/tokyo night/.git" "$many/zen/.git" "$many/handmade"
ln -s "$themes_home/checkout" "$many/in-progress"
listed=$(HOME="$themes_home/many" LC_ALL=C "$ROOT/bin/omarchy-theme-extras")
[[ $listed == "$many/tokyo night"$'\n'"$many/zen" ]] ||
fail "omarchy-theme-extras lists every clone and nothing else" "got: $listed"
pass "omarchy-theme-extras lists every clone and nothing else"
git_calls=$(mktemp)
trap 'rm -rf "$stub_dir" "$themes_home" "$git_calls"' EXIT
HOME="$themes_home/many" LC_ALL=C GIT_CALLS="$git_calls" PATH="$ROOT/bin:$stub_dir:$PATH" \
"$ROOT/bin/omarchy-theme-update" >/dev/null 2>&1
pulled=$(<"$git_calls")
[[ $pulled == "<-C><$many/tokyo night><pull>"$'\n'"<-C><$many/zen><pull>" ]] ||
fail "omarchy-theme-update pulls each clone by its whole path" "got: $pulled"
pass "omarchy-theme-update pulls each clone by its whole path"
+1 -1
View File
@@ -111,7 +111,7 @@ pick clone "$(printf 'Clock\tomarchy.clock')"
[[ $ROWS == *"Clock"* && $ROWS != *"Weather"* ]] || [[ $ROWS == *"Clock"* && $ROWS != *"Weather"* ]] ||
fail "clone picker offers only built-in plugins" "$ROWS" fail "clone picker offers only built-in plugins" "$ROWS"
pass "clone picker offers built-in plugins" pass "clone picker offers built-in plugins"
[[ $CALLS == *"terminal: omarchy-plugin-clone --edit omarchy.clock"* ]] || [[ $CALLS == *"terminal: omarchy-plugin-clone omarchy.clock --edit"* ]] ||
fail "clone picker delegates cloning and editing to the clone command" "$CALLS" fail "clone picker delegates cloning and editing to the clone command" "$CALLS"
pass "clone picker clones and opens the personal plugin" pass "clone picker clones and opens the personal plugin"
+5
View File
@@ -172,6 +172,11 @@ assert(
defaultById['update.omarchy'].iconFont === 'omarchy', defaultById['update.omarchy'].iconFont === 'omarchy',
'menu update Omarchy entry renders the private glyph with the Omarchy font' 'menu update Omarchy entry renders the private glyph with the Omarchy font'
) )
assertEqual(
defaultById['update.themes'].when,
'omarchy-theme-extras',
'menu hides Extra Themes until a theme cloned from git is there to update'
)
assert( assert(
defaultById['setup.input'].action.includes('input.lua'), defaultById['setup.input'].action.includes('input.lua'),
'menu keeps Input as a direct config action' 'menu keeps Input as a direct config action'
+2 -1
View File
@@ -160,6 +160,7 @@ run_notify 1 >/dev/null 2>&1
notify_args_written || fail "migration notifier sends the notification before exiting" notify_args_written || fail "migration notifier sends the notification before exiting"
grep -Fx -- '--exec' "$test_tmp/notify-args" >/dev/null || grep -Fx -- '--exec' "$test_tmp/notify-args" >/dev/null ||
fail "migration notifier attaches the click command to the toast" fail "migration notifier attaches the click command to the toast"
grep -Fx 'omarchy-launch-floating-terminal-with-presentation omarchy-migrate' "$test_tmp/notify-args" >/dev/null || grep -Fx 'omarchy-launch-floating-terminal-with-presentation' "$test_tmp/notify-args" >/dev/null &&
grep -Fx 'omarchy-migrate' "$test_tmp/notify-args" >/dev/null ||
fail "migration notifier points the click command at omarchy-migrate" fail "migration notifier points the click command at omarchy-migrate"
pass "migration notifier lets the shell own the click instead of waiting for it" pass "migration notifier lets the shell own the click instead of waiting for it"
+63 -5
View File
@@ -65,6 +65,18 @@ local omarchy_monitor_scale = "auto"
LUA LUA
} }
# The shipped default's own shape: the catch-all rule hands the panel a
# bare-word reference to the "auto" local.
write_default_auto_config() {
cat >"$monitor_lua" <<'LUA'
local omarchy_gdk_scale = 2
local omarchy_monitor_scale = "auto"
hl.env("GDK_SCALE", tostring(omarchy_gdk_scale))
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy_monitor_scale })
LUA
}
write_internal_monitor_config() { write_internal_monitor_config() {
cat >"$monitor_lua" <<'LUA' cat >"$monitor_lua" <<'LUA'
hl.monitor({ output = "eDP-1", mode = "preferred", position = "0x0", scale = 1.25 }) hl.monitor({ output = "eDP-1", mode = "preferred", position = "0x0", scale = 1.25 })
@@ -208,20 +220,39 @@ run_clamshell() {
"$ROOT/bin/omarchy-hyprland-monitor-clamshell" "$ROOT/bin/omarchy-hyprland-monitor-clamshell"
} }
# Regression (#7265, #7301): with scale = "auto" the compositor's resolution of
# it IS the configured scale, not a transient to correct. Forcing a number here
# made the scale flap: every idle-wake applied the fallback 2, every config
# reload resolved auto back to the panel's own value.
write_auto_monitor_config write_auto_monitor_config
: >"$eval_log" : >"$eval_log"
OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell
grep -F 'scale = 2' "$eval_log" >/dev/null || fail "clamshell recovery ignores transient auto scale" ! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves an auto-scaled panel alone"
! grep -F 'scale = "auto"' "$eval_log" >/dev/null || fail "clamshell recovery does not apply auto scale"
[[ ! -f $scale_state ]] || fail "clamshell recovery does not remember transient scale" [[ ! -f $scale_state ]] || fail "clamshell recovery does not remember transient scale"
pass "clamshell recovery ignores transient auto scale" pass "clamshell recovery leaves an auto-scaled panel alone"
write_auto_monitor_config # The same delegation through the shipped default config, where "auto" reaches
# the panel via the catch-all rule's bare-word reference to the local.
write_default_auto_config
: >"$eval_log" : >"$eval_log"
OMARCHY_TEST_INTERNAL_SCALE=2 run_clamshell OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell
! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves the shipped auto default alone"
pass "clamshell recovery leaves the shipped auto default alone"
# A numeric config keeps both sync behaviors: a matching active scale is not
# reapplied, and a drifted one is corrected back to the configured value.
write_internal_monitor_config
: >"$eval_log"
OMARCHY_TEST_INTERNAL_SCALE=1.25 run_clamshell
! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery does not reapply matching scale" ! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery does not reapply matching scale"
pass "clamshell recovery avoids redundant scale apply" pass "clamshell recovery avoids redundant scale apply"
write_internal_monitor_config
: >"$eval_log"
OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell
grep -F 'scale = 1.25' "$eval_log" >/dev/null || fail "clamshell recovery corrects a drifted numeric scale"
pass "clamshell recovery corrects a drifted numeric scale"
write_auto_monitor_config write_auto_monitor_config
: >"$eval_log" : >"$eval_log"
OMARCHY_TEST_INTERNAL_SCALE=1.6 OMARCHY_TEST_EXTERNAL_ACTIVE=true OMARCHY_TEST_CLAMSHELL=true run_clamshell OMARCHY_TEST_INTERNAL_SCALE=1.6 OMARCHY_TEST_EXTERNAL_ACTIVE=true OMARCHY_TEST_CLAMSHELL=true run_clamshell
@@ -235,6 +266,15 @@ grep -F 'scale = 1.6' "$eval_log" >/dev/null || fail "clamshell recovery uses re
! grep -F 'scale = "auto"' "$eval_log" >/dev/null || fail "clamshell recovery avoids auto after disabled internal display" ! grep -F 'scale = "auto"' "$eval_log" >/dev/null || fail "clamshell recovery avoids auto after disabled internal display"
pass "clamshell recovery uses remembered internal scale" pass "clamshell recovery uses remembered internal scale"
# Recovery of a panel that is off, under an auto config with nothing
# remembered, still needs a number: the historical default 2.
write_auto_monitor_config
rm -f "$scale_state"
: >"$eval_log"
OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell
grep -F 'scale = 2' "$eval_log" >/dev/null || fail "clamshell recovery falls back to the default scale"
pass "clamshell recovery falls back to the default scale"
write_internal_monitor_config write_internal_monitor_config
: >"$eval_log" : >"$eval_log"
OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell
@@ -307,6 +347,15 @@ OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell
grep -F 'scale = 1.75' "$eval_log" >/dev/null || fail "clamshell recovery leaves an expression scale unresolved" grep -F 'scale = 1.75' "$eval_log" >/dev/null || fail "clamshell recovery leaves an expression scale unresolved"
pass "clamshell recovery leaves an expression scale unresolved" pass "clamshell recovery leaves an expression scale unresolved"
# An expression is Hyprland's to evaluate, not this parser's: like "auto", it
# names no number to correct an enabled panel toward.
write_expression_scale_config
remember_scale 1.75
: >"$eval_log"
OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell
! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves an expression-scaled panel alone"
pass "clamshell recovery leaves an expression-scaled panel alone"
# A quoted value is a string, not a reference to a local of the same name. # A quoted value is a string, not a reference to a local of the same name.
write_shadowed_auto_config write_shadowed_auto_config
remember_scale 1.75 remember_scale 1.75
@@ -315,6 +364,15 @@ OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell
grep -F 'scale = 1.75' "$eval_log" >/dev/null || fail "clamshell recovery does not resolve a quoted scale against a local" grep -F 'scale = 1.75' "$eval_log" >/dev/null || fail "clamshell recovery does not resolve a quoted scale against a local"
pass "clamshell recovery does not resolve a quoted scale against a local" pass "clamshell recovery does not resolve a quoted scale against a local"
# An explicit "auto" on the internal rule delegates just the same: while the
# panel is enabled, it is not corrected toward the remembered scale.
write_shadowed_auto_config
remember_scale 1.75
: >"$eval_log"
OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell
! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves an explicitly auto panel alone"
pass "clamshell recovery leaves an explicitly auto panel alone"
# Half of `3 / 2` is no better inside the rule than inside a local. # Half of `3 / 2` is no better inside the rule than inside a local.
write_expression_rule_config write_expression_rule_config
remember_scale 1.75 remember_scale 1.75
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more