Compare commits

..
109 Commits
Author SHA1 Message Date
Ryan Hughes c668141e9c Merge pull request #11901 from omacom/fix/v4-0-4-complete-kernel-headers
Treat kernel headers as part of the 4.0.4 base system
2026-09-14 22:34:12 -07:00
Ryan Hughes 8f3ab025b8 Treat matching kernel headers as a base system guarantee
(cherry picked from commit 24417bf191)
2026-09-15 00:46:59 -04:00
Ryan Hughes 28d5251e23 Install matching kernel headers for every DKMS setup
(cherry picked from commit 662051ecde)
2026-09-15 00:07:19 -04:00
Ryan Hughes cbaab83a1e Merge pull request #11898 from omacom/fix/v4-0-4-hardware-headers
[4.0.4] Use Omarchy kernel headers for hardware DKMS drivers
2026-09-14 20:47:19 -07:00
Ryan Hughes dc0d894584 Use Omarchy kernel headers for hardware DKMS drivers 2026-09-14 23:45:55 -04:00
Ryan Hughes ec73ba5d00 Migrate to linux-omarchy except on T2 Macs (#11845)
Backport of #11845 for v4-0-4. Supersedes #11729 with the final generic
kernel migration, without introducing the intermediate PTL migration.

Resolve against 4.0.3: remove the retired PTL installer, update the ISO
package list and NVIDIA headers, and retain the release hardware scope.
Cam Link and Dell sidecar support are absent from this release branch,
so omit their installer and test changes.

(cherry picked from commit 86a2e5830e)
2026-09-14 17:05:51 -04:00
Ryan Hughes 0534987009 Credit @Chainfire for the asdcontrol security report
Chainfire (@Chainfire) privately reported the passwordless asdcontrol watchdog reboot on August 25, 2026.

The fix landed in the 4.0.2 security work: #9200 (df819a6f) removed Omarchy's unrestricted sudo grant, while omacom/omarchy-pkgs#242 and omacom/omarchy-pkgs#243 restricted the package's grant. This records the reporter credit omitted from those changes.

Reported-by: Chainfire (https://github.com/Chainfire)
(cherry picked from commit 5b91db503c)
2026-09-08 12:49:55 -04:00
Ryan Hughes b9657b6313 Credit _SiCk // afflicted.sh for sudo expiry validation
Thanks to _SiCk // afflicted.sh for independently reproducing the passwordless sudo persistence issue and reviewing the proposed fix in #7990. The follow-up fix landed in #9387 (945af75a), with the 4.0.3 backport in cf459ba7.

The original boot-cleanup patch was contributed by @Adolanium. Preserve that co-author credit alongside this acknowledgment.

Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
(cherry picked from commit 131bda36b5)
2026-09-08 12:45:36 -04:00
Ryan Hughes 15b4928e11 Merge pull request #10775 from omacom/backport/v4-0-3-legacy-omarchy-font
Fix legacy icon font cleanup (4.0.3 backport)
2026-09-08 02:38:44 -04:00
Ryan Hughes 4ea7ca2b8b Retire the stock user icon font missed by Quattro upgrades
(cherry picked from commit f5cd244ed0)
2026-09-08 02:37:35 -04:00
Ryan Hughes f4d156c09a Keep Cursor bundled Node off the mise PATH
(cherry picked from commit 138d839b37)
2026-09-08 02:23:35 -04:00
Ryan Hughes 6ba4ceef57 Merge pull request #10760 from omacom/backport/v4-0-3-broadcom-wl-dkms
Backport: install broadcom-wl-dkms now that Arch dropped the prebuilt module
2026-09-07 23:03:53 -04:00
Ryan Hughes 3248aa55c2 Install broadcom-wl-dkms now that Arch dropped the prebuilt module
Arch removed the prebuilt broadcom-wl package on 2026-09-02 and rebuilt
broadcom-wl-dkms with replaces=(broadcom-wl). A replaces entry only helps
upgrades of an already-installed package, so the name is now unresolvable
as an explicit pacman target and the ISO offline mirror download fails
with "target not found: broadcom-wl".

broadcom-wl-dkms depends on dkms, so the explicit dkms in the hardware fix
is redundant; linux-headers stays because the module has to build against
the running kernel.

(cherry picked from commit 649c0b7433)
2026-09-07 22:56:53 -04:00
Ryan Hughes 7cc9057413 Preserve named font metadata and exercise glyph maintenance
Keep the baseline glyph names and add Cursor with compatible format-2 post and format-12 cmap tables. Exercise listing, disposable SVG addition and occupied-codepoint rejection.
2026-09-07 21:34:52 -04:00
Ryan Hughes d57cec42d4 Cover selected Cursor and Muse release integration 2026-09-07 21:17:36 -04:00
JP c5b59bcfff Add Muse Code as a default coding agent (#9915)
* Add Muse Code as a default coding agent

Meta ships Muse Code only as a binary, so it installs from the AUR
(muse-code-bin) instead of mise, and a fresh install runs the muse login
browser flow in the install terminal before the agent opens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148qKzr366p2Ubu2igCLvPg

* Refine Muse Code menu and prompt forwarding

* Install Muse Code from OPR

* Install Muse Code through mise's HTTP backend

* Preinstall the Muse mise stub

* Use the shared Muse installation flow

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
(cherry picked from commit 0d223fe820)
2026-09-07 21:16:59 -04:00
David Heinemeier Hansson a9e1c01ee9 Add Cursor CLI as a coding agent choice (#10465)
* Add Cursor CLI as a coding agent choice

* Launch Cursor CLI through its agent subcommand with --trust

Cursor CLI still dispatches a one-word prompt that names one of its
subcommands (update, login, help) even after a bare --, so name the agent
subcommand outright and pass the prompt behind -- there, where it also
keeps a prompt starting with a dash from being read as an option. --yolo
only auto-allows commands; the workspace trust dialog is skipped only by
--trust, and a launcher that must not stop to ask needs both.

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

* Leave an official Cursor CLI install alone

Cursor's own installer symlinks ~/.local/bin/cursor-agent, the same path the
mise wrapper takes. The migration now installs the wrapper only when no
cursor-agent command exists, and Remove Preinstalls deletes the path only
when it holds the wrapper omarchy-mise-install wrote, the way the Hermes
wrapper is handled. Selecting the agent treats an executable at that path
other than the wrapper as the user's own install and skips mise, since the
mise shims precede ~/.local/bin on PATH and a mise copy would only shadow
it. The wrapper resolves through mise's registry, which lists cursor-agent
from 2026.8.15 on.

The tests write a real cursor-agent stub before Remove Preinstalls runs,
cover the preinstall opt-out for the new migration, and check that a
symlinked official install survives removal and selection alike while a
dead file at the same path still installs.

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

* Give Cursor its brand mark and one name in the menu

Add Cursor's mark to the Omarchy icon font as U+E90D and point the agent
entry and both editor entries at it, so one brand is drawn one way across
the menu. Label the agent entry "Cursor CLI", the name the command and the
manual already use, and spell it the same in the migration and the tests.
Append the manual row after the others at the standard width.

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

* Keep an official Cursor CLI install through a user re-provision

User setup writes every mise wrapper unconditionally, which is fine on a
fresh install but replaces the symlink Cursor's own installer leaves at the
same path when omarchy-provision-user runs again with --force. Guard that
one line the way the migration does.

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

---------

Co-authored-by: John Cavanaugh <59479+cavanaug@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit a62e34ea8e)
(cherry picked from commit 1c05f141f158b3521c193b66f03350aa94719d95)
2026-09-07 21:15:42 -04:00
Ryan Hughes 4064436eea Keep locate reference checks binary-safe after collector tests
Search the same forbidden ASCII references as bytes so generated Python bytecode does not abort the aggregate test run.

(cherry picked from commit 8f3642b3662ea2409e17c80ee18a231bac5c8d7c)
2026-09-07 20:57:03 -04:00
Ryan Hughes 2cdcafa5b5 Finalize the selected icon font with validated source outlines
Build from the T3 font and append only Hermes, OpenClaw and Perplexity using distro FontTools. Retain every baseline glyph and metric; omit Ori and Cursor. Update the font character bounds and preserve glyph naming metadata.

(cherry picked from commit 0c28d16e30f03da897503ae2e10ffd4b7c28b01a)
2026-09-07 20:57:03 -04:00
Ryan Hughes 461cbe9b9c Exercise selected AI ports against the 4.0.2 baseline
Keep provisioning tests isolated from application refresh and link the real command-presence helper into the Hermes remover fixture. Cover only selected AI menu/scaffold additions, retained default agents and Dictation placement, and new-user/migration mise pruning parity.

(cherry picked from commit 1bd969eb386169e107d0b9f15be4b3e1d2a9b4cb)
2026-09-07 20:57:03 -04:00
David Heinemeier Hansson 256bfeaa75 Start 1Password at a fixed device scale factor (#10673)
1Password reads the display scale itself, the way Electron apps do, so
on a scaled monitor it comes up oversized next to every other window.
Pin it with --force-device-scale-factor=1 and let the compositor scale
it.

The app menu is covered by the packaged .desktop, which we build
ourselves in omarchy-pkgs. This is the other route in: the hotkey runs
the binary directly and never reads that file.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 446fbc28b1)
(cherry picked from commit 2acfa32b0aac3e4613849e7b8e56968379971a51)
2026-09-07 20:57:03 -04:00
Ryan Hughes 36695fbe84 Merge pull request #8784 from omacom/t3-theme-support
Add theme support for T3 Code

(cherry picked from commit 5fb2cc7967)
(cherry picked from commit f1e9f5a4e14cb266c85b9956d64d3d0a7835a654)
2026-09-07 20:57:03 -04:00
Ryan Hughes 9d95fc7f01 Merge pull request #7591 from omacom/mise-shim-preserve-argv0
Keep mise upgrades from pruning versions still in use

(cherry picked from commit 0973169098)
(cherry picked from commit e4fa14cefff0a19e72bb9f41722edd6b6f1afd93)
2026-09-07 20:57:03 -04:00
Spencer Bull e919a64014 Merge pull request #10613 from spencerbull/hermes-desktop-in-app-updates
Enable Hermes in-app updates and guard removal

(cherry picked from commit a703092631)
(cherry picked from commit 2438293d775d679ce99364cdbced108596c5b2bc)
2026-09-07 20:57:03 -04:00
Ryan Hughes 866d994dcf Merge pull request #7994 from Adolanium/mise-install-name-guard
Quote mise-install arguments and refuse unusable command names

(cherry picked from commit e989b5b3ee)
(cherry picked from commit fabcc4e437614cdd20638b108ae59b04191752e6)
2026-09-07 20:57:03 -04:00
Ryan Hughes cf459ba752 Merge pull request #9387 from omacom/security/nopasswd-expiry-fail-closed
Fail closed when passwordless sudo expiry cannot arm

(cherry picked from commit a87d396f01)
(cherry picked from commit 88ba230e6affefe83899ea5cda228311c73b924b)
2026-09-07 20:57:03 -04:00
Spencer Bull 1fe83ff129 Merge pull request #10460 from codemonkey76/fix-openclaw-cmd-present
Use the command helper in the AI app removers

(cherry picked from commit 18410a0e48)
(cherry picked from commit 67125c330b36204356a92867b9c9120a4599afae)
2026-09-07 20:57:03 -04:00
Ryan Hughes 6a72d4236b Merge pull request #10605 from omacom/fix/kitty-system-defaults
Move Kitty defaults to the system config and restrict remote control

(cherry picked from commit f04366de79)
(cherry picked from commit 6dfbfa146b91bf310f2ff7147953afddef73de9a)
2026-09-07 20:57:03 -04:00
Ryan Hughes 7a35a8ee9b Merge pull request #10579 from omacom/fix/locate-service-defaults
Replace locate configuration script with packaged service defaults

(cherry picked from commit d7c783ecf5)
(cherry picked from commit 142ac21ed3d86fa3733b2a4fe41618eb67747ef8)
2026-09-07 20:57:03 -04:00
Ryan Hughes 6d327ce83c Merge pull request #10425 from acrogenesis/security/root-owned-sleep-hooks
Harden ownership of installed sleep hooks

(cherry picked from commit c82a0837b0)
(cherry picked from commit 3b5f4446f6022ad056fc06d320d157075841cde7)
2026-09-07 20:57:03 -04:00
Ryan Hughes f94c6db34d Merge pull request #10225 from mdisec/security/harden-quattro-fprintd-path
Harden lock authentication command lookup

(cherry picked from commit 3f91f06a62)
(cherry picked from commit 72116e5cefef29446d34d328badd3a3701928b3d)
2026-09-07 20:57:03 -04:00
powderluv 8d1a7ef00c Install libfprint-git for every fingerprint reader (#10442)
* Use updated libfprint-git for fingerprint setup on edge

* Pick the fingerprint driver from the reader, not the release channel

The channel gate blocked every edge and dev user until the newer
libfprint-git pin is published, misrouted dev checkouts on the stable
mirror, and left the stock-libfprint migration reverting the driver on
accounts without its marker. Key both the setup and the migration on
omarchy-hw-fingerprint-git, a USB ID table of readers stock libfprint
cannot drive, so the git snapshot only goes where it is needed on any
channel. Qualify the package with the omarchy repo, and skip pacman
entirely when the packages are already current so a rerun cannot become
a partial upgrade.

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

* Install libfprint-git for every fingerprint reader

Stock libfprint lags upstream on new readers, and gating the git
snapshot per reader or per channel only added machinery to keep in sync
with the package repo. Install libfprint-git unconditionally instead:
the omarchy-pkgs pin is the single place a new reader gets enabled. The
migration that swapped it back to stock goes away with the policy it
enforced; late updaters keep the driver they have and pick up the new
pin as a normal package upgrade.

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

---------

Co-authored-by: powderluv <powderluv@powderluv.org>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit adcc96a782)
(cherry picked from commit 8d0ca2a628fdeab5ce9f9c4a779630cd38223c85)
2026-09-07 20:57:03 -04:00
Spencer Bull 6cc561b8d3 Merge pull request #10381 from spencerbull/hermes-system-theme
Make Hermes follow the Omarchy theme as a skin

(cherry picked from commit 988da12cb0)
(cherry picked from commit 6d3ae7a811ecda54ca11bff0991c2a72878a29e3)
2026-09-07 20:57:03 -04:00
Spencer Bull 6cb5396dc7 Merge pull request #9625 from spencerbull/t3code/add-perplexity-ai-app
Add the Perplexity desktop app to Install > AI

(cherry picked from commit f1b065c292)
(cherry picked from commit 680930915eaba8c3741980c56ad160a40c50bcb9)
2026-09-07 20:57:03 -04:00
Spencer Bull 27594ba3f5 Merge pull request #10271 from spencerbull/hermes-remove-ask-user-data
Ask, default no, before Remove Hermes deletes the user's data

(cherry picked from commit 959e49dc52)
(cherry picked from commit 4e77199945a15ac194511b1af6317761fb813ba0)
2026-09-07 20:57:03 -04:00
Spencer Bull be6c401200 Merge pull request #10246 from omacom/add-openclaw-ai-app
Add OpenClaw as a desktop app and a coding agent

(cherry picked from commit eb56446c42)
(cherry picked from commit bde2584b5596412142d0a2039d44dd6a25949517)
2026-09-07 20:57:03 -04:00
Spencer Bull d49dd939b7 Merge pull request #9663 from spencerbull/fix/hermes-prompt-probe
Fix the Hermes CLI readiness probe, and tear the CLI down on uninstall

(cherry picked from commit f99d33a8dd)
(cherry picked from commit de739ea736563763150725ce33151001c5966add)
2026-09-07 20:57:03 -04:00
Spencer Bull 68e3d7a2ec Merge pull request #7469 from omacom/hermes-agent
Add Hermes as a desktop app and a coding agent

(cherry picked from commit b71dcad96e)
(cherry picked from commit b27908369ed5fa95ca24dca2c68b895032d879d2)
2026-09-07 20:57:03 -04:00
Ryan Hughes 15d64a7025 Restore baseline menu rows after T3 conflict resolution
Correct the local conflict parser consuming context beyond the selected hunk. Preserve all baseline entries and add only the selected T3 and removal scaffold rows.

(cherry picked from commit 473713413a9132375b6bf96627e8220f118ddf00)
2026-09-07 20:57:03 -04:00
Ryan Hughes 82de446713 Port the selected AI removal scaffold for 4.0.3
Port only Remove > AI and T3 removal/tests from #7504 (023021ad2d). Keep Dictation in its baseline location and omit unrelated removers.

(cherry picked from commit 73480ffabc091b812a0cfbe7bc845799c6078a88)
2026-09-07 20:57:03 -04:00
Omarchybot ebaad12947 Add T3 Code to the AI install menu (#7496)
t3code-bin is in the Omarchy repo now, so the menu can offer it the way it offers Cursor and Grok Bot: install the package, then launch the desktop entry it ships.

The mark is a trace rather than a download. T3 publishes no monochrome SVG — the app icon is a black rounded tile with the letters knocked out of it, and a tile flattens to a solid square once the menu recolors every path with the theme foreground. Tracing the lettermark out of that icon keeps the silhouette that actually reads.

The font is package-owned, so the glyph reaches a desktop through an omarchy-settings release rather than omarchy update. Until that release lands, a pulled checkout draws the entry with no icon.

🤖 Generated by Opus 5 in Claude Code.

(cherry picked from commit 260a729104)
(cherry picked from commit a863dc8555a51e62535f0edf28d4158d9d2e9fb4)
2026-09-07 20:57:03 -04:00
Ryan Hughes cf6fedc9f5 Merge pull request #10733 from omacom/backport/v4-0-3-plugin-auth
Backport plugin authentication boundary to 4.0.3 (#9618)
2026-09-07 19:57:14 -04:00
Ryan Hughes fb257bf872 Restore narrow service proxies for Indicators clones 2026-09-07 19:43:18 -04:00
Ryan Hughes 1cbe72e5dd Test authentication service lifecycle in the 4.0.3 backport
Exercise production JavaScript from #9485 and #9618 together with a synchronous Qt test double. Cover keepLoaded identity, detached authentication retention, refreshed manifests, cleanup eligibility, sticky classification, and clone lookups without the unrelated video-wallpaper services alias. Native QObject, PAM, and Wayland validation remains separate.
2026-09-07 18:40:08 -04:00
Ryan Hughes b1cdec9e47 Merge pull request #9618 from acrogenesis/security/plugin-auth-boundary
Restrict third-party plugin access to authentication services

(cherry picked from commit e78d89ee2a)
2026-09-07 18:36:02 -04:00
Brian Armstrong af6f64fa3a Honor keepLoaded for services during plugin hot-reload (#9485)
* Honor keepLoaded for services during plugin hot-reload

Plugin reload destroyed every service, including omarchy.lock, which drops the ext-session-lock client while Hyprland still holds the lock and surfaces the crashed-lockscreen fallback.

* Prove keepLoaded service survival with a fixture service

A fresh lock service also reports an empty lastEventAt, so comparing it
across the rescan passed whether or not the instance survived. A fixture
keepLoaded service whose in-memory marker is set before the rescan and
read back after can only pass when the same instance is still mounted.

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

* Drop kept services whose plugin no longer declares a service

The _syncServices cleanup only asked whether the plugin was still
installed and enabled, so a kept service whose plugin dropped its
service kind or entry point kept running as a zombie until shell
restart. Apply the same eligibility checks used at creation, and hand
kept instances the refreshed manifest after a rescan.

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

* Cover omarchy.media in keepLoaded expectations; note kept services reload on restart

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>
(cherry picked from commit d3d23fddde)
2026-09-07 18:31:34 -04:00
Ryan Hughes 346e69e1ce Merge pull request #9267 from omacom/fix/close-password-only-sshd
Disable sshd entirely when no usable key is authorized

(cherry picked from commit b686ed892d)
2026-08-30 18:47:46 -04:00
Ryan Hughes 0e1ce609d8 Merge pull request #9255 from omacom/security/migrate-existing-sshd-hardening-v4-0-2
Harden existing key-based SSH setups

(cherry picked from commit 3c2a24b248)
2026-08-30 18:40:46 -04:00
Ryan Hughes 3cb3b2d20b Merge pull request #9263 from omacom/fix/shell-test-host-assumptions
Fix test failures caused by host assumptions

(cherry picked from commit a93ee6a433)
2026-08-30 18:28:00 -04:00
Omarchybot f8e35ec654 Merge pull request #7984 from Chessing234/fix/webapp-name-slashes
Keep a web app name out of the launcher's directory structure

(cherry picked from commit 83881e979b)
2026-08-30 18:03:24 -04:00
Ryan Hughes f802ae107d Merge pull request #9249 from omacom/fix/acceptance-installed-tree-default
Default the acceptance suite's OMARCHY_PATH to the installed tree alone

(cherry picked from commit 981274b20a)
2026-08-30 16:59:38 -04:00
Ryan Hughes 6f8b62f04c Merge pull request #9240 from omacom/fix/acceptance-security-coverage
Fix the bar visibility toggle and make the acceptance suite cover 4.0.2 security hardening

(cherry picked from commit 432b5e3e24)
2026-08-30 16:45:30 -04:00
Ryan Hughes 9aa0545c2a Merge pull request #9232 from omacom/fix/menu-acceptance-style-navigation
Fix Style submenu navigation in the menu acceptance test

(cherry picked from commit e68994680a)
2026-08-30 15:33:18 -04:00
Ryan Hughes ebd6480387 Merge pull request #9225 from omacom/fix/sshd-hardening-verification-case
Match sshd -T keywords case-insensitively when verifying SSH hardening

(cherry picked from commit a24064c720)
2026-08-30 15:14:32 -04:00
Ryan Hughes 07579f5e94 Merge pull request #9214 from omacom/rc-channel-pacman
Point the rc channel at the rc package repository

(cherry picked from commit 4271b880c3)
2026-08-30 13:54:21 -04:00
Ryan Hughes ff4cf8afbc Merge pull request #9200 from omacom/security/v4-0-2-input-asdcontrol-sshd
[4.0.2] Close unprivileged input and SSH escalation paths

(cherry picked from commit 21b27c5aed)
2026-08-30 13:05:45 -04:00
Ryan Hughes cf3d69c38d Merge pull request #9002 from acrogenesis/remove-legacy-installer-privileged-files
Repair legacy paths and privileged files left by retired installers

(cherry picked from commit 943d2fcbe9)
2026-08-30 12:08:00 -04:00
Adolanium 47ce81ebca Quote install-app and install-font names like install-and-launch (#7843)
* Quote install-app and install-font names like install-and-launch

* Quote the package list too, not just the display name

The display name was quoted but omarchy-pkg-add's own arguments were still interpolated into the bash -c string raw, so `omarchy install app Vim 'vim; id'` ran id. The list has to reach the helper as several words, so it cannot be quoted whole: it is split the way the unquoted expansion split it and each word is quoted on its own. Reading with -d '' keeps a newline-separated list intact instead of dropping every package after the first, which plain read -a would. install-font's package is singular and is quoted whole, and install-and-launch carried the same flaw.

Reported by acrogenesis in review of #7843.

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

* Test that install-font skips font-set when pkg-add fails

The hostile-package case was asserting the family still got set, which only held because the mock always exits 0. pacman would reject that name and the && chain would skip font-set.

* Keep the installers working when errexit is inherited

read -d '' always ends at EOF rather than on its delimiter, so it reports failure on every input. Under an inherited errexit the installers exited there and built no command at all.

Reported by Codex XHigh in review of #7843.

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

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
(cherry picked from commit 625c4a1603)
2026-08-30 12:08:00 -04:00
Ryan Hughes 2421e76fb8 Merge pull request #8419 from AFOliveira/security/windows-vm-mount-boundary
[codex] Secure Windows VM host mounts

(cherry picked from commit 158e8cfb3a)
2026-08-30 12:08:00 -04:00
Ryan Hughes bffc6a2a5d Merge pull request #8934 from ErikMelton/security/plymouth-publication-race
Secure Plymouth and SDDM asset publication

(cherry picked from commit e229927671)
2026-08-29 18:46:11 -04:00
Ryan Hughes 7678e5b0d8 Merge pull request #8496 from Chessing234/security/webapp-http-only
(cherry picked from commit 0b3f1b7ead)
2026-08-29 15:40:44 -04:00
Ryan Hughes be730884f6 Merge pull request #8473 from bastidotnet/fix/webapp-desktop-value-escaping
(cherry picked from commit f20bf0a21b)
2026-08-29 15:40:44 -04:00
Ryan Hughes a73a312e1b Merge pull request #8951 from omacom/cups-browsed-temporarily-removed
Temporarily remove automatic printer discovery

(cherry picked from commit c720f0b981)
2026-08-29 15:39:54 -04:00
David Heinemeier Hansson 159163bb16 Merge pull request #8203 from hjanuschka/fix-chromium-first-run-eula
Skip Chromium's new first-run EULA

(cherry picked from commit 5236f4426c)
2026-08-29 15:39:54 -04:00
Ryan Hughes 521779b114 Merge pull request #8416 from mdisec/theme-name-shell-syntax
Refuse a theme name that is shell syntax, and quote the one the unlock picker returns

(cherry picked from commit 9da8824098)
2026-08-29 03:20:17 -04:00
Ryan Hughes 51af7bf794 Merge pull request #8627 from mdisec/security/harden-cups-browsed
Harden CUPS printer discovery

(cherry picked from commit 169ad00a84)
2026-08-29 02:24:37 -04:00
Ryan Hughes 92c8f87730 Merge pull request #8268 from Skeptomenos/fix/copy-url-python-shim-recursion
Avoid mise shim recursion in Copy URL migration test

(cherry picked from commit f0672772d2)
2026-08-28 22:56:02 -04:00
Ryan Hughes 3bb9867245 Merge pull request #8835 from basecamp/fix-browser-policy-exit-trap
Fix migration 1787515927 failing on Bash 5.3

(cherry picked from commit 62eb5182d0)
2026-08-28 22:55:52 -04:00
Ryan Hughes da0fe6d89f Harden browser policy directories (#7972)
Cherry-picked from quattro (7d58bb9a).

Stop world-writable Chromium and Firefox policy directories: create them
root-owned at 0755, purge non-root entries, refuse planted symlinks, and
write the browser theme colour through a passwordless helper instead of
a world-writable policy file.

Conflict resolution for v4-0-2:
- bin/omarchy-install-browser: dropped the `chromium)` case, which does
  not exist on this branch.
- test/shell.d/default-apps-test.sh: dropped; the file does not exist on
  this branch.
2026-08-28 18:25:24 -04:00
Ryan Hughes 2645b82bcd Merge pull request #8397 from ErikMelton/unauthorized-http-get-requests-from-notifications
Require textFormat declaration for all Text elements

(cherry picked from commit 468b511249)
2026-08-28 15:50:37 -04:00
David Heinemeier Hansson 9c1ec524cd Merge pull request #8198 from bastidotnet/harden-apple-brightness-device-cache
Validate the cached Apple-display device path before use

(cherry picked from commit 06e32d243d)
2026-08-28 15:32:34 -04:00
orienw 880605f22b Fix Codex usage collection on 0.149 (#7649)
* Fix Codex usage collector approval policy

* Capture codex argv with boundaries in the scanner test

The stub joined its arguments with "$*", so the assertion compared one
flattened string and could not tell five arguments from fewer containing
spaces. Passing "-s read-only" and "-a on-request" as single arguments --
which codex rejects as an unexpected argument -- passed the test. NUL
separation and an array comparison keep the boundaries the assertion is
about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.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 4cd8a081cb)
2026-08-28 15:32:34 -04:00
Ryan Hughes 294c38a2f0 Merge pull request #8072 from basecamp/require-omarchy-repo-signatures
Require signed packages from the Omarchy repository

(cherry picked from commit c5a5e14e99)
2026-08-27 11:29:59 -04:00
Omarchybot 1082b7864d Constrain the tzupdate sudoers rule to a single timezone argument (#8194)
The wildcard granted passwordless root for timedatectl set-timezone plus any trailing arguments, so -H/--host and -M/--machine reached the SSH and machine transports as root. Systemd 261 guards argv injection into ssh, but -H still drives root's SSH client at an attacker-chosen host, and the transport resolves its helper through PATH; only Defaults secure_path stands between that and a planted ssh running as root. Match the argument with an anchored POSIX ERE that admits exactly one timezone token (no whitespace, no leading-dash segment, no traversal component), so no second argument and no option can ever match. The sole caller, omarchy-menu-timezone, passes one list-timezones value and is unaffected.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
(cherry picked from commit 0ae1694830)
2026-08-26 17:56:33 -04:00
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
394 changed files with 26278 additions and 1194 deletions

No files matched your search

+9 -10
View File
@@ -61,19 +61,18 @@ its screenshots are also hosted.
- [Prompt](manual/40-prompt.md)
- [Branding](manual/41-branding.md)
- [Common tweaks](manual/42-common-tweaks.md)
- [Extra themes](manual/43-extra-themes.md)
- [Making your own theme](manual/44-making-your-own-theme.md)
- [Making your own theme](manual/43-making-your-own-theme.md)
**The Rest**
- [Mac support](manual/45-mac-support.md)
- [Troubleshooting](manual/46-troubleshooting.md)
- [FAQ](manual/47-faq.md)
- [System snapshots](manual/48-system-snapshots.md)
- [Security](manual/49-security.md)
- [Omarchy on...](manual/50-omarchy-on.md)
- [Dual Boot Install](manual/51-dual-boot-install.md)
- [Unattended Installs](manual/52-unattended-installs.md)
- [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
+3 -7
View File
@@ -70,10 +70,6 @@ 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.
Then confirm it in the running menu per [`visual-verification.md`](visual-verification.md). Avoid leaving two fonts with the `omarchy` family registered: Qt can use an old copy in `~/.local/share/fonts` even when `fc-match omarchy` reports the packaged font. For a preview, either replace the packaged file in the disposable VM or temporarily exclude it with a `<rejectfont>` rule in `~/.config/fontconfig/conf.d/` before loading the candidate.
Refresh the font cache and restart the shell afterwards — Qt reads the font database at startup, so `omarchy menu refresh` alone will not pick up a changed font. Remove temporary fonts and rules after verification.
+1
View File
@@ -13,6 +13,7 @@ commands and reusable setup leaves:
- use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths.
- keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`.
- keep every per-user setup leaf under `install/user/` (including `install/user/hardware/` and `install/user/first-run/`) so it is clear what must run for each user.
- The base install supplies matching kernel headers before hardware setup. DKMS installers should install their driver packages and assume the headers exist.
- prefer helper commands for package and command checks where available.
Raw `command -v`, `pacman`, and `pacman-key` are acceptable in package-helper
+1 -3
View File
@@ -20,9 +20,7 @@ Run `omarchy-restart-shell` after making changes to QML files.
[`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and
`shell/services/PluginRegistry.qml` for the current contract; fields such as
`activation` are optional.
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the
shell-injected properties `omarchyPath`, `shell`, `manifest`, and
`pluginRegistry` / `barWidgetRegistry` as appropriate.
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views and bar configuration must be detached snapshots rather than shared objects. These facades reduce accidental authority but are not a same-process QML sandbox: a visual bar widget can walk its parent hierarchy to ordinary host objects. Authentication services must therefore remain outside both `ShellRoot._services` and the host QObject tree. Do not expose authentication services through new third-party-facing properties.
- Panel / overlay / menu plugins must expose `open(payloadJson)` and
`close()` lifecycle methods for `shell summon` and `shell hide`.
+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
+1
View File
@@ -63,6 +63,7 @@ GROUP_DESCRIPTIONS[monitor]="Monitor status helpers"
GROUP_DESCRIPTIONS[network]="Network status helpers"
GROUP_DESCRIPTIONS[notification]="Notification helpers"
GROUP_DESCRIPTIONS[mise]="Mise tool wrappers"
GROUP_DESCRIPTIONS[openclaw]="OpenClaw agent platform setup"
GROUP_DESCRIPTIONS[osd]="On-screen display status helpers"
GROUP_DESCRIPTIONS[pkg]="Package management helpers"
GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management"
+36 -3
View File
@@ -74,12 +74,45 @@ crush)
command=(crush --yolo)
fi
;;
claude | grok)
command=("$agent" --permission-mode bypassPermissions)
claude)
command=(claude --permission-mode auto)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
grok)
command=(grok --permission-mode bypassPermissions)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
openclaw)
# The launcher owns onboarding and the gateway dance: OpenClaw's terminal UI
# must attach to the running gateway (the embedded `openclaw chat` refuses to
# start while the gateway owns the state directory). It has no permission
# prompts to skip, and --message seeds the session while keeping it
# interactive.
command=(omarchy-launch-openclaw --tui)
[[ -n ${prompt:-} ]] && command+=(--message "$prompt")
;;
codex)
command=(codex --dangerously-bypass-approvals-and-sandbox)
command=(codex --approve-for-me)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
cursor-agent)
# --yolo covers commands only; the workspace trust dialog has its own flag.
# A one-word prompt naming a subcommand (update, login, help) still runs that
# subcommand after a bare --, so the agent subcommand is named outright, and
# -- after it keeps a prompt starting with a dash from being read as an option.
command=(cursor-agent --yolo --trust)
[[ -n ${prompt:-} ]] && command+=(agent -- "$prompt")
;;
hermes)
if [[ -n ${prompt:-} ]]; then
command=(env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=$prompt")
else
command=(hermes --yolo)
fi
;;
muse)
# --approval-mode never skips the tool prompts but keeps Muse's own sandbox.
command=(muse --approval-mode never)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
omp)
+1 -1
View File
@@ -528,7 +528,7 @@ def fetch_codex_rpc():
try:
proc = subprocess.Popen(
[codex, "-s", "read-only", "-a", "untrusted", "app-server"],
[codex, "-s", "read-only", "-a", "on-request", "app-server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
+8 -1
View File
@@ -6,6 +6,12 @@
set -e
# Install and upgrade callers can start this helper as root. Ignore their PATH
# so optional commands never fall through to a user-writable directory.
if (( EUID == 0 )); then
export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin
fi
target_user=${OMARCHY_INSTALL_USER:-${SUDO_USER:-}}
if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then
target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1)
@@ -34,7 +40,8 @@ auth required pam_faillock.so authsucc
account include system-local-login
EOF
if omarchy-cmd-present fprintd-list && fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then
if [[ -x /usr/bin/fprintd-list ]] &&
/usr/bin/fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then
echo "Configuring lock screen fingerprint authentication..."
as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF'
#%PAM-1.0
+17 -4
View File
@@ -4,7 +4,13 @@
# omarchy:args=[--no-osd] [+N%|N%-|N%]
# omarchy:examples=omarchy brightness display apple | omarchy brightness display apple +5% | omarchy brightness display apple --no-osd 50%
device_cache="${XDG_RUNTIME_DIR:-/tmp}/omarchy-brightness-display-apple.device"
# Only cache under the user-private runtime dir. With no XDG_RUNTIME_DIR we skip
# caching (detect every run) rather than fall back to a predictable, world-writable
# /tmp path another user could pre-create.
device_cache=""
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
device_cache="$XDG_RUNTIME_DIR/omarchy-brightness-display-apple.device"
fi
no_osd=0
if [[ ${1:-} == "--no-osd" ]]; then
no_osd=1
@@ -28,9 +34,14 @@ find_apple_display_device() {
local cached=""
local device=""
if [[ -r $device_cache ]]; then
if [[ -n $device_cache && -r $device_cache ]]; then
read -r cached <"$device_cache" || true
if [[ -n $cached && -e $cached ]]; then
# Trust a cached value only if it still names a hiddev character device. A
# stale or unexpected cache (a regular file, a non-hiddev node) is ignored and
# we re-detect instead of handing an arbitrary path to asdcontrol. The globs
# are left unquoted on purpose: [[ ]] pattern-matches an unquoted right side,
# and quoting them would turn the match into a literal string comparison.
if [[ ( $cached == /dev/hiddev* || $cached == /dev/usb/hiddev* ) && -c $cached ]]; then
printf '%s\n' "$cached"
return 0
fi
@@ -39,7 +50,9 @@ find_apple_display_device() {
device="$(detect_apple_display_device)" || return 1
[[ -n $device ]] || return 1
printf '%s\n' "$device" >"$device_cache"
if [[ -n $device_cache ]]; then
printf '%s\n' "$device" >"$device_cache"
fi
printf '%s\n' "$device"
}
+1 -1
View File
@@ -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
+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
+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
+7 -7
View File
@@ -24,9 +24,7 @@ readonly ignore_pattern=${OMARCHY_CRASH_IGNORE:-}
declare -A last_notified
announce() {
local comm=$1 pid=$2 exe=$3 signal=$4 exec_command
exec_command=$(printf 'omarchy-agent-crash %q %q %q %q' "$pid" "$comm" "$exe" "$signal")
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.
@@ -35,14 +33,16 @@ announce() {
omarchy-notification-wait || return 1
# --exec rather than a libnotify action: the shell runs clicks from its own
# omarchy-exec hint and never emits ActionInvoked. Keeps the default
# "omarchy-action" app name too, the only one shouldBypassDnd() lets through.
# 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" \
--exec "$exec_command" \
"Process crashed: $comm" \
"Click to diagnose with AI"
"Click to diagnose with AI" \
--exec omarchy-agent-crash "$pid" "$comm" "$exe" "$signal"
}
# -n 0 so a restart does not re-announce crashes already dealt with.
+35 -4
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# omarchy:summary=Set and launch the default coding agent
# omarchy:args=[pi|omp|opencode|claude|codex|grok|gemini|copilot|crush]
# omarchy:args=[pi|omp|opencode|claude|codex|grok|gemini|openclaw|hermes|copilot|crush|cursor-agent|muse]
# omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude
installing=false
@@ -32,20 +32,51 @@ codex) agent="codex"; name="Codex" ;;
crush) agent="crush"; name="Crush" ;;
grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;;
gemini | gemini-cli) agent="gemini"; name="Gemini" ;;
openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-openclaw-cli" ;;
hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;;
copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;;
muse | muse-code | musecode)
agent="muse"; name="Muse Code"
# Meta's launcher verifies and updates the native binary for this platform.
agent_package="http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]"
;;
cursor | cursor-agent) agent="cursor-agent"; name="Cursor CLI" ;;
*)
echo "Usage: omarchy-default-agent <pi|omp|opencode|claude|codex|grok|gemini|copilot|crush>"
echo "Usage: omarchy-default-agent <pi|omp|opencode|claude|codex|grok|gemini|openclaw|hermes|copilot|crush|cursor-agent|muse>"
exit 1
;;
esac
agent_package=${agent_package:-$agent}
if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then
# Hermes reaches mise through its own installer rather than straight from
# here: it needs its interpreter pinned, and a bare `mise use` has nowhere to
# say so. See omarchy-install-hermes-cli. OpenClaw comes from its pacman
# package the same way; see omarchy-install-openclaw-cli.
if [[ -n ${agent_installer:-} ]]; then
# Not omarchy-cmd-present: the stub is on PATH from first boot and says
# nothing about whether Hermes is installed behind it. Treating a cold stub
# as installed skips the floating terminal and runs the minute-long install
# inside the menu action instead.
agent_present() { "$agent_installer" --check; }
agent_install() { "$agent_installer" --now; }
else
# Anything at the wrapper's path other than the wrapper is the user's own
# install, such as the symlink Cursor's installer leaves. A mise copy would
# only shadow it, since the mise shims precede ~/.local/bin on PATH.
user_install() {
[[ -x $HOME/.local/bin/$agent ]] &&
{ [[ -L $HOME/.local/bin/$agent ]] || ! grep -q '^mise use -g' "$HOME/.local/bin/$agent"; }
}
agent_present() { user_install || mise where "$agent_package" &>/dev/null; }
agent_install() { user_install || mise use -g "$agent_package"; }
fi
if [[ $installing == "false" ]] && ! agent_present; then
exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent"
fi
if ! mise use -g "$agent_package"; then
if ! agent_install; then
if [[ $installing == "true" ]]; then
echo "Could not install $name with mise" >&2
else
+6 -2
View File
@@ -34,8 +34,12 @@ systemd, shell, or app-launcher environment; reboot to make every layer agree.
Affects only \$OMARCHY_PATH-resolved trees: bin/, default/, shell/,
themes/, applications/, config/. Files installed at fixed system paths
(/etc/, /usr/lib/systemd/, udev rule bodies, /etc/skel after user
creation, /usr/share/plymouth) are NOT covered — for those, use
omarchy-dev-pkg-test to build and install the package from the checkout.
creation) are NOT covered — for those, use omarchy-dev-pkg-test to build
and install the package from the checkout.
The Plymouth and SDDM themes under /usr/share are the exception: omarchy
plymouth set and omarchy refresh plymouth republish them from the checkout,
reading this link's authorization out of the root-owned /etc/omarchy.conf.
Also writes $sudoers_file so sudo resolves omarchy-*
from the checkout instead of the packaged copies. That part takes effect
+14 -4
View File
@@ -129,7 +129,8 @@ term_pt_for() {
'BEGIN { printf "%d", int(s * p / b + 0.5) }'
}
# Set the font point size in every terminal config that exists. Family is left
# Set the font point size in terminal configs, creating Kitty overrides when
# it inherits its size from the system config. Family is left
# untouched — that is omarchy-font-set's job. Live-reload signals mirror
# omarchy-font-set; foot has no reload signal, so running instances are nudged.
set_terminal_size() {
@@ -139,8 +140,13 @@ set_terminal_size() {
sed -i -E "s/^size[[:space:]]*=.*/size = $pt/" ~/.config/alacritty/alacritty.toml
fi
if [[ -f ~/.config/kitty/kitty.conf ]]; then
sed -i -E "s/^font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
mkdir -p ~/.config/kitty
if grep -qE '^[[:space:]]*font_size[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
sed --follow-symlinks -i -E "s/^[[:space:]]*font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
else
printf '\nfont_size %s.0\n' "$pt" >>~/.config/kitty/kitty.conf
fi
pkill -USR1 kitty 2>/dev/null || true
fi
@@ -177,9 +183,13 @@ term_current_pt() {
elif [[ -f ~/.config/alacritty/alacritty.toml ]]; then
grep -oP '^size[[:space:]]*=[[:space:]]*\K[0-9.]+' ~/.config/alacritty/alacritty.toml | head -1
elif [[ -f ~/.config/kitty/kitty.conf ]]; then
grep -oP '^font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | head -1
local pt
pt=$(grep -oP '^[[:space:]]*font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | tail -1)
echo "${pt:-$TERM_DEFAULT_PT}"
elif [[ -f ~/.config/foot/foot.ini ]]; then
grep -oP ':size=\K[0-9.]+' ~/.config/foot/foot.ini | head -1
elif omarchy-cmd-present kitty; then
echo "$TERM_DEFAULT_PT"
fi
}
+38 -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
}
@@ -124,6 +149,9 @@ write_networkmanager_dns() {
local servers="$1"
install -d -m 0755 "$(dirname "$NM_DNS_CONF")"
# omarchy:heredoc-expands paths=none -- $servers is a normalized, single-line
# DNS server list written as data, not a path or command; nothing user-writable
# is resolved or executed from the root-owned drop-in.
cat >"$NM_DNS_CONF" <<EOF
# Managed by omarchy-dns. Remove this file or run omarchy dns DHCP to use DHCP DNS again.
[global-dns]
@@ -277,6 +305,9 @@ Custom)
split_dns_servers "$dns_servers"
write_networkmanager_dns "$dns_servers"
set_connection_dns "$ipv4_dns" "$ipv6_dns"
# omarchy:heredoc-expands paths=none -- $dns_servers is a normalized,
# single-line DNS server list; the //,/ turns its comma separators into the
# spaces resolved.conf wants. It is written as data, not a path or command.
tee /etc/systemd/resolved.conf >/dev/null <<EOF
[Resolve]
DNS=${dns_servers//,/ }
+8 -2
View File
@@ -30,8 +30,14 @@ if [[ -f ~/.config/alacritty/alacritty.toml ]]; then
sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml
fi
if [[ -f ~/.config/kitty/kitty.conf ]]; then
sed -i "s/^font_family .*/font_family $font_name/g" ~/.config/kitty/kitty.conf
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
mkdir -p ~/.config/kitty
if grep -qE '^[[:space:]]*font_family[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
kitty_font_name=$(printf '%s' "$font_name" | sed 's/[\\&/]/\\&/g')
sed --follow-symlinks -i -E "s/^[[:space:]]*font_family[[:space:]]+.*/font_family $kitty_font_name/" ~/.config/kitty/kitty.conf
else
printf '\nfont_family %s\n' "$font_name" >>~/.config/kitty/kitty.conf
fi
pkill -USR1 kitty
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
+38 -3
View File
@@ -30,6 +30,35 @@ MKINITCPIO_CONF="/etc/mkinitcpio.conf.d/omarchy_resume.conf"
SWAP_FILE="/swap/swapfile"
RESUME_DROP_IN="/etc/limine-entry-tool.d/resume.conf"
install_root_file() {
local source="$1"
local destination="$2"
local mode="$3"
local stage
stage=$(sudo /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1
safe_stage_path "$stage" "$destination" || return 1
if sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" &&
sudo /usr/bin/mv -Tf -- "$stage" "$destination"; then
return 0
else
safe_stage_path "$stage" "$destination" && sudo /usr/bin/rm -f -- "$stage"
return 1
fi
}
safe_stage_path() {
local stage="$1"
local destination="$2"
local prefix suffix
prefix="${destination%/*}/.${destination##*/}.omarchy."
[[ $stage == "$prefix"* ]] || return 1
suffix=${stage#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
# Check if hibernation is already configured
if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; then
# Fix empty resume_offset if btrfs map-swapfile failed during initial setup
@@ -83,14 +112,20 @@ if ! swapon --show | grep -q "$SWAP_FILE"; then
sudo swapon -p 0 "$SWAP_FILE"
fi
# Ensure keyboard backlight doesn't prevent sleep
# Install this before writing the resume marker so a failed install remains
# retryable through the normal setup command.
if ! install_root_file "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" \
/usr/lib/systemd/system-sleep/keyboard-backlight 0755; then
echo "Could not install the keyboard-backlight system-sleep hook" >&2
exit 1
fi
# Add resume hook to mkinitcpio
sudo mkdir -p /etc/mkinitcpio.conf.d
echo "Adding resume hook to $MKINITCPIO_CONF"
echo "HOOKS+=(resume)" | sudo tee "$MKINITCPIO_CONF" >/dev/null
# Ensure keyboard backlight doesn't prevent sleep
sudo cp -p "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" /usr/lib/systemd/system-sleep/
# Add resume= kernel parameters so the initramfs resume hook knows where to find the
# hibernation image. Without these, resume happens late (after GPU drivers load) and fails.
if [[ ! -f $RESUME_DROP_IN ]]; then
+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.
+159
View File
@@ -0,0 +1,159 @@
#!/bin/bash
# omarchy:summary=Install the Hermes desktop app
# omarchy:requires-sudo=true
set -e
if (( EUID == 0 )); then
echo "Run this command as your desktop user, without sudo." >&2
exit 1
fi
echo "Installing Hermes Desktop..."
omarchy-pkg-add hermes-desktop
if [[ ! -r /usr/share/hermes-desktop/install.sh || ! -r /usr/share/hermes-desktop/runtime.patch ]] ||
! release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json 2>/dev/null); then
echo "The installed Hermes package cannot prepare in-app updates. Run 'omarchy update', then try again." >&2
exit 1
fi
# If Hermes was already installed for the terminal, the app supersedes it: one
# machine, one Hermes. This drops that copy so the terminal, the default agent
# and the app all end up on the app's installation.
omarchy-install-hermes-cli || true
# Keep the runtime at the root even when invoked from a Hermes profile.
HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}")
home_parent=$(dirname -- "$HERMES_HOME")
if [[ ${home_parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then
HERMES_HOME=$(dirname -- "$home_parent")
fi
export HERMES_HOME
runtime="$HERMES_HOME/hermes-agent"
native_app="$runtime/apps/desktop/release/linux-unpacked"
runtime_ready() {
[[ -f $runtime/.hermes-bootstrap-complete && -f $runtime/venv/bin/hermes && -x $runtime/venv/bin/hermes && -f $runtime/venv/bin/python && -x $runtime/venv/bin/python ]] &&
timeout 15 "$runtime/venv/bin/hermes" --version >/dev/null 2>&1
}
check_main() {
local main_commit
main_commit=$(git -C "$runtime" rev-parse --verify refs/heads/main 2>/dev/null || true)
if [[ -n $main_commit && $main_commit != "$release_commit" && $main_commit != "$(git -C "$runtime" rev-parse --verify refs/remotes/origin/main 2>/dev/null)" ]]; then
echo "Hermes main has local commits. Keep that work and prepare the desktop with 'hermes desktop --build-only'." >&2
return 1
fi
}
if ! runtime_ready; then
# The upstream installer can reset an existing checkout. Do not pin a newer
# or modified runtime back to the package release while repairing setup.
if [[ -e $runtime || -L $runtime ]]; then
if [[ $(git -C "$runtime" rev-parse HEAD 2>/dev/null) != "$release_commit" ]] ||
[[ -n $(git -C "$runtime" status --porcelain --untracked-files=all) ]]; then
echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2
exit 1
fi
check_main
fi
# Upstream replaces these commands, including foreign files and symlinks.
# Keep their original bytes/links before handing the names to the desktop.
command_backup=""
for command in hermes hermes-agent hermes-acp; do
command_path="$HOME/.local/bin/$command"
if [[ -e $command_path || -L $command_path ]]; then
if [[ ! -f $command_path && ! -L $command_path ]]; then
echo "Cannot replace $command_path: move it aside before installing Hermes Desktop." >&2
exit 1
fi
if [[ -z $command_backup ]]; then
command_backup=$(mktemp -d "$HOME/.local/bin/.hermes-before-desktop.XXXXXX")
echo "Saving existing Hermes commands in $command_backup"
fi
cp -a -- "$command_path" "$command_backup/"
fi
done
echo "Setting up the Hermes runtime..."
bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$HERMES_HOME"
if ! runtime_ready; then
echo "Hermes runtime setup did not complete. Re-run this command after resolving the installer error." >&2
exit 1
fi
fi
runtime_commit=$(git -C "$runtime" rev-parse HEAD)
if [[ $runtime_commit == "$release_commit" ]]; then
# The updater switches to main before checking for changes. Start main at
# the packaged release, with enough history for its first fast-forward.
check_main
if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then
git -C "$runtime" fetch --unshallow origin main
fi
git -C "$runtime" switch -C main "$release_commit"
if git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
git -C "$runtime" apply /usr/share/hermes-desktop/runtime.patch
elif ! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
echo "The Hermes Linux runtime patch conflicts with local changes. Existing files have been kept." >&2
exit 1
fi
fi
if [[ -e $native_app || -L $native_app ]]; then
if [[ ! -f $native_app/Hermes || ! -x $native_app/Hermes || ! -f $native_app/resources/app.asar || ! -f $native_app/resources/install-stamp.json ]]; then
echo "The Hermes desktop app at $native_app is incomplete. Repair it with 'hermes desktop --build-only' before trying again." >&2
exit 1
fi
else
if [[ $runtime_commit != "$release_commit" ]]; then
echo "The Hermes runtime has moved beyond the packaged desktop release. Run 'hermes desktop --build-only', then try again." >&2
exit 1
fi
desktop_changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json)
if [[ -n $desktop_changes ]]; then
echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2
exit 1
fi
mkdir -p -- "${native_app%/*}"
staging=$(mktemp -d "${native_app%/*}/.linux-unpacked.XXXXXX")
trap 'rm -rf -- "$staging"' EXIT
cp -a /opt/hermes-desktop/. "$staging/"
chmod 0755 "$staging/chrome-sandbox"
mv -T --no-clobber -- "$staging" "$native_app"
if [[ -e $staging ]]; then
echo "A Hermes desktop app appeared during setup. It has been kept; please try again." >&2
exit 1
fi
trap - EXIT
# Record this matching prebuilt app using the CLI's own content hash, so
# subsequent menu launches do not rebuild an app that is already current.
env -u PYTHONPATH -u PYTHONHOME "$runtime/venv/bin/python" - "$runtime" <<'PY'
import sys
from pathlib import Path
sys.path.insert(0, sys.argv[1])
from hermes_cli.main import _write_desktop_build_stamp
_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False)
PY
fi
echo "Opening Hermes Desktop..."
setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 &
# Only a running Hermes can be told which skin to show; a unit outlives this
# terminal and reports to the journal.
echo "Matching Hermes to the current theme once it is set up..."
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait
echo ""
echo "Hermes Desktop has been installed."
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# omarchy:summary=Install the OpenClaw agent platform and its Control UI web app
# omarchy:requires-sudo=true
set -e
echo "Installing OpenClaw..."
omarchy-pkg-add openclaw
# The desktop app is OpenClaw's Control UI: a web app served by its own
# gateway. The launcher entry goes through omarchy-launch-openclaw, which
# onboards or starts that gateway before opening the window. The icon ships
# inside the package, so nothing is fetched here.
echo "Installing the OpenClaw web app..."
omarchy-webapp-install OpenClaw "http://127.0.0.1:18789" \
/usr/lib/node_modules/openclaw/dist/control-ui/apple-touch-icon.png \
omarchy-launch-openclaw
# A first install runs onboarding right here: launching the app instead would
# open a second floating terminal for the wizard, identical to this one.
# omarchy-openclaw-onboard runs the wizard the way this flow needs (terminal
# prompts, gateway as a user service, and it actually returns). A machine
# that is already onboarded goes straight to the app.
if [[ -f $HOME/.openclaw/openclaw.json ]]; then
echo "Opening OpenClaw..."
setsid uwsm-app -- gtk-launch OpenClaw >/dev/null 2>&1 &
elif omarchy-openclaw-onboard && [[ -f $HOME/.openclaw/openclaw.json ]]; then
echo "Opening OpenClaw..."
setsid uwsm-app -- gtk-launch OpenClaw >/dev/null 2>&1 &
fi
echo ""
echo "OpenClaw has been installed."
echo "If you skipped onboarding, launching OpenClaw from the app grid resumes it."
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# omarchy:summary=Install T3 Code and point it at the Omarchy palette
# omarchy:requires-sudo=true
set -e
T3CODE_HOME="${T3CODE_HOME:-$HOME/.t3}"
T3CODE_STATE_DIR="$T3CODE_HOME/userdata"
echo "Installing T3 Code..."
omarchy-pkg-add t3code-bin
echo "Matching T3 Code to the current theme..."
mkdir -p "$T3CODE_STATE_DIR"
# An updated user's current theme may have been staged before this template existed.
if [[ ! -f $HOME/.local/state/omarchy/current/theme/t3code.json ]]; then
omarchy-theme-refresh
fi
omarchy-theme-set-t3code
# The packaged CLI selects the published palette before the app's first launch.
t3 theme set omarchy --base-dir "$T3CODE_HOME"
echo "Opening T3 Code..."
setsid uwsm-app -- gtk-launch t3code >/dev/null 2>&1 &
echo ""
echo "T3 Code has been installed."
+7 -1
View File
@@ -16,6 +16,12 @@ fi
printf -v install_message '%q' "Installing ${name}..."
printf -v desktop_id_arg '%q' "$desktop_id"
# The list has to reach omarchy-pkg-add as several words, so each word is quoted
# rather than the whole string; -d '' reads past newlines and always ends at EOF.
read -r -d '' -a package_list <<<"$packages" || true
printf -v packages_arg '%q ' "${package_list[@]}"
packages_arg="${packages_arg% }"
# The subshell keeps & from backgrounding the package installation too.
exec omarchy-launch-floating-terminal-with-presentation \
"echo ${install_message}; omarchy-pkg-add ${packages} && (setsid uwsm-app -- gtk-launch ${desktop_id_arg} >/dev/null 2>&1 &)"
"echo ${install_message}; omarchy-pkg-add ${packages_arg} && (setsid uwsm-app -- gtk-launch ${desktop_id_arg} >/dev/null 2>&1 &)"
+10 -1
View File
@@ -12,4 +12,13 @@ if [[ -z $name || -z $packages ]]; then
exit 1
fi
exec omarchy-launch-floating-terminal-with-presentation "echo 'Installing ${name}...'; omarchy-pkg-add ${packages}"
printf -v install_message '%q' "Installing ${name}..."
# The list has to reach omarchy-pkg-add as several words, so each word is quoted
# rather than the whole string; -d '' reads past newlines and always ends at EOF.
read -r -d '' -a package_list <<<"$packages" || true
printf -v packages_arg '%q ' "${package_list[@]}"
packages_arg="${packages_arg% }"
exec omarchy-launch-floating-terminal-with-presentation \
"echo ${install_message}; omarchy-pkg-add ${packages_arg}"
+10 -16
View File
@@ -4,9 +4,10 @@
# omarchy:args=<chrome|brave|brave-origin|edge|firefox|zen>
# omarchy:examples=omarchy install browser firefox | omarchy install browser brave
setup_policy_directory() {
sudo mkdir -p "$1"
sudo chmod a+rw "$1"
source "$OMARCHY_PATH/install/helpers/browser-policy.sh"
setup_chromium_policy_directory() {
browser_policy_setup_dir "$1"
}
announce_browser_installed() {
@@ -21,13 +22,6 @@ copy_chromium_flags() {
omarchy-install-chromium-ytdlp
}
setup_firefox_preferences() {
local distribution_dir="$1"
setup_policy_directory "$distribution_dir"
sudo cp -f "$OMARCHY_PATH/default/firefox/policies.json" "$distribution_dir/policies.json"
}
setup_firefox_wayland() {
mkdir -p ~/.config/environment.d
echo "MOZ_ENABLE_WAYLAND=1" > ~/.config/environment.d/omarchy-firefox-wayland.conf
@@ -38,7 +32,7 @@ chrome)
echo "Installing Chrome..."
omarchy-pkg-aur-add google-chrome || exit 1
setup_policy_directory /etc/opt/chrome/policies/managed
setup_chromium_policy_directory /etc/opt/chrome/policies/managed
copy_chromium_flags ~/.config/chrome-flags.conf
omarchy-theme-set-browser
announce_browser_installed "Chrome"
@@ -47,7 +41,7 @@ edge)
echo "Installing Edge..."
omarchy-pkg-aur-add microsoft-edge-stable-bin || exit 1
setup_policy_directory /etc/opt/edge/policies/managed
setup_chromium_policy_directory /etc/opt/edge/policies/managed
copy_chromium_flags ~/.config/microsoft-edge-stable-flags.conf
omarchy-theme-set-browser
announce_browser_installed "Edge"
@@ -56,7 +50,7 @@ brave)
echo "Installing Brave..."
omarchy-pkg-aur-add brave-bin || exit 1
setup_policy_directory /etc/brave/policies/managed
setup_chromium_policy_directory /etc/brave/policies/managed
copy_chromium_flags ~/.config/brave-flags.conf
omarchy-theme-set-browser
announce_browser_installed "Brave"
@@ -65,7 +59,7 @@ brave-origin)
echo "Installing Brave Origin..."
omarchy-pkg-aur-add brave-origin-bin || exit 1
setup_policy_directory /etc/brave/policies/managed
setup_chromium_policy_directory /etc/brave/policies/managed
copy_chromium_flags ~/.config/brave-origin-flags.conf
omarchy-theme-set-browser
announce_browser_installed "Brave Origin"
@@ -74,7 +68,7 @@ firefox)
echo "Installing Firefox..."
omarchy-pkg-add firefox || exit 1
setup_firefox_preferences /usr/lib/firefox/distribution
browser_policy_setup_firefox_distribution /usr/lib/firefox/distribution
setup_firefox_wayland
announce_browser_installed "Firefox"
;;
@@ -82,7 +76,7 @@ zen)
echo "Installing Zen..."
omarchy-pkg-aur-add zen-browser-bin || exit 1
setup_firefox_preferences /opt/zen-browser/distribution
browser_policy_setup_firefox_distribution /opt/zen-browser/distribution
setup_firefox_wayland
announce_browser_installed "Zen"
;;
+5 -1
View File
@@ -13,5 +13,9 @@ if [[ -z $name || -z $package || -z $family ]]; then
exit 1
fi
printf -v install_message '%q' "Installing ${name}..."
printf -v package_arg '%q' "$package"
printf -v family_arg '%q' "$family"
exec omarchy-launch-floating-terminal-with-presentation \
"echo 'Installing ${name}...'; omarchy-pkg-add ${package} && sleep 2 && omarchy-font-set '${family}'"
"echo ${install_message}; omarchy-pkg-add ${package_arg} && sleep 2 && omarchy-font-set ${family_arg}"
+1 -1
View File
@@ -10,7 +10,7 @@ set -e
echo "Installing Xbox controller Bluetooth support..."
# Install xpadneo to ensure controllers work out of the box
omarchy-pkg-add linux-headers xpadneo-dkms
omarchy-pkg-add xpadneo-dkms
# Prevent xpad/xpadneo driver conflict
echo blacklist xpad | sudo tee /etc/modprobe.d/blacklist-xpad.conf >/dev/null
+280
View File
@@ -0,0 +1,280 @@
#!/bin/bash
# omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin
# omarchy:args=[--check|--now|--owns|--remove]
# omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now
# Hermes pins every one of its dependencies exactly and declares
# Requires-Python >=3.11,<3.14, so it can neither be built against Arch's
# Python nor share the python-* packages. mise builds it a private environment
# instead.
#
# It gets its own installer rather than a line in omarchy-mise-install because
# of the interpreter pin. Given no compatible interpreter to hand, uv builds
# the venv against the system Python in violation of Hermes' own bound,
# reports success, and leaves the breakage to surface later inside a
# dependency -- and omarchy-mise-install writes a fixed stub with nowhere to
# say otherwise.
#
# There is only ever one Hermes on a machine. hermes-desktop cannot run against
# this one -- it needs a runtime built from its own commit, and the version gap
# fails its readiness probe -- so it installs its own under ~/.hermes and puts
# that on PATH. When the package is present it therefore owns Hermes outright:
# this installer stands aside and removes its own copy, so the terminal, the
# default agent and the app are all the same installation.
set -euo pipefail
mode=${1:-}
tool='pipx:hermes-agent[extras=all]'
python='3.13'
# The line that identifies the stub as this installer's; matched whole, so a
# wrapper that merely mentions the command is not mistaken for ours.
marker='# Written by omarchy-install-hermes-cli.'
# The package, not the runtime directory: it is installed before the app has
# ever run, and that is exactly when we must not start building a second copy.
desktop_owns_hermes() {
omarchy-pkg-present hermes-desktop
}
# The venv appears at the python-deps stage, several stages before the one that
# installs the command, so its presence says nothing about being usable. The
# marker is written last, and the command is what the agent actually runs.
desktop_hermes_ready() {
[[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]] || return 1
# An executable of that name proves nothing about whose it is; the app's own
# points into ~/.hermes, and anything else is not the install we are asking
# about. Matched as a plain string, because the path carries a dot and an
# unanchored pattern would also claim a wrapper pointing at ~/xhermes.
[[ -f $HOME/.local/bin/hermes ]] || return 1
grep -qF "$HOME/.hermes" "$HOME/.local/bin/hermes" || return 1
# And a marker left behind by an install whose venv has since gone answers
# for nothing, so the command has to run, exactly as a foreign one must.
hermes_prompt_ready
}
# Whether Hermes is really installed, not merely whether the stub exists. A
# stub on its own is cold: running it installs Hermes, which takes minutes.
installed() {
[[ -d "$(mise where "$tool" 2>/dev/null)/hermes-agent/lib/python$python" ]]
}
# The stub is the only thing this installer owns. Anything else at that path
# -- Hermes' official installer, a hand-rolled wrapper, even a dangling link
# -- was put there by the user and is never deleted or overwritten here.
# Symlinks count as foreign even when they resolve to a marked file: the stub
# is written as a regular file, so a link is someone else's arrangement.
ours() {
[[ -f $HOME/.local/bin/hermes && ! -L $HOME/.local/bin/hermes ]] &&
grep -qxF "$marker" "$HOME/.local/bin/hermes"
}
foreign_hermes() {
[[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours
}
# A hermes at that path is usable when it is a command that runs: a regular
# executable whose --version answers. The executable bit alone proves little -- a directory
# passes -x on search permission, and a wrapper whose interpreter or target is
# gone passes it too. The desktop app applies the same probe with the same 15
# second budget, so what passes here is what it will use.
hermes_runs() {
[[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] &&
timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1
}
# A flag counts only when the help defines it, not whenever it is mentioned:
# what follows must be a shape argparse prints after a definition -- the usage
# line's closing bracket, the gap before same-line help text, an uppercase
# metavar, or the end of the line. Prose like "With --tui: run ..." stays
# prose, and --tui-theme or --tui_mode never answers for --tui. Not probed by
# parsing an actual invocation on purpose: a release that ignores unknown
# arguments would turn the probe into a live session.
help_defines_flag() {
grep -qE -- "$1(]|[[:space:]][[:upper:]]|[[:space:]]{2}|$)" <<<"$2"
}
# Probed for the flags omarchy-agent actually passes -- --query to seed the
# session and --tui to keep it interactive -- rather than a marker standing in
# for them: the old probe keyed on chat carrying --oneshot, which no released
# Hermes did (it lived at the top level until v0.21 added chat's own), so
# every release read as "not ready".
hermes_prompt_ready() {
local help
hermes_runs &&
help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) &&
help_defines_flag '--tui' "$help" &&
help_defines_flag '--query' "$help"
}
# --owns answers whether the wrapper on PATH is the one this command wrote, so
# the migration and Remove Preinstalls do not each carry their own copy of the
# marker and drift from it.
if [[ $mode == "--owns" ]]; then
if ours; then exit 0; else exit 1; fi
fi
# --remove tears down a Hermes CLI this installer put in place -- the mise tool
# it installed and the stub it marked -- so Remove Hermes clears a CLI the app
# never superseded (an interrupted install, or the terminal CLI from before the
# app existed) rather than leaving it stranded on PATH. The whole teardown
# turns on the marked stub, exactly as replacement does further down: without
# it nothing proves the mise environment is Omarchy's rather than one the user
# built against the same spec, and a user's stays theirs. The desktop takeover
# removes the environment without asking, but that is its own bargain -- a
# second Hermes has to go whoever built it, and the app still provides the
# command afterwards; here nothing would. Idempotent: nothing owned, nothing
# to do.
if [[ $mode == "--remove" ]]; then
if ours; then
mise rm -g "$tool" >/dev/null 2>&1 || true
mise uninstall --all "$tool" >/dev/null 2>&1 || true
rm -f "$HOME/.local/bin/hermes" 2>/dev/null || true
# Every step is attempted before any is judged, and judged by what is left
# rather than by what the commands claimed: the marked stub still answering
# hermes, or mise still resolving the tool, is a CLI still installed no
# matter how the removal exited.
# Not "run --remove again": once the stub is gone nothing marks the mise
# environment as ours, so a rerun would find nothing it owns and succeed
# without touching what was left. Only the full commands finish the job.
if ours || mise where "$tool" >/dev/null 2>&1; then
echo "Could not remove the Hermes CLI Omarchy installed. Finish by hand:" >&2
echo " rm -f ~/.local/bin/hermes" >&2
echo " mise rm -g '$tool'" >&2
echo " mise uninstall --all '$tool'" >&2
exit 1
fi
fi
exit 0
fi
# --check lets callers tell a cold stub from a working one before they commit
# to a path that assumes Hermes is ready.
if [[ $mode == "--check" ]]; then
if desktop_owns_hermes; then
if desktop_hermes_ready; then exit 0; else exit 1; fi
fi
# A foreign command is ready only when it also supports prompted sessions;
# since it is not ours to replace, nothing this installer does will update it.
if foreign_hermes; then
if hermes_prompt_ready; then exit 0; else exit 1; fi
fi
if installed && hermes_prompt_ready; then exit 0; else exit 1; fi
fi
# Hand Hermes over to the app rather than keeping a second copy beside it.
if desktop_owns_hermes; then
# Not gated on that copy being healthy: `mise up` can rebuild it against the
# wrong interpreter and a half-finished install answers to neither test, and
# either way it is still a second Hermes. Removing nothing is harmless.
if mise where "$tool" >/dev/null 2>&1; then
echo "Hermes Desktop provides Hermes; removing the separate CLI install..." >&2
fi
mise rm -g "$tool" >/dev/null 2>&1 || true
mise uninstall --all "$tool" >/dev/null 2>&1 || true
# Our own stub has to go with it. Left in place it still answers `hermes`
# until the app's bootstrap overwrites it, and answering means building the
# second Hermes this whole arrangement exists to avoid.
if ours; then
rm -f "$HOME/.local/bin/hermes"
fi
if desktop_hermes_ready; then
exit 0
fi
echo "Hermes Desktop is installed but has not set Hermes up yet." >&2
echo "Launch Hermes Desktop once to finish installing it." >&2
exit 1
fi
# The user already has a hermes of their own. Leave it be: a working one is
# what the default agent will run, and a broken one is theirs to fix.
if foreign_hermes; then
if hermes_prompt_ready; then
exit 0
fi
if hermes_runs; then
echo "~/.local/bin/hermes does not support the interactive seeded sessions Omarchy needs." >&2
echo "Update it to a Hermes Agent release with interactive chat queries, then run omarchy-install-hermes-cli again." >&2
exit 1
fi
echo "~/.local/bin/hermes exists but is not runnable, and it was not installed by Omarchy." >&2
echo "Fix or remove it, then run omarchy-install-hermes-cli again." >&2
exit 1
fi
# Only the marked wrapper proves the matching mise environment is ours to replace.
if installed && ! hermes_prompt_ready; then
if ours; then
echo "Updating Hermes for prompted sessions..." >&2
mise rm -g "$tool" >/dev/null 2>&1 || true
mise uninstall --all "$tool" >/dev/null 2>&1 || true
else
echo "A Hermes mise environment exists without an Omarchy-owned wrapper." >&2
echo "Update or remove it explicitly, then run omarchy-install-hermes-cli again." >&2
exit 1
fi
fi
mkdir -p "$HOME/.local/bin"
rm -f "$HOME/.local/bin/hermes"
cat >"$HOME/.local/bin/hermes" <<EOF
#!/bin/bash
$marker
export UV_PYTHON="$python"
# Exported rather than set on the install line alone, so the version resolved
# to run agrees with the one just installed. Hermes ships several times a week
# and mise's cooldown would otherwise hold a new release back for days.
export MISE_MINIMUM_RELEASE_AGE=0
# mise up -- which omarchy update runs -- reinstalls without that pin, so this
# asks which interpreter is actually there rather than whether anything is.
if ! [[ -d "\$(mise where '$tool' 2>/dev/null)/hermes-agent/lib/python$python" ]]; then
echo "Installing Hermes on Python $python (this takes a minute)..." >&2
# mise's pipx backend shells out to uv, which a stock Omarchy does not have.
# It is fetched here rather than when this stub was written, so setting up a
# machine that never runs Hermes costs nothing.
if omarchy-cmd-missing uv && ! mise where uv >/dev/null 2>&1; then
mise use -g --quiet uv@latest || exit 1
fi
mise use -g --quiet --force '$tool' || exit 1
fi
# The pin belongs to building Hermes, not to everything Hermes then runs.
# Exported it would reach the agent and every command it shells out to, so a
# uv in the user's own project would resolve 3.13 there too -- uv only warns
# when that contradicts the project's requires-python, and builds it anyway.
exec env -u UV_PYTHON mise x '$tool' -- hermes "\$@"
EOF
chmod +x "$HOME/.local/bin/hermes"
# The desktop app resolves a hermes on PATH by running `hermes --version` with
# a 15 second budget, then falls back to cloning its own copy when that times
# out. A first-run mise install does not fit in 15 seconds, so anything that
# hands Hermes to the GUI has to install it here rather than leave it stubbed.
if [[ $mode == "--now" ]]; then
"$HOME/.local/bin/hermes" --version
if ! hermes_prompt_ready; then
echo "Hermes installed without the interactive seeded sessions Omarchy needs." >&2
exit 1
fi
fi
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# omarchy:summary=Ensure the OpenClaw CLI is installed for the default agent
# omarchy:args=[--check|--now]
# omarchy:requires-sudo=true
# OpenClaw is not a mise tool: the openclaw pacman package is the one OpenClaw
# installation on the machine — the CLI, the gateway, and the Install > AI web
# app all share it, and the fast ring keeps it current. The default-agent flow
# talks to that package through the same --check/--now contract mise-backed
# agents get from mise itself.
set -euo pipefail
case "${1:---now}" in
--check)
omarchy-pkg-present openclaw
;;
--now)
if ! omarchy-pkg-present openclaw; then
echo "Installing OpenClaw..."
omarchy-pkg-add openclaw
fi
;;
*)
echo "Usage: omarchy-install-openclaw-cli [--check|--now]" >&2
exit 1
;;
esac
+4 -1
View File
@@ -4,8 +4,11 @@
set -e
# 1Password reads the display scale itself, the way Electron apps do, and comes
# up oversized next to every other window on a scaled monitor. The packaged
# .desktop pins it too; this covers the hotkey, which runs the binary directly.
if omarchy-cmd-present 1password; then
exec setsid uwsm-app -- 1password
exec setsid uwsm-app -- 1password --force-device-scale-factor=1
else
exec omarchy-launch-floating-terminal-with-presentation omarchy-install-service-1password
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
+91
View File
@@ -0,0 +1,91 @@
#!/bin/bash
# omarchy:summary=Open the OpenClaw Control UI (or its terminal UI with --tui), onboarding or starting the gateway first when needed.
# omarchy:args=[--tui [--message <text>]]
set -euo pipefail
tui=false
message=()
if [[ ${1:-} == "--tui" ]]; then
tui=true
shift
if [[ ${1:-} == "--message" ]]; then
message=(--message "${2:?--message needs a value}")
shift 2
fi
fi
# Onboarding is OpenClaw's own interactive wizard, run through
# omarchy-openclaw-onboard so it stays in the terminal, installs the gateway
# as a user service, and returns (see that script for why bare `openclaw
# onboard` does none of those as of 2026.9.1). The config check gates what
# follows because the wizard's "Skip for now" also exits 0: only inference
# that passed writes the config, and skipping must not loop back into the
# wizard.
if [[ ! -f $HOME/.openclaw/openclaw.json ]]; then
if [[ $tui == "true" ]]; then
# Already in a terminal, so the wizard runs right here.
omarchy-openclaw-onboard
[[ -f $HOME/.openclaw/openclaw.json ]] || exit 1
else
# The wizard needs a real terminal, and chaining the relaunch means
# finishing it lands the user in the app. Single quotes so $HOME expands
# in the spawned terminal.
# shellcheck disable=SC2016
exec omarchy-launch-floating-terminal-with-presentation \
'omarchy-openclaw-onboard && [[ -f $HOME/.openclaw/openclaw.json ]] && omarchy-launch-openclaw'
fi
fi
dashboard_url() {
# browserUrl carries a single-use browser handoff; url is the shared-auth
# fallback for gateways predating the handoff flow. The timeout keeps a
# wedged CLI from hanging an app-grid launch that has no terminal to ^C.
timeout 10 openclaw dashboard --json 2>/dev/null | jq -re '.browserUrl // .url // empty'
}
# --json never starts the gateway, so an empty answer means it is not running.
# Recovery goes through the gateway's own service commands. `dashboard --yes`
# used to be the start/install-without-prompting path, but as of 2026.9.1 it
# defers to "the owning supervisor" for both a missing and a stopped unit,
# and once the gateway is up it copies a one-time pairing URL into the
# clipboard, which nothing here needs. Enablement, not the unit file, decides:
# a unit that was written but never enabled (an install that died halfway)
# would otherwise be "started" once and stay off at every following login,
# where --force rewrites and enables it.
if ! url=$(dashboard_url); then
if systemctl --user is-enabled --quiet openclaw-gateway.service 2>/dev/null; then
timeout 60 openclaw gateway start >&2 || true
else
timeout 120 openclaw gateway install --force >&2 || true
fi
# A first-ever service install (unit write, daemon-reload, first boot)
# takes materially longer than starting an installed unit, so the budget
# is sized for the slow case.
for _ in {1..30}; do
url=$(dashboard_url) && break
sleep 1
done
fi
if [[ -z ${url:-} ]]; then
echo "OpenClaw's gateway did not come up. Check it with: openclaw gateway status" >&2
echo "If onboarding never finished, rerun it with: omarchy-openclaw-onboard" >&2
exit 1
fi
if [[ $tui == "true" ]]; then
# Attach to the gateway rather than `openclaw chat`: chat is the embedded
# local runtime, which refuses to start while the gateway owns ~/.openclaw's
# state directory -- and on any machine set up through Install > AI, the
# gateway service is running whenever the desktop session is.
exec openclaw tui "${message[@]}"
fi
# The handoff URL rides in the browser's argv, which uwsm's app daemon echoes
# into the user journal. Accepted: the token is single-use with a ten-minute
# expiry against a loopback-only gateway, and journal access already implies
# access to ~/.openclaw itself.
exec omarchy-launch-webapp "$url"
+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
+3 -3
View File
@@ -85,16 +85,16 @@ wait_for_pacman_transaction
mkdir -p "$STATE_DIR"
[[ -d $MIGRATIONS_DIR ]] || exit 0
while IFS=$'\t' read -r name file marker; do
while IFS=$'\t' read -r name file marker <&3; do
[[ -n $name ]] || continue
if [[ ! -f $marker ]]; then
echo -e "\e[32m\nRunning migration (${name%.sh})\e[0m"
OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$file"
OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$file" 3<&-
mkdir -p "$(dirname "$marker")"
touch "$marker"
fi
done < <(migration_entries)
done 3< <(migration_entries)
# Clear a login-time notification the user left sitting there and then resolved
# by running migrations some other way. The substring matches both the current
+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.
+32 -2
View File
@@ -12,8 +12,38 @@ package=$1
command=${2:-$1}
bin=${3:-$command}
# The command name becomes a file name under ~/.local/bin, so a slash in it
# writes the wrapper somewhere else and the rm below deletes somewhere else. A
# leading dot hides it or walks up, and a leading dash makes a name that reads
# as an option to whatever picks it up. Checked before anything is removed or
# written, and kept to those shapes so package names like npm:playwright still
# stand in for the command name.
case "$command" in
*/* | .* | -* | *[[:cntrl:]]*)
echo "omarchy-mise-install: '$command' is not usable as a command name" >&2
exit 1
;;
esac
mkdir -p "$HOME/.local/bin"
# The heredoc below is unquoted, so whatever these hold is written into the
# wrapper as shell source. Quote them the way omarchy-install-and-launch does, so
# a package name carrying shell characters stays one argument instead of running.
printf -v package_arg '%q' "$package"
printf -v bin_arg '%q' "$bin"
# Keep values that are inert inside double quotes in the form existing migrations
# recognize, including mise backend options that %q would unnecessarily escape.
case "$package" in
*'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;;
*) package_arg="\"$package\"" ;;
esac
case "$bin" in
*'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;;
*) bin_arg="\"$bin\"" ;;
esac
# These tools install and upgrade on first run, so mise's release cooldown would
# hold a new version back for days after it ships. Exported rather than set on
# the install line alone, so resolving the version to execute agrees with the
@@ -22,8 +52,8 @@ 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
exec mise x "$package" -- "$bin" "\$@"
mise use -g --quiet $package_arg || exit 1
exec mise x $package_arg -- $bin_arg "\$@"
EOF
chmod +x "$HOME/.local/bin/$command"
+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
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# omarchy:summary=Run OpenClaw's setup wizard the way Omarchy needs it: in the terminal, installing the gateway as a user service, and returning when it is done.
# Not bare `openclaw onboard`: as of 2026.9.1 that is the guided flow, which
# ends by running a foreground gateway and handing off to a browser tab, and
# never returns. --install-daemon keeps it to the classic wizard (minimal
# prompts with --flow quickstart) that installs the gateway service, and
# --skip-ui drops its closing Control UI/TUI prompt since whoever called this
# opens one next.
#
# That classic wizard has one wrinkle: with --skip-ui it prints "Onboarding
# complete" and then never exits. Upstream only calls exit(0) when it launched
# the TUI, and the model sign-in leaves an open socket that keeps the process
# alive otherwise. So this script watches for the gateway to answer and then
# stops the wizard. That is safe because every prompt in the quickstart flow
# runs before the gateway service is installed and reachable: by the time the
# dashboard answers, only the closing notes are left.
set -uo pipefail
wizard=(openclaw onboard --flow quickstart --install-daemon --skip-ui)
config=$HOME/.openclaw/openclaw.json
settle_seconds=${OMARCHY_OPENCLAW_ONBOARD_SETTLE_SECONDS:-3}
# Counted from the moment the config exists, i.e. once the wizard has applied
# setup; the prompts before that take as long as the user takes.
gateway_timeout=${OMARCHY_OPENCLAW_ONBOARD_GATEWAY_TIMEOUT:-180}
gateway_answers() {
timeout 10 openclaw dashboard --json 2>/dev/null | jq -e '.ok == true' >/dev/null 2>&1
}
# A gateway already answering means OpenClaw is set up, and this run must not
# read its own success off state that predates it: the wizard's repair pass
# would be stopped mid-prompt the moment the watcher looked. Nothing to do.
if [[ -f $config ]] && gateway_answers; then
echo "OpenClaw is already set up and its gateway is running." >&2
echo "To change providers or settings, run: openclaw onboard --classic" >&2
exit 0
fi
# Marks when this run began, so a config left behind by an earlier, incomplete
# setup is not mistaken for this run having applied its own: the gateway
# deadline below must not start ticking while the user is still at prompts.
started=$(mktemp)
trap 'rm -f "$started"' EXIT
# Backgrounded so this script can watch it, but with the terminal kept as its
# stdin (bash would otherwise hand a background job /dev/null). Job control is
# off in a script, so it stays in the terminal's foreground process group and
# reads from it freely. Ctrl-C does not reach it directly, though: bash starts
# async children with SIGINT ignored when job control is off, so the INT trap
# below is what turns Ctrl-C into the wizard's exit.
"${wizard[@]}" <&0 &
wizard_pid=$!
stop_wizard() {
kill -TERM "$wizard_pid" 2>/dev/null || true
}
# Any signal at this script, whether Ctrl-C from the terminal or a kill aimed
# at its pid alone, takes the wizard down with it rather than leaving it
# running unwatched.
trap 'stop_wizard' INT TERM HUP
# Whether this run has applied setup: the config exists and is not older than
# the run itself.
config_applied() {
[[ -f $config && ! $started -nt $config ]]
}
# Whether this run's gateway is up: setup applied, and the process answering
# on the gateway's port is the main process of the service the wizard
# installs. Not the dashboard alone: with no config on disk `openclaw
# dashboard --json` still probes the default loopback port, so a gateway left
# behind by something else (an earlier guided onboarding's foreground
# gateway, say) would read as this run's success while the user is still at
# the first prompt. And not the unit being active either: its Type=simple
# counts it active from the fork, before it has found the port taken by such
# an orphan, and the app would then open on the orphan rather than the
# service this run installed.
gateway_ready() {
config_applied || return 1
local json port listener main_pid
json=$(timeout 10 openclaw dashboard --json 2>/dev/null) || return 1
jq -e '.ok == true' <<<"$json" >/dev/null 2>&1 || return 1
port=$(jq -r '.port // empty' <<<"$json" 2>/dev/null)
[[ -n $port ]] || return 1
listener=$(ss -ltnpH "sport = :$port" 2>/dev/null | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
main_pid=$(systemctl --user show -p MainPID --value openclaw-gateway.service 2>/dev/null)
[[ -n $listener && -n $main_pid && $main_pid != 0 && $listener == "$main_pid" ]]
}
stopped=false
timed_out=false
config_seen_at=
while kill -0 "$wizard_pid" 2>/dev/null; do
sleep 2
if gateway_ready; then
# Let the outro finish printing, then end the process the wizard leaves
# running.
sleep "$settle_seconds"
stop_wizard
stopped=true
break
fi
config_applied || continue
: "${config_seen_at:=$SECONDS}"
if (( SECONDS - config_seen_at >= gateway_timeout )); then
# Setup was applied but the service never came up (port taken, unit
# failing, ...): the wizard would sit in its never-exiting state forever,
# and so would whoever is waiting on this script.
stop_wizard
timed_out=true
break
fi
done
wait "$wizard_pid"
rc=$?
if [[ $timed_out == true ]]; then
echo "OpenClaw's gateway did not come up within ${gateway_timeout}s of setup finishing." >&2
echo "Check it with: openclaw gateway status" >&2
exit 1
fi
# A wizard stopped here after the gateway came up did its job. One that
# exited on its own, including a user who chose "Skip for now" (no config,
# non-zero), keeps its own exit code.
[[ $stopped == true ]] && rc=0
exit "$rc"
+1 -1
View File
@@ -24,6 +24,6 @@ if [[ -n $pkg_names ]]; then
source omarchy-sudo-keepalive
echo "$pkg_names" | sed 's/^/aur\//' | tr '\n' ' ' | xargs yay -S --noconfirm
sudo updatedb
sudo updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots
omarchy-show-done
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
+4 -2
View File
@@ -3,5 +3,7 @@
# omarchy:summary=Restore the default Omarchy Plymouth boot theme and SDDM login screen
# omarchy:requires-sudo=true
omarchy-refresh-plymouth
omarchy-refresh-sddm
set -euo pipefail
"$OMARCHY_PATH/bin/omarchy-refresh-plymouth"
"$OMARCHY_PATH/bin/omarchy-refresh-sddm"
+365 -67
View File
@@ -5,79 +5,377 @@
# omarchy:examples=omarchy plymouth set '#1d2021' '#ebdbb2' ~/.local/state/omarchy/current/theme/plymouth/logo.png
# omarchy:requires-sudo=true
# Configure the Plymouth boot theme with a custom background color, text color, and logo.
# Stages the change in a temp dir, then commits the staged files to /usr/share and
# rebuilds the initramfs. Also syncs the SDDM login screen (the post-logout
# screen) with the same colors and logo so boot/login stay visually unified.
set -euo pipefail
if (( $# != 3 )); then
# Build the authoritative theme in a root-owned directory, then publish each
# fixed destination atomically. The caller opens the selected logo before sudo,
# so the privileged process never resolves a user-controlled input path.
usage() {
echo "Usage: omarchy-plymouth-set <background-hex> <text-hex> <path-to-logo.png>" >&2
exit 1
fi
}
bg_hex="${1#\#}"
text_hex="${2#\#}"
logo_path="$3"
if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then
echo "Invalid background color: $1 (expected #RRGGBB)" >&2
exit 1
fi
if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then
echo "Invalid text color: $2 (expected #RRGGBB)" >&2
exit 1
fi
if [[ ! -f $logo_path ]]; then
echo "Logo file not found: $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}')
theme_dir="/usr/share/plymouth/themes/omarchy"
staging_dir=$(mktemp -d)
trap 'rm -rf "$staging_dir"' EXIT
find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} +
cp "$logo_path" "$staging_dir/logo.png"
sed -i \
-e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \
-e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \
"$staging_dir/omarchy.script"
for asset in bullet.png entry.png lock.png progress_bar.png; do
magick "$staging_dir/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$staging_dir/$asset"
done
sudo cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/"
sudo plymouth-set-default-theme omarchy
if omarchy-cmd-present limine-mkinitcpio; then
sudo limine-mkinitcpio
if (( $# == 3 )); then
mode=set
elif (( $# == 1 )); then
case "$1" in
--refresh-default)
mode=refresh-plymouth
;;
--refresh-sddm-default)
mode=refresh-sddm
;;
*)
usage
;;
esac
else
sudo mkinitcpio -P
usage
fi
# Sync the SDDM login screen with the same colors and logo.
sddm_dir="/usr/share/sddm/themes/omarchy"
sddm_template="$OMARCHY_PATH/default/sddm/omarchy/Main.qml"
if (( EUID == 0 )); then
echo "Error: run omarchy-plymouth-set as your user, not under sudo." >&2
exit 1
fi
sed \
-e "s/#1a1b26/#$bg_hex/g" \
-e "s/#ffffff/#$text_hex/g" \
"$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null
logo_fd=
if [[ $mode != "set" ]]; then
bg_hex=
text_hex=
else
bg_hex="${1#\#}"
text_hex="${2#\#}"
logo_path="$3"
sudo cp "$logo_path" "$sddm_dir/logo.png"
for asset in bullet.png entry.png lock.png; do
sudo cp "$staging_dir/$asset" "$sddm_dir/$asset"
done
for asset in entry lock; do
magick "$staging_dir/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$staging_dir/$asset-failed.png"
sudo cp "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png"
done
sudo rm -f "$sddm_dir/logo.svg"
if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then
echo "Invalid background color: $1 (expected #RRGGBB)" >&2
exit 1
fi
if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then
echo "Invalid text color: $2 (expected #RRGGBB)" >&2
exit 1
fi
if [[ ! -f $logo_path ]]; then
echo "Logo file not found: $logo_path" >&2
exit 1
fi
if [[ -L $logo_path ]]; then
echo "Logo file is a symlink, which is not accepted: $logo_path" >&2
exit 1
fi
# Open the logo while still unprivileged. A replacement symlink to a root-only
# file therefore fails here instead of being followed after sudo starts.
if ! exec {logo_fd}<"$logo_path"; then
echo "Unable to open logo file as the current user: $logo_path" >&2
exit 1
fi
if [[ ! -f /proc/$$/fd/$logo_fd ]]; then
echo "Logo input is no longer a regular file: $logo_path" >&2
exit 1
fi
fi
run_root_transaction() {
sudo /bin/bash -c '
set -eEuo pipefail
PATH=/usr/bin:/bin
export PATH
umask 077
# Every check below is a bare assertion that aborts under set -e. Name the
# subject of each one so a refusal reaches the user instead of exiting mute.
failure_context="the privileged Plymouth transaction"
failure_reported=
report_failure() {
[[ -z $failure_reported ]] || return 0
failure_reported=1
printf "omarchy-plymouth-set: refusing to publish: %s failed validation\n" "$failure_context" >&2
if [[ -n ${failure_hint:-} ]]; then
printf "omarchy-plymouth-set: %s\n" "$failure_hint" >&2
fi
}
trap report_failure ERR
mode=$1
source_root=$2
bg_hex=$3
text_hex=$4
max_asset_size=$5
failure_context="the arguments of the privileged transaction"
[[ $mode == "set" || $mode == "refresh-plymouth" || $mode == "refresh-sddm" ]]
[[ $source_root == /* ]]
[[ $max_asset_size =~ ^[0-9]+$ ]]
(( max_asset_size > 0 ))
failure_context="the Omarchy source tree $source_root"
canonical_source_root=$(realpath -e -- "$source_root")
[[ $canonical_source_root == "$source_root" ]]
validate_trusted_directory() {
local directory=$1 canonical uid directory_mode
failure_context="directory $directory"
canonical=$(realpath -e -- "$directory")
[[ $canonical == "$directory" && -d $directory && ! -L $directory ]]
while :; do
failure_context="directory $directory (must be root-owned and not group- or world-writable)"
uid=$(stat -c %u -- "$directory")
directory_mode=$(stat -c %a -- "$directory")
(( uid == 0 ))
(( (8#$directory_mode & 0022) == 0 ))
[[ $directory == "/" ]] && break
directory=${directory%/*}
[[ -n $directory ]] || directory=/
done
}
validate_trusted_configuration_file() {
local configuration=$1 canonical uid configuration_mode size
failure_context="root configuration $configuration"
[[ -f $configuration && ! -L $configuration ]]
canonical=$(realpath -e -- "$configuration")
[[ $canonical == "$configuration" ]]
validate_trusted_directory "${configuration%/*}"
uid=$(stat -c %u -- "$configuration")
configuration_mode=$(stat -c %a -- "$configuration")
size=$(stat -c %s -- "$configuration")
(( uid == 0 ))
(( (8#$configuration_mode & 0022) == 0 ))
(( size > 0 && size <= 4096 ))
}
# A packaged tree must be root-owned. A development checkout is the one
# deliberate exception: omarchy dev link records its canonical path in a
# root-owned /etc/omarchy.conf. That is already an explicit decision to run
# privileged Omarchy commands from user-editable code in the checkout, so
# reading its packaged assets does not widen the development trust boundary.
development_source=false
source_root_uid=$(stat -c %u -- "$source_root")
if (( source_root_uid != 0 )); then
omarchy_conf=/etc/omarchy.conf
failure_context="$source_root is user-owned and $omarchy_conf must contain its trusted dev-link authorization; run omarchy dev link to authorize it"
failure_hint="$source_root is user-owned; run omarchy dev link to authorize this development checkout, or omarchy dev unlink to use the packaged tree"
validate_trusted_configuration_file "$omarchy_conf"
# validate_trusted_configuration_file walks /etc up to / and leaves its own
# subject behind in failure_context. Without restoring ours, a checkout
# that simply is not the authorized one refuses with "directory / must be
# root-owned and not group- or world-writable" -- naming a directory that
# passed, and sending the reader after a filesystem problem that is not
# there.
failure_context="the dev-link authorization in $omarchy_conf, which must name $source_root"
quoted_source_root=$source_root
quoted_source_root=${quoted_source_root//\\/\\\\}
quoted_source_root=${quoted_source_root//\"/\\\"}
quoted_source_root=${quoted_source_root//\$/\\\$}
quoted_source_root=${quoted_source_root//\`/\\\`}
expected_config_line="export OMARCHY_PATH=\"$quoted_source_root\""
mapfile -t omarchy_config_lines <"$omarchy_conf"
(( ${#omarchy_config_lines[@]} == 1 ))
[[ ${omarchy_config_lines[0]} == "$expected_config_line" ]]
development_source=true
failure_hint=
fi
if [[ $mode == "set" ]]; then
[[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]
[[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]
fi
theme_dir=/usr/share/plymouth/themes/omarchy
sddm_dir=/usr/share/sddm/themes/omarchy
plymouth_theme_assets=(
bullet.png
entry.png
lock.png
logo.png
omarchy.plymouth
omarchy.script
preview-unlock.png
progress_bar.png
progress_box.png
)
plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png)
sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png)
sddm_default_assets=("${sddm_theme_assets[@]}" metadata.desktop theme.conf)
plymouth_assets=()
sddm_assets=()
case "$mode" in
set)
plymouth_assets=("${plymouth_theme_assets[@]}")
sddm_assets=("${sddm_theme_assets[@]}")
;;
refresh-plymouth)
plymouth_assets=("${plymouth_default_assets[@]}")
;;
refresh-sddm)
sddm_assets=("${sddm_default_assets[@]}")
;;
esac
validate_trusted_file() {
local source=$1 canonical uid file_mode size
failure_context="packaged source file $source"
[[ -f $source && ! -L $source ]]
canonical=$(realpath -e -- "$source")
[[ $canonical == "$source" ]]
file_mode=$(stat -c %a -- "$source")
size=$(stat -c %s -- "$source")
(( size > 0 && size <= max_asset_size ))
if ! $development_source; then
validate_trusted_directory "${source%/*}"
uid=$(stat -c %u -- "$source")
(( uid == 0 ))
(( (8#$file_mode & 0022) == 0 ))
fi
}
copy_trusted_file() {
local source=$1 destination=$2
validate_trusted_file "$source"
install -o 0 -g 0 -m 0600 -- "$source" "$destination"
}
staging_dir=$(mktemp -d /tmp/omarchy-plymouth.XXXXXXXX)
temporary=
cleanup() {
[[ -z $temporary ]] || rm -f -- "$temporary"
rm -rf -- "$staging_dir"
}
trap cleanup EXIT HUP INT TERM
chown 0:0 -- "$staging_dir"
chmod 0700 -- "$staging_dir"
plymouth_stage=$staging_dir/plymouth
sddm_stage=$staging_dir/sddm
mkdir -m 0700 -p -- "$plymouth_stage/logos" "$sddm_stage"
for asset in "${plymouth_assets[@]}"; do
copy_trusted_file "$source_root/default/plymouth/$asset" "$plymouth_stage/$asset"
done
if [[ $mode == "set" ]]; then
# stdin was opened by the unprivileged caller. Read no more than the
# documented limit into the root-owned stage before doing other work.
failure_context="the selected logo (expected 1 to $max_asset_size bytes)"
head -c "$((max_asset_size + 1))" >"$plymouth_stage/logo.png"
logo_size=$(stat -c %s -- "$plymouth_stage/logo.png")
(( logo_size > 0 && logo_size <= max_asset_size ))
chown 0:0 -- "$plymouth_stage/logo.png"
chmod 0600 -- "$plymouth_stage/logo.png"
cp --reflink=never -- "$plymouth_stage/logo.png" "$sddm_stage/logo.png"
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}")
sed -i \
-e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \
-e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \
"$plymouth_stage/omarchy.script"
for asset in bullet.png entry.png lock.png progress_bar.png; do
magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset"
done
copy_trusted_file "$source_root/default/sddm/omarchy/Main.qml" "$sddm_stage/Main.qml"
sed -i \
-e "s/#1a1b26/#__OMARCHY_SDDM_BG__/g" \
-e "s/#ffffff/#__OMARCHY_SDDM_TEXT__/g" \
-e "s/#__OMARCHY_SDDM_BG__/#$bg_hex/g" \
-e "s/#__OMARCHY_SDDM_TEXT__/#$text_hex/g" \
"$sddm_stage/Main.qml"
for asset in bullet.png entry.png lock.png; do
cp --reflink=never -- "$plymouth_stage/$asset" "$sddm_stage/$asset"
done
for asset in entry lock; do
magick "$plymouth_stage/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$sddm_stage/$asset-failed.png"
done
chown -R 0:0 -- "$staging_dir"
find "$staging_dir" -type f -exec chmod 0600 -- {} +
elif (( ${#sddm_assets[@]} )); then
for asset in "${sddm_assets[@]}"; do
copy_trusted_file "$source_root/default/sddm/omarchy/$asset" "$sddm_stage/$asset"
done
fi
if (( ${#plymouth_assets[@]} )); then
validate_trusted_directory "$theme_dir"
if [[ $mode == "refresh-plymouth" ]]; then
validate_trusted_directory "$theme_dir/logos"
fi
fi
if (( ${#sddm_assets[@]} )); then
validate_trusted_directory "$sddm_dir"
fi
publish_asset() {
local source=$1 destination=$2 parent filename source_size copied_size
failure_context="destination $destination"
[[ -f $source && ! -L $source ]]
(( $(stat -c %u -- "$source") == 0 ))
source_size=$(stat -c %s -- "$source")
(( source_size > 0 && source_size <= max_asset_size ))
[[ $destination == /* && $destination != */ && $destination != *"/../"* ]]
parent=${destination%/*}
filename=${destination##*/}
[[ -n $parent && -n $filename && $filename != "." && $filename != ".." ]]
validate_trusted_directory "$parent"
temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX")
install -o 0 -g 0 -m 0644 -- "$source" "$temporary"
copied_size=$(stat -c %s -- "$temporary")
(( copied_size == source_size ))
cmp -s -- "$source" "$temporary"
sync -f -- "$temporary"
mv --no-copy -fT -- "$temporary" "$destination"
temporary=
}
if (( ${#plymouth_assets[@]} )); then
for asset in "${plymouth_assets[@]}"; do
publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset"
done
fi
if (( ${#sddm_assets[@]} )); then
for asset in "${sddm_assets[@]}"; do
publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset"
done
validate_trusted_directory "$sddm_dir"
rm -f -- "$sddm_dir/logo.svg"
fi
' bash "$mode" "$OMARCHY_PATH" "$bg_hex" "$text_hex" "$((64 * 1024 * 1024))"
}
if [[ $mode == "set" ]]; then
run_root_transaction <&"$logo_fd"
else
run_root_transaction </dev/null
fi
if [[ $mode != "refresh-sddm" ]]; then
sudo plymouth-set-default-theme omarchy
if omarchy-cmd-present limine-mkinitcpio; then
sudo limine-mkinitcpio
else
sudo mkinitcpio -P
fi
fi
+19 -3
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,15 @@ user_groups() {
if [[ -f $PROVISIONING_DIR/groups ]]; then
while IFS= read -r group; do
[[ -n $group ]] || continue
# Never replay old privileged group defaults. Docker is always opt-in.
# Input is only retained when the factory image has one of the features
# whose installer deliberately grants access to raw input devices.
[[ $group == "docker" ]] && continue
if [[ $group == "input" ]] &&
! pacman -Qq xpadneo-dkms &>/dev/null &&
! pacman -Qq ydotool &>/dev/null; then
continue
fi
getent group "$group" >/dev/null || continue
[[ ",$groups," == *",$group,"* ]] || groups+=",$group"
done <"$PROVISIONING_DIR/groups"
@@ -736,6 +746,12 @@ create_user() {
# for specific commands), and a duplicate grant is harmless.
echo "%wheel ALL=(ALL:ALL) ALL" >/etc/sudoers.d/00-omarchy-wheel
chmod 440 /etc/sudoers.d/00-omarchy-wheel
source "$OMARCHY_PATH/install/helpers/browser-policy.sh"
for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do
[[ -d $dir || -L $dir ]] || continue
browser_policy_setup_dir "$dir"
done
}
install_authorized_keys() {
@@ -776,7 +792,7 @@ configure_login() {
# After=) is what makes it deterministic — no sleep/race against SDDM's startup.
install_autologin_once_cleanup() {
local unit=omarchy-provision-autologin-once.service
cat >"/etc/systemd/system/$unit" <<UNIT
sed "s|@UNIT@|$unit|g" >"/etc/systemd/system/$unit" <<'UNIT'
[Unit]
Description=Drop the first-boot autologin before the next login
Before=display-manager.service
@@ -785,7 +801,7 @@ ConditionPathExists=/etc/sddm.conf.d/autologin.conf
[Service]
Type=oneshot
ExecStart=/usr/bin/rm -f /etc/sddm.conf.d/autologin.conf
ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/$unit /etc/systemd/system/$unit
ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/@UNIT@ /etc/systemd/system/@UNIT@
[Install]
WantedBy=graphical.target
+9 -1
View File
@@ -84,7 +84,7 @@ 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
mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.hermes/skills
for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
skill=${skill%/}
name=${skill##*/}
@@ -92,6 +92,14 @@ for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
ln -sfn "$skill" ~/.claude/skills/"$name"
ln -sfn "$skill" ~/.codex/skills/"$name"
ln -sfn "$skill" ~/.pi/agent/skills/"$name"
ln -sfn "$skill" ~/.hermes/skills/"$name"
if [[ -d ~/.hermes/profiles ]]; then
for profile in ~/.hermes/profiles/*/; do
[[ -d $profile ]] || continue
mkdir -p "$profile/skills"
ln -sfn "$skill" "$profile/skills/$name"
done
fi
done
mkdir -p ~/Downloads ~/Pictures ~/Videos ~/.config/gtk-3.0
+3 -8
View File
@@ -3,11 +3,6 @@
# omarchy:summary=Overwrite the user config for the Plymouth drive decryption and boot sequence with the Omarchy default and rebuild it.
# omarchy:requires-sudo=true
sudo cp -r "$OMARCHY_PATH/default/plymouth/." /usr/share/plymouth/themes/omarchy/
sudo plymouth-set-default-theme omarchy
if omarchy-cmd-present limine-mkinitcpio; then
sudo limine-mkinitcpio
else
sudo mkinitcpio -P
fi
# Reuse the fixed-file publisher so root never resolves the source checkout or
# follows a destination symlink while restoring the packaged assets.
exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-default
+3 -2
View File
@@ -3,5 +3,6 @@
# omarchy:summary=Refresh the SDDM theme from default
# omarchy:requires-sudo=true
sudo rm -rf /usr/share/sddm/themes/omarchy
sudo cp -r "$OMARCHY_PATH/default/sddm/omarchy" /usr/share/sddm/themes/omarchy
# Reuse the fixed-file publisher so root never resolves an untrusted source or
# follows a destination symlink while restoring the packaged SDDM theme.
exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-sddm-default
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# omarchy:summary=Remove the Hermes desktop app along with the Hermes runtime it installed.
# omarchy:requires-sudo=true
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail
ensure_hermes_stopped() {
python3 - "$HOME" <<'PY'
import os
from pathlib import Path
import sys
home = Path(sys.argv[1])
roots = [str((home / relative).resolve()) for relative in ('.hermes', '.config/Hermes')]
roots.append('/opt/hermes-desktop')
def belongs_to_hermes(target):
target = target.removesuffix(' (deleted)')
return any(target == root or target.startswith(root + '/') for root in roots)
holders = []
for process in Path('/proc').iterdir():
if not process.name.isdigit() or int(process.name) == os.getpid():
continue
try:
if process.stat().st_uid != os.getuid():
continue
targets = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')]
entries = [process / 'exe', process / 'cwd']
try:
entries.extend((process / 'fd').iterdir())
except PermissionError:
pass
for entry in entries:
try:
targets.append(os.readlink(entry))
except OSError:
pass
if any(belongs_to_hermes(target) for target in targets):
holders.append(process.name)
except (FileNotFoundError, ProcessLookupError, PermissionError):
continue
if holders:
print('Close Hermes and processes using its files before removing it (PIDs: '
+ ', '.join(holders) + '). Then try again.', file=sys.stderr)
sys.exit(1)
PY
}
# Removing an open SQLite WAL leaves a live writer on a deleted generation.
ensure_hermes_stopped
omarchy-pkg-drop hermes-desktop
# The installer leaves a unit waiting to hand the app the Omarchy theme.
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
# The mise CLI is the app's predecessor, not the app itself: Hermes Desktop takes
# it over on install and runs its own runtime instead, so a copy still here is one
# the app never superseded -- an interrupted install, or the terminal CLI from
# before the app existed. Remove Hermes clears that too, scoped by the installer
# to what Omarchy owns so a hermes the user set up themselves is left alone.
# Tolerated here rather than fatal, so the ~/.hermes handling below still runs;
# the failure is answered for at the end instead of being swallowed.
cli_removed=true
ensure_hermes_stopped
omarchy-install-hermes-cli --remove || cli_removed=false
# The app writes this when the runtime it provisions under ~/.hermes has landed,
# and it is the only thing that tells that runtime apart from one the user
# installed themselves -- the paths are the same either way. Without it the app
# never got that far: a machine where it was installed but never launched still
# has whatever was there before, and none of it is ours to delete unasked.
if [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]]; then
# The checkout and venv, its own uv, its own node. None of it is any use once
# the app is gone, so it goes without asking; what the user made with the app
# is a different question, answered below.
rm -rf \
"$HOME/.hermes/hermes-agent" \
"$HOME/.hermes/bootstrap-cache" \
"$HOME/.hermes/bin" \
"$HOME/.hermes/node"
# Only the wrappers pointing into ~/.hermes, matched as a plain string: the
# path carries a dot, so an unanchored pattern would also claim a wrapper
# pointing at a sibling like ~/xhermes.
for command in hermes hermes-agent hermes-acp; do
wrapper="$HOME/.local/bin/$command"
if [[ -f $wrapper && ! -L $wrapper ]] && grep -qF "$HOME/.hermes" "$wrapper"; then
rm -f "$wrapper"
fi
done
# When Hermes brought its own Node it symlinked these next to its own commands,
# and they point at what we just deleted. Only the links into ~/.hermes: a
# system Node, or someone else's, lives somewhere else entirely.
for command in node npm npx; do
link="$HOME/.local/bin/$command"
if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then
rm -f "$link"
fi
done
fi
# What survives to here is the user's: the chats, memories and skills in
# ~/.hermes, the connections and their encrypted tokens in ~/.config/Hermes.
# Keeping them stays the default -- they are small, and finding them intact
# after a reinstall is the better surprise -- but a removal meant to be
# complete should not leave credentials behind either, so the choice is put in
# front of the user with the size, default no. Asked whenever the directories
# exist, marker or no marker: on a machine where the marker never appeared the
# data came from the terminal CLI or an install the app never finished, and it
# is still what removal is asked to clean up. Naming the paths keeps the
# question honest there too -- ~/.hermes may still carry a runtime the app
# never owned, a yes takes that with it, and saying so is the prompt's job.
# Without a terminal to ask in, keeping everything is the answer.
data_removed=false
if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]] && omarchy-cmd-present gum; then
# du answers non-zero when either directory is missing, and pipefail would
# turn that into an aborted removal; the size is worth no such thing.
size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true)
if gum confirm --default=false "Also delete ~/.hermes and ~/.config/Hermes ($size: chats, memories, skills, connections and tokens)?"; then
ensure_hermes_stopped
rm -rf "$HOME/.hermes" "$HOME/.config/Hermes"
data_removed=true
fi
fi
echo ""
echo "Hermes Desktop has been removed."
if [[ $data_removed == true ]]; then
echo "Its chats, memories, and settings in ~/.hermes and ~/.config/Hermes are gone too."
elif [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]]; then
echo "Your chats, memories, and skills are still in ~/.hermes,"
echo "and your connections and settings in ~/.config/Hermes."
fi
# The messages above still hold -- the app and its runtime are gone -- but a CLI
# teardown that failed already said so on stderr, and that stands.
if [[ $cli_removed == "false" ]]; then
exit 1
fi
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# omarchy:summary=Remove the OpenClaw agent platform along with its gateway service and web app.
# omarchy:requires-sudo=true
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail
unit_dir="$HOME/.config/systemd/user"
# Only systemd's own word counts as "stopped": a non-zero exit from is-active
# also covers an unreachable user manager, which says nothing about whether
# the process is alive.
unit_stopped() {
local state
state=$(systemctl --user is-active "$1" 2>/dev/null) || true
[[ $state == inactive || $state == failed ]]
}
# The user services OpenClaw installs for itself: the gateway through
# onboarding, and the node host if the user ever paired this machine to
# another gateway. OpenClaw writes them to $HOME/.config regardless of
# XDG_CONFIG_HOME, so look exactly there; no unit means nothing registered.
# Upstream's own teardown knows every piece its install wrote (unit file, the
# default.target.wants enablement symlink, the reload), so prefer it while the
# binary is still installed and fall back to doing the same by hand. A service
# that will not stop aborts the removal: dropping the package would strand the
# live process on deleted code with no way to restart it cleanly.
for unit_file in "$unit_dir"/openclaw-gateway.service "$unit_dir"/openclaw-node.service; do
[[ -f $unit_file ]] || continue
unit=${unit_file##*/}
role=${unit#openclaw-}
role=${role%.service}
if openclaw "$role" uninstall >/dev/null 2>&1 ||
systemctl --user disable --now "$unit" 2>/dev/null ||
unit_stopped "$unit"; then
# .bak is what `gateway install --force` leaves behind when it rewrites a
# unit, so it goes with the unit.
rm -f "$unit_file" "$unit_file.bak" "$unit_dir/default.target.wants/$unit"
systemctl --user daemon-reload 2>/dev/null || true
# A unit that had been failing stays listed as "not-found failed" after
# its file is gone until its failed state is reset.
systemctl --user reset-failed "$unit" 2>/dev/null || true
else
echo "Could not stop $unit; OpenClaw was not removed." >&2
exit 1
fi
done
omarchy-pkg-drop openclaw
# The web app launcher and icon omarchy-install-ai-openclaw created.
rm -f "$HOME/.local/share/applications/OpenClaw.desktop"
rm -f "$HOME/.local/share/icons/hicolor/256x256/apps/openclaw.png"
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true
# ~/.openclaw stays unless asked: the chats, memories, and credentials in
# there are the user's agent, and finding them intact after a reinstall is the
# better surprise. But it also holds the plugin runtimes and caches OpenClaw
# downloads for itself, easily hundreds of megabytes, so the choice is put in
# front of the user with the size rather than left silent. Without a terminal
# to ask in, keeping it is the answer.
state_removed=false
if [[ -d $HOME/.openclaw && -t 0 ]] && omarchy-cmd-present gum; then
size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1)
if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, and downloaded plugins)?"; then
rm -rf "$HOME/.openclaw"
state_removed=true
fi
fi
echo ""
echo "OpenClaw has been removed."
if [[ $state_removed == true ]]; then
echo "Its chats, memories, and settings in ~/.openclaw are gone too."
elif [[ -d $HOME/.openclaw ]]; then
echo "Your agent's chats, memories, and settings are still in ~/.openclaw."
fi
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# omarchy:summary=Remove the Perplexity desktop app along with its runtime caches.
# omarchy:requires-sudo=true
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail
omarchy-pkg-drop perplexity
# The runtime the app downloads for itself: llama.cpp builds and local models
# under ~/.local/share, the older download location under ~/.cache. Of the
# perplexity-* dirs these are the only ones the desktop app owns; the rest
# belong to Perplexity products that outlive it.
rm -rf \
"$HOME/.cache/Perplexity" \
"$HOME/.cache/perplexity-rpc-server" \
"$HOME/.local/share/perplexity-rpc-server"
# What is left is the user's: the logins and session in ~/.config/Perplexity,
# the secret vault and device identity in ~/.local/state/perplexity, and the
# launcher flags. Keeping them stays the default -- they are small, and being
# signed in after a reinstall is the better surprise -- but a removal meant to
# be complete should not leave credentials behind either, so the choice is put
# in front of the user, default no. Without a terminal to ask in, keeping it
# is the answer -- and gum draws the prompt on stderr, so a redirected stderr
# would block on a question nobody can see.
data_removed=false
if [[ -t 0 && -t 2 ]]; then
# du answers non-zero when a directory is missing, and pipefail would turn
# that into an aborted removal; the size is worth no such thing.
size=$(du -shc "$HOME/.config/Perplexity" "$HOME/.local/state/perplexity" 2>/dev/null | tail -1 | cut -f1 || true)
if gum confirm --default=false "Also delete your Perplexity data ($size: logins, settings, secret vault and device identity)?"; then
rm -rf \
"$HOME/.config/Perplexity" \
"$HOME/.local/state/perplexity"
rm -f "$HOME/.config/perplexity-flags.conf"
data_removed=true
fi
fi
echo ""
echo "Perplexity has been removed."
if [[ $data_removed == "true" ]]; then
echo "Its logins, settings, secret vault, and device identity are gone too."
else
echo "Nothing of yours was touched: not the logins in ~/.config/Perplexity,"
echo "nor the secret vault and device identity in ~/.local/state/perplexity."
fi
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# omarchy:summary=Remove T3 Code along with its configuration and workspaces.
# omarchy:requires-sudo=true
set -e
omarchy-pkg-drop t3code-bin
# T3 Code drives agents that keep their own state -- ~/.claude.json, ~/.grok,
# ~/.local/share/opencode -- and those outlive it, so only its own goes.
rm -rf \
"$HOME/.config/t3code" \
"$HOME/.t3"
rm -f "$HOME/.config/t3code-flags.conf"
echo ""
echo "T3 Code has been removed."
+21
View File
@@ -17,6 +17,27 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w
~/.local/bin/gh ~/.local/bin/opencode ~/.local/bin/playwright ~/.local/bin/playwright-cli ~/.local/bin/pi \
~/.local/bin/omp ~/.local/bin/grok ~/.local/bin/crush ~/.local/bin/ghui ~/.local/bin/hunk
# Cursor's own installer links ~/.local/bin/cursor-agent as well, so only
# the mise wrapper omarchy-mise-install wrote is a preinstall.
if [[ -f ~/.local/bin/cursor-agent && ! -L ~/.local/bin/cursor-agent ]] &&
grep -Eq '^mise use -g .*"cursor-agent"' ~/.local/bin/cursor-agent; then
rm -f ~/.local/bin/cursor-agent
fi
# Preserve a user-managed Muse launcher at the same path.
if [[ -f ~/.local/bin/muse && ! -L ~/.local/bin/muse ]] &&
grep -Eq '^mise use -g .*"http:muse\[' ~/.local/bin/muse; then
rm -f ~/.local/bin/muse
fi
# Only the wrapper omarchy-install-hermes-cli wrote is a preinstall. Hermes
# Desktop's command, an official install, or anything else at that path is
# the user's, so it is the installer that decides whether the wrapper is its
# own, rather than a copy of its marker kept here.
if omarchy-install-hermes-cli --owns; then
rm -f ~/.local/bin/hermes
fi
omarchy-pkg-drop \
aether \
cliamp \
+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"
+10 -10
View File
@@ -41,6 +41,9 @@ setup_pam_config() {
fi
else
echo "Creating polkit configuration with fingerprint authentication..."
# omarchy:heredoc-expands paths=none -- $fprintd_gate is the literal PAM
# line defined above, shared with the two sed insertions so the gate cannot
# drift between files. The only path in it is the fixed /usr/bin one.
sudo tee /etc/pam.d/polkit-1 >/dev/null <<EOF
$fprintd_gate
auth sufficient pam_fprintd.so
@@ -71,18 +74,15 @@ if ! omarchy-hw-fingerprint; then
exit 1
fi
# Install required packages
echo "Installing required packages..."
# libfprint-git provides+conflicts libfprint; pacman -S --noconfirm
# defaults the conflict prompt to N and aborts. Pre-remove it (deps-only,
# so an installed fprintd stays put) so stock libfprint installs cleanly.
if pacman -Q libfprint-git &>/dev/null; then
sudo pacman -Rdd --noconfirm libfprint-git
# libfprint-git tracks upstream ahead of the Arch release, so a new reader only
# needs a pin bump in omarchy-pkgs. It conflicts with stock libfprint, and
# --noconfirm answers that prompt with N; --ask 4 accepts the replacement in
# one transaction, so a failed install leaves the existing driver in place.
if omarchy-pkg-missing libfprint-git fprintd usbutils; then
echo "Installing required packages..."
sudo pacman -S --needed --noconfirm --ask 4 libfprint-git fprintd usbutils
fi
omarchy-pkg-add libfprint fprintd usbutils
# Enroll first fingerprint
echo -e "\e[32m\nLet's setup your right index finger as the first fingerprint.\e[0m"
echo -e "Keep moving the finger around on sensor until the process completes.\n"
+47
View File
@@ -107,6 +107,50 @@ authorize_pasted_key() {
authorize_key "$key" || exit 1
}
# Only called after a key is authorized. Disabling password authentication
# before then could lock the owner out of the machine.
disable_password_auth() {
local config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf
local effective_config
if [[ ! -s $AUTHORIZED_KEYS ]]; then
echo -e "\e[31mCannot disable SSH password authentication without an authorized key.\e[0m" >&2
return 1
fi
echo "Disabling SSH password authentication, now that a key is authorized..."
sudo install -Dm644 /dev/stdin "$config" <<'CONF'
# Written by omarchy-setup-security-sshd once an SSH key was authorized.
# Delete this file and reload sshd to allow password logins again.
PasswordAuthentication no
KbdInteractiveAuthentication no
CONF
# Validate before reloading: a config sshd rejects would otherwise take the
# service down on its next restart, potentially stranding a remote owner.
if ! sudo sshd -t; then
echo -e "\e[31msshd rejected the hardening config; removing it and leaving passwords on.\e[0m" >&2
sudo rm -f "$config"
return 1
fi
# Syntax alone is insufficient because sshd uses the first value it reads for
# these settings. An earlier administrator rule could leave passwords enabled.
# Match keywords case-insensitively: OpenSSH 9.x dumps them lowercase, 10.x
# in CamelCase.
if ! effective_config=$(sudo sshd -T) ||
! grep -qixF "passwordauthentication no" <<<"$effective_config" ||
! grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config"; then
echo -e "\e[31msshd did not apply the password-authentication restrictions; removing the ineffective config.\e[0m" >&2
sudo rm -f "$config"
return 1
fi
# Reload rather than restart so an administrator already connected keeps
# their session.
sudo systemctl reload sshd.service
}
echo -e "\e[32mSetting up SSH server access with key-based authentication.\n\e[0m"
setup_sshd
@@ -123,5 +167,8 @@ else
esac
fi
disable_password_auth
echo -e "\e[32m\nPerfect! The SSH server is running and your key is authorized.\e[0m"
echo "Password logins are off; this machine now accepts authorized keys only."
echo "You can now connect with: ssh $USER@$(hostname)"
+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
+16 -5
View File
@@ -13,6 +13,19 @@ if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then
exit 1
fi
arm_expiry() {
if sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \
rm -f -- "$NOPASSWD_FILE"; then
return 0
fi
echo "Failed to schedule passwordless sudo expiry. Revoking access now." >&2
if ! sudo rm -f -- "$NOPASSWD_FILE"; then
echo "CRITICAL: Could not remove $NOPASSWD_FILE. Remove it as root immediately." >&2
fi
return 1
}
echo "Toggle passwordless sudo..."
# Safety: if the file exists but the timer doesn't (e.g. after reboot), clean up
@@ -24,8 +37,7 @@ fi
if sudo test -f "$NOPASSWD_FILE"; then
if [[ $1 ]]; then
sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null
sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \
rm "$NOPASSWD_FILE"
arm_expiry || exit 1
echo "Passwordless sudo timer updated. It will now automatically disable in ${MINUTES} minutes."
else
sudo rm "$NOPASSWD_FILE"
@@ -48,12 +60,11 @@ else
if gum confirm "Enable passwordless sudo for ${MINUTES} minutes? This is a significant security risk!"; then
echo "${USER} ALL=(ALL) NOPASSWD: ALL" | sudo tee "$NOPASSWD_FILE" > /dev/null
sudo chmod 440 "$NOPASSWD_FILE"
sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \
rm "$NOPASSWD_FILE"
arm_expiry || exit 1
echo ""
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${MINUTES} minutes."
echo "Note: if you restart before then, run omarchy-sudo-passwordless again to disable it."
echo "A restart removes the passwordless sudo rule as well."
else
echo "Aborted. No changes made."
fi
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
# omarchy:summary=Reset the sudo lockout/faillock for the current user.
su -c "faillock --reset --user $USER"
+2 -2
View File
@@ -59,7 +59,7 @@ claim_path() {
announce() {
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
+30 -6
View File
@@ -16,24 +16,48 @@ 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
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path.
# git reads a URL as scp-style when a colon appears before any slash, so the path
# after it need not hold one: `git@host:omarchy-blue-theme.git` is a repo in that
# user's home, and leaving its prefix on names the theme after the whole URL.
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:]')
[[ $REPO_PATH != *"://"* && $REPO_PATH == *:* && ${REPO_PATH%%:*} != */* ]] && REPO_PATH="${REPO_PATH#*:}"
THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]')
THEME_PATH="$THEMES_DIR/$THEME_NAME"
# The name comes from the URL, is joined into a path that is about to be
# removed, and then names a directory the rest of Omarchy passes around by
# name: Style > Unlock builds a command line out of the one the picker
# returned. So it is held to the characters a theme name needs rather than
# screened for the harm of the day -- a repo called `..` would take
# ~/.config/omarchy with it, and one called `a';'id` would carry its own
# command into that picker. The leading character is kept out of `.` and `-`,
# which also covers `host:-s/foo.git` leaving basename with `.git`.
# A bracket range follows the locale's collation, not ASCII: `[a-z]` takes in
# `é` under en_US.UTF-8. Pin the locale so the set is the one written here.
if ! (LC_ALL=C; [[ $THEME_NAME =~ ^[a-z0-9_][a-z0-9._+-]*$ ]]); 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
+132 -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
@@ -198,6 +326,8 @@ post_theme_commands=(
omarchy-theme-set-gnome
omarchy-theme-set-pi
omarchy-theme-set-claude
omarchy-theme-set-hermes
omarchy-theme-set-t3code
omarchy-theme-set-browser
omarchy-theme-set-vscode
omarchy-theme-set-obsidian
+9 -19
View File
@@ -3,23 +3,15 @@
# omarchy:summary=Apply the current theme color to Chromium, Chrome, Edge, and Brave
# omarchy:hidden=true
source "$OMARCHY_PATH/install/helpers/browser-policy.sh"
CHROMIUM_THEME=$HOME/.local/state/omarchy/current/theme/chromium.theme
THEME_HEX_COLOR=$BROWSER_POLICY_DEFAULT_COLOR
if [[ -f $CHROMIUM_THEME ]]; then
THEME_RGB_COLOR=$(<$CHROMIUM_THEME)
THEME_HEX_COLOR=$(printf '#%02x%02x%02x' ${THEME_RGB_COLOR//,/ })
else
# Use a default, neutral grey if theme doesn't have a color
THEME_HEX_COLOR="#1c2027"
THEME_HEX_COLOR=$(browser_policy_theme_hex "$(<$CHROMIUM_THEME)")
fi
set_browser_policy() {
local policy_dir="$1"
[[ -d $policy_dir ]] || return
echo "{\"BrowserThemeColor\": \"$THEME_HEX_COLOR\", \"BrowserColorScheme\": \"device\"}" | tee "$policy_dir/color.json" >/dev/null
}
refresh_running_browser() {
local process="$1"
local command="$2"
@@ -30,17 +22,15 @@ refresh_running_browser() {
fi
}
set_browser_policy /etc/chromium/policies/managed
failed=0
omarchy-theme-set-browser-policy "${THEME_HEX_COLOR#\#}" || failed=1
refresh_running_browser chromium chromium
set_browser_policy /etc/opt/chrome/policies/managed
refresh_running_browser chrome google-chrome-stable || refresh_running_browser chrome google-chrome
set_browser_policy /etc/opt/edge/policies/managed
refresh_running_browser msedge microsoft-edge-stable
set_browser_policy /etc/brave/policies/managed
refresh_running_browser brave brave
# Match on the binary path: the running process is named plain "brave", and a
# bare -f brave-origin pattern would also match the installer's own terminal.
refresh_running_browser /opt/brave-origin-bin/ brave-origin -f
exit "$failed"
+123
View File
@@ -0,0 +1,123 @@
#!/bin/bash
# omarchy:summary=Write the current theme color into the browser policy directories
# omarchy:args=<rrggbb>
# omarchy:hidden=true
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 (printf's builtin aside: install, mktemp, rm) 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
# Enterprise policy trust roots. The list is fixed here rather than taken from
# the caller: the caller chooses a color, never a path.
POLICY_DIRS=(
/etc/chromium/policies/managed
/etc/opt/chrome/policies/managed
/etc/opt/edge/policies/managed
/etc/brave/policies/managed
)
# The path etc/sudoers.d/omarchy-theme-browser 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-theme-set-browser-policy
usage() {
echo "Usage: omarchy-theme-set-browser-policy <rrggbb>" >&2
}
if (( $# != 1 )); then
usage
exit 1
fi
color="$1"
# Six lowercase hex digits is the whole of what this accepts. The leading "#"
# is added when the JSON is written rather than passed in: "#" opens a comment
# in sudoers, and keeping it out of argv lets the sudoers rule spell the
# argument as a plain six-character glob.
if [[ ! $color =~ ^[0-9a-f]{6}$ ]]; then
echo "omarchy-theme-set-browser-policy: expected six lowercase hex digits, got '$color'" >&2
exit 1
fi
# 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-theme-browser and nothing else. Listing runs nothing
# and, under -n, prompts for nothing.
sudo_grants_passwordless() {
sudo -n -l -l "$PACKAGED_PATH" "$@" 2>/dev/null | grep -q '!authenticate'
}
require_root() {
if (( EUID == 0 )); then
return
elif [[ -t 0 ]] || sudo_grants_passwordless "$@"; then
exec sudo "$PACKAGED_PATH" "$@"
else
exec pkexec "$PACKAGED_PATH" "$@"
fi
}
require_root "$color"
failed=0
staged=""
# Bash 5.3 makes the EXIT trap's last command decide the script's exit status,
# so this handler must not end on a false test. Every successful run clears
# staged, and a trailing `[[ -n $staged ]] && ...` would report that as failure.
cleanup() {
if [[ -n $staged ]]; then
rm -f "$staged"
fi
}
trap cleanup EXIT
for policy_dir in "${POLICY_DIRS[@]}"; do
# Only browsers Omarchy has installed have a policy directory. Creating one
# here would hand a browser a managed-policy root it does not otherwise have.
[[ -d $policy_dir && ! -L $policy_dir ]] || continue
dest=$policy_dir/color.json
staged=$(mktemp) || {
failed=1
continue
}
printf '{"BrowserThemeColor": "#%s", "BrowserColorScheme": "device"}\n' "$color" >"$staged"
if [[ -L $dest || -d $dest ]]; then
if ! rm -rf -- "$dest"; then
rm -f "$staged"
staged=""
echo "omarchy-theme-set-browser-policy: cannot replace $dest" >&2
failed=1
continue
fi
fi
if ! install -m 0644 -o root -g root -T "$staged" "$dest"; then
rm -f "$staged"
staged=""
echo "omarchy-theme-set-browser-policy: cannot write $dest" >&2
failed=1
continue
fi
rm -f "$staged"
staged=""
done
exit "$failed"
+233
View File
@@ -0,0 +1,233 @@
#!/bin/bash
# omarchy:summary=Sync the generated Omarchy theme to Hermes as a skin
# omarchy:args=[--activate] [--wait]
# omarchy:hidden=true
# A skin is Hermes' one theme unit for the desktop app, the TUI and the CLI;
# its gateway watches the active skin file and repaints every surface on change.
set -euo pipefail
HERMES_SOURCE_PATH="$HOME/.local/state/omarchy/current/theme/hermes.yaml"
HERMES_THEME_NAME_PATH="$HOME/.local/state/omarchy/current/theme.name"
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
HERMES_CONFIG_PATH="$HERMES_HOME/config.yaml"
HERMES_SKIN_NAME="omarchy"
# The command the readiness probe vets, rather than whichever hermes is on PATH.
HERMES_COMMAND="$HOME/.local/bin/hermes"
# Written by the desktop app once the runtime its first launch provisions is in.
HERMES_BOOTSTRAP_MARKER="$HERMES_HOME/hermes-agent/.hermes-bootstrap-complete"
HERMES_ACTIVATE=0
HERMES_WAIT=0
HERMES_WAIT_LIMIT=$((30 * 60))
usage() {
echo "Usage: omarchy-theme-set-hermes [--activate] [--wait]"
}
for arg in "$@"; do
case "$arg" in
--activate)
HERMES_ACTIVATE=1
;;
--wait)
HERMES_ACTIVATE=1
HERMES_WAIT=1
;;
-h | --help)
usage
exit 0
;;
*)
usage >&2
exit 1
;;
esac
done
# A theme switch runs this beside a dozen other hooks; only --activate explains.
note() {
if (( HERMES_ACTIVATE == 1 )); then
echo "$*" >&2
fi
}
# A theme applied before the template existed has no skin rendered yet.
if [[ ! -f $HERMES_SOURCE_PATH ]]; then
(( HERMES_ACTIVATE == 1 )) || exit 0
if [[ ! -s $HERMES_THEME_NAME_PATH ]]; then
echo "Hermes skin source missing: $HERMES_SOURCE_PATH" >&2
echo "Select an Omarchy theme first." >&2
exit 1
fi
omarchy-theme-refresh
if [[ ! -f $HERMES_SOURCE_PATH ]]; then
echo "Hermes skin source missing after refreshing the theme: $HERMES_SOURCE_PATH" >&2
exit 1
fi
fi
# The first launch takes minutes and may be abandoned; the readiness probe
# would start Hermes to answer, so poll for the marker instead.
if (( HERMES_WAIT == 1 )); then
waited=0
until [[ -f $HERMES_BOOTSTRAP_MARKER && -f $HERMES_CONFIG_PATH ]]; do
if (( waited >= HERMES_WAIT_LIMIT )); then
echo "Hermes did not finish setting up within $((HERMES_WAIT_LIMIT / 60)) minutes; run omarchy-theme-set-hermes --activate once it has." >&2
exit 0
fi
sleep 10
waited=$((waited + 10))
done
fi
# Provisioning creates ~/.hermes on every machine for the Omarchy skill; the
# config is what Hermes writes once it has actually run.
if [[ ! -f $HERMES_CONFIG_PATH ]]; then
note "Hermes is not set up yet; launch it once, then run omarchy-theme-set-hermes --activate."
exit 0
fi
# Hermes parses the skin as YAML and hands its strings to every surface, so only
# the name, a plain description and #rrggbb colours may reach it, in the order
# YAML needs them. YAML breaks lines on bytes grep does not, so the bytes are
# counted first, NUL included.
skin_is_well_formed() {
local skin="$1"
[[ -f $skin ]] &&
(( $(LC_ALL=C tr -d ' -~\n' <"$skin" | wc -c) == 0 )) &&
awk -v name="name: $HERMES_SKIN_NAME" '
bad { next }
/^#/ || /^[[:space:]]*$/ { next }
!seen_name { if ($0 == name) seen_name = 1; else bad = 1; next }
!seen_colors {
if ($0 == "colors:") seen_colors = 1
else if (!seen_description && $0 ~ /^description: [A-Za-z0-9 ,.()-]{0,200}$/) seen_description = 1
else bad = 1
next
}
/^ [a-z_]{1,64}: "#[0-9a-fA-F]{6}"$/ { colors++; next }
{ bad = 1 }
END { exit (bad || !seen_colors || colors == 0) }
' "$skin"
}
# The check has to cover the bytes that get published, and the theme can change
# underneath between the two, so a private copy is taken and that is checked.
snapshot_dir=$(mktemp -d)
trap 'rm -rf "$snapshot_dir"' EXIT
HERMES_SNAPSHOT="$snapshot_dir/$HERMES_SKIN_NAME.yaml"
take_snapshot() {
cp "$HERMES_SOURCE_PATH" "$HERMES_SNAPSHOT" 2>/dev/null && skin_is_well_formed "$HERMES_SNAPSHOT"
}
if ! take_snapshot; then
echo "Skipping Hermes skin: $(basename "$HERMES_SOURCE_PATH") is not a plain color palette." >&2
exit 0
fi
# The gateway reads the file whole on an mtime change, so the write is atomic;
# -T so a directory at the skin's path is an error rather than a destination.
publish_skin() {
local skins_dir="$1"
local tmp
mkdir -p "$skins_dir" 2>/dev/null || return 1
tmp=$(mktemp "$skins_dir/$HERMES_SKIN_NAME.yaml.XXXXXX" 2>/dev/null) || return 1
if ! cp "$HERMES_SNAPSHOT" "$tmp" 2>/dev/null || ! mv -T "$tmp" "$skins_dir/$HERMES_SKIN_NAME.yaml" 2>/dev/null; then
rm -f "$tmp"
return 1
fi
}
# A profile is a Hermes home of its own; existing ones get the skin, none are
# made, and one that cannot take it does not cost the others.
publish_skin_everywhere() {
local profile
publish_skin "$HERMES_HOME/skins" || {
echo "Could not publish the Hermes skin to $HERMES_HOME/skins." >&2
return 1
}
for profile in "$HERMES_HOME"/profiles/*/; do
[[ -d $profile ]] || continue
publish_skin "${profile%/}/skins" || note "Could not publish the skin to the Hermes profile $(basename "$profile")."
done
}
publish_skin_everywhere
# Hermes reads the config of the profile named in active_profile; the profile
# exists once its directory does, with or without a config of its own.
active_config_path() {
local profile
profile=$(cat "$HERMES_HOME/active_profile" 2>/dev/null || true)
profile=${profile,,}
if [[ -n $profile && $profile != "default" && -d $HERMES_HOME/profiles/$profile ]]; then
echo "$HERMES_HOME/profiles/$profile/config.yaml"
else
echo "$HERMES_CONFIG_PATH"
fi
}
# A theme switch finishes the hand-over only for the app Omarchy installed, and
# only Hermes' default is ever replaced, so a config plainly naming another skin
# ends it here without starting Hermes. Anything less plain is for Hermes to read.
if (( HERMES_ACTIVATE == 0 )); then
omarchy-pkg-present hermes-desktop || exit 0
skin_line=$(grep -m1 -x ' skin: .*' "$(active_config_path)" 2>/dev/null || true)
case "${skin_line# skin: }" in
"" | default | null | true | false | *[!A-Za-z0-9_-]*) ;;
*) exit 0 ;;
esac
fi
# Omarchy's cold stub installs Hermes when run, so ask the probe before running it.
if ! omarchy-install-hermes-cli --check 2>/dev/null; then
note "Hermes is not ready, so the Omarchy skin is published but not active."
note "Once Hermes runs, activate it with: hermes config set display.skin $HERMES_SKIN_NAME"
exit 0
fi
# Only Hermes' own default is replaced, so a skin chosen in Hermes stays; an
# answer that did not come is not a default.
if ! current_skin=$(timeout 15 "$HERMES_COMMAND" config get display.skin 2>/dev/null); then
note "Hermes did not say which skin it is on, so the Omarchy skin is published but not active."
exit 0
fi
if [[ -n $current_skin && $current_skin != "default" && $current_skin != "$HERMES_SKIN_NAME" ]]; then
note "Hermes is set to the '$current_skin' skin; leaving it. Switch with: hermes config set display.skin $HERMES_SKIN_NAME"
exit 0
fi
# Hermes' own writer: it updates the active profile's config and touches the
# skin file so a running gateway broadcasts the change. A refusal is cosmetic.
if ! timeout 30 "$HERMES_COMMAND" config set display.skin "$HERMES_SKIN_NAME" >/dev/null 2>&1; then
note "Hermes refused to switch skins, so the Omarchy skin is published but not active."
exit 0
fi
note "Hermes is on the Omarchy skin."
# The desktop applies a skin only from a broadcast, and a config written before
# the gateway seeded its watcher goes unannounced; a later write is announced.
# The theme may have changed underneath in the meantime, so it is checked again.
if (( HERMES_WAIT == 1 )); then
sleep 60
if take_snapshot; then
publish_skin_everywhere
fi
fi
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# omarchy:summary=Sync the generated Omarchy theme to T3 Code
# omarchy:hidden=true
set -euo pipefail
T3CODE_SOURCE_PATH="$HOME/.local/state/omarchy/current/theme/t3code.json"
T3CODE_HOME="${T3CODE_HOME:-$HOME/.t3}"
T3CODE_STATE_DIR="$T3CODE_HOME/userdata"
# The filename is the theme id T3 Code shows and `t3 theme set` accepts, and
# it stays stable while the colors change underneath on every theme switch.
T3CODE_THEME_PATH="$T3CODE_STATE_DIR/themes/omarchy.json"
[[ -f $T3CODE_SOURCE_PATH ]] || exit 0
# A theme without an accent or a background leaves its placeholder unresolved.
# Publishing that would only make T3 Code reject the file, so skip instead and
# leave the previous palette in place.
if grep -q '{{' "$T3CODE_SOURCE_PATH"; then
echo "Skipping T3 Code theme: $(basename "$T3CODE_SOURCE_PATH") has unresolved colors." >&2
exit 0
fi
# Only follow an install that already exists. Creating the directory here would
# leave a stray T3 Code state dir on machines that do not run it.
[[ -d $T3CODE_STATE_DIR ]] || exit 0
# T3 Code watches this file and retints every connected client on change, so
# the write has to be atomic: a half-written file would read as invalid.
mkdir -p "$(dirname "$T3CODE_THEME_PATH")"
tmp=$(mktemp "$T3CODE_THEME_PATH.XXXXXX")
cp "$T3CODE_SOURCE_PATH" "$tmp"
mv "$tmp" "$T3CODE_THEME_PATH"
+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
+6
View File
@@ -5,3 +5,9 @@
# omarchy:examples=omarchy toggle bar | omarchy toggle bar off | omarchy toggle bar on
omarchy-toggle bar-off "${1:-toggle}"
# The shell's watch on the toggles directory can miss flag changes that land in
# quick succession, stranding the bar off screen until the shell restarts.
# Nudge the bar to re-read the flag; quiet best-effort so the toggle still
# works when the shell is not up.
omarchy-shell -q omarchy.bar syncHidden
+51 -9
View File
@@ -3,6 +3,35 @@
# omarchy:summary=Toggle dedicated vs integrated GPU mode via supergfxd (for hybrid gpu laptops, like Asus G14).
# omarchy:requires-sudo=true
install_root_file() {
local source="$1"
local destination="$2"
local mode="$3"
local stage
stage=$(sudo /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1
safe_stage_path "$stage" "$destination" || return 1
if sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" &&
sudo /usr/bin/mv -Tf -- "$stage" "$destination"; then
return 0
else
safe_stage_path "$stage" "$destination" && sudo /usr/bin/rm -f -- "$stage"
return 1
fi
}
safe_stage_path() {
local stage="$1"
local destination="$2"
local prefix suffix
prefix="${destination%/*}/.${destination##*/}.omarchy."
[[ $stage == "$prefix"* ]] || return 1
suffix=${stage#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
if omarchy-cmd-missing supergfxctl; then
omarchy-pkg-add supergfxctl
@@ -54,18 +83,31 @@ case "$gpu_mode" in
;;
"Hybrid")
if gum confirm "Use only integrated GPU and reboot?"; then
# Switch to integrated mode and ensure vfio is enabled (needed for sleep/wake trick)
sudo sed -i "s/\"mode\": \".*\"/\"mode\": \"Integrated\"/" /etc/supergfxd.conf
sudo sed -i 's/"vfio_enable": false/"vfio_enable": true/' /etc/supergfxd.conf
# Force igpu mode after system sleep (or dgpu could get activated)
sudo mkdir -p /usr/lib/systemd/system-sleep
sudo cp -p "$OMARCHY_PATH/default/systemd/system-sleep/force-igpu" /usr/lib/systemd/system-sleep/
# Delay supergfxd startup to avoid race condition with display manager
# that can cause system freeze when booting in Integrated mode
sudo mkdir -p /etc/systemd/system/supergfxd.service.d
sudo cp -p "$OMARCHY_PATH/default/systemd/system/supergfxd.service.d/delay-start.conf" /etc/systemd/system/supergfxd.service.d/
if ! install_root_file "$OMARCHY_PATH/default/systemd/system/supergfxd.service.d/delay-start.conf" \
/etc/systemd/system/supergfxd.service.d/delay-start.conf 0644; then
echo "Could not install the supergfxd startup-delay override" >&2
exit 1
fi
# Publish the self-guarding sleep hook before enabling Integrated mode. It
# remains inert while the config says Hybrid, so any failed step is safe to
# retry without leaving the GPU config partially switched.
sudo mkdir -p /usr/lib/systemd/system-sleep
if ! install_root_file "$OMARCHY_PATH/default/systemd/system-sleep/force-igpu" \
/usr/lib/systemd/system-sleep/force-igpu 0755; then
echo "Could not install the force-igpu system-sleep hook" >&2
exit 1
fi
# Switch both settings in one atomic config rewrite only after every
# supporting file has been installed successfully.
sudo sed -i \
-e 's/"mode": ".*"/"mode": "Integrated"/' \
-e 's/"vfio_enable": false/"vfio_enable": true/' \
/etc/supergfxd.conf
omarchy-system-reboot
fi
+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
+51 -14
View File
@@ -175,7 +175,10 @@ target_home=$(getent passwd "$target_user" | cut -d: -f6)
[[ -n $target_home && -d $target_home ]] || fail "Home directory for '$target_user' was not found."
target_uid=$(id -u "$target_user")
target_runtime_dir="/run/user/$target_uid"
package_path="/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin:$target_home/.local/bin"
# User-local commands are needed only after dropping to the target user. Never
# expose their search path to commands run through as_root.
root_path=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin
package_path="$root_path:$target_home/.local/bin"
as_root() {
if (( EUID == 0 )); then
@@ -597,6 +600,9 @@ preserve_kernel_cmdline_root() {
fi
log "Preserving the kernel cmdline root parameters in $default_conf"
# omarchy:heredoc-expands paths=none -- ${boot_params[*]} is the kernel
# cmdline fragment assembled above from findmnt output (root=, rootflags=),
# not a filesystem path this script controls.
as_root tee -a "$default_conf" >/dev/null <<EOF
# Written by omarchy-upgrade-to-quattro. The += drop-ins in
# /etc/limine-entry-tool.d/ stop limine-entry-tool from reading
@@ -658,7 +664,7 @@ configure_lock_authentication() {
as_root env \
OMARCHY_INSTALL_USER="$target_user" \
OMARCHY_PATH=/usr/share/omarchy \
PATH="$package_path" \
PATH="$root_path" \
"$apply_lock"
}
@@ -1187,11 +1193,19 @@ ensure_sleep_lock_service() {
}
run_post_upgrade_migrations() {
PATH="$package_path" command -v omarchy-migrate >/dev/null 2>&1 || return 0
local pending_status
log "Running Omarchy migrations"
if ! run_as_user_omarchy OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1 omarchy-migrate; then
warn "Could not run Omarchy migrations; the user may be prompted to run them after login."
fail "Omarchy migrations did not complete. Fix the error above and rerun the upgrade before rebooting."
fi
if run_as_user_omarchy omarchy-migrate --pending >/dev/null; then
fail "Omarchy migrations are still pending. Rerun the upgrade before rebooting."
else
pending_status=$?
if (( pending_status != 1 )); then
fail "Could not verify that Omarchy migrations completed. Rerun the upgrade before rebooting."
fi
fi
}
@@ -1276,7 +1290,7 @@ apply_firewall_defaults() {
fi
log "Applying Omarchy firewall defaults"
as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$package_path" \
as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$root_path" \
bash -euo pipefail "$firewall_script" ||
warn "Could not apply firewall defaults; run 'sudo bash $firewall_script' after reboot."
}
@@ -1312,14 +1326,28 @@ apply_system_transition() {
/usr/share/icons/Yaru/scalable/actions/go-next-symbolic.svg
as_root gtk-update-icon-cache /usr/share/icons/Yaru >/dev/null 2>&1 || true
as_root install -d -m 0777 /etc/chromium/policies/managed
local browser_policy_helper=/usr/share/omarchy/install/helpers/browser-policy.sh
if ! as_root test -f "$browser_policy_helper"; then
warn "$browser_policy_helper is unavailable; Chromium policy directories were not hardened."
else
as_root env OMARCHY_PATH=/usr/share/omarchy \
bash -euo pipefail -c '
source "$OMARCHY_PATH/install/helpers/browser-policy.sh"
browser_policy_setup_dir /etc/chromium/policies/managed
for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do
[[ $dir == "/etc/chromium/policies/managed" ]] && continue
[[ -d $dir || -L $dir ]] || continue
browser_policy_setup_dir "$dir"
done
'
fi
as_root install -d -m 0755 /usr/lib/chromium
printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \
printf '%s\n' '{"distribution":{"require_eula":false},"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
@@ -1350,7 +1378,6 @@ EOF
as_root systemctl disable docker.service >/dev/null 2>&1 || true
enable_system_service cups.service
enable_system_service cups-browsed.service
enable_system_service avahi-daemon.service
enable_system_service linux-modules-cleanup.service
enable_system_service docker.socket
@@ -1382,6 +1409,9 @@ EOF
autologin_user=$(as_root awk -F= '/^User=/ { print $2; exit }' /etc/sddm.conf.d/autologin.conf 2>/dev/null || true)
fi
[[ -n ${autologin_user:-} ]] || autologin_user="$target_user"
# omarchy:heredoc-expands paths=none -- $autologin_user is a username, read
# back from the root-owned drop-in or falling back to $target_user. Same
# mechanism as the old getty override: a name expands, no path does.
cat <<EOF | as_root tee /etc/sddm.conf.d/autologin.conf >/dev/null
[Autologin]
User=$autologin_user
@@ -1392,6 +1422,8 @@ EOF
fi
as_root install -d -m 0755 -o sddm -g sddm /var/lib/sddm 2>/dev/null || as_root install -d -m 0755 /var/lib/sddm
# omarchy:heredoc-expands paths=none -- $target_user is a username, not a
# path; SDDM's state file records who logged in last.
cat <<EOF | as_root tee /var/lib/sddm/state.conf >/dev/null
[Last]
Session=omarchy.desktop
@@ -1537,7 +1569,6 @@ refresh xournalpp/settings.xml 1c1a9efbf1b6dc7813bf3440fd5bf8409fc283e8d0192ca29
# fastfetch/config.jsonc -> /etc/fastfetch/config.jsonc
# fontconfig/fonts.conf -> /usr/share/fontconfig/conf.avail/50-omarchy.conf
# mimeapps.list -> /usr/share/applications/mimeapps.list
# omarchy.ttf -> /usr/share/fonts/omarchy/omarchy.ttf
# systemd/user/bt-agent.service -> /usr/lib/systemd/user/bt-agent.service
# systemd/user/omarchy-recover-internal-monitor.service -> /usr/lib/systemd/user/omarchy-recover-internal-monitor.service
# systemd/user/omarchy-sleep-lock.service -> /usr/lib/systemd/user/omarchy-sleep-lock.service
@@ -1570,7 +1601,6 @@ retire fontconfig/fonts.conf 6dec98b539388b95ecfdb3c7ab951001c712820eb0581002832
retire fontconfig/fonts.conf 3545f6c5a8c1465df7a4e251b3c2047d4ba03654c86636794f64cb0d8ea8ef63
retire fontconfig/fonts.conf 0f085b449f1cbe8eda3b59a6235bf2a3ddb6e982ab5d22fa642248916e63bafc
retire mimeapps.list 3b574cef135b5deb7a8a0c7e17139037cf1fe155300e3809f60bed0e04120975
retire omarchy.ttf e55e67119e82f56f92d90cbf54b7ccc1b2946b32c535a29370439d7ef5215966
retire systemd/user/bt-agent.service 0406b577a1225dc2a9f86638d3c346eb3635168576f04050be50ebcc0be6be12
retire systemd/user/omarchy-recover-internal-monitor.service b9b92cedc44cf3cb6216948629be55b53d16746e31896dc6469fd49ba55e82f4
retire systemd/user/omarchy-recover-internal-monitor.service e1483079b9f2aefcd43b4722c75a31643e5f3bb5a2eada5f52f1d7d201e8c289
@@ -2306,6 +2336,11 @@ refresh_current_theme_after_upgrade() {
# hooks because one of them runs `hyprctl reload`. Still poke terminal
# emulators so the active upgrade terminal picks up generated theme files.
run_as_user_omarchy omarchy-restart-terminal >/dev/null 2>&1 || true
# apply_system_transition purged user-owned color.json. Headless theme-set
# skipped omarchy-theme-set-browser, so rewrite the colour here.
run_as_user_omarchy omarchy-theme-set-browser >/dev/null 2>&1 ||
warn "Could not apply browser theme colour. Run 'omarchy theme set \"$theme_name\"' after reboot if Chromium's theme looks stale."
}
# Everything below mutates the system, so a non-zero exit from here on leaves a
@@ -2340,8 +2375,10 @@ run_as_user_omarchy omarchy-bar defaults ||
cleanup_retired_services
ensure_sleep_lock_service
remove_retired_default_packages
run_post_upgrade_migrations
run_final_system_package_upgrade
# This also retires the stock ~/.local/share/fonts/omarchy.ttf missed by the
# former ~/.config retirement entry, through migration 1788848726.
run_post_upgrade_migrations
run_post_upgrade_update_steps
refresh_current_theme_after_upgrade
# Do not force-reload Hyprland in the live upgraded session. The legacy
+2
View File
@@ -14,6 +14,8 @@ fi
if grep -q "https://pkgs.omarchy.org/stable/" /etc/pacman.conf; then
pkgs="stable"
elif grep -q "https://pkgs.omarchy.org/rc/" /etc/pacman.conf; then
pkgs="rc"
elif grep -q "https://pkgs.omarchy.org/edge/" /etc/pacman.conf; then
pkgs="edge"
else
+98 -16
View File
@@ -13,6 +13,18 @@ safe_icon_name() {
| sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//'
}
require_plain_name() {
# The name becomes a filename. A slash would turn it into directory levels, so
# the launcher lands somewhere omarchy-webapp-remove cannot address and the app
# is stuck in the launcher; a leading ../ leaves the applications directory
# altogether. Refuse rather than silently renaming what the user typed -- most
# often it is a URL entered in the name field.
if [[ $1 == */* ]]; then
echo "App name cannot contain '/': $1"
exit 1
fi
}
icon_name_from_ref() {
local ref="$1"
local name
@@ -42,6 +54,34 @@ download_icon() {
[[ -s $2 && $(file -b --mime-type "$2") == image/* ]]
}
# Chromium --app= treats javascript:, file:, and data: as a document to
# run. Prefix schemeless input with https as before, then refuse anything
# that is not http(s).
normalize_webapp_url() {
local url=$1
if [[ ! $url =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then
url="https://$url"
fi
printf '%s' "$url"
}
# Raw whitespace must be percent-encoded in a URL. Refuse it before serializing
# the desktop entry; before Exec argument quoting, it also split browser flags
# and additional URLs into separate arguments. Schemes are case-insensitive.
require_http_url() {
local url=$1
if [[ $url =~ [[:space:]] ]]; then
echo "Error: web app URL must not contain whitespace." >&2
exit 1
fi
if [[ ! ${url,,} =~ ^https?:// ]]; then
echo "Error: web app URL must be http or https." >&2
exit 1
fi
}
fetch_site_icon() {
local site_url="$1" dest="$2"
local origin page icon_url
@@ -65,13 +105,44 @@ fetch_site_icon() {
download_icon "https://www.google.com/s2/favicons?domain=${site_url}&sz=256" "$dest"
}
desktop_string_escape() {
# Desktop Entry "string" value (freedesktop Desktop Entry Spec, "Value types"):
# a raw newline would start a new key line and let a value inject a second
# Exec=. Escape backslash first, then tab/CR/LF and a leading space. Every value
# written into the .desktop file passes through here.
#
# Parameter expansion rather than sed: GNU sed's N auto-prints the pattern space
# and exits at end of input, so a `:a;N;$!ba` slurp skips every following s///
# for a value with no newline in it - which is every value except the injection
# attempt this exists to stop.
local value="$1"
value=${value//\\/\\\\}
value=${value//$'\t'/\\t}
value=${value//$'\r'/\\r}
value=${value//$'\n'/\\n}
[[ $value == " "* ]] && value="\\s${value# }"
printf '%s' "$value"
}
desktop_exec_arg() {
# One Exec argument, double-quoted per the freedesktop Exec spec: inside quotes
# " ` $ \ take a backslash and a literal % becomes %%. Only the default Exec's
# URL needs this; $CUSTOM_EXEC stays a whole command line (file-syntax only).
local escaped
escaped=$(printf '%s' "$1" \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/`/\\`/g' -e 's/\$/\\$/g' -e 's/%/%%/g')
printf '"%s"' "$escaped"
}
if (( $# < 3 )); then
echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m"
APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app")
require_plain_name "$APP_NAME"
APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com")
if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then
APP_URL="https://$APP_URL"
fi
APP_URL=$(normalize_webapp_url "$APP_URL")
require_http_url "$APP_URL"
# Try to fetch the site's icon automatically first.
mkdir -p "$ICON_DIR"
@@ -88,10 +159,8 @@ if (( $# < 3 )); then
INTERACTIVE_MODE=true
else
APP_NAME="$1"
APP_URL="$2"
if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then
APP_URL="https://$APP_URL"
fi
APP_URL=$(normalize_webapp_url "$2")
require_http_url "$APP_URL"
ICON_REF="$3"
CUSTOM_EXEC="$4" # Optional custom exec command
MIME_TYPES="$5" # Optional mime types
@@ -104,6 +173,8 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then
exit 1
fi
require_plain_name "$APP_NAME"
if [[ -z $ICON_REF ]]; then
ICON_VALUE=$(safe_icon_name "$APP_NAME")
mkdir -p "$ICON_DIR"
@@ -128,28 +199,39 @@ else
ICON_VALUE=$(icon_name_from_ref "$ICON_REF")
fi
# Use custom exec if provided, otherwise default behavior
EXEC_COMMAND="${CUSTOM_EXEC:-omarchy-launch-webapp $APP_URL}"
# Default Exec quotes the URL as one Exec-spec argument; the whole line then gets
# the file-syntax escaping below (unescaped first at read time per spec, so the
# layers compose). $CUSTOM_EXEC is a full command line, so it gets file-syntax only.
if [[ -n $CUSTOM_EXEC ]]; then
EXEC_COMMAND=$CUSTOM_EXEC
else
EXEC_COMMAND="omarchy-launch-webapp $(desktop_exec_arg "$APP_URL")"
fi
# Create application .desktop file
DESKTOP_FILE="$HOME/.local/share/applications/$APP_NAME.desktop"
mkdir -p "$(dirname "$DESKTOP_FILE")"
DESKTOP_DIR="$HOME/.local/share/applications"
DESKTOP_FILE="$DESKTOP_DIR/$APP_NAME.desktop"
mkdir -p "$DESKTOP_DIR"
name_field=$(desktop_string_escape "$APP_NAME")
exec_field=$(desktop_string_escape "$EXEC_COMMAND")
icon_field=$(desktop_string_escape "$ICON_VALUE")
cat >"$DESKTOP_FILE" <<EOF
[Desktop Entry]
Version=1.0
Name=$APP_NAME
Comment=$APP_NAME
Exec=$EXEC_COMMAND
Name=$name_field
Comment=$name_field
Exec=$exec_field
Terminal=false
Type=Application
Icon=$ICON_VALUE
Icon=$icon_field
StartupNotify=true
EOF
# Add mime types if provided
if [[ -n $MIME_TYPES ]]; then
echo "MimeType=$MIME_TYPES" >>"$DESKTOP_FILE"
printf 'MimeType=%s\n' "$(desktop_string_escape "$MIME_TYPES")" >>"$DESKTOP_FILE"
fi
chmod +x "$DESKTOP_FILE"
+26 -8
View File
@@ -9,14 +9,31 @@ ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps"
OLD_ICON_DIR="$HOME/.local/share/applications/icons"
DESKTOP_DIR="$HOME/.local/share/applications/"
if (( $# == 0 )); then
# Find all web apps
while IFS= read -r -d '' file; do
if grep -q '^Exec=.*\(omarchy-launch-webapp\|omarchy-webapp-handler\).*' "$file"; then
WEB_APPS+=("$(basename "${file%.desktop}")")
fi
done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0)
# Always index the launchers, so removal deletes the file that was found rather
# than a path rebuilt from the displayed name. Installs predating the name
# validation could nest the launcher inside directories, and those are exactly
# the ones a reconstructed path cannot reach.
WEB_APP_PATHS=()
while IFS= read -r -d '' file; do
if grep -q '^Exec=.*\(omarchy-launch-webapp\|omarchy-webapp-handler\).*' "$file"; then
WEB_APPS+=("$(basename "${file%.desktop}")")
WEB_APP_PATHS+=("$file")
fi
done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0 2>/dev/null)
# The launcher matching a chosen name, or empty when nothing was indexed under
# it (an app removed between the scan and the pick, say).
path_for_web_app() {
local wanted="$1" i
for i in "${!WEB_APPS[@]}"; do
if [[ ${WEB_APPS[$i]} == "$wanted" ]]; then
printf '%s\n' "${WEB_APP_PATHS[$i]}"
return 0
fi
done
}
if (( $# == 0 )); then
if ((${#WEB_APPS[@]})); then
mapfile -t SORTED_WEB_APPS < <(printf '%s\n' "${WEB_APPS[@]}" | sort)
APP_NAME=$(omarchy-menu-select "Select web app to remove" "${SORTED_WEB_APPS[@]}" -- --width 520 --maxheight 520)
@@ -34,7 +51,8 @@ if [[ -z $APP_NAME ]]; then
fi
icon_name=$(printf '%s\n' "$APP_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//')
rm -f "$DESKTOP_DIR/$APP_NAME.desktop"
desktop_file=$(path_for_web_app "$APP_NAME")
rm -f "${desktop_file:-$DESKTOP_DIR/$APP_NAME.desktop}"
rm -f "$ICON_DIR/$icon_name.png" "$ICON_DIR/$APP_NAME.png" "$OLD_ICON_DIR/$APP_NAME.png"
if [[ ${OMARCHY_REMOVE_NOTIFY:-true} != "false" ]]; then
+1263 -120
View File
File diff suppressed because it is too large. Load diff
+14 -28
View File
@@ -1,35 +1,21 @@
# Remove the include below to disconnect Kitty from Omarchy's theming system.
include ~/.local/state/omarchy/current/theme/kitty.conf
# Settings below override Omarchy's defaults in /etc/xdg/kitty/kitty.conf.
# Learn more: https://sw.kovidgoyal.net/kitty/conf/
# Font
font_family JetBrainsMono Nerd Font
bold_italic_font auto
font_size 9.0
# font_family JetBrainsMono Nerd Font
# font_size 12
# Window
window_padding_width 14
hide_window_decorations yes
confirm_os_window_close 0
# Window padding
# window_padding_width 14
# Keybindings
map ctrl+insert copy_to_clipboard
map shift+insert paste_from_clipboard
# Send Shift+Enter as CSI-u so TUIs can distinguish it from Enter.
map shift+enter send_text all \e[13;2u
# Kitty legacy encoding sends Alt+Shift+Enter the same as Alt+Enter; send CSI-u so tmux can match M-S-Enter.
map alt+shift+enter send_text all \e[13;4u
# Unmap a shortcut, passing it through to the terminal application
# map ctrl+insert
# Allow remote access
allow_remote_control yes
listen_on unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid}
# Set or replace a shortcut
# map ctrl+shift+c copy_to_clipboard
# Aesthetics
cursor_shape block
cursor_blink_interval 0
shell_integration no-cursor
enable_audio_bell no
# Minimal Tab bar styling
tab_bar_edge bottom
tab_bar_style powerline
tab_powerline_style slanted
tab_title_template {title}{' :{}:'.format(num_windows) if num_windows > 1 else ''}
# Remove all inherited shortcuts, including Kitty's built-in shortcuts
# clear_all_shortcuts yes
@@ -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"
+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:
+2 -2
View File
@@ -45,8 +45,8 @@ alias ....='cd ../../..'
# Tools
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'
+6
View File
@@ -10,6 +10,12 @@ The private-use glyphs in `omarchy.ttf` are:
- `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>
- `U+E908` — T3 Code, traced from the app icon in <https://aur.archlinux.org/cgit/aur.git/plain/t3code-icon.png?h=t3code-bin>, since upstream publishes no monochrome SVG
- `U+E90A` — Hermes, Font Awesome's staff-snake (CC BY 4.0) from <https://fontawesome.com/icons/staff-snake>, the mark Hermes serves as its favicon: their app icon is a portrait that reads as a smudge at menu size
- `U+E90B` — Perplexity, from <https://simpleicons.org/icons/perplexity.svg>
- `U+E90C` — OpenClaw, traced from the lobster mascot the openclaw package ships as `dist/control-ui/favicon.svg`, since upstream publishes no monochrome SVG
- `U+E90D` — Cursor, from <https://simpleicons.org/icons/cursor.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.
+7
View File
@@ -0,0 +1,7 @@
-- Hermes Desktop's frameless HUD manages its own geometry.
o.window({ class = "^Hermes$", title = "^Hermes HUD$" }, {
tag = "-default-opacity",
float = true,
border_size = 0,
opacity = "1 1",
})
+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" })
+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"),
}
Loaded 100 of 394 files, more files were not shown because too many files have changed in this diff. Show more