Compare commits

..
112 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
David Heinemeier Hansson f0020448ca More manual tweaks 2026-08-14 16:57:59 +02:00
David Heinemeier HanssonandClaude Fable 5 ebdc0263e0 Point the theme installer at the extra themes page on omarchy.org
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:41:06 +02:00
David Heinemeier HanssonandClaude Fable 5 e6d7c620de Extra themes are added via PRs to omarchy-site now
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:38:54 +02:00
David Heinemeier HanssonandClaude Fable 5 1fe471dc53 Link the manual to the extra themes page on omarchy.org
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:34:31 +02:00
Luis Fernando Jiménez b15ec6c6b3 Fix typo(Primeagen) in Neovim documentation (#6867) 2026-08-14 16:19:47 +02:00
David Heinemeier HanssonandClaude Fable 5 8b70f01583 Point Learn > Omarchy at the manual on omarchy.org
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:56:39 +02:00
David Heinemeier HanssonandClaude Fable 5 e7ea031f98 Host the manual's images in the repo under manual/images (#6861)
* Host the manual's images in the repo under manual/images

Replaces all learn.omacom.io/manual.omakub.org hotlinks with local webp
files capped at 1600px wide (~20MB total), so the manual is fully
self-contained. Theme and unlock previews are converted from the
canonical themes/*/preview.png files. A handful of illustration shots
are interim conversions of the current images, pending retakes.

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

* Retake the illustration screenshots fresh at 3x scaling

Navigation, clipboard history, notices, tmux layouts, prompt, and About
reshot on a clean workspace at 3.13x (1920x1080 logical on 6K) across
ten themes: Tokyo Night, Catppuccin, Gruvbox, Kanagawa, Everforest,
Nord, Osaka Jade, Rose Pine, Matte Black, and Ristretto.

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

* Drop the extra themes chapter from the manual

The community theme gallery will live elsewhere. Removes the chapter
and its 114 gallery images, renumbers the following chapters, and
repoints the two references (theme installs still work via
Install > Style > Theme in the menu).

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

* Reference the shipped theme previews instead of copying them

The themes chapter now points straight at themes/*/preview.png and
preview-unlock.png, so previews can never drift from the source and
manual/images drops from 6.3MB to 3.5MB.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:47:38 +02:00
David Heinemeier HanssonandClaude Opus 5 4559f2d5fc Add Moonlight to the preinstall remove/install lists
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DPqQk3igpstNXSTkfSbJa
2026-08-14 12:30:32 +02:00
David Heinemeier HanssonandClaude Opus 5 28dcbae376 Restore preinstalls from the menu, and match the lists to what quattro ships (#6854)
* Restore preinstalls from the menu, and drop the Omacom apps with them

Remove Preinstalls missed omacut, omacalc, and omawrite, so the three Omacom
apps survived an opt-out that was supposed to clear the desk.

Opting out was also one-way. Install > Preinstalls now puts everything back:
the shipped .desktop launchers and mise stubs via omarchy-refresh-applications,
the dropped packages via pacman, and the opt-out marker deleted so the
preinstalled keybindings return on reload. The two menu entries guard on the
marker, so exactly one of them is ever visible.

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

* Trim the preinstall lists to what quattro actually ships

Remove Preinstalls was still dropping typora, spotify, 1password, 1password-cli,
signal-desktop, opencode, claude-code, and github-cli. None of those are in
omarchy-base.packages anymore: typora gave way to omawrite, the services moved
to on-demand menu installs, and the agent CLIs are mise-managed. Removing them
took out apps the user had deliberately installed, and restoring them would have
put back what we no longer ship.

Both lists are now the same twelve packages, all of them in omarchy-base.packages.

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

* Keep the opt-out marker when a restore fails

omarchy-pkg-add exits non-zero when pacman cannot install a package, but the
restore ran straight past it, cleared the marker, and reloaded Hyprland. That
reported success and brought back keybindings for apps that never arrived. The
marker now falls last, behind a check on the transaction.

The new test also pins the two lists to each other and to omarchy-base.packages,
which is the drift that let retired packages linger in the removal list.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:24:47 +02:00
Fayi FB fa8359359b Fix Pi prompt launch (#6857) 2026-08-14 12:23:07 +02:00
David Heinemeier Hansson ca5add0463 Words 2026-08-14 12:21:59 +02:00
David Heinemeier Hansson 3bd6ed02fe Words 2026-08-14 09:51:17 +02:00
David Heinemeier Hansson 864b0d050a Recognize colored package conflict errors 2026-08-14 00:15:07 -07:00
David Heinemeier HanssonandClaude Opus 5 5ca3030c5a Put a blocked package upgrade back to whoever is updating (#6830)
Pacman answers its own conflict question with No under --noconfirm, so one
retired package can stop every update after it. Which package to drop is a
decision rather than a cleanup, so run the upgrade again with pacman asking
when there is a terminal to answer on, and report instead when -y promised
not to ask.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 09:08:34 +02:00
David Heinemeier HanssonandClaude Fable 5 625c66301d Point the headless theme test at the renumbered Tokyo Night background (#6851)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:59:38 +02:00
Shrijit Srivastav d35f4b6d59 Wait on the profile being repaired, not on every browser (#6837)
The migration asked a user to close every running browser before repairing
the Copy URL shortcut, but a browser only ever rewrites its own Preferences
on exit. Waiting on all browsers deadlocks `omarchy update` for anyone whose
main browser is effectively never closed: the pending ghosts commonly sit in
a stale profile nobody has open, yet the migration blocks on the always-open
browser until the prompt is declined, failing the whole update.

A running Chromium-family browser holds a SingletonLock (and socket) inside
its user-data-dir, so whether the profile being repaired is open is
mechanical. Gate on that instead of on the sheer presence of a browser
process — the repair proceeds where the affected profile is closed, and
browsers attached to other profiles no longer hold the update hostage.

The gate stays conservative while an affected profile actually is open, and
the existing post-repair verification still catches a browser that starts
mid-repair and restores stale Preferences on exit.

The migration test now simulates an open profile with its SingletonLock
instead of a pgrep stub; every prior scenario still passes.
2026-08-14 08:59:14 +02:00
David Heinemeier HanssonandClaude Opus 5 dc698e5df0 Suppress LocalSend's redundant tray item (#6852)
LocalSend registers an Ayatana item with no ItemIsMenu and no Activate
handler, so its primary click is a silent no-op and the menu offers only
Open and Quit. Share > Receive already opens it, so drop the item the way
Dropbox's is dropped when its dedicated widget owns the surface.

Closes #6838

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 08:23:45 +02:00
Adrian RangelandClaude Opus 5 df708831b6 Keep the Copy URL migration's browser prompt visible (#6842)
gum draws its confirm UI on stderr, so the migration's `2>/dev/null` threw
away the whole prompt while gum still held the terminal in raw mode reading
keys. With a browser open, an update stopped after "Running migration
(1786643346)" on an unpainted screen with no way to tell it was waiting for
an answer.

Nothing else in the repo suppresses gum's stderr; the redirect only ever hid
gum's own error in the no-terminal case, where the migration already explains
itself on stderr before deferring.

Fixes #6841


Claude-Session: https://claude.ai/code/session_01So4yKzTDdJJp7orjnF8i1J

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:19:25 +02:00
David Heinemeier HanssonandClaude Fable 5 5e5197a081 Point the README manual TOC at the renumbered chapters
The switchers chapter shifted everything from chapter 3 up by one, and
the TOC was still on the old numbers. Add its missing entry too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:53:23 +02:00
David Heinemeier HanssonandClaude Fable 5 3a4eda7f08 Drop retired nmtui and bluetoothctl interactions from the bar README
The network and bluetooth panels stopped launching them; the module
catalogue still said otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:52:13 +02:00
David Heinemeier HanssonandClaude Fable 5 1ae83f1606 Add a switchers chapter and fix drift found on a second manual pass
Add "Coming From Mac or Windows" as chapter 3: a translation layer for
Cmd/Win instincts rather than a feature tour.

Fill the remaining coverage gaps: fcitx5 input methods, laptop speaker
tuning, touchpad haptics, crash capture in the toggle table, the
installable font list, and the ChatGPT Desktop and Grok Bot entries.

Correct claims that drifted from the source: the network and bluetooth
bar icons no longer launch nmtui/bluetoothctl, monitor scaling steps
rather than cycles, and the display widget's scroll steps brightness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:52:13 +02:00
David Heinemeier HanssonandClaude Fable 5 de854d3f0c Free the Copy URL shortcut from ghost extension registrations (#6821)
* Rebind ghost Copy URL shortcut registrations to the pinned id

Chromium never hands a suggested shortcut to one extension while
another — even a long-gone one — still holds the registration. Profiles
that first loaded Copy URL before its id was pinned registered
Alt+Shift+L under an id derived from the extension's load path at the
time, so the pinned extension never receives the shortcut and the
keypress does nothing (#6816).

The quattro upgrade tried to repair this against one hardcoded
path-derived id, which only ever matched a single home directory. The
historical ids are unknowable in general — they hash long-gone absolute
paths through whatever symlinks existed then — but the registration
itself names the command, so a migration now rebinds any copy-url
command that points away from the pinned id, unless that id belongs to
an extension that is actually installed or the pinned extension already
holds a binding of its own.

Browsers rewrite Preferences on exit, which reverts any repair made
while one runs, so the migration asks for this user's browser windows to
be closed first — failing and staying pending when there is no terminal
to ask in or the prompt is declined. The backup a repair leaves behind
marks it as attempted but unverified: until a browser-free run confirms
the registration stayed repaired, the migration keeps itself pending
rather than trusting a disk state an open browser may still overwrite.

The upgrade-time repair is dropped: the upgrade already runs migrations,
so the migration is the single implementation.

Fixes #6816

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

* Pin the WhatsApp Slim extension id

Keyless unpacked extensions get path-derived ids, which go stale if the
load path or packaging ever changes — the same class of bug that broke
the Copy URL shortcut for pre-package installs. Pin the id with a
manifest key like the other bundled extensions.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:37:29 +02:00
David Heinemeier HanssonandClaude Opus 5 30f8f191c0 Add a toggle for crash capture (#6824)
Crash capture stays on by default, but Trigger > Toggle > Crash Capture (or
`omarchy toggle crash-capture`) now turns the watcher off. The toggle writes the
usual flag file and stops the unit for this session; the unit checks the same
flag with ConditionPathExists, so the choice survives a logout without the unit
having to be disabled.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:26:08 +02:00
Eduardo EscobarandClaude Opus 5 f73740ff07 Blank the lock screen while the fingerprint reader waits (#6817)
The blank timer was gated on `authenticating`, which is
`authenticatingPassword || fingerprintAuthenticating`. The fingerprint
PAM sits armed for the entire lock waiting for a finger, so on any
machine with a reader enrolled the gate is true from lock until unlock:
the timer is stopped when the lock begins and never re-armed, and the
display stays lit indefinitely.

Gate on `authenticatingPassword` instead. A password check in flight
still holds the display up, and the passive fingerprint wait no longer
does.


Claude-Session: https://claude.ai/code/session_01EDpyC9793TKZBS2jXUNECG

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:04:47 +02:00
Jitendra dara 5c51ad6d54 Fix broken manual links in README (#6809)
* Fix README having broken links

Reordered and updated the list of manual sections in the README.

* Update README with new section ordering and links
2026-08-13 21:02:47 +02:00
82ae514609 Fix notification focus for agent terminals (#6801)
* Fix notification focus for agent terminals

* Restrict notification title fallback to agents

* Simplify the focus fallback to a lazy two-tier query

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

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 20:28:43 +02:00
David Heinemeier Hansson 14dc3a31d1 Add omarchy-dev-font for adding marks to the icon font (#6819) 2026-08-13 19:46:46 +02:00
Rob Zolkos ed795d8a13 Select packaged Cursor for theme sync (#6808) 2026-08-13 19:33:22 +02:00
David Heinemeier HanssonandClaude Fable 5 b08598086c Keep AppLibrary scans out of login shells (#6815)
* Keep AppLibrary scans out of login shells

A login shell sources the user's profile, and tools like mise touch
~/.local/share on activation. Quickshell's desktop-entry watcher monitors
that tree, so every scan retriggered another scan: a self-sustaining loop
that pinned ~20% of a core at idle. The scans only use bash builtins,
find, and sort, so they don't need the login environment.

Fixes #6806

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

* Pin the AppLibrary scans to non-login shells in tests

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:30:56 +02:00
David Heinemeier HanssonandClaude Fable 5 5b8fef406f Flesh out the Windows VM chapter
Cover the KVM and disk requirements, the resources and credentials the
installer asks for, the launch/stop/status commands and --keep-alive,
what the RDP session carries, and what removal deletes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:18:46 +02:00
David Heinemeier Hansson b508541c88 Use official marks for the AI installers (#6814)
* Add LM Studio and Ollama marks to the Omarchy icon font

* Use the official marks for the AI installers in the menu
2026-08-13 18:18:17 +02:00
David Heinemeier Hansson e2cffe1771 Add Grok Bot to the Install > AI menu (#6811) 2026-08-13 18:02:15 +02:00
David Heinemeier HanssonandClaude Fable 5 b2207c3357 Persist notification images so history keeps avatars (#6804)
* Persist notification images so history keeps avatars

Persisted popup and history entries stored image/appIcon as URLs into
resources that die with the live notification: Chromium-family senders
(every Omarchy web app, WhatsApp included) pass avatars as files in a
scoped /tmp dir deleted when the notification closes, and raw image-data
hints surface as in-process image:// URLs that die with the server
object. Replaying history then found dead references and hid the icon.

Copy file-backed images into the notification state dir when persisting,
keyed by the entry's file stem, and reference the copies from the JSON.
Blank dead image:// URLs so the card falls back to the app icon. The
copies die with their JSON: superseded-popup deletes, history trims and
clears remove them, and a startup sweep collects copies orphaned by a
restart killing a queued job mid-write.

Hold DND-silenced notifications open until their history write has run,
since untracking tells the sender to delete its avatar file, and carry
replayed on-screen rows over via their persisted copies, since the
replay dismisses their live notifications first.

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

* Coalesce silenced updates and bound image copies through temp files

A replaces_id update lands on a held DND notification without a second
onNotification, so releasing after the first write could persist a stale
snapshot. Re-snapshot when the write completes and write again until the
content is stable, reusing the original file identity.

The image copy reopened the sender-controlled path after checking it, so
a file growing or becoming a FIFO mid-copy defeated the size bound. Read
through head -c under a timeout into a temp file, validate its size, and
rename it into place; the startup sweep clears temp files a killed job
leaves behind.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:01:48 +02:00
David Heinemeier HanssonandClaude Fable 5 edcbcb4691 Point the hyprsunset autostart hint at autostart.lua
The comment still described the old exec-once conf syntax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:04:28 +02:00
David Heinemeier HanssonandClaude Fable 5 93372d5fad Drop the tz and style command group descriptions
Neither group has any commands behind it, so both only ever printed
"Unknown Omarchy command" when browsed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:04:28 +02:00
David Heinemeier HanssonandClaude Fable 5 55434792d1 Cover the shell, capture, browsers, and networking in the manual
Add chapters for the top bar and its panels, screenshots and recording,
toggles/idle/screensaver, browsers, shell plugins, and networking, which
collects the Wi-Fi, DNS, firewall, and Tailscale material that was spread
across the FAQ, TUIs, and services chapters.

Fill smaller gaps in the existing chapters: user hooks, autostart, menu
extensions, printing, power profiles, factory reset, drive and user
passwords, passwordless sudo, firmware updates, display text size, and
the LocalSend share menu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:04:09 +02:00
David Heinemeier Hansson 144f4d1e31 First pass on updates for Quattro 2026-08-13 15:18:33 +02:00
David Heinemeier Hansson 9c9e082954 New launch backgrounds for Omarchy 2026-08-13 15:18:33 +02:00
David Heinemeier Hansson fa78633596 Not needed 2026-08-13 05:42:02 -07:00
25c2b3aed2 perf(agents): cut codex usage collector memory with SQL filter and cache (#6780)
* perf(agents): cut codex usage collector memory with SQL filter and cache

The codex collector scanned every row of opencode.db (1.7 GB, 55k+ rows)
with Python-side json.loads, peaking around 716 MB of RSS on every run
-- including the panel's refreshLimits() call, which passed --limits-only
that the collector silently ignored.

Filter rows in SQL (LIKE gates + json_valid + json_extract authority,
mirroring the old Python filter semantics) so giant blobs are never
parsed, and cache the local stats scan in XDG_CACHE_HOME following the
claude collector's pattern (atomic writes, flock, schemaVersion).
--force rescans, --limits-only and normal mode reuse a fresh cache and
fall back to a full scan when it is missing, stale, or corrupt.

Measured: cold scan 716 MB -> 158 MB peak; warm --limits-only ~85 MB
and ~1.4 s. Output record schema and values are unchanged for the same
data (parity verified against the old filter, including malformed rows).

* Scope the codex scan cache's 15-minute reuse to --limits-only

A no-flag run is the widget's periodic refresh, and refreshIntervalSec is
configurable down to 30 seconds; holding every mode to a 15-minute cache
meant stats could lag far behind the interval the user asked for. Mirror
the claude collector: normal runs reuse a scan for ~20 seconds purely to
dedup concurrent collectors, and only --limits-only, which promises just
fresh limits, may reuse a scan for up to 15 minutes.

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

* Invalidate the codex scan cache across day boundaries

The cached stats embed date-dependent fields (todayPrompts,
todayTotalTokens, recentDays), but only the file's age was checked, so a
cache written at 23:58 served yesterday's numbers as "today" for up to
15 minutes past midnight. Stamp the envelope with the scan's local date
and treat any other date as a miss.

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

* Reject codex scan caches with a future mtime

A cache whose mtime is ahead of the clock has a negative age, which the
freshness check accepted forever: setting the clock backwards froze the
stats until real time caught up with the file. Require a non-negative
age before trusting the cache.

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

* Never cache an interrupted opencode scan

A transient lock, schema migration, or corrupted database aborts the
opencode scan mid-flight; the partial numbers still serve the current
run, but persisting them let a single bad read suppress opencode usage
for every cache reader until expiry. The claude collector already skips
its opencode cache write on a database error; do the same here.

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

* Make the json_valid guard order explicit in the opencode query

The query relied on json_valid(data) evaluating before json_extract(),
but SQLite does not promise that AND terms run left to right; a
reordered plan would let json_extract raise on a malformed row and
silently truncate the scan. Wrap each json_extract in a CASE so the
guard is structural rather than positional.

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

* Drop a claude-collector comment that is false for codex

"These caches were world-readable before" was copied from the claude
collector; codex had no caches before this one existed. Explain the
chmod on its own terms.

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

---------

Co-authored-by: markbusking <marcosbustos.dev@gmail.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 13:47:44 +02:00
David Heinemeier Hansson 929849c7b9 Add the manual to the source code for better agent evolution 2026-08-13 13:37:57 +02:00
David Heinemeier HanssonandClaude Fable 5 78d3224846 Bound the hybrid GPU gate's supergfxctl query (#6799)
omarchy-hw-hybrid-gpu gates the Hybrid GPU menu entry, and it queried
supergfxctl unbounded — a wedged supergfxd stalled menu rendering
forever. Bound the query with the same TERM-then-KILL escalation the
toggle uses, and treat a daemon that cannot answer like a machine
without supergfxctl: fall back to counting GPUs rather than hiding
hardware that is really there. An ordinary supergfxctl failure still
hides the entry.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 12:37:43 +02:00
Aditya Garud 090574e3a1 Treat LVDS panels as internal displays (#6752)
* Treat LVDS panels as internal displays

* Test empty DRM monitor discovery
2026-08-13 12:25:25 +02:00
8b9e43d3e5 Only offer video capture devices as webcams (#6732)
* Only offer video capture devices as webcams

V4L2 exposes raw processing and output-only nodes beside usable cameras. The webcam picker and automatic recorder selection treated the first /dev/video node as a camera, so IPU6 laptops opened a black overlay instead of their loopback capture device.\n\nShare one device lister across detection, selection, and recording, and keep only groups whose first video node advertises Video Capture in Device Caps. Cover raw IPU nodes, ordinary capture devices, and capture-less systems.

* Fall through to a later capture-capable node in a webcam group

A group whose first video node is not capture-capable vanished entirely,
even when a later node in the same group could capture. Probe each node
until one qualifies, still emitting at most one device per group. Also
exit zero explicitly: a trailing filtered device used to leak the failed
capability check as the script's exit status.

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

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 12:23:48 +02:00
860503f56a Stop hybrid GPU mode queries from hanging (#6738)
* Bound hybrid GPU mode queries

* Test blocked hybrid GPU queries

* Give the blocked-client test headroom over its 12s of kill cycles

The third case spends ~12s of real TERM/KILL escalation against its own
15s watchdog, which can tip to a spurious 124 on a loaded machine. Also
drop the TEST_LOG plumbing no stub ever wrote.

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

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 12:22:58 +02:00
David Heinemeier HanssonandClaude Opus 5 ce21845407 Let --force actually re-probe the Claude limits endpoint (#6798)
The interval floor was applied with max(), so the zero that --force
picked could never win: max(0, 15) is 15. Forcing a refresh within
fifteen seconds of the last probe silently served the cache instead,
though --force documents itself as ignoring them.

The window exists to absorb a panel opened and shut repeatedly, which
arrives as --limits-only. --force is a person pressing refresh, and it
should outrank a window meant for flicks.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 12:13:41 +02:00
Grant Wiley f6fd2e705a Wait for ttfx before closing its terminal (#6773) 2026-08-13 11:46:45 +02:00
Francisco Cabral 5f74a996c7 Stop closed network panels from leaving Wi-Fi scanning enabled (#6772)
* Stop closed network panels from leaving Wi-Fi scanning enabled

refresh() defaults scanWifi to false and its no-scan branch enabled the
scanner unconditionally. Five paths reach it with no panel on screen —
Component.onCompleted, clearNetworkAction(), failNetworkAction(), the
band-change actionProc exit, and the 30s actionTimeout — so the scanner
stayed on and Quickshell kept re-arming RequestScan behind a closed panel.
scanRestart had the mirror gap: it enabled the scanner 100ms after
refresh(true) without re-checking that the panel was still open.

Every sweep takes the radio off the operating channel, so this degraded
the link it was scanning from: one sweep every 17s, gateway RTT rising
from ~2ms to repeated 150ms+ spikes on an otherwise idle connection.

Gate the scanner block on the panel being open, cancel a pending restart
on close and re-check the panel when it fires, and track the WifiDevice
this instance enabled so close, device replacement and destruction
release the right object. Destruction matters on its own: a bar reload
with the panel open would otherwise die with opened still true and never
write scannerEnabled = false.

* Cover the scanner ownership helper's own invariants

The previous assertion only pinned that no write bypasses
setScannerEnabled(); it said nothing about what the helper does. Dropping
either the opened gate or the release-before-adopt from the helper still
passed, while a closed instance could reclaim scanning and a device swap
could leave the previous interface scanning.

Run the helper's actual JavaScript against stand-in devices instead,
following the extract-and-eval pattern the agents panel tests already use.
Removing either invariant now fails its own assertion.
2026-08-13 11:46:16 +02:00
David Heinemeier HanssonandClaude Opus 5 8ca61d6b8b Open the About window at the size it last settled on (#6770)
* Measure the About layout in UTF-8 so its window is not fitted too narrow

wc -L only counts display columns in a UTF-8 locale. A session that never
set one leaves it counting the box-drawing and Nerd Font glyphs the About
layout is built from as nothing, which measured the content 21 columns
narrower than it renders and fitted the window to clip it.

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

* Open the About window at the size it last settled on

The window used to map at the float rule's starting size, paint, and only
then measure itself and resize, so every open flashed one window size and
reflowed into another. The size that hugs the content can only be measured
from inside the terminal, so remember it and apply it as a window rule
before the terminal is spawned: the window now maps at its final size and
never moves. A rebranded logo or a new font falls back to the float rule
for one launch, refits, and is remembered from then on.

The fit itself now moves the window by the cells it is off by, rather than
scaling it to the grid, which multiplied up the terminal's padding along
with them and left the fit a column or two short. It accepts a cell of
slack instead of chasing an exact grid, since a window lands where the
terminal's cell boundaries put it, not where it was asked to.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:32:20 +02:00
David Heinemeier HanssonandClaude Opus 5 c8fb5be42e Explain an expired Claude sign-in instead of hiding the limits (#6795)
Only the Claude Code CLI can refresh the OAuth token it saves; the
collector just reads it. A machine left alone long enough finds the
token lapsed, and that branch returned an empty limits list with no
status text at all, so the panel hid its whole limits section and
explained nothing.

Say what is wrong, and fall back to the cached limits already on disk
rather than discarding them. Cached windows are kept only until they
reset: a percentage from a window that has rolled over describes a
period that is over, and pinning a stale 78% on an allowance that is
now untouched would be worse than showing nothing. The probe-failure
path gets the same filtering for the same reason.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:13:07 +02:00
ecd57bceee Drop the kms hook when the proprietary NVIDIA driver handles early KMS (#6791)
* Drop the kms hook when the proprietary NVIDIA driver handles early KMS

install/hardware/nvidia.sh early-loads nvidia_drm (modeset=1) for early KMS,
but HOOKS still carried the kms hook, so autodetect pulled nouveau and
~100 MB of its GSP firmware into every initramfs for a driver that never
runs. On a Limine UKI setup that meant a 256 MB image where ~144 MB is
normal, doubled again by the fallback history on /boot.

Filter kms out of HOOKS when nvidia_drm is in MODULES (nvidia.conf sorts
before this drop-in) and every PCI display controller is NVIDIA. Hybrid
systems keep kms so the iGPU retains early KMS at the LUKS prompt.

Verified on an RTX 4090 (nvidia-open-dkms 610.57.04): UKI shrinks
256,183,296 -> 144,066,048 bytes, nouveau and its firmware gone, the
nvidia-utils GSP blobs and all four nvidia modules retained, Plymouth
still owns the LUKS prompt via nvidia_drm.

Fixes #6790

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

* Address review: quote literals, harden PCI detection, add shell tests

Quote fixed string literals in the [[ ]] comparisons per AGENTS.md, and
read the PCI tree through OMARCHY_PCI_DEVICES_PATH, the same seam
bin/omarchy-hw-nvidia already uses.

Require a positively identified NVIDIA display controller before dropping
kms: an empty or unreadable PCI tree previously counted as "no non-NVIDIA
GPU" and would have dropped the hook. Unexpected trees now keep kms.

Cover the conditional in test/shell.d/nvidia-kms-hook-test.sh: nvidia-only,
hybrid, no nvidia_drm, MODULES unset under set -u, audio-function-only,
empty tree, and a device directory missing its sysfs attributes.

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

* Treat unreadable PCI devices as inconclusive and make the test hermetic

A device whose class/vendor attributes cannot be read could be another
GPU, so skipping it let a readable NVIDIA GPU beside it drop kms without
having verified the whole tree. Count it as a non-NVIDIA sighting so kms
stays, and cover the mixed case in the test.

The test also sourced the host's /etc/vconsole.conf under set -u, where a
valid KEYMAP-only file makes the XKBLAYOUT expansion fail in the subshell
and ties the result to the machine running it. Predefine XKBLAYOUT and
FILES before sourcing the config.

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

* Rebuild existing initramfses once the kms hook no longer applies

The settings package deploys the new omarchy_hooks.conf conditional, but
nothing rebuilds the initramfs when only a mkinitcpio drop-in changes, so
existing NVIDIA-only installs would carry nouveau's ~100 MB of GSP
firmware until their next kernel update. Following the precedent of
1784476564, add a migration that rebuilds via limine-mkinitcpio — once
per machine, and only where evaluating the installed drop-ins shows the
conditional actually dropped kms, so hybrid machines, non-NVIDIA
machines, and user-edited configs are left alone.

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

* Document the mid-sourcing MODULES caveat in the kms conditional

A later-sorting drop-in that resets MODULES outright (as
surface_device_modules.conf does) would strip nvidia_drm after kms was
already dropped. Every machine Omarchy writes such a file for is hybrid
Intel and keeps kms through the PCI scan, but that is worth stating so
the invariant is not broken by accident.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
2026-08-13 10:48:18 +02:00
David Heinemeier HanssonandClaude Fable 5 6fad76184f Unmask wpa_supplicant left masked from the iwd era (#6793)
Quattro hands wifi to NetworkManager, which starts wpa_supplicant through
D-Bus activation. Installs carrying a wpa_supplicant.service mask from the
iwd days break that activation: NetworkManager retries five times, gives
up, and every wifi device sits at "unavailable" with no network to
research the fix on. Remove the mask (including a runtime one) and restart
an active NetworkManager when a wifi device is stuck, so wifi comes back
without a reboot.

Fixes #6783

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 10:36:01 +02:00
David Heinemeier HanssonandClaude Fable 5 457aec6a8c Stop Bluetooth discovery when the panel closes (#6794)
The discovery retry timer turned adapter.discovering on every second
while the panel was open, and nothing ever turned it off. The BlueZ
discovery session behind it is held by quickshell's D-Bus connection,
so one visit to the panel left the radio in inquiry until the next
shell restart — continuously starving A2DP audio on the same controller
into stuttering, and 'bluetoothctl show' kept reporting
'Discovering: yes' long after the panel was gone.

The panel now tracks the StopDiscovery it owes BlueZ and settles it
once closed. A timer bound to the confirmed discovery state does the
stopping, rather than a write in the close handler: quickshell only
forwards a discovering write that differs from the last state BlueZ
reported, so a stop issued while a just-fired StartDiscovery is still
awaiting confirmation would be swallowed and leak the session. Binding
to adapter.discovering re-arms the stop whenever the confirmation
lands, a reopen inside the first interval keeps the scan running
uninterrupted, and attempts are bounded so a session another BlueZ
client holds up cannot draw StopDiscovery calls forever.

One widget instance exists per monitor and they all share the default
adapter — the same shared-backend shape the network panel's wifi
scanner fix (#6772) dealt with — so the debt follows the session: an
instance opening onto a running scan adopts it, a closing instance
hands it to a panel still open on another monitor (the popout handoff
closes one instance as it opens the next), and a destroyed instance
passes it to a surviving sibling.

Fixes #6789

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 10:35:22 +02:00
f075a789f4 Recover the wallpaper picker after interrupted thumbnails (#6775)
* Recover image picker after interrupted thumbnails

* Use arithmetic assertions in image cache tests

* Bound thumbnail lock waits and reap partial thumbnails

A hung generator (vips stuck on a corrupt file or slow mount) held its
flock forever, wedging every later picker open; the directory-lock era
capped that wait at 30 seconds, so keep the same bound. A generator
killed mid-write also stranded its partial .jpg.<pid>.jpg forever, since
nothing prunes the cache directory; only the lock holder writes those,
so reap them right after taking the lock.

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

* Harden thumbnail locks and cache publication against races

Adversarial review caught three holes. The lock fd leaked into
vipsthumbnail, so an orphaned or hung vips kept holding the lock after
its shell died; close it for the child. Reaping legacy lock directories
unconditionally raced a still-running legacy generator through an
upgrade; only reap ones older than the longest plausible generation.
And cache publication was neither atomic nor exclusive, so a picker
killed mid-write, or two interleaving, could leave truncated or
mismatched rows behind signatures that still validated - the same
permanent hiding this branch set out to fix; publish via renames under
a per-key lock, rows first.

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

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:58:15 +02:00
9672d852f0 fix --help being skipped for bin routes that only partially resolve to a file (#6788)
* fix `--help` being skipped for bin routes that only partially resolve to a file

* Add regression tests for --help on partially resolved routes

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

* Stop help-flag scanning at -- and match --json as an exact token

A `--` marks the rest of the args as belonging to the command itself, so
neither --help nor --json past it should be treated as router flags. The
--json check also matched substrings of flattened args, so a --json inside
one quoted argument switched --help output to JSON.

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

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:53:31 +02:00
David Heinemeier Hansson 38542a1f51 Open the agent in a normal tiled window (#6769)
The floating rule pinned every agent terminal to 1200x800, which
overflows small and scaled displays: window rules see logical pixels, so
a 2560x1440 monitor at scale 1.6 is only 1600x900 and the window covered
89% of its height. Tiling drops the fixed size along with the rule, and
the shared app-id still earns the terminal tag from terminals.lua.
2026-08-12 22:37:16 +02:00
David Heinemeier Hansson 438f7b3340 Stop ttfx before closing the screensaver terminal (#6764) 2026-08-12 20:05:10 +02:00
David Heinemeier Hansson f5893ddd9e Launch the default agent from the agents widget right-click (#6759) 2026-08-12 18:46:17 +02:00
David Heinemeier Hansson 2cc3510d2a Offer an AI diagnosis when a process crashes (#6746)
* Offer an AI diagnosis when a process crashes

systemd-coredump journals every core dump under a known MESSAGE_ID with the
crashing program, pid, and signal as structured fields. omarchy-crash-watch
follows that stream and raises a "Process crashed: <program>" toast; clicking it
opens omarchy-agent-crash, which briefs the default agent on the crash.

The toast goes through omarchy-notification-send --exec rather than a libnotify
action, because the shell runs clicks from its own omarchy-exec hint and never
emits ActionInvoked. It keeps the default "omarchy-action" app name too, the
only one shouldBypassDnd() lets through -- a crash being the last notification
worth swallowing. It stays quiet until an agent is configured, since a
diagnosis is all it offers.

The method lives in a diagnose-crash skill rather than the prompt, so it is
edited in one place and works with whichever agent is default. It covers
investigating the core, and reporting a confirmed Omarchy bug upstream: scoped
to bugs Omarchy controls, searched for duplicates first, only with the user's
agreement, and signed with the model and harness that produced it.

A migration reaches existing installs, whose skill symlinks and unit enablement
would otherwise sit behind one-time setup paths.

* Let the diagnosis clean up the core it extracted

"Do not modify or delete anything" contradicted the symbolization step right
above it, which writes a core to a temp file and deletes it on exit. Read
literally, the core survives -- and the same section warns it holds passwords
and tokens. The prohibition is about the system, not about your own scratch.

* Do not spend a crash toast on a dead notification server

The shell owns org.freedesktop.Notifications, so its own crash takes the
notification server down with it -- and a shell crash is exactly what you want
told about. The toast was sent once into that gap and the dedupe window was
recorded regardless, so the rest of the crash loop went quiet for a minute and
`journalctl -n 0` never replays what was missed.

It now waits for the restarted shell to reclaim the bus name, as
omarchy-migrate-notify already does, and only a delivered toast starts the
dedupe window.
2026-08-12 18:37:40 +02:00
David Heinemeier Hansson 9502b81f3b Reshape the agent launcher into omarchy agent (#6757)
* Reshape the agent launcher into omarchy agent

omarchy-launch-agent becomes omarchy-agent, with prompts on omarchy-agent-prompt
rather than the bare route: `omarchy agent` is both a command and a group, so a
positional prompt there would shadow any subcommand under it. The launcher takes
flags only and points at `omarchy agent prompt` when handed one.

Every agent window now launches under a fixed org.omarchy.agent app-id instead of
omarchy-launch-tui's default of org.omarchy.<binary>, so one rule floats them all
whichever agent is default.

Omarchy also stops picking an agent for you. omarchy-default-agent prints nothing
until one is chosen, leaving every entry under Setup > Defaults > Agent unchecked,
and a first-run invitation offers to take you there.

* Wordsmith

* Cover the agent routes and the invitation

The route split is the point of the change, so exercise `omarchy agent`,
`omarchy agent prompt`, and a rejected positional prompt through the router
rather than only the binaries behind them.

The invitation gets the same treatment as the Voxtype and fingerprint ones: it
notifies once, opens the agent defaults menu, and leaves both the notification
and the marker alone for anyone who already chose an agent.

* Offer the agent choice from the keybinding

Super + Shift + Ctrl + A now runs `omarchy-agent --pick`, which opens Setup >
Defaults > Agent when nothing is chosen yet. A keypress that writes to stderr
and opens nothing just looks broken.

* Reach existing installs with the agent invitation

first-run installs the invitation hook, and existing accounts marked it complete
long ago, so they would never see it -- while being the accounts most likely to
need it, since the old getter returned opencode implicitly and most have no
agent recorded at all. Post-update hooks run later in the same update, so the
invitation arrives without waiting for another one.

* Say what the Defaults submenus set

Setup > Defaults lists Agent, Browser, Terminal, Editor, but the header inside
each repeated the same bare word, which reads as a category rather than a
setting -- and says nothing at all when the menu is summoned straight into it.
The list keeps its short labels; the headers now name the setting.
2026-08-12 17:56:19 +02:00
David Heinemeier HanssonandClaude Opus 5 b97a1480dc Treat the guard test's socket fixture as optional (#6755)
Standing in for an abandoned compositor means binding a Unix socket, and the
sandboxes this guard exists for are the ones that deny it: the fixture raised
PermissionError and took the whole file down with set -e, adding a failure in
the environment the guard was written to keep clean. Run the cases that need no
socket first and skip the rest when one cannot be bound.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:58:46 +02:00
66e3f479a6 Follow the keyboard being typed on in the layout widget (#6740)
* Read the keyboard being typed on rather than the one holding main

The main flag names no keyboard for long. fcitx5 takes it with the
virtual keyboard it binds to inject, and those are filtered out, so on a
seat running an input method the pick lands on nothing at all: no label,
and the widget hides itself off the bar. #6727 keeps polling in that
state rather than settling it, and the poll has nothing new to read.
Once fcitx5 unbinds, the flag lands on whichever device libinput listed
last, as easily a lid switch as a keyboard, and a device that never
receives the toggle reports the layout it started on forever, which is
the reading #6574 opened.

Every device carries the seat's layout list, but only the keyboard being
typed on advances through it, so read the furthest-advanced one.
activelayout names the keyboard it moved ahead of the layout, so take
that name and let it settle the pick, and the click that switches it.

* Leave the buttons out of the seat the widget reads

Reading the keyboard being typed on left keyboardName standing for two
things at once: the device a click switches, and the device activelayout
last named. Only the second was still being set, so the first went empty
until a switch happened -- which left the click doing nothing on a seat
whose only switch is the click, and left the poll running forever on the
one-keyboard install it was written to leave alone. Give each its own
property, and set the switch target from the reading that confirmed the
keyboard is there.

Layout progress only points at the keyboard being typed on while the
other devices stay where they started, and the ACPI power button, lid
switch and sleep key never do move on their own -- but they answer to
switchxkblayout and can hold the main flag, so anything that reads or
switches whatever the seat hands back can end up describing a button, and
unplugging the keyboard beside one leaves it standing in for the seat.
Drop them where the virtual keyboards are already dropped.

A reading that reaches hyprctl and finds no keyboard now clears the label
rather than leaving a device that is gone described on the bar, told
apart from the empty output a killed query leaves by the device list
itself, and the watchdog asks again rather than waiting for a poll that a
settled seat has already stopped.

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

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:57:57 +02:00
David Heinemeier HanssonandClaude Opus 5 0fa3170504 Skip shell tests when the compositor can't be reached (#6749)
* Skip shell tests when the compositor can't be reached, not just when WAYLAND_DISPLAY is unset

A set variable only proves the environment was inherited. Sandboxes pass it
through while blocking $XDG_RUNTIME_DIR, so Quickshell cleared the guard and
aborted inside QGuiApplication, leaving two core dumps per launch instead of a
clean skip. Probe the socket and, when there's a signature to ask with,
Hyprland itself. Disable core dumps on the way through for the compositor that
dies mid-run, which no probe can catch.

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

* Retry the compositor query before calling it dead

Hyprland can miss a query while it reconfigures outputs, and one miss was enough
to skip a whole file's runtime coverage. Retry the way omarchy-launch-shell
does. Only a leftover socket reaches the query at all, so the ordinary skip
still returns immediately.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:49:15 +02:00
David Heinemeier HanssonandClaude Opus 5 0900855a28 Stop hot-reloading the shell when a package upgrade rewrites it (#6751)
Quickshell watches the QML it loaded and reloads on change, so pacman
replacing /usr/share/omarchy/shell mid-transaction makes the running
shell reload against a half-written tree. That reload fails, and a
failure that reaches the config load is not harmless: it raises the
reload popup, which is a second engine generation.
EngineGeneration::currentGeneration() returns null unless exactly one
exists, so the IPC kill that omarchy-update sends moments later takes
the QCoreApplication::exit(0) branch instead of the generation's own
quit, and Quickshell tears the QML graph down after deleting the
QGuiApplication. The first GUI resource touched on the way out aborts:

  FATAL: QPixmap: Must construct a QGuiApplication before a QPixmap

The user gets the crash dialog after an update and a coredump per
occurrence. Reported in #6748 with 3 crashes across 10 updates, always
following a failed reload.

Fixing this in omarchy-update — stopping the shell around the pacman
step — would cover one caller and cost the polkit agent and the
notification server for the length of the transaction, which the
migrations that run next still notify through. It would also have to
carry omarchy-restart-shell's refusal to restart a locked session, or
reintroduce the hazard that refusal exists for.

And omarchy-update is not the only thing that rewrites the tree. The
pacman guard turns away a bare pacman -Syu, but nothing turns away a
targeted pacman -S omarchy, a pacman -U of a locally built package, the
documented OMARCHY_ALLOW_DIRECT_PACMAN bypass, omarchy-dev-pkg-test, or
a checkout in a dev-linked tree.

Turn the watcher off instead. Omarchy has never reloaded through it:
omarchy-restart-shell is what picks up QML changes, and config and
plugin changes go through the shell's own IPC. Third-party plugin hot
reload is PluginRegistry's own inotifywait and FileView watches its own
files, neither of which this touches — QuickshellSettings::watchFiles()
gates the config scanner and nothing else. The popup goes off with it,
because QML can still ask for a reload directly and leave the same
extra generation behind.

Environment reaches Quickshell only at launch, so the update that
delivers this still runs under a watching shell. It takes effect from
the next one.

Verified against an isolated instance: breaking a config in place and
then sending the IPC kill reproduces the FATAL, and it stops with either
variable set. QS_DISABLE_FILE_WATCHER also keeps the failed reload from
happening at all.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:42:37 +02:00
David Heinemeier HanssonandClaude Opus 5 106320ab11 Install ChatGPT as a desktop app under Install > AI (#6747)
Drops the ChatGPT web app from the default set at the same time, so installing
the openai-codex-desktop package can't leave two identical-looking ChatGPT
entries in the launcher. Super + Shift + A still opens the web version, which
is the only place it was really used.

The bundled ChatGPT icon stays: the package's own chatgpt.desktop asks for
Icon=chatgpt and ships no hicolor icon of its own.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 15:56:40 +02:00
David Heinemeier HanssonandClaude Opus 5 970ec26bb0 Don't let a usbfs claim count as a kernel driver (#6744)
The vendor-ID guess rejects any device with a driver bound, on the
reasoning that libfprint drives readers from userspace so a real one sits
there unbound. But libusb claims interfaces through a synthetic usbfs
driver, so the reader binds one for as long as fprintd holds the claim —
which is exactly while it is being enrolled or verified against.

Readers that name themselves take the product-string branch and never
reach this, so the exposure is the ones that don't: Goodix 27c6:6594
reports "Goodix USB2.0 MISC", matches on vendor ID alone, and drops out
of detection mid-authentication. The menu entry disappears and the
first-run invitation stops firing while the reader is in use.

Ignore a driver link that resolves to usbfs, and keep rejecting the real
ones — usbio-bridge, usbhid, uvcvideo — including on a device that has
both.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:01:58 +02:00
a9c159a1f4 Restore FPC fingerprint detection in Quattro (#6737)
* Restore FPC fingerprint detection

* Anchor the FPC product match to a prefix

*fpc* is an unanchored three-letter token on the one branch that is
trusted outright, with none of the kernel-driver checking the vendor
guess gets. Every FPC reader on record leads with it — "FPC Sensor
Controller", "FPC Sensor Controller L:0002 FW:25.26.23.14", and this
branch's "FPC L:0000 FW:1425046" — so requiring the prefix costs no
coverage while keeping three letters from matching mid-string, where
FPC abbreviates unrelated things like flexible printed circuit.

Also restore the note about why Elan is kept out of the vendor list, so
the two signatures read as the same deliberate exception.

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

* Write no product descriptor for a two-field device spec

${remainder#*:} returns the string unchanged when there is no second
colon, so a spec meant to describe a device with no product descriptor
wrote the product id out as its product string instead. Every call site
happened to pass a trailing colon, so the suite was right by accident.

Guard the split and drop the trailing colons, so the two vendor-match
cases exercise the path they were written for.

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

* Bind a named driver in the kernel-driver fixture

Touching a bare `driver` file asserts that any driver at all disqualifies
a vendor guess, which is more than the detector should promise: libusb
claims an interface through a synthetic `usbfs` driver, so a reader in
active use looks bound by that rule.

Link the interface at a named driver directory instead, the way sysfs
does. The case still covers what it was written for — a Synaptics bridge
or a camera on a fingerprint vendor ID — without fixing the shape of the
answer for drivers it was never about.

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

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:59:01 +02:00
markbus-aiandmarkbus-ai 5c74f82300 Ask for the keyboard layout only when the answer can change (#6727)
The widget polled hyprctl every 10 seconds per monitor, including on the single-layout install where it never shows. Keep the poll only where its answer can change - a seat with more than one keyboard, where Hyprland moves the main flag with no event to announce it - and stop it entirely once a one-keyboard seat has been read.

Also coalesce a refresh that arrives mid-query instead of dropping it, time out a query that never returns rather than letting it hold the guard shut for good, and re-read the layout on configreloaded.

Co-Authored-By: markbus-ai <markbus-ai@users.noreply.github.com>
2026-08-12 13:28:44 +02:00
David Heinemeier Hansson 0fcdd716a1 Drop backgrounds no longer worthy 2026-08-12 12:18:57 +02:00
David Heinemeier Hansson 3da9eaf61c Add quattro background by vulturetone 2026-08-12 12:18:47 +02:00
David Heinemeier Hansson 21b610a7fa Need an extra CR for presentation 2026-08-12 11:58:25 +02:00
David Heinemeier HanssonandClaude Opus 5 9b8bf1da71 Fix two races in the notification popup and history handling (#6735)
* Replay the history a dismissal or a clear was still being written into

The popup files a replay reads are written by a serialized queue of shell
jobs, and the read ran as its own process alongside it. A dismissal issued a
moment earlier could still be queued when the directory was read, leaving the
notification out of the replay it was the newest entry of, and a clear issued
a moment earlier could still be queued too, replaying entries it was about to
remove.

The read now waits for the queue to go idle, so the replay shows the history
as of the moment it was asked for rather than whichever jobs happened to have
landed.

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

* Catch up on an update that arrived before its popup had a row

Watching a notification for in-place updates starts the moment it is handed
over, but the row those updates write to is inserted a tick later, deferred to
keep a mid-incubation Repeater from being mutated underneath. A client fast
enough to update inside that window found no row to write to, and a property
that has already changed does not change again — so the toast and its file sat
on the superseded content until something else moved.

The row is now refreshed from the live notification once it exists. That reads
the same object the signals would have, so an update that beat the insert is
picked up and one that did not costs nothing: a refresh whose content matches
the row it would write is dropped, which also collapses the several signals a
single multi-property update emits into one rewrite.

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

* Hold queued file work behind the replay's read, not just ahead of it

The read waited for everything queued before it, but nothing stopped the queue
from running on while it worked. A clear or an archive issued during the read
could delete or move files out from under awk mid-glob, so a replay could still
show a partial history — some of what a clear was in the middle of emptying.

The read is a barrier in both directions now: the queue holds until it exits,
and it releases on exit rather than on output, so a read that comes back empty
or fails cannot park the queue behind it.

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

* Queue the replay's read instead of waiting for the queue to empty

Waiting for the queue to go idle before starting the read still let work
overtake it. A clear or an archive enqueued after the replay was asked for,
while the current job was running, was dequeued the moment that job exited —
the read only starts once nothing is left — so the replay showed the state
after those jobs, which is the race this was meant to close. Unbroken file
traffic could postpone the read indefinitely for the same reason.

The read is now an entry in that queue rather than a process running beside
it. It takes its place in line behind the work queued before the request and
ahead of everything queued after, so no later job can overtake it and no
amount of traffic can push it back.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 11:57:05 +02:00
David Heinemeier HanssonandClaude Opus 5 fd23ca023a Prune the package cache before updating (#6734)
The pacman cache grows without bound across updates, and nothing in the
update flow ever reclaimed it. On a machine that has been updating for a
while it reaches several gigabytes of superseded versions that nothing
will ever install again.

Prune it with paccache -rk2 as the first step of an update. Both halves
of that placement are load-bearing.

Keeping two versions rather than one preserves the rollback path. The
cache is Arch's only offline downgrade: when an update breaks a single
package, reinstalling its predecessor from here is the surgical fix,
where a snapshot rollback would revert every other package too. Pruning
before the packages update means the installed version is still the
newest cached, so it survives along with a spare. Retention is by
version order and never consults what is installed, so that holds while
the installed version is among the two newest cached; a deliberate
downgrade or repeated failed transactions can stack newer archives on
top of it.

Running before the snapshot is what actually frees the space. The cache
sits on the snapshotted root subvolume, so a prune taken afterwards
leaves the fresh snapshot holding those extents and reclaims nothing
until it ages out of the number cleanup.

A failed prune warns and continues. Cache housekeeping should not trip
the update's ERR trap and tell the user their update went wrong.

This runs after omarchy-update-requires-free-space, so it reclaims space
during healthy updates but does not rescue a machine already under the
10 GiB gate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 11:56:02 +02:00
David Heinemeier HanssonandClaude Opus 5 cd84583b56 Show a notification the sender updated, instead of the version it replaced
A client that updates a notification through replaces_id does not produce a
second onNotification: Quickshell writes the new content onto the Notification
object the shell is already holding. The card draws a snapshot copied out of
that object — deliberately, since a live QObject in a ListModel role becomes a
dangling pointer the moment the server destroys it — so the toast kept showing
the superseded text, and archived it to history when it left the screen. A
Slack thread that updates in place read as stuck.

Every property the card draws is now watched on the notification we hold, and
a change rewrites both the model row and the file the popup was persisted
under. The file name is that popup's identity, so the rewrite lands in place:
a shell restart restores the version last shown, and so does the copy that
reaches history.

The countdown starts over when the content changes. New text arriving a second
before the toast was due to expire deserves a full look, not the remainder of
the clock the text it replaced had nearly run through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:58:14 -07:00
David Heinemeier HanssonandClaude Opus 5 ab57ad65fd Make notification history the last ten notifications on disk
History was a pair of in-memory lists mirrored into notifications.json, split
into "pending" and "past" by a seen/unseen distinction no surface exposed,
capped at 100, deduped by an id that repeats across server generations, and
pruned by a 15-minute TTL. Replaying it showed five rows drawn from whichever
list happened to hold them.

Every toast already writes a file under ~/.local/state/omarchy/notifications
so it can survive a shell restart. That file is now the history record: when
the popup leaves the screen it moves into notifications/history instead of
being deleted, the newest ten are kept, and showHistory replays exactly what
is in there, including the toasts still on screen when it is asked for. A
notification DND silenced is written straight into the same directory, since
a toast that never showed is the one worth looking back at.

That leaves the models, notifications.json history payload, past pruning, and
the /tmp image cache that existed to keep century-old history thumbnails alive
with nothing to do, so they go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:58:14 -07:00
David Heinemeier HanssonandClaude Opus 5 d6b21f8075 Launch the default coding agent without permission prompts (#6729)
Every supported agent spells it differently, so map each one to its own
bypass flag instead of leaving the launcher at each agent's default.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 08:56:03 +02:00
David Heinemeier HanssonandClaude Opus 5 2fa490dc96 Rebuild boot images the Plymouth migration left stale
1784917531 gated its UKI rebuild on initramfs_async=0 being present in
the Limine config, but omarchy-settings ships omarchy-defaults.conf with
that parameter already in it. Any machine that installed the package and
ran the migration in the same update matched the config the package had
just written, skipped the rebuild, and kept booting an image baked
before the config existed — without initramfs_async=0, so encrypted
boots still fell back to an unthemed text LUKS prompt. Compare the
booted command line against the configured one and rebuild when they
disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:35:40 -07:00
329 changed files with 15016 additions and 1661 deletions

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ matching guide before starting:
- [`agents/skills/command-metadata.md`](agents/skills/command-metadata.md) - adding or changing commands in `bin/`
- [`agents/skills/install-scripts.md`](agents/skills/install-scripts.md) - working under `install/` or on system/user setup commands
- [`agents/skills/shell-dev.md`](agents/skills/shell-dev.md) - editing the Quickshell desktop under `shell/`
- [`agents/skills/icon-font.md`](agents/skills/icon-font.md) - adding branded glyphs to `default/fonts/omarchy/omarchy.ttf`
- [`agents/skills/acceptance-tests.md`](agents/skills/acceptance-tests.md) - writing or running graphical acceptance tests under `test/acceptance.d/`
- [`agents/skills/visual-verification.md`](agents/skills/visual-verification.md) - verifying any change with a visual effect in the running UI
- [`docs/migrations.md`](docs/migrations.md) - creating or changing migrations under `migrations/`
+70
View File
@@ -4,6 +4,76 @@ Omarchy is a beautiful, modern & opinionated Linux distribution by DHH.
Read more at [omarchy.org](https://omarchy.org).
## The Omarchy Manual
The manual lives in [`manual/`](manual/), which is its authoritative source. It's
mirrored to [learn.omacom.io](https://learn.omacom.io/2/the-omarchy-manual), where
its screenshots are also hosted.
- [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md)
**The Basics**
- [Getting Started](manual/02-getting-started.md)
- [Coming From Mac or Windows](manual/03-coming-from-mac-or-windows.md)
- [Navigation](manual/04-navigation.md)
- [The top bar](manual/05-the-top-bar.md)
- [Themes](manual/06-themes.md)
- [Hotkeys](manual/07-hotkeys.md)
- [Unified Clipboard & History](manual/08-unified-clipboard-history.md)
- [Reminders](manual/09-reminders.md)
- [Notices](manual/10-notices.md)
- [Text Extraction & Dictation](manual/11-text-extraction-dictation.md)
- [Screenshots & Recording](manual/12-screenshots-recording.md)
- [Toggles, idle & screensaver](manual/13-toggles-idle-screensaver.md)
- [Omarchy CLI](manual/14-omarchy-cli.md)
**The Applications**
- [Terminal](manual/15-terminal.md)
- [Neovim](manual/16-neovim.md)
- [AI](manual/17-ai.md)
- [Development Tools](manual/18-development-tools.md)
- [Shell Tools](manual/19-shell-tools.md)
- [Shell Functions](manual/20-shell-functions.md)
- [TUIs](manual/21-tuis.md)
- [GUIs](manual/22-guis.md)
- [Browsers](manual/23-browsers.md)
- [Commercial apps/services](manual/24-commercial-apps-services.md)
- [Web Apps](manual/25-web-apps.md)
- [Gaming](manual/26-gaming.md)
- [Filling out PDFs](manual/27-filling-out-pdfs.md)
- [Windows VM](manual/28-windows-vm.md)
- [Other Packages](manual/29-other-packages.md)
**Configuration**
- [Updates](manual/30-updates.md)
- [Dotfiles](manual/31-dotfiles.md)
- [Shell plugins](manual/32-shell-plugins.md)
- [Monitors](manual/33-monitors.md)
- [Keyboard, Mouse, Trackpad](manual/34-keyboard-mouse-trackpad.md)
- [Networking](manual/35-networking.md)
- [System sleep](manual/36-system-sleep.md)
- [Hardware authentication](manual/37-hardware-authentication.md)
- [Fonts](manual/38-fonts.md)
- [Backgrounds](manual/39-backgrounds.md)
- [Prompt](manual/40-prompt.md)
- [Branding](manual/41-branding.md)
- [Common tweaks](manual/42-common-tweaks.md)
- [Making your own theme](manual/43-making-your-own-theme.md)
**The Rest**
- [Mac support](manual/44-mac-support.md)
- [Troubleshooting](manual/45-troubleshooting.md)
- [FAQ](manual/46-faq.md)
- [System snapshots](manual/47-system-snapshots.md)
- [Security](manual/48-security.md)
- [Omarchy on...](manual/49-omarchy-on.md)
- [Dual Boot Install](manual/50-dual-boot-install.md)
- [Unattended Installs](manual/51-unattended-installs.md)
## License
Omarchy is released under the [MIT License](https://opensource.org/licenses/MIT).
+79
View File
@@ -0,0 +1,79 @@
# Omarchy Icon Font
Read this before adding a branded glyph to `default/fonts/omarchy/omarchy.ttf`.
The Omarchy icon font is a small private-use font carrying the marks Nerd
Fonts does not have: the Omarchy logo and the agent and app brand marks. The
menu draws one by naming the font on an entry:
```jsonc
"setup.default.agent.grok": {"icon":"","iconFont":"omarchy","label":"Grok", ...}
```
Without `iconFont`, an entry's `icon` is drawn in the menu font, so reach for
this font only when a Nerd Font glyph would misrepresent the thing. A generic
robot for four different AI apps is the case that justifies a real mark; a
folder or a microphone is not.
`default/fonts/omarchy/README.md` lists every glyph with the URL its artwork
came from. Keep that list accurate — it is the only record of provenance.
## Adding a glyph
`omarchy dev font` does the work:
```bash
omarchy dev font list
omarchy dev font add ollama https://simpleicons.org/icons/ollama.svg
```
`add` fetches the SVG, scales it into the same 64..960 box the existing marks
use so it lands at their optical size, appends it at the next free private-use
codepoint, and adds a line to the font README. It prints the codepoint and the
glyph itself.
The source must be a **monochrome SVG with a single `<path>`**, because the
menu recolors the glyph with the active theme's foreground and selection
colors. Brand icon sets such as <https://simpleicons.org> publish exactly that
shape. App favicons usually do not: they are multi-color, carry a container
tile, or split across several paths. Prefer the official mark when it is
published as flat monochrome art, and fall back to an icon set's redraw when it
is not.
Two-tone marks are a trap. Every path becomes solid foreground, so a logo whose
meaning depends on lighter and darker halves turns into an unreadable blob.
Pick a source whose silhouette alone reads.
## After adding
The command prints these, and all of them matter:
- Point the menu entry at the new codepoint with `"iconFont":"omarchy"`.
- Bump the charset range asserted in `test/shell.d/menu-test.sh`; that test
pins the font's coverage and fails until it matches.
- Check the README line the command added, and give it a proper display name
with `--label` if the glyph name is not the brand's name.
The font is package-owned: `omarchy-settings` installs it to
`/usr/share/fonts/omarchy/omarchy.ttf`, so a new glyph reaches the desktop
through a settings release, not through `omarchy update`. Between the merge and
that release, a pulled checkout renders the new entry with no icon.
## Verifying
Render the whole font and look at it, which catches inverted contours and
filled counters that a glyph list cannot show:
```bash
python3 -c "open('/tmp/row.txt','w').write(' '.join(chr(c) for c in range(0xE900, 0xE910)))"
magick -background white -fill black -font default/fonts/omarchy/omarchy.ttf \
-pointsize 110 label:@/tmp/row.txt /tmp/font-row.png
```
Then confirm it in the running menu per
[`visual-verification.md`](visual-verification.md). Fontconfig prefers the
packaged font over a copy in `~/.local/share/fonts` for the same family, so a
preview needs either the real file replaced or a `<rejectfont>` rule in
`~/.config/fontconfig/conf.d/` pointing fontconfig away from the packaged one.
Restart the shell afterwards — Qt reads the font database at startup, so
`omarchy menu refresh` alone will not pick up a changed font.
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=ChatGPT
Exec=omarchy-launch-webapp https://chatgpt.com/
Terminal=false
Type=Application
Icon=chatgpt
StartupNotify=true
+1 -1
View File
@@ -1,7 +1,7 @@
[Desktop Entry]
Version=1.0
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
Type=Application
Icon=docker
+29 -6
View File
@@ -78,7 +78,6 @@ GROUP_DESCRIPTIONS[setup]="Interactive setup wizards"
GROUP_DESCRIPTIONS[shell]="Omarchy shell IPC helpers"
GROUP_DESCRIPTIONS[screensaver]="Screensaver branding and animation"
GROUP_DESCRIPTIONS[snapshot]="System snapshots"
GROUP_DESCRIPTIONS[style]="Global UI style controls"
GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers"
GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock"
GROUP_DESCRIPTIONS[tailscale]="Tailscale helpers"
@@ -86,7 +85,6 @@ GROUP_DESCRIPTIONS[theme]="Theme management"
GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features"
GROUP_DESCRIPTIONS[transcode]="Image and video transcoding"
GROUP_DESCRIPTIONS[tui]="Terminal UI launchers"
GROUP_DESCRIPTIONS[tz]="Timezone selection"
GROUP_DESCRIPTIONS[update]="Omarchy and system updates"
GROUP_DESCRIPTIONS[version]="Version and channel information"
GROUP_DESCRIPTIONS[voxtype]="Voxtype dictation"
@@ -122,6 +120,31 @@ append_pipe_value() {
fi
}
# A route can resolve to a binary before all args are consumed (`update aur` only matches the `update` binary, leaving `aur` and `--help` as leftovers)
# --help must never be lost among those leftovers and get forwarded into the real command, so this checks the whole remainder rather than just the first token.
# A `--` marks everything after it as belonging to the command itself, so scanning stops there.
remaining_has_help_flag() {
local token=""
for token in "$@"; do
[[ $token == "--" ]] && break
[[ $token == "--help" || $token == "-h" ]] && return 0
done
return 1
}
remaining_has_json_flag() {
local token=""
for token in "$@"; do
[[ $token == "--" ]] && break
[[ $token == "--json" ]] && return 0
done
return 1
}
register_route() {
local route="$1"
local key="$2"
@@ -930,7 +953,7 @@ dispatch_fast_or_help() {
remaining=("${args[@]:DIRECT_RESOLVED_COUNT}")
binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY"
if (( ${#remaining[@]} > 0 )) && [[ ${remaining[0]} == "--help" || ${remaining[0]} == "-h" ]]; then
if remaining_has_help_flag "${remaining[@]}"; then
if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then
echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2
return 127
@@ -941,7 +964,7 @@ dispatch_fast_or_help() {
fi
key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}"
if [[ " ${remaining[*]} " == *" --json "* ]]; then
if remaining_has_json_flag "${remaining[@]}"; then
show_command_json "$key"
else
show_command_help "$key"
@@ -994,8 +1017,8 @@ dispatch_or_help() {
key="$RESOLVED_KEY"
remaining=("${args[@]:RESOLVED_COUNT}")
if (( ${#remaining[@]} > 0 )) && [[ ${remaining[0]} == "--help" || ${remaining[0]} == "-h" ]]; then
if [[ " ${remaining[*]} " == *" --json "* ]]; then
if remaining_has_help_flag "${remaining[@]}"; then
if remaining_has_json_flag "${remaining[@]}"; then
show_command_json "$key"
else
show_command_help "$key"
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# omarchy:summary=Launch the default coding agent in a terminal
# omarchy:args=[--inline] [--pick]
# omarchy:examples=omarchy agent | omarchy agent --inline
inline=false
pick=false
# Flags only. A bare prompt would shadow the subcommands under this group, so
# prompts go through omarchy-agent-prompt, which passes one here as --prompt.
while (($#)); do
case "$1" in
--inline)
inline=true
shift
;;
--pick)
pick=true
shift
;;
--prompt)
prompt=${2:?--prompt needs a value}
shift 2
;;
*)
echo "Unexpected argument: $1" >&2
echo "To pass a prompt: omarchy agent prompt \"$*\"" >&2
exit 1
;;
esac
done
# Agents refuse to remember trust for $HOME, so launches from the keybinding or
# menu start in the work directory instead of re-asking on every session.
[[ $PWD == "$HOME" && -d $HOME/Work ]] && cd "$HOME/Work"
agent=$(omarchy-default-agent)
# Omarchy ships without a default, so there is nothing to launch until one is
# picked. --pick offers the choice instead of the error, which is what the
# keybinding wants: a keypress that opens nothing explains nothing.
if [[ -z $agent ]]; then
[[ $pick == "true" ]] && exec omarchy-menu summon setup.default.agent
echo "Choose default agent with: omarchy default agent <name>" >&2
exit 1
fi
if omarchy-cmd-missing "$agent"; then
echo "$agent is not installed. Choose an installed agent with: omarchy default agent <name>" >&2
exit 1
fi
# Agents launched from the keybinding or menu run unattended, so each one starts
# with its own spelling of "don't stop to ask". Pi has no approval prompts.
case "$agent" in
opencode)
command=(opencode --auto)
[[ -n ${prompt:-} ]] && command+=(--prompt "$prompt")
;;
gemini)
command=(gemini --yolo)
[[ -n ${prompt:-} ]] && command+=(--prompt-interactive "$prompt")
;;
copilot)
command=(copilot --allow-all)
[[ -n ${prompt:-} ]] && command+=(--interactive "$prompt")
;;
crush)
# --yolo belongs to the interactive command only; `crush run` never prompts.
if [[ -n ${prompt:-} ]]; then
command=(crush run "$prompt")
else
command=(crush --yolo)
fi
;;
claude)
command=(claude --permission-mode auto)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
grok)
command=(grok --permission-mode bypassPermissions)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
codex)
command=(codex --approve-for-me)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
omp)
command=(omp --auto-approve)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
pi)
command=(pi)
[[ -n ${prompt:-} ]] && command+=("$prompt")
;;
*)
echo "Unsupported default agent: $agent" >&2
exit 1
;;
esac
if [[ $inline == "true" ]]; then
exec "${command[@]}"
else
# A fixed app-id rather than the default org.omarchy.<binary>, so every agent
# window shares one class for window rules and themes to single out.
exec omarchy-launch-tui --app-id=org.omarchy.agent "${command[@]}"
fi
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# omarchy:summary=Diagnose a crashed process with the default coding agent
# omarchy:args=<pid> [comm] [exe] [signal]
# omarchy:examples=omarchy agent crash 1516893
# Clicked from a "Process crashed:" notification, or run by hand against any PID
# in `coredumpctl list`. The method lives in the diagnose-crash skill so it is
# edited in one place and works with whichever agent is default; this only
# gathers the facts and points at it.
set -euo pipefail
pid=${1:?usage: omarchy-agent-crash <pid> [comm] [exe] [signal]}
if [[ ! $pid =~ ^[0-9]+$ ]]; then
echo "Not a PID: $pid" >&2
echo "Usage: omarchy agent crash <pid> (see: coredumpctl list)" >&2
exit 1
fi
comm=${2:-unknown}
exe=${3:-unknown}
signal=${4:-unknown}
skill="$OMARCHY_PATH/default/agents/skills/diagnose-crash/SKILL.md"
# Looked up live so a hand-run PID still gets a timestamp. A rotated-away core
# only costs the timestamp, so failure is tolerated.
when=$(coredumpctl list "$pid" --no-pager --no-legend 2>/dev/null | tail -1 | cut -d' ' -f1-4) || true
when=${when:-unknown}
prompt=$(
cat <<PROMPT
A process crashed on this Omarchy machine and I want to know why.
What systemd-coredump recorded:
process: $comm
PID: $pid
binary: $exe
signal: $signal
time: $when
Use the diagnose-crash skill: it covers how to investigate, what to report, and
when a crash is worth reporting upstream to Omarchy. If your harness has no skill
mechanism, read the skill files directly and follow them instead:
$skill
PROMPT
)
exec omarchy-agent --prompt "$prompt"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# omarchy:summary=Launch the default coding agent with a prompt
# omarchy:args=[--inline] <prompt...>
# omarchy:examples=omarchy agent prompt "Review this project"
# Prompts live here rather than on `omarchy agent`, where a bare prompt would
# shadow the subcommands under that group.
set -euo pipefail
inline=()
if [[ ${1:-} == "--inline" ]]; then
inline=(--inline)
shift
fi
if (($# == 0)); then
echo "Usage: omarchy agent prompt [--inline] <prompt...>" >&2
exit 1
fi
exec omarchy-agent "${inline[@]}" --prompt "$*"
+54 -11
View File
@@ -765,24 +765,67 @@ def probe_limits(access_token: str) -> dict[str, Any]:
return {"ok": True, "limits": limits}
# A cached percentage outlives the probe that measured it, but only until its
# window rolls over: once a window has reset, the figure describes a period
# that is over, and a stale 78% would misreport an allowance that is now
# untouched. A window with no reset time, or one that will not parse, is kept
# — an unreadable timestamp is no reason to throw away a real number.
def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool:
raw = str(entry.get("resetsAt") or "")
if raw == "":
return True
try:
resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception:
return True
if resets_at.tzinfo is None:
resets_at = resets_at.replace(tzinfo=dt.timezone.utc)
return resets_at > now
def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]:
entries = cached.get("limits")
if not isinstance(entries, list):
return []
now = dt.datetime.now(dt.timezone.utc)
return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)]
def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[str, Any]:
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP}
if access_token == "":
result["usageStatusText"] = "Waiting for auth"
return result
if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000:
return result
# A panel that is opened and shut repeatedly must not turn into a request
# per flick, so recent probe results are reused for a short window — and
# kept as the answer of record when a later probe fails.
probe_cache = cache_root() / "claude-limits.json"
cached = read_fresh_json(probe_cache, float("inf")) or {}
fallback = usable_cached_limits(cached)
# Probing needs a live token and only the Claude Code CLI can mint one: it
# refreshes the credential file when it runs, so a machine left alone long
# enough finds the saved token lapsed. Say so — an empty limits list with
# nothing else set hides the whole section and explains nothing — and keep
# showing the last numbers whose window has not since reset.
if access_token == "":
result["limits"] = fallback
result["usageStatusText"] = "Waiting for auth"
return result
if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000:
result["limits"] = fallback
result["usageStatusText"] = "Sign-in expired"
result["authHelpText"] = (
"Claude Code's saved sign-in expired"
+ (" — showing the last known limits." if fallback else ".")
+ " Start Claude Code, or run `claude auth login`, to refresh it."
)
return result
# --force is a person asking for fresh numbers, so it skips the reuse window
# entirely; the interval is there to absorb repeated panel opens, not to
# overrule someone who pressed refresh.
fetched_at = number(cached.get("fetchedAtMs")) / 1000
min_interval = 0 if force else PROBE_MIN_INTERVAL_SECONDS
if cached.get("limits") and time.time() - fetched_at < max(min_interval, PROBE_MIN_INTERVAL_SECONDS):
result["limits"] = cached["limits"]
if fallback and not force and time.time() - fetched_at < PROBE_MIN_INTERVAL_SECONDS:
result["limits"] = fallback
return result
probe = probe_limits(access_token)
@@ -795,8 +838,8 @@ def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[s
# route. Ask the shell to try again sooner than its regular interval.
if probe.get("transport"):
result["retryAdvised"] = True
if cached.get("limits"):
result["limits"] = cached["limits"]
if fallback:
result["limits"] = fallback
else:
result["usageStatusText"] = "Claude limits unavailable"
result["authHelpText"] = probe["helpText"]
+196 -24
View File
@@ -11,12 +11,16 @@ panel only ever reads the JSON this prints.
"""
import argparse
import fcntl
import hashlib
import json
import os
import select
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -25,6 +29,14 @@ AGENT_ID = "codex"
AGENT_NAME = "Codex"
AUTH_HELP = "Run `codex login` to authenticate."
# A scan this recent is only reused to dedup concurrent collector runs (the
# update command backgrounds one per agent while the panel refreshes on its
# own); every periodic widget refresh lands a real rescan, however low
# refreshIntervalSec is set. --limits-only promises only fresh limits, so it
# may reuse a scan for up to 15 minutes.
SCAN_REUSE_SECONDS = 20
LIMITS_ONLY_REUSE_SECONDS = 900
def local_day(value):
if value is None:
@@ -197,16 +209,47 @@ def scan_opencode_sessions():
# A subscription burned entirely through opencode leaves no native session
# files, but opencode records per-message provider, model, and token usage
# in its own database. Read-only: opencode may be writing right now.
# Returns whether the scan ran to completion: a scan cut short by a
# database error still contributes what it read, but must not be cached
# as if it were the whole story.
db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db"
if not db.is_file():
return
return True
try:
conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2)
except sqlite3.Error:
return
return False
try:
conn.execute("PRAGMA query_only = ON")
for session_id, raw in conn.execute("SELECT session_id, data FROM message"):
# OpenCode DBs grow huge (every historical message, JSON included), and
# Python-side json.loads of every row wasted ~600 MB of RSS on machines
# whose subscription never ran on OpenAI. The json_extract conditions are
# the authority for the rows that reach them: role == "assistant" and
# providerID == "openai", the same exact-match values the Python filter
# below checks. The guards in front are pure acceleration, not a perfect
# proxy for the Python filter:
# - The LIKE gates skip rows whose JSON cannot contain the two
# key/value pairs, avoiding the JSON parse of tens-of-MB blobs. They
# can differ from json.loads on duplicate keys (Python keeps the
# last, SQLite json_extract keeps the first) and ASCII-escaped
# values (\"ass\\u0069stant\" decodes for Python but not for LIKE),
# so a row the authority would accept can be gated out. Both cases
# are vanishingly rare in real opencode data.
# - json_valid guards the parse itself: json_extract() RAISES on
# malformed JSON instead of returning NULL, and one such row would
# otherwise abort the whole scan. SQLite does not promise that AND
# terms evaluate left to right, so the guard is a CASE around each
# json_extract rather than a separate AND term. Rows that are not
# well-formed JSON are skipped here; the per-row try/except below
# stays as the final safety net for rows that pass the SQL filter
# but fail json.loads.
for session_id, raw in conn.execute(
"SELECT session_id, data FROM message"
" WHERE data LIKE '%\"role\"%:%\"assistant\"%'"
" AND data LIKE '%\"providerID\"%:%\"openai\"%'"
" AND CASE WHEN json_valid(data) THEN json_extract(data, '$.role') END = 'assistant'"
" AND CASE WHEN json_valid(data) THEN json_extract(data, '$.providerID') END = 'openai'"
):
# One malformed row must not abort the scan, so every shape assumption
# lives inside the try.
try:
@@ -232,9 +275,12 @@ def scan_opencode_sessions():
continue
add_usage(day, "opencode:" + str(session_id), model, input_tokens, output_tokens, cache_read, cache_write)
except sqlite3.Error:
pass
# Transient lock, schema migration, corruption: the numbers stop here,
# incomplete.
return False
finally:
conn.close()
return True
def scan_native_codex_sessions():
@@ -291,6 +337,143 @@ def scan_native_codex_sessions():
continue
def cache_root():
root = Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "omarchy" / "agent-usage"
root.mkdir(parents=True, exist_ok=True)
return root
def scan_cache_paths():
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db"
# The digest covers every data path the scan reads: the codex session
# roots, the opencode DB, and (via Path.home()) the pi/omp session roots.
digest = hashlib.sha1((str(Path.home()) + "\n" + str(codex_home) + "\n" + str(db)).encode("utf-8")).hexdigest()[:16]
root = cache_root()
return root / f"codex-scan-{digest}.json", root / f"codex-scan-{digest}.lock"
def read_fresh_json(path, max_age_seconds):
if max_age_seconds <= 0 or not path.exists():
return None
try:
# A negative age means the mtime is in the future: the clock moved
# backwards since the write, so the cache's freshness cannot be trusted.
age = time.time() - path.stat().st_mtime
if 0 <= age <= max_age_seconds:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return None
def write_json(path, payload):
# A temp name unique to this writer, not derived from the target: several
# collectors can run at once (the update command backgrounds one per agent,
# the panel refreshes on its own), and a shared temp path means the second
# replace finds the first one's file already moved away.
handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(handle_fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(payload, separators=(",", ":")) + "\n")
# mkstemp opens at 0600; nothing in the cache is sensitive, so open it
# up to the usual 0644.
tmp.chmod(0o644)
tmp.replace(path)
except BaseException:
tmp.unlink(missing_ok=True)
raise
# The cache payload is a versioned envelope around the local-stats dict, so a
# corrupted or foreign-shaped file is a cache miss (rescan + rewrite) instead
# of a crash or a garbage record.
def read_cached_stats(cache_file, max_age_seconds):
cached = read_fresh_json(cache_file, max_age_seconds)
if not isinstance(cached, dict) or cached.get("schemaVersion") != 1:
return None
# today* fields only mean "today" on the day they were scanned. A cache
# from another local date (midnight passed, or the clock moved) is a miss,
# not merely old, whatever its mtime says.
if cached.get("scanDate") != today:
return None
stats = cached.get("stats")
if not isinstance(stats, dict):
return None
if not all(key in stats for key in ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage")):
return None
return stats
def write_cached_stats(cache_file, stats):
try:
write_json(cache_file, {"schemaVersion": 1, "scanDate": today, "stats": stats})
except Exception as exc:
print(f"omarchy-agent-usage-codex: could not write usage cache ({exc})", file=sys.stderr)
def local_stats():
"""Snapshot the aggregated local usage into the record's stats dict."""
return {
"todayPrompts": today_prompts,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_total_tokens,
"todayTokensByModel": today_tokens_by_model,
"recentDays": [recent[day] for day in recent_dates],
"totalPrompts": total_prompts,
"totalSessions": len(total_sessions),
# Days with any recorded usage, for the all-time "N days" summary. The
# dates travel too: merging snapshots from several machines needs their
# union, which a count alone cannot give.
"activeDays": len(active_days),
"activeDates": sorted(active_days),
"modelUsage": model_usage,
}
def run_local_scans():
scan_pi_sessions()
scan_native_codex_sessions()
complete = scan_opencode_sessions()
return local_stats(), complete
def cached_local_stats(max_age):
"""Local stats, with the cache as a pure optimization.
The cache must never take the collector down: any cache-layer failure
(unwritable cache root, lock errors, disk full) degrades to a direct scan
and a warning on stderr. The JSON record is the contract; the cache is not.
"""
try:
return _cached_local_stats(max_age)
except Exception as exc:
print(f"omarchy-agent-usage-codex: cache unavailable ({exc}); scanning directly", file=sys.stderr)
stats, _ = run_local_scans()
return stats
def _cached_local_stats(max_age):
cache_file, lock_file = scan_cache_paths()
cached = read_cached_stats(cache_file, max_age)
if cached is not None:
return cached
with lock_file.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
cached = read_cached_stats(cache_file, max_age)
if cached is not None:
return cached
stats, complete = run_local_scans()
# An interrupted scan still serves this run, but caching it would
# suppress the missing usage for every reader until the cache expires.
if complete:
write_cached_stats(cache_file, stats)
return stats
def rpc_request(proc, request_id, method, params=None, timeout=8):
payload = {"id": request_id, "method": method, "params": params or {}}
proc.stdin.write(json.dumps(payload) + "\n")
@@ -390,16 +573,17 @@ def fetch_codex_rpc():
def main():
parser = argparse.ArgumentParser()
# Local stats and limits come from the same cheap scan, so there is no
# cache to force past and no faster limits-only path. The flags exist so
# every collector accepts the same invocation.
# --force rescans everything and rewrites the cache. --limits-only is kept
# for CLI compatibility with the panel's refreshLimits() call: only the
# limits probe must be fresh, so it may reuse a scan for far longer than a
# normal run, whose short window exists purely to dedup concurrent
# collector runs.
parser.add_argument("--force", action="store_true")
parser.add_argument("--limits-only", action="store_true")
parser.parse_args()
args = parser.parse_args()
scan_pi_sessions()
scan_native_codex_sessions()
scan_opencode_sessions()
max_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS)
stats = cached_local_stats(max_age)
rpc = fetch_codex_rpc()
record = {
@@ -409,20 +593,8 @@ def main():
"updatedAt": datetime.now(timezone.utc).isoformat(),
"ready": True,
"hasLocalStats": True,
"todayPrompts": today_prompts,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_total_tokens,
"todayTokensByModel": today_tokens_by_model,
"recentDays": [recent[day] for day in recent_dates],
"totalPrompts": total_prompts,
"totalSessions": len(total_sessions),
# Days with any recorded usage, for the all-time "N days" summary. The
# dates travel too: merging snapshots from several machines needs their
# union, which a count alone cannot give.
"activeDays": len(active_days),
"activeDates": sorted(active_days),
"modelUsage": model_usage,
}
record.update(stats)
record.update(rpc)
print(json.dumps(record, separators=(",", ":")))
+2 -2
View File
@@ -64,7 +64,7 @@ start_webcam_overlay() {
# Auto-detect first available webcam if none specified
if [[ -z $WEBCAM_DEVICE ]]; then
WEBCAM_DEVICE=$(v4l2-ctl --list-devices 2>/dev/null | grep -m1 "^[[:space:]]*/dev/video" | tr -d '\t')
WEBCAM_DEVICE=$(omarchy-capture-webcam-list | sed -n '1s/[[:space:]].*//p')
if [[ -z $WEBCAM_DEVICE ]]; then
omarchy-notification-send -u critical -t 3000 "No webcam devices found"
return 1
@@ -228,7 +228,7 @@ stop_screenrecording() {
omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \
-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
# re-reads the file, so the preview only has to outlive that load -- not the
@@ -3,21 +3,7 @@
# omarchy:summary=Pick a webcam and start a screen recording with it
# omarchy:examples=omarchy capture screenrecording-with-webcam
set -o pipefail
webcam_list() {
v4l2-ctl --list-devices 2>/dev/null | while IFS= read -r line; do
if [[ $line != $'\t'* && -n $line ]]; then
local name="$line"
local device
IFS= read -r device || break
device=$(tr -d '\t' <<<"$device" | head -1)
[[ -n $device ]] && echo "$device $name"
fi
done
}
mapfile -t devices < <(webcam_list)
mapfile -t devices < <(omarchy-capture-webcam-list)
if (( ${#devices[@]} == 0 )); then
omarchy-notification-send "No webcam devices found" -u critical -t 3000
exit 1
+1 -1
View File
@@ -70,7 +70,7 @@ case "$PROCESSING" in
# 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)" \
--image "$FILEPATH" \
--exec "$(printf '%q %q' "$SCREENSHOT_EDITOR" "$FILEPATH")" || true
--exec "$SCREENSHOT_EDITOR" "$FILEPATH" || true
;;
copy)
grim -g "$SELECTION" - | wl-copy --type image/png
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# omarchy:summary=List webcam devices that support video capture
# omarchy:hidden=true
capture_capable() {
local device="$1"
v4l2-ctl --device "$device" --info 2>/dev/null | awk '
/^[[:space:]]*Device Caps[[:space:]]*:/ { inspect = 1; next }
inspect && /^[[:space:]]*Video Capture/ { found = 1 }
END { exit !found }
'
}
name=""
emitted=0
while IFS= read -r line; do
if [[ -n $line && $line != [[:space:]]* ]]; then
name="$line"
emitted=0
elif (( ! emitted )); then
device="${line#"${line%%[![:space:]]*}"}"
if [[ $device == /dev/video* ]] && capture_capable "$device"; then
emitted=1
printf '%s %s\n' "$device" "$name"
fi
fi
done < <(v4l2-ctl --list-devices 2>/dev/null)
exit 0
+67 -13
View File
@@ -25,6 +25,51 @@ valid_url() {
[[ $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
# volume/brightness bar), so download progress never stacks like notifications.
osd_progress() {
@@ -41,16 +86,19 @@ download_url() {
mkdir -p "$DOWNLOAD_DIR"
# 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"
exit 0
fi
osd_progress 0
# Stream the download: OMARCHY_PROG carries the percent (drives the OSD),
# OMARCHY_FILE (printed only after a successful move) carries title + path.
local line rest pct intpct last="" er nowms lastms=0 title="" filepath=""
# Stream the download: OMARCHY_PROG carries the percent (drives the OSD), and
# OMARCHY_FILE and OMARCHY_TITLE (printed only after a successful move) carry the
# 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
case $line in
OMARCHY_PROG*)
@@ -67,22 +115,26 @@ download_url() {
osd_progress "$intpct"
;;
OMARCHY_FILE*)
rest=${line#OMARCHY_FILE$'\t'}
title=${rest%%$'\t'*}
filepath=${rest#*$'\t'}
resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue
filepath=$resolved
;;
OMARCHY_TITLE*)
title=$(decode_title "${line#OMARCHY_TITLE$'\t'}") || title=""
;;
esac
done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --restrict-filenames --no-simulate \
--quiet --no-warnings --progress --newline \
done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --no-simulate \
--quiet --no-warnings --no-exec --no-exec-before-download --progress --newline \
--progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \
--paths "$DOWNLOAD_DIR" -o '%(title)s [%(id)s].%(ext)s' \
--print $'after_move:OMARCHY_FILE\t%(title)s\t%(filepath)s' \
"$url" 2>&1)
--paths "$DOWNLOAD_DIR" -o '%(title)s.%(ext)s' \
--print $'after_move:OMARCHY_FILE\t%(filepath)s' \
--print $'after_move:OMARCHY_TITLE\t%(title)j' \
-- "$url" 2>&1)
osd_close
# after_move only prints on a successful download+move, so a captured path == success.
if [[ -n $filepath ]]; then
[[ -n $title ]] || title=$(title_from_file "$filepath")
((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact
# 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
# 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" \
-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
# re-reads the file, so the preview only has to outlive that load, not the
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# omarchy:summary=Watch for process crashes and offer an AI diagnosis
# omarchy:hidden=true
# systemd-coredump journals every core dump under a known MESSAGE_ID with
# structured COREDUMP_* fields, which carry more than the core filenames do.
set -uo pipefail
# See systemd.journal-fields(7).
readonly COREDUMP_MESSAGE_ID=fc2e22bc6ee647b6b90729ab34a250b1
# nf-md-robot_dead, escaped so this file reads without a Nerd Font.
readonly CRASH_GLYPH=$'\U000f16a1'
# Crash loops dump core repeatedly, so announce each program at most once a
# window.
readonly dedupe_seconds=${OMARCHY_CRASH_DEDUPE_SECONDS:-60}
# Extended regex of process names never worth announcing.
readonly ignore_pattern=${OMARCHY_CRASH_IGNORE:-}
declare -A last_notified
announce() {
local comm=$1 pid=$2 exe=$3 signal=$4
# 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.
# Wait for the restarted shell to claim the name again: the crash least
# likely to be delivered is the one most worth reporting.
omarchy-notification-wait || return 1
# --exec rather than a libnotify action: the shell runs clicks from its own
# hint and never emits ActionInvoked. Keeps the default "omarchy-action" app
# 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 \
--urgency critical \
--glyph "$CRASH_GLYPH" \
"Process crashed: $comm" \
"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.
journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null |
while IFS= read -r entry; do
IFS=$'\t' read -r uid comm pid exe signal < <(
jq -r '[(._UID // "-"),
(.COREDUMP_COMM // "-"),
(.COREDUMP_PID // "-"),
(.COREDUMP_EXE // "-"),
(.COREDUMP_SIGNAL_NAME // "-")] | @tsv' <<<"$entry" 2>/dev/null
)
[[ $pid =~ ^[0-9]+$ ]] || continue
# The toast only offers a diagnosis, so it has nothing to offer until an
# agent is chosen. Checked per crash, not at startup, so picking one takes
# effect without restarting this service.
[[ -n $(omarchy-default-agent) ]] || continue
# Only this user's crashes; a daemon dumping core is a sysadmin's problem.
[[ $uid =~ ^[0-9]+$ ]] || continue
((uid == UID)) || continue
# comm is truncated to 15 characters, so prefer the executable's basename.
name=$comm
[[ $exe == /* ]] && name=${exe##*/}
[[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue
# Never announce our own machinery, or it notifies about itself.
[[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue
now=$EPOCHSECONDS
(((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue
# Only a delivered toast starts the dedupe window. A failed send that
# counted would suppress the rest of a crash loop for a minute, and
# `journalctl -n 0` never replays what was missed.
announce "$name" "$pid" "$exe" "$signal" && last_notified[$name]=$now
done
+5 -3
View File
@@ -17,7 +17,9 @@ if (($# == 0)); then
read -r agent <"$agent_file"
fi
[[ -n $agent ]] && echo "$agent" || echo "opencode"
# Silent when unset rather than defaulting: Omarchy picks no agent for you, so
# the menu leaves every entry unchecked until one is chosen.
[[ -n ${agent:-} ]] && echo "$agent"
exit 0
fi
@@ -57,7 +59,7 @@ printf '%s\n' "$agent" >"$agent_file"
if [[ $installing == "true" ]]; then
printf '\033[2J\033[3J\033[H'
exec omarchy-launch-agent --inline
exec omarchy-agent --inline
else
exec omarchy-launch-agent
exec omarchy-agent
fi
+652
View File
@@ -0,0 +1,652 @@
#!/usr/bin/python3
# omarchy:summary=Add branded glyphs to the Omarchy icon font
# omarchy:args=[list|add] <name> <svg-file-or-url> [--codepoint U+E9xx] [--font PATH]
# omarchy:examples=omarchy dev font list | omarchy dev font add ollama https://simpleicons.org/icons/ollama.svg
"""Append monochrome SVG marks to default/fonts/omarchy/omarchy.ttf.
The font is a private-use icon font: the menu renders a mark by setting
"iconFont":"omarchy" on an entry and using the glyph's codepoint as its
icon. Adding a mark means appending a glyph here, then pointing a menu
entry at the codepoint this prints.
Marks must be monochrome single-path SVGs so the menu can draw them in the
active theme's foreground and selection colors. Brand icon sets like
simpleicons.org publish exactly that shape; app favicons often do not.
The glyph is scaled into the same 64..960 box the existing marks use, so a
new mark lands at the same optical size as the ones already in the font.
"""
import argparse
import math
import os
import re
import struct
import sys
import urllib.request
UPEM = 1024
ART_BOX = (64, 64, 960, 960) # the box every existing mark is drawn in
TOL = 0.6 # cubic -> quadratic error tolerance, in font units
PUA_FIRST = 0xE900
# --- SVG path parsing ------------------------------------------------------
NUM = re.compile(r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?')
CMD = re.compile(r'[MmZzLlHhVvCcSsQqTtAa]')
def tokenize(d):
out, i = [], 0
while i < len(d):
c = d[i]
if CMD.match(c):
out.append(c)
i += 1
elif c in ' ,\t\r\n':
i += 1
else:
m = NUM.match(d, i)
if not m:
raise ValueError('bad path data at %d: %r' % (i, d[i:i + 20]))
out.append(float(m.group()))
i = m.end()
return out
def arc_to_cubics(p0, rx, ry, phi, large, sweep, p1):
"""Endpoint-parameterized SVG arc -> cubic segments."""
if p0 == p1:
return []
rx, ry = abs(rx), abs(ry)
if rx == 0 or ry == 0:
return [('L', p1)]
phi = math.radians(phi % 360)
cosp, sinp = math.cos(phi), math.sin(phi)
dx2, dy2 = (p0[0] - p1[0]) / 2.0, (p0[1] - p1[1]) / 2.0
x1 = cosp * dx2 + sinp * dy2
y1 = -sinp * dx2 + cosp * dy2
lam = x1 * x1 / (rx * rx) + y1 * y1 / (ry * ry)
if lam > 1:
s = math.sqrt(lam)
rx, ry = rx * s, ry * s
num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1
den = rx * rx * y1 * y1 + ry * ry * x1 * x1
co = math.sqrt(max(0.0, num / den)) if den else 0.0
if large == sweep:
co = -co
cx1 = co * rx * y1 / ry
cy1 = -co * ry * x1 / rx
cx = cosp * cx1 - sinp * cy1 + (p0[0] + p1[0]) / 2.0
cy = sinp * cx1 + cosp * cy1 + (p0[1] + p1[1]) / 2.0
def angle(ux, uy, vx, vy):
dot = ux * vx + uy * vy
n = math.hypot(ux, uy) * math.hypot(vx, vy)
a = math.acos(max(-1.0, min(1.0, dot / n))) if n else 0.0
return -a if ux * vy - uy * vx < 0 else a
theta = angle(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry)
delta = angle((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry)
if not sweep and delta > 0:
delta -= 2 * math.pi
elif sweep and delta < 0:
delta += 2 * math.pi
segs = []
n = max(1, int(math.ceil(abs(delta) / (math.pi / 2) - 1e-9)))
step = delta / n
k = 4.0 / 3.0 * math.tan(step / 4.0)
def at(t):
x, y = rx * math.cos(t), ry * math.sin(t)
return (cosp * x - sinp * y + cx, sinp * x + cosp * y + cy)
def deriv(t):
x, y = -rx * math.sin(t), ry * math.cos(t)
return (cosp * x - sinp * y, sinp * x + cosp * y)
for i in range(n):
t0 = theta + i * step
t1 = t0 + step
a, b = at(t0), at(t1)
da, db = deriv(t0), deriv(t1)
segs.append(('C',
(a[0] + k * da[0], a[1] + k * da[1]),
(b[0] - k * db[0], b[1] - k * db[1]),
b))
return segs
def parse_path(d):
"""Return one list of absolute segments per subpath."""
t = tokenize(d)
subs, cur = [], None
pos = start = (0.0, 0.0)
prev_c = prev_q = None
i, cmd = 0, None
while i < len(t):
if isinstance(t[i], str):
cmd = t[i]
i += 1
rel = cmd.islower()
c = cmd.upper()
def take(n):
nonlocal i
v = t[i:i + n]
i += n
return v
def abspt(x, y):
return (pos[0] + x, pos[1] + y) if rel else (x, y)
if c == 'M':
x, y = take(2)
pos = start = abspt(x, y)
if cur:
subs.append(cur)
cur = [('M', pos)]
cmd = 'l' if rel else 'L' # implicit lineto for extra pairs
prev_c = prev_q = None
elif c == 'Z':
if cur:
subs.append(cur)
cur = None
pos = start
prev_c = prev_q = None
elif c in 'LHV':
if c == 'L':
x, y = take(2)
p = abspt(x, y)
elif c == 'H':
x = take(1)[0]
p = (pos[0] + x, pos[1]) if rel else (x, pos[1])
else:
y = take(1)[0]
p = (pos[0], pos[1] + y) if rel else (pos[0], y)
cur.append(('L', p))
pos = p
prev_c = prev_q = None
elif c in 'CS':
if c == 'C':
x1, y1, x2, y2, x, y = take(6)
c1, c2, p = abspt(x1, y1), abspt(x2, y2), abspt(x, y)
else:
x2, y2, x, y = take(4)
c2, p = abspt(x2, y2), abspt(x, y)
c1 = (2 * pos[0] - prev_c[0], 2 * pos[1] - prev_c[1]) if prev_c else pos
cur.append(('C', c1, c2, p))
prev_c, prev_q = c2, None
pos = p
elif c in 'QT':
if c == 'Q':
x1, y1, x, y = take(4)
q, p = abspt(x1, y1), abspt(x, y)
else:
x, y = take(2)
p = abspt(x, y)
q = (2 * pos[0] - prev_q[0], 2 * pos[1] - prev_q[1]) if prev_q else pos
cur.append(('C',
(pos[0] + 2.0 / 3 * (q[0] - pos[0]), pos[1] + 2.0 / 3 * (q[1] - pos[1])),
(p[0] + 2.0 / 3 * (q[0] - p[0]), p[1] + 2.0 / 3 * (q[1] - p[1])),
p))
prev_q, prev_c = q, None
pos = p
elif c == 'A':
rx, ry, rot, large, sweep, x, y = take(7)
p = abspt(x, y)
cur.extend(arc_to_cubics(pos, rx, ry, rot, int(large), int(sweep), p))
pos = p
prev_c = prev_q = None
else:
raise ValueError('unsupported path command %r' % cmd)
if cur:
subs.append(cur)
return subs
# --- outline conversion ----------------------------------------------------
def cubic_to_quads(p0, p1, p2, p3, tol, depth=0):
"""Approximate one cubic with quadratics: [(control, end), ...]."""
ex = p0[0] - 3 * p1[0] + 3 * p2[0] - p3[0]
ey = p0[1] - 3 * p1[1] + 3 * p2[1] - p3[1]
if math.sqrt(3) / 36 * math.hypot(ex, ey) <= tol or depth >= 8:
return [(((3 * p1[0] - p0[0] + 3 * p2[0] - p3[0]) / 4.0,
(3 * p1[1] - p0[1] + 3 * p2[1] - p3[1]) / 4.0), p3)]
def mid(a, b):
return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
p01, p12, p23 = mid(p0, p1), mid(p1, p2), mid(p2, p3)
p012, p123 = mid(p01, p12), mid(p12, p23)
m = mid(p012, p123)
return (cubic_to_quads(p0, p01, p012, m, tol, depth + 1) +
cubic_to_quads(m, p123, p23, p3, tol, depth + 1))
def contours_from_svg(path_d, view):
"""SVG path -> TrueType contours [[(x, y, on_curve), ...], ...]."""
vbx, vby, vbw, vbh = view
ax0, ay0, ax1, ay1 = ART_BOX
scale = min((ax1 - ax0) / vbw, (ay1 - ay0) / vbh)
ox = ax0 + ((ax1 - ax0) - vbw * scale) / 2.0
oy = ay0 + ((ay1 - ay0) - vbh * scale) / 2.0
def tf(p):
# SVG y grows down, font y grows up.
return (ox + (p[0] - vbx) * scale, oy + (vbh - (p[1] - vby)) * scale)
contours = []
for sub in parse_path(path_d):
pts, cursor = [], None
for seg in sub:
if seg[0] in ('M', 'L'):
cursor = tf(seg[1])
pts.append((cursor[0], cursor[1], True))
elif seg[0] == 'C':
c1, c2, p = tf(seg[1]), tf(seg[2]), tf(seg[3])
for q, end in cubic_to_quads(cursor, c1, c2, p, TOL):
pts.append((q[0], q[1], False))
pts.append((end[0], end[1], True))
cursor = p
if len(pts) > 2:
contours.append(clean(pts))
return orient(contours)
def clean(pts):
"""Round to integers, drop duplicates and the redundant closing point."""
out = []
for x, y, on in pts:
p = (int(round(x)), int(round(y)), on)
if not out or out[-1] != p:
out.append(p)
while len(out) > 1 and out[-1][:2] == out[0][:2] and out[-1][2] and out[0][2]:
out.pop()
return out
def area(pts):
a = 0.0
for i in range(len(pts)):
x0, y0 = pts[i][0], pts[i][1]
x1, y1 = pts[(i + 1) % len(pts)][0], pts[(i + 1) % len(pts)][1]
a += x0 * y1 - x1 * y0
return a / 2.0
def orient(contours):
"""TrueType draws outer contours clockwise (negative shoelace area).
Flipping the whole set preserves each hole's direction relative to its
outer contour, which is what keeps counters (eyes, cutouts) unfilled.
"""
if contours and area(max(contours, key=lambda c: abs(area(c)))) > 0:
return [list(reversed(c)) for c in contours]
return contours
# --- glyph and table encoding ----------------------------------------------
def encode_glyph(contours):
if not contours:
return b''
xs = [p[0] for c in contours for p in c]
ys = [p[1] for c in contours for p in c]
out = [struct.pack('>hhhhh', len(contours), min(xs), min(ys), max(xs), max(ys))]
ends, n = [], 0
for c in contours:
n += len(c)
ends.append(n - 1)
out.append(struct.pack('>%dH' % len(ends), *ends))
out.append(struct.pack('>H', 0)) # no instructions
flags, xdel, ydel = [], [], []
px = py = 0
for c in contours:
for x, y, on in c:
dx, dy = x - px, y - py
px, py = x, y
f = 1 if on else 0
if dx == 0:
f |= 0x10
elif -255 <= dx <= 255:
f |= 0x02 | (0x10 if dx > 0 else 0)
xdel.append(struct.pack('>B', abs(dx)))
else:
xdel.append(struct.pack('>h', dx))
if dy == 0:
f |= 0x20
elif -255 <= dy <= 255:
f |= 0x04 | (0x20 if dy > 0 else 0)
ydel.append(struct.pack('>B', abs(dy)))
else:
ydel.append(struct.pack('>h', dy))
flags.append(struct.pack('>B', f))
data = b''.join(out + flags + xdel + ydel)
return data + b'\0' * (-len(data) % 4)
def read_tables(d):
t = {}
for i in range(struct.unpack('>H', d[4:6])[0]):
off = 12 + i * 16
tag = d[off:off + 4].decode('latin-1')
s, l = struct.unpack('>II', d[off + 8:off + 16])
t[tag] = (s, l)
return t
def checksum(data):
data += b'\0' * (-len(data) % 4)
return sum(struct.unpack('>%dI' % (len(data) // 4), data)) & 0xFFFFFFFF
def read_loca(d, t, ng):
ls = t['loca'][0]
if struct.unpack('>h', d[t['head'][0] + 50:t['head'][0] + 52])[0] == 0:
return [x * 2 for x in struct.unpack('>%dH' % (ng + 1), d[ls:ls + 2 * (ng + 1)])]
return list(struct.unpack('>%dI' % (ng + 1), d[ls:ls + 4 * (ng + 1)]))
def read_cmap(d, cs):
"""Read the format 12 subtable; it carries every mapping the font has."""
m = {}
for i in range(struct.unpack('>H', d[cs + 2:cs + 4])[0]):
pid, eid, off = struct.unpack('>HHI', d[cs + 4 + i * 8:cs + 12 + i * 8])
sub = cs + off
if struct.unpack('>H', d[sub:sub + 2])[0] != 12:
continue
for j in range(struct.unpack('>I', d[sub + 12:sub + 16])[0]):
s, e, g = struct.unpack('>III', d[sub + 16 + j * 12:sub + 28 + j * 12])
for c in range(s, e + 1):
m[c] = g + (c - s)
return m
def read_names(d, ps, ng):
n = struct.unpack('>H', d[ps + 32:ps + 34])[0]
idx = list(struct.unpack('>%dH' % n, d[ps + 34:ps + 34 + n * 2]))
p = ps + 34 + n * 2
pool = []
need = max([i - 258 + 1 for i in idx if i >= 258] or [0])
while len(pool) < need:
ln = d[p]
pool.append(d[p + 1:p + 1 + ln].decode('latin-1'))
p += 1 + ln
out = [pool[i - 258] if i >= 258 else '#mac%d' % i for i in idx]
return out + ['#mac0'] * (ng - len(out))
def build_post(d, ps, names):
idx, pool = [], []
for nm in names:
if nm.startswith('#mac'):
idx.append(int(nm[4:]))
else:
idx.append(258 + len(pool))
pool.append(nm)
body = struct.pack('>H', len(idx)) + struct.pack('>%dH' % len(idx), *idx)
for nm in pool:
body += struct.pack('>B', len(nm)) + nm.encode('latin-1')
return d[ps:ps + 32] + body
def build_cmap(m):
"""Emit format 4 and 12 subtables under the usual encoding records."""
groups = []
for c in sorted(m):
if groups and c == groups[-1][1] + 1 and m[c] == groups[-1][2] + (c - groups[-1][0]):
groups[-1][1] = c
else:
groups.append([c, c, m[c]])
segs = [(a, b, g) for a, b, g in groups if b <= 0xFFFF] + [(0xFFFF, 0xFFFF, 0)]
count = len(segs)
pow2 = 2 ** (count.bit_length() - 1) * 2
f4 = struct.pack('>HHHHHHH', 4, 16 + count * 8, 0, count * 2,
pow2, count.bit_length() - 1, count * 2 - pow2)
f4 += struct.pack('>%dH' % count, *[s[1] for s in segs])
f4 += struct.pack('>H', 0)
f4 += struct.pack('>%dH' % count, *[s[0] for s in segs])
deltas = [((s[2] - s[0]) & 0xFFFF) if s[0] != 0xFFFF else 1 for s in segs]
f4 += struct.pack('>%dh' % count, *[x - 65536 if x > 32767 else x for x in deltas])
f4 += struct.pack('>%dH' % count, *([0] * count))
f12 = struct.pack('>HHIII', 12, 0, 16 + len(groups) * 12, 0, len(groups))
for a, b, g in groups:
f12 += struct.pack('>III', a, b, g)
f0 = struct.pack('>HHH', 0, 262, 0) + bytes(256)
body, offsets = b'', {}
for data in (f4, f12, f0):
offsets[id(data)] = len(body)
body += data
records = [(0, 3, f4), (0, 4, f12), (1, 0, f0), (3, 1, f4), (3, 10, f12)]
head = struct.pack('>HH', 0, len(records))
base = 4 + len(records) * 8
for pid, eid, data in records:
head += struct.pack('>HHI', pid, eid, base + offsets[id(data)])
return head + body
def assemble(tables):
tags = sorted(tables)
n = len(tags)
sr = 2 ** (n.bit_length() - 1) * 16
font = struct.pack('>IHHHH', 0x00010000, n, sr, n.bit_length() - 1, n * 16 - sr)
offset = 12 + n * 16
body, records = b'', []
for tag in tags:
data = tables[tag]
records.append((tag, checksum(data), offset + len(body), len(data)))
body += data + b'\0' * (-len(data) % 4)
font += b''.join(struct.pack('>4sIII', t.encode('latin-1'), c, o, l)
for t, c, o, l in records)
font += body
adj = (0xB1B0AFBA - checksum(font)) & 0xFFFFFFFF
hs = [o for t, c, o, l in records if t == 'head'][0]
return font[:hs + 8] + struct.pack('>I', adj) + font[hs + 12:]
# --- commands --------------------------------------------------------------
def load_font(path):
d = open(path, 'rb').read()
t = read_tables(d)
ng = struct.unpack('>H', d[t['maxp'][0] + 4:t['maxp'][0] + 6])[0]
return d, t, ng
def glyph_list(path):
d, t, ng = load_font(path)
loca = read_loca(d, t, ng)
names = read_names(d, t['post'][0], ng)
gs = t['glyf'][0]
rows = []
for cp, gid in sorted(read_cmap(d, t['cmap'][0]).items()):
if cp < PUA_FIRST:
continue
a, b = loca[gid], loca[gid + 1]
box = ''
if b > a:
_, x0, y0, x1, y1 = struct.unpack('>hhhhh', d[gs + a:gs + a + 10])
box = '%d x %d' % (x1 - x0, y1 - y0)
name = names[gid]
rows.append((cp, '' if name.startswith('#mac') else name, box))
return rows
def add_glyph(font_path, codepoint, name, contours):
d, t, ng = load_font(font_path)
loca = read_loca(d, t, ng)
gs = t['glyf'][0]
glyphs = [d[gs + loca[i]:gs + loca[i + 1]] for i in range(ng)]
nhm = struct.unpack('>H', d[t['hhea'][0] + 34:t['hhea'][0] + 36])[0]
hm = t['hmtx'][0]
adv, lsb = [], []
for i in range(ng):
if i < nhm:
a, b = struct.unpack('>Hh', d[hm + i * 4:hm + i * 4 + 4])
else:
a = adv[-1]
b = struct.unpack('>h', d[hm + nhm * 4 + (i - nhm) * 2:][:2])[0]
adv.append(a)
lsb.append(b)
names = read_names(d, t['post'][0], ng)
cmap = read_cmap(d, t['cmap'][0])
gid = len(glyphs)
glyphs.append(encode_glyph(contours))
xs = [p[0] for c in contours for p in c]
adv.append(UPEM)
lsb.append(min(xs) if xs else 0)
names.append(name)
cmap[codepoint] = gid
ng = len(glyphs)
offs, o = [0], 0
for g in glyphs:
o += len(g)
offs.append(o)
short = offs[-1] <= 0x1FFFE and all(x % 2 == 0 for x in offs)
hhea = bytearray(d[t['hhea'][0]:t['hhea'][0] + t['hhea'][1]])
struct.pack_into('>H', hhea, 34, ng)
maxp = bytearray(d[t['maxp'][0]:t['maxp'][0] + t['maxp'][1]])
struct.pack_into('>H', maxp, 4, ng)
struct.pack_into('>H', maxp, 6, max(struct.unpack('>H', bytes(maxp[6:8]))[0],
sum(len(c) for c in contours)))
struct.pack_into('>H', maxp, 8, max(struct.unpack('>H', bytes(maxp[8:10]))[0],
len(contours)))
boxes = [struct.unpack('>hhhhh', g[:10])[1:] for g in glyphs if len(g) >= 10]
head = bytearray(d[t['head'][0]:t['head'][0] + t['head'][1]])
struct.pack_into('>hhhh', head, 36,
min(b[0] for b in boxes), min(b[1] for b in boxes),
max(b[2] for b in boxes), max(b[3] for b in boxes))
struct.pack_into('>h', head, 50, 0 if short else 1)
struct.pack_into('>I', head, 8, 0) # checkSumAdjustment, recomputed below
tables = {tag: d[s:s + l] for tag, (s, l) in t.items()}
tables['glyf'] = b''.join(glyphs)
tables['loca'] = (struct.pack('>%dH' % len(offs), *[x // 2 for x in offs]) if short
else struct.pack('>%dI' % len(offs), *offs))
tables['hmtx'] = b''.join(struct.pack('>Hh', adv[i], lsb[i]) for i in range(ng))
tables['hhea'] = bytes(hhea)
tables['maxp'] = bytes(maxp)
tables['cmap'] = build_cmap(cmap)
tables['post'] = build_post(d, t['post'][0], names)
tables['head'] = bytes(head)
open(font_path, 'wb').write(assemble(tables))
def read_svg(location):
if not location.startswith(('http://', 'https://')):
return open(location, encoding='utf-8').read()
# Icon sites reject the stock urllib agent, so ask like a browser would.
req = urllib.request.Request(location, headers={'User-Agent': 'omarchy-dev-font'})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode('utf-8')
except OSError as e:
sys.exit('could not fetch %s: %s' % (location, e))
def svg_contours(svg, location):
vb = re.search(r'viewBox="([^"]+)"', svg)
if not vb:
sys.exit('%s: no viewBox; need an SVG with a viewBox' % location)
view = tuple(float(x) for x in vb.group(1).replace(',', ' ').split())
paths = re.findall(r'<path[^>]*\bd="([^"]+)"', svg)
if len(paths) != 1:
sys.exit('%s: expected a single <path>, found %d — flatten the mark to '
'one monochrome path first' % (location, len(paths)))
return contours_from_svg(paths[0], view)
def default_font():
root = os.environ.get('OMARCHY_PATH')
if not root:
sys.exit('OMARCHY_PATH is not set')
return os.path.join(root, 'default/fonts/omarchy/omarchy.ttf')
def readme_for(font_path):
return os.path.join(os.path.dirname(font_path), 'README.md')
def record_source(font_path, codepoint, label, location):
"""Append the new mark to the font's README so sources stay tracked."""
readme = readme_for(font_path)
if not os.path.exists(readme):
return None
lines = open(readme, encoding='utf-8').read().split('\n')
last = max(i for i, l in enumerate(lines) if l.startswith('- `U+'))
entry = '- `U+%04X` — %s' % (codepoint, label)
if location.startswith(('http://', 'https://')):
entry += ', from <%s>' % location
lines.insert(last + 1, entry)
open(readme, 'w', encoding='utf-8').write('\n'.join(lines))
return readme
def main():
common = argparse.ArgumentParser(add_help=False)
common.add_argument('--font', help='font to edit '
'(default: $OMARCHY_PATH/default/fonts/omarchy/omarchy.ttf)')
p = argparse.ArgumentParser(prog='omarchy dev font', parents=[common],
description=__doc__.split('\n')[0])
sub = p.add_subparsers(dest='command')
sub.add_parser('list', parents=[common], help='show the marks the font carries')
add = sub.add_parser('add', parents=[common],
help='append a mark from a monochrome SVG')
add.add_argument('name', help='glyph name, e.g. ollama')
add.add_argument('svg', help='SVG file or URL')
add.add_argument('--codepoint', help='private-use codepoint (default: next free)')
add.add_argument('--label', help='README label (default: the glyph name)')
args = p.parse_args()
font = args.font or default_font()
if not os.path.exists(font):
sys.exit('no font at %s' % font)
if args.command in (None, 'list'):
for cp, name, box in glyph_list(font):
print('U+%04X %s %-12s %s' % (cp, chr(cp), name, box))
return
taken = {cp for cp, _, _ in glyph_list(font)}
if args.codepoint:
cp = int(args.codepoint.upper().replace('U+', ''), 16)
if cp in taken:
sys.exit('U+%04X is already used' % cp)
else:
cp = max(taken) + 1 if taken else PUA_FIRST
contours = svg_contours(read_svg(args.svg), args.svg)
add_glyph(font, cp, args.name, contours)
readme = record_source(font, cp, args.label or args.name, args.svg)
print('Added %s as U+%04X (%s)' % (args.name, cp, chr(cp)))
print()
print('Next:')
if readme:
print(' - check the new line in %s' % os.path.relpath(readme))
print(' - point a menu entry at it: "icon":"%s","iconFont":"omarchy"' % chr(cp))
print(' - bump the charset range in test/shell.d/menu-test.sh to e900-%x' % cp)
print(' - the font is package-owned, so it reaches the desktop through an')
print(' omarchy-settings release, not through omarchy update')
if __name__ == '__main__':
main()
+32 -7
View File
@@ -6,6 +6,18 @@
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
provider_from_arg() {
@@ -28,19 +40,32 @@ provider_from_arg() {
esac
}
self_path() {
readlink -f "$OMARCHY_PATH/bin/${BASH_SOURCE[0]##*/}"
# The path etc/sudoers.d/omarchy-dns names. The privileged half always runs from
# 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() {
if (( EUID == 0 )); then
return
fi
if [[ -t 0 ]]; then
exec sudo "$(self_path)" "$@"
elif [[ -t 0 ]] || sudo_grants_passwordless "$@"; then
# A terminal can carry sudo's own password prompt. Without one, sudo is
# right only where the grant reaches; polkit can at least put a prompt on
# screen, and offer to authenticate as someone else.
exec sudo "$PACKAGED_PATH" "$@"
else
exec pkexec "$(self_path)" "$@"
exec pkexec "$PACKAGED_PATH" "$@"
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
+5 -2
View File
@@ -2,8 +2,11 @@
# omarchy:summary=Returns true when an external monitor is physically connected.
for status in /sys/class/drm/card*-*/status; do
[[ $status == *-eDP-*/status ]] && continue
drm_path="${OMARCHY_DRM_PATH:-/sys/class/drm}"
for status in "$drm_path"/card*-*/status; do
[[ -e $status ]] || continue
[[ $status =~ -(eDP|LVDS|DSI)-[^/]+/status$ ]] && continue
[[ $(< $status) == "connected" ]] && exit 0
done
exit 1
+17 -7
View File
@@ -9,6 +9,7 @@
# also make USB touchscreens) are left out to avoid nagging laptops with no
# reader — those still match on the product string below when present.
fingerprint_vendors=" 27c6 138a 06cb 08ff 1c7a 147e "
usb_devices_path="${OMARCHY_USB_DEVICES_PATH:-/sys/bus/usb/devices}"
# libfprint drives every reader it supports from userspace over libusb, so a
# real reader sits there with no kernel driver bound to any of its interfaces.
@@ -17,23 +18,32 @@ fingerprint_vendors=" 27c6 138a 06cb 08ff 1c7a 147e "
# — all bind one. Only the vendor-ID guess needs this; a device that names
# itself a fingerprint reader is trusted outright.
has_kernel_driver() {
local intf
local intf driver
for intf in "$1"/*:*; do
[[ -e $intf/driver ]] && return 0
[[ -e $intf/driver ]] || continue
# usbfs is the exception: libusb claims an interface through it, so a reader
# fprintd is enrolling or verifying against binds a driver for as long as it
# holds the claim. That is userspace driving the device — what a reader is
# supposed to look like — so it must not read as a kernel driver here.
driver=$(readlink -f "$intf/driver")
[[ ${driver##*/} == "usbfs" ]] || return 0
done
return 1
}
for dev in /sys/bus/usb/devices/*; do
for dev in "$usb_devices_path"/*; do
# The device's own product descriptor usually names it, e.g. "Goodix
# Fingerprint USB Device" — driver-independent and vendor-agnostic.
if [[ -r $dev/product ]]; then
product=$(<"$dev/product")
product=${product,,}
# Elan's match-on-chip readers report "ELAN:ARM-M4", the family name
# rather than the function. Elan is left out of the vendor list above
# on purpose, so without this they match nothing.
[[ $product == *fingerprint* || $product == *biometric* || $product == *elan:arm-m4* ]] && exit 0
# Elan's match-on-chip readers report "ELAN:ARM-M4" and Fingerprint Cards'
# report "FPC Sensor Controller" or "FPC L:0000 FW:1425046" — the family or
# the manufacturer rather than the function. Both vendors are left out of
# the list above on purpose (Elan also makes touchscreens), so without these
# they match nothing. FPC leads the string on every reader on record, and
# three letters are little to match on, so require the prefix.
[[ $product == *fingerprint* || $product == *biometric* || $product == *elan:arm-m4* || $product == "fpc "* ]] && exit 0
fi
if [[ -r $dev/idVendor ]]; then
+18 -3
View File
@@ -2,8 +2,23 @@
# omarchy:summary=Detect whether the system has an active hybrid GPU configuration
if omarchy-cmd-present supergfxctl; then
supergfxctl -s 2>/dev/null | grep -qw Hybrid
else
multiple_gpus() {
(($(lspci | grep -cE 'VGA|3D|Display') >= 2))
}
if omarchy-cmd-present supergfxctl; then
# A wedged supergfxd blocks its clients forever, and this gate runs while
# the menu renders. Bound the query, and treat a daemon that cannot answer
# like a machine without supergfxctl: count GPUs instead of hiding hardware
# that is really there.
modes=$(timeout --kill-after=1s 1s supergfxctl -s 2>/dev/null)
status=$?
if ((status == 124 || status == 137)); then
multiple_gpus
else
grep -qw Hybrid <<<"$modes"
fi
else
multiple_gpus
fi
+1 -1
View File
@@ -2,4 +2,4 @@
# omarchy:summary=Check whether a webcam is available
v4l2-ctl --list-devices 2>/dev/null | grep -q '^[[:space:]]*/dev/video'
[[ -n $(omarchy-capture-webcam-list) ]]
+9 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# omarchy:summary=Focus a Hyprland window by application class
# omarchy:summary=Focus a Hyprland window by application identity
# omarchy:args=<app-name>
# omarchy:examples=omarchy hyprland focus app Slack
@@ -12,10 +12,17 @@ usage() {
app=${1:-}
[[ -n $app ]] || usage
# Agent terminals notify as kitty/foot/etc. while their shared window class is
# org.omarchy.agent, leaving the terminal name only in initialTitle. So match
# by class first, then fall back to the launch-time title of agent windows.
address=$(
hyprctl clients -j 2>/dev/null |
jq -r --arg pattern "$app" \
'[.[] | select((.class // "") | test($pattern; "i"))] | first.address // empty'
'def matches($value): ($value // "") | test($pattern; "i");
first(
(.[] | select(matches(.class))),
(.[] | select(.initialClass == "org.omarchy.agent" and matches(.initialTitle)))
).address // empty'
)
[[ -n $address ]] || exit 1
+32 -2
View File
@@ -11,6 +11,14 @@ MONITOR_LUA="$HOME/.config/hypr/monitors.lua"
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() {
[[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]
}
@@ -118,9 +126,17 @@ remember_internal_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() {
local scale
scale=$(configured_monitor_scale)
if (( $# )); then
scale="$1"
else
scale=$(configured_monitor_scale)
fi
if valid_scale "$scale"; then
echo "$scale"
return
@@ -159,11 +175,25 @@ enable_internal_output() {
sync_internal_scale() {
[[ -n $INTERNAL ]] || return 0
local configured_scale
local desired_scale
local active_scale
desired_scale=$(read_monitor_scale)
configured_scale=$(configured_monitor_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
enable_internal_output "$desired_scale"
+7
View File
@@ -28,6 +28,13 @@ off() {
exit 1
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
omarchy-notification-send -g 󰍹 "Can't disable the only active display"
exit 1
@@ -22,6 +22,15 @@ on() {
exit 1
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
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 height="$(echo "$monitor_info" | jq -r '.height')"
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")"
# GTK only honors integer GDK_SCALE values, so persist the nearest whole
# factor even when the monitor scale itself is fractional.
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# omarchy:summary=Install the ChatGPT desktop app
# omarchy:requires-sudo=true
set -e
echo "Installing ChatGPT..."
omarchy-pkg-add openai-codex-desktop
echo "Opening ChatGPT..."
setsid uwsm-app -- /usr/bin/chatgpt >/dev/null 2>&1 &
echo ""
echo "ChatGPT has been installed."
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# omarchy:summary=Restore the preinstalled Omarchy applications (web apps, TUIs, and selected packages).
# omarchy:requires-sudo=true
if gum confirm "Are you sure you want to restore all preinstalled web apps, TUI wrappers, and desktop applications?"; then
echo -e "Restoring preinstalled Omarchy applications...\n"
# Recreates the shipped .desktop launchers (web apps and TUIs) and the mise stubs
# that back claude, gh, opencode, and the rest of the agents
omarchy-refresh-applications
# Mirrors the list in omarchy-remove-preinstalls; both track omarchy-base.packages
if ! omarchy-pkg-add \
aether \
cliamp \
libreoffice-fresh \
xournalpp \
pinta \
obsidian \
obs-studio \
kdenlive \
moonlight-qt \
lazydocker \
omacut \
omacalc \
omawrite; then
echo -e "\nPreinstalls are still marked as removed. Fix the errors above and try again."
exit 1
fi
# Last, so a failure above leaves the opt-out intact rather than restoring
# keybindings for apps that never came back
rm -f ~/.local/state/omarchy/preinstalls-removed
hyprctl reload
fi
+132 -40
View File
@@ -2,13 +2,32 @@
# omarchy:summary=Launch the fastfetch TUI that gives information about the current system.
# The float rule only sets a starting size: the exact fit depends on the terminal
# font and the user's About logo, so once the terminal grid has settled we measure
# the rendered content and resize the window to hug it with even padding all around.
# Bash defers WINCH traps while read blocks, so poll for size changes and re-render
# fastfetch at the new width whenever the window is resized.
# The size that hugs the About content depends on the terminal font and the user's
# logo, so it can only be measured from inside the terminal. We remember the size
# that fit and apply it as a window rule before launching, so the window opens at
# it instead of resizing after the first paint. Bash defers WINCH traps while read
# blocks, so poll for size changes and re-render fastfetch whenever it is resized.
LOGO_FILE="$HOME/.config/omarchy/branding/about.txt"
FIT_FILE="$HOME/.local/state/omarchy/windows/about.fit"
# A user-level fastfetch config can relocate or restyle the logo in ways this
# measurement cannot see, so leave sizing to the float rule in that case.
custom_fastfetch_config() {
[[ -f $HOME/.config/fastfetch/config.jsonc ]]
}
# wc -L counts display columns only in a UTF-8 locale. A session that never set
# one counts every box-drawing and Nerd Font glyph in the About layout as
# nothing, which measures the content narrower than it renders.
display_columns() {
LC_ALL=C.UTF-8 wc -L
}
logo_dimensions() {
[[ -f $LOGO_FILE ]] || return 1
printf '%s %s' "$(display_columns <"$LOGO_FILE")" "$(wc -l <"$LOGO_FILE")"
}
hypr_dispatch() {
local lua="$1"
@@ -16,74 +35,147 @@ hypr_dispatch() {
hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null
}
remember_fit() {
local directory tmp
directory=$(dirname "$FIT_FILE")
mkdir -p "$directory"
tmp=$(mktemp "$directory/.about.fit.XXXXXX")
printf '%s %s %s %s\n' "$1" "$2" "$3" "$4" >"$tmp"
mv "$tmp" "$FIT_FILE"
}
# Replaces the rule from the last launch so a remembered size never outlives the
# fit it came from. Called without one it only clears, leaving the float rule's
# starting size — where a Hyprland without the Lua API stays too.
apply_size_rule() {
local rule=""
(( $# == 2 )) && rule="omarchy_about_size_rule = hl.window_rule({ match = { class = \"org.omarchy.about\" }, size = { $1, $2 } })"
hyprctl eval "if omarchy_about_size_rule then omarchy_about_size_rule:set_enabled(false) end; omarchy_about_size_rule = nil; $rule" >/dev/null 2>&1
}
# Sized before the terminal is spawned, so the window maps at its final size.
presize_window() {
local logo_w logo_h fit_logo_w fit_logo_h fit_w fit_h
if ! custom_fastfetch_config && [[ -r $FIT_FILE ]]; then
read -r logo_w logo_h <<<"$(logo_dimensions)"
read -r fit_logo_w fit_logo_h fit_w fit_h <"$FIT_FILE"
# A different logo needs a different window, so leave that launch to the
# float rule and let the fit measure the new size.
if [[ -n ${logo_w:-} && $logo_w == "$fit_logo_w" && $logo_h == "$fit_logo_h" ]] &&
[[ $fit_w =~ ^[0-9]+$ && $fit_h =~ ^[0-9]+$ ]]; then
apply_size_rule "$fit_w" "$fit_h"
return
fi
fi
apply_size_rule
}
# Hyprland animates a resize and the terminal reflows to every step of it, so
# wait for the grid to hold still before measuring it.
settle_grid() {
local current previous="" held=0
for _ in {1..20}; do
current=$(stty size)
if [[ $current == $previous ]]; then
(( ++held == 3 )) && break
else
held=0
previous=$current
fi
sleep 0.05
done
}
fit_window() {
# A user-level fastfetch config can relocate or restyle the logo in ways this
# measurement cannot see, so leave sizing to the float rule in that case.
[[ -f $HOME/.config/fastfetch/config.jsonc ]] && return 0
custom_fastfetch_config && return 0
local rows cols address width height
read -r rows cols <<<"$(stty size)"
read -r address width height <<<"$(hyprctl clients -j | jq -r '.[] | select(.class == "org.omarchy.about") | "\(.address) \(.size[0]) \(.size[1])"')"
[[ -f $LOGO_FILE && -n ${address:-} ]] || return 1
(( cols > 0 && rows > 0 && width > 0 && height > 0 )) || return 1
# Mirror the logo block in the fastfetch config: 2 columns of padding on the
# left of the logo, 6 between the logo and the modules, 2 rows above the logo.
# wc -L measures display columns, so double-width glyphs count correctly.
local logo_w logo_h
logo_w=$(wc -L <"$LOGO_FILE")
logo_h=$(( $(wc -l <"$LOGO_FILE") + 2 ))
read -r logo_w logo_h <<<"$(logo_dimensions)"
[[ -n ${logo_w:-} ]] || return 1
# The guard character keeps command substitution from eating the trailing
# break line, which provides the bottom padding row.
local modules module_w module_h
modules=$(fastfetch --logo none | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g'; printf X)
modules=${modules%X}
module_w=$(printf '%s' "$modules" | wc -L)
module_w=$(printf '%s' "$modules" | display_columns)
module_h=$(printf '%s' "$modules" | wc -l)
# Two columns of right padding to match the logo's left padding, and one row
# for the cursor line so the trailing break stays visible as bottom padding.
# Mirror the logo block in the fastfetch config: 2 columns of padding left of
# the logo, 6 between logo and modules, 2 rows above it. Then 2 columns of
# right padding to match, and a row for the cursor so the trailing break shows.
local target_c=$(( 2 + logo_w + 6 + module_w + 2 ))
local target_r=$(( (logo_h > module_h ? logo_h : module_h) + 1 ))
local target_w=$(( (target_c * width + cols / 2) / cols ))
local target_h=$(( (target_r * height + rows / 2) / rows ))
local target_r=$(( (logo_h + 2 > module_h ? logo_h + 2 : module_h) + 1 ))
# Skip resizes within one terminal cell so the fit doesn't churn.
(( (target_w - width) ** 2 <= (width / cols) ** 2 && (target_h - height) ** 2 <= (height / rows) ** 2 )) && return
local nudges=0 rows cols address width height shift_w shift_h target_w target_h
while :; do
read -r rows cols <<<"$(stty size)"
read -r address width height <<<"$(hyprctl clients -j | jq -r '.[] | select(.class == "org.omarchy.about") | "\(.address) \(.size[0]) \(.size[1])"')"
[[ -n ${address:-} ]] || return 1
(( cols > 0 && rows > 0 && width > 0 && height > 0 )) || return 1
hypr_dispatch "hl.dsp.window.resize({ window = \"address:$address\", x = $target_w, y = $target_h })" resizewindowpixel "exact $target_w $target_h,address:$address"
hypr_dispatch "hl.dsp.window.center({ window = \"address:$address\" })" centerwindow
# A window has to land on the terminal's cell boundaries, so take a cell of
# slack over chasing an exact grid, and remember where it came to rest.
if (( cols >= target_c && cols <= target_c + 1 && rows >= target_r && rows <= target_r + 1 )); then
remember_fit "$logo_w" "$logo_h" "$width" "$height"
return 0
fi
# Two nudges is the budget, and each one is measured before the next is spent.
(( ++nudges <= 2 )) || return 1
# Move by the cells the window is off by, rather than scaling it to the grid,
# which would multiply up the terminal's padding along with them. Dividing a
# window that carries that padding still leaves the cell a touch generous, so
# round the move away from the grid that would clip.
shift_w=$(( (target_c - cols) * width ))
shift_h=$(( (target_r - rows) * height ))
target_w=$(( width + (shift_w >= 0 ? (shift_w + cols - 1) / cols : shift_w / cols) ))
target_h=$(( height + (shift_h >= 0 ? (shift_h + rows - 1) / rows : shift_h / rows) ))
hypr_dispatch "hl.dsp.window.resize({ window = \"address:$address\", x = $target_w, y = $target_h })" resizewindowpixel "exact $target_w $target_h,address:$address"
hypr_dispatch "hl.dsp.window.center({ window = \"address:$address\" })" centerwindow
settle_grid
done
return 1
}
if [[ ${1:-} == "--render" ]]; then
printf '\e[?25l'
# Give the compositor a moment to apply the float rule before measuring cells.
previous=""
for _ in {1..20}; do
current=$(stty size)
[[ $current == $previous ]] && break
previous=$current
sleep 0.05
done
# Give the compositor a moment to apply the window rules before measuring cells.
settle_grid
fitted=false
passes=0
while :; do
size=$(stty size)
logo_stamp=$(stat -c %Y "$LOGO_FILE" 2>/dev/null)
clear
fastfetch
if [[ $fitted == false ]] && fit_window; then
fitted=true
# A second pass picks up a fit that could not measure the window the first
# time. Beyond that, a window that will not settle would be fitted again on
# every repaint.
if [[ $fitted == false ]] && (( ++passes <= 2 )); then
fit_window && fitted=true
fi
while [[ $(stty size) == $size && $(stat -c %Y "$LOGO_FILE" 2>/dev/null) == $logo_stamp ]]; do
read -t 0.5 -n 1 -s && exit
(( $? > 128 )) || exit
done
# A rebranded logo changes the content dimensions, so measure again.
[[ $(stat -c %Y "$LOGO_FILE" 2>/dev/null) == $logo_stamp ]] || fitted=false
if [[ $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != $logo_stamp ]]; then
fitted=false
passes=0
fi
done
fi
presize_window
exec omarchy-launch-or-focus-tui --app-id=org.omarchy.about omarchy-launch-about --render
-72
View File
@@ -1,72 +0,0 @@
#!/bin/bash
# omarchy:summary=Launch the default coding agent in a terminal
# omarchy:args=[--inline] [prompt...]
# omarchy:examples=omarchy launch agent | omarchy launch agent "Review this project"
if [[ ${1:-} == "--inline" ]]; then
inline=true
shift
else
inline=false
fi
# Agents refuse to remember trust for $HOME, so launches from the keybinding or
# menu start in the work directory instead of re-asking on every session.
[[ $PWD == "$HOME" && -d $HOME/Work ]] && cd "$HOME/Work"
agent=$(omarchy-default-agent)
if omarchy-cmd-missing "$agent"; then
echo "$agent is not installed. Choose an installed agent with: omarchy default agent <name>" >&2
exit 1
fi
if (($# > 0)); then
prompt="$*"
fi
case "$agent" in
opencode)
if [[ -n ${prompt:-} ]]; then
command=(opencode --prompt "$prompt")
else
command=(opencode)
fi
;;
gemini)
if [[ -n ${prompt:-} ]]; then
command=(gemini --prompt-interactive "$prompt")
else
command=(gemini)
fi
;;
copilot)
if [[ -n ${prompt:-} ]]; then
command=(copilot --interactive "$prompt")
else
command=(copilot)
fi
;;
crush)
if [[ -n ${prompt:-} ]]; then
command=(crush run "$prompt")
else
command=(crush)
fi
;;
pi | omp | claude | codex | grok)
command=("$agent")
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
*)
echo "Unsupported default agent: $agent" >&2
exit 1
;;
esac
if [[ $inline == "true" ]]; then
exec "${command[@]}"
else
exec omarchy-launch-tui "${command[@]}"
fi
+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
+7 -1
View File
@@ -9,8 +9,14 @@
#
# Backgrounded because bash defers a trap until a foreground command returns but
# interrupts wait. systemd-cat execs, so the job is Quickshell itself.
#
# Quickshell's own reloading is off; Omarchy restarts the shell deliberately.
# A package upgrade rewriting $OMARCHY_PATH/shell would otherwise reload it
# against a half-written tree, and that failed reload leaves a second engine
# generation behind that turns the next restart's IPC kill into a crash.
run_shell() {
systemd-cat -t omarchy-shell -- quickshell -n -p "$OMARCHY_PATH/shell" &
QS_DISABLE_FILE_WATCHER=1 QS_NO_RELOAD_POPUP=1 \
systemd-cat -t omarchy-shell -- quickshell -n -p "$OMARCHY_PATH/shell" &
shell_pid=$!
local status
+43 -22
View File
@@ -111,8 +111,8 @@ cache_key=$(printf '%s' "$image_dirs_env" | md5sum | cut -d ' ' -f 1)
rows_cache_file="$cache_dir/$cache_key.rows"
rows_signature_file="$cache_dir/$cache_key.signature"
rows_fast_signature_file="$cache_dir/$cache_key.fast-signature"
rows_signature="v2"$'\n'
rows_fast_signature="v1"$'\n'
rows_signature="v3"$'\n'
rows_fast_signature="v2"$'\n'
rows_cacheable=true
rows_cache_hit=false
image_files=()
@@ -142,23 +142,32 @@ generate_thumbnail() {
local image="$1"
local thumbnail="$2"
local lock="$thumbnail.lock"
local lock_fd
local tmp="$thumbnail.$$.jpg"
if mkdir "$lock" 2>/dev/null; then
# Callers fan out one generator per image, so keep each vips single-threaded.
if VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]"; then
mv -f "$tmp" "$thumbnail"
else
rm -f "$tmp" "$thumbnail"
fi
# Older releases used directories as locks, which could survive a killed
# generator and block this thumbnail forever. Only reap aged ones, so a
# legacy generator still running through an upgrade keeps its lock.
if [[ -d $lock ]] && (( $(date +%s) - $(stat -c '%Y' "$lock" 2>/dev/null || date +%s) > 120 )); then
rmdir "$lock" 2>/dev/null
fi
rmdir "$lock" 2>/dev/null || true
exec {lock_fd}>"$lock" || return
flock -w 30 "$lock_fd" || return
# A generator killed mid-write leaves its partial $thumbnail.<pid>.jpg
# behind. Only the lock holder writes these, so any found now are stale.
rm -f "$thumbnail".*.jpg
[[ -f $thumbnail ]] && return
# Callers fan out one generator per image, so keep each vips single-threaded.
# Close the lock fd for vips: an orphaned or hung vips must not keep holding
# the lock after this shell is killed.
if VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]" {lock_fd}>&-; then
mv -f "$tmp" "$thumbnail"
else
for ((i = 0; i < 3000; i++)); do
[[ -f $thumbnail ]] && return
[[ -d $lock ]] || break
sleep 0.01
done
rm -f "$tmp" "$thumbnail"
fi
}
@@ -226,7 +235,10 @@ elif [[ $rows_cache_hit != true ]]; then
if [[ -s $pending_file ]]; then
pruned=""
while IFS=$'\t' read -r row_image row_thumbnail; do
[[ -e $row_thumbnail ]] || continue
if [[ ! -e $row_thumbnail ]]; then
rows_cacheable=false
continue
fi
if [[ -z $pruned ]]; then
pruned="$row_image"$'\t'"$row_thumbnail"
@@ -237,12 +249,21 @@ elif [[ $rows_cache_hit != true ]]; then
rows="$pruned"
fi
if [[ $rows_cacheable == true ]]; then
printf '%s' "$rows" >"$rows_cache_file"
printf '%s' "$rows_signature" >"$rows_signature_file"
printf '%s' "$rows_fast_signature" >"$rows_fast_signature_file"
else
rm -f "$rows_cache_file" "$rows_signature_file" "$rows_fast_signature_file"
# Publish the cache under a lock and via renames: a picker killed mid-write,
# or two pickers interleaving, must never leave truncated or mismatched rows
# behind signatures that still validate. Rows go first so a kill between
# renames leaves signatures that are either older (a harmless miss) or
# describe the same directory state.
if exec {rows_lock_fd}>"$rows_cache_file.lock" && flock -w 30 "$rows_lock_fd"; then
if [[ $rows_cacheable == true ]]; then
rm -f "$cache_dir/$cache_key".*.tmp
printf '%s' "$rows" >"$rows_cache_file.$$.tmp" && mv -f "$rows_cache_file.$$.tmp" "$rows_cache_file"
printf '%s' "$rows_signature" >"$rows_signature_file.$$.tmp" && mv -f "$rows_signature_file.$$.tmp" "$rows_signature_file"
printf '%s' "$rows_fast_signature" >"$rows_fast_signature_file.$$.tmp" && mv -f "$rows_fast_signature_file.$$.tmp" "$rows_fast_signature_file"
else
rm -f "$rows_cache_file" "$rows_signature_file" "$rows_fast_signature_file"
fi
exec {rows_lock_fd}>&-
fi
fi
+1 -1
View File
@@ -38,7 +38,7 @@ id=$(cut -f2 <<<"$selection")
if [[ $1 == "clone" ]]; then
omarchy-launch-floating-terminal-with-presentation \
"omarchy-plugin-clone --edit $(printf '%q' "$id")"
"omarchy-plugin-clone $(printf '%q' "$id") --edit"
elif [[ $1 == "remove" ]]; then
omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove $(printf '%q' "$id")"
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
# invitation over and exit instead of staying activated until it is answered.
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
# 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
#!/bin/bash
export MISE_MINIMUM_RELEASE_AGE=0
mise use -g "$package" || exit 1
mise use -g --quiet "$package" || exit 1
exec mise x "$package" -- "$bin" "\$@"
EOF
+151 -63
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# 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 󰢌
set -euo pipefail
@@ -11,61 +11,75 @@ description=""
glyph=
urgency="low"
app_name="omarchy-action"
app_icon=""
image=
exec_command=
args=()
expire_timeout=-1
replaces_id=0
print_id=0
exec_args=()
exec_present=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() {
case $1 in
-g | --glyph)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
glyph=$2
parsed_option_args=2
local opt val nargs
if [[ $1 == --?*=* ]]; then
opt=${1%%=*}
val=${1#*=}
nargs=1
else
opt=$1
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
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)
if (($# < 2)); then
echo "Missing value for $1" >&2
-t | --expire-time)
[[ $val =~ ^-?[0-9]+$ ]] || {
echo "Invalid $opt value (milliseconds expected): $val" >&2
exit 1
fi
urgency="$2"
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
}
expire_timeout=$val
;;
esac
return 1
parsed_option_args=$nargs
return 0
}
while (($# > 0)); do
@@ -77,47 +91,121 @@ while (($# > 0)); do
done
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
fi
headline=$1
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
shift
fi
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"
else
args+=("$1")
shift
echo "Unknown option: $1" >&2
usage
exit 1
fi
done
# Tag as a user-action toast so it pops through DND.
args+=("-a" "$app_name" "-u" "$urgency")
case $urgency in
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
args+=("--hint=string:omarchy-glyph:$glyph")
hints+=(omarchy-glyph s "$glyph")
fi
if [[ -n $image ]]; then
args+=("--hint=string:image-path:$image")
hints+=(image-path s "$image")
fi
# The shell runs the click command itself, from a copy it keeps alongside the
# on-screen popup. A libnotify action would instead keep this process blocked
# until the click, and die unanswered whenever the shell restarts underneath it.
if [[ -n $exec_command ]]; then
args+=("--hint=string:omarchy-exec:$exec_command")
if ((exec_present)); then
if ((${#exec_args[@]} == 0)) || [[ -z ${exec_args[0]} ]]; then
echo "--exec needs a command: --exec <program> [args...]" >&2
exit 1
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
if [[ -n $description ]]; then
notify-send "${args[@]}" "$headline" "$description"
hint_count=$((${#hints[@]} / 3))
# 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
notify-send "${args[@]}" "$headline"
"${notify_cmd[@]}" >/dev/null
fi
+7
View File
@@ -93,6 +93,13 @@ if [[ -z $url ]]; then
[[ -n $url ]] || fail "a git URL is required"
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
cat >&2 <<WARN
+10 -1
View File
@@ -34,6 +34,15 @@ if [[ ! -f $logo_path ]]; then
exit 1
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_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}')
@@ -72,7 +81,7 @@ sed \
-e "s/#ffffff/#$text_hex/g" \
"$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
sudo cp "$staging_dir/$asset" "$sddm_dir/$asset"
done
+2
View File
@@ -72,6 +72,8 @@ run_first_run_step "install Voxtype post-update hook" \
omarchy-hook-install post-update "$OMARCHY_PATH/install/user/first-run/install-voxtype.hook"
run_first_run_step "install fingerprint setup post-update hook" \
omarchy-hook-install post-update "$OMARCHY_PATH/install/user/first-run/setup-fingerprint.hook"
run_first_run_step "install agent setup post-update hook" \
omarchy-hook-install post-update "$OMARCHY_PATH/install/user/first-run/setup-agent.hook"
run_first_run_step "enable user systemd units" \
bash "$OMARCHY_PATH/install/user/first-run/enable-user-units.sh"
+7 -1
View File
@@ -141,7 +141,8 @@ say() {
notice() {
clear_logo
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
}
@@ -676,6 +677,11 @@ user_groups() {
if [[ -f $PROVISIONING_DIR/groups ]]; then
while IFS= read -r group; do
[[ -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
[[ ",$groups," == *",$group,"* ]] || groups+=",$group"
done <"$PROVISIONING_DIR/groups"
+9 -4
View File
@@ -83,11 +83,16 @@ fi
# Dev-aware skill symlinks. Cannot live in /etc/skel because OMARCHY_PATH may
# point at a dev checkout (omarchy dev link) where the target differs.
# Loops every skill directory, so shipping a new one needs no edit here.
mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills
ln -sfn "$OMARCHY_PATH/default/agents/skills/omarchy" ~/.agents/skills/omarchy
ln -sfn "$OMARCHY_PATH/default/agents/skills/omarchy" ~/.claude/skills/omarchy
ln -sfn "$OMARCHY_PATH/default/agents/skills/omarchy" ~/.codex/skills/omarchy
ln -sfn "$OMARCHY_PATH/default/agents/skills/omarchy" ~/.pi/agent/skills/omarchy
for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
skill=${skill%/}
name=${skill##*/}
ln -sfn "$skill" ~/.agents/skills/"$name"
ln -sfn "$skill" ~/.claude/skills/"$name"
ln -sfn "$skill" ~/.codex/skills/"$name"
ln -sfn "$skill" ~/.pi/agent/skills/"$name"
done
mkdir -p ~/Downloads ~/Pictures ~/Videos ~/.config/gtk-3.0
xdg-user-dirs-update --set TEMPLATES "$HOME"
+4 -8
View File
@@ -20,19 +20,15 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w
omarchy-pkg-drop \
aether \
cliamp \
typora \
spotify \
libreoffice-fresh \
1password \
1password-cli \
xournalpp \
signal-desktop \
pinta \
obsidian \
obs-studio \
kdenlive \
moonlight-qt \
lazydocker \
opencode \
claude-code \
github-cli
omacut \
omacalc \
omawrite
fi
+7 -2
View File
@@ -24,9 +24,14 @@ echo -e "\e[32mRemoving FIDO2 device from authentication.\n\e[0m"
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..."
sudo rm -rf /etc/fido2
sudo rm -rf "$authdir"
fi
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
# 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
# Spawn from Hyprland so the shell inherits the canonical session environment,
+71 -4
View File
@@ -4,6 +4,7 @@
# omarchy:requires-sudo=true
set -e
set -o pipefail
check_fido2_hardware() {
@@ -50,13 +51,79 @@ if ! check_fido2_hardware; then
fi
# Create the pamu2fcfg file
if [[ ! -f /etc/fido2/fido2 ]]; then
sudo mkdir -p /etc/fido2
authdir=/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 "Touch your FIDO2 key when it lights up...\n"
if pamu2fcfg >/tmp/fido2; then
sudo mv /tmp/fido2 /etc/fido2/fido2
# A unique sibling created by root cannot be replaced by another process
# 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"
else
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
# 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
gum spin --spinner "globe" --title "Done! Press any key to close..." -- bash -c 'read -n 1 -s'
# The device node is there whether or not a terminal is behind it, so opening
# 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"
+3
View File
@@ -20,4 +20,7 @@ if pgrep -x "1password" >/dev/null && omarchy-cmd-present 1password; then
fi
# Avoid running screensaver when locked
pkill -x ttfx 2>/dev/null || true
# ttfx handles SIGTERM asynchronously, so keep its terminal alive until it exits.
timeout 1s pidwait -x ttfx 2>/dev/null || true
pkill -f '[o]rg.omarchy.screensaver' 2>/dev/null || true
+2 -2
View File
@@ -59,7 +59,7 @@ claim_path() {
announce() {
local path="$1"
local name="${path##*/}"
local args=("Received $name" "Saved to ${dir/#$HOME/~}")
local args=("Received $name" "Saved to ${dir/#$HOME/~}" -u critical)
case "${name,,}" in
*.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`
# 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() {
+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
+19 -5
View File
@@ -6,7 +6,7 @@
# omarchy:examples=omarchy theme install git@github.com:example/omarchy-example-theme.git
if [[ -z $1 ]]; then
echo -e "\e[32mSee https://manuals.omamix.org/2/the-omarchy-manual/90/extra-themes\n\e[0m"
echo -e "\e[32mSee https://omarchy.org/themes/\n\e[0m"
REPO_URL=$(gum input --placeholder="Git repo URL (https or git@host:org/repo.git)" --header="")
else
REPO_URL="$1"
@@ -16,24 +16,38 @@ if [[ -z $REPO_URL ]]; then
exit 1
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"
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path
REPO_PATH="$REPO_URL"
[[ $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"
# 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
if [[ -d $THEME_PATH ]]; then
rm -rf "$THEME_PATH"
fi
# 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."
exit 1
fi
# Apply the new theme with omarchy-theme-set
omarchy-theme-set $THEME_NAME
# Apply the new theme with omarchy-theme-set, which stages only the files an
# 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"
THEME_PATH="$THEMES_DIR/$THEME_NAME"
# Ensure a theme was set
if [[ -z $THEME_NAME ]]; then
# Ensure a theme was set, and that the name cannot climb out of THEMES_DIR
# on its way into the rm below.
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
exit 1
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"
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() {
local pid
local pids=()
@@ -122,12 +135,121 @@ set_theme_background() {
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_HEADLESS=0
if [[ ${OMARCHY_THEME_HEADLESS:-} == "1" || ${OMARCHY_THEME_OFFLINE:-} == "1" ]]; then
THEME_HEADLESS=1
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
echo "Theme '$THEME_NAME' does not exist"
exit 1
@@ -143,9 +265,15 @@ flock 9
rm -rf "$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 "$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
if [[ ! -f $NEXT_THEME_PATH/colors.toml && -f $NEXT_THEME_PATH/alacritty.toml ]]; then
+1 -1
View File
@@ -150,4 +150,4 @@ set_theme() {
! omarchy-toggle-enabled skip-vscode-theme-changes && set_theme "code" "$HOME/.config/Code/User/settings.json" "$HOME/.vscode/extensions"
! omarchy-toggle-enabled skip-vscode-insiders-theme-changes && set_theme "code-insiders" "$HOME/.config/Code - Insiders/User/settings.json" "$HOME/.vscode-insiders/extensions"
! omarchy-toggle-enabled skip-codium-theme-changes && set_theme "codium" "$HOME/.config/VSCodium/User/settings.json" "$HOME/.vscode-oss/extensions"
! omarchy-toggle-enabled skip-cursor-theme-changes && set_theme "cursor" "$HOME/.config/Cursor/User/settings.json" "$HOME/.cursor/extensions"
! omarchy-toggle-enabled skip-cursor-theme-changes && set_theme "/usr/bin/cursor" "$HOME/.config/Cursor/User/settings.json" "$HOME/.cursor/extensions"
+5 -5
View File
@@ -2,9 +2,9 @@
# omarchy:summary=Update user-installed git themes
for dir in ~/.config/omarchy/themes/*/; do
if [[ -d $dir ]] && [[ ! -L ${dir%/} ]] && [[ -d $dir/.git ]]; then
echo "Updating: $(basename "$dir")"
git -C "$dir" pull
fi
mapfile -t themes < <(omarchy-theme-extras)
for theme in "${themes[@]}"; do
echo "Updating: $(basename "$theme")"
git -C "$theme" pull
done
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# omarchy:summary=Toggle crash capture notifications
# nf-md-robot_dead, escaped so this file reads without a Nerd Font.
readonly CRASH_GLYPH=$'\U000f16a1'
omarchy-toggle crash-capture-off
# The unit checks the same flag with ConditionPathExists, so stopping and
# starting only settles this session; the next login reads the flag itself.
if omarchy-toggle-enabled crash-capture-off; then
systemctl --user stop omarchy-crash-watch.service
omarchy-notification-send -g "$CRASH_GLYPH" "Crash capture disabled"
else
systemctl --user start omarchy-crash-watch.service
omarchy-notification-send -g "$CRASH_GLYPH" "Crash capture enabled"
fi
+14 -1
View File
@@ -22,7 +22,20 @@ CONF
sudo systemctl enable --now supergfxd
fi
gpu_mode=$(supergfxctl -g)
gpu_mode=""
for attempt in {1..3}; do
if gpu_mode=$(timeout --kill-after=1s 3s supergfxctl -g 2>/dev/null) && [[ -n $gpu_mode ]]; then
break
fi
gpu_mode=""
(( attempt < 3 )) && sleep 1
done
if [[ -z $gpu_mode ]]; then
echo "supergfxd is not responding. Try again, or check: systemctl status supergfxd" >&2
exit 1
fi
case "$gpu_mode" in
"Integrated")
+40 -14
View File
@@ -7,44 +7,70 @@
KIND="${1:-}"
ACTION="${2:-toggle}"
usage() {
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
}
case "$KIND" in
touchpad) LABEL="Touchpad" ICON="touchpad" ;;
touchscreen) LABEL="Touchscreen" ICON="touch" ;;
*)
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
usage
exit 1
;;
esac
# Hyprland sources this directory on reload, so the disabled state survives restarts
STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled.lua"
# The persisted disable is the device name stored as plain data; on every
# 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")"
if [[ -z $device ]]; then
echo "No $KIND device found" >&2
exit 1
fi
require_device() {
if [[ -z $device ]]; then
echo "No $KIND device found" >&2
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() {
hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null
rm -f "$STATE_FILE"
# Clear the persisted state before requiring a usable device, so a device
# 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"
}
disable() {
hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null
mkdir -p "$(dirname "$STATE_FILE")"
printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE"
require_device
apply_device false
mkdir -p "$(dirname "$NAME_FILE")"
printf '%s\n' "$device" >"$NAME_FILE"
omarchy-osd -i "$ICON" -m "$LABEL disabled"
}
case "$ACTION" in
on) enable ;;
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
;;
esac
+12
View File
@@ -21,7 +21,15 @@ trap 'omarchy-update-stay-awake stop' EXIT
omarchy-update-requires-free-space
# -y is a promise not to ask anything. Steps that would need an answer report
# and move on instead of waiting on a prompt nobody is here to give.
[[ ${1:-} != "-y" ]] || export OMARCHY_UPDATE_UNATTENDED=1
if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then
# Before the snapshot: the cache is on the snapshotted subvolume, so pruning
# after it frees nothing until that snapshot ages out.
omarchy-update-pkg-prune
# 127 means Snapper is deliberately absent. Any other failure already said
# what went wrong, and a missing snapshot is not worth blocking an update
# over, but it must not pass for one either.
@@ -32,6 +40,10 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then
omarchy-update-dev
omarchy-update-keyring
# Migrations ship with the packages installed here and are written against
# them, so everything below waits on this finishing. An upgrade that stopped
# takes the update with it rather than migrating against what is still on disk.
omarchy-update-system-pkgs
omarchy-migrate
omarchy-hook post-update
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# omarchy:summary=Prune superseded versions from the pacman package cache
# omarchy:requires-sudo=true
set -e
# The cache is the only offline downgrade path, so keep two. Run before the
# packages update and the installed version survives with a spare. paccache
# retains by version order, never by what is installed.
echo -e "\e[32m\nPrune package cache\e[0m"
sudo paccache -rk2 || echo -e "\e[33mCould not prune the package cache.\e[0m" >&2
echo
+11 -3
View File
@@ -5,6 +5,14 @@
set -e
# The conflict handler's last resort: pacman puts its questions to the person
# running the update instead of answering them itself. Nothing here may capture
# a stream, because an upgrade without --noconfirm prompts on stderr. The
# handler has already said why, so no heading here either.
if [[ ${OMARCHY_UPDATE_CONFLICT:-} == 1 && ${OMARCHY_UPDATE_INTERACTIVE:-} == 1 ]]; then
exec sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --overwrite '/usr/share/omarchy/*'
fi
echo -e "\e[32m\nUpdate system packages\e[0m"
errors=$(mktemp)
@@ -22,9 +30,9 @@ if sudo env LC_ALL=C OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm \
fi
cat "$errors" >&2
# An upgrade blocked only by files pacman doesn't own yet is the one failure
# worth retrying: the handler clears them and runs this again. Anything else,
# including a second failure, is for a human.
# The handler takes it from here: it clears files pacman doesn't own yet and
# runs this again, and puts a package conflict to whoever started the update.
# Anything else, including a second failure, is for a human.
[[ ${OMARCHY_UPDATE_RETRY:-} != 1 ]] || exit 1
# exec, so the EXIT trap above does not fire and the handler can still read the
# report. It takes over deleting it.
+25 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# omarchy:summary=Retry a system package update after resolving file conflicts
# omarchy:summary=Retry a system package update that hit a conflict
# omarchy:hidden=true
# Internal to omarchy-update-system-pkgs. Not a command to run by hand: it acts
@@ -11,6 +11,10 @@
# release's installer, an in-place edit -- belong to nobody, and pacman refuses
# to install over a file it doesn't own. This clears them and runs the upgrade
# again.
#
# Packages that conflict with each other are the other blocked upgrade, and that
# one is a decision rather than a cleanup, so it goes back to the person
# watching the update.
set -e
@@ -57,6 +61,26 @@ restore_moved() {
trap 'restore_moved; rm -f "$errors"' EXIT
trap 'exit 1' INT TERM
# Pacman answers its own conflict question with No under --noconfirm, so one
# retired package can stop every upgrade after it. Which package to drop is a
# decision -- an upstream split retires one, but so does a package the user
# picked on purpose -- so put the question to whoever started the update rather
# than guess at it. Pacman printed what conflicts just above.
if grep -q 'unresolvable package conflicts detected' "$errors"; then
# -y promised not to ask anything, and without a terminal there is nowhere to
# ask; either way pacman would sit on a prompt nobody answers. An upgrade that
# isn't running --noconfirm puts its questions on stderr and reads the answers
# from stdin, so those are the two that have to be a terminal.
if [[ ${OMARCHY_UPDATE_UNATTENDED:-} == 1 || ! -t 0 || ! -t 2 ]]; then
echo -e "\e[33m\nThis upgrade needs an answer. Run omarchy update interactively to give it.\e[0m" >&2
exit 1
fi
echo -e "\e[33m\nA package conflict stopped this upgrade. Running it again so you can answer:\e[0m"
OMARCHY_UPDATE_INTERACTIVE=1 omarchy-update-system-pkgs
exit 0
fi
# Paths one of these packages installs that pacman doesn't own. Moving them out
# of the way is what lets the upgrade through, and works on a leftover directory
# as well as a file.
+3 -80
View File
@@ -1317,9 +1317,9 @@ apply_system_transition() {
printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \
as_root tee /usr/lib/chromium/initial_preferences >/dev/null
if getent group docker >/dev/null; then
as_root usermod -aG docker "$target_user"
fi
# Deliberately do NOT add the user to the docker group. That group is
# root-equivalent, so it is opt-in now (Setup > Security > Sudoless Docker);
# re-granting it on upgrade would silently undo that default for everyone.
if [[ -f /usr/bin/powerprofilesctl ]]; then
as_root sed -i '/env python3/ c\#!/bin/python3' /usr/bin/powerprofilesctl || true
@@ -1792,82 +1792,6 @@ CHROMIUM_FLAGS_PATCH_PY
done
}
# True if any Chromium-family browser that stores the Copy URL shortcut is running.
browser_is_running() {
pgrep -x 'chromium|chrome|brave|msedge|vivaldi-bin|vivaldi|opera|helium' >/dev/null 2>&1
}
repair_chromium_copy_url_shortcuts() {
local profile_root preferences
local -a profile_roots=(
"$HOME/.config/chromium"
"$HOME/.config/google-chrome"
"$HOME/.config/google-chrome-beta"
"$HOME/.config/BraveSoftware/Brave-Browser"
"$HOME/.config/BraveSoftware/Brave-Browser-Beta"
"$HOME/.config/microsoft-edge"
"$HOME/.config/microsoft-edge-beta"
"$HOME/.config/vivaldi"
"$HOME/.config/opera"
"$HOME/.config/helium"
)
local -a pending=()
for profile_root in "${profile_roots[@]}"; do
[[ -d $profile_root ]] || continue
for preferences in "$profile_root"/*/Preferences; do
[[ -f $preferences ]] || continue
grep -qF 'bocglpkldciamkbmlphanhkfnhpmnbma' "$preferences" || continue
pending+=("$preferences")
done
done
(( ${#pending[@]} )) || return 0
# A running browser holds Preferences in memory and rewrites it on exit,
# which would revert this repair. Ask the user to close it first.
if browser_is_running && (( ! OMARCHY_UPGRADE_YES )) && [[ -r /dev/tty ]]; then
if ! gum confirm "Close all browser windows before the Copy URL shortcut is repaired, then continue" </dev/tty; then
echo "Skipped Copy URL shortcut repair; re-run after closing your browser." >&2
return 0
fi
fi
for preferences in "${pending[@]}"; do
python3 - "$preferences" "$preferences.omarchy-upgrade-to-quattro.$BACKUP_SUFFIX.bak" <<'CHROMIUM_SHORTCUTS_PATCH_PY'
import json
import shutil
import sys
from pathlib import Path
path = Path(sys.argv[1])
backup_path = Path(sys.argv[2])
old_id = "bocglpkldciamkbmlphanhkfnhpmnbma"
new_id = "bgpiichlckmfanooecilcjemknkcpngb"
preferences = json.loads(path.read_text())
extensions = preferences.get("extensions", {})
commands = extensions.get("commands", {})
changed = False
for command in commands.values():
if command.get("extension") == old_id and command.get("command_name") == "copy-url":
command["extension"] = new_id
changed = True
if changed:
settings = extensions.get("settings", {})
old_command = settings.get(old_id, {}).get("commands", {}).get("copy-url", {})
new_command = settings.get(new_id, {}).get("commands", {}).get("copy-url", {})
old_command.pop("was_assigned", None)
if new_command:
new_command["was_assigned"] = True
shutil.copy2(path, backup_path)
path.write_text(json.dumps(preferences, separators=(",", ":")))
CHROMIUM_SHORTCUTS_PATCH_PY
done
}
repair_sleep_lock_unit_override() {
local unit="$HOME/.config/systemd/user/omarchy-sleep-lock.service"
local legacy_exec='ExecStart=%h/.local/share/omarchy/bin/omarchy-system-sleep-monitor'
@@ -1964,7 +1888,6 @@ copy_missing_config_defaults "$root/config" "$HOME/.config"
mark_removed_preinstalls_from_legacy_bindings
copy_always_config_defaults
repair_chromium_copy_url_extension_flags
repair_chromium_copy_url_shortcuts
# Carry over user-added backgrounds from the legacy git checkout backup, then
# remove old symlinks that pointed ~/.config/omarchy/themes/* back into the
+487 -117
View File
@@ -4,11 +4,371 @@
# omarchy:args=<install|remove|launch|stop|status> [options]
# 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() {
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
if [[ ! -e /dev/kvm ]]; then
@@ -26,7 +386,7 @@ check_prerequisites() {
# Check disk space
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 " Available: ${AVAILABLE_SPACE}GB"
echo " Required: ${REQUIRED_SPACE}GB (${DISK_SIZE_GB}GB disk + 10GB for Windows image)"
@@ -34,6 +394,8 @@ check_prerequisites() {
fi
}
# --- commands ----------------------------------------------------------------
install_windows() {
# Set up trap to handle Ctrl+C
trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT
@@ -43,10 +405,9 @@ install_windows() {
omarchy-pkg-add freerdp openbsd-netcat gum
mkdir -p "$HOME/.windows"
mkdir -p "$HOME/.config/windows"
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]
Name=Windows
Comment=Start Windows VM via Docker and connect with RDP
@@ -70,7 +431,7 @@ EOF
RAM_OPTIONS=""
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"
fi
done
@@ -91,16 +452,16 @@ EOF
exit 1
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"
SELECTED_CORES=2
fi
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
if (( MAX_DISK_GB < 32 )); then
if ((MAX_DISK_GB < 32)); then
echo "❌ Insufficient disk space for Windows VM!"
echo " Available: ${AVAILABLE_SPACE}GB"
echo " Minimum required: 42GB (32GB disk + 10GB for Windows image)"
@@ -109,7 +470,7 @@ EOF
DISK_OPTIONS=""
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"
fi
done
@@ -139,6 +500,10 @@ EOF
if [[ -z $USERNAME ]]; then
USERNAME="docker"
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:")
if [[ -z $PASSWORD ]]; then
@@ -147,6 +512,11 @@ EOF
else
PASSWORD_DISPLAY="(user-defined)"
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
gum style \
@@ -170,38 +540,19 @@ EOF
exit 1
fi
mkdir -p $HOME/Windows
mkdir -p "$HOME/Windows"
# Create docker-compose.yml in user config directory
cat << EOF | tee "$COMPOSE_FILE" > /dev/null
services:
windows:
image: dockurr/windows
container_name: omarchy-windows
environment:
VERSION: "11"
RAM_SIZE: "$SELECTED_RAM"
CPU_CORES: "$SELECTED_CORES"
DISK_SIZE: "$SELECTED_DISK"
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
local tz
tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC)
# Write the root-owned compose from the validated settings (one prompt if
# sudoless Docker is off), then bring the stack up.
write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \
"$USERNAME" "$PASSWORD" "$tz" "$HOME/.windows" "$HOME/Windows" || {
echo "❌ Failed to write the Windows VM configuration."
exit 1
}
write_credentials "$USERNAME" "$PASSWORD"
echo ""
echo "Starting Windows VM installation..."
@@ -210,14 +561,12 @@ EOF
echo "Monitor installation progress at: http://127.0.0.1:8006"
echo ""
# Start docker-compose with user's config
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 " Common issues:"
echo " - Docker daemon not running: sudo systemctl start docker"
echo " - Port already in use: check if another VM is running"
echo " - Permission issues: make sure you're in the docker group"
exit 1
fi
@@ -237,7 +586,6 @@ EOF
echo "Once finished, launch 'Windows' via Super + Space"
echo ""
echo "To stop the VM: omarchy-windows-vm stop"
echo "To change resources: ~/.config/windows/docker-compose.yml"
echo ""
}
@@ -249,11 +597,12 @@ remove_windows() {
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 "$HOME/.local/share/applications/windows-vm.desktop"
rm -f "$HOME/.local/share/applications/windows-vm.desktop"
rm -rf "$HOME/.config/windows"
rm -rf "$HOME/.windows"
@@ -267,49 +616,30 @@ launch_windows() {
KEEP_ALIVE=true
fi
# Check if config exists
if [[ ! -f $COMPOSE_FILE ]]; then
echo "Windows VM not configured. Please run: omarchy-windows-vm install"
exit 1
if ! migrate_legacy_compose; then
if [[ ! -f $COMPOSE_FILE ]]; then
echo "Windows VM not configured. Please run: omarchy-windows-vm install"
exit 1
fi
fi
# Extract credentials from compose file
WIN_USER=$(grep "USERNAME:" "$COMPOSE_FILE" | sed 's/.*USERNAME: "\(.*\)"/\1/')
WIN_PASS=$(grep "PASSWORD:" "$COMPOSE_FILE" | sed 's/.*PASSWORD: "\(.*\)"/\1/')
# Use defaults if not found
# RDP credentials come from the private per-user file. Fall back to the compose
# only when it is readable (sudoless mode), reversing the writer's escaping.
WIN_USER=$(read_credential USERNAME) || WIN_USER=""
WIN_PASS=$(read_credential PASSWORD) || WIN_PASS=""
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_PASS ]] && WIN_PASS="admin"
# Check if container is already running
CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null)
if [[ $CONTAINER_STATUS != "running" ]]; then
echo "Starting Windows VM..."
# 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
echo "Starting Windows VM (this may prompt for authorization)..."
if ! priv up_wait; then
echo "❌ Failed to start Windows VM!"
echo " Try checking: omarchy-windows-vm status"
omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM"
exit 1
fi
# Build the connection info
@@ -329,14 +659,27 @@ To stop: omarchy-windows-vm stop"
"" \
"$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
HYPR_SCALE=$(hyprctl monitors -j | jq -r '.[] | select (.focused == true) | .scale')
SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}')
RDP_SCALE=""
if (( SCALE_PERCENT >= 170 )); then
if ((SCALE_PERCENT >= 170)); then
RDP_SCALE="/scale:180"
elif (( SCALE_PERCENT >= 130 )); then
elif ((SCALE_PERCENT >= 130)); then
RDP_SCALE="/scale:140"
fi
# 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
echo ""
echo "RDP session closed. Stopping Windows VM..."
docker-compose -f "$COMPOSE_FILE" down
echo "Windows VM stopped."
if priv down; then
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
echo ""
echo "RDP session closed. Windows VM is still running."
@@ -358,24 +705,34 @@ To stop: omarchy-windows-vm stop"
}
stop_windows() {
migrate_legacy_compose 2>/dev/null || true
if [[ ! -f $COMPOSE_FILE ]]; then
echo "Windows VM not configured."
exit 1
fi
echo "Stopping Windows VM..."
docker-compose -f "$COMPOSE_FILE" down
echo "Windows VM stopped."
if priv down; then
echo "Windows VM stopped."
else
echo "⚠️ Could not stop the Windows VM (authorization declined?). It may still be running."
exit 1
fi
}
status_windows() {
migrate_legacy_compose 2>/dev/null || true
if [[ ! -f $COMPOSE_FILE ]]; then
echo "Windows VM not configured."
echo "To set up: omarchy-windows-vm install"
exit 1
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
echo "Windows VM container not found."
@@ -421,28 +778,41 @@ show_usage() {
# Main command dispatcher
case "$1" in
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
__priv)
((EUID == 0)) || {
echo "omarchy-windows-vm __priv must run as root" >&2
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
+2 -2
View File
@@ -5,8 +5,8 @@ profile {
identity = true
}
# To enable auto switch to nightlight, set in your .config/hypr/autostart:
# exec-once = uwsm app -- hyprsunset
# To enable auto switch to nightlight, add to your .config/hypr/autostart.lua:
# o.launch_on_start("hyprsunset")
# and use the following:
# profile {
# time = 20:00
@@ -4,4 +4,4 @@
# To put it into use, remove .sample from this file name.
# 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
if [[ -n $weather && $weather != "Weather unavailable" ]]; then
notify-send -u low "$weather"
omarchy-notification-send -u low "$weather"
fi
@@ -4,4 +4,4 @@
# To put it into use, remove .sample from this file name.
# 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.
# 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"
@@ -0,0 +1,97 @@
---
name: diagnose-crash
description: >
Diagnose why a program crashed on this machine, from a systemd-coredump core dump.
Use when a process has segfaulted, aborted, or otherwise dumped core, when asked
why an application crashed or disappeared, or when a "Process crashed:" desktop
notification is acted on. Triggers: crash, segfault, SIGSEGV, SIGABRT, core dump,
coredumpctl, "why did X crash", "X keeps crashing", backtrace symbolization.
Covers reporting a confirmed Omarchy bug upstream — see reporting.md.
---
# Diagnosing a Crash
Work from evidence. The goal is an honest account of what happened, not a
plausible-sounding story.
## Establish the facts
`coredumpctl info <pid>` is the starting point. Beyond the backtrace, note the
**command line** the process was started with — it usually reveals what the
program was working on when it died, which is often the whole answer.
`coredumpctl list` shows whether this crash is a one-off or a pattern. Repeated
crashes of the same program, or several programs dying together, point somewhere
different than a single failure does.
## Rule out the boring causes first
Check resource exhaustion before blaming the program: `free -h`, and the journal
for OOM kills. A process killed by the OOM killer is not a bug in that process.
## Correlate against the timeline
The crash timestamp is the most underused piece of evidence. Compare it against:
- **Filesystem mtimes.** A directory or file whose mtime lands on the same second
as the crash strongly suggests what triggered it.
- **The journal** around that moment, for related warnings from the same or
neighbouring processes.
- **Recent package updates.** A crash that starts right after an update points at
the update.
## Read the whole core, not just frame 0
Thread stacks other than the crashing one show what work was **in flight** —
thumbnailers, image loaders, IPC readers, GPU queues. That context often explains
the trigger even when the crashing frame itself cannot be symbolized.
Note any third-party code in the address space: file-manager or browser
extensions, plugins, out-of-tree drivers. In-process third-party code is a common
crash source and worth flagging — but do not pin blame on it without evidence
that it is actually implicated.
## Symbolize when you can
This is Arch, which runs a public debuginfod server:
```bash
core=$(mktemp -t crash-XXXXXX.core)
trap 'rm -f "$core"' EXIT
coredumpctl dump <pid> --output="$core"
DEBUGINFOD_URLS="https://debuginfod.archlinux.org" \
gdb -q <executable> "$core" \
-batch -ex 'set debuginfod enabled on' -ex 'bt'
```
A core is a verbatim copy of the process's memory and can hold passwords, tokens,
and private documents. Write it to a fresh `mktemp` path rather than a predictable
shared one, and delete it when you are done — never leave it lying in `/tmp`.
Many packages publish no debug symbols. When frames stay unresolved, say so —
never invent function names to fill the gap. An unsymbolized stack still has
shape: which library each frame belongs to, and whether the crash came from a
signal handler, a main loop, or a worker thread.
## Report
1. What crashed, and what it was doing at the time.
2. The most likely mechanism — separating clearly what the evidence **proves**
from what you are **inferring**.
3. Whether any user data was lost, and where it can be recovered from. Check the
trash before concluding anything is gone.
4. Whether it is likely to recur, and what would avoid or fix it.
Be straight about the limits of the evidence. If the cause is genuinely
ambiguous, say so rather than assembling confidence out of guesswork.
**Leave the system as you found it.** Diagnosis reads; it does not fix, tidy, or
reconfigure. The one thing to clean up is your own: delete the core you extracted
above, which is a copy of the crashed process's memory.
## If it is an Omarchy bug
Most application crashes are upstream bugs in those applications, not Omarchy's
doing. In the minority of cases where the cause really does sit within Omarchy's
sphere of control, read [`reporting.md`](reporting.md) before offering to file
anything.
@@ -0,0 +1,104 @@
# Reporting a Crash Upstream to Omarchy
Read this only after concluding that a crash is genuinely Omarchy's to fix.
## Is it even Omarchy's bug?
Be strict here. Omarchy is a configuration layer over Arch Linux, so a crash
inside a third-party application — a file manager, a browser, a GNOME or Qt
library — is almost always an upstream bug in **that** project, not in Omarchy.
Omarchy's sphere of control is roughly:
- the `omarchy-*` commands
- the Quickshell shell and its plugins
- the Hyprland and terminal configuration it ships
- its themes
- its install and migration scripts
- how it packages and configures what it installs
A crash in a program Omarchy merely installs is **not** an Omarchy bug unless
Omarchy's own packaging or configuration is implicated.
If it is not Omarchy's, say so and stop. Suggesting the right upstream project is
useful; filing there yourself is not part of this.
## Three conditions, all required
1. **It is a verified bug in Omarchy's sphere**, established on evidence. Issues
are for verified bugs only. An "is this even a bug?" belongs on the Discord at
<https://omarchy.org/discord>; a feature idea belongs in GitHub Discussions
under Suggestions.
2. **The user has explicitly agreed.** Show them the exact title and body you
propose, and wait for a yes. Never file unprompted.
3. **The machine can file it** — `gh auth status` must succeed. If `gh` is missing
or unauthenticated, do not install or authenticate it. Say so, and hand the
user the finished text to submit themselves.
## Search before filing
A duplicate issue costs a maintainer more time than no report at all.
```bash
gh search issues --repo basecamp/omarchy "<program> crash"
gh issue list --repo basecamp/omarchy --state all --search "<signal> <program>"
```
Search on the crashing program, the signal, and distinctive symbols from the
backtrace — not on the wording of the title you were about to write.
`gh search issues` accepts only `open` or `closed` for `--state`, and errors on
anything else. Leaving it off searches both, which is what you want here.
Include **closed** issues. A matching issue closed as fixed, when the crash still
reproduces on a current system, is a regression — and reporting that is worth far
more than another duplicate.
## Adding to an existing report
If a plausible match comes back, read it properly first:
```bash
gh issue view <number> --repo basecamp/omarchy --comments
```
Confirm it is genuinely the same failure. The same program crashing is not the
same bug if the trigger or the stack differs.
If it is the same, add to that issue rather than opening a new one — but only
when you have something the thread does not already contain: a different
reproduction, a symbolized stack where it has none, a narrower trigger, a version
where it regressed.
A comment that only says the bug happens to you too is noise. If that is all you
have, tell the user so and file nothing.
```bash
gh issue comment <number> --repo basecamp/omarchy --body "..."
```
## Filing a new issue
Only when the search turns up nothing that matches:
```bash
gh issue create --repo basecamp/omarchy --title "..." --body "..."
```
Include what happened, what was expected, steps to reproduce, system details from
`omarchy version`, and diagnostics from `omarchy debug --no-sudo --print` (which
also writes `/tmp/omarchy-debug.log`; the interactive `omarchy debug` can upload
it and print a shareable URL worth including).
`gh` cannot attach media. If a screenshot would help, save one and give the user
the path to drag into the web form.
## Signing
End the issue or comment with a line naming the model and agent harness that
produced it, so a human reader knows it was machine-authored:
> Filed by \<model name\> via \<agent harness\>.
Use your actual model and harness names. If you are not certain of them, say so
plainly rather than inventing a version string.
+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
`~/.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
Never edit stock themes under `/usr/share/omarchy/themes/` — changes are lost
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
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:
+3 -3
View File
@@ -43,10 +43,10 @@ alias ...='cd ../..'
alias ....='cd ../../..'
# Tools
alias a='omarchy-launch-agent --inline'
alias a='omarchy-agent --inline'
alias c='opencode --auto'
alias cx='printf "\033[2J\033[3J\033[H" && claude --permission-mode bypassPermissions'
alias cy='codex -s danger-full-access -a never'
alias cx='printf "\033[2J\033[3J\033[H" && claude --permission-mode auto'
alias cy='codex --approve-for-me'
alias d='docker'
alias r='rails'
alias t='tmux attach || tmux new -s Work'
@@ -2,12 +2,19 @@
"manifest_version": 3,
"name": "WhatsApp Slim",
"version": "1.1",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAszLhFYxjvKqx1VAORt3M6b0H1Kz03cmQ1MrUjJZ8gj3nhRcvufk6PS17yUIjEdKKmuj+1HuFVwOaHJQsACGJXwn2TeKQqZmZx/83yd7ydgabHXTsJbhy5O+Yunv+VA1B2IX/phcXpkH9bPv2PuC/bQ13nmmAvT2b+EMyq8EPQHOoSWhE4M4kIhLDczTmxfJX5X3tTxH66952DYuQWvGpIXLAUXkvIgap0qemW68/LymPaWzcg6g3oE/nZ7C0j7JE5bnqLFxqXNlA9Jt+Ss/1Y33QL3OSD9EEsrpgueS0bbqtkmFQ9/jGlruIL+qj0xe+CAtqbJ5ty9H/sqi01kjfyQIDAQAB",
"description": "Make WhatsApp Web follow the system theme and collapse its chat list in narrow windows, this extension is installed by Omarchy",
"content_scripts": [
{
"matches": ["https://web.whatsapp.com/*"],
"css": ["whatsapp.css"],
"js": ["system-theme.js"],
"matches": [
"https://web.whatsapp.com/*"
],
"css": [
"whatsapp.css"
],
"js": [
"system-theme.js"
],
"run_at": "document_start"
}
]
+2
View File
@@ -8,6 +8,8 @@ The private-use glyphs in `omarchy.ttf` are:
- `U+E903` — omp, from <https://omp.sh/favicon.svg>
- `U+E904` — Grok, from <https://grok.com/images/favicon.svg>
- `U+E905` — Codex, from the OpenAI mark on <https://developers.openai.com/codex/>
- `U+E906` — LM Studio, from <https://simpleicons.org/icons/lmstudio.svg>
- `U+E907` — Ollama, from <https://simpleicons.org/icons/ollama.svg>
The agent marks are monochrome so the menu can render them using the active
theme's foreground and selection colors.
Binary file not shown.
+3 -2
View File
@@ -20,8 +20,9 @@ o.window({
}, { tag = "+floating-window" })
-- The About fastfetch layout needs more columns than the standard float provides.
-- This size only positions the first paint: omarchy-launch-about then measures
-- the rendered content and fits the window to it exactly.
-- This size only covers the first launch: omarchy-launch-about measures the
-- rendered content, remembers the size that hugs it, and applies that as its own
-- rule before every later launch.
o.window("org.omarchy.about", { float = true })
o.window("org.omarchy.about", { center = true })
o.window("org.omarchy.about", { size = { 920, 480 } })
+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 + SHIFT + M", "Music", { omarchy = "spotify" })
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 + O", "Obsidian", { launch = "obsidian", focus = "^obsidian$" })
o.bind("SUPER + SHIFT + W", "Omawrite", { launch = "omawrite" })
+1 -1
View File
@@ -93,7 +93,7 @@ o.bind("SUPER + CTRL + ALT + T", "Show time", "omarchy-notification-time")
o.bind("SUPER + CTRL + ALT + B", "Show battery remaining", "omarchy-notification-battery")
o.bind("SUPER + CTRL + ALT + W", "Toggle weather", "omarchy-notification-weather")
o.bind("SUPER + SHIFT + CTRL + A", "Agent", "omarchy-launch-agent")
o.bind("SUPER + SHIFT + CTRL + A", "Agent", "omarchy-agent --pick")
o.bind("SUPER + CTRL + A", "Audio", "omarchy-shell shell toggle omarchy.audio")
o.bind("SUPER + CTRL + B", "Bluetooth", "omarchy-shell shell toggle omarchy.bluetooth")
o.bind("SUPER + CTRL + D", "Display", "omarchy-shell shell toggle omarchy.monitor")
+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
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)
local ok, _, code = os.execute(command .. " >/dev/null 2>&1")
return ok == true or ok == 0 or code == 0
-- Subshell, so the redirection covers every command rather than binding to
-- 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
function o.cmd_present(command)
@@ -125,7 +136,7 @@ function o.bind_toggle(keys, description, toggle, options)
end
function o.notify(message)
return "notify-send -u low " .. shell_quote(message)
return "omarchy-notification-send -u low " .. shell_quote(message)
end
function o.window(match, rules)
+13 -3
View File
@@ -4,9 +4,19 @@
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 {
home = home,
config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config"),
state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state"),
omarchy_path = os.getenv("OMARCHY_PATH") or "/usr/share/omarchy",
config_home = env_or("XDG_CONFIG_HOME", home .. "/.config"),
state_home = env_or("XDG_STATE_HOME", home .. "/.local/state"),
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.
-- 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 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 = {}
@@ -12,19 +14,23 @@ local function shell_quote(path)
end
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")
if handle then
for filename in handle:lines() do
local module = filename:gsub("%.lua$", "")
if module_prefix then
module = module_prefix .. "." .. module
end
local name = filename:gsub("%.lua$", "")
if not exclude[name] then
local module = name
if module_prefix then
module = module_prefix .. "." .. module
end
if options and options.reload then
package.loaded[module] = nil
end
if options and options.reload then
package.loaded[module] = nil
end
require(module)
require(module)
end
end
handle:close()
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"
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")
+14 -8
View File
@@ -38,7 +38,7 @@
// Learn
"learn.keybindings": {"icon":"","label":"Keybindings","action":"omarchy-menu-keybindings"},
"learn.omarchy": {"icon":"","label":"Omarchy","action":"omarchy-launch-webapp 'https://learn.omacom.io/2/the-omarchy-manual'"},
"learn.omarchy": {"icon":"","label":"Omarchy","action":"omarchy-launch-webapp 'https://omarchy.org/manual/'"},
"learn.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-webapp 'https://wiki.hypr.land/'"},
"learn.arch": {"icon":"󰣇","label":"Arch","action":"omarchy-launch-webapp 'https://wiki.archlinux.org/title/Main_page'"},
"learn.neovim": {"icon":"","label":"Neovim","action":"omarchy-launch-webapp 'https://www.lazyvim.org/keymaps'"},
@@ -84,6 +84,7 @@
"trigger.share.receive": {"icon":"󰥦","label":"Receive","action":"uwsm-app -- localsend"},
"trigger.toggle.idle-lock": {"icon":"󰅶","label":"Stay Awake","action":"omarchy-toggle-idle"},
"trigger.toggle.notifications": {"icon":"󰂛","label":"Notifications","action":"omarchy-toggle-notification-silencing"},
"trigger.toggle.crash-capture": {"icon":"󱚡","label":"Crash Capture","action":"omarchy-toggle-crash-capture"},
"trigger.toggle.screensaver": {"icon":"󱄄","label":"Screensaver","action":"omarchy-toggle-screensaver"},
"trigger.toggle.nightlight": {"icon":"󰔎","label":"Nightlight","action":"omarchy-toggle-nightlight"},
"trigger.toggle.top-bar": {"icon":"󰍜","label":"Menu Bar","action":"omarchy-toggle-bar"},
@@ -128,7 +129,7 @@
"setup.network.dns.custom": {"icon":"","label":"Custom","checked":"[[ \"$(omarchy-dns)\" == \"Custom\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-dns Custom'"},
"setup.network.qr": {"icon":"󰐲","label":"QR Code","aliases":["wifi-qr"],"when":"[[ $(omarchy-network-status) == wifi* ]]","action":"omarchy-shell shell summon omarchy.wifiqr"},
"setup.default": {"icon":"","label":"Defaults","aliases":["default","defaults"]},
"setup.default.agent": {"icon":"󰚩","label":"Agent"},
"setup.default.agent": {"icon":"󰚩","label":"Agent","title":"Default Agent"},
"setup.default.agent.claude": {"icon":"󰛄","label":"Claude","checked":"[[ \"$(omarchy-default-agent)\" == \"claude\" ]]","action":"omarchy-default-agent claude"},
"setup.default.agent.codex": {"icon":"","iconFont":"omarchy","label":"Codex","checked":"[[ \"$(omarchy-default-agent)\" == \"codex\" ]]","action":"omarchy-default-agent codex"},
"setup.default.agent.copilot": {"icon":"","label":"Copilot","checked":"[[ \"$(omarchy-default-agent)\" == \"copilot\" ]]","action":"omarchy-default-agent copilot"},
@@ -138,7 +139,7 @@
"setup.default.agent.omp": {"icon":"","iconFont":"omarchy","label":"omp","checked":"[[ \"$(omarchy-default-agent)\" == \"omp\" ]]","action":"omarchy-default-agent omp"},
"setup.default.agent.opencode": {"icon":"","iconFont":"omarchy","label":"OpenCode","checked":"[[ \"$(omarchy-default-agent)\" == \"opencode\" ]]","action":"omarchy-default-agent opencode"},
"setup.default.agent.pi": {"icon":"","iconFont":"omarchy","label":"Pi","checked":"[[ \"$(omarchy-default-agent)\" == \"pi\" ]]","action":"omarchy-default-agent pi"},
"setup.default.browser": {"icon":"","label":"Browser"},
"setup.default.browser": {"icon":"","label":"Browser","title":"Default Browser"},
"setup.default.browser.chromium": {"icon":"","label":"Chromium","when":"omarchy-cmd-present chromium","checked":"[[ \"$(omarchy-default-browser)\" == \"chromium\" ]]","action":"omarchy-default-browser chromium"},
"setup.default.browser.chrome": {"icon":"󰊯","label":"Chrome","when":"omarchy-cmd-present google-chrome-stable","checked":"[[ \"$(omarchy-default-browser)\" == \"chrome\" ]]","action":"omarchy-default-browser chrome"},
"setup.default.browser.brave": {"icon":"󰖟","label":"Brave","when":"omarchy-cmd-present brave","checked":"[[ \"$(omarchy-default-browser)\" == \"brave\" ]]","action":"omarchy-default-browser brave"},
@@ -146,12 +147,12 @@
"setup.default.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-cmd-present microsoft-edge-stable","checked":"[[ \"$(omarchy-default-browser)\" == \"edge\" ]]","action":"omarchy-default-browser edge"},
"setup.default.browser.firefox": {"icon":"","label":"Firefox","when":"omarchy-cmd-present firefox","checked":"[[ \"$(omarchy-default-browser)\" == \"firefox\" ]]","action":"omarchy-default-browser firefox"},
"setup.default.browser.zen": {"icon":"󰖟","label":"Zen","when":"omarchy-cmd-present zen-browser","checked":"[[ \"$(omarchy-default-browser)\" == \"zen\" ]]","action":"omarchy-default-browser zen"},
"setup.default.terminal": {"icon":"","label":"Terminal"},
"setup.default.terminal": {"icon":"","label":"Terminal","title":"Default Terminal"},
"setup.default.terminal.alacritty": {"icon":"","label":"Alacritty","when":"omarchy-cmd-present alacritty","checked":"[[ \"$(omarchy-default-terminal)\" == \"alacritty\" ]]","action":"omarchy-default-terminal alacritty"},
"setup.default.terminal.foot": {"icon":"","label":"Foot","when":"omarchy-cmd-present foot","checked":"[[ \"$(omarchy-default-terminal)\" == \"foot\" ]]","action":"omarchy-default-terminal foot"},
"setup.default.terminal.ghostty": {"icon":"","label":"Ghostty","when":"omarchy-cmd-present ghostty","checked":"[[ \"$(omarchy-default-terminal)\" == \"ghostty\" ]]","action":"omarchy-default-terminal ghostty"},
"setup.default.terminal.kitty": {"icon":"","label":"Kitty","when":"omarchy-cmd-present kitty","checked":"[[ \"$(omarchy-default-terminal)\" == \"kitty\" ]]","action":"omarchy-default-terminal kitty"},
"setup.default.editor": {"icon":"","label":"Editor"},
"setup.default.editor": {"icon":"","label":"Editor","title":"Default Editor"},
"setup.default.editor.neovim": {"icon":"","label":"Neovim","when":"omarchy-cmd-present nvim","checked":"[[ \"$(omarchy-default-editor)\" == \"nvim\" ]]","action":"omarchy-default-editor nvim"},
"setup.default.editor.vscode": {"icon":"","label":"VSCode","when":"omarchy-cmd-present code","checked":"[[ \"$(omarchy-default-editor)\" == \"code\" ]]","action":"omarchy-default-editor code"},
"setup.default.editor.cursor": {"icon":"","label":"Cursor","when":"omarchy-cmd-present cursor","checked":"[[ \"$(omarchy-default-editor)\" == \"cursor\" ]]","action":"omarchy-default-editor cursor"},
@@ -172,6 +173,7 @@
"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.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.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"},
@@ -201,6 +203,7 @@
"install.ai": {"icon":"󱚤","label":"AI"},
"install.gaming": {"icon":"","label":"Gaming"},
"install.windows": {"icon":"󰍲","label":"Windows","when":"[[ ! -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm install'"},
"install.preinstalls": {"icon":"󰏓","label":"Preinstalls","when":"[[ -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-preinstalls"},
"install.browser.chrome": {"icon":"","label":"Chrome","when":"! omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser chrome'"},
"install.browser.edge": {"icon":"󰇩","label":"Edge","when":"! omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser edge'"},
"install.browser.brave": {"icon":"","label":"Brave","when":"! omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser brave'"},
@@ -227,9 +230,11 @@
"install.terminal.foot": {"icon":"","label":"Foot","when":"! omarchy-pkg-present foot","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal foot'"},
"install.terminal.ghostty": {"icon":"","label":"Ghostty","when":"! omarchy-pkg-present ghostty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal ghostty'"},
"install.terminal.kitty": {"icon":"","label":"Kitty","when":"! omarchy-pkg-present kitty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal kitty'"},
"install.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","when":"! omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-chatgpt"},
"install.ai.dictation": {"icon":"","label":"Dictation","when":"! omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"},
"install.ai.lm-studio": {"icon":"󱚤","label":"LM Studio","when":"! omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"},
"install.ai.ollama": {"icon":"󱚤","label":"Ollama","when":"! omarchy-cmd-present ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; omarchy-install-app Ollama \"$ollama_pkg\""},
"install.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","when":"! omarchy-pkg-present grok-bot","action":"omarchy-install-and-launch 'Grok Bot' grok-bot grok-bot"},
"install.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","when":"! omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"},
"install.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","when":"! omarchy-cmd-present ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; omarchy-install-app Ollama \"$ollama_pkg\""},
"install.gaming.steam": {"icon":"","label":"Steam","when":"! omarchy-pkg-present steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-steam"},
"install.gaming.retroarch": {"icon":"󰯉","label":"RetroArch","when":"! omarchy-pkg-present retroarch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-retroarch"},
"install.gaming.minecraft": {"icon":"󰍳","label":"Minecraft","when":"! omarchy-pkg-present minecraft-launcher","action":"omarchy-install-and-launch Minecraft minecraft-launcher minecraft-launcher"},
@@ -279,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.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.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.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'"},
@@ -322,7 +328,7 @@
"update.omarchy": {"icon":"","iconFont":"omarchy","label":"Omarchy","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update"},
"update.channel": {"icon":"󰔫","label":"Channel"},
"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.hardware": {"icon":"󰇅","label":"Hardware","title":"Restart"},
"update.firmware": {"icon":"","label":"Firmware","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update-firmware"},
@@ -0,0 +1,19 @@
[Unit]
Description=Announce process crashes and offer an AI diagnosis
# Needs the session bus to notify, and uwsm-app to open the diagnosis terminal.
# Both are up only after graphical-session.target.
After=graphical-session.target
PartOf=graphical-session.target
ConditionEnvironment=WAYLAND_DISPLAY
# Set by omarchy-toggle-crash-capture. Checked here so a disabled watcher stays
# disabled across logins without the unit having to be disabled.
ConditionPathExists=!%h/.local/state/omarchy/toggles/crash-capture-off
[Service]
Type=simple
ExecStart=/usr/bin/omarchy-crash-watch
Restart=always
RestartSec=5
[Install]
WantedBy=graphical-session.target
+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
`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
`omarchy-theme-set <name>` builds a clean staging directory at
`~/.local/state/omarchy/current/next-theme`:
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`.
4. Run `omarchy-theme-set-templates` to render templates into the staging
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 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` 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
- 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.
- Add a hand-written file in `themes/<name>/` only when that theme needs to
override the generated output entirely.
+40
View File
@@ -1,5 +1,45 @@
HOOKS=(base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck btrfs-overlayfs)
# The proprietary NVIDIA driver does early KMS itself: nvidia.conf (written by
# install/hardware/nvidia.sh, sourced before this file) early-loads nvidia_drm
# with modeset=1. Keeping the kms hook on such a system makes autodetect pull
# in nouveau — and ~100 MB of its GSP firmware — for a driver that never runs.
# Drop kms only when nvidia_drm is early-loaded and NVIDIA owns every display
# controller. Hybrid systems keep kms: their iGPU still needs it for early
# KMS at the LUKS prompt. So does anything unexpected, like a PCI tree that
# cannot be read.
#
# This reads MODULES midway through mkinitcpio's drop-in sourcing, so a
# later-sorting drop-in that resets MODULES outright — surface_device_modules.conf
# does — would strip nvidia_drm after kms was already dropped. Every machine
# Omarchy writes such a file for carries an Intel iGPU, which keeps kms here
# through the scan below; keep it that way.
if [[ " ${MODULES[*]:-} " == *" nvidia_drm "* ]]; then
_omarchy_nvidia_gpu=0
_omarchy_other_gpu=0
for _omarchy_pci in "${OMARCHY_PCI_DEVICES_PATH:-/sys/bus/pci/devices}"/*; do
if [[ ! -r $_omarchy_pci/class || ! -r $_omarchy_pci/vendor ]]; then
# An unreadable device could be another GPU. Inconclusive keeps kms.
_omarchy_other_gpu=1
continue
fi
[[ $(<"$_omarchy_pci/class") == "0x03"* ]] || continue
if [[ $(<"$_omarchy_pci/vendor") == "0x10de" ]]; then
_omarchy_nvidia_gpu=1
else
_omarchy_other_gpu=1
fi
done
if ((_omarchy_nvidia_gpu && !_omarchy_other_gpu)); then
_omarchy_hooks=()
for _omarchy_hook in "${HOOKS[@]}"; do
[[ $_omarchy_hook == "kms" ]] || _omarchy_hooks+=("$_omarchy_hook")
done
HOOKS=("${_omarchy_hooks[@]}")
fi
unset _omarchy_nvidia_gpu _omarchy_other_gpu _omarchy_pci _omarchy_hooks _omarchy_hook
fi
# Bundle vconsole.conf so Plymouth uses the configured keyboard layout at the
# LUKS prompt, but only when that layout types Latin letters. Passphrases are
# Latin characters, so bundling a Hebrew/Greek/Cyrillic/Arabic layout would
+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,
# then grant it directly when the install user already exists (deferred-provisioning
# installs create the user at first boot instead).
provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}"
mkdir -p "$provisioning_dir"
grep -qxF docker "$provisioning_dir/groups" 2>/dev/null || echo docker >>"$provisioning_dir/groups"
if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then
usermod -aG docker "$OMARCHY_INSTALL_USER"
fi
# The Docker daemon runs as root and its socket is root-owned, so membership in
# the docker group is equivalent to passwordless root: any process in it can
# `docker run -v /:/host` and rewrite the host as root. We therefore do NOT add
# the install user to the docker group by default, so a single rogue process
# running as the user cannot silently escalate to root.
#
# The daemon is still enabled (docker.socket, in enable-services.sh) for system
# use. The Docker TUI (Super + Shift + D) and the Windows VM reach it through a
# polkit prompt, and the plain `docker` CLI runs under sudo. Users who want the
# 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
if grep -qi synaptics /proc/bus/input/devices \
&& ! lsmod | grep -q '^psmouse'; then
modprobe psmouse synaptics_intertouch=1
#
# Only when modprobe can resolve psmouse for the running kernel, and never
# fatally. Installs run this under arch-chroot, where uname -r still names the
# 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
+3 -2
View File
@@ -78,7 +78,7 @@ lua51
luarocks
man-db
mariadb-libs
mise
mise-bin
moonlight-qt
mpv
mpv-mpris
@@ -108,7 +108,8 @@ python-poetry-core
ttfx
qemu-user-static-binfmt
qrencode
quickshell-git
qt6-imageformats
quickshell
ripgrep
ruby
tensaku
Loaded 100 of 329 files, more files were not shown because too many files have changed in this diff. Show more