diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2e9a3140..d353097d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: false contact_links: - name: Suggestion - url: https://github.com/basecamp/omarchy/discussions/categories/suggestions + url: https://github.com/omacom/omarchy/discussions/categories/suggestions about: Suggest a new feature, change to existing feature, or other ideas in Discussions. - name: Support url: https://omarchy.org/discord diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..4eb613b9 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,47 @@ +# Security at Omarchy + +## Report a vulnerability + +If you believe you’ve found a security vulnerability in Omarchy, please tell the [Omarchy Security Team](https://omarchy.org/teams/#security) privately so we have an opportunity to investigate and fix it before it is made public. + +[security@omarchy.org](mailto:security@omarchy.org?subject=Security%20report) + +Please don’t report potential vulnerabilities publicly in GitHub Issues, Discord, or social media before they’ve been resolved. + +## What is a vulnerability? + +We consider a bug a security vulnerability when it can be exploited to cross a meaningful security boundary: an untrusted or lower-privileged party gains access, permissions, or control they didn’t already have. + +Code that could be more robust but does not cross a security boundary is an improvement rather than a security vulnerability. We may still merge a proposed fix and credit the reporter in our release notes. + +Eligibility for our [security credits](https://omarchy.org/security/credits/) page depends on whether a report identifies a confirmed security vulnerability, not on its severity. + +## What to include + +Give us enough information to understand and reproduce the issue: + +- The affected component and Omarchy version. +- An explanation of what an attacker can do before and after exploitation. +- Steps to reproduce the issue and any proof of concept. +- Your preferred contact details for follow-up. + +## Responsible disclosure + +Please act in good faith while investigating and reporting vulnerabilities: + +- Only test systems and accounts you own or have explicit permission to test. +- Avoid privacy violations, disruption, data destruction, and service degradation. +- Don’t exploit a vulnerability beyond what is needed to demonstrate it. +- Give us a reasonable opportunity to investigate and address the issue before publishing details. + +We’ll review your report and keep you informed as we’re able while we work toward a resolution. + +## Credits + +Researchers who privately report a confirmed security vulnerability and give us the chance to ship a fix are thanked on the [security credits](https://omarchy.org/security/credits/) page. Accepted improvements that don’t cross a security boundary may still be credited in our release notes. + +Credits link to each reporter’s X profile and show their avatar. For duplicate reports, only the first reporter is eligible for credit. + +## Regular bugs and support + +For anything that isn’t a security vulnerability, please use the [Omarchy issue tracker](https://github.com/omacom/omarchy/issues). diff --git a/.gitignore b/.gitignore index 8de9215b..1de1faa6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ # Python bytecode (orchestrator) __pycache__/ *.pyc + +.claude/ diff --git a/README.md b/README.md index c7e541a2..5834780f 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,12 @@ # Omarchy -Omarchy is a beautiful, modern & opinionated Linux distribution by DHH. +Omarchy is a beautiful, fun & agentic Linux distribution by DHH. Read more at [omarchy.org](https://omarchy.org). ## The Omarchy Manual -The manual lives in [`manual/`](manual/), which is its authoritative source. It's -mirrored to [learn.omacom.io](https://learn.omacom.io/2/the-omarchy-manual), where -its screenshots are also hosted. +The manual lives in [`manual/`](manual/), which is its authoritative source. - [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md) @@ -61,19 +59,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 diff --git a/agents/skills/icon-font.md b/agents/skills/icon-font.md index 333f8752..15585006 100644 --- a/agents/skills/icon-font.md +++ b/agents/skills/icon-font.md @@ -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 `` 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 `` 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. diff --git a/agents/skills/install-scripts.md b/agents/skills/install-scripts.md index 999a6e9c..aa993e5e 100644 --- a/agents/skills/install-scripts.md +++ b/agents/skills/install-scripts.md @@ -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 diff --git a/agents/skills/migrations.md b/agents/skills/migrations.md index bb6cf2b8..5ed1d489 100644 --- a/agents/skills/migrations.md +++ b/agents/skills/migrations.md @@ -126,6 +126,7 @@ New migration format: - Start with an `echo` describing what the migration does. - Use `$OMARCHY_PATH` to reference the Omarchy directory. - Be idempotent. Check existing state before changing it. +- Migrations are strictly ordered and synchronous. A migration that cannot finish must exit non-zero, remain pending, and stop the queue; never mark later migrations complete against state an earlier migration has not established. - Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`, `omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and `omarchy-pkg-missing` when appropriate. @@ -162,6 +163,10 @@ rm ~/.local/state/omarchy/migrations/.sh omarchy-migrate ``` +Keep a dedicated test while the migration is still being written or bugfixed, if it calls an Omarchy helper whose interface can still change, or if it is a security-sensitive privileged repair (FIDO2, leftover installer artifacts, udev, sshd). Once a one-shot rewrite has shipped in a tagged release and is frozen, drop the test even when that rewrite used sudo, pacman, or limine-mkinitcpio. Keep the migration itself for late-updaters. Tests of `omarchy-migrate`, the login notifier, and `omarchy-upgrade-to-quattro` stay. + Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through the normal migration runner. Do not add compatibility migrations for old installer layouts; put pre-4 package-layout transition work in the upgrade command instead. + +Clearing a privileged file that a retired installer left on disk is the exception, and belongs in a migration whether or not that installer was part of a package layout transition. The upgrade command only runs on a machine still making the 3 to 4 crossing, so anything put there never reaches an install that crossed already, and it never runs at all for an installer that was retired on its own — while the file the installer wrote is still sitting on those machines. The upgrade command finishes by running `omarchy-migrate` (`run_post_upgrade_migrations`), so one migration reaches every population; a copy in the upgrade command would only be a second copy of the same predicate to keep correct. Such a migration must name the defect it clears and match what the old installer actually produced before deleting it. Leave safe administrator-authored files alone; if one still contains the vulnerable privileged action, preserve it under an inactive name rather than discarding custom content or leaving the action executable. A user config that depends on the same retired compatibility path may be repaired in that migration when doing so eliminates an overlapping migration, but only by matching and replacing the exact legacy path while preserving the rest of the file. diff --git a/agents/skills/shell-dev.md b/agents/skills/shell-dev.md index d33f21ef..9765af89 100644 --- a/agents/skills/shell-dev.md +++ b/agents/skills/shell-dev.md @@ -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`. diff --git a/agents/skills/visual-verification.md b/agents/skills/visual-verification.md index 56dc8e78..a8ed63d7 100644 --- a/agents/skills/visual-verification.md +++ b/agents/skills/visual-verification.md @@ -15,10 +15,7 @@ Take a full-screen screenshot without opening the editor: omarchy capture screenshot fullscreen save ``` -The command prints the saved path and writes to the configured Pictures -directory. Use `omarchy screenshot` for the interactive smart-region flow. -Capture reference and candidate states as separate images when changing a -layer-shell surface or layout, then compare both. +The command writes to Omasnap's configured screenshot directory. Use `omarchy screenshot` for the interactive capture flow, which copies the capture and shows a timed preview. Use `omarchy screenshot --editor=overlay` to test annotation before output. Capture reference and candidate states as separate images when changing a layer-shell surface or layout, then compare both. Record a short full-screen video for animation, transition, timing, capture, or screen-recording changes: diff --git a/applications/Docker.desktop b/applications/Docker.desktop index 4f9831c0..b1852335 100644 --- a/applications/Docker.desktop +++ b/applications/Docker.desktop @@ -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 diff --git a/bin/omarchy b/bin/omarchy index 994172ef..2bace6df 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -27,6 +27,7 @@ declare -A BINARY_TO_KEY declare -A GROUP_DESCRIPTIONS GROUP_DESCRIPTIONS[agent]="AI coding agent usage data" +GROUP_DESCRIPTIONS[ascii]="Text drawn as ASCII art" GROUP_DESCRIPTIONS[audio]="Audio input and output controls" GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings" GROUP_DESCRIPTIONS[battery]="Battery status helpers" @@ -39,6 +40,7 @@ GROUP_DESCRIPTIONS[channel]="Omarchy release channel management" GROUP_DESCRIPTIONS[clipboard]="Clipboard helpers" GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers" GROUP_DESCRIPTIONS[config]="System configuration helpers" +GROUP_DESCRIPTIONS[crash]="Crash notification controls" GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs" GROUP_DESCRIPTIONS[finalize]="Finalize user setup" GROUP_DESCRIPTIONS[default]="Default application selection" @@ -63,6 +65,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" diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 964254e8..e35bcf04 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -52,7 +52,7 @@ if omarchy-cmd-missing "$agent"; then fi # Agents launched from the keybinding or menu run unattended, so each one starts -# with its own spelling of "don't stop to ask". Pi has no approval prompts. +# with its own spelling of "don't stop to ask". Pi and Ori have none to skip. case "$agent" in opencode) command=(opencode --auto) @@ -82,14 +82,50 @@ 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 --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) command=(omp --auto-approve) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; +ori) + # Ori is a harness launcher, and `ori code` is the agent it runs itself. + # A prompt alone means one headless turn there, printed after the turn ends, + # so --interactive is what seeds the session with it and keeps the window. + command=(ori code) + [[ -n ${prompt:-} ]] && command+=(--interactive --prompt "$prompt") + ;; pi) command=(pi) [[ -n ${prompt:-} ]] && command+=("$prompt") diff --git a/bin/omarchy-agent-usage-codex b/bin/omarchy-agent-usage-codex index e0200535..972c164d 100755 --- a/bin/omarchy-agent-usage-codex +++ b/bin/omarchy-agent-usage-codex @@ -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, diff --git a/bin/omarchy-apply-lock b/bin/omarchy-apply-lock index 9b97c0db..5bb261cb 100755 --- a/bin/omarchy-apply-lock +++ b/bin/omarchy-apply-lock @@ -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 diff --git a/bin/omarchy-ascii b/bin/omarchy-ascii new file mode 100755 index 00000000..166ae92b --- /dev/null +++ b/bin/omarchy-ascii @@ -0,0 +1,1217 @@ +#!/bin/bash + +# omarchy:summary=Render text as ASCII art in the font the Omarchy logo is drawn in +# omarchy:args=[text...] +# omarchy:examples=omarchy ascii Omarchy | omarchy ascii "Hello there" + +set -o pipefail + +usage() { + cat <<'EOF' +Usage: omarchy-ascii [text...] + +Renders text as ASCII art in Delta Corps Priest 1, the FIGlet font the Omarchy +logo is drawn in. Reads the text from stdin when given none. + +Options: + --help Show this help + +Delta Corps Priest 1 draws letters and spaces only. Digits and punctuation have +no glyph in it, so they are skipped. +EOF +} + +words=() + +while (( $# > 0 )); do + case "$1" in + --help) + usage + exit 0 + ;; + --) + shift + words+=("$@") + break + ;; + -?*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + words+=("$1") + ;; + esac + shift +done + +if (( ${#words[@]} > 0 )); then + text="${words[*]}" +elif [[ ! -t 0 ]]; then + text=$(cat) +else + usage >&2 + exit 1 +fi + +if [[ -z ${text//[[:space:]]/} ]]; then + echo "Nothing to render" >&2 + exit 1 +fi + +# The text arrives as awk's input, one record per line, and the font on a +# descriptor of its own. Handing the text to awk as a variable instead would +# read backslash escapes in it as awk's own, and would put a long text through +# the argument list, which has a size limit that a piped one does not. +awk ' +function rtrim(s) { + sub(/ +$/, "", s) + return s +} + +function ltrim(s) { + sub(/^ +/, "", s) + return s +} + +# A single-character replacement that never treats its needle as a pattern, +# because the hardblank is "$" in this font and would anchor a regex instead. +function replace(s, from, to, at, result) { + while ((at = index(s, from)) > 0) { + result = result substr(s, 1, at - 1) to + s = substr(s, at + 1) + } + + return result s +} + +function load_font( line, header, position, code, i) { + while ((getline line < FONT) > 0) { + font_line++ + + if (font_line == 1) { + split(line, header, " ") + hardblank = substr(header[1], length(header[1])) + height = header[2] + comment_lines = header[6] + continue + } + + if (font_line <= comment_lines + 1) { + continue + } + + # Glyphs follow the comments in character order from 32, `height` lines + # each, every line closed by the endmark the first one introduces. + position = font_line - comment_lines - 2 + code = 32 + int(position / height) + if (code > 126) { + continue + } + + if (endmark == "") { + endmark = substr(line, length(line)) + } + + while (length(line) > 0 && substr(line, length(line)) == endmark) { + line = substr(line, 1, length(line) - 1) + } + + for (i = 1; i <= blocks; i++) { + gsub(block[i], stand_in[i], line) + } + + # The hardblank becomes a marker here, while the string is one glyph wide, + # so printing a finished row is a single pass rather than one rescan per + # hardblank in it. + line = replace(line, hardblank, hardblank_mark) + + glyph[code, position % height] = line + if (length(line) > width[code]) { + width[code] = length(line) + } + } + + close(FONT) +} + +function drawable(source, chars, total, i, code) { + total = split(source, chars, "") + for (i = 1; i <= total; i++) { + code = code_of[chars[i]] + if (code != "" && width[code] > 0) { + return 1 + } + } + + return 0 +} + +# Lay one glyph beside what is drawn so far, kerned: slid left until the two +# would touch, which is the smallest run of blanks between them across all rows. +function draw(source, chars, total, i, r, ch, code, piece, glyph_width, out, trail, amount, lead, cut, keep, remaining, fragment, body, pad, line, drew) { + total = split(source, chars, "") + drew = 0 + + for (i = 1; i <= total; i++) { + ch = chars[i] + code = code_of[ch] + + if (code == "" || width[code] == 0) { + if (!(ch in skipped)) { + skipped[ch] = 1 + skipped_list[++skipped_total] = (ch in escaped) ? escaped[ch] : ch + } + + continue + } + + glyph_width = width[code] + for (r = 0; r < height; r++) { + piece[r] = sprintf("%-" glyph_width "s", glyph[code, r]) + } + + if (!drew) { + for (r = 0; r < height; r++) { + out[r] = rtrim(piece[r]) + trail[r] = glyph_width - length(out[r]) + } + + drew = 1 + continue + } + + amount = -1 + for (r = 0; r < height; r++) { + lead = glyph_width - length(ltrim(piece[r])) + if (amount < 0 || trail[r] + lead < amount) { + amount = trail[r] + lead + } + } + + # Every row gives up the same number of columns, taken from its own trailing + # blanks first and from the glyph leading ones once those run out. A row is + # held without its trailing blanks, counted in trail[] instead, so placing a + # glyph costs its own width rather than the width of the art so far -- which + # is what keeps a long line from taking time in the square of its length. + for (r = 0; r < height; r++) { + cut = (amount < trail[r]) ? amount : trail[r] + keep = amount - cut + remaining = trail[r] - cut + fragment = substr(piece[r], keep + 1) + body = rtrim(fragment) + + if (body == "") { + trail[r] = remaining + length(fragment) + } else { + pad = (remaining > 0) ? sprintf("%" remaining "s", "") : "" + out[r] = out[r] pad body + trail[r] = length(fragment) - length(body) + } + } + } + + # A line that drew nothing still occupies its block, the way a blank line + # between two words of art does, so the art keeps the shape of the text. + for (r = 0; r < height; r++) { + line = out[r] + for (i = 1; i <= blocks; i++) { + gsub(stand_in[i], block[i], line) + } + + gsub(hardblank_mark, " ", line) + sub(/ +$/, "", line) + print line + } +} + +BEGIN { + FONT = "/dev/fd/3" + + # The font draws with five block characters, and the layout above is column + # arithmetic. Each one becomes a single byte for the duration and turns back + # at print time, so length() and substr() count columns no matter what the + # locale believes a character is. + blocks = split("█ ▀ ▄ ▌ ▐", block, " ") + for (i = 1; i <= blocks; i++) { + stand_in[i] = sprintf("%c", i) + } + + hardblank_mark = sprintf("%c", blocks + 1) + + for (i = 32; i <= 126; i++) { + code_of[sprintf("%c", i)] = i + } + + # Skipped characters are named on stderr, and a control character named there + # would reach the terminal as a control character. Name those by code. + for (i = 1; i < 32; i++) { + escaped[sprintf("%c", i)] = sprintf("\\x%02x", i) + } + + escaped[sprintf("%c", 127)] = "\\x7f" + + load_font() + + if (height == "") { + print "Could not read the embedded font." >"/dev/stderr" + aborted = 1 + exit 1 + } +} + +{ + source[++source_count] = $0 + if (drawable($0)) { + any_drawable = 1 + } +} + +END { + if (aborted) { + exit 1 + } + + if (!any_drawable) { + print "Delta Corps Priest 1 draws letters and spaces only, and that text has neither." >"/dev/stderr" + exit 1 + } + + for (i = 1; i <= source_count; i++) { + draw(source[i]) + } + + if (skipped_total > 0) { + list = skipped_list[1] + for (i = 2; i <= skipped_total; i++) { + list = list " " skipped_list[i] + } + + print "Skipped, no glyph in Delta Corps Priest 1: " list >"/dev/stderr" + } +} +' 3<<'DELTA_CORPS_PRIEST_1' <<<"$text" +flf2a$ 9 8 19 0 3 0 64 0 +Font Author: CoSMiC cHiLD + +FIGFont created by patorjk.com's FIGFont Editor: http://patorjk.com/figfont-editor +$ $@ +$ $@ +$ $@ +$ $@ +$ $@ +$ $@ +$ $@ +$ $@ +$ $@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ + ▄████████$@ + ███ ███$@ + ███ ███$@ + ███ ███$@ +▀███████████$@ + ███ ███$@ + ███ ███$@ + ███ █▀ $@ + @@ +▀█████████▄ $@ + ███ ███$@ + ███ ███$@ + ▄███▄▄▄██▀ $@ +▀▀███▀▀▀██▄ $@ + ███ ██▄$@ + ███ ███$@ +▄█████████▀ $@ + @@ + ▄████████$@ +███ ███$@ +███ █▀ $@ +███ $@ +███ $@ +███ █▄ $@ +███ ███$@ +████████▀ $@ + @@ +████████▄ $@ +███ ▀███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ▄███$@ +████████▀ $@ + @@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ▄███▄▄▄ $@ +▀▀███▀▀▀ $@ + ███ █▄ $@ + ███ ███$@ + ██████████$@ + @@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ▄███▄▄▄ $@ +▀▀███▀▀▀ $@ + ███ $@ + ███ $@ + ███ $@ + @@ + ▄██████▄ $@ + ███ ███$@ + ███ █▀ $@ + ▄███ $@ +▀▀███ ████▄ $@ + ███ ███$@ + ███ ███$@ + ████████▀ $@ + @@ + ▄█ █▄ $@ + ███ ███ $@ + ███ ███ $@ + ▄███▄▄▄▄███▄▄$@ +▀▀███▀▀▀▀███▀ $@ + ███ ███ $@ + ███ ███ $@ + ███ █▀ $@ + @@ + ▄█ $@ +███ $@ +███▌$@ +███▌$@ +███▌$@ +███ $@ +███ $@ +█▀ $@ + @@ + ▄█$@ + ███$@ + ███$@ + ███$@ + ███$@ + ███$@ + ███$@ +█▄ ▄███$@ +▀▀▀▀▀▀ $@@ + ▄█ ▄█▄$@ + ███ ▄███▀$@ + ███▐██▀ $@ + ▄█████▀ $@ +▀▀█████▄ $@ + ███▐██▄ $@ + ███ ▀███▄$@ + ███ ▀█▀$@ + ▀ $@@ + ▄█ $@ +███ $@ +███ $@ +███ $@ +███ $@ +███ $@ +███▌ ▄$@ +█████▄▄██$@ +▀ $@@ + ▄▄▄▄███▄▄▄▄ $@ + ▄██▀▀▀███▀▀▀██▄$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ▀█ ███ █▀ $@ + @@ +███▄▄▄▄ $@ +███▀▀▀██▄$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀█ █▀ $@ + @@ + ▄██████▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀██████▀ $@ + @@ + ▄███████▄$@ + ███ ███$@ + ███ ███$@ + ███ ███$@ +▀█████████▀ $@ + ███ $@ + ███ $@ + ▄████▀ $@ + @@ +████████▄ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ▀ ███ $@ + ▀██████▀▄█$@ + @@ + ▄████████$@ + ███ ███$@ + ███ ███$@ + ▄███▄▄▄▄██▀$@ +▀▀███▀▀▀▀▀ $@ +▀███████████$@ + ███ ███$@ + ███ ███$@ + ███ ███$@@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ███ $@ +▀███████████$@ + ███$@ + ▄█ ███$@ + ▄████████▀ $@ + @@ + ███ $@ +▀█████████▄$@ + ▀███▀▀██$@ + ███ ▀$@ + ███ $@ + ███ $@ + ███ $@ + ▄████▀ $@ + @@ +███ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +████████▀ $@ + @@ + ▄█ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀██████▀ $@ + @@ + ▄█ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ▄█▄ ███$@ + ▀███▀███▀ $@ + @@ +▀████ ▐████▀$@ + ███▌ ████▀ $@ + ███ ▐███ $@ + ▀███▄███▀ $@ + ████▀██▄ $@ + ▐███ ▀███ $@ + ▄███ ███▄ $@ +████ ███▄$@ + @@ +▄██ ▄ $@ +███ ██▄$@ +███▄▄▄███$@ +▀▀▀▀▀▀███$@ +▄██ ███$@ +███ ███$@ +███ ███$@ + ▀█████▀ $@ + @@ + ▄███████▄ $@ +██▀ ▄██$@ + ▄███▀$@ + ▀█▀▄███▀▄▄$@ + ▄███▀ ▀$@ +▄███▀ $@ +███▄ ▄█$@ + ▀████████▀$@ + @@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ + ▄████████$@ + ███ ███$@ + ███ ███$@ + ███ ███$@ +▀███████████$@ + ███ ███$@ + ███ ███$@ + ███ █▀ $@ + @@ +▀█████████▄ $@ + ███ ███$@ + ███ ███$@ + ▄███▄▄▄██▀ $@ +▀▀███▀▀▀██▄ $@ + ███ ██▄$@ + ███ ███$@ +▄█████████▀ $@ + @@ + ▄████████$@ +███ ███$@ +███ █▀ $@ +███ $@ +███ $@ +███ █▄ $@ +███ ███$@ +████████▀ $@ + @@ +████████▄ $@ +███ ▀███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ▄███$@ +████████▀ $@ + @@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ▄███▄▄▄ $@ +▀▀███▀▀▀ $@ + ███ █▄ $@ + ███ ███$@ + ██████████$@ + @@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ▄███▄▄▄ $@ +▀▀███▀▀▀ $@ + ███ $@ + ███ $@ + ███ $@ + @@ + ▄██████▄ $@ + ███ ███$@ + ███ █▀ $@ + ▄███ $@ +▀▀███ ████▄ $@ + ███ ███$@ + ███ ███$@ + ████████▀ $@ + @@ + ▄█ █▄ $@ + ███ ███ $@ + ███ ███ $@ + ▄███▄▄▄▄███▄▄$@ +▀▀███▀▀▀▀███▀ $@ + ███ ███ $@ + ███ ███ $@ + ███ █▀ $@ + @@ + ▄█ $@ +███ $@ +███▌$@ +███▌$@ +███▌$@ +███ $@ +███ $@ +█▀ $@ + @@ + ▄█$@ + ███$@ + ███$@ + ███$@ + ███$@ + ███$@ + ███$@ +█▄ ▄███$@ +▀▀▀▀▀▀ $@@ + ▄█ ▄█▄$@ + ███ ▄███▀$@ + ███▐██▀ $@ + ▄█████▀ $@ +▀▀█████▄ $@ + ███▐██▄ $@ + ███ ▀███▄$@ + ███ ▀█▀$@ + ▀ $@@ + ▄█ $@ +███ $@ +███ $@ +███ $@ +███ $@ +███ $@ +███▌ ▄$@ +█████▄▄██$@ +▀ $@@ + ▄▄▄▄███▄▄▄▄ $@ + ▄██▀▀▀███▀▀▀██▄$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ███ ███ ███$@ + ▀█ ███ █▀ $@ + @@ +███▄▄▄▄ $@ +███▀▀▀██▄$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀█ █▀ $@ + @@ + ▄██████▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀██████▀ $@ + @@ + ▄███████▄$@ + ███ ███$@ + ███ ███$@ + ███ ███$@ +▀█████████▀ $@ + ███ $@ + ███ $@ + ▄████▀ $@ + @@ +████████▄ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ███ $@ +███ ▀ ███ $@ + ▀██████▀▄█$@ + @@ + ▄████████$@ + ███ ███$@ + ███ ███$@ + ▄███▄▄▄▄██▀$@ +▀▀███▀▀▀▀▀ $@ +▀███████████$@ + ███ ███$@ + ███ ███$@ + ███ ███$@@ + ▄████████$@ + ███ ███$@ + ███ █▀ $@ + ███ $@ +▀███████████$@ + ███$@ + ▄█ ███$@ + ▄████████▀ $@ + @@ + ███ $@ +▀█████████▄$@ + ▀███▀▀██$@ + ███ ▀$@ + ███ $@ + ███ $@ + ███ $@ + ▄████▀ $@ + @@ +███ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +████████▀ $@ + @@ + ▄█ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ + ▀██████▀ $@ + @@ + ▄█ █▄ $@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ███$@ +███ ▄█▄ ███$@ + ▀███▀███▀ $@ + @@ +▀████ ▐████▀$@ + ███▌ ████▀ $@ + ███ ▐███ $@ + ▀███▄███▀ $@ + ████▀██▄ $@ + ▐███ ▀███ $@ + ▄███ ███▄ $@ +████ ███▄$@ + @@ +▄██ ▄ $@ +███ ██▄$@ +███▄▄▄███$@ +▀▀▀▀▀▀███$@ +▄██ ███$@ +███ ███$@ +███ ███$@ + ▀█████▀ $@ + @@ + ▄███████▄ $@ +██▀ ▄██$@ + ▄███▀$@ + ▀█▀▄███▀▄▄$@ + ▄███▀ ▀$@ +▄███▀ $@ +███▄ ▄█$@ + ▀████████▀$@ + @@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@ +@ +@ +@@ +DELTA_CORPS_PRIEST_1 diff --git a/bin/omarchy-bar-text-color b/bin/omarchy-bar-text-color index a2ce8cde..35cd0ead 100755 --- a/bin/omarchy-bar-text-color +++ b/bin/omarchy-bar-text-color @@ -109,7 +109,10 @@ right) ;; esac -pixel=$(magick "$background_path" -auto-orient \ +# Sample the first frame only. Without the selector a video background makes +# ImageMagick decode the whole file and emit one value per frame, and the match +# below then fails into the fallback colour. +pixel=$(magick "$background_path[0]" -auto-orient \ -resize "${screen_width}x${screen_height}^" \ -gravity center -extent "${screen_width}x${screen_height}" \ -gravity NorthWest -crop "$crop" +repage \ diff --git a/bin/omarchy-branding-about-animation b/bin/omarchy-branding-about-animation new file mode 100755 index 00000000..460f04e0 --- /dev/null +++ b/bin/omarchy-branding-about-animation @@ -0,0 +1,104 @@ +#!/bin/bash + +# omarchy:summary=Shared helpers for animating the About branding (source this, don't run it). +# omarchy:group=branding +# omarchy:name=about-animation +# omarchy:hidden=true + +# The About branding's animation: a sheen, and the frames that make it. *When* +# those frames are written stays with the caller, because that is inseparable +# from how the caller's window closes and resizes; this only says what to write, +# and where — it is handed a logo and knows nothing about About beyond that. + +# A band of light leans across the logo, rests, and leans across again. Two +# columns per row puts it at 45 degrees on screen, where a cell is twice as tall +# as it is wide. +ESC=$'\e' +# Bright white, so the band does not depend on how the terminal reads bold. Foot's +# bold-text-in-bright brightens a bold regular colour into its bright counterpart, +# which turns a bold green logo bright green — exactly the colour a bright green +# band would have used, leaving a glint nobody can see. No regular colour +# brightens into bright white, so this one shows either way. +SHEEN_BAND="${ESC}[1m${ESC}[97m" +SHEEN_SLANT=2 +SHEEN_HALF=2 +SHEEN_FRAME_SECONDS=0.025 +SHEEN_REST_TICKS=8 +# Re-measure the grid every half second, the cadence a still logo already cost, +# rather than spawning a process per frame. +SHEEN_POLL_FRAMES=20 + +# A frame is one string holding every row of the logo, positioned and coloured, +# so a tick writes the whole logo at once and never shows it half drawn. +compose_frame() { + local centre=$1 + local row line length at from to frame="" + + for (( row = 0; row < SHEEN_ROWS; row++ )); do + line=${SHEEN_LINES[row]} + length=${#line} + + # Where the band crosses this row. Running off the right needs no clamp, + # because a slice that starts past the end of a line is already empty, but a + # negative offset would count from the end of it instead of off the left. + at=$(( centre - row * SHEEN_SLANT )) + from=$(( at - SHEEN_HALF )) + from=$(( from < 0 ? 0 : from )) + to=$(( at + SHEEN_HALF + 1 )) + to=$(( to < 0 ? 0 : to )) + + frame+="${ESC}[$((SHEEN_TOP + row));${SHEEN_LEFT}H" + frame+="${SHEEN_BASE}${line:0:from}${SHEEN_BAND}${line:from:to - from}${SHEEN_BASE}${line:to}" + done + + SHEEN_COMPOSED=$frame +} + +# Builds every frame up front: a tick that recomputed a logo's worth of colour +# spans in bash would not hold the frame rate, and the sweep is the same every +# time. Leaves them in SHEEN_FRAMES, and answers whether this logo can be +# animated at all — one it cannot put back exactly as it found it is one to leave +# alone, because nothing on screen would say the difference. +# +# sheen_build +sheen_build() { + local file=$1 columns=$5 + SHEEN_TOP=$2 + SHEEN_LEFT=$3 + SHEEN_BASE=$4 + + SHEEN_LINES=() + # A failing redirection reports itself before 2>/dev/null would apply, so order + # it first: the caller's window must not get a shell error painted across it. + mapfile -t SHEEN_LINES 2>/dev/null <"$file" || return 1 + SHEEN_ROWS=${#SHEEN_LINES[@]} + (( SHEEN_ROWS > 0 )) || return 1 + + local row line width=0 + for (( row = 0; row < SHEEN_ROWS; row++ )); do + line=${SHEEN_LINES[row]} + + # A renderer substitutes $1 to $9 for colours, so a logo written with those is + # not the text that reached the screen. + [[ $line == *'$'* ]] && return 1 + + # These frames slice the row by character and a terminal draws it by column, + # so one character has to be one cell — and everything that breaks that breaks + # it here. A wide glyph, a combining mark or a joined emoji is not one cell; a + # tab or an escape is one the renderer expanded itself; and a shell whose + # locale is counting bytes is not counting characters at all. + (( ${#line} == $(printf '%s' "$line" | LC_ALL=C.UTF-8 wc -L) )) || return 1 + + if (( ${#line} > width )); then + width=${#line} + fi + done + (( width > 0 && width <= columns )) || return 1 + + SHEEN_FRAMES=() + local centre last=$(( width + SHEEN_ROWS * SHEEN_SLANT + SHEEN_HALF )) + for (( centre = -SHEEN_HALF; centre <= last; centre++ )); do + compose_frame "$centre" + SHEEN_FRAMES+=("$SHEEN_COMPOSED") + done +} diff --git a/bin/omarchy-brightness-display-apple b/bin/omarchy-brightness-display-apple index 81202b87..c9cce2ff 100755 --- a/bin/omarchy-brightness-display-apple +++ b/bin/omarchy-brightness-display-apple @@ -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" } diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index 7666e1e6..0c4c5494 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -15,8 +15,9 @@ # start. # # Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and -# the picker target it was launched with) to /tmp/omarchy-screenrecord.log so -# users can attach a log when reporting capture failures. +# the picker target it was launched with) to +# $XDG_RUNTIME_DIR/omarchy-screenrecord.log so users can attach a log when +# reporting capture failures. [[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}" @@ -34,9 +35,18 @@ WEBCAM_SIZE="medium" RESOLUTION="" FULLSCREEN="false" STOP_RECORDING="false" -RECORDING_FILE="/tmp/omarchy-screenrecord-filename" -REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region" -LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null") +# Both of these live in the per-user runtime directory, which is 0700. What +# RECORDING_FILE holds is read back on stop and used as a path -- ffmpeg writes +# beside it, `mv` replaces it, and `rm -f` deletes its preview -- so a name in +# world-writable /tmp is a name any other local account can create first and +# then point wherever it likes. The debug log carries the recorder's stderr and +# the picked geometry, which is nobody else's business either. Fall back to +# the state directory when there is no session runtime dir -- :-/tmp would put +# both files back at the fixed names this comment describes. +RUNTIME_DIR="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}" +RECORDING_FILE="$RUNTIME_DIR/omarchy-screenrecord-filename" +REGION_FILE="$RUNTIME_DIR/omarchy-screenrecord-region" +LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "$RUNTIME_DIR/omarchy-screenrecord.log" || echo "/dev/null") for arg in "$@"; do case "$arg" in @@ -144,6 +154,11 @@ select_capture_target() { } start_screenrecording() { + mkdir -p "$RUNTIME_DIR" || return 1 + # XDG_STATE_HOME may be outside a private home; protect existing fallbacks too. + if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then + chmod 700 "$RUNTIME_DIR" || return 1 + fi local capture_args=() local target @@ -228,7 +243,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 diff --git a/bin/omarchy-capture-screenshot b/bin/omarchy-capture-screenshot index 8b2ac7d0..ec383fcd 100755 --- a/bin/omarchy-capture-screenshot +++ b/bin/omarchy-capture-screenshot @@ -2,81 +2,22 @@ # omarchy:summary=Take a screenshot # omarchy:group=capture -# omarchy:args=[smart|region|windows|fullscreen] [slurp|copy|save] [--editor=] +# omarchy:args=[smart|region|windows|fullscreen|scroll] [copy|save] # omarchy:examples=omarchy screenshot | omarchy capture screenshot region # omarchy:aliases=omarchy screenshot -[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs -OUTPUT_DIR="${OMARCHY_SCREENSHOT_DIR:-${XDG_PICTURES_DIR:-$HOME/Pictures}}" - -if [[ ! -d $OUTPUT_DIR ]]; then - mkdir -p "$OUTPUT_DIR" - omarchy-notification-send "Created screenshot directory: $OUTPUT_DIR" -t 2000 +if [[ -n ${OMARCHY_SCREENSHOT_DIR:-} && -z ${OMASNAP_SCREENSHOT_DIR:-} ]]; then + export OMASNAP_SCREENSHOT_DIR="$OMARCHY_SCREENSHOT_DIR" fi -pkill slurp && exit 0 - -SCREENSHOT_EDITOR="${OMARCHY_SCREENSHOT_EDITOR:-tensaku-edit}" - -# Parse --editor flag from any position -ARGS=() +omasnap_args=() for arg in "$@"; do - if [[ $arg == --editor=* ]]; then - SCREENSHOT_EDITOR="${arg#--editor=}" - else - ARGS+=("$arg") - fi + case "$arg" in + copy) omasnap_args+=(--copy) ;; + save) omasnap_args+=(--save) ;; + slurp) ;; + *) omasnap_args+=("$arg") ;; + esac done -set -- "${ARGS[@]}" -MODE="${1:-smart}" -PROCESSING="${2:-slurp}" - -# The picker leaves the screen freeze running (PID on its first output line) -# so grim captures the frozen overlay rather than live content shifting -# during teardown. -# -# Software-composited cursors (Hyprland's fallback on GPUs without working -# hardware cursors) are baked into the frames grim captures, so force -# hardware cursors until after grim runs and restore the setting on exit. -NO_HW_CURSORS=$(hyprctl getoption cursor:no_hardware_cursors -j | jq '.int') - -set_no_hw_cursors() { - hyprctl eval "hl.config({ cursor = { no_hardware_cursors = $1 } })" &>/dev/null || - hyprctl keyword cursor:no_hardware_cursors "$1" &>/dev/null -} - -cleanup() { - [[ -n $FREEZE_PID ]] && kill $FREEZE_PID 2>/dev/null - set_no_hw_cursors "$NO_HW_CURSORS" -} -trap cleanup EXIT - -set_no_hw_cursors 0 -{ read -r FREEZE_PID; read -r SELECTION; } < <(omarchy-capture-region "$MODE" --keep-freeze) - -[[ -z $SELECTION ]] && exit 0 - -FILENAME="screenshot-$(date +'%Y-%m-%d_%H-%M-%S').png" -FILEPATH="$OUTPUT_DIR/$FILENAME" - -case "$PROCESSING" in - slurp) - grim -g "$SELECTION" "$FILEPATH" || exit 1 - echo "$FILEPATH" - wl-copy --type image/png <"$FILEPATH" - - # Best-effort: the screenshot is already saved and on the clipboard, so a - # 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 - ;; - copy) - grim -g "$SELECTION" - | wl-copy --type image/png - ;; - save) - grim -g "$SELECTION" "$FILEPATH" || exit 1 - echo "$FILEPATH" - ;; -esac +exec omasnap "${omasnap_args[@]}" diff --git a/bin/omarchy-capture-webcam-resize b/bin/omarchy-capture-webcam-resize index cde61e08..3c9d1215 100755 --- a/bin/omarchy-capture-webcam-resize +++ b/bin/omarchy-capture-webcam-resize @@ -8,7 +8,10 @@ set -euo pipefail readonly MARGIN=40 -readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region" +# Has to resolve to whatever omarchy-capture-screenrecording writes, fallback +# included, or a recording without a session runtime dir anchors the camera to +# the whole monitor instead of the region it recorded. +readonly REGION_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}/omarchy-screenrecord-region" usage() { echo "Usage: omarchy-capture-webcam-resize " >&2 diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index 7e112388..baceaa92 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -34,7 +34,7 @@ validate_dev_checkout() { link_dev_checkout() { local checkout="$1" - [[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout" + [[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout" omarchy-dev-link "$checkout" --no-reboot } @@ -72,6 +72,10 @@ case "$channel" in ;; esac +# A failure past this point leaves the channel switch half-applied, so say how +# to pick it back up rather than dying silently under set -e. +trap 'echo -e "\nThe channel switch did not complete. Review the error above, then rerun: omarchy-channel-set '"$channel"'" >&2' ERR + if [[ -z $dev_checkout && $OMARCHY_PATH != "/usr/share/omarchy" ]]; then leaving_dev=1 fi @@ -85,7 +89,7 @@ fi omarchy-refresh-pacman "$pacman_channel" # --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites. -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -S --needed --noconfirm --ask 4 "${packages[@]}" +omarchy-update-pacman -S --needed --noconfirm --ask 4 "${packages[@]}" if [[ -z $dev_checkout ]]; then omarchy-dev-unlink --no-reboot diff --git a/bin/omarchy-chromium-ytdlp-host b/bin/omarchy-chromium-ytdlp-host index 49160240..3911451a 100755 --- a/bin/omarchy-chromium-ytdlp-host +++ b/bin/omarchy-chromium-ytdlp-host @@ -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 diff --git a/bin/omarchy-clipboard-open b/bin/omarchy-clipboard-open index 98f26bb2..827fe263 100755 --- a/bin/omarchy-clipboard-open +++ b/bin/omarchy-clipboard-open @@ -30,7 +30,7 @@ open_image() { local path="$1" [[ -r $path ]] || exit 1 - exec tensaku-edit "$path" + exec omasnap "$path" } open_text() { diff --git a/bin/omarchy-cmd-missing b/bin/omarchy-cmd-missing index 470d69c3..2b48be81 100755 --- a/bin/omarchy-cmd-missing +++ b/bin/omarchy-cmd-missing @@ -3,7 +3,7 @@ # omarchy:summary=Check whether any required commands are missing for cmd in "$@"; do - if ! command -v "$cmd" &>/dev/null; then + if ! command -v -- "$cmd" &>/dev/null; then exit 0 fi done diff --git a/bin/omarchy-cmd-present b/bin/omarchy-cmd-present index 4e758760..57b05102 100755 --- a/bin/omarchy-cmd-present +++ b/bin/omarchy-cmd-present @@ -3,7 +3,7 @@ # omarchy:summary=Check whether all required commands are available for cmd in "$@"; do - command -v "$cmd" &>/dev/null || exit 1 + command -v -- "$cmd" &>/dev/null || exit 1 done exit 0 diff --git a/bin/omarchy-crash-mute b/bin/omarchy-crash-mute new file mode 100755 index 00000000..88229fea --- /dev/null +++ b/bin/omarchy-crash-mute @@ -0,0 +1,73 @@ +#!/bin/bash + +# omarchy:summary=Silence crash notifications for one program, or list what is silenced +# omarchy:args=[--] [] [on|off|toggle] +# omarchy:examples=omarchy crash mute | omarchy crash mute hyprland | omarchy crash mute /usr/bin/hyprland | omarchy crash mute hyprland off + +# The flag omarchy-crash-watch reads before announcing a crash. Muting is per +# program; Trigger > Toggle > Crash Capture is the switch for all of them. + +set -uo pipefail + +readonly MUTES="$HOME/.local/state/omarchy/toggles/crash-ignore" + +usage() { + echo "Usage: omarchy crash mute [--] [] [on|off|toggle]" >&2 +} + +# Only regular files, because that is all the watcher honours: anything else in +# there would be reported as muted while the crashes kept arriving. The dotted +# glob is for a program legitimately called .hidden, and `.` and `..` fail the +# same -f test that keeps them out. +list() { + local entry found=0 + + for entry in "$MUTES"/* "$MUTES"/.*; do + [[ -f $entry ]] || continue + printf '%s\n' "${entry##*/}" + found=1 + done + + ((found)) || echo "No programs muted. Crashes all notify." +} + +# A program may be named -h, and the router answers that with its own help +# before this ever runs. `omarchy crash mute -- -h` is the way through. +[[ ${1:-} == "--" ]] && shift + +if (($# == 0)); then + list + exit 0 +fi + +program=$1 +action=${2:-on} + +# The watcher keys the mute on the executable's basename, so accept the path it +# reports as readily as the name, and reduce either the same way it does. +program=${program##*/} + +if [[ -z $program || $program == "." || $program == ".." ]]; then + echo "Not a program name: $1" >&2 + usage + exit 1 +fi + +case "$action" in + on|off|toggle) ;; + *) + echo "Not an action: $action" >&2 + usage + exit 1 + ;; +esac + +omarchy-toggle "crash-ignore/$program" "$action" || exit 1 + +# Report what is now true rather than what was asked for: the flag is what the +# watcher reads, and a toggle does not say which way it went. +if omarchy-toggle-enabled "crash-ignore/$program"; then + echo "Muted crash notifications for $program." +else + echo "Crash notifications for $program are back on." +fi diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index f58247a0..d3e78d16 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -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,25 +33,32 @@ 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. journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | while IFS= read -r entry; do + # A dash for a field that is empty as well as one that is missing: tab is + # IFS whitespace, so an empty field collapses into the next delimiter and + # every field after it shifts along one. A process can set its own comm to + # nothing, and that crash used to be read as somebody else's and dropped. IFS=$'\t' read -r uid comm pid exe signal < <( - jq -r '[(._UID // "-"), - (.COREDUMP_COMM // "-"), - (.COREDUMP_PID // "-"), - (.COREDUMP_EXE // "-"), - (.COREDUMP_SIGNAL_NAME // "-")] | @tsv' <<<"$entry" 2>/dev/null + jq -r 'def field: if . == null or . == "" then "-" else . end; + [(._UID | field), + (.COREDUMP_COMM | field), + (.COREDUMP_PID | field), + (.COREDUMP_EXE | field), + (.COREDUMP_SIGNAL_NAME | field)] | @tsv' <<<"$entry" 2>/dev/null ) [[ $pid =~ ^[0-9]+$ ]] || continue @@ -71,11 +76,29 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | name=$comm [[ $exe == /* ]] && name=${exe##*/} + # A process can set its own comm to anything prctl takes, slashes included, + # and a crash with no recorded executable falls back to it. The mute below + # turns this name into a path, so keep it one component: a crash must not + # reach a flag outside crash-ignore/, nor have a diagnosis write one there. + name=${name##*/} + + # What that leaves is not always a name. "/" leaves nothing, which is no + # kind of array subscript and no kind of toast; a dot component names a + # directory rather than a flag, so a mute on it would touch that directory + # and then never match; and a dash is what the read above puts there when + # the crash recorded no name at all. + [[ -n $name && $name != "-" && $name != "." && $name != ".." ]] || name=unknown + [[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue # Never announce our own machinery, or it notifies about itself. [[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue + # Muted at the end of a diagnosis, when the user was offered it and said + # yes. A flag per program rather than one list, so omarchy-crash-mute can + # lift one without reading, rewriting and re-parsing the rest. + omarchy-toggle-enabled "crash-ignore/$name" && continue + now=$EPOCHSECONDS (((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index 3ff91358..eb2ef7fb 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Set and launch the default coding agent -# omarchy:args=[pi|omp|opencode|claude|codex|grok|agy|copilot|crush] +# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse] # omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude installing=false @@ -27,25 +27,57 @@ case "$1" in pi) agent="pi"; name="Pi" ;; omp | oh-my-pi) agent="omp"; name="Oh My Pi"; agent_package="github:can1357/oh-my-pi" ;; opencode | open-code) agent="opencode"; name="OpenCode" ;; +ori | openrouter) agent="ori"; name="Ori"; agent_package="github:OpenRouterLabs/ori-releases" ;; claude | claude-code) agent="claude"; name="Claude Code" ;; codex) agent="codex"; name="Codex" ;; crush) agent="crush"; name="Crush" ;; grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;; +openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-openclaw-cli" ;; agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-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 " + echo "Usage: omarchy-default-agent " 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 @@ -54,6 +86,10 @@ if ! mise use -g "$agent_package"; then exit 1 fi +if [[ $agent == "claude" ]]; then + omarchy-install-chromium-claude 2>/dev/null || true +fi + mkdir -p "$(dirname "$agent_file")" printf '%s\n' "$agent" >"$agent_file" diff --git a/bin/omarchy-default-editor b/bin/omarchy-default-editor index 25134081..74964fd6 100755 --- a/bin/omarchy-default-editor +++ b/bin/omarchy-default-editor @@ -17,6 +17,9 @@ if (($# == 0)); then read -r editor <"$editor_file" fi + # sublime-text-4 ships `subl`; older defaults stored `sublime_text`. + [[ $editor == sublime_text ]] && editor=subl + [[ -n $editor ]] && echo "$editor" || echo "nvim" exit 0 fi @@ -25,10 +28,10 @@ case "$1" in code) selection="code"; editor="code"; name="VSCode"; glyph= ;; cursor) selection="cursor"; editor="cursor"; name="Cursor"; glyph= ;; zed | zeditor) selection="zed"; editor="zeditor"; name="Zed"; glyph= ;; -sublime_text) selection="sublime_text"; editor="sublime_text"; name="Sublime Text"; glyph= ;; +sublime_text | subl) selection="sublime_text"; editor="subl"; name="Sublime Text"; glyph= ;; helix) selection="helix"; editor="helix"; name="Helix"; glyph= ;; vim) selection="vim"; editor="vim"; name="Vim"; glyph= ;; -emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;; +emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;; nvim) selection="nvim"; editor="nvim"; name="Neovim"; glyph= ;; *) echo "Usage: omarchy-default-editor " diff --git a/bin/omarchy-dev-link b/bin/omarchy-dev-link index ccaa404f..b09473b5 100755 --- a/bin/omarchy-dev-link +++ b/bin/omarchy-dev-link @@ -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 diff --git a/bin/omarchy-display-text-size b/bin/omarchy-display-text-size index b15b200f..bdf87786 100755 --- a/bin/omarchy-display-text-size +++ b/bin/omarchy-display-text-size @@ -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 } diff --git a/bin/omarchy-dns b/bin/omarchy-dns index a22e028c..19c6549e 100755 --- a/bin/omarchy-dns +++ b/bin/omarchy-dns @@ -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() { @@ -137,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" </dev/null </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 diff --git a/bin/omarchy-git-url-check b/bin/omarchy-git-url-check new file mode 100755 index 00000000..53109949 --- /dev/null +++ b/bin/omarchy-git-url-check @@ -0,0 +1,50 @@ +#!/bin/bash + +# omarchy:summary=Check that a git URL names a repository, not a transport helper +# omarchy:args= +# 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: `::
`, and +# `://
` 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 diff --git a/bin/omarchy-hibernation-setup b/bin/omarchy-hibernation-setup index 7bc57246..1aeb0d75 100755 --- a/bin/omarchy-hibernation-setup +++ b/bin/omarchy-hibernation-setup @@ -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 diff --git a/bin/omarchy-hook b/bin/omarchy-hook index 8c2a59d9..499cdf14 100755 --- a/bin/omarchy-hook +++ b/bin/omarchy-hook @@ -11,6 +11,18 @@ if (( $# < 1 )); then fi HOOK=$1 + +# Hook names are fixed labels chosen by Omarchy code (post-update, theme-set, +# font-set). The name becomes a filename under the hooks directory. A slash +# would turn it into directory levels, and a bare `.` or `..` would point bash +# at the directory itself or its parent. Refuse those rather than follow them. +# Dots inside a name (a..b) are fine; once slashes are out, only the whole +# name being `.` or `..` can leave the directory. +if [[ -z $HOOK || $HOOK == */* || $HOOK == "." || $HOOK == ".." ]]; then + echo "Invalid hook name: $HOOK" >&2 + exit 2 +fi + HOOK_PATH="$HOME/.config/omarchy/hooks/$1" HOOK_DIR="$HOOK_PATH.d" shift diff --git a/bin/omarchy-hook-install b/bin/omarchy-hook-install index 7c536482..08b82e58 100755 --- a/bin/omarchy-hook-install +++ b/bin/omarchy-hook-install @@ -15,8 +15,19 @@ fi HOOK_TYPE=$1 HOOK_FILE=$2 + +# Hook types are the same labels omarchy-hook runs (post-update, theme-set). +# The type becomes a directory under the hooks directory. A slash would turn +# it into directory levels. A bare `.` or `..` is a name the runner already +# refuses, so installing under it would write a hook nothing can run. Refuse +# those rather than mkdir/cp into them. Dots inside a name (a..b) are fine. +if [[ -z $HOOK_TYPE || $HOOK_TYPE == */* || $HOOK_TYPE == "." || $HOOK_TYPE == ".." ]]; then + echo "Invalid hook name: $HOOK_TYPE" >&2 + exit 2 +fi + HOOK_DIR="$HOME/.config/omarchy/hooks/$HOOK_TYPE.d" -HOOK_NAME=$(basename "$HOOK_FILE") +HOOK_NAME=$(basename -- "$HOOK_FILE") HOOK_PATH="$HOOK_DIR/$HOOK_NAME" if [[ ! -f $HOOK_FILE ]]; then @@ -25,7 +36,7 @@ if [[ ! -f $HOOK_FILE ]]; then fi mkdir -p "$HOOK_DIR" -cp "$HOOK_FILE" "$HOOK_PATH" +cp -- "$HOOK_FILE" "$HOOK_PATH" chmod 755 "$HOOK_PATH" echo "Installed $HOOK_TYPE hook: $HOOK_PATH" diff --git a/bin/omarchy-hw-dell-xps13-sidecar-amps b/bin/omarchy-hw-dell-xps13-sidecar-amps new file mode 100755 index 00000000..ecc206fa --- /dev/null +++ b/bin/omarchy-hw-dell-xps13-sidecar-amps @@ -0,0 +1,8 @@ +#!/bin/bash + +# omarchy:summary=Match the Dell XPS 13 DX13260 that requires the sidecar amplifier workaround. + +product_sku="${OMARCHY_DMI_PRODUCT_SKU:-/sys/class/dmi/id/product_sku}" + +omarchy-hw-match "DX13260" && + grep -qix "0E53" "$product_sku" 2>/dev/null diff --git a/bin/omarchy-hw-elgato-camlink-4k b/bin/omarchy-hw-elgato-camlink-4k new file mode 100755 index 00000000..e94a54b2 --- /dev/null +++ b/bin/omarchy-hw-elgato-camlink-4k @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Detect whether an Elgato Cam Link 4K is plugged in. + +grep -qsx "Cam Link 4K" /sys/bus/usb/devices/*/product diff --git a/bin/omarchy-hw-match b/bin/omarchy-hw-match index 318ca7e8..cecca3b0 100755 --- a/bin/omarchy-hw-match +++ b/bin/omarchy-hw-match @@ -3,5 +3,5 @@ # omarchy:summary=Match against the computer's DMI product name or product family (case-insensitive). # omarchy:args= -grep -qi "$1" /sys/class/dmi/id/product_name 2>/dev/null || -grep -qi "$1" /sys/class/dmi/id/product_family 2>/dev/null +grep -qi -- "$1" /sys/class/dmi/id/product_name 2>/dev/null || +grep -qi -- "$1" /sys/class/dmi/id/product_family 2>/dev/null diff --git a/bin/omarchy-hyprland-monitor-clamshell b/bin/omarchy-hyprland-monitor-clamshell index a55e2028..f33f23af 100755 --- a/bin/omarchy-hyprland-monitor-clamshell +++ b/bin/omarchy-hyprland-monitor-clamshell @@ -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" diff --git a/bin/omarchy-hyprland-monitor-internal b/bin/omarchy-hyprland-monitor-internal index 836073ee..c0be60fa 100755 --- a/bin/omarchy-hyprland-monitor-internal +++ b/bin/omarchy-hyprland-monitor-internal @@ -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 diff --git a/bin/omarchy-hyprland-monitor-internal-mirror b/bin/omarchy-hyprland-monitor-internal-mirror index d5213d4c..ec48c740 100755 --- a/bin/omarchy-hyprland-monitor-internal-mirror +++ b/bin/omarchy-hyprland-monitor-internal-mirror @@ -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 diff --git a/bin/omarchy-hyprland-monitor-scaling b/bin/omarchy-hyprland-monitor-scaling index 240a1ea5..8de107f4 100755 --- a/bin/omarchy-hyprland-monitor-scaling +++ b/bin/omarchy-hyprland-monitor-scaling @@ -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. diff --git a/bin/omarchy-install-ai-claude b/bin/omarchy-install-ai-claude new file mode 100755 index 00000000..85a7e593 --- /dev/null +++ b/bin/omarchy-install-ai-claude @@ -0,0 +1,15 @@ +#!/bin/bash + +# omarchy:summary=Install the Claude desktop app +# omarchy:requires-sudo=true + +set -euo pipefail + +echo "Installing Claude..." +omarchy-pkg-add claude-desktop + +echo "Opening Claude..." +setsid uwsm-app -- /usr/bin/claude-desktop >/dev/null 2>&1 & + +echo "" +echo "Claude has been installed." diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes new file mode 100755 index 00000000..6b3764fd --- /dev/null +++ b/bin/omarchy-install-ai-hermes @@ -0,0 +1,162 @@ +#!/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]) +if Path(sys.argv[1], "hermes_cli/main_desktop.py").is_file(): + from hermes_cli.main_desktop import _write_desktop_build_stamp +else: + 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." diff --git a/bin/omarchy-install-ai-openclaw b/bin/omarchy-install-ai-openclaw new file mode 100755 index 00000000..cb79a9ad --- /dev/null +++ b/bin/omarchy-install-ai-openclaw @@ -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." diff --git a/bin/omarchy-install-ai-t3-code b/bin/omarchy-install-ai-t3-code new file mode 100755 index 00000000..b9e3af38 --- /dev/null +++ b/bin/omarchy-install-ai-t3-code @@ -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." diff --git a/bin/omarchy-install-and-launch b/bin/omarchy-install-and-launch index 53bbfbc9..2a7e547c 100755 --- a/bin/omarchy-install-and-launch +++ b/bin/omarchy-install-and-launch @@ -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 &)" diff --git a/bin/omarchy-install-app b/bin/omarchy-install-app index bae90897..bee13e61 100755 --- a/bin/omarchy-install-app +++ b/bin/omarchy-install-app @@ -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}" diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index f71c7c98..4593bc72 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -6,9 +6,10 @@ set -e -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() { @@ -23,13 +24,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 @@ -40,7 +34,7 @@ chromium) echo "Installing Chromium..." omarchy-pkg-add chromium - setup_policy_directory /etc/chromium/policies/managed + setup_chromium_policy_directory /etc/chromium/policies/managed copy_chromium_flags ~/.config/chromium-flags.conf omarchy-theme-set-browser announce_browser_installed "Chromium" @@ -49,7 +43,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" @@ -58,7 +52,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" @@ -67,7 +61,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" @@ -76,7 +70,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" @@ -85,7 +79,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" ;; @@ -93,7 +87,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" ;; diff --git a/bin/omarchy-install-chromium-claude b/bin/omarchy-install-chromium-claude new file mode 100755 index 00000000..df338271 --- /dev/null +++ b/bin/omarchy-install-chromium-claude @@ -0,0 +1,43 @@ +#!/bin/bash + +# omarchy:summary=Install the Claude extension for Chromium-based browsers +# omarchy:requires-sudo=true + +set -euo pipefail + +if (( EUID == 0 )); then + export PATH=/usr/bin:/bin +fi + +EXTENSION_ID="fcoeoabgfenejglbffodgkkbkcdhcgfn" +EXTENSION_JSON='{ "external_update_url": "https://clients2.google.com/service/update2/crx" }' +PACKAGED_PATH=/usr/bin/omarchy-install-chromium-claude + +# Brave and Brave Origin share Chromium's external extension directory. +# Seed all supported browsers, including those installed after choosing Claude. +EXTENSION_DIRS=( + /usr/share/chromium/extensions + /usr/share/google-chrome/extensions + /usr/share/microsoft-edge/extensions +) + +installed=true +for dir in "${EXTENSION_DIRS[@]}"; do + if [[ ! -f $dir/$EXTENSION_ID.json ]] || [[ $(cat "$dir/$EXTENSION_ID.json") != "$EXTENSION_JSON" ]]; then + installed=false + fi +done + +if [[ $installed == "true" ]]; then + exit 0 +fi + +if (( EUID == 0 )); then + for dir in "${EXTENSION_DIRS[@]}"; do + printf '%s\n' "$EXTENSION_JSON" | install -D -m 0644 /dev/stdin "$dir/$EXTENSION_ID.json" + done +elif [[ -t 0 ]]; then + exec sudo "$PACKAGED_PATH" +else + exec pkexec "$PACKAGED_PATH" +fi diff --git a/bin/omarchy-install-chromium-copy-url b/bin/omarchy-install-chromium-copy-url index 4f6dae02..0ee586d6 100755 --- a/bin/omarchy-install-chromium-copy-url +++ b/bin/omarchy-install-chromium-copy-url @@ -16,6 +16,9 @@ browser_dirs=( "$HOME/.config/BraveSoftware/Brave-Browser" "$HOME/.config/BraveSoftware/Brave-Browser-Beta" "$HOME/.config/BraveSoftware/Brave-Browser-Nightly" + "$HOME/.config/BraveSoftware/Brave-Origin" + "$HOME/.config/BraveSoftware/Brave-Origin-Beta" + "$HOME/.config/BraveSoftware/Brave-Origin-Nightly" "$HOME/.config/microsoft-edge" "$HOME/.config/microsoft-edge-dev" ) diff --git a/bin/omarchy-install-chromium-ytdlp b/bin/omarchy-install-chromium-ytdlp index c700578c..63ba46d5 100755 --- a/bin/omarchy-install-chromium-ytdlp +++ b/bin/omarchy-install-chromium-ytdlp @@ -17,6 +17,9 @@ browser_dirs=( "$HOME/.config/BraveSoftware/Brave-Browser" "$HOME/.config/BraveSoftware/Brave-Browser-Beta" "$HOME/.config/BraveSoftware/Brave-Browser-Nightly" + "$HOME/.config/BraveSoftware/Brave-Origin" + "$HOME/.config/BraveSoftware/Brave-Origin-Beta" + "$HOME/.config/BraveSoftware/Brave-Origin-Nightly" "$HOME/.config/microsoft-edge" "$HOME/.config/microsoft-edge-dev" ) diff --git a/bin/omarchy-install-dev-env b/bin/omarchy-install-dev-env index 263fd755..03577198 100755 --- a/bin/omarchy-install-dev-env +++ b/bin/omarchy-install-dev-env @@ -12,37 +12,10 @@ if [[ -z $1 ]]; then fi install_php() { - omarchy-pkg-add php composer php-sqlite xdebug + mise tool-alias set php github:nunomaduro/static-php-builds + mise use --global php@latest - # Install Path for Composer - if [[ :$PATH: != *:$HOME/.config/composer/vendor/bin:* ]]; then - echo 'export PATH="$HOME/.config/composer/vendor/bin:$PATH"' >>"$HOME/.bashrc" - source "$HOME/.bashrc" - echo "Added Composer global bin directory to PATH." - else - echo "Composer global bin directory already in PATH." - fi - - # Enable some extensions - local php_ini_path="/etc/php/php.ini" - local extensions_to_enable=( - "bcmath" - "intl" - "iconv" - "openssl" - "pdo_sqlite" - "pdo_mysql" - ) - - # Enable Xdebug - sudo sed -i \ - -e 's/^;zend_extension=xdebug.so/zend_extension=xdebug.so/' \ - -e 's/^;xdebug.mode=debug/xdebug.mode=debug/' \ - /etc/php/conf.d/xdebug.ini - - for ext in "${extensions_to_enable[@]}"; do - sudo sed -i "s/^;extension=${ext}/extension=${ext}/" "$php_ini_path" - done + mise x php -- composer global config bin-dir "$HOME/.local/bin" } install_node() { @@ -84,7 +57,7 @@ laravel) echo -e "Installing PHP and Laravel...\n" install_php install_node - composer global require laravel/installer + mise x php -- composer global require laravel/installer echo -e "\nYou can now run: laravel new myproject" ;; symfony) diff --git a/bin/omarchy-install-font b/bin/omarchy-install-font index 6f68892e..8811ba48 100755 --- a/bin/omarchy-install-font +++ b/bin/omarchy-install-font @@ -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}" diff --git a/bin/omarchy-install-gaming-xbox-controllers b/bin/omarchy-install-gaming-xbox-controllers index f4134f76..f2cda4e7 100755 --- a/bin/omarchy-install-gaming-xbox-controllers +++ b/bin/omarchy-install-gaming-xbox-controllers @@ -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 diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli new file mode 100755 index 00000000..4f4b99d7 --- /dev/null +++ b/bin/omarchy-install-hermes-cli @@ -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" </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 diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli new file mode 100755 index 00000000..18e12317 --- /dev/null +++ b/bin/omarchy-install-openclaw-cli @@ -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 diff --git a/bin/omarchy-install-service-1password b/bin/omarchy-install-service-1password index f59cab92..fc7238a6 100755 --- a/bin/omarchy-install-service-1password +++ b/bin/omarchy-install-service-1password @@ -28,7 +28,7 @@ echo "Installing 1Password extension for Chromium..." install_chromium_extension echo "Opening 1Password..." -uwsm-app -- 1password >/dev/null 2>&1 & +setsid uwsm-app -- 1password >/dev/null 2>&1 & echo "" echo "1Password has been installed. Restart Chromium to load the browser extension." diff --git a/bin/omarchy-install-service-dropbox b/bin/omarchy-install-service-dropbox index 0ee07bfb..7a4e190e 100755 --- a/bin/omarchy-install-service-dropbox +++ b/bin/omarchy-install-service-dropbox @@ -1,6 +1,7 @@ #!/bin/bash # omarchy:summary=Install and start the Dropbox service. Must then be authenticated via the web. +# omarchy:requires-sudo=true echo "Installing all dependencies..." omarchy-pkg-add dropbox dropbox-cli libappindicator-gtk3 python-gpgme nautilus-dropbox diff --git a/bin/omarchy-install-service-once b/bin/omarchy-install-service-once index 9b5cf446..b721a738 100755 --- a/bin/omarchy-install-service-once +++ b/bin/omarchy-install-service-once @@ -10,4 +10,4 @@ echo "Enabling ONCE background service..." sudo systemctl enable --now once-background.service echo -e "\nLaunching ONCE..." -once +sudo once diff --git a/bin/omarchy-launch-1password b/bin/omarchy-launch-1password index d50da0b2..61889fd9 100755 --- a/bin/omarchy-launch-1password +++ b/bin/omarchy-launch-1password @@ -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 diff --git a/bin/omarchy-launch-about b/bin/omarchy-launch-about index 76ece301..2d7c9d2c 100755 --- a/bin/omarchy-launch-about +++ b/bin/omarchy-launch-about @@ -10,10 +10,50 @@ LOGO_FILE="$HOME/.config/omarchy/branding/about.txt" FIT_FILE="$HOME/.local/state/omarchy/windows/about.fit" +OMARCHY_FASTFETCH_DIR=/etc/fastfetch -# A user-level fastfetch config can relocate or restyle the logo in ways this -# measurement cannot see, so leave sizing to the float rule in that case. +# The logo block in the fastfetch config. The fit below reproduces this layout to +# size the window, and the sheen has to repaint the very cells fastfetch drew the +# logo on, so both read the padding from here. +LOGO_PAD_LEFT=2 +LOGO_PAD_TOP=2 +LOGO_PAD_RIGHT=6 + +# The content is measured once and then goes on living: an uptime that turns +# minutes into hours and hours into days, a version string that grows, a module +# that shows up on the next boot. A window fitted to exactly what was measured +# has nowhere to put any of it, and the layout clips or scrolls the moment it +# grows — which is also the moment the logo stops being where it was drawn. Keep +# a little in hand rather than measure again every time something ticks over. +FIT_SPARE_COLUMNS=2 +FIT_SPARE_ROWS=1 + +POLL_SECONDS=0.5 + +# fastfetch has no animation of its own, so the sheen is ours. It knows about a +# logo and nothing about About, which is why it is a file of its own. +source omarchy-branding-about-animation + +# A user's own fastfetch config can relocate or restyle the logo in ways this +# measurement cannot see, so leave sizing to the float rule in that case. It can +# sit in any of several directories fastfetch searches ahead of Omarchy's own, so +# ask fastfetch for that order rather than keep a copy here for its next release +# to outdate. custom_fastfetch_config() { + local directory listed=false + + # A whole line at a time, because a home directory may contain a space, and the + # marker fastfetch puts beside the config it settled on is not part of the path. + while IFS= read -r directory; do + listed=true + directory=${directory% (\*)} + [[ ${directory%/} == "$OMARCHY_FASTFETCH_DIR" ]] && return 1 + [[ -f ${directory%/}/config.jsonc ]] && return 0 + done < <(fastfetch --list-config-paths 2>/dev/null) + + # Silence is not the same answer as "none of them", so fall back to the + # directory fastfetch has always looked in first rather than read it as one. + [[ $listed == true ]] && return 1 [[ -f $HOME/.config/fastfetch/config.jsonc ]] } @@ -100,17 +140,22 @@ fit_window() { # The guard character keeps command substitution from eating the trailing # break line, which provides the bottom padding row. - local modules module_w module_h + local modules module_w modules=$(fastfetch --logo none | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g'; printf X) modules=${modules%X} module_w=$(printf '%s' "$modules" | display_columns) - module_h=$(printf '%s' "$modules" | wc -l) + + # Ask fastfetch how tall its layout came out rather than predicting it from the + # logo and the module column: once the logo is the taller of the two, fastfetch + # writes a row more than that arithmetic expects, and a window sized by it + # scrolls the top padding away. + measure_layout || return 1 # Mirror the logo block in the fastfetch config: 2 columns of padding left of - # the logo, 6 between logo and modules, 2 rows above it. Then 2 columns of - # right padding to match, and a row for the cursor so the trailing break shows. - local target_c=$(( 2 + logo_w + 6 + module_w + 2 )) - local target_r=$(( (logo_h + 2 > module_h ? logo_h + 2 : module_h) + 1 )) + # the logo, 6 between logo and modules. Then 2 columns of right padding to + # match, a row for the cursor so the trailing break shows, and the spare above. + local target_c=$(( LOGO_PAD_LEFT + logo_w + LOGO_PAD_RIGHT + module_w + LOGO_PAD_LEFT + FIT_SPARE_COLUMNS )) + local target_r=$(( LAYOUT_ROWS + 1 + FIT_SPARE_ROWS )) local nudges=0 rows cols address width height shift_w shift_h target_w target_h while :; do @@ -146,17 +191,155 @@ fit_window() { return 1 } +# One run answers both questions the sheen has to ask first. How tall the layout +# is, because a window too small for it scrolls, which moves the logo off the rows +# the frames address. And what colour fastfetch drew the logo in, because the +# glint has to hand every cell back in the colour it arrived in — assume it, and +# a logo fastfetch colours differently comes out of the first glint a new one. +measure_layout() { + [[ -n ${LAYOUT_ROWS:-} ]] && return 0 + + local rendered plain needle offset found row + + # --pipe false because fastfetch drops its colours when it is not writing to a + # terminal, and it is writing to this substitution. + rendered=$(fastfetch --pipe false 2>/dev/null; printf X) + rendered=${rendered%X} + LAYOUT_ROWS=$(printf '%s' "$rendered" | wc -l) + (( LAYOUT_ROWS > 0 )) || return 1 + + # Find the logo in what fastfetch drew rather than working it out from the + # padding this file was written against. The config that runs is the one in + # /etc, which a checkout does not replace, so the two can disagree — and a logo + # measured two rows above where it was drawn is a logo the sheen moves. Not + # finding it at all is the same answer as finding it somewhere unexpected: + # whatever is on screen is not the text in the file, so leave it alone. + IFS=$'\t' read -r offset needle < <(logo_landmark) || return 1 + [[ -n $needle ]] || return 1 + + plain=$(printf '%s' "$rendered" | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g') + found=$(printf '%s' "$plain" | LC_ALL=C.UTF-8 awk -v needle="$needle" \ + 'index($0, needle) { print NR, index($0, needle); exit }') + [[ -n $found ]] || return 1 + read -r LOGO_ROW LOGO_COLUMN <<<"$found" + LOGO_ROW=$(( LOGO_ROW - offset )) + (( LOGO_ROW >= 1 && LOGO_COLUMN >= 1 )) || return 1 + + # Whatever fastfetch set before that row is what the sheen has to give back. + row=$(printf '%s' "$rendered" | sed -n "$(( LOGO_ROW + offset ))p") + LOGO_COLOR="" + [[ $row =~ ^(($ESC\[[0-9;]*m)+) ]] && LOGO_COLOR=${BASH_REMATCH[1]} + + return 0 +} + +# The longest line of the logo, and how far down the logo it sits — the most +# distinctive thing to look for in the render, and the offset that turns where it +# was found back into where the logo starts. +# Tab-separated, and the offset first, because the line may hold spaces of its +# own and splitting on them would cut the landmark short. +logo_landmark() { + LC_ALL=C.UTF-8 awk ' + { if (length($0) > best) { best = length($0); line = $0; at = NR - 1 } } + END { if (best > 0) printf "%d\t%s\n", at, line } + ' "$LOGO_FILE" 2>/dev/null +} + +# The About screen's own reasons the logo might not be where these frames would +# draw it. Whether the logo itself can be animated is the sheen's own question. +build_sheen() { + # Whatever the last build left is not this window's, and the loop below plays + # whatever is here — so a build that fails has to leave nothing to play. + SHEEN_FRAMES=() + + custom_fastfetch_config && return 1 + + # fastfetch honours NO_COLOR when it writes to a terminal but not when it writes + # to the measurement below, so a logo drawn without colour would be measured as + # green and left green by the first glint. A glint is colour anyway, which is + # the thing NO_COLOR asks for none of. + [[ -n ${NO_COLOR:-} ]] && return 1 + + measure_layout || return 1 + + # The layout needs a row for the cursor past its last line. Without one it has + # scrolled, and the logo is no longer on the rows the frames address. Ask the + # terminal where the cursor actually is rather than trust the arithmetic, and + # keep the arithmetic for a terminal that will not say. + local rows cols + read -r rows cols <<<"$(stty size)" + (( rows > LAYOUT_ROWS )) || return 1 + + # The cell the logo's first row starts on, every attribute fastfetch left on + # those cells so a glint that has passed leaves them as it found them, and the + # room it has to work in left of the module column. + sheen_build "$LOGO_FILE" "$LOGO_ROW" "$LOGO_COLUMN" "${ESC}[0m${LOGO_COLOR}" "$(( cols - LOGO_COLUMN + 1 ))" +} + +# What the frames were built against. A window that resized, or a logo that was +# rebranded, needs fastfetch run again before anything is drawn over it. +content_changed() { + [[ $resized == true ]] && return 0 + + [[ $(stty size) != "$grid" || $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != "$logo_stamp" ]] +} + +# A tick either times out, which is the delay, or a key arrives and About closes. +# Anything else on stdin is a terminal that went away, which closes it too. +tick() { + read -t "$1" -n 1 -s && exit + (( $? > 128 )) || exit +} + +play_sheen() { + local index + + for (( index = 0; index < ${#SHEEN_FRAMES[@]}; index++ )); do + # Stop before painting a frame rather than after: a resize has already moved + # the cells these address, and the rest of a sweep would land across them. + # The grid costs a process, so it stays on the poll interval. The trap costs + # nothing, so it is read last — a signal that arrived while the grid was being + # read would otherwise be seen only after another frame had gone out. + (( index % SHEEN_POLL_FRAMES == 0 )) && content_changed && return 1 + [[ $resized == true ]] && return 1 + + printf '%s' "${SHEEN_FRAMES[index]}" + tick "$SHEEN_FRAME_SECONDS" + done + + return 0 +} + +# The logo is still between glints, so About is a quiet window to leave open. +rest_sheen() { + local ticks + + for (( ticks = 0; ticks < SHEEN_REST_TICKS; ticks++ )); do + tick "$POLL_SECONDS" + content_changed && return 1 + done + + return 0 +} + if [[ ${1:-} == "--render" ]]; then printf '\e[?25l' + # A sweep runs for seconds between polls, so it reads this instead. The polling + # stays as the backstop, for a signal that arrived while it could not be taken. + resized=false + trap 'resized=true' WINCH + # Give the compositor a moment to apply the window rules before measuring cells. settle_grid fitted=false passes=0 while :; do - size=$(stty size) + grid=$(stty size) logo_stamp=$(stat -c %Y "$LOGO_FILE" 2>/dev/null) + resized=false + LAYOUT_ROWS="" clear fastfetch # A second pass picks up a fit that could not measure the window the first @@ -165,10 +348,10 @@ if [[ ${1:-} == "--render" ]]; then if [[ $fitted == false ]] && (( ++passes <= 2 )); then fit_window && fitted=true fi - while [[ $(stty size) == $size && $(stat -c %Y "$LOGO_FILE" 2>/dev/null) == $logo_stamp ]]; do - read -t 0.5 -n 1 -s && exit - (( $? > 128 )) || exit - done + # An empty frame list plays nothing, so a logo that cannot be animated waits + # here exactly as the still one did, and there is one loop rather than two. + build_sheen + while play_sheen && rest_sheen; do :; done # A rebranded logo changes the content dimensions, so measure again. if [[ $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != $logo_stamp ]]; then fitted=false diff --git a/bin/omarchy-launch-docker-tui b/bin/omarchy-launch-docker-tui new file mode 100755 index 00000000..8d09894a --- /dev/null +++ b/bin/omarchy-launch-docker-tui @@ -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 diff --git a/bin/omarchy-launch-editor b/bin/omarchy-launch-editor index 8a69dabd..cbf12c78 100755 --- a/bin/omarchy-launch-editor +++ b/bin/omarchy-launch-editor @@ -18,17 +18,20 @@ else editor="nvim" fi +# sublime-text-4 ships `subl` on PATH, not `sublime_text`. +[[ $editor == sublime_text ]] && editor=subl + omarchy-cmd-present "$editor" || editor="nvim" case "${editor##*/}" in nvim | vim | nano | micro | hx | helix | fresh) if [[ $inline == "true" ]]; then - exec "$editor" "$@" + exec "$editor" -- "$@" else - exec omarchy-launch-tui "$editor" "$@" + exec omarchy-launch-tui "$editor" -- "$@" fi ;; *) - exec setsid uwsm-app -- "$editor" "$@" + exec setsid uwsm-app -- "$editor" -- "$@" ;; esac diff --git a/bin/omarchy-launch-floating-terminal-with-presentation b/bin/omarchy-launch-floating-terminal-with-presentation index c9cb255a..4497ff0d 100755 --- a/bin/omarchy-launch-floating-terminal-with-presentation +++ b/bin/omarchy-launch-floating-terminal-with-presentation @@ -8,6 +8,7 @@ source omarchy-restart-gum cmd="$*" -presentation_script="omarchy-show-logo; $cmd; if (( \$? != 130 )); then omarchy-show-done; fi" +# 130 is the user bailing with Ctrl-C; anything else gets the Done/Failed prompt. +presentation_script="omarchy-show-logo; $cmd; code=\$?; if (( code != 130 )); then omarchy-show-done \$code; fi" exec setsid uwsm-app -- xdg-terminal-exec --app-id=org.omarchy.terminal --title=Omarchy -e bash -c "$presentation_script" diff --git a/bin/omarchy-launch-openclaw b/bin/omarchy-launch-openclaw new file mode 100755 index 00000000..13ab6c24 --- /dev/null +++ b/bin/omarchy-launch-openclaw @@ -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 ]] + +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" diff --git a/bin/omarchy-menu-images b/bin/omarchy-menu-images index 8c316462..8305cb0f 100755 --- a/bin/omarchy-menu-images +++ b/bin/omarchy-menu-images @@ -75,8 +75,9 @@ fi selection_file=$(mktemp) done_file=$(mktemp) pending_file=$(mktemp) +pending_video_file=$(mktemp) rm -f "$done_file" -trap 'rm -f "$selection_file" "$done_file" "$pending_file"' EXIT +trap 'rm -f "$selection_file" "$done_file" "$pending_file" "$pending_video_file"' EXIT image_dirs_env="" for dir in "${image_dirs[@]}"; do @@ -111,8 +112,8 @@ cache_key=$(printf '%s' "$image_dirs_env" | md5sum | cut -d ' ' -f 1) rows_cache_file="$cache_dir/$cache_key.rows" rows_signature_file="$cache_dir/$cache_key.signature" rows_fast_signature_file="$cache_dir/$cache_key.fast-signature" -rows_signature="v3"$'\n' -rows_fast_signature="v2"$'\n' +rows_signature="v4"$'\n' +rows_fast_signature="v3"$'\n' rows_cacheable=true rows_cache_hit=false image_files=() @@ -133,17 +134,25 @@ else image_files+=("$image") image_signature=$(stat -Lc '%s:%Y' "$image") || continue rows_signature+="$image:$image_signature"$'\n' - done < <(find -L "$dir" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print0 2>/dev/null | sort -z) + done < <(find -L "$dir" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + -print0 2>/dev/null | sort -z) fi done fi +is_video_path() { + [[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]] +} + generate_thumbnail() { local image="$1" local thumbnail="$2" local lock="$thumbnail.lock" local lock_fd local tmp="$thumbnail.$$.jpg" + local thumbnail_command # Older releases used directories as locks, which could survive a killed # generator and block this thumbnail forever. Only reap aged ones, so a @@ -161,13 +170,29 @@ generate_thumbnail() { [[ -f $thumbnail ]] && return - # Callers fan out one generator per image, so keep each vips single-threaded. - # Close the lock fd for vips: an orphaned or hung vips must not keep holding - # the lock after this shell is killed. - if VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]" {lock_fd}>&-; then + # Callers fan out one generator per file, so keep each image conversion + # single-threaded. ffmpegthumbnailer provides a still preview for videos. + if is_video_path "$image"; then + # Videos are generated before the picker opens, so one unreadable or + # stalled file must not hold it shut. A failed run drops the row. + thumbnail_command=(timeout -k 5 10 ffmpegthumbnailer -i "$image" -o "$tmp" -s 1536 -q 8) + else + thumbnail_command=(env VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]") + fi + + # Close the lock fd for the converter: an orphaned or hung child must not + # keep holding the lock after this shell is killed. + if "${thumbnail_command[@]}" {lock_fd}>&-; then mv -f "$tmp" "$thumbnail" else + status=$? rm -f "$tmp" "$thumbnail" + # Remember a video the converter rejected, so it costs nothing on the next + # open. The key covers size and mtime, so a repaired file starts clean. A + # timeout is left to retry: the machine may only have been busy. + if is_video_path "$image" && (( status != 124 && status != 137 )); then + : >"$thumbnail.failed" + fi fi } @@ -186,7 +211,17 @@ thumbnail_for() { thumbnail="$cache_dir/$hash.jpg" if [[ ! -f $thumbnail ]]; then - if [[ $lazy_thumbnails == true && $cache_only != true ]]; then + # A video that already failed to convert has no row to offer. Hand the + # marker back so the caller can keep the rows uncached over its absence. + if is_video_path "$image" && [[ -f $thumbnail.failed ]]; then + printf '%s' "$thumbnail.failed" + return + fi + + # A lazy row stands in with the media file itself, which the picker draws + # with an Image -- fine for a picture, blank for a video. Videos take the + # queue instead, which also keeps them under its narrower fan out. + if [[ $lazy_thumbnails == true && $cache_only != true ]] && ! is_video_path "$image"; then rows_cacheable=false if [[ $prepare_only != true ]]; then @@ -197,19 +232,35 @@ thumbnail_for() { return fi - printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file" + if is_video_path "$image"; then + printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_video_file" + else + printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file" + fi fi printf '%s' "$thumbnail" } -# Generate every queued thumbnail at once; each vips run is single-threaded. +# Each vips run is single-threaded, so still images can fill every core. +# ffmpegthumbnailer leaves FFmpeg's automatic threading on, so a full-width fan +# out of those would put a codec thread pool on every core at once. drain_pending_thumbnails() { - [[ -s $pending_file ]] || return 0 + local video_jobs - export -f generate_thumbnail - xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \ - bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true + export -f generate_thumbnail is_video_path + + if [[ -s $pending_file ]]; then + xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \ + bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true + fi + + if [[ -s $pending_video_file ]]; then + video_jobs=$(( $(nproc) / 4 )) + (( video_jobs > 0 )) || video_jobs=1 + xargs -a "$pending_video_file" -0 -n 2 -P "$video_jobs" \ + bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true + fi } if [[ $rows_cache_hit != true && -f $rows_cache_file && -f $rows_signature_file ]] && cmp -s "$rows_signature_file" <(printf '%s' "$rows_signature"); then @@ -219,6 +270,12 @@ elif [[ $rows_cache_hit != true ]]; then for image in "${image_files[@]}"; do thumbnail=$(thumbnail_for "$image") [[ -n $thumbnail ]] || continue + # Cached rows are trusted on the directory's mtime alone, which a file + # repaired in place never changes. Leave them uncached instead. + if [[ $thumbnail == *.failed ]]; then + rows_cacheable=false + continue + fi if [[ $lazy_thumbnails == true && $cache_only != true && $thumbnail == $image ]]; then rows_cacheable=false fi @@ -232,7 +289,7 @@ elif [[ $rows_cache_hit != true ]]; then drain_pending_thumbnails - if [[ -s $pending_file ]]; then + if [[ -s $pending_file || -s $pending_video_file ]]; then pruned="" while IFS=$'\t' read -r row_image row_thumbnail; do if [[ ! -e $row_thumbnail ]]; then diff --git a/bin/omarchy-menu-keybindings b/bin/omarchy-menu-keybindings index 4269a831..767b5dac 100755 --- a/bin/omarchy-menu-keybindings +++ b/bin/omarchy-menu-keybindings @@ -38,6 +38,12 @@ parse_keycodes() { if (code != "" && symbol != "" && symbol != "NoSymbol") keycode_symbol[code] = toupper(symbol) } + # Read off the keysym rather than the keycode, so the key left of 1 reads + # as the symbol printed on it whichever key the active layout puts it on. + for (code in keycode_symbol) { + if (keycode_symbol[code] == "GRAVE") keycode_symbol[code] = "~" + } + mouse_symbol["272"] = "LEFT MOUSE BUTTON" mouse_symbol["273"] = "RIGHT MOUSE BUTTON" mouse_symbol["274"] = "MIDDLE MOUSE BUTTON" @@ -305,6 +311,7 @@ dynamic_bindings() { case "$key" in comma) key="COMMA" ;; + grave) key="~" ;; period) key="PERIOD" ;; minus) key="MINUS" ;; equal) key="EQUAL" ;; @@ -324,15 +331,41 @@ static_bindings() { echo "SHIFT ALT,D,Download Video from Web App,sendshortcut,SHIFT ALT,D," } +# Actions Omarchy binds to a second chord meant as an alternative, named one at +# a time. A rule would be wrong here: Alt + Tab and Shift + Alt + Tab both say +# "Reveal active window on top" while cycling opposite ways, and a media key is +# nobody's idea of an alternative to a Super chord. Only chords a user reaches +# for interchangeably belong on one row. +alternative_chord_actions() { + cat <<'ACTIONS' +Close window +Calculator +Toggle scratchpad +Move window to scratchpad +ACTIONS +} + # Parse and format keybindings # # `awk` does the heavy lifting: # - Set the field separator to a comma ','. # - Joins the key combination (e.g., "SUPER + Q"). # - Joins the command that the key executes. +# - Puts an action's alternative chord on the row with the first one. # - Prints display text and dispatch metadata as tab-separated fields. parse_binding_records() { - awk -F, ' + awk -F, -v alternatives="$(alternative_chord_actions)" ' +BEGIN { + # The column every row pads its chords to. Nothing is allowed past it: the + # menu renders in monospace, and a row that overruns pushes its arrow out + # of a column the eye reads straight down. + column = 35; + + split(alternatives, named, "\n"); + for (i in named) { + if (named[i] != "") shares_a_row[named[i]] = 1; + } +} { # Combine the modifier and key (first two fields) key_combo = $1 " + " $2; @@ -365,8 +398,35 @@ parse_binding_records() { gsub(/[ \t]+/, " ", key_combo); # Collapse multiple spaces to one } + # An alternative chord joins the row the first one opened: "SUPER + W / + # SUPER + Q -> Close window". Both halves have to agree on what they run, + # since a label is only what a chord is called; an unresolved dispatcher + # says nothing at all, so it never counts as agreement. if (action != "") { - printf "%-35s → %s\t%s\t%s\n", key_combo, action, dispatcher, arg; + action_key = action SUBSEP dispatcher SUBSEP arg; + together = ""; + + if (action in shares_a_row && dispatcher != "" && action_key in leads) { + together = chords[leads[action_key]] " / " key_combo; + } + + if (together != "" && length(together) <= column) { + chords[leads[action_key]] = together; + } else { + # Also the path a pair too wide for the column takes: two rows in + # line beat one that juts out of it. + entries++; + chords[entries] = key_combo; + actions[entries] = action; + dispatchers[entries] = dispatcher; + args[entries] = arg; + leads[action_key] = entries; + } + } +} +END { + for (entry = 1; entry <= entries; entry++) { + printf "%-*s → %s\t%s\t%s\n", column, chords[entry], actions[entry], dispatchers[entry], args[entry]; } }' } @@ -374,7 +434,13 @@ parse_binding_records() { prioritize_entries() { awk -F '\t' ' { + # Alternative chords are display only. Classifying on them would read + # "SUPER SHIFT + RETURN / SUPER SHIFT + B" as the alternate browser chord + # and drop the browser 20 rows down its own list, so match the chord that + # leads the row and ignore the rest. line = $1 + sub(/ \/ [^→]*→/, " →", line) + prio = 50 if (match(line, /Keybindings/)) prio = 0 if (match(line, /Omarchy menu/)) prio = 1 @@ -436,6 +502,12 @@ prioritize_entries() { cut -f2- } +# Drop repeated records while keeping the order Hyprland reported them in, so +# the chord a user declared first is the one that leads a merged entry. +dedupe_binding_records() { + awk '!seen[$0]++' +} + output_binding_records_uncached() { local dynamic @@ -446,7 +518,7 @@ output_binding_records_uncached() { [[ -n $dynamic ]] && printf '%s\n' "$dynamic" static_bindings } | - sort -u | + dedupe_binding_records | parse_keycodes | parse_binding_records | prioritize_entries @@ -458,7 +530,7 @@ output_binding_records_uncached() { keybindings_cache_key() { { - printf 'v11\n' + printf 'v13\n' hyprctl devices 2>/dev/null | grep -F 'active keymap:' hyprctl binds 2>/dev/null } | sha256sum | awk '{ print $1 }' diff --git a/bin/omarchy-migrate b/bin/omarchy-migrate index 056a578d..e64aae32 100755 --- a/bin/omarchy-migrate +++ b/bin/omarchy-migrate @@ -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 diff --git a/bin/omarchy-migrate-notify b/bin/omarchy-migrate-notify index 0ee2e0b0..00cce3d6 100755 --- a/bin/omarchy-migrate-notify +++ b/bin/omarchy-migrate-notify @@ -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. diff --git a/bin/omarchy-mise-install b/bin/omarchy-mise-install index e6dfc96c..356c3324 100755 --- a/bin/omarchy-mise-install +++ b/bin/omarchy-mise-install @@ -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" </dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }') + if ! route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }'); then + route_device="" + fi if [[ -n $route_device && -d /sys/class/net/$route_device/wireless ]]; then interface=$route_device else diff --git a/bin/omarchy-notification-send b/bin/omarchy-notification-send index 8a5851b8..9e7fd049 100755 --- a/bin/omarchy-notification-send +++ b/bin/omarchy-notification-send @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Send an Omarchy desktop notification -# omarchy:args=[--exec ] [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] +# omarchy:args=[--app-name ] [-g ] [-u ] [-i ] [-t ] [-r ] [-p] [--image ] [description] [--exec [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 ] [-g ] [-u ] [-i ] [-t ] [-r ] [-p] [--image ] [description] [--exec [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 ] [--app-name ] [-g ] [-u ] [--image ] [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 [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 "; emit just the id. + out=$("${notify_cmd[@]}") + printf '%s\n' "${out##* }" else - notify-send "${args[@]}" "$headline" + "${notify_cmd[@]}" >/dev/null fi diff --git a/bin/omarchy-openclaw-onboard b/bin/omarchy-openclaw-onboard new file mode 100755 index 00000000..5bac15bd --- /dev/null +++ b/bin/omarchy-openclaw-onboard @@ -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" diff --git a/bin/omarchy-pkg-add b/bin/omarchy-pkg-add index 977d7efd..1f5e4091 100755 --- a/bin/omarchy-pkg-add +++ b/bin/omarchy-pkg-add @@ -7,15 +7,15 @@ if omarchy-pkg-missing "$@"; then if (( EUID == 0 )); then - pacman -S --noconfirm --needed "$@" || exit 1 + pacman -S --noconfirm --needed -- "$@" || exit 1 else - sudo pacman -S --noconfirm --needed "$@" || exit 1 + sudo pacman -S --noconfirm --needed -- "$@" || exit 1 fi fi for pkg in "$@"; do # Secondary check to handle states where pacman doesn't actually register an error - if ! pacman -Q "$pkg" &>/dev/null; then + if ! pacman -Q -- "$pkg" &>/dev/null; then echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2 exit 1 fi diff --git a/bin/omarchy-pkg-aur-add b/bin/omarchy-pkg-aur-add index 7a152be7..64ad097c 100755 --- a/bin/omarchy-pkg-aur-add +++ b/bin/omarchy-pkg-aur-add @@ -4,12 +4,12 @@ # omarchy:args= if omarchy-pkg-missing "$@"; then - yay -S --noconfirm --needed "$@" || exit 1 + yay -S --noconfirm --needed -- "$@" || exit 1 fi for pkg in "$@"; do # Secondary check to handle states where pacman doesn't actually register an error - if ! pacman -Q "$pkg" &>/dev/null; then + if ! pacman -Q -- "$pkg" &>/dev/null; then echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2 exit 1 fi diff --git a/bin/omarchy-pkg-aur-install b/bin/omarchy-pkg-aur-install index dcb7ba1e..dc393b8d 100755 --- a/bin/omarchy-pkg-aur-install +++ b/bin/omarchy-pkg-aur-install @@ -24,6 +24,7 @@ if [[ -n $pkg_names ]]; then source omarchy-sudo-keepalive echo "$pkg_names" | sed 's/^/aur\//' | tr '\n' ' ' | xargs yay -S --noconfirm - sudo updatedb - omarchy-show-done + code=$? + sudo updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots + omarchy-show-done $code fi diff --git a/bin/omarchy-pkg-install b/bin/omarchy-pkg-install index 15e5e288..e5e0fb42 100755 --- a/bin/omarchy-pkg-install +++ b/bin/omarchy-pkg-install @@ -22,5 +22,5 @@ if [[ -n $pkg_names ]]; then # Convert newline-separated selections to space-separated for pacman echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -S --noconfirm - omarchy-show-done + omarchy-show-done $? fi diff --git a/bin/omarchy-pkg-missing b/bin/omarchy-pkg-missing index c4059dff..b282d2f3 100755 --- a/bin/omarchy-pkg-missing +++ b/bin/omarchy-pkg-missing @@ -4,7 +4,7 @@ # omarchy:args= for pkg in "$@"; do - if ! pacman -Q "$pkg" &>/dev/null; then + if ! pacman -Q -- "$pkg" &>/dev/null; then exit 0 fi done diff --git a/bin/omarchy-pkg-present b/bin/omarchy-pkg-present index 8c4340d6..0a0645bd 100755 --- a/bin/omarchy-pkg-present +++ b/bin/omarchy-pkg-present @@ -4,7 +4,7 @@ # omarchy:args= for pkg in "$@"; do - pacman -Q "$pkg" &>/dev/null || exit 1 + pacman -Q -- "$pkg" &>/dev/null || exit 1 done exit 0 diff --git a/bin/omarchy-pkg-remove b/bin/omarchy-pkg-remove index 486d3772..37ca448e 100755 --- a/bin/omarchy-pkg-remove +++ b/bin/omarchy-pkg-remove @@ -20,5 +20,5 @@ pkg_names=$(yay -Qqe | fzf "${fzf_args[@]}") if [[ -n $pkg_names ]]; then # Convert newline-separated selections to space-separated for yay echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -Rns --noconfirm - omarchy-show-done + omarchy-show-done $? fi diff --git a/bin/omarchy-plugin-add b/bin/omarchy-plugin-add index f0bdfca5..d77593e9 100755 --- a/bin/omarchy-plugin-add +++ b/bin/omarchy-plugin-add @@ -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 < " >&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 /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" <"/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 diff --git a/bin/omarchy-provision-user b/bin/omarchy-provision-user index 57024b5e..3bcbf7a8 100755 --- a/bin/omarchy-provision-user +++ b/bin/omarchy-provision-user @@ -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 ~/.gemini/config/skills +mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills ~/.hermes/skills for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do skill=${skill%/} name=${skill##*/} @@ -93,6 +93,14 @@ for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do ln -sfn "$skill" ~/.codex/skills/"$name" ln -sfn "$skill" ~/.pi/agent/skills/"$name" ln -sfn "$skill" ~/.gemini/config/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 diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index 299d6c20..af16a347 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -23,4 +23,4 @@ sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirr omarchy-hook pre-refresh-pacman # Reset all package DBs and then update -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syyuu --noconfirm +omarchy-update-pacman -Syyuu --noconfirm diff --git a/bin/omarchy-refresh-plymouth b/bin/omarchy-refresh-plymouth index 58042dba..fa47ffff 100755 --- a/bin/omarchy-refresh-plymouth +++ b/bin/omarchy-refresh-plymouth @@ -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 diff --git a/bin/omarchy-refresh-sddm b/bin/omarchy-refresh-sddm index d03ddd33..233570a0 100755 --- a/bin/omarchy-refresh-sddm +++ b/bin/omarchy-refresh-sddm @@ -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 diff --git a/bin/omarchy-reinstall-pkgs b/bin/omarchy-reinstall-pkgs index c1d51cbf..97aff9c6 100755 --- a/bin/omarchy-reinstall-pkgs +++ b/bin/omarchy-reinstall-pkgs @@ -11,8 +11,8 @@ set -e omarchy-refresh-pacman # Downgrade any packages to the stable setup -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Suu --noconfirm +omarchy-update-pacman -Suu --noconfirm # Ensure all packages are installed mapfile -t packages < <(grep -v '^#' "$OMARCHY_PATH/install/omarchy-base.packages" | grep -v '^$') -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm --needed "${packages[@]}" +omarchy-update-pacman -Syu --noconfirm --needed "${packages[@]}" diff --git a/bin/omarchy-remove-ai-claude b/bin/omarchy-remove-ai-claude new file mode 100755 index 00000000..3df3c105 --- /dev/null +++ b/bin/omarchy-remove-ai-claude @@ -0,0 +1,23 @@ +#!/bin/bash + +# omarchy:summary=Remove the Claude desktop app along with its configuration and 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 + +# The app rewrites ~/.config/Claude for as long as it runs, so a removal that +# leaves it running deletes the directory and watches it come straight back. +pkill -x claude-desktop || true + +omarchy-pkg-drop claude-desktop + +# Not ~/.claude, ~/.claude.json, or ~/.cache/claude-cli-nodejs: those belong +# to the Claude Code CLI, which ships in a different package that survives +# this one. The desktop app keeps its state in its own Electron directories. +rm -rf \ + "$HOME/.config/Claude" \ + "$HOME/.cache/Claude" + +echo "" +echo "Claude has been removed." diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes new file mode 100755 index 00000000..da8af34c --- /dev/null +++ b/bin/omarchy-remove-ai-hermes @@ -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 diff --git a/bin/omarchy-remove-ai-openclaw b/bin/omarchy-remove-ai-openclaw new file mode 100755 index 00000000..e610cbbe --- /dev/null +++ b/bin/omarchy-remove-ai-openclaw @@ -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 diff --git a/bin/omarchy-remove-ai-perplexity b/bin/omarchy-remove-ai-perplexity new file mode 100755 index 00000000..93859484 --- /dev/null +++ b/bin/omarchy-remove-ai-perplexity @@ -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 diff --git a/bin/omarchy-remove-dev-env b/bin/omarchy-remove-dev-env index a24e7f9e..0d8f584b 100755 --- a/bin/omarchy-remove-dev-env +++ b/bin/omarchy-remove-dev-env @@ -9,7 +9,41 @@ if [[ -z $1 ]]; then exit 1 fi +cleanup_ocaml_sudo_credentials() { + local status=$? + trap - EXIT HUP INT TERM + + if ! /usr/bin/sudo -k >/dev/null 2>&1; then + echo "Failed to invalidate sudo credentials after OCaml removal." >&2 + (( status != 0 )) || status=1 + fi + + exit "$status" +} + +begin_ocaml_sudo_boundary() { + trap cleanup_ocaml_sudo_credentials EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + + /usr/bin/sudo -k >/dev/null || { + echo "Unable to start OCaml removal with a cold sudo credential state." >&2 + exit 1 + } + + if ! /usr/bin/sudo -N -V >/dev/null 2>&1; then + echo "This sudo does not support --no-update; refusing to remove OCaml." >&2 + exit 1 + fi +} + remove_php() { + mise uninstall php --all + mise rm -g php + mise tool-alias unset php + + # PHP from before it moved to mise omarchy-pkg-drop php composer php-sqlite xdebug } @@ -46,7 +80,9 @@ php) ;; laravel) echo -e "Removing Laravel...\n" - composer global remove laravel/installer 2>/dev/null || true + mise x php -- composer global remove laravel/installer 2>/dev/null || true + + rm -f "$HOME/.local/bin/laravel" "$HOME/.config/composer/vendor/bin/laravel" ;; symfony) echo -e "Removing Symfony CLI...\n" @@ -87,10 +123,15 @@ dotnet) mise rm -g dotnet ;; ocaml) + begin_ocaml_sudo_boundary + echo -e "Removing OCaml...\n" opam switch remove default -y 2>/dev/null || true rm -rf ~/.opam 2>/dev/null || true - sudo rm -f /usr/local/bin/opam 2>/dev/null || true + if ! /usr/bin/sudo -N -- /usr/bin/rm -f /usr/local/bin/opam; then + echo "Failed to remove the system opam launcher." >&2 + exit 1 + fi ;; clojure) echo -e "Removing Clojure...\n" diff --git a/bin/omarchy-remove-gaming-retroarch b/bin/omarchy-remove-gaming-retroarch index 2eb2037b..05c8fe89 100755 --- a/bin/omarchy-remove-gaming-retroarch +++ b/bin/omarchy-remove-gaming-retroarch @@ -25,6 +25,7 @@ omarchy-pkg-drop \ libretro-vice-x128-git libretro-vice-x64-git libretro-vice-x64dtv-git libretro-vice-x64sc-git \ libretro-vice-xcbm2-git libretro-vice-xcbm5x0-git libretro-vice-xpet-git \ libretro-vice-xplus4-git libretro-vice-xscpu64-git libretro-vice-xvic-git \ + libretro-database-git \ retroarch-joypad-autoconfig-git rm -rf \ @@ -35,3 +36,4 @@ rm -rf \ echo "" echo "RetroArch and its cores have been removed." echo "ROMs and BIOS files at ~/Games/roms and ~/Games/bios were left in place." + diff --git a/bin/omarchy-remove-preinstalls b/bin/omarchy-remove-preinstalls index 6b2c6ed1..bfb8fe34 100755 --- a/bin/omarchy-remove-preinstalls +++ b/bin/omarchy-remove-preinstalls @@ -15,7 +15,28 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w # Remove mise stubs rm -f ~/.local/bin/codex ~/.local/bin/claude ~/.local/bin/agy ~/.local/bin/copilot \ ~/.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 + ~/.local/bin/omp ~/.local/bin/ori ~/.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 \ diff --git a/bin/omarchy-remove-security-fido2 b/bin/omarchy-remove-security-fido2 index c93b490f..6df388c0 100755 --- a/bin/omarchy-remove-security-fido2 +++ b/bin/omarchy-remove-security-fido2 @@ -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..." diff --git a/bin/omarchy-remove-security-sudoless-docker b/bin/omarchy-remove-security-sudoless-docker new file mode 100755 index 00000000..dd4c6175 --- /dev/null +++ b/bin/omarchy-remove-security-sudoless-docker @@ -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 diff --git a/bin/omarchy-restart-app b/bin/omarchy-restart-app index d250650d..e7d7ce5d 100755 --- a/bin/omarchy-restart-app +++ b/bin/omarchy-restart-app @@ -3,5 +3,5 @@ # omarchy:summary=Restart an application by killing it and relaunching via uwsm. # omarchy:args= [application-args...] -pkill -x $1 +pkill -x -- "$1" setsid uwsm-app -- "$@" >/dev/null 2>&1 & diff --git a/bin/omarchy-restart-shell b/bin/omarchy-restart-shell index 8211a1e6..dfc21724 100755 --- a/bin/omarchy-restart-shell +++ b/bin/omarchy-restart-shell @@ -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, diff --git a/bin/omarchy-setup-security-fido2 b/bin/omarchy-setup-security-fido2 index 8513968e..85fe2039 100755 --- a/bin/omarchy-setup-security-fido2 +++ b/bin/omarchy-setup-security-fido2 @@ -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" diff --git a/bin/omarchy-setup-security-fingerprint b/bin/omarchy-setup-security-fingerprint index dd4f428f..a57dea4a 100755 --- a/bin/omarchy-setup-security-fingerprint +++ b/bin/omarchy-setup-security-fingerprint @@ -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 </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" diff --git a/bin/omarchy-setup-security-sshd b/bin/omarchy-setup-security-sshd index 155d6000..7b93936f 100755 --- a/bin/omarchy-setup-security-sshd +++ b/bin/omarchy-setup-security-sshd @@ -143,6 +143,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 @@ -161,5 +205,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)" diff --git a/bin/omarchy-setup-security-sudoless-docker b/bin/omarchy-setup-security-sudoless-docker new file mode 100755 index 00000000..4577cbf1 --- /dev/null +++ b/bin/omarchy-setup-security-sudoless-docker @@ -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 diff --git a/bin/omarchy-show-done b/bin/omarchy-show-done index 60741076..14c9f156 100755 --- a/bin/omarchy-show-done +++ b/bin/omarchy-show-done @@ -1,6 +1,25 @@ #!/bin/bash -# omarchy:summary=Display a "Done!" message with a spinner and wait for user to press any key. +# omarchy:summary=Display a "Done!" or "Failed!" message and wait for user to press any key. +# omarchy:args=[exit-code] -echo -gum spin --spinner "globe" --title "Done! Press any key to close..." -- bash -c 'read -n 1 -s' +code="${1:-0}" + +# 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 +else + printf '\n\033[31m● \033[0mFailed (exit code %d)! Press any key to close...' "$code" >/dev/tty +fi +read -rsn 1 /dev/tty diff --git a/bin/omarchy-state b/bin/omarchy-state index 4fda5b3a..3b2f5e17 100755 --- a/bin/omarchy-state +++ b/bin/omarchy-state @@ -21,6 +21,19 @@ if [[ -z $STATE_NAME ]]; then fi case "$COMMAND" in -set) touch "$STATE_DIR/$STATE_NAME" ;; +set) + # State names are fixed labels (reboot-required, restart-*-required). The + # name becomes a filename under the state directory. A slash would turn it + # into directory levels, and a bare `.` or `..` would touch the directory + # itself or its parent. Refuse those. Dots inside a name (a..b) are fine; + # once slashes are out, only the whole name being `.` or `..` can leave the + # directory. clear needs no such guard: find -name matches basenames only, + # so a pattern can never walk out of the directory. + if [[ $STATE_NAME == */* || $STATE_NAME == "." || $STATE_NAME == ".." ]]; then + echo "Invalid state name: $STATE_NAME" >&2 + exit 2 + fi + touch "$STATE_DIR/$STATE_NAME" + ;; clear) find "$STATE_DIR" -maxdepth 1 -type f -name "$STATE_NAME" -delete ;; esac diff --git a/bin/omarchy-sudo-docker b/bin/omarchy-sudo-docker new file mode 100755 index 00000000..19399cc4 --- /dev/null +++ b/bin/omarchy-sudo-docker @@ -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 diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index ad157ae9..719d881b 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -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 diff --git a/bin/omarchy-sudo-reset b/bin/omarchy-sudo-reset deleted file mode 100755 index 56d91a8d..00000000 --- a/bin/omarchy-sudo-reset +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Reset the sudo lockout/faillock for the current user. - -su -c "faillock --reset --user $USER" diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 51eca401..43348e5a 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -297,19 +297,34 @@ rebuild_next_boot() { umount "$next$esp_mount" } +# Both the staged system and the retained baseline must lose the old hashes. +scrub_factory_accounts() { + local root="$1" user users + + users=$(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$root/etc/passwd") || return 1 + for user in $users; do + userdel --root "$root" "$user" 2>>"$LOG_FILE" || return 1 + rm -rf "${root:?}/home/$user" || return 1 + done + + # passwd --lock preserves the hash. Replace it, then remove the backups + # that userdel and usermod leave behind. + usermod --root "$root" --password '!' root >>"$LOG_FILE" 2>&1 || return 1 + rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-,subuid-,subgid-} +} + # Remove the seller's account material and machine identity from the retained # @factory baseline so it can neither be mounted for recovery nor restore the # seller's account on a future reset. Idempotent (a scrubbed baseline has no # uid>=1000 accounts left to remove). sanitize_factory_baseline() { - local factory="$1" user + local factory="$1" btrfs property set -ts "$factory" ro false - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$factory/etc/passwd"); do - userdel --root "$factory" "$user" 2>>"$LOG_FILE" || true - rm -rf "${factory:?}/home/$user" - done - passwd --root "$factory" --lock root >>"$LOG_FILE" 2>&1 || true + if ! scrub_factory_accounts "$factory"; then + btrfs property set -ts "$factory" ro true + fail "could not remove account credentials from the factory baseline (see $LOG_FILE)" + fi rm -f "$factory"/etc/ssh/ssh_host_* rm -f "$factory"/etc/NetworkManager/system-connections/* rm -rf "$factory"/var/lib/NetworkManager/* "$factory/var/lib/tailscale" "$factory/var/lib/iwd" @@ -337,17 +352,13 @@ stage_full_reset() { rm -rf "$next"/var/lib/NetworkManager/* "$next/var/lib/tailscale" "$next/var/lib/iwd" rm -f "$next/var/lib/sddm/state.conf" "$next/etc/sddm.conf.d/autologin.conf" - # A factory snapshot from a normal (normal) install contains the original + # A factory snapshot from a normal install contains the original # user account; first-boot setup must start from none. A leftover account # would keep its password hash and group memberships (including wheel), so # failure here has to abort the reset, not be shrugged off. - local user - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$next/etc/passwd"); do - log "Removing user $user from the factory system" - userdel --root "$next" "$user" 2>>"$LOG_FILE" || - fail "could not remove user $user from the factory system (see $LOG_FILE)" - done - passwd --root "$next" --lock root >>"$LOG_FILE" 2>&1 || true + log "Removing account credentials from the factory system" + scrub_factory_accounts "$next" || + fail "could not remove account credentials from the factory system (see $LOG_FILE)" # @factory itself survives the wipe as the baseline for future resets. If it # came from a normal install it still holds the seller's account and diff --git a/bin/omarchy-tailscale-receive b/bin/omarchy-tailscale-receive index 6da2b125..3f27641c 100755 --- a/bin/omarchy-tailscale-receive +++ b/bin/omarchy-tailscale-receive @@ -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() { diff --git a/bin/omarchy-theme-bg-next b/bin/omarchy-theme-bg-next index dfb3d3f0..f9907d7a 100755 --- a/bin/omarchy-theme-bg-next +++ b/bin/omarchy-theme-bg-next @@ -10,7 +10,8 @@ CURRENT_BACKGROUND_LINK="$HOME/.local/state/omarchy/current/background" mapfile -d '' -t BACKGROUNDS < <( find -L "$USER_BACKGROUNDS_PATH" "$THEME_BACKGROUNDS_PATH" -maxdepth 1 -type f \ - \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ -print0 2>/dev/null | sort -z ) TOTAL=${#BACKGROUNDS[@]} diff --git a/bin/omarchy-theme-bg-set b/bin/omarchy-theme-bg-set index 45b420df..835321d7 100755 --- a/bin/omarchy-theme-bg-set +++ b/bin/omarchy-theme-bg-set @@ -1,11 +1,11 @@ #!/bin/bash -# omarchy:summary=Set the current background image -# omarchy:args= +# omarchy:summary=Set the current background image or video +# omarchy:args= # omarchy:examples=omarchy theme bg set ~/Pictures/background.png if [[ -z $1 ]]; then - echo "Usage: omarchy-theme-bg-set " >&2 + echo "Usage: omarchy-theme-bg-set " >&2 exit 1 fi @@ -17,7 +17,7 @@ if [[ ! -f $BACKGROUND ]]; then exit 1 fi -# Create symlink to the new background +# Create symlink to the new background media ln -nsf "$BACKGROUND" "$CURRENT_BACKGROUND_LINK" # Update the live shell background immediately when it is running. The diff --git a/bin/omarchy-theme-extras b/bin/omarchy-theme-extras new file mode 100755 index 00000000..7cad2c11 --- /dev/null +++ b/bin/omarchy-theme-extras @@ -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 diff --git a/bin/omarchy-theme-install b/bin/omarchy-theme-install index cf6a9df6..b324fe55 100755 --- a/bin/omarchy-theme-install +++ b/bin/omarchy-theme-install @@ -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" diff --git a/bin/omarchy-theme-remove b/bin/omarchy-theme-remove index 56ccddf7..7e58289f 100755 --- a/bin/omarchy-theme-remove +++ b/bin/omarchy-theme-remove @@ -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 diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index 2637ac68..e440ee43 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -19,6 +19,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=() @@ -37,12 +50,17 @@ shell_ipc() { timeout 2 omarchy-shell "$@" >/dev/null 2>&1 } +is_video_path() { + [[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]] +} + snapshot_background_path() { local background="$1" local name="$2" local snapshot extension [[ -f $background ]] || return + is_video_path "$background" && return mkdir -p "$BACKGROUND_TRANSITION_CACHE" extension=${background##*.} @@ -76,14 +94,25 @@ remember_current_theme_background() { printf '%s\n' "$current_background" >"$state_file" } +background_transition_uses_snapshots() { + local next_background="$1" + local current_background + + current_background=$(readlink -f "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) + ! is_video_path "$current_background" && ! is_video_path "$next_background" +} + choose_theme_background() { + local theme_path="${1:-$CURRENT_THEME_PATH}" + local current_theme_backgrounds="$CURRENT_THEME_PATH/backgrounds" local backgrounds=() local current_background remembered_background state_file index next_index i CHOSEN_THEME_BACKGROUND="" mapfile -d '' -t backgrounds < <( - find -L "$HOME/.config/omarchy/backgrounds/$THEME_NAME/" "$CURRENT_THEME_PATH/backgrounds/" -maxdepth 1 -type f \ - \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \ + find -L "$HOME/.config/omarchy/backgrounds/$THEME_NAME/" "$theme_path/backgrounds/" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ -print0 2>/dev/null | sort -z ) @@ -91,6 +120,10 @@ choose_theme_background() { if [[ $PREVIOUS_THEME_NAME == "$THEME_NAME" ]]; then current_background=$(readlink "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) + if [[ $theme_path != $CURRENT_THEME_PATH && ${current_background%/*} == $current_theme_backgrounds ]]; then + current_background="$theme_path/backgrounds/${current_background##*/}" + fi + index=-1 for i in "${!backgrounds[@]}"; do if [[ ${backgrounds[$i]} == $current_background ]]; then @@ -110,6 +143,9 @@ choose_theme_background() { state_file=$(theme_background_state_file "$THEME_NAME" 2>/dev/null || true) remembered_background=$(cat "$state_file" 2>/dev/null || true) + if [[ $theme_path != $CURRENT_THEME_PATH && ${remembered_background%/*} == $current_theme_backgrounds ]]; then + remembered_background="$theme_path/backgrounds/${remembered_background##*/}" + fi if [[ -f $remembered_background ]]; then CHOSEN_THEME_BACKGROUND="$remembered_background" return @@ -118,6 +154,16 @@ choose_theme_background() { CHOSEN_THEME_BACKGROUND="${backgrounds[0]}" } +choose_staged_theme_background() { + local next_theme_backgrounds="$NEXT_THEME_PATH/backgrounds" + + choose_theme_background "$NEXT_THEME_PATH" || return 1 + + if [[ ${CHOSEN_THEME_BACKGROUND%/*} == $next_theme_backgrounds ]]; then + CHOSEN_THEME_BACKGROUND="$CURRENT_THEME_PATH/backgrounds/${CHOSEN_THEME_BACKGROUND##*/}" + fi +} + set_theme_background_link() { choose_theme_background || return 1 ln -nsf "$CHOSEN_THEME_BACKGROUND" "$CURRENT_BACKGROUND_LINK" @@ -126,14 +172,19 @@ set_theme_background_link() { set_theme_background() { local new_background new_background_snapshot - if ! choose_theme_background; then - omarchy-notification-send "No background was found for theme" -t 2000 - shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true - return + if [[ -z $CHOSEN_THEME_BACKGROUND || ! -f $CHOSEN_THEME_BACKGROUND ]]; then + if ! choose_theme_background; then + omarchy-notification-send "No background was found for theme" -t 2000 + shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true + return + fi fi new_background="$CHOSEN_THEME_BACKGROUND" - new_background_snapshot=$(snapshot_background_path "$new_background" "next") + new_background_snapshot="" + if [[ $BACKGROUND_TRANSITION_SNAPSHOTS == "true" ]]; then + new_background_snapshot=$(snapshot_background_path "$new_background" "next") + fi if [[ -f $OLD_BACKGROUND_SNAPSHOT && -f $new_background_snapshot ]]; then shell_ipc background themeTransition "$OLD_BACKGROUND_SNAPSHOT" "$new_background_snapshot" "$new_background" "$colors_payload" "$shell_payload" || \ @@ -154,12 +205,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 @@ -178,9 +338,15 @@ remember_current_theme_background 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 @@ -190,9 +356,19 @@ fi # Generate dynamic configs omarchy-theme-set-templates +CHOSEN_THEME_BACKGROUND="" OLD_BACKGROUND_SNAPSHOT="" +BACKGROUND_TRANSITION_SNAPSHOTS=true if [[ $THEME_HEADLESS != "1" && $OMARCHY_THEME_SKIP_BACKGROUND != "1" ]]; then - OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background) + # Resolve the staged choice while the old theme still exists. Video changes + # switch directly to the durable path in QML, so neither side needs a copy. + if choose_staged_theme_background; then + if background_transition_uses_snapshots "$CHOSEN_THEME_BACKGROUND"; then + OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background) + else + BACKGROUND_TRANSITION_SNAPSHOTS=false + fi + fi fi # Swap next theme in as current @@ -233,6 +409,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 diff --git a/bin/omarchy-theme-set-browser b/bin/omarchy-theme-set-browser index 4dd7592f..612e586f 100755 --- a/bin/omarchy-theme-set-browser +++ b/bin/omarchy-theme-set-browser @@ -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" diff --git a/bin/omarchy-theme-set-browser-policy b/bin/omarchy-theme-set-browser-policy new file mode 100755 index 00000000..ccd07722 --- /dev/null +++ b/bin/omarchy-theme-set-browser-policy @@ -0,0 +1,123 @@ +#!/bin/bash + +# omarchy:summary=Write the current theme color into the browser policy directories +# omarchy:args= +# 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 " >&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" diff --git a/bin/omarchy-theme-set-hermes b/bin/omarchy-theme-set-hermes new file mode 100755 index 00000000..c73b24c7 --- /dev/null +++ b/bin/omarchy-theme-set-hermes @@ -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 diff --git a/bin/omarchy-theme-set-t3code b/bin/omarchy-theme-set-t3code new file mode 100755 index 00000000..7b6f69bf --- /dev/null +++ b/bin/omarchy-theme-set-t3code @@ -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" diff --git a/bin/omarchy-theme-set-vscode b/bin/omarchy-theme-set-vscode index c87b6bb8..65178099 100755 --- a/bin/omarchy-theme-set-vscode +++ b/bin/omarchy-theme-set-vscode @@ -68,8 +68,17 @@ install_generated_extension() { ui_theme="vs-dark" fi + # VS Code caches loaded color themes in memory and only rebuilds a registry + # entry when the providing extension changes, so derive the version from the + # theme content: identical palettes keep the cache, new palettes invalidate it. + GENERATED_EXTENSION_VERSION="1.0.$(cksum "$GENERATED_THEME" | cut -d' ' -f1)" + mkdir -p "$ext_dir/themes" - ln -sfn "$GENERATED_THEME" "$ext_dir/themes/omarchy-color-theme.json" + # A real file rather than a symlink into current/theme: the _watch watcher + # below needs a same-inode rewrite to fire, and a symlink would dangle while + # omarchy-theme-set swaps current/theme via rm + mv. + rm -f "$ext_dir/themes/omarchy-color-theme.json" + cat "$GENERATED_THEME" >"$ext_dir/themes/omarchy-color-theme.json" cat > "$ext_dir/package.json" </dev/null) if [[ -n $preview ]]; then @@ -32,7 +32,10 @@ find_preview() { done if [[ -d $theme_path/backgrounds ]]; then - find -L "$theme_path/backgrounds" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print 2>/dev/null | sort | head -n 1 + find -L "$theme_path/backgrounds" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + -print 2>/dev/null | sort | head -n 1 fi } @@ -48,7 +51,7 @@ add_theme_preview() { ln -s "$preview" "$preview_dir/$theme_name.$extension" } -fast_signature="v1"$'\n' +fast_signature="v2"$'\n' for theme_dir in "$USER_THEMES_PATH" "$OMARCHY_THEMES_PATH"; do if [[ -d $theme_dir ]]; then fast_signature+="$theme_dir:$(stat -Lc '%Y' "$theme_dir")"$'\n' @@ -104,7 +107,7 @@ fi current_theme=$(cat "$HOME/.local/state/omarchy/current/theme.name" 2>/dev/null) selected_preview="" -for extension in png jpg jpeg webp gif bmp; do +for extension in png jpg jpeg webp gif bmp mp4 m4v mov webm mkv avi; do if [[ -e $preview_dir/$current_theme.$extension ]]; then selected_preview="$preview_dir/$current_theme.$extension" break diff --git a/bin/omarchy-theme-update b/bin/omarchy-theme-update index a68cb176..5a700ca8 100755 --- a/bin/omarchy-theme-update +++ b/bin/omarchy-theme-update @@ -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 diff --git a/bin/omarchy-toggle-bar b/bin/omarchy-toggle-bar index f76fe049..be18a647 100755 --- a/bin/omarchy-toggle-bar +++ b/bin/omarchy-toggle-bar @@ -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 diff --git a/bin/omarchy-toggle-fullscreen-desktop b/bin/omarchy-toggle-fullscreen-desktop new file mode 100755 index 00000000..b5543f85 --- /dev/null +++ b/bin/omarchy-toggle-fullscreen-desktop @@ -0,0 +1,27 @@ +#!/bin/bash + +# omarchy:summary=Toggle a full screen desktop: hide the top bar and remove the window gaps together +# omarchy:args=[toggle|on|off] +# omarchy:examples=omarchy toggle fullscreen desktop | omarchy toggle fullscreen desktop off | omarchy toggle fullscreen desktop on + +ACTION="${1:-toggle}" + +case $ACTION in + toggle|"") + # Only leave full screen when both halves are already in it, so a lone bar + # or a lone gap toggle is pulled into line rather than flipped away. + if omarchy-toggle-enabled bar-off && omarchy-hyprland-toggle-enabled window-no-gaps; then + ACTION="off" + else + ACTION="on" + fi + ;; + on|off) ;; + *) + echo "Usage: omarchy-toggle-fullscreen-desktop [toggle|on|off]" >&2 + exit 1 + ;; +esac + +omarchy-toggle-bar "$ACTION" +omarchy-hyprland-toggle window-no-gaps "$ACTION" diff --git a/bin/omarchy-toggle-hybrid-gpu b/bin/omarchy-toggle-hybrid-gpu index aa7c0582..835c007b 100755 --- a/bin/omarchy-toggle-hybrid-gpu +++ b/bin/omarchy-toggle-hybrid-gpu @@ -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 diff --git a/bin/omarchy-toggle-input-device b/bin/omarchy-toggle-input-device index aea0ca11..77ff084a 100755 --- a/bin/omarchy-toggle-input-device +++ b/bin/omarchy-toggle-input-device @@ -7,44 +7,70 @@ KIND="${1:-}" ACTION="${2:-toggle}" +usage() { + echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 +} + case "$KIND" in touchpad) LABEL="Touchpad" ICON="touchpad" ;; touchscreen) LABEL="Touchscreen" ICON="touch" ;; *) - echo "Usage: omarchy-toggle-input-device [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 [on|off|toggle]" >&2 + usage exit 1 ;; esac diff --git a/bin/omarchy-update b/bin/omarchy-update index e71e8086..1738ebff 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -1,6 +1,7 @@ #!/bin/bash # omarchy:summary=Update Omarchy and system packages +# omarchy:alias=omarchy up # omarchy:args=[-y] # omarchy:examples=omarchy update | omarchy update -y # omarchy:requires-sudo=true diff --git a/bin/omarchy-update-confirm b/bin/omarchy-update-confirm index 472927f1..c3ca37e6 100755 --- a/bin/omarchy-update-confirm +++ b/bin/omarchy-update-confirm @@ -8,7 +8,7 @@ gum style --border normal --padding "1 2" \ "• You cannot stop the update once you start!" \ "• Make sure you're connected to power or have a full battery" \ "" \ - "What's new: https://github.com/basecamp/omarchy/releases/latest" + "What's new: https://github.com/omacom/omarchy/releases/latest" echo diff --git a/bin/omarchy-update-keyring b/bin/omarchy-update-keyring index d561e22a..3a1c1c17 100755 --- a/bin/omarchy-update-keyring +++ b/bin/omarchy-update-keyring @@ -3,6 +3,11 @@ # omarchy:summary=Ensure the Omarchy and Arch keyring packages are installed and populated # omarchy:requires-sudo=true +# omarchy-update runs this under set -e as a trusted pre-step, so a failed recv +# or a broken keyring has to stop this script here, not surface later as +# signature errors in the middle of the main transaction. +set -euo pipefail + if omarchy-pkg-missing omarchy-keyring || ! sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 &>/dev/null; then sudo pacman-key --recv-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 --keyserver keys.openpgp.org sudo pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571 @@ -19,4 +24,8 @@ fi # Always reinstall, as the keyring can be updated without a package version bump. echo -e "\e[32m\nUpdate Arch signing keys\e[0m" sudo pacman -Sy --noconfirm archlinux-keyring >/dev/null 2> >(grep -vE '^warning: archlinux-keyring-[^ ]+ is up to date -- reinstalling$' >&2) + +# Say "correct" only once the key verifiably is: before the failure checks +# above, a failed recv or reinstall still ended here with exit 0. +sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 >/dev/null echo "Keys are correct" diff --git a/bin/omarchy-update-pacman b/bin/omarchy-update-pacman new file mode 100755 index 00000000..66f66731 --- /dev/null +++ b/bin/omarchy-update-pacman @@ -0,0 +1,25 @@ +#!/bin/bash + +# omarchy:summary=Run a pacman transaction for the Omarchy update flow, shielded from desktop session teardown. +# omarchy:args= +# omarchy:hidden=true +# omarchy:requires-sudo=true + +# Upgrading systemd runs its post_upgrade scriptlet mid-transaction, which +# reexecs both the system manager and every user manager. A pacman running +# inside a user-session scope can be SIGKILLed by that reexec, abandoning the +# transaction halfway. Registering the transaction as a PID 1 scope keeps it +# out of the user manager's cgroups entirely, and system scopes survive the +# system manager's own reexec. +scope=() +if [[ -d /run/systemd/system && ! -L /run/systemd/system ]]; then + scope=(systemd-run --scope --quiet --collect) +fi + +# LC_ALL passes through so callers that parse pacman's stderr can pin the locale. +env_args=(OMARCHY_UPDATE_PACMAN=1) +if [[ -n ${LC_ALL:-} ]]; then + env_args+=(LC_ALL="$LC_ALL") +fi + +exec sudo env "${env_args[@]}" "${scope[@]}" pacman "$@" diff --git a/bin/omarchy-update-system-pkgs b/bin/omarchy-update-system-pkgs index 9e6f9998..c2e86388 100755 --- a/bin/omarchy-update-system-pkgs +++ b/bin/omarchy-update-system-pkgs @@ -10,7 +10,7 @@ set -e # a stream, because an upgrade without --noconfirm prompts on stderr. The # handler has already said why, so no heading here either. if [[ ${OMARCHY_UPDATE_CONFLICT:-} == 1 && ${OMARCHY_UPDATE_INTERACTIVE:-} == 1 ]]; then - exec sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --overwrite '/usr/share/omarchy/*' + exec omarchy-update-pacman -Syu --overwrite '/usr/share/omarchy/*' fi echo -e "\e[32m\nUpdate system packages\e[0m" @@ -23,7 +23,7 @@ trap 'rm -f "$errors"' EXIT # # Progress bars stay on stdout. Errors are on stderr, kept for the conflict # handler below; LC_ALL=C is what keeps them parseable in any locale. -if sudo env LC_ALL=C OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm \ +if LC_ALL=C omarchy-update-pacman -Syu --noconfirm \ --overwrite '/usr/share/omarchy/*' 2>"$errors"; then cat "$errors" >&2 exit 0 diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 37c09c04..86f1efce 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -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 </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 </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 </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 @@ -2049,7 +2079,10 @@ for file in \ done ln -snf "$HOME/.local/state/omarchy/current/theme/btop.theme" "$HOME/.config/btop/themes/current.theme" if [[ ! -e $HOME/.local/state/omarchy/current/background && -d $HOME/.local/state/omarchy/current/theme/backgrounds ]]; then - background=$(find "$HOME/.local/state/omarchy/current/theme/backgrounds" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) | sort | head -n1) + background=$(find "$HOME/.local/state/omarchy/current/theme/backgrounds" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + | sort | head -n1) [[ -n ${background:-} ]] && ln -snf "$background" "$HOME/.local/state/omarchy/current/background" fi @@ -2306,6 +2339,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 +2378,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 diff --git a/bin/omarchy-version-channel b/bin/omarchy-version-channel index 3ed4cbc3..dcd3dd61 100755 --- a/bin/omarchy-version-channel +++ b/bin/omarchy-version-channel @@ -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 diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index acfdf858..e07354ba 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -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" <>"$DESKTOP_FILE" + printf 'MimeType=%s\n' "$(desktop_string_escape "$MIME_TYPES")" >>"$DESKTOP_FILE" fi chmod +x "$DESKTOP_FILE" diff --git a/bin/omarchy-webapp-remove b/bin/omarchy-webapp-remove index 9cf72c38..b3244637 100755 --- a/bin/omarchy-webapp-remove +++ b/bin/omarchy-webapp-remove @@ -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 diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 793c1969..6c8204da 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -4,11 +4,1128 @@ # omarchy:args= [options] # omarchy:requires-sudo=true -COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" +# The Windows VM runs a privileged container (KVM, /dev/net/tun, NET_ADMIN), so +# it needs the root-owned Docker daemon. By default the user is NOT in the docker +# group (that group is root-equivalent), so Docker access is gated behind a +# single polkit prompt. If the user opted into sudoless Docker +# (omarchy-setup-security-sudoless-docker), the socket is reachable directly and +# no prompt appears. +# +# The compose file lives in a root-owned directory and is only ever written by +# the elevated, input-validated write_compose action below. That is the whole +# point: a root-invoked `docker compose up` must never consume a file that a +# process running as the user could have rewritten to bind-mount / into the +# container. Earlier versions kept it under ~/.config/windows, which a rogue +# process could edit and then trigger a privileged bring-up of — a file-swap +# path to root. Do not move it back under $HOME. + +RUNTIME_DIR="${OMARCHY_WINDOWS_DIR:-/var/lib/omarchy/windows}" +COMPOSE_FILE="$RUNTIME_DIR/docker-compose.yml" +LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" +# The guest password lives in the root-owned compose (readable by root and the +# docker group), but RDP needs it as the user. Keep a private copy here, 0600 in +# the user's own config, so the plaintext password is never world-readable. +CREDENTIALS_FILE="$HOME/.config/windows/credentials" +IMAGE="dockurr/windows" +CONTAINER="omarchy-windows" +VM_LOCK_DIR=/run/lock/omarchy-windows-vm +# Removal is the only path that recursively proves there are no bind aliases. +# Bound every metadata walk so a large or hostile caller tree fails closed +# instead of hanging a privileged action indefinitely. +TREE_SCAN_TIMEOUT_SECONDS=5 +TREE_SCAN_KILL_AFTER_SECONDS=1 +TREE_SCAN_TIMEOUT=/usr/bin/timeout +TREE_SCAN_FIND=/usr/bin/find + +# The privileged process must not resolve mount, stat, Docker, or any other +# helper from a caller-controlled PATH, and validation must not change with an +# inherited locale. pkexec normally sanitizes both; pin them here as defense in +# depth for every direct __priv entry as well. +if ((EUID == 0)); then + export PATH=/usr/bin:/usr/sbin:/bin:/sbin + export LC_ALL=C.UTF-8 +fi + +# --- privilege helpers ------------------------------------------------------- + +# True when this session can reach the Docker socket directly (sudoless Docker +# on and in effect). Asking about the socket rather than the configured groups +# keeps the prompt in place through the window where sudoless Docker is enabled +# but the reboot that grants the group has not happened yet. +docker_needs_sudo() { omarchy-sudo-docker; } + +# The command to hand pkexec for the privileged re-exec. pkexec runs whatever +# executable it is given (after authorization) and only shows the path in the +# prompt — it does NOT require the target to be root-owned. So resolve to the +# packaged command and refuse to elevate anything a non-root user could have +# written: a PATH-injected shim, or a user-owned dev checkout. Without this, a +# prompt the user grants for the trusted helper could run an attacker's binary +# as root. Fails closed (empty output) when no trustworthy target is found. +priv_target() { + local candidate=/usr/bin/omarchy-windows-vm canonical probe owner mode + [[ -f $candidate && ! -L $candidate && -x $candidate ]] || return 1 + canonical=$(realpath -e -- "$candidate" 2>/dev/null) || return 1 + [[ $canonical == "$candidate" ]] || return 1 + probe=$candidate + while :; do + owner=$(stat -Lc '%u' "$probe" 2>/dev/null) || return 1 + mode=$(stat -Lc '%a' "$probe" 2>/dev/null) || return 1 + [[ $owner == 0 ]] && ! ((8#$mode & 022)) || return 1 + [[ $probe == / ]] && break + probe=$(dirname -- "$probe") + done + printf '%s\n' "$candidate" +} + +# Run a privileged VM action. write_compose always elevates (the compose is +# root-owned); the daemon operations run directly when sudoless Docker is on and +# otherwise behind a polkit prompt. The stock org.freedesktop.policykit.exec +# policy is auth_admin (not auth_admin_keep), so each elevated action prompts: +# a launch asks once to start and, unless authorization is still cached by the +# agent, again to stop. +priv() { + local action="$1" + shift + if [[ $action != write_compose && $action != remove ]] && ! docker_needs_sudo; then + # Bring-up normally runs directly for a docker-group user, but recreating + # the protected bind anchors after reboot (or migrating an old compose) + # still needs one privileged invocation. + if [[ -d $VM_LOCK_DIR && ! -L $VM_LOCK_DIR && -r $VM_LOCK_DIR && -x $VM_LOCK_DIR ]] && { + [[ $action != up && $action != up_wait ]] || { + ! compose_needs_security_migration && mounts_ready >/dev/null 2>&1 + } + }; then + with_vm_lock "__priv_$action" "$@" + return + fi + fi + local target + target=$(priv_target) || { + echo "omarchy-windows-vm: refusing to run a non-root-owned command as root" >&2 + return 1 + } + pkexec "$target" __priv "$action" "$@" +} + +dc() { docker-compose -f "$COMPOSE_FILE" "$@"; } + +with_vm_lock() { + local fd rc=0 + if ((EUID == 0)); then + assert_boundary_dir /run 0 && assert_boundary_dir /run/lock 0 || return 1 + if getent group docker >/dev/null 2>&1; then + install -d -o root -g docker -m 0750 -- "$VM_LOCK_DIR" || return 1 + else + install -d -o root -g root -m 0700 -- "$VM_LOCK_DIR" || return 1 + fi + fi + assert_boundary_dir "$VM_LOCK_DIR" 0 || return 1 + # Flock the directory inode itself. Concurrent first callers may both run + # install -d, but mkdir is atomic and they necessarily open the same stable + # inode below the root-owned /run/lock parent. + exec {fd}<"$VM_LOCK_DIR/." || return 1 + flock -x "$fd" || { exec {fd}<&-; return 1; } + "$@" || rc=$? + flock -u "$fd" || rc=1 + exec {fd}<&- + return "$rc" +} + +# --- validation (shared by the user-side prompts and the root-side writer) ---- + +valid_ram() { [[ $1 =~ ^[0-9]{1,3}G$ ]]; } +valid_cores() { [[ $1 =~ ^[0-9]{1,2}$ ]] && ((10#$1 >= 1)); } +valid_disk() { [[ $1 =~ ^[0-9]{1,4}G$ ]]; } +valid_username() { [[ $1 =~ ^[A-Za-z0-9_-]{1,20}$ ]]; } +valid_tz() { [[ $1 =~ ^[A-Za-z0-9_/.+-]{1,64}$ ]]; } +valid_password() { [[ $1 =~ ^[[:print:]]{1,64}$ ]]; } + +# The only privileged sub-actions __priv may dispatch. A bash command name +# containing a slash is executed as a path, so validating the action here — not +# just interpolating it into "__priv_${action}" — is what stops an action like +# ../tmp/evil from running an arbitrary file as root. +valid_priv_action() { + case "$1" in + write_compose | up | up_wait | down | status | remove) return 0 ;; + *) return 1 ;; + esac +} + +# --- privileged actions (run as root via pkexec, or directly when sudoless) --- + +# Resolve the account that authorized pkexec. Never trust HOME or a caller- +# supplied mount path in the privileged process: pkexec can reset HOME, and the +# old path arguments were the source of an arbitrary host bind-mount primitive. +resolve_caller() { + local entry canonical parent owner mode + + if ((EUID == 0)); then + [[ ${PKEXEC_UID:-} =~ ^[0-9]{1,10}$ ]] && ((10#$PKEXEC_UID > 0)) || { + echo "omarchy-windows-vm: cannot identify the user who authorized this action" >&2 + return 1 + } + CALLER_UID=$((10#$PKEXEC_UID)) + else + CALLER_UID=$(id -u) + fi + + entry=$(getent passwd "$CALLER_UID") || { + echo "omarchy-windows-vm: no account exists for uid $CALLER_UID" >&2 + return 1 + } + IFS=: read -r _ _ _ CALLER_GID _ CALLER_HOME _ <<<"$entry" + [[ $CALLER_GID =~ ^[0-9]+$ && $CALLER_HOME == /* && -d $CALLER_HOME ]] || { + echo "omarchy-windows-vm: invalid home directory for uid $CALLER_UID" >&2 + return 1 + } + + # A direct, non-root development invocation with a non-standard runtime has + # no privilege boundary and may use its current HOME (which also keeps these + # functions testable). Production always uses the account database value. + if ((EUID != 0)) && [[ $RUNTIME_DIR != /var/lib/omarchy/windows ]]; then + CALLER_HOME=${HOME:-$CALLER_HOME} + fi + canonical=$(realpath -e -- "$CALLER_HOME" 2>/dev/null) || return 1 + [[ $canonical == "$CALLER_HOME" ]] || { + echo "omarchy-windows-vm: refusing a home directory reached through a symlink" >&2 + return 1 + } + + if ((EUID == 0)); then + owner=$(stat -Lc '%u' "$CALLER_HOME") || return 1 + [[ $owner == "$CALLER_UID" ]] || { + echo "omarchy-windows-vm: caller does not own $CALLER_HOME" >&2 + return 1 + } + # The user must not be able to rename or replace their home while root is + # opening and pinning the familiar data entries below it. + parent=$(dirname -- "$CALLER_HOME") + while :; do + owner=$(stat -Lc '%u' "$parent") || return 1 + mode=$(stat -Lc '%a' "$parent") || return 1 + [[ $owner == 0 ]] && ! ((8#$mode & 022)) || { + echo "omarchy-windows-vm: unsafe writable parent in home path: $parent" >&2 + return 1 + } + [[ $parent == / ]] && break + parent=$(dirname -- "$parent") + done + fi + + # Docker only ever sees fixed paths below the root-owned runtime tree. The + # user's real data stays wherever ~/.windows and ~/Windows resolve (including + # separately mounted homes and legitimate symlinks); those sources are pinned + # into these anchors with bind mounts before Docker is allowed to start. + MOUNT_ROOT="$RUNTIME_DIR/mounts" + USERS_DIR="$MOUNT_ROOT/users" + CALLER_DATA_ROOT="$USERS_DIR/$CALLER_UID" + EXPECTED_STORAGE="$CALLER_DATA_ROOT/storage" + EXPECTED_SHARED="$CALLER_DATA_ROOT/shared" + LEGACY_STORAGE="$CALLER_HOME/.windows" + LEGACY_SHARED="$CALLER_HOME/Windows" + # The first protected-anchor implementation used a root-owned sibling of + # home. Recognize that exact derived pair during upgrade, but never accept a + # path read from user input. + OLD_MOUNT_ROOT="$(dirname -- "$CALLER_HOME")/.omarchy-windows" + OLD_EXPECTED_STORAGE="$OLD_MOUNT_ROOT/users/$CALLER_UID/storage" + OLD_EXPECTED_SHARED="$OLD_MOUNT_ROOT/users/$CALLER_UID/shared" +} + +boundary_owner() { + # Production boundaries remain root-owned even when a docker-group user runs + # the read-only bring-up checks directly. A non-standard runtime is supported + # only for unprivileged tests/development and is owned by that caller. + if ((EUID == 0)) || [[ $RUNTIME_DIR == /var/lib/omarchy/windows ]]; then + printf '0' + else + printf '%s' "$CALLER_UID" + fi +} + +assert_boundary_dir() { + local path="$1" expected_owner="$2" owner mode canonical + [[ -d $path && ! -L $path ]] || return 1 + canonical=$(realpath -e -- "$path" 2>/dev/null) || return 1 + [[ $canonical == "$path" ]] || return 1 + owner=$(stat -Lc '%u' "$path") || return 1 + mode=$(stat -Lc '%a' "$path") || return 1 + [[ $owner == "$expected_owner" ]] && ! ((8#$mode & 022)) +} + +prepare_boundary_component() { + local path="$1" parent="$2" owner="$3" mode="$4" + assert_boundary_dir "$parent" "$owner" || return 1 + if [[ -e $path || -L $path ]]; then + assert_boundary_dir "$path" "$owner" || return 1 + else + if ((EUID == 0)); then + install -d -o root -g root -m "$mode" -- "$path" || return 1 + else + install -d -m "$mode" -- "$path" || return 1 + fi + fi + chmod "$mode" -- "$path" || return 1 + if ((EUID == 0)); then chown root:root -- "$path" || return 1; fi + assert_boundary_dir "$path" "$owner" +} + +prepare_runtime_tree() { + local owner probe runtime_parent + owner=$(boundary_owner) + if ((EUID == 0)); then + [[ $RUNTIME_DIR == /var/lib/omarchy/windows ]] || { + echo "omarchy-windows-vm: refusing a non-standard privileged runtime path" >&2 + return 1 + } + # Check the nearest existing ancestor before mkdir can follow anything. + # Every new component is then created by root and checked again below. + probe=$RUNTIME_DIR + while [[ ! -e $probe && ! -L $probe ]]; do probe=$(dirname -- "$probe"); done + while :; do + assert_boundary_dir "$probe" 0 || { + echo "omarchy-windows-vm: unsafe runtime parent: $probe" >&2 + return 1 + } + [[ $probe == / ]] && break + probe=$(dirname -- "$probe") + done + fi + + runtime_parent=$(dirname -- "$RUNTIME_DIR") + if ((EUID == 0)) && [[ ! -e $runtime_parent && ! -L $runtime_parent ]]; then + [[ $runtime_parent == /var/lib/omarchy ]] || return 1 + assert_boundary_dir /var/lib 0 || return 1 + install -d -o root -g root -m 0755 -- "$runtime_parent" || return 1 + fi + prepare_boundary_component "$RUNTIME_DIR" "$runtime_parent" "$owner" 0755 && + prepare_boundary_component "$MOUNT_ROOT" "$RUNTIME_DIR" "$owner" 0711 && + prepare_boundary_component "$USERS_DIR" "$MOUNT_ROOT" "$owner" 0711 && + prepare_boundary_component "$CALLER_DATA_ROOT" "$USERS_DIR" "$owner" 0711 || { + echo "omarchy-windows-vm: unsafe VM mount boundary" >&2 + return 1 + } +} + +# Open the source directory itself and keep the descriptor alive until after the +# bind. /proc/$BASHPID/fd refers to this exact shell process (including when a +# function runs in a pipeline subshell), not the short-lived mount subprocess, +# so a rename or symlink swap after open cannot change which inode is mounted. +open_mount_source() { + local path="$1" label="$2" fd record uid identity + [[ -d $path ]] || { + echo "omarchy-windows-vm: $label source is not a directory: $path" >&2 + return 1 + } + # Appending /. makes a directory-to-FIFO swap fail with ENOTDIR instead of + # leaving the privileged helper blocked while opening an attacker-held pipe. + exec {fd}<"$path/." || { + echo "omarchy-windows-vm: cannot open $label source: $path" >&2 + return 1 + } + [[ -d /proc/$BASHPID/fd/$fd ]] || { + exec {fd}<&- + return 1 + } + record=$(stat -Lc '%u|%d:%i' "/proc/$BASHPID/fd/$fd" 2>/dev/null) || { + exec {fd}<&- + return 1 + } + IFS='|' read -r uid identity <<<"$record" + [[ $uid == "$CALLER_UID" ]] || { + exec {fd}<&- + echo "omarchy-windows-vm: $label source must be a directory owned by uid $CALLER_UID" >&2 + return 1 + } + OPENED_MOUNT_FD=$fd + OPENED_MOUNT_ID=$identity +} + +# Return 0 when ancestor_id contains the already-open descendant directory, 1 +# when the walk reaches the namespace root without finding it, and 2 on any +# error or an implausibly deep walk. Every hop is opened relative to a pinned +# directory FD; no caller-mutable pathname is re-resolved. +pinned_dir_contains() { + local ancestor_id="$1" descendant_fd="$2" walk_fd parent_fd current_id parent_id depth + exec {walk_fd}<"/proc/$BASHPID/fd/$descendant_fd/." || return 2 + for ((depth = 0; depth < 256; depth++)); do + current_id=$(stat -Lc '%d:%i' "/proc/$BASHPID/fd/$walk_fd" 2>/dev/null) || { + exec {walk_fd}<&- + return 2 + } + if [[ $current_id == "$ancestor_id" ]]; then + exec {walk_fd}<&- + return 0 + fi + exec {parent_fd}<"/proc/$BASHPID/fd/$walk_fd/.." || { + exec {walk_fd}<&- + return 2 + } + parent_id=$(stat -Lc '%d:%i' "/proc/$BASHPID/fd/$parent_fd" 2>/dev/null) || { + exec {parent_fd}<&- + exec {walk_fd}<&- + return 2 + } + if [[ $parent_id == "$current_id" ]]; then + exec {parent_fd}<&- + exec {walk_fd}<&- + return 1 + fi + exec {walk_fd}<&- + walk_fd=$parent_fd + done + exec {walk_fd}<&- + return 2 +} + +# A bind alias can give the same directory inode a second parent chain, so an +# upward walk alone is insufficient for destructive removal. Search from a +# pinned tree root for the other pinned inode without following symlinks or +# crossing the removal traversal's filesystem boundary. Return 0 when found, 1 +# when absent, and 2 on timeout, traversal error, or unexpected output. +pinned_tree_contains() { + local root_fd="$1" needle_fd="$2" found rc + # -xdev still evaluates a nested mountpoint itself before pruning its + # children, so a direct different-filesystem alias of the needle is found too. + # This matches removal's traversal boundary without skipping mount aliases. + if found=$("$TREE_SCAN_TIMEOUT" --signal=TERM --kill-after="${TREE_SCAN_KILL_AFTER_SECONDS}s" \ + "${TREE_SCAN_TIMEOUT_SECONDS}s" "$TREE_SCAN_FIND" -P "/proc/$BASHPID/fd/$root_fd/." \ + -xdev -type d -samefile "/proc/$BASHPID/fd/$needle_fd/." \ + -printf 'found\n' -quit 2>/dev/null); then + rc=0 + else + rc=$? + fi + ((rc == 0)) || return 2 + case "$found" in + found) return 0 ;; + "") return 1 ;; + *) return 2 ;; + esac +} + +validate_pinned_sources_disjoint() { + local storage_fd="$1" storage_id="$2" shared_fd="$3" shared_id="$4" rc + if [[ $storage_id == "$shared_id" ]]; then + echo "omarchy-windows-vm: storage and shared must be different directories" >&2 + return 1 + fi + if pinned_dir_contains "$storage_id" "$shared_fd"; then + echo "omarchy-windows-vm: shared directory must not be inside storage" >&2 + return 1 + else + rc=$? + ((rc == 1)) || { + echo "omarchy-windows-vm: could not verify storage/shared ancestry" >&2 + return 1 + } + fi + if pinned_dir_contains "$shared_id" "$storage_fd"; then + echo "omarchy-windows-vm: storage directory must not be inside shared" >&2 + return 1 + else + rc=$? + ((rc == 1)) || { + echo "omarchy-windows-vm: could not verify storage/shared ancestry" >&2 + return 1 + } + fi +} + +removal_trees_disjoint() { + local storage_fd shared_fd anchor_storage_fd anchor_shared_fd storage_id shared_id rc=1 scan_rc + local scan scan_root_fd scan_needle_fd scan_label + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id" || + ! mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" || + ! mounted_leaf_matches "$EXPECTED_SHARED" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! exec {anchor_storage_fd}<"$EXPECTED_STORAGE/."; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! exec {anchor_shared_fd}<"$EXPECTED_SHARED/."; then + exec {anchor_storage_fd}<&- + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + # Scan the protected anchor views first. Also scan the pinned caller views: + # a submount attached after the original non-recursive bind is intentionally + # absent from the anchor view, but removal must still refuse that alias. + rc=0 + for scan in \ + "$anchor_storage_fd:$anchor_shared_fd:shared below protected storage" \ + "$anchor_shared_fd:$anchor_storage_fd:storage below protected shared" \ + "$storage_fd:$shared_fd:shared below storage source" \ + "$shared_fd:$storage_fd:storage below shared source"; do + IFS=: read -r scan_root_fd scan_needle_fd scan_label <<<"$scan" + if pinned_tree_contains "$scan_root_fd" "$scan_needle_fd"; then + echo "omarchy-windows-vm: refusing removal: $scan_label" >&2 + rc=1 + break + else + scan_rc=$? + if ((scan_rc != 1)); then + echo "omarchy-windows-vm: removal containment scan failed or timed out" >&2 + rc=1 + break + fi + fi + done + + exec {anchor_storage_fd}<&- + exec {anchor_shared_fd}<&- + exec {storage_fd}<&- + exec {shared_fd}<&- + return "$rc" +} + +prepare_mount_anchor() { + local path="$1" owner + owner=$(boundary_owner) + [[ ! -L $path ]] || return 1 + if mountpoint -q -- "$path" 2>/dev/null; then return 0; fi + prepare_boundary_component "$path" "$CALLER_DATA_ROOT" "$owner" 0700 || return 1 + [[ -z $(find "$path" -mindepth 1 -print -quit) ]] || { + echo "omarchy-windows-vm: refusing to hide data below mount anchor: $path" >&2 + return 1 + } +} + +mounted_leaf_matches() { + local stable="$1" identity="$2" actual owner mode canonical + [[ -d $stable && ! -L $stable ]] || return 1 + canonical=$(realpath -e -- "$stable" 2>/dev/null) || return 1 + [[ $canonical == "$stable" ]] || return 1 + mountpoint -q -- "$stable" 2>/dev/null || return 1 + [[ $(mount_layer_count "$stable") == 1 ]] || return 1 + actual=$(stat -Lc '%d:%i' "$stable" 2>/dev/null) || return 1 + owner=$(stat -Lc '%u' "$stable" 2>/dev/null) || return 1 + mode=$(stat -Lc '%a' "$stable" 2>/dev/null) || return 1 + [[ $actual == "$identity" && $owner == "$CALLER_UID" && $mode == 700 ]] +} + +bind_mount_leaf() { + local fd="$1" identity="$2" stable="$3" actual owner + MOUNT_LEAF_NEW=0 + if mountpoint -q -- "$stable" 2>/dev/null; then + mounted_leaf_matches "$stable" "$identity" || { + echo "omarchy-windows-vm: protected mount at $stable no longer matches its home source" >&2 + return 1 + } + return 0 + fi + + # util-linux normally canonicalizes a /proc//fd link back to a pathname, + # which would throw away the FD pin. Pass the procfd to mount(2) unchanged. + mount --no-canonicalize --bind "/proc/$BASHPID/fd/$fd" "$stable" || return 1 + MOUNT_LEAF_NEW=1 + actual=$(stat -Lc '%d:%i' "$stable" 2>/dev/null) || actual="" + owner=$(stat -Lc '%u' "$stable" 2>/dev/null) || owner="" + if [[ $actual != "$identity" || $owner != "$CALLER_UID" ]]; then + if umount -- "$stable"; then + MOUNT_LEAF_NEW=0 + else + echo "omarchy-windows-vm: could not roll back unverified bind at $stable" >&2 + fi + echo "omarchy-windows-vm: bind verification failed for $stable" >&2 + return 1 + fi + mounted_leaf_matches "$stable" "$identity" || { + if umount -- "$stable"; then + MOUNT_LEAF_NEW=0 + else + echo "omarchy-windows-vm: could not roll back invalid bind at $stable" >&2 + fi + return 1 + } +} + +prepare_caller_mounts() { + local storage_fd storage_id shared_fd shared_id storage_mode shared_mode + CALLER_MOUNTS_NEW_STORAGE=0 + CALLER_MOUNTS_NEW_SHARED=0 + resolve_caller && prepare_runtime_tree || return 1 + prepare_mount_anchor "$EXPECTED_STORAGE" && prepare_mount_anchor "$EXPECTED_SHARED" || return 1 + + # Pre-open and validate both sources before changing either mount anchor. + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + # Privacy is an explicit preflight step for both already-pinned sources, not + # a side effect halfway through the two-mount transaction. Old umask-022 + # installs are hardened together before either Docker-facing anchor changes. + chmod 0700 -- "/proc/$BASHPID/fd/$storage_fd" "/proc/$BASHPID/fd/$shared_fd" || { + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + } + storage_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$storage_fd" 2>/dev/null) || storage_mode="" + shared_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$shared_fd" 2>/dev/null) || shared_mode="" + if [[ $storage_mode != 700 || $shared_mode != 700 ]]; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + if bind_mount_leaf "$storage_fd" "$storage_id" "$EXPECTED_STORAGE"; then + CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW + else + CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW + rollback_new_caller_mounts || true + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! bind_mount_leaf "$shared_fd" "$shared_id" "$EXPECTED_SHARED"; then + CALLER_MOUNTS_NEW_SHARED=$MOUNT_LEAF_NEW + rollback_new_caller_mounts || true + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + CALLER_MOUNTS_NEW_SHARED=$MOUNT_LEAF_NEW + + exec {storage_fd}<&- + exec {shared_fd}<&- +} + +mounts_ready() { + local storage_fd storage_id shared_fd shared_id rc=1 + resolve_caller || return 1 + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if assert_boundary_dir "$RUNTIME_DIR" "$(boundary_owner)" && + assert_boundary_dir "$MOUNT_ROOT" "$(boundary_owner)" && + assert_boundary_dir "$USERS_DIR" "$(boundary_owner)" && + assert_boundary_dir "$CALLER_DATA_ROOT" "$(boundary_owner)" && + mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" && + mounted_leaf_matches "$EXPECTED_SHARED" "$shared_id"; then + rc=0 + fi + exec {storage_fd}<&- + exec {shared_fd}<&- + return "$rc" +} + +mount_layer_count() { + local path="$1" + awk -v path="$path" '$5 == path { count++ } END { print count + 0 }' /proc/self/mountinfo +} + +mount_descendant_count() { + local path="$1" + awk -v prefix="$path/" 'index($5, prefix) == 1 { count++ } END { print count + 0 }' /proc/self/mountinfo +} + +rollback_new_caller_mounts() { + local failed=0 + if ((CALLER_MOUNTS_NEW_SHARED)); then + if umount -- "$EXPECTED_SHARED"; then CALLER_MOUNTS_NEW_SHARED=0; else failed=1; fi + fi + if ((CALLER_MOUNTS_NEW_STORAGE)); then + if umount -- "$EXPECTED_STORAGE"; then CALLER_MOUNTS_NEW_STORAGE=0; else failed=1; fi + fi + ((failed == 0)) || echo "omarchy-windows-vm: could not roll back newly created VM mounts" >&2 + return "$failed" +} + +write_compose_atomically() ( + local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" + local tmp="" rc esc_password + cleanup_writer() { + rc=$? + trap - EXIT + [[ -z $tmp ]] || rm -f -- "$tmp" || true + if ((rc != 0)) && ! rollback_new_caller_mounts; then rc=1; fi + exit "$rc" + } + trap cleanup_writer EXIT + + # Neutralize anything in the password that could be misread when the compose + # is parsed. Two layers apply, in this order at parse time: docker compose + # variable interpolation over the raw text ($VAR / $$), then YAML parsing of + # the double-quoted scalar. Encode for the inner layer first (backslash, then + # double-quote) and the interpolation layer last ($ -> $$), so a password + # containing " \ or $ reaches the guest verbatim. unescape() reverses this in + # the opposite order for the RDP credentials. + esc_password=${password//\\/\\\\} + esc_password=${esc_password//\"/\\\"} + esc_password=${esc_password//\$/\$\$} + + tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || exit 1 + # omarchy:heredoc-expands paths=EXPECTED_STORAGE,EXPECTED_SHARED -- both are + # root-protected anchors derived from the authenticated caller uid and bound + # to source inodes that were opened and validated before this compose is + # written. The remaining expansions are revalidated scalar settings. + cat >"$tmp" </dev/null || chown root:root "$tmp" || exit 1 + fi + mv -fT -- "$tmp" "$COMPOSE_FILE" || exit 1 + tmp="" + trap - EXIT +) + +# Reads KEY=VALUE lines on stdin, re-validates every field, and writes the +# compose atomically as root. Re-validation here is the security boundary: the +# writer refuses rather than emit a compose an attacker could have influenced. +# Only these fixed keys are honored; image, container name, devices, caps, and +# port bindings are hard-coded and never taken from input. +__priv_write_compose() { + local ram cores disk username password tz key value + + while IFS='=' read -r key value; do + case "$key" in + RAM) ram="$value" ;; + CORES) cores="$value" ;; + DISK) disk="$value" ;; + USERNAME) username="$value" ;; + PASSWORD) password="$value" ;; + TZ) tz="$value" ;; + esac + done + + valid_ram "$ram" || { echo "invalid RAM: $ram" >&2; exit 2; } + valid_cores "$cores" || { echo "invalid CPU cores: $cores" >&2; exit 2; } + valid_disk "$disk" || { echo "invalid disk size: $disk" >&2; exit 2; } + valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } + valid_password "$password" || { echo "invalid password" >&2; exit 2; } + valid_tz "$tz" || tz="UTC" + prepare_caller_mounts || exit 2 + # Readable by root and the docker group only. Any failure after mounting rolls + # back just the binds this writer created and leaves an old compose untouched. + write_compose_atomically "$ram" "$cores" "$disk" "$username" "$password" "$tz" || exit 2 +} + +# Read the host source of a bind mount out of the compose (e.g. /storage). +get_mount_source() { + sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1 +} + +# True only when a trusted compose has an exact security upgrade path. The +# result forces a one-time elevated migration for sudoless-Docker users. An +# arbitrary or mixed bind pair is never classified as migratable. +compose_needs_security_migration() { + local storage shared + [[ -f $COMPOSE_FILE ]] || return 1 + resolve_caller || return 1 + [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || return 1 + storage=$(get_mount_source /storage) + shared=$(get_mount_source /shared) + if compose_mount_pair_is_migratable "$storage" "$shared"; then + return 0 + fi + [[ $storage == "$EXPECTED_STORAGE" && $shared == "$EXPECTED_SHARED" ]] && ! compose_web_protected +} + +compose_mount_pair_is_migratable() { + local storage="$1" shared="$2" + [[ $storage == "$LEGACY_STORAGE" && $shared == "$LEGACY_SHARED" ]] || + [[ $storage == "$OLD_EXPECTED_STORAGE" && $shared == "$OLD_EXPECTED_SHARED" ]] +} + +mount_source_count() { + local destination="$1" + sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$destination\$|x|p" "$COMPOSE_FILE" | wc -l +} + +compose_web_protected() { + [[ $(sed -n 's/^[[:space:]]*PROTECT:.*$/x/p' "$COMPOSE_FILE" | wc -l) == 1 && + $(sed -n 's/^[[:space:]]*PROTECT:[[:space:]]*"Y"[[:space:]]*$/x/p' "$COMPOSE_FILE" | wc -l) == 1 ]] +} + +rewrite_compose_security() { + local tmp + tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || return 1 + awk -v storage="$EXPECTED_STORAGE" -v shared="$EXPECTED_SHARED" ' + /^ environment:$/ { print; print " PROTECT: \"Y\""; next } + /^[[:space:]]+PROTECT:/ { next } + /^[[:space:]]*-[[:space:]]*\/[^:]*:\/storage$/ { print " - " storage ":/storage"; next } + /^[[:space:]]*-[[:space:]]*\/[^:]*:\/shared$/ { print " - " shared ":/shared"; next } + { print } + ' "$COMPOSE_FILE" >"$tmp" || { rm -f "$tmp"; return 1; } + [[ $(sed -n 's/^[[:space:]]*PROTECT:.*$/x/p' "$tmp" | wc -l) == 1 && + $(sed -n 's/^[[:space:]]*PROTECT:[[:space:]]*"Y"[[:space:]]*$/x/p' "$tmp" | wc -l) == 1 ]] || { + rm -f "$tmp" + return 1 + } + chmod 0640 "$tmp" || { rm -f "$tmp"; return 1; } + if ((EUID == 0)); then + chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" || { + rm -f "$tmp" + return 1 + } + fi + mv -fT -- "$tmp" "$COMPOSE_FILE" || { rm -f "$tmp"; return 1; } +} + +assert_compose_trusted() { + local owner expected mode + [[ -f $COMPOSE_FILE && ! -L $COMPOSE_FILE ]] || return 1 + owner=$(stat -Lc '%u' "$COMPOSE_FILE") || return 1 + mode=$(stat -Lc '%a' "$COMPOSE_FILE") || return 1 + expected=$(boundary_owner) + [[ $owner == "$expected" ]] && ! ((8#$mode & 022)) +} + +assert_mounts_safe() { + local storage shared needs_rewrite=0 mounts_prepared=0 + resolve_caller || return 1 + assert_compose_trusted || { + echo "omarchy-windows-vm: refusing an untrusted compose file" >&2 + return 1 + } + storage=$(get_mount_source /storage) + shared=$(get_mount_source /shared) + + [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || { + echo "omarchy-windows-vm: refusing duplicate or missing VM mounts in the compose" >&2 + return 1 + } + + if compose_mount_pair_is_migratable "$storage" "$shared"; then + ((EUID == 0)) || { + echo "omarchy-windows-vm: legacy VM data needs an authorized migration" >&2 + return 1 + } + prepare_caller_mounts || return 1 + mounts_prepared=1 + needs_rewrite=1 + storage=$EXPECTED_STORAGE + shared=$EXPECTED_SHARED + fi + + [[ $storage == "$EXPECTED_STORAGE" && $shared == "$EXPECTED_SHARED" ]] || { + echo "omarchy-windows-vm: refusing unexpected host paths in the compose" >&2 + return 1 + } + + if ! compose_web_protected; then + ((EUID == 0)) || { + echo "omarchy-windows-vm: web-console protection needs an authorized migration" >&2 + return 1 + } + needs_rewrite=1 + fi + + if ((needs_rewrite)) && ! rewrite_compose_security; then + if ((mounts_prepared)); then rollback_new_caller_mounts || true; fi + return 1 + fi + + # Mounts disappear at reboot. Root recreates them from the already-opened, + # caller-owned sources; a docker-group invocation may proceed directly only + # while the exact pinned pair is still present. + if ((EUID == 0)); then + prepare_caller_mounts || return 1 + fi + mounts_ready || { + echo "omarchy-windows-vm: refusing an unsafe VM mount anchor" >&2 + return 1 + } +} + +__priv_up() { assert_mounts_safe && dc up -d; } + +__priv_down() { dc down; } + +# Bring the VM up and wait until the guest reports it is ready, all under a +# single elevation so the readiness poll does not prompt on every iteration. +__priv_up_wait() { + assert_mounts_safe || return 1 + local status + status=$(docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null) + if [[ $status != "running" ]]; then + dc up -d || return 1 + fi + + # docker logs persists across restarts, so anchor the scan to the current + # start time; an empty --since would match a stale "started successfully". + local started_at count=0 + while true; do + started_at=$(docker inspect --format='{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null) + if [[ -n $started_at ]] && docker logs --since "$started_at" "$CONTAINER" 2>&1 | grep -qi "windows started successfully"; then + return 0 + fi + sleep 2 + ((++count > 60)) && { + echo "Timeout: Windows VM did not report ready within 2 minutes" >&2 + return 1 + } + done +} + +# Print the status (empty if the container does not exist) and always succeed, +# so a non-zero exit from priv status means the elevation itself failed +# (authorization declined) rather than "no such container". +__priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; } + +__priv_remove() { + # Rebuild/verify both pinned binds before deleting through the storage anchor. + # In particular, a legitimate ~/.windows symlink means deleting only the link + # from the user side would strand the virtual disk in its external target. + assert_mounts_safe || return 1 + [[ $EXPECTED_STORAGE == "$USERS_DIR/$CALLER_UID/storage" ]] || return 1 + [[ $EXPECTED_SHARED == "$USERS_DIR/$CALLER_UID/shared" ]] || return 1 + [[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && + $(mount_layer_count "$EXPECTED_SHARED") == 1 && + $(mount_descendant_count "$EXPECTED_STORAGE") == 0 && + $(mount_descendant_count "$EXPECTED_SHARED") == 0 ]] || { + echo "omarchy-windows-vm: refusing removal with unknown or stacked VM mounts" >&2 + return 1 + } + + dc down || { + echo "omarchy-windows-vm: could not stop the Windows VM; storage was not deleted" >&2 + return 1 + } + if docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "omarchy-windows-vm: Windows container still exists; storage was not deleted" >&2 + return 1 + fi + docker info >/dev/null 2>&1 || { + echo "omarchy-windows-vm: cannot verify Docker state; storage was not deleted" >&2 + return 1 + } + mounts_ready || { + echo "omarchy-windows-vm: VM mount identity changed during removal" >&2 + return 1 + } + removal_trees_disjoint || return 1 + + # find -xdev deliberately empties the verified disk source without crossing + # into another mounted filesystem. Shared files are never traversed. + find "$EXPECTED_STORAGE" -xdev -mindepth 1 -delete || return 1 + [[ -z $(find "$EXPECTED_STORAGE" -mindepth 1 -print -quit) ]] || return 1 + + # Release only the single known top mounts checked above. Unmount shared first + # so a storage-unmount failure cannot expose shared data to deletion. + umount -- "$EXPECTED_SHARED" || return 1 + umount -- "$EXPECTED_STORAGE" || return 1 + docker rmi "$IMAGE" 2>/dev/null || true + rm -f "$COMPOSE_FILE" + rmdir -- "$EXPECTED_STORAGE" "$EXPECTED_SHARED" "$CALLER_DATA_ROOT" 2>/dev/null || true +} + +# --- config helpers ---------------------------------------------------------- + +# Validate both familiar home entries before creating or changing either. A +# legitimate symlink is kept exactly as-is; only its caller-owned directory +# target is used. The privileged half repeats the ownership check on pinned FDs. +preflight_user_mount_source() { + local path="$1" label="$2" uid owner + uid=$(id -u) + if [[ -L $path ]]; then + [[ -d $path ]] || { + echo "omarchy-windows-vm: $label is a broken or non-directory symlink: $path" >&2 + return 1 + } + elif [[ -e $path ]]; then + [[ -d $path ]] || { + echo "omarchy-windows-vm: $label is not a directory: $path" >&2 + return 1 + } + else + return 0 + fi + owner=$(stat -Lc '%u' -- "$path") || return 1 + [[ $owner == "$uid" ]] || { + echo "omarchy-windows-vm: $label must be owned by uid $uid: $path" >&2 + return 1 + } +} + +prepare_user_mount_sources() { + local storage="$HOME/.windows" shared="$HOME/Windows" storage_id shared_id + preflight_user_mount_source "$storage" storage && + preflight_user_mount_source "$shared" shared || return 1 + [[ -e $storage || -L $storage ]] || install -d -m 0700 -- "$storage" || return 1 + [[ -e $shared || -L $shared ]] || install -d -m 0700 -- "$shared" || return 1 + storage_id=$(stat -Lc '%d:%i' -- "$storage") || return 1 + shared_id=$(stat -Lc '%d:%i' -- "$shared") || return 1 + [[ $storage_id != "$shared_id" ]] || { + echo "omarchy-windows-vm: storage and shared must be different directories" >&2 + return 1 + } + chmod 0700 -- "$storage" "$shared" +} + +storage_space_path() { + if [[ -d $HOME/.windows ]]; then + realpath -e -- "$HOME/.windows" + else + printf '%s\n' "$HOME" + fi +} + +available_storage_gb() { + local path + path=$(storage_space_path) || return 1 + df -P -- "$path" | awk 'NR==2 {print int($4/1024/1024)}' +} + +# Feed the collected settings to the elevated writer. +write_compose() { + local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" + printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \ + "$ram" "$cores" "$disk" "$username" "$password" "$tz" | + priv write_compose +} + +# Reverse, in the opposite order, the escaping __priv_write_compose applied to +# the password: undo the interpolation layer ($$ -> $) first, then the YAML +# layer (\" -> ", then \\ -> \). +unescape() { + local v=$1 + v=${v//\$\$/\$} + v=${v//\\\"/\"} + v=${v//\\\\/\\} + printf '%s' "$v" +} + +# Store the RDP credentials privately for the user (0600) so the plaintext +# password is not world-readable. The password is one validated printable line +# (no newline), so plain KEY=VALUE is safe. +write_credentials() { + local username="$1" password="$2" old_umask dir tmp + dir=$(dirname -- "$CREDENTIALS_FILE") + mkdir -p "$dir" || return 1 + chmod 0700 "$dir" || return 1 + old_umask=$(umask) + umask 077 + tmp=$(mktemp "$dir/.credentials.XXXXXX") || { umask "$old_umask"; return 1; } + if ! printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$tmp" || + ! chmod 0600 "$tmp" || ! mv -fT -- "$tmp" "$CREDENTIALS_FILE"; then + rm -f -- "$tmp" + umask "$old_umask" + return 1 + fi + umask "$old_umask" +} + +# Read one field from the private credentials file; IFS on the first = keeps a +# password that itself contains =. +read_credential() { + local want="$1" key value + [[ -f $CREDENTIALS_FILE ]] || return 1 + while IFS='=' read -r key value; do + [[ $key == "$want" ]] && { + printf '%s' "$value" + return 0 + } + done <"$CREDENTIALS_FILE" + return 1 +} + +read_compose_value() { + local key="$1" file="$2" + sed -n "s/.*${key}: \"\(.*\)\"/\1/p" "$file" | head -n1 +} + +# Older installs kept the compose under ~/.config/windows. Carry those settings +# into the root-owned location (preserving the VM's data via the same volume +# paths) so an upgrade does not strand or re-download an existing VM. +migrate_legacy_compose() { + [[ -f $COMPOSE_FILE ]] && return 0 + [[ -f $LEGACY_COMPOSE_FILE ]] || return 1 + + echo "Migrating Windows VM configuration to $COMPOSE_FILE ..." + local ram cores disk username password tz + ram=$(read_compose_value RAM_SIZE "$LEGACY_COMPOSE_FILE") + cores=$(read_compose_value CPU_CORES "$LEGACY_COMPOSE_FILE") + disk=$(read_compose_value DISK_SIZE "$LEGACY_COMPOSE_FILE") + username=$(read_compose_value USERNAME "$LEGACY_COMPOSE_FILE") + password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE") + tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE") + [[ -z $tz ]] && tz="UTC" + prepare_user_mount_sources || { + echo "Could not validate the existing Windows VM data directories." >&2 + return 1 + } + write_credentials "$username" "$password" || return 1 + # The elevated writer derives both mount anchors from the authenticated uid; + # it never consumes volume paths from this user-owned legacy file. + if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz"; then + echo "Could not migrate the existing configuration automatically." >&2 + echo "Re-run: omarchy-windows-vm install" >&2 + return 1 + fi + rm -f "$LEGACY_COMPOSE_FILE" +} + +# --- prerequisites ----------------------------------------------------------- check_prerequisites() { local DISK_SIZE_GB=${1:-64} - local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead + local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead # Check for KVM support if [[ ! -e /dev/kvm ]]; then @@ -25,8 +1142,11 @@ check_prerequisites() { fi # Check disk space - AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') - if (( AVAILABLE_SPACE < REQUIRED_SPACE )); then + AVAILABLE_SPACE=$(available_storage_gb) || { + echo "❌ Could not determine available space for $HOME/.windows" >&2 + exit 1 + } + if ((AVAILABLE_SPACE < REQUIRED_SPACE)); then echo "❌ Insufficient disk space!" echo " Available: ${AVAILABLE_SPACE}GB" echo " Required: ${REQUIRED_SPACE}GB (${DISK_SIZE_GB}GB disk + 10GB for Windows image)" @@ -34,19 +1154,20 @@ check_prerequisites() { fi } +# --- commands ---------------------------------------------------------------- + install_windows() { # Set up trap to handle Ctrl+C trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT + prepare_user_mount_sources || exit 1 check_prerequisites omarchy-pkg-add freerdp openbsd-netcat gum - mkdir -p "$HOME/.windows" - mkdir -p "$HOME/.config/windows" mkdir -p "$HOME/.local/share/applications" - cat << EOF | tee "$HOME/.local/share/applications/windows-vm.desktop" > /dev/null + cat </dev/null [Desktop Entry] Name=Windows Comment=Start Windows VM via Docker and connect with RDP @@ -70,7 +1191,7 @@ EOF RAM_OPTIONS="" for size in 2 4 8 16 32 64; do - if (( size <= TOTAL_RAM_GB )); then + if ((size <= TOTAL_RAM_GB)); then RAM_OPTIONS="$RAM_OPTIONS ${size}G" fi done @@ -91,16 +1212,19 @@ EOF exit 1 fi - if ! [[ $SELECTED_CORES =~ ^[0-9]+$ ]] || (( SELECTED_CORES < 1 )) || (( SELECTED_CORES > TOTAL_CORES )); then + if ! valid_cores "$SELECTED_CORES" || ((SELECTED_CORES > TOTAL_CORES)); then echo "Invalid input. Using default: 2 cores" SELECTED_CORES=2 fi - AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') - MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image + AVAILABLE_SPACE=$(available_storage_gb) || { + echo "❌ Could not determine available space for $HOME/.windows" >&2 + exit 1 + } + MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image # Check if we have enough space for minimum - if (( MAX_DISK_GB < 32 )); then + if ((MAX_DISK_GB < 32)); then echo "❌ Insufficient disk space for Windows VM!" echo " Available: ${AVAILABLE_SPACE}GB" echo " Minimum required: 42GB (32GB disk + 10GB for Windows image)" @@ -109,7 +1233,7 @@ EOF DISK_OPTIONS="" for size in 32 64 128 256 512; do - if (( size <= MAX_DISK_GB )); then + if ((size <= MAX_DISK_GB)); then DISK_OPTIONS="$DISK_OPTIONS ${size}G" fi done @@ -139,6 +1263,10 @@ EOF if [[ -z $USERNAME ]]; then USERNAME="docker" fi + if ! valid_username "$USERNAME"; then + echo "Invalid username (use letters, digits, - or _, up to 20 chars). Using default: docker" + USERNAME="docker" + fi PASSWORD=$(gum input --placeholder="Password (Press enter to use default: admin)" --password --header="Enter Windows password:") if [[ -z $PASSWORD ]]; then @@ -147,6 +1275,11 @@ EOF else PASSWORD_DISPLAY="(user-defined)" fi + if ! valid_password "$PASSWORD"; then + echo "Invalid password (printable characters, up to 64). Using default: admin" + PASSWORD="admin" + PASSWORD_DISPLAY="(default)" + fi # Display configuration summary gum style \ @@ -170,38 +1303,21 @@ EOF exit 1 fi - mkdir -p $HOME/Windows + local tz + tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC) - # Create docker-compose.yml in user config directory - cat << EOF | tee "$COMPOSE_FILE" > /dev/null -services: - windows: - image: dockurr/windows - container_name: omarchy-windows - environment: - VERSION: "11" - RAM_SIZE: "$SELECTED_RAM" - CPU_CORES: "$SELECTED_CORES" - DISK_SIZE: "$SELECTED_DISK" - USERNAME: "$USERNAME" - PASSWORD: "$PASSWORD" - TZ: "$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC)" - ARGUMENTS: "-rtc base=localtime,clock=host,driftfix=slew" - devices: - - /dev/kvm - - /dev/net/tun - cap_add: - - NET_ADMIN - ports: - - 127.0.0.1:8006:8006 - - 127.0.0.1:3389:3389/tcp - - 127.0.0.1:3389:3389/udp - volumes: - - $HOME/.windows:/storage - - $HOME/Windows:/shared - restart: "no" - stop_grace_period: 2m -EOF + # Write the root-owned compose from the validated settings (one prompt if + # sudoless Docker is off). The writer pins the familiar home directories (or + # their legitimate symlink targets) into root-protected bind anchors. + write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \ + "$USERNAME" "$PASSWORD" "$tz" || { + echo "❌ Failed to write the Windows VM configuration." + exit 1 + } + write_credentials "$USERNAME" "$PASSWORD" || { + echo "❌ Failed to store private RDP credentials." >&2 + exit 1 + } echo "" echo "Starting Windows VM installation..." @@ -210,14 +1326,12 @@ EOF echo "Monitor installation progress at: http://127.0.0.1:8006" echo "" - # Start docker-compose with user's config echo "Starting Windows VM with docker-compose..." - if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then + if ! priv up; then echo "❌ Failed to start Windows VM!" echo " Common issues:" echo " - Docker daemon not running: sudo systemctl start docker" echo " - Port already in use: check if another VM is running" - echo " - Permission issues: make sure you're in the docker group" exit 1 fi @@ -237,7 +1351,6 @@ EOF echo "Once finished, launch 'Windows' via Super + Space" echo "" echo "To stop the VM: omarchy-windows-vm stop" - echo "To change resources: ~/.config/windows/docker-compose.yml" echo "" } @@ -249,11 +1362,20 @@ remove_windows() { echo "Removing Windows VM..." - docker-compose -f "$COMPOSE_FILE" down 2>/dev/null || true + if [[ ! -f $COMPOSE_FILE && -f $LEGACY_COMPOSE_FILE ]]; then + migrate_legacy_compose || { + echo "❌ Could not safely migrate the VM before removal." >&2 + exit 1 + } + fi + if [[ -f $COMPOSE_FILE ]]; then + priv remove || { + echo "❌ Windows VM removal stopped before user-side cleanup; inspect the VM data before retrying." >&2 + exit 1 + } + fi - docker rmi dockurr/windows 2>/dev/null || echo "Image already removed or not found" - - rm "$HOME/.local/share/applications/windows-vm.desktop" + rm -f "$HOME/.local/share/applications/windows-vm.desktop" rm -rf "$HOME/.config/windows" rm -rf "$HOME/.windows" @@ -267,61 +1389,32 @@ launch_windows() { KEEP_ALIVE=true fi - # Check if config exists - if [[ ! -f $COMPOSE_FILE ]]; then - echo "Windows VM not configured. Please run: omarchy-windows-vm install" - exit 1 + if ! migrate_legacy_compose; then + if [[ ! -f $COMPOSE_FILE ]]; then + echo "Windows VM not configured. Please run: omarchy-windows-vm install" + exit 1 + fi fi - # Extract credentials from compose file - WIN_USER=$(grep "USERNAME:" "$COMPOSE_FILE" | sed 's/.*USERNAME: "\(.*\)"/\1/') - WIN_PASS=$(grep "PASSWORD:" "$COMPOSE_FILE" | sed 's/.*PASSWORD: "\(.*\)"/\1/') - - # Use defaults if not found + # RDP credentials come from the private per-user file. Fall back to the compose + # only when it is readable (sudoless mode), reversing the writer's escaping. + WIN_USER=$(read_credential USERNAME) || WIN_USER="" + WIN_PASS=$(read_credential PASSWORD) || WIN_PASS="" + if [[ -z $WIN_USER || -z $WIN_PASS ]] && [[ -r $COMPOSE_FILE ]]; then + [[ -z $WIN_USER ]] && WIN_USER=$(unescape "$(read_compose_value USERNAME "$COMPOSE_FILE")") + [[ -z $WIN_PASS ]] && WIN_PASS=$(unescape "$(read_compose_value PASSWORD "$COMPOSE_FILE")") + fi [[ -z $WIN_USER ]] && WIN_USER="docker" [[ -z $WIN_PASS ]] && WIN_PASS="admin" - # Check if container is already running - CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null) - - if [[ $CONTAINER_STATUS != "running" ]]; then - echo "Starting Windows VM..." - - if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then - echo "❌ Failed to start Windows VM!" - echo " Try checking: omarchy-windows-vm status" - echo " View logs: docker logs omarchy-windows" - omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM" - exit 1 - fi + echo "Starting Windows VM (this may prompt for authorization)..." + if ! priv up_wait; then + echo "❌ Failed to start Windows VM!" + echo " Try checking: omarchy-windows-vm status" + omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM" + exit 1 fi - # docker logs keeps output across stop/start and is only cleared when the - # container is removed, so an unanchored grep matches "started successfully" - # from an earlier boot and returns immediately. Anchor the scan to the current - # container start, and run it even when the container was already up: the - # image restarts the guest in place whenever Windows reboots. - windows_started() { - local started_at - started_at=$(docker inspect --format='{{.State.StartedAt}}' omarchy-windows 2>/dev/null) - # An empty --since would drop the filter and match a stale boot again - [[ -n $started_at ]] || return 1 - docker logs --since "$started_at" omarchy-windows 2>&1 | grep -qi "windows started successfully" - } - - echo "Waiting for Windows VM to start..." - WAIT_COUNT=0 - until windows_started; do - sleep 2 - WAIT_COUNT=$((WAIT_COUNT + 1)) - if (( WAIT_COUNT > 60 )); then # 2 minutes timeout - echo "" - echo "❌ Timeout: Windows VM failed to start within 2 minutes" - echo " Check logs: docker logs omarchy-windows" - exit 1 - fi - done - # Build the connection info if [[ $KEEP_ALIVE = "true" ]]; then LIFECYCLE="VM will keep running after RDP closes @@ -345,7 +1438,8 @@ To stop: omarchy-windows-vm stop" # network each attempt blocks ~23s and no RDP window is ever drawn. The VM # authenticates against a local Windows account, so point FreeRDP at a # realm-less config and let it fall straight through to NTLM. - KRB5_CONF="$(dirname "$COMPOSE_FILE")/krb5.conf" + KRB5_CONF="$HOME/.config/windows/krb5.conf" + mkdir -p "$(dirname "$KRB5_CONF")" if [[ ! -f $KRB5_CONF ]]; then printf '[libdefaults]\n dns_lookup_kdc = false\n dns_lookup_realm = false\n' >"$KRB5_CONF" fi @@ -356,22 +1450,49 @@ To stop: omarchy-windows-vm stop" SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}') RDP_SCALE="" - if (( SCALE_PERCENT >= 170 )); then + if ((SCALE_PERCENT >= 170)); then RDP_SCALE="/scale:180" - elif (( SCALE_PERCENT >= 130 )); then + elif ((SCALE_PERCENT >= 130)); then RDP_SCALE="/scale:140" fi # If scale is less than 130%, don't set any scale (use default 100) - # Connect with RDP in fullscreen (auto-detects resolution) - xfreerdp3 /u:"$WIN_USER" /p:"$WIN_PASS" /v:127.0.0.1:3389 -grab-keyboard /sound /microphone /clipboard /cert:ignore /title:"Windows VM - Omarchy" /dynamic-resolution /gfx:AVC444 /floatbar:sticky:off,default:visible,show:fullscreen $RDP_SCALE + RDP_ARGS=( + "/u:$WIN_USER" + "/p:$WIN_PASS" + /v:127.0.0.1:3389 + -grab-keyboard + /sound + /microphone + /clipboard + /cert:ignore + "/title:Windows VM - Omarchy" + /dynamic-resolution + /gfx:AVC444 + /floatbar:sticky:off,default:visible,show:fullscreen + ) + if [[ -n $RDP_SCALE ]]; then + RDP_ARGS+=("$RDP_SCALE") + fi + + # Connect with RDP in fullscreen (auto-detects resolution). The arguments go + # in over stdin rather than on the command line: /proc//cmdline is + # world-readable, so passing the VM password as /p:"$WIN_PASS" would show it + # to every other user on the machine for as long as the session is open. + # /args-from must stay the only argument here — FreeRDP rejects it outright + # when it is combined with any other, so new flags belong in RDP_ARGS above. + printf '%s\n' "${RDP_ARGS[@]}" | xfreerdp3 /args-from:stdin # After RDP closes, stop the container unless --keep-alive was specified if [[ $KEEP_ALIVE = "false" ]]; then echo "" echo "RDP session closed. Stopping Windows VM..." - docker-compose -f "$COMPOSE_FILE" down - echo "Windows VM stopped." + if priv down; then + echo "Windows VM stopped." + else + echo "⚠️ Could not stop the Windows VM (authorization declined?)." + echo " It may still be running. Stop it with: omarchy-windows-vm stop" + fi else echo "" echo "RDP session closed. Windows VM is still running." @@ -380,24 +1501,34 @@ To stop: omarchy-windows-vm stop" } stop_windows() { + migrate_legacy_compose 2>/dev/null || true if [[ ! -f $COMPOSE_FILE ]]; then echo "Windows VM not configured." exit 1 fi echo "Stopping Windows VM..." - docker-compose -f "$COMPOSE_FILE" down - echo "Windows VM stopped." + if priv down; then + echo "Windows VM stopped." + else + echo "⚠️ Could not stop the Windows VM (authorization declined?). It may still be running." + exit 1 + fi } status_windows() { + migrate_legacy_compose 2>/dev/null || true if [[ ! -f $COMPOSE_FILE ]]; then echo "Windows VM not configured." echo "To set up: omarchy-windows-vm install" exit 1 fi - CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null) + if ! CONTAINER_STATUS=$(priv status); then + echo "Could not query the Windows VM (authorization declined?)." + echo "To try again: omarchy-windows-vm status" + exit 1 + fi if [[ -z $CONTAINER_STATUS ]]; then echo "Windows VM container not found." @@ -443,28 +1574,41 @@ show_usage() { # Main command dispatcher case "$1" in - install) - install_windows - ;; - remove) - remove_windows - ;; - launch|start) - launch_windows "$2" - ;; - stop|down) - stop_windows - ;; - status) - status_windows - ;; - help|--help|-h|"") - show_usage - ;; - *) - echo "Unknown command: $1" >&2 - echo "" >&2 - show_usage >&2 +__priv) + ((EUID == 0)) || { + echo "omarchy-windows-vm __priv must run as root" >&2 exit 1 - ;; + } + action="$2" + shift 2 + valid_priv_action "$action" || { + echo "omarchy-windows-vm: unknown privileged action" >&2 + exit 1 + } + with_vm_lock "__priv_${action}" "$@" + ;; +install) + install_windows + ;; +remove) + remove_windows + ;; +launch | start) + launch_windows "$2" + ;; +stop | down) + stop_windows + ;; +status) + status_windows + ;; +help | --help | -h | "") + show_usage + ;; +*) + echo "Unknown command: $1" >&2 + echo "" >&2 + show_usage >&2 + exit 1 + ;; esac diff --git a/config/hypr/bindings.lua b/config/hypr/bindings.lua index 98d3ecbe..6da7b938 100644 --- a/config/hypr/bindings.lua +++ b/config/hypr/bindings.lua @@ -1,5 +1,5 @@ --- Keep only your personal keybinding overrides here. Add new bindings or --- unbind defaults before replacing them. +-- Keep only your personal keybinding overrides here. Add new bindings with +-- o.bind or replace defaults with o.rebind. -- See current bindings and descriptions: -- omarchy menu keybindings --print @@ -15,10 +15,9 @@ -- Add a new binding. -- o.bind("SUPER + SHIFT + R", "SSH", "alacritty -e ssh your-server") --- Change an existing binding by unbinding it first, then binding the key again. --- This example changes SUPER+SPACE from the launcher to the Omarchy root menu. --- hl.unbind("SUPER + SPACE") --- o.bind("SUPER + SPACE", "Omarchy menu", "omarchy-menu toggle root") +-- Change an existing binding. o.rebind takes the same arguments as o.bind. +-- This example replaces the default file manager with Flea. +-- o.rebind("SUPER + SHIFT + F", "File manager", { launch = "flea" }) -- Disable a default binding without replacing it. -- hl.unbind("SUPER + SHIFT + B") diff --git a/config/hypr/input.lua b/config/hypr/input.lua index d20be92a..d06e05c7 100644 --- a/config/hypr/input.lua +++ b/config/hypr/input.lua @@ -45,7 +45,8 @@ -- }) -- App-specific touchpad scroll speeds. --- o.window("(Alacritty|kitty|foot)", { scroll_touchpad = 1.5 }) +-- o.window("(Alacritty|kitty)", { scroll_touchpad = 1.5 }) +-- o.window("foot", { scroll_touchpad = 2.0 }) -- o.window("com.mitchellh.ghostty", { scroll_touchpad = 0.2 }) -- Enable touchpad gestures for changing workspaces. diff --git a/config/hypr/monitors.lua b/config/hypr/monitors.lua index 189f13c2..99d07e59 100644 --- a/config/hypr/monitors.lua +++ b/config/hypr/monitors.lua @@ -1,10 +1,10 @@ -- See https://wiki.hypr.land/Configuring/Basics/Monitors/ -- List current monitors and supported resolutions with: hyprctl monitors all -local omarchy_gdk_scale = 2 +-- Monitor scale is Hyprland's scale for the output. It sizes everything +-- Wayland-native, accepts fractions (1.6, 1.75), and applies immediately. +-- "auto" lets Hyprland pick per display. local omarchy_monitor_scale = "auto" - -hl.env("GDK_SCALE", tostring(omarchy_gdk_scale)) hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy_monitor_scale }) -- Configure a specific monitor. @@ -12,3 +12,11 @@ hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy -- Portrait/rotated secondary monitor (transform: 1 = 90°, 3 = 270°). -- hl.monitor({ output = "DP-2", mode = "preferred", position = "auto", scale = 1, transform = 1 }) + +-- GDK scale is GDK_SCALE, the factor GTK draws its own UI at. It's what +-- sizes X11/XWayland windows, which Omarchy leaves unscaled so they stay +-- crisp instead of being stretched by the compositor. GTK only honors whole +-- numbers, so use the nearest integer to the monitor scale, and restart an +-- app for a change to reach it. +local omarchy_gdk_scale = 2 +hl.env("GDK_SCALE", tostring(omarchy_gdk_scale)) diff --git a/config/imv/config b/config/imv/config index f96aa6a3..5de2530e 100644 --- a/config/imv/config +++ b/config/imv/config @@ -12,5 +12,5 @@ # Rotate the currently open image by 90 degrees = exec mogrify -rotate 90 "$imv_current_file" -# Edit the current image in Tensaku and quit the viewer - = exec tensaku-edit "$imv_current_file" & ; quit +# Edit the current image in Omasnap and quit the viewer + = exec omasnap "$imv_current_file" & ; quit diff --git a/config/kitty/kitty.conf b/config/kitty/kitty.conf index 7493b9e2..3bbeb342 100644 --- a/config/kitty/kitty.conf +++ b/config/kitty/kitty.conf @@ -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 diff --git a/config/omarchy/hooks/font-set.d/show-font-notification.sample b/config/omarchy/hooks/font-set.d/show-font-notification.sample index a75f2fa8..c1f50ee5 100644 --- a/config/omarchy/hooks/font-set.d/show-font-notification.sample +++ b/config/omarchy/hooks/font-set.d/show-font-notification.sample @@ -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" diff --git a/config/omarchy/hooks/post-boot.d/weather.sample b/config/omarchy/hooks/post-boot.d/weather.sample index 60a1c88f..01857cb7 100644 --- a/config/omarchy/hooks/post-boot.d/weather.sample +++ b/config/omarchy/hooks/post-boot.d/weather.sample @@ -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 diff --git a/config/omarchy/hooks/post-update.d/show-update-notification.sample b/config/omarchy/hooks/post-update.d/show-update-notification.sample index f6ba07b0..929e1e2a 100644 --- a/config/omarchy/hooks/post-update.d/show-update-notification.sample +++ b/config/omarchy/hooks/post-update.d/show-update-notification.sample @@ -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" diff --git a/config/omarchy/hooks/theme-set.d/show-theme-notification.sample b/config/omarchy/hooks/theme-set.d/show-theme-notification.sample index db19b298..d224db7c 100644 --- a/config/omarchy/hooks/theme-set.d/show-theme-notification.sample +++ b/config/omarchy/hooks/theme-set.d/show-theme-notification.sample @@ -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" diff --git a/config/omarchy/shell.json b/config/omarchy/shell.json index e6202da8..b8aa5de4 100644 --- a/config/omarchy/shell.json +++ b/config/omarchy/shell.json @@ -21,6 +21,9 @@ { "id": "omarchy.indicators" }, + { + "id": "omacom.elsewhen" + }, { "id": "omarchy.clock", "format": "dddd HH:mm", diff --git a/config/wireplumber/wireplumber.conf.d/kef-lsx-no-suspend.conf b/config/wireplumber/wireplumber.conf.d/kef-lsx-no-suspend.conf new file mode 100644 index 00000000..02955660 --- /dev/null +++ b/config/wireplumber/wireplumber.conf.d/kef-lsx-no-suspend.conf @@ -0,0 +1,19 @@ +## Never suspend the KEF LSX II LT USB sink. +## The speaker's USB firmware stops answering control requests when the host +## stops the stream after idle (usb_set_interface -110), and only a replug +## recovers it. Keeping the stream open avoids the trigger. + +monitor.alsa.rules = [ + { + matches = [ + { + node.name = "~alsa_output.usb-KEF_LSX_II_LT.*" + } + ] + actions = { + update-props = { + session.suspend-timeout-seconds = 0 + } + } + } +] diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 7859c6ec..ea773007 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -87,7 +87,38 @@ ambiguous, say so rather than assembling confidence out of guesswork. **Leave the system as you found it.** Diagnosis reads; it does not fix, tidy, or reconfigure. The one thing to clean up is your own: delete the core you extracted -above, which is a copy of the crashed process's memory. +above, which is a copy of the crashed process's memory. The single change a +diagnosis may make is the mute below, and only when the user asks for it. + +## Offer to stop the notifications for this program + +A crash you have explained often keeps happening anyway. Finish by offering to +silence notifications for **that one program**, and never run it unprompted. Say +how to lift it in the same breath, so it is not a one-way door. + +```bash +omarchy-crash-mute '' # silence it +omarchy-crash-mute '' off # let it speak again +omarchy-crash-mute # list what is muted +``` + +Pass the `binary:` path from the crash facts, or the `process:` name where no +binary was recorded; the command reduces either to the name the watcher keys on. +A diagnosis run by hand from `omarchy agent crash ` has neither, so take +them from `coredumpctl info`. Prefer the binary: a process name is truncated to +15 characters and a basename is not, so muting the truncated form matches +nothing, forever, while looking like it worked. + +Quote it. The name is whatever the crashed program's author called a file, and a +single quote inside one closes yours and runs the rest as your shell. + +The key is a bare name, so anything run through an interpreter is keyed as the +interpreter: muting `python3.13` silences every Python program on the machine. +Say so rather than quietly doing it. + +None of this fixes anything, and a mute offered in place of a fix that was within +reach is the wrong answer. For every program rather than one, the switch is +_Trigger > Toggle > Crash Capture_. ## If it is an Omarchy bug diff --git a/default/agents/skills/diagnose-crash/reporting.md b/default/agents/skills/diagnose-crash/reporting.md index 154f1a25..15bd08bc 100644 --- a/default/agents/skills/diagnose-crash/reporting.md +++ b/default/agents/skills/diagnose-crash/reporting.md @@ -40,8 +40,8 @@ useful; filing there yourself is not part of this. A duplicate issue costs a maintainer more time than no report at all. ```bash -gh search issues --repo basecamp/omarchy " crash" -gh issue list --repo basecamp/omarchy --state all --search " " +gh search issues --repo omacom/omarchy " crash" +gh issue list --repo omacom/omarchy --state all --search " " ``` Search on the crashing program, the signal, and distinctive symbols from the @@ -59,7 +59,7 @@ more than another duplicate. If a plausible match comes back, read it properly first: ```bash -gh issue view --repo basecamp/omarchy --comments +gh issue view --repo omacom/omarchy --comments ``` Confirm it is genuinely the same failure. The same program crashing is not the @@ -74,7 +74,7 @@ A comment that only says the bug happens to you too is noise. If that is all you have, tell the user so and file nothing. ```bash -gh issue comment --repo basecamp/omarchy --body "..." +gh issue comment --repo omacom/omarchy --body "..." ``` ## Filing a new issue @@ -82,7 +82,7 @@ gh issue comment --repo basecamp/omarchy --body "..." Only when the search turns up nothing that matches: ```bash -gh issue create --repo basecamp/omarchy --title "..." --body "..." +gh issue create --repo omacom/omarchy --title "..." --body "..." ``` Include what happened, what was expected, steps to reproduce, system details from diff --git a/default/agents/skills/omarchy/SKILL.md b/default/agents/skills/omarchy/SKILL.md index 1b00974a..4ce34bb0 100644 --- a/default/agents/skills/omarchy/SKILL.md +++ b/default/agents/skills/omarchy/SKILL.md @@ -13,7 +13,7 @@ description: > # Omarchy Skill -Manage [Omarchy](https://omarchy.org/) Linux systems - a beautiful, modern, opinionated Arch Linux distribution with Hyprland. +Manage [Omarchy](https://omarchy.org/) Linux systems - a beautiful, fun, agentic Arch Linux distribution with Hyprland. This skill is for end-user customization on installed systems. It is not for contributing to Omarchy source code. @@ -278,7 +278,7 @@ This skill intentionally does not cover Omarchy source development. Do not use t ## Example Requests - "Change my theme to catppuccin" -> `omarchy theme set catppuccin` -- "Add a keybinding for Super+E to open file manager" -> Check existing bindings first, call `hl.unbind` if needed, then `o.bind` in `~/.config/hypr/bindings.lua` +- "Add a keybinding for Super+E to open file manager" -> Check existing bindings first, then use `o.rebind` to replace one or `o.bind` to add one in `~/.config/hypr/bindings.lua` - "Configure my external monitor" -> Edit `~/.config/hypr/monitors.lua` - "Make the window gaps smaller" -> Edit `~/.config/hypr/looknfeel.lua` - "Turn on night light" -> `omarchy toggle nightlight` (for time-based schedules, edit `~/.config/hypr/hyprsunset.conf` profiles, then `omarchy restart hyprsunset`) diff --git a/default/agents/skills/omarchy/capture.md b/default/agents/skills/omarchy/capture.md index 41a52b6c..a898db86 100644 --- a/default/agents/skills/omarchy/capture.md +++ b/default/agents/skills/omarchy/capture.md @@ -10,12 +10,11 @@ omarchy screenshot # Interactive smart-region flow omarchy capture screenshot region # Select a region omarchy capture screenshot windows # Pick a window omarchy capture screenshot fullscreen save # Full screen, straight to disk (no editor) +omarchy capture screenshot scroll # Capture and stitch a scrolling region +omarchy screenshot --editor=overlay # Opt into annotation before output ``` -The first argument picks the mode (`smart|region|windows|fullscreen`), the -second what happens with it (`slurp|copy|save`). `save` skips the annotation -editor and prints the saved path. Screenshots land in the configured Pictures -directory (override with `OMARCHY_SCREENSHOT_DIR`). +The first argument picks the Omasnap mode (`smart|region|windows|fullscreen|scroll`). By default, a capture copies to the clipboard and shows a preview for 10 seconds. Use the preview's Edit action to annotate, or pass `--editor=overlay` or `--editor=window` to edit before output. A second argument of `copy` or `save` skips the preview and sends the screenshot straight to that destination. Saved screenshots land in `~/Pictures/Screenshots` by default (override with `OMASNAP_SCREENSHOT_DIR`; the legacy `OMARCHY_SCREENSHOT_DIR` is also honored by the Omarchy command). ## Screen Recording @@ -33,7 +32,7 @@ Recordings land in the configured Videos directory (override with `omarchy capture webcam resize `. If recording fails to start, rerun with `OMARCHY_SCREENRECORD_DEBUG=true` to -collect a log at `/tmp/omarchy-screenrecord.log` worth attaching to a bug +collect a log at `$XDG_RUNTIME_DIR/omarchy-screenrecord.log` (or `${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/omarchy-screenrecord.log` without a session runtime directory) worth attaching to a bug report. ## Text Capture (OCR) diff --git a/default/agents/skills/omarchy/contributing.md b/default/agents/skills/omarchy/contributing.md index 117a9901..c76c12d6 100644 --- a/default/agents/skills/omarchy/contributing.md +++ b/default/agents/skills/omarchy/contributing.md @@ -3,13 +3,13 @@ Read this when the user wants to report an Omarchy bug, suggest a feature, or contribute a fix upstream. -Omarchy lives at https://github.com/basecamp/omarchy. Route requests to the +Omarchy lives at https://github.com/omacom/omarchy. Route requests to the right place: - **Verified bugs** -> GitHub issues. Issues are for validated bugs only, not support requests. - **Feature ideas and suggestions** -> - https://github.com/basecamp/omarchy/discussions/categories/suggestions + https://github.com/omacom/omarchy/discussions/categories/suggestions - **Support and "is this a bug?" questions** -> the Discord community at https://omarchy.org/discord. Start here when the problem isn't clearly a bug in Omarchy itself. @@ -38,12 +38,12 @@ drag-and-drop in the web form, so save the capture and hand the user the file path to attach (`gh` cannot upload media). For screen-recording failures specifically, rerun with -`OMARCHY_SCREENRECORD_DEBUG=true` and attach `/tmp/omarchy-screenrecord.log`. +`OMARCHY_SCREENRECORD_DEBUG=true` and attach `$XDG_RUNTIME_DIR/omarchy-screenrecord.log` (or `${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/omarchy-screenrecord.log` without a session runtime directory). File the issue with `gh` when available: ```bash -gh issue create --repo basecamp/omarchy --title "..." --body "..." +gh issue create --repo omacom/omarchy --title "..." --body "..." ``` Include: what happened, what was expected, steps to reproduce, system details, @@ -54,7 +54,7 @@ the debug log URL (or attached log), and the capture. Never develop against `/usr/share/omarchy`. Clone a working copy instead: ```bash -gh repo fork basecamp/omarchy --clone +gh repo fork omacom/omarchy --clone cd omarchy ``` diff --git a/default/agents/skills/omarchy/hyprland.md b/default/agents/skills/omarchy/hyprland.md index 961ff388..fd518ad3 100644 --- a/default/agents/skills/omarchy/hyprland.md +++ b/default/agents/skills/omarchy/hyprland.md @@ -43,18 +43,16 @@ View current bindings: `omarchy menu keybindings --print` **IMPORTANT: When re-binding an existing key:** 1. First check existing bindings: `omarchy menu keybindings --print` -2. If the key is already bound, you MUST call `hl.unbind(...)` BEFORE the new `o.bind(...)` +2. If the key is already bound, use `o.rebind(...)` to remove the existing binding and add its replacement. It takes the same arguments as `o.bind(...)`. 3. Inform the user what the key was previously bound to Example - rebinding SUPER+F (which is bound to fullscreen by default): ```lua --- Unbind existing SUPER+F (was: fullscreen) -hl.unbind("SUPER + F") --- New binding for file manager -o.bind("SUPER + F", "File manager", { launch = "nautilus" }) +-- Replace SUPER+F (was: fullscreen) with the file manager. +o.rebind("SUPER + F", "File manager", { launch = "nautilus" }) ``` -Always tell the user: "Note: SUPER+F was previously bound to fullscreen. I've added an unbind to override it." +Tell the user which action was replaced. Use `hl.unbind(...)` to remove a binding without replacing it. ## Display/Monitors diff --git a/default/agents/skills/omarchy/theming.md b/default/agents/skills/omarchy/theming.md index fcd19ab6..5212aa15 100644 --- a/default/agents/skills/omarchy/theming.md +++ b/default/agents/skills/omarchy/theming.md @@ -22,11 +22,34 @@ omarchy theme install # Install from git repo Additional user backgrounds for any theme (stock or custom) go in `~/.config/omarchy/backgrounds//`. +## 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/.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: diff --git a/default/fonts/omarchy/README.md b/default/fonts/omarchy/README.md index fe2e2f26..df93aa55 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -11,6 +11,12 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E906` — LM Studio, from - `U+E907` — Ollama, from - `U+E908` — T3 Code, traced from the app icon in , since upstream publishes no monochrome SVG +- `U+E909` — Ori, from , OpenRouter's own mark: Ori ships no separate logo and its product page uses this one +- `U+E90A` — Hermes, traced from the [official desktop app portrait](https://raw.githubusercontent.com/NousResearch/hermes-agent/2237be355906fbe6065ce1815711eee52b2d646e/apps/desktop/assets/icon.png), the same artwork shipped by `hermes-desktop`. The monochrome trace is kept in [hermes.svg](hermes.svg). +- `U+E90B` — Perplexity, from +- `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 +- `U+E90E` — Claude, from The agent marks are monochrome so the menu can render them using the active theme's foreground and selection colors. diff --git a/default/fonts/omarchy/hermes.svg b/default/fonts/omarchy/hermes.svg new file mode 100644 index 00000000..6ce1c6ef --- /dev/null +++ b/default/fonts/omarchy/hermes.svg @@ -0,0 +1,27 @@ + + diff --git a/default/fonts/omarchy/omarchy.ttf b/default/fonts/omarchy/omarchy.ttf index 2905c95b..3c70d803 100644 Binary files a/default/fonts/omarchy/omarchy.ttf and b/default/fonts/omarchy/omarchy.ttf differ diff --git a/default/hypr/apps/1password.lua b/default/hypr/apps/1password.lua index d57bf651..15683599 100644 --- a/default/hypr/apps/1password.lua +++ b/default/hypr/apps/1password.lua @@ -1 +1,3 @@ -o.window("^(1[p|P]assword)$", { no_screen_share = true, tag = "+floating-window" }) +-- 1Password 8.12 renamed its app id from "1Password" to the reverse-DNS form, +-- so match both to keep older installs floating too. +o.window("^(1[pP]assword|com\\.onepassword\\.OnePassword)$", { no_screen_share = true, tag = "+floating-window" }) diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index 6f6abda3..734d2b8f 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -3,9 +3,12 @@ o.window(".*[Rr]esolve.*", { float = true, stay_focused = true, + -- Prevent modal dialog pointer warps when focus follows the mouse. + no_follow_mouse = true, tag = "-default-opacity", opacity = "1 1", }) o.window({ class = ".*[Rr]esolve.*", title = "^DaVinci Resolve( Studio)? - .+$" }, { fullscreen = true }) -o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager)$" }, { stay_focused = false }) +-- Resolve exposes the Voiceover panel under the generic "Dialog" title. +o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager|Preferences|Find Directory|Dialog)$" }, { stay_focused = false }) diff --git a/default/hypr/apps/hermes.lua b/default/hypr/apps/hermes.lua new file mode 100644 index 00000000..a7ad307f --- /dev/null +++ b/default/hypr/apps/hermes.lua @@ -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", +}) diff --git a/default/hypr/apps/jetbrains.lua b/default/hypr/apps/jetbrains.lua index 678e823e..1b9658e3 100644 --- a/default/hypr/apps/jetbrains.lua +++ b/default/hypr/apps/jetbrains.lua @@ -1,2 +1,2 @@ --- Disable mouse focus (see https://github.com/basecamp/omarchy/pull/5183#issuecomment-4189299971). +-- Disable mouse focus (see https://github.com/omacom/omarchy/pull/5183#issuecomment-4189299971). o.window("^(jetbrains-.*)$", { no_follow_mouse = true }) diff --git a/default/hypr/apps/screenshot-selection.lua b/default/hypr/apps/screenshot-selection.lua index f4e3b2fd..4c93f703 100644 --- a/default/hypr/apps/screenshot-selection.lua +++ b/default/hypr/apps/screenshot-selection.lua @@ -1,2 +1,5 @@ --- Remove the 1px border around the slurp region selection used by screenshots. +-- Remove the 1px border around the slurp region selection used by recordings, OCR, and QR capture. hl.layer_rule({ match = { namespace = "selection" }, no_anim = true, animation = "none" }) + +-- Keep the Omasnap overlay immediate and out of screen shares. +hl.layer_rule({ match = { namespace = "^omasnap$" }, no_anim = true, animation = "none", no_screen_share = true }) diff --git a/default/hypr/apps/system.lua b/default/hypr/apps/system.lua index 789e3e03..3235e7b9 100644 --- a/default/hypr/apps/system.lua +++ b/default/hypr/apps/system.lua @@ -27,8 +27,6 @@ o.window("org.omarchy.about", { float = true }) o.window("org.omarchy.about", { center = true }) o.window("org.omarchy.about", { size = { 920, 480 } }) -o.window("dev.tensaku.Tensaku", { float = true }) -o.window("dev.tensaku.Tensaku", { center = true }) o.window("omacalc", { float = true }) -- Fullscreen screensaver. diff --git a/default/hypr/apps/windows-vm.lua b/default/hypr/apps/windows-vm.lua new file mode 100644 index 00000000..a31bf882 --- /dev/null +++ b/default/hypr/apps/windows-vm.lua @@ -0,0 +1,5 @@ +-- Keep the Windows VM display opaque instead of applying the default window opacity. +o.window({ class = "^xfreerdp$", title = "^Windows VM - Omarchy$" }, { + tag = "-default-opacity", + opacity = "1 1", +}) diff --git a/default/hypr/bindings/applications.lua b/default/hypr/bindings/applications.lua index 151a3fea..0c990e64 100644 --- a/default/hypr/bindings/applications.lua +++ b/default/hypr/bindings/applications.lua @@ -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" }) diff --git a/default/hypr/bindings/clipboard.lua b/default/hypr/bindings/clipboard.lua index f095ea5c..4aba781d 100644 --- a/default/hypr/bindings/clipboard.lua +++ b/default/hypr/bindings/clipboard.lua @@ -42,7 +42,8 @@ local function universal_clipboard_shortcut(default_mods, default_key, terminal_ end end -o.bind("SUPER + C", "Universal copy", universal_clipboard_shortcut("CTRL", "C", "CTRL", "Insert")) -o.bind("SUPER + V", "Universal paste", universal_clipboard_shortcut("CTRL", "V", "SHIFT", "Insert")) +o.bind("SUPER + A", "Select all", send_shortcut_once("CTRL", "A")) +o.bind("SUPER + C", "Universal copy", universal_clipboard_shortcut("CTRL", "C", "CTRL SHIFT", "C")) +o.bind("SUPER + V", "Universal paste", universal_clipboard_shortcut("CTRL", "V", "CTRL SHIFT", "V")) o.bind("SUPER + X", "Universal cut", send_shortcut_once("CTRL", "X")) o.bind("SUPER + CTRL + V", "Clipboard manager", "omarchy-shell shell toggle omarchy.clipboard") diff --git a/default/hypr/bindings/tiling.lua b/default/hypr/bindings/tiling.lua index 30a32982..72721b51 100644 --- a/default/hypr/bindings/tiling.lua +++ b/default/hypr/bindings/tiling.lua @@ -1,4 +1,5 @@ o.bind("SUPER + W", "Close window", hl.dsp.window.close()) +o.bind("SUPER + Q", "Close window", hl.dsp.window.close()) o.bind("CTRL + ALT + DELETE", "Close all windows", "omarchy-hyprland-window-close-all") o.bind("SUPER + J", "Toggle window split", hl.dsp.layout("togglesplit")) diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 406b773f..9c03d273 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -19,6 +19,7 @@ o.bind("SUPER + SHIFT + CTRL + SPACE", "Theme menu", "omarchy-menu toggle theme" o.bind("SUPER + BACKSPACE", "Toggle window transparency", "omarchy-hyprland-window-transparency-toggle") o.bind("SUPER + SHIFT + BACKSPACE", "Toggle window gaps", "omarchy-hyprland-window-gaps-toggle") o.bind("SUPER + CTRL + BACKSPACE", "Toggle single-window square aspect", "omarchy-hyprland-window-single-square-aspect-toggle") +o.bind_toggle("SUPER + CTRL + ALT + F", "Toggle full screen desktop", "fullscreen-desktop") -- xkbcommon names the comma keysym "comma"; the upper-case "COMMA" does not match. o.bind("SUPER + comma", "Dismiss last notification", "omarchy-shell notifications dismissOne") diff --git a/default/hypr/disabled-input-device.lua b/default/hypr/disabled-input-device.lua new file mode 100644 index 00000000..c9c20943 --- /dev/null +++ b/default/hypr/disabled-input-device.lua @@ -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 diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index d1affbc7..04964703 100644 --- a/default/hypr/helpers.lua +++ b/default/hypr/helpers.lua @@ -105,6 +105,11 @@ function o.bind(keys, description, dispatcher, options) hl.bind(keys, dispatcher, opts) end +function o.rebind(keys, description, dispatcher, options) + hl.unbind(keys) + o.bind(keys, description, dispatcher, options) +end + function o.launch(command) return "uwsm-app -- " .. command end @@ -136,7 +141,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) diff --git a/default/hypr/input.lua b/default/hypr/input.lua index bdcd384f..a87ad994 100644 --- a/default/hypr/input.lua +++ b/default/hypr/input.lua @@ -75,5 +75,7 @@ hl.config({ }) -- Scroll nicely in the terminal. -o.window("(Alacritty|kitty|foot)", { scroll_touchpad = 1.5 }) +o.window("(Alacritty|kitty)", { scroll_touchpad = 1.5 }) +-- foot only applies its scrollback multiplier to wheel clicks, not precise touchpad scrolling. +o.window("foot", { scroll_touchpad = 2.0 }) o.window("com.mitchellh.ghostty", { scroll_touchpad = 0.2 }) diff --git a/default/hypr/paths.lua b/default/hypr/paths.lua index dd3fc348..c20489f6 100644 --- a/default/hypr/paths.lua +++ b/default/hypr/paths.lua @@ -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"), } diff --git a/default/hypr/qconsole.lua b/default/hypr/qconsole.lua index d3a36ae6..a512f8ae 100644 --- a/default/hypr/qconsole.lua +++ b/default/hypr/qconsole.lua @@ -5,6 +5,14 @@ -- How much of the usable screen the console covers, measured from the top. local share = 0.5 +-- A console holding a single window is boxed into a centered panel this many +-- times wider than it is tall, rather than stretched the width of the screen. +-- A second app on the scratchpad gets the full width back: two windows splitting +-- a half-width column is worse than the band this replaced. +local box = 2 + +local SCRATCHPAD = "special:scratchpad" + -- Seed the console with the default agent the first time it opens, rather than -- at boot, so nothing is running until it is wanted. The exec rule has to pin -- the workspace itself: Hyprland only tags a spawn with the workspace it came @@ -23,22 +31,25 @@ hl.config({ }, }) +-- The panel is always flush with the top and always centered, so two numbers +-- describe it: the gap down each side and the gap underneath. +-- -- Refitting replaces the rule in place rather than stacking a new one, but it -- still schedules a monitor and window state refresh, and monitor.focused fires --- on every hop between screens. Most of those hops do not change the number, so +-- on every hop between screens. Most of those hops do not change the gaps, so -- only write the rule when it actually moves. -local covering = nil +local beside, below = nil, nil -local function cover(bottom) - if covering == bottom then - return +local function cover(side, bottom) + if beside == side and below == bottom then + return false end - covering = bottom + beside, below = side, bottom hl.workspace_rule({ - workspace = "special:scratchpad", + workspace = SCRATCHPAD, gaps_in = 0, - gaps_out = { top = 0, right = 0, bottom = bottom, left = 0 }, + gaps_out = { top = 0, right = side, bottom = bottom, left = side }, -- Nothing to highlight in a console that is only ever focused when it is -- open, and the active border reads as a stray frame around a panel that @@ -47,39 +58,136 @@ local function cover(bottom) on_created_empty = seed, }) + + return true +end + +-- One window reads as a console and gets the panel. A second app has turned the +-- scratchpad into a workspace, and a workspace wants the whole width. Only tiled +-- windows count: the gaps are what size the panel, and a floating window on top +-- of the console is not laid out by them. +local function alone() + local tiled = 0 + for _, window in ipairs(hl.get_workspace_windows(SCRATCHPAD)) do + if not window.floating then + tiled = tiled + 1 + end + end + return tiled <= 1 end -- Sizing the console with a window rule would freeze it at whatever the screen -- measured when it first opened, because Hyprland resolves those expressions -- once, as the window maps. Rescaling the monitor afterwards would leave a -- console that is no longer half of anything. Gaps are re-applied by the layout --- instead, so the console is sized by the gap left underneath it and that gap --- is recomputed whenever the monitor layout changes. -local function fit() - local monitor = hl.get_active_monitor() - +-- instead, so the console is sized by the area left around it, and that area is +-- recomputed whenever the monitor it opens on changes. +local function fit(monitor) -- A monitor handle whose output has gone away answers nil to every field, and -- layout changes are exactly when that happens, so this also covers reading - -- height and reserved below. + -- width, height and reserved below. if not monitor or not monitor.scale or monitor.scale <= 0 then - return + return false + end + + -- Width and height are the panel's own pixels, so a monitor turned on its + -- side still reports them the way the panel is built. The odd transforms are + -- the quarter turns, and those are the ones that swap the work area. + local width, height = monitor.width, monitor.height + if monitor.transform % 2 == 1 then + width, height = height, width end -- Monitor dimensions are in physical pixels; gaps are logical, so the scale -- has to come out before the reserved area (already logical) comes off. local reserved = monitor.reserved - local usable = monitor.height / monitor.scale - reserved.top - reserved.bottom + height = height / monitor.scale - reserved.top - reserved.bottom + width = width / monitor.scale - reserved.left - reserved.right - cover(math.max(0, math.floor(usable * (1 - share)))) + local tall = math.floor(height * share) + local wide = width + if alone() then + wide = math.min(width, tall * box) + end + + return cover(math.floor((width - wide) / 2), math.floor(height - tall)) +end + +-- The console keeps the geometry of the output it is open on: a follow_mouse hop +-- onto another screen must not resize a console that is already showing. While +-- it is hidden there is nothing to size but the output that will show it next. +local function console_monitor() + local ws = hl.get_workspace(SCRATCHPAD) + local mon = ws and ws.visible and ws.monitor + + if mon and mon.scale and mon.scale > 0 then + return mon + end + + return hl.get_active_monitor() +end + +local function refit(monitor) + if fit(monitor or console_monitor()) then + -- Land the new gaps in this pass rather than a frame later, so the console + -- does not visibly resize itself once it has already dropped down. + hl.exec_scheduled_prop_refresh_immediately() + end end -- Until a monitor can be read, cover the whole work area rather than leaving --- the console unruled, so it is never seeded without its placement. -cover(0) -fit() +-- the console unruled, so it is never seeded without its placement. A reload +-- runs this again with the console already on screen, so it starts from the +-- output the console is on rather than whichever one the pointer is over. +cover(0, 0) +fit(console_monitor()) -hl.on("monitor.layout_changed", fit) -hl.on("monitor.focused", fit) +hl.on("monitor.layout_changed", function() + refit() +end) + +hl.on("monitor.focused", function() + refit() +end) + +-- Special workspaces open on the monitor they are toggled on, not on whichever +-- output last happened to be focused when the rule was written, so these two +-- take the monitor they are handed rather than looking one up. +hl.on("workspace.special_active", function(ws, mon) + if ws and ws.name == SCRATCHPAD then + refit(mon) + end +end) + +hl.on("workspace.move_to_monitor", function(ws, mon) + if ws and ws.name == SCRATCHPAD then + refit(mon) + end +end) + +-- The panel is only centered while the console holds one tiled window, so the +-- count has to be rechecked as apps come and go: opened and closed, moved on or +-- off (Super+Alt+S, Super+Shift+1), and floated or tiled (Super+T, Super+O). +-- window.close is left out, since it still counts the window on its way out and +-- window.destroy follows it anyway. Measured on Hyprland 0.56.2, a move is +-- trailed by several window.update_rules, as is a float toggle, and the last of +-- those always reads the settled count; the earlier ones refit to what the rule +-- already is, which cover() skips. +-- +-- Only while it is on screen, though. A hidden console is refitted on its way in +-- by workspace.special_active, and every window opened anywhere on the desktop +-- would otherwise rewrite the rule. +local function recount() + local ws = hl.get_workspace(SCRATCHPAD) + if ws and ws.visible then + refit() + end +end + +hl.on("window.open", recount) +hl.on("window.destroy", recount) +hl.on("window.move_to_workspace", recount) +hl.on("window.update_rules", recount) -- The direction names the edge the offset is measured from, not where the -- workspace goes: "slide top" drops it down into view, and "slide bottom" diff --git a/default/hypr/require_all.lua b/default/hypr/require_all.lua index 56153a00..b77a55cd 100644 --- a/default/hypr/require_all.lua +++ b/default/hypr/require_all.lua @@ -4,6 +4,8 @@ -- Pass a module prefix for normal package.path modules, e.g. -- require_all.files(paths.omarchy_path .. "/default/hypr/apps", "default.hypr.apps") -- Pass nil as the prefix when the directory itself has been added to package.path. +-- Pass options.exclude as a set of base names (without ".lua") to skip; a legacy +-- file that must never be loaded as code stays on disk for a migration to remove. local M = {} @@ -12,19 +14,23 @@ local function shell_quote(path) end function M.files(dir, module_prefix, options) + local exclude = options and options.exclude or {} local handle = io.popen("find " .. shell_quote(dir) .. " -maxdepth 1 -type f -name '*.lua' -printf '%f\\n' 2>/dev/null | sort") if handle then for filename in handle:lines() do - local module = filename:gsub("%.lua$", "") - if module_prefix then - module = module_prefix .. "." .. module - end + local name = filename:gsub("%.lua$", "") + if not exclude[name] then + local module = name + if module_prefix then + module = module_prefix .. "." .. module + end - if options and options.reload then - package.loaded[module] = nil - end + if options and options.reload then + package.loaded[module] = nil + end - require(module) + require(module) + end end handle:close() end diff --git a/default/hypr/toggles.lua b/default/hypr/toggles.lua index 1ca62205..bb9f28d4 100644 --- a/default/hypr/toggles.lua +++ b/default/hypr/toggles.lua @@ -4,6 +4,20 @@ local require_all = require("default.hypr.require_all") local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr" package.path = toggles_dir .. "/?.lua;" .. package.path -require_all.files(toggles_dir, nil, { reload = true }) +-- touchpad-disabled.lua / touchscreen-disabled.lua were generated Lua in older +-- versions and could carry an injected USB device name. They must never be loaded +-- as code again: exclude them so a not-yet-migrated install cannot execute a +-- leftover payload on reload. The migration recovers the name and deletes them. +require_all.files(toggles_dir, nil, { + reload = true, + exclude = { + ["touchpad-disabled"] = true, + ["touchscreen-disabled"] = true, + }, +}) + +local disabled_input_device = require("default.hypr.disabled-input-device") +disabled_input_device("touchpad") +disabled_input_device("touchscreen") require("default.hypr.workspace-layouts") diff --git a/default/omarchy/launcher.hides b/default/omarchy/launcher.hides index 113565c7..77a57d3b 100644 --- a/default/omarchy/launcher.hides +++ b/default/omarchy/launcher.hides @@ -12,6 +12,7 @@ fcitx5-configtool fcitx5-wayland-launcher foot-server footclient +hermes java-java-openjdk jconsole-java-openjdk jshell-java-openjdk diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index e204da69..cda35f98 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -103,7 +103,7 @@ // Style "style.theme": {"icon":"󰸌","label":"Theme","aliases":["theme","themes"],"action":"theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""}, "style.background": {"icon":"","label":"Background","aliases":["background","wallpaper"],"action":"background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\""}, - "style.unlock": {"icon":"󰟵","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme '$unlock'\"; fi"}, + "style.unlock": {"icon":"󰟵","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme $(printf %q \"$unlock\")\"; fi"}, "style.font": {"icon":"","label":"Font","provider":"fonts"}, "style.bar": {"icon":"󰍜","label":"Menu Bar"}, "style.bar.position": {"icon":"","label":"Position"}, @@ -140,9 +140,14 @@ "setup.default.agent.codex": {"icon":"","iconFont":"omarchy","label":"Codex","checked":"[[ \"$(omarchy-default-agent)\" == \"codex\" ]]","action":"omarchy-default-agent codex"}, "setup.default.agent.copilot": {"icon":"","label":"Copilot","checked":"[[ \"$(omarchy-default-agent)\" == \"copilot\" ]]","action":"omarchy-default-agent copilot"}, "setup.default.agent.crush": {"icon":"󰋑","label":"Crush","checked":"[[ \"$(omarchy-default-agent)\" == \"crush\" ]]","action":"omarchy-default-agent crush"}, + "setup.default.agent.cursor-agent": {"icon":"","iconFont":"omarchy","label":"Cursor CLI","checked":"[[ \"$(omarchy-default-agent)\" == \"cursor-agent\" ]]","action":"omarchy-default-agent cursor-agent"}, "setup.default.agent.grok": {"icon":"","iconFont":"omarchy","label":"Grok","checked":"[[ \"$(omarchy-default-agent)\" == \"grok\" ]]","action":"omarchy-default-agent grok"}, + "setup.default.agent.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes","checked":"[[ \"$(omarchy-default-agent)\" == \"hermes\" ]]","action":"omarchy-default-agent hermes"}, + "setup.default.agent.muse": {"icon":"󰛤","label":"Muse Code","checked":"[[ \"$(omarchy-default-agent)\" == \"muse\" ]]","action":"omarchy-default-agent muse"}, "setup.default.agent.omp": {"icon":"","iconFont":"omarchy","label":"omp","checked":"[[ \"$(omarchy-default-agent)\" == \"omp\" ]]","action":"omarchy-default-agent omp"}, + "setup.default.agent.openclaw": {"icon":"","iconFont":"omarchy","label":"OpenClaw","checked":"[[ \"$(omarchy-default-agent)\" == \"openclaw\" ]]","action":"omarchy-default-agent openclaw"}, "setup.default.agent.opencode": {"icon":"","iconFont":"omarchy","label":"OpenCode","checked":"[[ \"$(omarchy-default-agent)\" == \"opencode\" ]]","action":"omarchy-default-agent opencode"}, + "setup.default.agent.ori": {"icon":"","iconFont":"omarchy","label":"Ori","checked":"[[ \"$(omarchy-default-agent)\" == \"ori\" ]]","action":"omarchy-default-agent ori"}, "setup.default.agent.pi": {"icon":"","iconFont":"omarchy","label":"Pi","checked":"[[ \"$(omarchy-default-agent)\" == \"pi\" ]]","action":"omarchy-default-agent pi"}, "setup.default.browser": {"icon":"","label":"Browser","title":"Default Browser"}, "setup.default.browser.chromium": {"icon":"","label":"Chromium","checked":"[[ \"$(omarchy-default-browser)\" == \"chromium\" ]]","action":"omarchy-default-browser chromium"}, @@ -160,12 +165,12 @@ "setup.default.editor": {"icon":"","label":"Editor","title":"Default Editor"}, "setup.default.editor.neovim": {"icon":"","label":"Neovim","checked":"[[ \"$(omarchy-default-editor)\" == \"nvim\" ]]","action":"omarchy-default-editor nvim"}, "setup.default.editor.vscode": {"icon":"","label":"VSCode","checked":"[[ \"$(omarchy-default-editor)\" == \"code\" ]]","action":"omarchy-default-editor code"}, - "setup.default.editor.cursor": {"icon":"","label":"Cursor","checked":"[[ \"$(omarchy-default-editor)\" == \"cursor\" ]]","action":"omarchy-default-editor cursor"}, + "setup.default.editor.cursor": {"icon":"","iconFont":"omarchy","label":"Cursor","checked":"[[ \"$(omarchy-default-editor)\" == \"cursor\" ]]","action":"omarchy-default-editor cursor"}, "setup.default.editor.zed": {"icon":"","label":"Zed","checked":"[[ \"$(omarchy-default-editor)\" == \"zeditor\" ]]","action":"omarchy-default-editor zed"}, - "setup.default.editor.sublime": {"icon":"","label":"Sublime Text","checked":"[[ \"$(omarchy-default-editor)\" == \"sublime_text\" ]]","action":"omarchy-default-editor sublime_text"}, + "setup.default.editor.sublime": {"icon":"","label":"Sublime Text","checked":"[[ \"$(omarchy-default-editor)\" == \"subl\" ]]","action":"omarchy-default-editor sublime_text"}, "setup.default.editor.helix": {"icon":"","label":"Helix","checked":"[[ \"$(omarchy-default-editor)\" == \"helix\" ]]","action":"omarchy-default-editor helix"}, "setup.default.editor.vim": {"icon":"","label":"Vim","checked":"[[ \"$(omarchy-default-editor)\" == \"vim\" ]]","action":"omarchy-default-editor vim"}, - "setup.default.editor.emacs": {"icon":"","label":"Emacs","checked":"[[ \"$(omarchy-default-editor)\" == \"emacs\" ]]","action":"omarchy-default-editor emacs"}, + "setup.default.editor.emacs": {"icon":" ","label":"Emacs","checked":"[[ \"$(omarchy-default-editor)\" == \"emacs\" ]]","action":"omarchy-default-editor emacs"}, "setup.plugin": {"icon":"󰐱","label":"Plugins","aliases":["plugin","plugins"]}, "setup.plugin.enable": {"icon":"󰄬","label":"Enable Plugin","action":"omarchy-menu-plugin enable"}, "setup.plugin.disable": {"icon":"󰅖","label":"Disable Plugin","action":"omarchy-menu-plugin disable"}, @@ -178,6 +183,7 @@ "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"}, "setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"}, + "setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"}, "setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""}, "setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"}, "setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"}, @@ -224,7 +230,7 @@ "install.service.bitwarden": {"icon":"󰟵","label":"Bitwarden","disabled":"omarchy-pkg-present bitwarden","action":"omarchy-install-and-launch Bitwarden 'bitwarden bitwarden-cli' bitwarden"}, "install.service.chromium-account": {"icon":"","label":"Chromium Account","when":"[[ -f ~/.config/chromium-flags.conf ]]","disabled":"grep -q oauth2-client-id ~/.config/chromium-flags.conf","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-chromium-google-account"}, "install.editor.vscode": {"icon":"","label":"VSCode","disabled":"omarchy-pkg-present visual-studio-code-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-vscode"}, - "install.editor.cursor": {"icon":"","label":"Cursor","disabled":"omarchy-pkg-present cursor-bin","action":"omarchy-install-and-launch Cursor cursor-bin cursor"}, + "install.editor.cursor": {"icon":"","iconFont":"omarchy","label":"Cursor","disabled":"omarchy-pkg-present cursor-bin","action":"omarchy-install-and-launch Cursor cursor-bin cursor"}, "install.editor.zed": {"icon":"","label":"Zed","disabled":"omarchy-pkg-present zed","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-zed"}, "install.editor.sublime": {"icon":"","label":"Sublime Text","disabled":"omarchy-pkg-present sublime-text-4","action":"omarchy-install-and-launch 'Sublime Text' sublime-text-4 sublime_text"}, "install.editor.helix": {"icon":"","label":"Helix","disabled":"omarchy-pkg-present helix","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-helix"}, @@ -235,11 +241,15 @@ "install.terminal.ghostty": {"icon":"","label":"Ghostty","disabled":"omarchy-pkg-present ghostty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal ghostty'"}, "install.terminal.kitty": {"icon":"","label":"Kitty","disabled":"omarchy-pkg-present kitty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal kitty'"}, "install.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","disabled":"omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-chatgpt"}, + "install.ai.claude": {"icon":"","iconFont":"omarchy","label":"Claude Desktop","disabled":"omarchy-pkg-present claude-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-claude"}, "install.ai.dictation": {"icon":"","label":"Dictation","disabled":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"}, "install.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","disabled":"omarchy-pkg-present grok-bot","action":"omarchy-install-and-launch 'Grok Bot' grok-bot grok-bot"}, + "install.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","disabled":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-hermes"}, "install.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","disabled":"omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"}, "install.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","disabled":"omarchy-cmd-present ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; omarchy-install-app Ollama \"$ollama_pkg\""}, - "install.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","disabled":"omarchy-pkg-present t3code-bin","action":"omarchy-install-and-launch 'T3 Code' t3code-bin t3code"}, + "install.ai.openclaw": {"icon":"","iconFont":"omarchy","label":"OpenClaw","disabled":"omarchy-pkg-present openclaw","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-openclaw"}, + "install.ai.perplexity": {"icon":"","iconFont":"omarchy","label":"Perplexity","disabled":"omarchy-pkg-present perplexity","action":"omarchy-install-and-launch Perplexity perplexity perplexity"}, + "install.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","disabled":"omarchy-pkg-present t3code-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-t3-code"}, "install.gaming.steam": {"icon":"","label":"Steam","disabled":"omarchy-pkg-present steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-steam"}, "install.gaming.retroarch": {"icon":"󰯉","label":"RetroArch","disabled":"omarchy-pkg-present retroarch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-retroarch"}, "install.gaming.minecraft": {"icon":"󰍳","label":"Minecraft","disabled":"omarchy-pkg-present minecraft-launcher","action":"omarchy-install-and-launch Minecraft minecraft-launcher minecraft-launcher"}, @@ -267,8 +277,8 @@ "install.development.javascript.node": {"icon":"","label":"Node.js","disabled":"[[ -d $HOME/.local/share/mise/installs/node ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env node'"}, "install.development.javascript.bun": {"icon":"","label":"Bun","disabled":"[[ -d $HOME/.local/share/mise/installs/bun ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env bun'"}, "install.development.javascript.deno": {"icon":"","label":"Deno","disabled":"[[ -d $HOME/.local/share/mise/installs/deno ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env deno'"}, - "install.development.php.php": {"icon":"","label":"PHP","disabled":"omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env php'"}, - "install.development.php.laravel": {"icon":"","label":"Laravel","disabled":"[[ -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env laravel'"}, + "install.development.php.php": {"icon":"","label":"PHP","disabled":"[[ -d $HOME/.local/share/mise/installs/php ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env php'"}, + "install.development.php.laravel": {"icon":"","label":"Laravel","disabled":"[[ -x $HOME/.local/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env laravel'"}, "install.development.php.symfony": {"icon":"","label":"Symfony","disabled":"omarchy-pkg-present symfony-cli","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env symfony'"}, "install.development.elixir.elixir": {"icon":"","label":"Elixir","disabled":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env elixir'"}, "install.development.elixir.phoenix": {"icon":"","label":"Phoenix","disabled":"compgen -G \"$HOME/.mix/archives/phx_new*\"","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env phoenix'"}, @@ -289,6 +299,8 @@ "remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"}, "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, "remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"}, + "remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, + "remove.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"}, "remove.browser.chrome": {"icon":"","label":"Chrome","when":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser chrome'"}, "remove.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser edge'"}, "remove.browser.brave": {"icon":"","label":"Brave","when":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser brave'"}, @@ -298,10 +310,13 @@ "remove.service.dropbox": {"icon":"","label":"Dropbox","when":"omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-service-dropbox"}, "remove.service.tailscale": {"icon":"","label":"Tailscale","when":"omarchy-pkg-present tailscale","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-service-tailscale"}, "remove.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","when":"omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-chatgpt"}, + "remove.ai.claude": {"icon":"","iconFont":"omarchy","label":"Claude Desktop","when":"omarchy-pkg-present claude-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-claude"}, "remove.ai.dictation": {"icon":"","label":"Dictation","when":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-remove"}, "remove.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","when":"omarchy-pkg-present grok-bot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-grok-bot"}, "remove.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","when":"omarchy-pkg-present lmstudio-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-lm-studio"}, "remove.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","when":"omarchy-pkg-present ollama","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-ollama"}, + "remove.ai.openclaw": {"icon":"","iconFont":"omarchy","label":"OpenClaw","when":"omarchy-pkg-present openclaw","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-openclaw"}, + "remove.ai.perplexity": {"icon":"","iconFont":"omarchy","label":"Perplexity","when":"omarchy-pkg-present perplexity","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-perplexity"}, "remove.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","when":"omarchy-pkg-present t3code-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-t3-code"}, "remove.gaming.steam": {"icon":"","label":"Steam","when":"omarchy-pkg-present steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-gaming-steam"}, "remove.gaming.retroarch": {"icon":"","label":"RetroArch","when":"omarchy-pkg-present retroarch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-gaming-retroarch"}, @@ -328,8 +343,8 @@ "remove.development.javascript.node": {"icon":"","label":"Node.js","when":"[[ -d $HOME/.local/share/mise/installs/node ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env node'"}, "remove.development.javascript.bun": {"icon":"","label":"Bun","when":"[[ -d $HOME/.local/share/mise/installs/bun ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env bun'"}, "remove.development.javascript.deno": {"icon":"","label":"Deno","when":"[[ -d $HOME/.local/share/mise/installs/deno ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env deno'"}, - "remove.development.php.php": {"icon":"","label":"PHP","when":"omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env php'"}, - "remove.development.php.laravel": {"icon":"","label":"Laravel","when":"[[ -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env laravel'"}, + "remove.development.php.php": {"icon":"","label":"PHP","when":"[[ -d $HOME/.local/share/mise/installs/php ]] || omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env php'"}, + "remove.development.php.laravel": {"icon":"","label":"Laravel","when":"[[ -x $HOME/.local/bin/laravel || -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env laravel'"}, "remove.development.php.symfony": {"icon":"","label":"Symfony","when":"omarchy-pkg-present symfony-cli","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env symfony'"}, "remove.development.elixir.elixir": {"icon":"","label":"Elixir","when":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env elixir'"}, "remove.development.elixir.phoenix": {"icon":"","label":"Phoenix","when":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env phoenix'"}, @@ -338,7 +353,7 @@ "update.omarchy": {"icon":"","iconFont":"omarchy","label":"Omarchy","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update"}, "update.channel": {"icon":"󰔫","label":"Channel"}, "update.config": {"icon":"","label":"Config","title":"Reset to default"}, - "update.themes": {"icon":"󰸌","label":"Extra Themes","action":"omarchy-launch-floating-terminal-with-presentation omarchy-theme-update"}, + "update.themes": {"icon":"󰸌","label":"Extra Themes","when":"omarchy-theme-extras","action":"omarchy-launch-floating-terminal-with-presentation omarchy-theme-update"}, "update.process": {"icon":"","label":"Process","title":"Restart"}, "update.hardware": {"icon":"󰇅","label":"Hardware","title":"Restart"}, "update.firmware": {"icon":"","label":"Firmware","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update-firmware"}, diff --git a/default/pacman/pacman-edge.conf b/default/pacman/pacman-edge.conf index d83befca..a968d26d 100644 --- a/default/pacman/pacman-edge.conf +++ b/default/pacman/pacman-edge.conf @@ -26,7 +26,6 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/edge/$arch # Repositories for debug symbol packages. diff --git a/default/pacman/pacman-rc.conf b/default/pacman/pacman-rc.conf index 50d2e498..66ad3fb4 100644 --- a/default/pacman/pacman-rc.conf +++ b/default/pacman/pacman-rc.conf @@ -26,5 +26,4 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll -Server = https://pkgs.omarchy.org/edge/$arch +Server = https://pkgs.omarchy.org/rc/$arch diff --git a/default/pacman/pacman-stable.conf b/default/pacman/pacman-stable.conf index 5dafbc84..7e4b5538 100644 --- a/default/pacman/pacman-stable.conf +++ b/default/pacman/pacman-stable.conf @@ -26,5 +26,4 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/stable/$arch diff --git a/default/plymouth/omarchy.script b/default/plymouth/omarchy.script index 581514d7..0c9492bd 100644 --- a/default/plymouth/omarchy.script +++ b/default/plymouth/omarchy.script @@ -5,8 +5,6 @@ Window.SetBackgroundBottomColor(0.101, 0.105, 0.149); logo.image = Image("logo.png"); logo.sprite = Sprite(logo.image); -logo.sprite.SetX(Window.GetWidth() / 2 - logo.image.GetWidth() / 2); -logo.sprite.SetY(Window.GetHeight() / 2 - logo.image.GetHeight() / 2); logo.sprite.SetOpacity(1); # Use these to adjust the progress bar timing @@ -22,7 +20,18 @@ global.fake_progress_start_time = 0.0; # Track when fake progress started global.password_shown = 0; # Track if password dialog has been shown global.max_progress = 0.0; # Track the maximum progress reached to prevent backwards movement +# Window size the sprites were last laid out for +global.layout_width = 0; +global.layout_height = 0; + fun refresh_callback() { + # Displays can show up after this script ran, e.g. a Thunderbolt monitor + # whose DisplayPort tunnel comes up after plymouthd started. The window + # grows to the largest display, so re-center everything when it changes. + if (Window.GetWidth() != global.layout_width || Window.GetHeight() != global.layout_height) { + layout(); + } + global.animation_frame++; # Animate fake progress to limit over time with easing @@ -108,27 +117,23 @@ entry.image = Image("entry.png"); bullet.image = Image("bullet.png"); entry.sprite = Sprite(entry.image); -entry.x = Window.GetWidth() / 2 - entry.image.GetWidth() / 2; -entry.y = logo.sprite.GetY() + logo.image.GetHeight() + 40; -entry.sprite.SetPosition(entry.x, entry.y, 10001); entry.sprite.SetOpacity(0); # Scale lock to be slightly shorter than entry field height # Original lock is 84x96, entry height determines scale -lock_height = entry.image.GetHeight() * 0.8; -lock_scale = lock_height / 96; -lock_width = 84 * lock_scale; +lock.height = entry.image.GetHeight() * 0.8; +lock.width = 84 * (lock.height / 96); -scaled_lock = lock.image.Scale(lock_width, lock_height); -lock.sprite = Sprite(scaled_lock); -lock.x = entry.x - lock_width - 15; -lock.y = entry.y + entry.image.GetHeight() / 2 - lock_height / 2; -lock.sprite.SetPosition(lock.x, lock.y, 10001); +lock.sprite = Sprite(lock.image.Scale(lock.width, lock.height)); lock.sprite.SetOpacity(0); # Bullet array bullet.sprites = []; +fun position_bullet(index) { + bullet.sprites[index].SetPosition(entry.x + 20 + index * (7 + 5), entry.y + entry.image.GetHeight() / 2 - 3.5, 10002); +} + fun display_normal_callback() { hide_password_dialog(); @@ -165,11 +170,8 @@ fun display_password_callback(prompt, bullets) { for (index = 0; index < bullets_to_show; index++) { if (!bullet.sprites[index]) { # Scale bullet image to 7x7 pixels - scaled_bullet = bullet.image.Scale(7, 7); - bullet.sprites[index] = Sprite(scaled_bullet); - bullet.x = entry.x + 20 + index * (7 + 5); - bullet.y = entry.y + entry.image.GetHeight() / 2 - 3.5; - bullet.sprites[index].SetPosition(bullet.x, bullet.y, 10002); + bullet.sprites[index] = Sprite(bullet.image.Scale(7, 7)); + position_bullet(index); } bullet.sprites[index].SetOpacity(1); @@ -183,19 +185,11 @@ Plymouth.SetDisplayPasswordFunction(display_password_callback); progress_box.image = Image("progress_box.png"); progress_box.sprite = Sprite(progress_box.image); - -progress_box.x = Window.GetWidth() / 2 - progress_box.image.GetWidth() / 2; -progress_box.y = entry.y + entry.image.GetHeight() / 2 - progress_box.image.GetHeight() / 2; -progress_box.sprite.SetPosition(progress_box.x, progress_box.y, 0); progress_box.sprite.SetOpacity(0); progress_bar.original_image = Image("progress_bar.png"); progress_bar.sprite = Sprite(); progress_bar.image = progress_bar.original_image.Scale(1, progress_bar.original_image.GetHeight()); - -progress_bar.x = Window.GetWidth() / 2 - progress_bar.original_image.GetWidth() / 2; -progress_bar.y = progress_box.y + (progress_box.image.GetHeight() - progress_bar.original_image.GetHeight()) / 2; -progress_bar.sprite.SetPosition(progress_bar.x, progress_bar.y, 1); progress_bar.sprite.SetOpacity(0); fun progress_callback(duration, progress) { @@ -216,6 +210,39 @@ fun progress_callback(duration, progress) { Plymouth.SetBootProgressFunction(progress_callback); +#----------------------------------------- Layout -------------------------------- + +# Center the logo, dialogue and progress bar for the current window size +fun layout() { + global.layout_width = Window.GetWidth(); + global.layout_height = Window.GetHeight(); + + logo.sprite.SetX(global.layout_width / 2 - logo.image.GetWidth() / 2); + logo.sprite.SetY(global.layout_height / 2 - logo.image.GetHeight() / 2); + + entry.x = global.layout_width / 2 - entry.image.GetWidth() / 2; + entry.y = logo.sprite.GetY() + logo.image.GetHeight() + 40; + entry.sprite.SetPosition(entry.x, entry.y, 10001); + + lock.x = entry.x - lock.width - 15; + lock.y = entry.y + entry.image.GetHeight() / 2 - lock.height / 2; + lock.sprite.SetPosition(lock.x, lock.y, 10001); + + for (index = 0; bullet.sprites[index]; index++) { + position_bullet(index); + } + + progress_box.x = global.layout_width / 2 - progress_box.image.GetWidth() / 2; + progress_box.y = entry.y + entry.image.GetHeight() / 2 - progress_box.image.GetHeight() / 2; + progress_box.sprite.SetPosition(progress_box.x, progress_box.y, 0); + + progress_bar.x = global.layout_width / 2 - progress_bar.original_image.GetWidth() / 2; + progress_bar.y = progress_box.y + (progress_box.image.GetHeight() - progress_bar.original_image.GetHeight()) / 2; + progress_bar.sprite.SetPosition(progress_bar.x, progress_bar.y, 1); +} + +layout(); + #----------------------------------------- Message -------------------------------- message_sprite = Sprite(); diff --git a/default/systemd/system-sleep/force-igpu b/default/systemd/system-sleep/force-igpu index 6f129438..8568067e 100644 --- a/default/systemd/system-sleep/force-igpu +++ b/default/systemd/system-sleep/force-igpu @@ -1,29 +1,65 @@ #!/bin/bash +set -e + # Use the Vfio to Integrated trick to turn off NVIDIA dgpu when in integrated mode # without needing to restart the computer. This is needed because computers like the Asus G14 # will wake after suspend in Hybrid mode, even if the system was in Integrated mode before # suspending. +restore_marker=/run/omarchy-force-igpu-integrated +sleep_action=${SYSTEMD_SLEEP_ACTION:-$2} +[[ -x /usr/bin/supergfxctl ]] || exit 0 + +switch_mode() { + local expected="$1" current + + if ! /usr/bin/timeout --kill-after=1s 3s /usr/bin/supergfxctl -m "$expected"; then + echo "Could not request the GPU transition to $expected mode" >&2 + return 1 + fi + for _ in {1..10}; do + if current=$(/usr/bin/timeout --kill-after=1s 2s /usr/bin/supergfxctl -g 2>/dev/null) && + [[ $current == "$expected" ]]; then + return 0 + fi + sleep 1 + done + + echo "Could not confirm the GPU transition to $expected mode" >&2 + return 1 +} + case "$1" in pre) + # Remember the mode this sleep cycle started in. supergfxctl persists the + # temporary hibernate switch to Vfio, so post must not consult that mutable + # value when deciding whether to restore Integrated mode. + if [[ -L $restore_marker ]]; then + exit 1 + elif [[ ! -f $restore_marker ]]; then + /usr/bin/grep -Eq '"mode"[[:space:]]*:[[:space:]]*"Integrated"' /etc/supergfxd.conf 2>/dev/null || exit 0 + /usr/bin/install -m 0600 -o root -g root -T /dev/null "$restore_marker" + fi + # Before hibernating, switch to Vfio so the nvidia driver is detached from the dGPU. # Without this, hibernate resume fails because the nvidia driver can't freeze a # powered-off dGPU (returns -EIO), which aborts the entire resume. - if [[ $2 == "hibernate" ]]; then - /usr/bin/supergfxctl -m Vfio - sleep 1 + if [[ $sleep_action == "hibernate" ]]; then + switch_mode Vfio fi ;; post) + [[ -f $restore_marker && ! -L $restore_marker ]] || exit 0 + # small delay so the device is fully re-enumerated sleep 4 # force-bind dGPU to vfio (fully detached from nvidia) - /usr/bin/supergfxctl -m Vfio - sleep 1 + switch_mode Vfio # then go back to Integrated, which powers it off again - /usr/bin/supergfxctl -m Integrated + switch_mode Integrated + /usr/bin/rm -f -- "$restore_marker" ;; esac diff --git a/default/systemd/system-sleep/keyboard-backlight b/default/systemd/system-sleep/keyboard-backlight index c6fbea1c..014ab4a8 100644 --- a/default/systemd/system-sleep/keyboard-backlight +++ b/default/systemd/system-sleep/keyboard-backlight @@ -3,7 +3,9 @@ # Turn off keyboard backlight before hibernate to prevent hang on power-off. # The ASUS keyboard controller can block S4 shutdown if LEDs are active. -if [[ $1 == "pre" && $2 == "hibernate" ]]; then +sleep_action=${SYSTEMD_SLEEP_ACTION:-$2} + +if [[ $1 == "pre" && $sleep_action == "hibernate" ]]; then device="" for candidate in /sys/class/leds/*kbd_backlight*; do if [[ -e "$candidate" ]]; then diff --git a/default/systemd/system/camlink-4k-loopback.service b/default/systemd/system/camlink-4k-loopback.service new file mode 100644 index 00000000..3b7d7b07 --- /dev/null +++ b/default/systemd/system/camlink-4k-loopback.service @@ -0,0 +1,9 @@ +[Unit] +Description=Create the Cam Link 4K virtual camera +After=systemd-modules-load.service + +[Service] +# Reruns on every relay start, so a deleted or unloaded device comes back. +Type=oneshot +ExecStart=/usr/bin/modprobe v4l2loopback +ExecStart=/bin/sh -c 'grep -qsx "Cam Link 4K" /sys/devices/virtual/video4linux/*/name || exec v4l2loopback-ctl add -n "Cam Link 4K" -x 1' diff --git a/default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf b/default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf new file mode 100644 index 00000000..571a53bc --- /dev/null +++ b/default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=/usr/bin/updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots diff --git a/default/systemd/system/v4l2-relayd@camlink.service.d/camlink.conf b/default/systemd/system/v4l2-relayd@camlink.service.d/camlink.conf new file mode 100644 index 00000000..956b2a41 --- /dev/null +++ b/default/systemd/system/v4l2-relayd@camlink.service.d/camlink.conf @@ -0,0 +1,16 @@ +[Unit] +# udev starts this when the Cam Link 4K appears. Only the device's stop is +# tied in: a start dependency on it would leave a job waiting whenever the +# base v4l2-relayd.service is started with no Cam Link attached. +ConditionPathExists=/dev/camlink4k +StopPropagatedFrom=dev-camlink4k.device +Requires=camlink-4k-loopback.service +After=camlink-4k-loopback.service + +[Service] +RestartSec=2 +# The packaged command line, plus sync=false on the sink. v4l2src stamps each +# frame with its capture time, so by the time it reaches the sink it is already +# past due and a synced sink drops every one of them. +ExecStart= +ExecStart=/bin/sh -c 'DEVICE=$(grep -l -m1 -E "^${CARD_LABEL}$" /sys/devices/virtual/video4linux/*/name | cut -d/ -f6); exec /usr/bin/v4l2-relayd -i "${VIDEOSRC}" $${SPLASHSRC:+-s "$${SPLASHSRC}"} -o "appsrc name=appsrc caps=video/x-raw,format=${FORMAT},width=${WIDTH},height=${HEIGHT},framerate=${FRAMERATE} ! videoconvert ! v4l2sink name=v4l2sink sync=false device=/dev/$${DEVICE}" $EXTRA_OPTS' diff --git a/default/systemd/user/omarchy-speaker-tuning.service b/default/systemd/user/omarchy-speaker-tuning.service index 53b37447..ed6145a9 100644 --- a/default/systemd/user/omarchy-speaker-tuning.service +++ b/default/systemd/user/omarchy-speaker-tuning.service @@ -1,6 +1,6 @@ [Unit] Description=Omarchy speaker tuning filter-chain -Documentation=https://github.com/basecamp/omarchy/blob/master/docs/audio-tuning.md +Documentation=https://github.com/omacom/omarchy/blob/master/docs/audio-tuning.md # WirePlumber does the linking, so starting before it is up risks the output being # linked before the speaker device has been discovered. After=pipewire.service wireplumber.service diff --git a/default/themed/hermes.yaml.tpl b/default/themed/hermes.yaml.tpl new file mode 100644 index 00000000..2e39710e --- /dev/null +++ b/default/themed/hermes.yaml.tpl @@ -0,0 +1,47 @@ +name: omarchy +description: Omarchy system theme +colors: + background: "{{ background }}" + ui_text: "{{ foreground }}" + ui_primary: "{{ accent }}" + ui_accent: "{{ accent }}" + ui_border: "{{ muted }}" + ui_label: "{{ accent }}" + ui_ok: "{{ green }}" + ui_warn: "{{ yellow }}" + ui_error: "{{ red }}" + ui_tool: "{{ cyan }}" + ui_thinking: "{{ dark_foreground }}" + banner_border: "{{ muted }}" + banner_title: "{{ accent }}" + banner_accent: "{{ accent }}" + banner_dim: "{{ dark_foreground }}" + banner_text: "{{ foreground }}" + prompt: "{{ bright_foreground }}" + input_rule: "{{ muted }}" + response_border: "{{ accent }}" + shell_dollar: "{{ blue }}" + selection_bg: "{{ selection }}" + session_label: "{{ accent }}" + session_border: "{{ muted }}" + status_bar_bg: "{{ dark_background }}" + status_bar_text: "{{ foreground }}" + status_bar_strong: "{{ accent }}" + status_bar_dim: "{{ dark_foreground }}" + status_bar_good: "{{ green }}" + status_bar_warn: "{{ yellow }}" + status_bar_bad: "{{ red }}" + status_bar_critical: "{{ bright_red }}" + voice_status_bg: "{{ dark_background }}" + completion_menu_bg: "{{ lighter_background }}" + completion_menu_current_bg: "{{ selection }}" + completion_menu_meta_bg: "{{ lighter_background }}" + completion_menu_meta_current_bg: "{{ selection }}" + diff_added: "{{ mix background green 15% }}" + diff_removed: "{{ mix background red 15% }}" + diff_added_word: "{{ green }}" + diff_removed_word: "{{ red }}" + syntax_string: "{{ green }}" + syntax_number: "{{ yellow }}" + syntax_keyword: "{{ magenta }}" + syntax_comment: "{{ muted }}" diff --git a/default/themed/t3code.json.tpl b/default/themed/t3code.json.tpl new file mode 100644 index 00000000..e420dcc4 --- /dev/null +++ b/default/themed/t3code.json.tpl @@ -0,0 +1,36 @@ +{ + "name": "Omarchy", + "appearance": "{{ mode }}", + "canvas": "{{ background }}", + "accent": "{{ accent }}", + "colors": { + "chrome": "{{ background }}", + "toolbar": "{{ background }}", + "toolbarForeground": "{{ foreground }}", + "toolbarBorder": "{{ muted }}", + + "text": "{{ foreground }}", + "border": "{{ muted }}", + "focus": "{{ accent }}", + + "sidebar": "{{ dark_background }}", + "sidebarForeground": "{{ foreground }}", + "sidebarBorder": "{{ muted }}", + "sidebarRowHover": "{{ mix dark_background foreground 6% }}", + "sidebarRowActive": "{{ mix dark_background accent 18% }}", + "sidebarRowSelected": "{{ selection }}", + + "error": "{{ mix red foreground 35% }}", + "warning": "{{ mix yellow foreground 35% }}", + + "codeBackground": "{{ dark_background }}", + "codeForeground": "{{ foreground }}", + + "terminalBackground": "{{ background }}", + "terminalForeground": "{{ foreground }}", + "terminalCursor": "{{ bright_foreground }}", + "terminalSelection": "{{ selection }}", + "terminalScrollbar": "{{ muted }}", + "terminalScrollbarHover": "{{ dark_foreground }}" + } +} diff --git a/default/themed/vscode-theme.json.tpl b/default/themed/vscode-theme.json.tpl index 0897ca05..08cc795c 100644 --- a/default/themed/vscode-theme.json.tpl +++ b/default/themed/vscode-theme.json.tpl @@ -61,7 +61,7 @@ "textPreformat.background": "{{ background }}", "textSeparator.foreground": "{{ muted }}", - "toolbar.hoverBackground": "{{ background }}", + "toolbar.hoverBackground": "{{ muted }}60", "toolbar.activeBackground": "{{ muted }}", "button.background": "{{ accent }}", @@ -69,7 +69,7 @@ "button.hoverBackground": "{{ blue }}", "button.secondaryForeground": "{{ foreground }}", "button.secondaryBackground": "{{ muted }}", - "button.secondaryHoverBackground": "{{ background }}", + "button.secondaryHoverBackground": "{{ muted }}80", "button.border": "{{ accent }}20", "checkbox.background": "{{ background }}", "checkbox.foreground": "{{ foreground }}", @@ -135,7 +135,7 @@ "tree.indentGuidesStroke": "{{ muted }}", "tree.inactiveIndentGuidesStroke": "{{ muted }}60", "tree.tableColumnsBorder": "{{ muted }}", - "tree.tableOddRowsBackground": "{{ background }}40", + "tree.tableOddRowsBackground": "{{ muted }}20", "activityBar.background": "{{ background }}", "activityBar.dropBorder": "{{ accent }}", @@ -145,7 +145,7 @@ "activityBarBadge.background": "{{ accent }}", "activityBarBadge.foreground": "{{ background }}", "activityBar.activeBorder": "{{ accent }}", - "activityBar.activeBackground": "{{ background }}40", + "activityBar.activeBackground": "{{ muted }}80", "sideBar.background": "{{ background }}", "sideBar.foreground": "{{ foreground }}", @@ -230,8 +230,8 @@ "searchEditor.findMatchBackground": "{{ yellow }}40", "searchEditor.findMatchBorder": "{{ yellow }}", "editor.hoverHighlightBackground": "{{ accent }}20", - "editor.lineHighlightBackground": "{{ background }}60", - "editor.lineHighlightBorder": "{{ background }}00", + "editor.lineHighlightBackground": "{{ mix background foreground 6% }}", + "editor.lineHighlightBorder": "{{ mix background foreground 30% }}", "editorLink.activeForeground": "{{ blue }}", "editor.rangeHighlightBackground": "{{ accent }}10", "editor.rangeHighlightBorder": "{{ accent }}20", @@ -440,18 +440,22 @@ "statusBarItem.activeBackground": "{{ muted }}", "statusBarItem.hoverBackground": "{{ muted }}60", "statusBarItem.hoverForeground": "{{ foreground }}", - "statusBarItem.prominentForeground": "{{ foreground }}", + "statusBarItem.prominentForeground": "{{ background }}", "statusBarItem.prominentBackground": "{{ accent }}", "statusBarItem.prominentHoverBackground": "{{ accent }}80", + "statusBarItem.prominentHoverForeground": "{{ background }}", "statusBarItem.remoteBackground": "{{ accent }}", "statusBarItem.remoteForeground": "{{ background }}", "statusBarItem.remoteHoverBackground": "{{ accent }}80", + "statusBarItem.remoteHoverForeground": "{{ background }}", "statusBarItem.errorBackground": "{{ red }}", "statusBarItem.errorForeground": "{{ background }}", "statusBarItem.errorHoverBackground": "{{ red }}80", + "statusBarItem.errorHoverForeground": "{{ background }}", "statusBarItem.warningBackground": "{{ yellow }}", "statusBarItem.warningForeground": "{{ background }}", "statusBarItem.warningHoverBackground": "{{ yellow }}80", + "statusBarItem.warningHoverForeground": "{{ background }}", "statusBarItem.compactHoverBackground": "{{ muted }}", "statusBarItem.focusBorder": "{{ accent }}", @@ -526,7 +530,7 @@ "keybindingLabel.border": "{{ muted }}", "keybindingLabel.bottomBorder": "{{ muted }}", "keybindingTable.headerBackground": "{{ background }}", - "keybindingTable.rowsBackground": "{{ background }}40", + "keybindingTable.rowsBackground": "{{ muted }}20", "terminal.background": "{{ background }}", "terminal.foreground": "{{ foreground }}", @@ -623,7 +627,7 @@ "settings.checkboxBackground": "{{ background }}", "settings.checkboxForeground": "{{ foreground }}", "settings.checkboxBorder": "{{ muted }}", - "settings.rowHoverBackground": "{{ background }}", + "settings.rowHoverBackground": "{{ muted }}60", "settings.textInputBackground": "{{ background }}", "settings.textInputForeground": "{{ foreground }}", "settings.textInputBorder": "{{ muted }}", @@ -704,12 +708,12 @@ "notebook.editorBackground": "{{ background }}", "notebook.cellBorderColor": "{{ muted }}40", - "notebook.cellHoverBackground": "{{ background }}40", + "notebook.cellHoverBackground": "{{ muted }}40", "notebook.cellInsertionIndicator": "{{ accent }}", "notebook.cellStatusBarItemHoverBackground": "{{ muted }}", "notebook.cellToolbarSeparator": "{{ muted }}", "notebook.cellEditorBackground": "{{ background }}", - "notebook.focusedCellBackground": "{{ background }}60", + "notebook.focusedCellBackground": "{{ muted }}80", "notebook.focusedCellBorder": "{{ accent }}", "notebook.focusedEditorBorder": "{{ accent }}", "notebook.inactiveFocusedCellBorder": "{{ muted }}", diff --git a/default/udev/elgato-camlink-4k.rules b/default/udev/elgato-camlink-4k.rules new file mode 100644 index 00000000..27740681 --- /dev/null +++ b/default/udev/elgato-camlink-4k.rules @@ -0,0 +1,6 @@ +# Keep the Elgato Cam Link 4K's raw capture node away from users and hand it to +# the v4l2-relayd instance that re-exposes it as a 16:9 virtual camera. +# Sorted after 70-uaccess adds the tag and before 73-seat-late applies its ACL. +SUBSYSTEM=="video4linux", ENV{ID_VENDOR_ID}=="0fd9", ENV{ID_MODEL}=="Cam_Link_4K", ENV{ID_V4L_CAPABILITIES}==":capture:", \ + TAG-="uaccess", OWNER="root", GROUP="root", MODE="0600", SYMLINK+="camlink4k", \ + TAG+="systemd", ENV{SYSTEMD_ALIAS}="/dev/camlink4k", ENV{SYSTEMD_WANTS}="v4l2-relayd@camlink.service" diff --git a/default/uwsm/default b/default/uwsm/default index d2ad7cc3..ba98b166 100644 --- a/default/uwsm/default +++ b/default/uwsm/default @@ -10,8 +10,8 @@ export TERMINAL=xdg-terminal-exec # Used by terminal programs to open files with the selected Omarchy default editor export EDITOR="omarchy-launch-editor --inline" -# Use a custom directory for screenshots (remember to make the directory!) -# export OMARCHY_SCREENSHOT_DIR="$HOME/Pictures/Screenshots" +# Use a custom directory for screenshots +# export OMASNAP_SCREENSHOT_DIR="$HOME/Pictures/Screenshots" # Use a custom directory for screenrecordings (remember to make the directory!) # export OMARCHY_SCREENRECORD_DIR="$HOME/Videos/Screencasts" diff --git a/default/v4l2-relayd/camlink.conf b/default/v4l2-relayd/camlink.conf new file mode 100644 index 00000000..7f4ce67e --- /dev/null +++ b/default/v4l2-relayd/camlink.conf @@ -0,0 +1,9 @@ +# Elgato Cam Link 4K relayed as a fixed 16:9 virtual camera. Browser meeting +# apps send 720p at most; raise WIDTH/HEIGHT (up to 3840x2160) for apps that +# can use more. +VIDEOSRC="v4l2src device=/dev/camlink4k" +FORMAT=NV12 +WIDTH=1280 +HEIGHT=720 +FRAMERATE=30/1 +CARD_LABEL="Cam Link 4K" diff --git a/docs/file-layout.md b/docs/file-layout.md index 2a9d965b..73c48c4f 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -84,6 +84,7 @@ version ──► omarchy /usr/share/omarchy config/** ──► omarchy-settings /etc/skel/.config/** (seeds new users) /usr/share/omarchy/config/** (resync source) etc/fastfetch/config.jsonc ──► omarchy-settings /etc/fastfetch/config.jsonc +etc/xdg/kitty/kitty.conf ──► omarchy-settings /etc/xdg/kitty/kitty.conf applications/*.desktop ──► omarchy-settings /etc/skel/.local/share/applications/ /usr/share/omarchy/applications/ @@ -115,7 +116,6 @@ default/** ──► omarchy-settings /usr/share/omarchy ├─ hypr/toggles/*.lua (flags, │ single-window-aspect-ratio, window-no-gaps) /etc/skel/.local/state/omarchy/toggles/hypr/ ├─ nautilus-python/extensions/*.py /etc/skel/.local/share/nautilus-python/extensions/ - ├─ tensaku/state.toml /etc/skel/.local/state/tensaku/state.toml ├─ uwsm/env.d/10-omarchy /usr/share/uwsm/env.d/ ├─ environment.d/*.conf /usr/lib/environment.d/ ├─ fontconfig/conf.avail/50-omarchy.conf /usr/share/fontconfig/conf.avail/ @@ -124,8 +124,7 @@ default/** ──► omarchy-settings /usr/share/omarchy ├─ applications/mimeapps.list /usr/share/applications/mimeapps.list ├─ systemd/user/*.service /usr/lib/systemd/user/ ├─ systemd/user/app.slice.d/10-oomd.conf /usr/lib/systemd/user/app.slice.d/ - ├─ systemd/system-sleep/{force-igpu, - │ keyboard-backlight,unmount-fuse} /usr/lib/systemd/system-sleep/ + ├─ systemd/system-sleep/unmount-fuse /usr/lib/systemd/system-sleep/ ├─ systemd/zram-generator.conf.d/90-omarchy.conf /usr/lib/systemd/zram-generator.conf.d/ ├─ fonts/omarchy/omarchy.ttf /usr/share/fonts/omarchy/ ├─ sddm/omarchy/ /usr/share/sddm/themes/omarchy/ @@ -139,6 +138,8 @@ logo.{txt,svg}, icon.{txt,png} ──► omarchy-settings /usr/share/omarchy /etc/skel/.config/omarchy/branding/{about,screensaver}.txt ``` +The hardware-conditional `force-igpu` and `keyboard-backlight` sources also live under `default/systemd/system-sleep/`, but their setup commands publish root-owned copies only on machines that need them; they are not installed by `omarchy-settings`. + ### Why `etc-overrides/` exists Some files under `/etc/` (`.bashrc` in `/etc/skel`, `nsswitch.conf`, @@ -152,6 +153,14 @@ without a file conflict. Instead their sources (under `etc/` in the repo; Tradeoff: user edits to those files get clobbered on every `omarchy-settings` upgrade. This is documented in the PKGBUILD. +## Locate indexing + +`default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf` ships through `omarchy-settings` to `/usr/lib/systemd/system/plocate-updatedb.service.d/10-omarchy.conf`. It replaces the existing service's `ExecStart` with `updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots`, keeping Btrfs subvolume mounts searchable and excluding Snapper snapshots. The upstream service retains its timer, resource limits, and sandbox; Omarchy's existing AC-power condition still applies. + +`/etc/updatedb.conf` remains owned by plocate and is never rewritten by Omarchy. The command-line options override bind-mount pruning and add to the administrator's existing path exclusions. Installer and AUR package refreshes pass the same options directly because installation may run without systemd and an explicitly requested refresh should work on battery. + +Arch's systemd package hook reloads units when the vendor drop-in is installed or upgraded. The settings package containing the drop-in must ship alongside the runtime package that removes the old configuration helper and migration. Pacman removes those retired files; no new state migration is needed. A running indexer finishes with its original options, and subsequent service starts use the drop-in. For an immediate local test after installing the packages, restart `plocate-updatedb.service` while connected to AC power. + ## Env bootstrap (`default/bash/env-bootstrap`) Single source of truth for `OMARCHY_PATH` and dev-link-aware `PATH`. It: @@ -198,11 +207,7 @@ Runs once per user. It does **not** copy `~/.config/**`, `~/.bashrc`, `flags.lua`, or the nautilus extensions — `/etc/skel` already seeded those. It only does the things `/etc/skel` can't: -- Skill symlinks `~/.{agents,claude,codex,pi/agent}/skills/` → - `$OMARCHY_PATH/default/agents/skills/`, looping over every skill - directory there (currently `omarchy` and `diagnose-crash`) so new skills - need no edit. Symlinks (not copies) so `omarchy dev link` against a dev - checkout repoints them correctly. +- Skill symlinks into `~/.agents/skills/`, `~/.claude/skills/`, `~/.codex/skills/`, `~/.pi/agent/skills/`, `~/.gemini/config/skills/` (Antigravity), `~/.hermes/skills/`, and each existing `~/.hermes/profiles/*/skills/` → `$OMARCHY_PATH/default/agents/skills/`, looping over every skill directory there (currently `omarchy` and `diagnose-crash`) so new skills need no edit. Symlinks (not copies) so `omarchy dev link` against a dev checkout repoints them correctly. Hermes profile dirs are only linked when they already exist — provision does not create Hermes profiles. - `xdg-user-dirs-update` (Templates/Public/Desktop folded back into `$HOME`) and `~/.config/gtk-3.0/bookmarks` (needs `$HOME` expansion). - Hyprland's package-owned default input reads `XKBLAYOUT` / `XKBVARIANT` @@ -355,3 +360,9 @@ return to the packaged default. | New stock theme | `themes//` (+ matching templates under `default/themed/` if they need theme colors) | | User-installed theme | `~/.config/omarchy/themes//` | | Generated current theme/background state | `~/.local/state/omarchy/current/` | + +## Kitty defaults and user overrides + +Kitty loads `/etc/xdg/kitty/kitty.conf` before `~/.config/kitty/kitty.conf`. The `omarchy-settings` package owns the system file; the user template contains only the active theme include and commented examples for personal overrides. Keeping the theme include in the user file lets users remove it without changing the packaged defaults. Individual inherited keybindings can be unmapped with an empty `map ` directive, or all inherited bindings can be cleared with `clear_all_shortcuts yes`. + +The system default uses `allow_remote_control socket-only` so Omarchy can query the active terminal directory over its Unix socket while Kitty rejects remote-control requests arriving through terminal output. Changing this setting requires restarting Kitty. The migration refreshes the exact previous stock config with a backup; customized configs retain their settings and ordering, with only explicit unrestricted `yes`, `y`, or `true` remote-control settings commented out. diff --git a/docs/notifications.md b/docs/notifications.md index c2eba6d9..7651fd71 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -63,24 +63,33 @@ Ephemeral ones (the freedesktop `transient` hint, or an `app_name` of ## The sender contract `bin/omarchy-notification-send` is the one way Omarchy code sends -notifications — never raw `notify-send`. It translates its flags into -notify-send arguments and passes any unrecognized options through: +notifications — never raw `notify-send`. It calls +`org.freedesktop.Notifications.Notify` directly over the session bus (via +`busctl --user`), so each value is one typed D-Bus parameter and there is no +argv layer that could reinterpret a relayed headline as an option or a hint. Its +flags map onto that call: | Flag | Becomes | Meaning | |---|---|---| -| `-g` / `--glyph` | `--hint=string:omarchy-glyph:` | Nerd Font glyph for the icon slot when no image icon resolves | -| `--exec` | `--hint=string:omarchy-exec:` | shell command the card runs when clicked | -| `--image` | `--hint=string:image-path:` | the standard freedesktop image hint | -| `--app-name` | `-a` | defaults to `omarchy-action` | -| `-u` / `--urgency` | `-u` | defaults to `low` | +| `-g` / `--glyph` | hint `omarchy-glyph` | Nerd Font glyph for the icon slot when no image icon resolves | +| `--exec [args…]` | hint `omarchy-exec-argv` | the click command; consumes the rest of the line, so it comes last. Each word is a discrete argument the shell runs without re-parsing (see below) | +| `--image` | hint `image-path` | the standard freedesktop image hint | +| `-i` / `--icon` | `app_icon` | themed icon name for the toast | +| `--app-name` | `app_name` | defaults to `omarchy-action` | +| `-u` / `--urgency` | hint `urgency` (byte) | `low`/`normal`/`critical`; defaults to `low` | +| `-t` / `--expire-time` | `expire_timeout` | milliseconds on screen; server default otherwise | + +Unknown flags are a hard error, not a silent pass-through: `--exec` is the only +door to a click command, and there is no generic option pass-through to smuggle +one through. The defaults are the point: an unadorned `omarchy-notification-send "Done"` is a low-urgency user-action toast that pops through DND and is treated as ephemeral noise when silenced. -`--exec` is deliberately not a libnotify action. An action keeps the sender -blocked waiting for `ActionInvoked`, and dies unanswered whenever the shell -restarts underneath it — the installer toasts restart the shell as their +The click command is deliberately not a libnotify action. An action keeps the +sender blocked waiting for `ActionInvoked`, and dies unanswered whenever the +shell restarts underneath it — the installer toasts restart the shell as their first act. Carrying the command as a hint means the shell executes the click itself (detached, so the command outlives the shell process) from the copy it keeps with the popup, which the persistence files preserve: a restored toast @@ -90,6 +99,52 @@ immediately. For third-party clients the click falls back to the libnotify window by class via `omarchy-hyprland-focus-app` — chat apps rarely register an action and just expect click-to-jump. +### Click commands are argv, never shell strings + +`--exec` consumes the rest of the line as the click command: + +```bash +omarchy-notification-send "Download complete" "$title" --exec mpv -- "$file" +``` + +The caller's shell has already split those words into discrete arguments, and a +quoted argument (`"$file"`) stays one argument even with spaces. On the shell +side they are run through `Util.execArgv`, which invokes `bash -lc 'exec "$@"'` +with the arguments as **positional parameters** — never interpolated into the +script text. bash expands `"$@"` without re-tokenizing or re-evaluating it, so a +value carrying data an attacker controls — a downloaded video's title, a +received filename, a crashed process's name — is only ever a single argument and +can never be reparsed as a command. The login shell keeps the PATH and session +environment that GUI click targets (the screenshot editor, mpv, xdg-open) expect. + +The critical rule: **the splitting must happen at the call site, not inside the +tool.** Passing a single quoted string (`--exec "mpv $title"`) and letting the +tool whitespace-split it would hand argument boundaries to whoever controls the +string's content — a title with a space could inject an extra option or program. +So `--exec` refuses a lone quoted-string argument and points at the unquoted +form. There is no "take a command string and sanitize it" path; that is the +escaping trap (string-concatenated SQL) the yt-dlp title RCE exploited. + +The shell fails closed on a malformed argv hint (it must be a JSON array of +strings whose program is present and not a leading-dash option) rather than +running anything it can't validate. A caller can still deliberately name a shell +as the program (`--exec sh -c …`), but that runs code because the *developer* +wrote it, not because attacker data became a command — a reviewable red flag +(greppable as `--exec sh`/`--exec bash`), not an injection. Insulating against a +native same-user process is out of scope: it already runs with your privileges +and needs no notification to execute code. What is fully closed is untrusted +*content* — web notifications can't set the exec hint at all, and any relayed +title/filename is confined to inert argument data. + +The sender keeps that last part true structurally rather than leaving it to each +caller. Because it calls `Notify` directly, the headline and description are +typed string parameters — a relayed value like `--hint=…` or `-rf` is the +summary or body, never an option or a hint, and there is no argv/option layer +(no `notify-send`) left to reinterpret it. `--exec` is the only thing that can +build the `omarchy-exec-argv` hint. (The leading `--` on the `busctl` call is a +belt for `busctl`'s own getopt, which would otherwise read a dash-leading value +as a `busctl` option; the summary/body themselves are never parsed as options.) + ## Helper commands - `omarchy-notification-wait [timeout]` — polls until the shell answers IPC @@ -114,7 +169,8 @@ Everything goes through the same sender contract, so the pieces are small: `battery-low` hook. - **Crash capture** — `omarchy-crash-watch` follows the systemd-coredump journal stream and announces each crashed program (deduped per minute) as a - critical toast whose `--exec` runs `omarchy-agent-crash`. It waits for the + critical toast whose click runs `omarchy-agent-crash` (via `--exec`, so a + hostile process name stays a discrete argument). It waits for the server first: a shell crash takes the notification server down with it, and that crash is the one most worth reporting. - **Pending migrations** — `omarchy-migrate-notify` (from its user service diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index a7c1b389..e90282d5 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -20,7 +20,17 @@ wait). "author": "You", "description": "A clock that does cool things", "kinds": ["bar-widget"], - "entryPoints": { "barWidget": "Widget.qml" } + "entryPoints": { "barWidget": "Widget.qml" }, + "barWidget": { + "displayName": "Cool clock", + "category": "Time", + "allowMultiple": false, + "defaultSection": "left", + "defaults": { "format": "HH:mm" }, + "schema": [ + { "key": "format", "type": "string", "label": "Format" } + ] + } } ``` @@ -37,14 +47,11 @@ wait). Only one full bar option is active at a time. The built-in `omarchy.bar` is used when `bar.id` is omitted or when a selected third-party bar cannot load. -Panels, overlays, and menus are loaded when summoned. Plugins can set the -top-level manifest key `keepLoaded: true` to survive between summons. -First-party services are loaded at startup. +Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key `keepLoaded: true` to survive between summons, and to keep a service mounted across plugin hot-reload (so `omarchy.lock` is not destroyed while Hyprland still holds the session lock). The kept service instance is not replaced, so changes to its code take effect on a shell restart. First-party services are loaded at startup. -Entry points are QML `Item`s. Panel, overlay, and menu entry points expose -`open(payloadJson)` and `close()` for summon/hide; on load the host injects -`omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / -`barWidgetRegistry`) as properties. +Entry points are QML `Item`s. Panel, overlay, and menu entry points expose `open(payloadJson)` and `close()` for summon/hide; on load the host injects `omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / `barWidgetRegistry`) as properties. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades instead: 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. A full-bar plugin additionally receives 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 are stamped from trusted first-party manifests, authentication services are kept out of the host's public service map and QML object tree, and third-party registry/configuration snapshots can be changed only locally without mutating host state. The facades are API boundaries, not same-process QML sandboxes: a visual widget shares the host bar's scene and can walk its parent hierarchy to ordinary host objects. Sensitive state must not rely on the facade alone for isolation. + +A third-party replacement bar can render registered widget components, but widgets it hosts receive a service-less entry facade. Allowing the bar to manufacture an own-service facade for an arbitrary widget would also let it retrieve that plugin's live service object. Service-backed third-party widgets therefore retain their full integration only under the trusted built-in bar; a replacement bar may still provide their target-scoped lifecycle and settings operations. Full schema: [`shell/services/PluginRegistry.qml`](../shell/services/PluginRegistry.qml). @@ -81,12 +88,7 @@ one replaces the active bar, and it is therefore never offered under Disable. Bar widgets may set `barWidget.defaultSection` to `left`, `center`, or `right`; widgets that omit it default to `center`. -Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you -before cloning, plugins land disabled so you can review the code before -`omarchy plugin enable`, and updates show a diff before touching anything. -Commands confirm in a terminal even when given arguments; without one they -refuse rather than guess. Add `--yes` to skip every prompt (the path for -scripts and agents). +Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you before cloning, plugins land disabled so you can review the code before `omarchy plugin enable`, and updates show a diff before touching anything. Commands confirm in a terminal even when given arguments; without one they refuse rather than guess. Add `--yes` to skip every prompt (the path for scripts and agents). The scoped interfaces remove direct access to authentication services and avoid handing generic cross-plugin service factories to replacement bars, but visual plugins can still traverse ordinary objects in their shared QML scene. Plugin code also has the same user-level file and process access as the shell. You can still install by hand: drop a plugin into `~/.config/omarchy/plugins//`, run `omarchy-shell shell rescanPlugins`, then @@ -96,6 +98,10 @@ bar from the CLI — `use | reset | defaults | position | transparent | put | move | set`, with placement flags such as `--section` and `--index`. The lower-level IPC methods remain available through `omarchy-shell shell ...`. +## Elsewhen + +Elsewhen (`omacom.elsewhen`) ships in the `elsewhen` package at `/usr/share/omarchy/shell/plugins/omacom.elsewhen`, where the shell discovers it automatically. New installs place it immediately before the clock; the migration uses `omarchy bar put omacom.elsewhen --before omarchy.clock`, which preserves an existing placement and uses Elsewhen's normal right-side placement if the clock is absent. Existing plugin directories and symlinks are left intact. The normal update flow restarts the shell after migrations; the migration does not interrupt plugin loading with an immediate restart. With no shell to ask, as in an update from a TTY, the migration installs the package and skips the placement rather than failing the update; `omarchy bar put omacom.elsewhen --before omarchy.clock` places the widget later. + ## IPC The shell exposes a `shell` target (the host also registers diff --git a/docs/testing.md b/docs/testing.md index 3928ed2b..f8cd160e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -20,11 +20,10 @@ the end and exits non-zero. the theme pipeline: template rendering (`omarchy-theme-set-templates`, `omarchy-theme-color`, `omarchy-theme-osc`), the theme sync commands (tmux, GNOME, VS Code, Pi, Claude) run against stub binaries and a fake - `$HOME`, and the theme-state migrations. + `$HOME`. - **`./test/shell`** — runs every `test/shell.d/*-test.sh` (except `base-test.sh` itself). Each file is an independent suite covering one area: - a shell plugin, a `bin/` command, a config invariant, a migration. This is - where new tests go. + a shell plugin, a `bin/` command, a config invariant, or a still-live migration. This is where new tests go. - **Acceptance** — everything that needs a real desktop doing real things. Deliberately excluded from `./test/all`; it runs in a VM, not the development session. @@ -53,6 +52,7 @@ directory or an installed Omarchy. Assertions are TAP-flavored and blunt: - `pass "description"` prints `ok - description`. +- `skip "description"` prints `ok - description # SKIP`. Include why the check could not run. Like `pass`, it returns normally; use `exit 0` afterwards only when the rest of the file cannot run either. - `fail "description" [detail]` prints the optional detail and `not ok - description` to stderr, then **exits the file**. There is no counting or continuing within a file: the first failed assertion ends it, @@ -60,19 +60,13 @@ Assertions are TAP-flavored and blunt: already invalidated. - `require_command ` fails the file when a needed tool is absent. -The runner compensates for that early exit: `./test/shell` continues past a -failing file and summarizes the failures at the end. Aborting the whole run at -the first bad file once let a single packaging failure mask 114 of 134 files. -Failure granularity is therefore per file inside a run, per assertion inside a -file. +The runner compensates for that early exit: `./test/shell` continues past a failing file and summarizes the failures at the end. Aborting the whole run at the first bad file once let a single packaging failure mask 114 of 134 files. Failure granularity is therefore per file inside a run, per assertion inside a file. + +The runner also lists files with skipped checks, including files that ran some checks or later failed. Skips do not fail a run. When no files fail, a run with skips is reported as completed without failures rather than having passed every check. ## Compositor-dependent tests -Some tests launch Quickshell or query Hyprland, but the suite must stay green -on headless machines. `require_compositor "description"` handles this: when no -compositor answers it prints `ok - no Wayland compositor; skipping ...` and -exits 0 — a skip is a passing test — and otherwise returns so the file -proceeds. +Some tests launch Quickshell or query Hyprland, but the suite must stay green on headless machines. `require_compositor "description"` handles this: when no compositor answers it calls `skip` with the reason and exits 0, and otherwise returns so the file proceeds. The skip marker lets the runner distinguish unavailable runtime coverage from checks that passed. The probe is more than an environment check, because `WAYLAND_DISPLAY` only proves the variable was inherited. Sandboxes pass the environment through @@ -120,6 +114,7 @@ only a live session can prove. ## Conventions worth copying +- **Redirect background output.** Send background fixtures' stdout to a log or `/dev/null` and clean up the processes on exit. An inherited output pipe can hold the runner open after the test exits. - **Stub the world, run the real code.** Tests build a scratch `bin/` of stub executables (`sudo`, `tmux`, `gsettings`, helper commands) that log their arguments to a file, prepend it to `PATH`, and then run the real script @@ -132,7 +127,7 @@ only a live session can prove. fake `$HOME`, runs `bash -euo pipefail "$ROOT/migrations/.sh"`, and asserts the resulting state — including running it twice to prove idempotence, and once against non-legacy state to prove it leaves user - customization alone. + customization alone. Keep that test while the migration is still being written or bugfixed, if it calls an Omarchy helper whose interface can still change, or if it is a security-sensitive privileged repair. Once a one-shot rewrite has shipped in a tagged release and is frozen, drop the test even when that rewrite used sudo, pacman, or limine-mkinitcpio. Keep the migration itself for late-updaters. Tests of `omarchy-migrate`, the login notifier, and `omarchy-upgrade-to-quattro` stay. - **Assert the invariant, not the snapshot.** Config tests pin the property a test is named for (this widget stays adjacent to that one) rather than whole structures, so unrelated churn does not fail them. diff --git a/docs/theming.md b/docs/theming.md index fb270ce0..2399a7b0 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -6,20 +6,22 @@ Omarchy themes live under `themes//` in the source tree (installed at `colors.toml`; Omarchy generates the active theme files from `default/themed/*.tpl` when `omarchy-theme-set ` runs. -Beyond `colors.toml` and hand-written config overrides, a theme can ship -`backgrounds/` (users overlay their own via +Beyond `colors.toml` and hand-written config overrides, a first-party theme can +ship `backgrounds/` (users overlay their own via `~/.config/omarchy/backgrounds//`; the active image is the `~/.local/state/omarchy/current/background` symlink), `preview.png` and `preview-unlock.png` for the theme switcher, `icons.theme`, `keyboard.rgb`, `unlock.png`, and a `light.mode` marker file. +A theme installed from a git repo is held to a much shorter list; see [What an installed theme may not ship](#what-an-installed-theme-may-not-ship). + ## Theme activation flow `omarchy-theme-set ` builds a clean staging directory at `~/.local/state/omarchy/current/next-theme`: 1. Copy the first-party theme from `themes//`. -2. Overlay any user theme files from `~/.config/omarchy/themes//`. +2. Overlay `~/.config/omarchy/themes//`, in full when the user wrote it and filtered when it came from a git repo, naming anything it dropped on stderr. 3. If needed, generate `colors.toml` from `alacritty.toml`. 4. Run `omarchy-theme-set-templates` to render templates into the staging theme. @@ -43,6 +45,26 @@ Making a new app follow theme changes means adding its restart/retint command to that list. Runs serialize on a `flock`, so scripted theme changes queue instead of racing. +## What an installed theme may not ship + +`themes//` in this repo is Omarchy's own code and is trusted. So is a theme the user wrote by hand in `~/.config/omarchy/themes//`: it is their machine and their file, and both stage in full. + +`omarchy theme install ` is different. It clones a stranger's git repo straight into that same directory, so the contents are whatever the theme author pushed. `omarchy-theme-set` tells the two apart the way `omarchy-theme-extras` already does — a `.git` directory means it was cloned, while a plain directory or a symlink to a working copy is the user's own — and from a cloned one it drops only what can run code: + +- any `*.lua` — Hyprland `require`s a theme's `hyprland.lua` and `gum_env.lua` at login, and Neovim loads its `neovim.lua` at startup +- `alacritty.toml`, `foot.ini`, `ghostty.conf`, `kitty.conf` — each names the program the terminal launches +- `vscode.json` — names the extension `omarchy-theme-set-vscode` installs, and a VS Code extension is arbitrary JavaScript + +Symlinks are dropped with them, at any depth; in a cloned theme they point wherever the theme author chose. Everything a cloned theme ships that is colour is kept, including files Omarchy would otherwise have generated — `btop.theme`, `chromium.theme`, `helix.toml`, `shell.toml`, `icons.theme`, `keyboard.rgb` and the rest — so a theme can still say exactly how it wants each app to look. What is dropped gets generated from `default/themed/*.tpl` instead, and is named on stderr. + +A denylist is only right while it is maintained. Adding a template for another terminal, or for another editor that loads Lua, means adding it to `INSTALLED_THEME_DENIED` in `bin/omarchy-theme-set`; `test/shell.d/theme-staging-test.sh` fails on any `default/themed/*.tpl` whose output is recorded as neither code nor colour, so a new template cannot be added without that decision being made. + +A theme predating `colors.toml` is not left without a palette: its `alacritty.toml` is read through `omarchy-theme-colors-from-alacritty` into a scratch directory and only the resulting `colors.toml` is staged, so the colors survive and the terminal config does not. + +The restriction lives in `omarchy-theme-set` rather than in `omarchy-theme-install` on purpose. Filtering at staging also covers themes installed before the rule existed and files a theme gains later through `omarchy theme update`. + +What this does not cover: a theme distributed as an archive rather than a git repo, extracted into `~/.config/omarchy/themes/` by hand, is indistinguishable from one the user wrote and stages in full. `omarchy theme install` only takes git URLs, so the supported path is always filtered, but the check is a statement about where a theme came from and not a sandbox. + ## `colors.toml` `colors.toml` provides the palette keys used by templates. Keys are grouped @@ -352,6 +374,7 @@ local active_border_color = { colors = { "rgba(33ccffee)", "rgba(00ff99ee)" }, a ## Adding or overriding theme files - Add palette values to `themes//colors.toml`. +- Hand-written overrides work everywhere except a `.lua`, a terminal config or a `vscode.json` in a theme cloned from a git repo; see [What an installed theme may not ship](#what-an-installed-theme-may-not-ship). - Prefer generated files when the theme can be expressed with templates. - Add a hand-written file in `themes//` only when that theme needs to override the generated output entirely. diff --git a/docs/update-process.md b/docs/update-process.md index bec3350d..efecb94c 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -82,13 +82,18 @@ Omarchy update command, the hook exits non-zero with `AbortOnFail`, which stops the transaction before packages are changed. `omarchy-update-system-pkgs`, `omarchy-refresh-pacman`, `omarchy-reinstall-pkgs`, -`omarchy-channel-set`, and the v4 upgrader run pacman through: +and `omarchy-channel-set` run pacman through the hidden `omarchy-update-pacman` +helper (the v4 upgrader sets `OMARCHY_UPDATE_PACMAN=1` directly): ```bash -env OMARCHY_UPDATE_PACMAN=1 pacman ... +sudo env OMARCHY_UPDATE_PACMAN=1 systemd-run --scope --quiet --collect pacman ... ``` -so the guard allows Omarchy-owned update flows. A user can intentionally bypass +so the guard allows Omarchy-owned update flows. The `systemd-run --scope` +wrapper registers the transaction as a PID 1 scope: upgrading systemd reexecs +the system and user managers mid-transaction, and a pacman left inside a +user-session scope can be SIGKILLed by that reexec. On unbooted systems (such +as the installer chroot) the helper runs pacman directly. A user can intentionally bypass the guard with: ```bash @@ -272,12 +277,13 @@ scripts. | `omarchy-update-confirm` | Gum confirmation copy for `omarchy update`. | **Question.** Could be inlined into `omarchy-update`; separate file only helps keep copy isolated. | | `omarchy-update-dev` | Fast-forwards the active dev-linked checkout from its configured upstream; no-ops for package-backed installs. | **Keep.** Runs before package updates so a checkout conflict stops the update before system mutation. | | `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | -| `omarchy-update-system-pkgs` | Runs `sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm` with `--overwrite '/usr/share/omarchy/*'`, capturing stderr to a report file; on failure it execs `omarchy-update-system-pkgs-when-conflicted`. | **Keep for now.** Small leaf command, clear/testable. | +| `omarchy-update-system-pkgs` | Runs `omarchy-update-pacman -Syu --noconfirm` with `--overwrite '/usr/share/omarchy/*'`, capturing stderr to a report file; on failure it execs `omarchy-update-system-pkgs-when-conflicted`. | **Keep for now.** Small leaf command, clear/testable. | | `omarchy-update-system-pkgs-when-conflicted` | Hidden conflict handler: quarantines unowned conflicting files under `/var/lib/omarchy/replaced`, retries the upgrade once, restores files the upgrade didn't claim, and hands package-vs-package conflicts to an interactive pacman run (never under `-y`). | **Keep internal/hidden.** Keeps conflict recovery out of the happy path. | | `omarchy-update-pkg-prune` | Trims the pacman cache to two versions per package (`paccache -rk2`) before the snapshot, keeping the offline downgrade path while capping snapshot growth. | **Keep internal/hidden.** | | `omarchy-update-requires-free-space` | Aborts the update below a 10 GiB free-space threshold on `/`; silently skipped when free space cannot be determined; `OMARCHY_UPDATE_FORCE=1` bypasses. | **Keep internal/hidden.** | | `omarchy-migrate` | Public migration command. Waits for pacman, then runs all pending migrations for the current user. Supports `--pending`. | **Keep.** This replaces the discarded `omarchy-update-user-finalize` name and no longer needs `--force`. | | `omarchy-update-pacman-guard` | ALPM pre-transaction guard that aborts direct `pacman -Syu` style upgrades unless Omarchy set `OMARCHY_UPDATE_PACMAN=1` or the user explicitly set `OMARCHY_ALLOW_DIRECT_PACMAN=1`. | **Keep internal/hidden.** This is what nudges users back to `omarchy update`. | +| `omarchy-update-pacman` | Hidden helper that runs a guard-approved pacman transaction as a PID 1 scope (`systemd-run --scope`) so a mid-transaction systemd reexec cannot kill it; runs pacman directly when not booted under systemd. | **Keep internal/hidden.** Single place that owns how Omarchy invokes pacman for system mutation. | | `omarchy-migrate-notify` | Internal login-time notification helper. Uses `omarchy-migrate --pending` and shows a notification only when this user has pending migrations. | **Keep internal/hidden.** Clear name now that the public command is `omarchy-migrate`. | | `omarchy-update-user-notify` | Hidden compatibility wrapper for `omarchy-migrate-notify`. | **Temporary.** Keep only for old callers. | | `omarchy-update-available` | Update checker for shell widget and post-update refresh. | **Keep.** Could eventually be renamed `omarchy-update-check`, but current name matches widget semantics. | diff --git a/etc/cups/cups-browsed.conf b/etc/cups/cups-browsed.conf index ed1bdbad..19863e59 100644 --- a/etc/cups/cups-browsed.conf +++ b/etc/cups/cups-browsed.conf @@ -1,4 +1,8 @@ -# Omarchy override of cups-browsed's shipped config. The only behavioural -# change vs the upstream default (all-commented) is enabling auto-registration -# of remote IPP printers discovered via Avahi/mDNS. -CreateRemotePrinters Yes +# Keep state away from /var/cache/cups, which is writable by the account CUPS +# uses for print filters. cups-browsed is the only writer to this directory. +CacheDir /var/cache/cups-browsed + +# Auto-create queues only for modern driverless IPP printers. Remote queues +# exported by another CUPS server can still be added manually when needed. +CreateIPPPrinterQueues Driverless +CreateRemoteCUPSPrinterQueues No diff --git a/etc/cups/cups-files.conf b/etc/cups/cups-files.conf new file mode 100644 index 00000000..e515117f --- /dev/null +++ b/etc/cups/cups-files.conf @@ -0,0 +1,90 @@ +# +# File/directory/user/group configuration file for the CUPS scheduler. +# See "man cups-files.conf" for a complete description of this file. +# + +# List of events that are considered fatal errors for the scheduler... +#FatalErrors config + +# Strip domain in local username? +#StripUserDomain No + +# Do we call fsync() after writing configuration or status files? +#SyncOnClose No + +# Default user and group for filters/backends/helper programs; this cannot be +# any user or group that resolves to ID 0 for security reasons... +User 209 +Group 209 + +# Administrator user group, used to match @SYSTEM in cupsd.conf policy rules... +# This cannot contain the Group value for security reasons... +SystemGroup cups-browsed sys root + + +# Are Unix domain socket peer credentials used for authorization? +PeerCred on + +# User that is substituted for unauthenticated (remote) root accesses... +#RemoteRoot remroot + +# Do we allow file: device URIs other than to /dev/null? +#FileDevice No + +# Permissions for configuration and log files... +#ConfigFilePerm 0640 +#LogFilePerm 0644 + +# Location of the file logging all access to the scheduler; may be the name +# "syslog". If not an absolute path, the value of ServerRoot is used as the +# root directory. Also see the "AccessLogLevel" directive in cupsd.conf. +AccessLog /var/log/cups/access_log + +# Location of cache files used by the scheduler... +#CacheDir /var/cache/cups + +# Location of data files used by the scheduler... +#DataDir /usr/share/cups + +# Location of the static web content served by the scheduler... +#DocumentRoot /usr/share/cups/doc + +# Location of the file logging all messages produced by the scheduler and any +# helper programs; may be the name "syslog". If not an absolute path, the value +# of ServerRoot is used as the root directory. Also see the "LogLevel" +# directive in cupsd.conf. +ErrorLog /var/log/cups/error_log + +# Location of the file logging all pages printed by the scheduler and any +# helper programs; may be the name "syslog". If not an absolute path, the value +# of ServerRoot is used as the root directory. Also see the "PageLogFormat" +# directive in cupsd.conf. +PageLog /var/log/cups/page_log + +# Location of the file listing all of the local printers... +#Printcap /etc/printcap + +# Format of the Printcap file... +#PrintcapFormat bsd +#PrintcapFormat plist +#PrintcapFormat solaris + +# Location of all spool files... +#RequestRoot /var/spool/cups + +# Location of helper programs... +#ServerBin /usr/lib/cups + +# SSL/TLS keychain for the scheduler... +#ServerKeychain ssl + +# Location of other configuration files... +#ServerRoot /etc/cups + +# Location of scheduler state files... +#StateDir /run/cups + +# Location of scheduler/helper temporary files. This directory is emptied on +# scheduler startup and cannot be one of the standard (public) temporary +# directory locations for security reasons... +#TempDir /var/spool/cups/tmp diff --git a/etc/limine-entry-tool.d/omarchy-defaults.conf b/etc/limine-entry-tool.d/omarchy-defaults.conf index 49c6204b..ec0fa96f 100644 --- a/etc/limine-entry-tool.d/omarchy-defaults.conf +++ b/etc/limine-entry-tool.d/omarchy-defaults.conf @@ -15,7 +15,8 @@ ENABLE_LIMINE_FALLBACK=yes # Find and add other bootloaders FIND_BOOTLOADERS=yes -BOOT_ORDER="*, *fallback, Snapshots" +# Keep T2 Macs on their specialized kernel; prefer linux-omarchy elsewhere. +BOOT_ORDER="linux-t2, linux-omarchy, linux-omarchy-*, *, *fallback, Snapshots" # Snapper is configured with NUMBER_LIMIT="5" (see default/snapper/root), but # limine-snapper-sync can see the newly created sixth snapshot before cleanup diff --git a/etc/mise/conf.d/omarchy.toml b/etc/mise/conf.d/omarchy.toml new file mode 100644 index 00000000..c9354a2e --- /dev/null +++ b/etc/mise/conf.d/omarchy.toml @@ -0,0 +1,4 @@ +# Keep Cursor optional while applying these options whenever mise installs it. +# Expose only its launcher so the bundled Node cannot shadow the user's Node. +[tool_alias] +cursor-agent = 'http:cursor-agent[bin_path=bin,postinstall=mkdir -p "$MISE_TOOL_INSTALL_PATH/bin" && ln -sfn ../dist-package/cursor-agent "$MISE_TOOL_INSTALL_PATH/bin/cursor-agent"]' diff --git a/etc/sudoers.d/omarchy-asdcontrol b/etc/sudoers.d/omarchy-asdcontrol deleted file mode 100644 index a8483b62..00000000 --- a/etc/sudoers.d/omarchy-asdcontrol +++ /dev/null @@ -1 +0,0 @@ -%wheel ALL=(ALL) NOPASSWD: /usr/bin/asdcontrol diff --git a/etc/sudoers.d/omarchy-theme-browser b/etc/sudoers.d/omarchy-theme-browser new file mode 100644 index 00000000..853d2412 --- /dev/null +++ b/etc/sudoers.d/omarchy-theme-browser @@ -0,0 +1,8 @@ +# Theme switching is a menu action with no terminal to carry a password prompt, +# and it repaints the browser accent on every switch, so this one write must not +# stop for a password. The argument is spelled out as six hex digits rather than +# a wildcard: the grant covers a color and nothing else, and sudoers matches a +# command's arguments exactly, so it cannot be stretched into extra ones. The +# helper revalidates the same shape, since the terminal path does not come +# through this rule. +%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-theme-set-browser-policy [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f] diff --git a/etc/sudoers.d/omarchy-tzupdate b/etc/sudoers.d/omarchy-tzupdate index d35adb82..98b30771 100644 --- a/etc/sudoers.d/omarchy-tzupdate +++ b/etc/sudoers.d/omarchy-tzupdate @@ -1 +1 @@ -%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone * +%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl ^set-timezone [A-Za-z0-9_+][A-Za-z0-9_+.-]*(/[A-Za-z0-9_+][A-Za-z0-9_+.-]*)*$ diff --git a/etc/sysctl.d/99-omarchy-sysctl.conf b/etc/sysctl.d/99-omarchy-sysctl.conf index 1cce6d7f..dac0fb2b 100644 --- a/etc/sysctl.d/99-omarchy-sysctl.conf +++ b/etc/sysctl.d/99-omarchy-sysctl.conf @@ -1,6 +1,12 @@ # Solve common flakiness with SSH (MTU discovery on flaky links). net.ipv4.tcp_mtu_probing=1 +# BBR estimates bottleneck bandwidth and minimum RTT and paces to them, where +# cubic keeps pushing until packets drop. That cuts queueing latency +# (bufferbloat) on fast links. fq is the qdisc BBR is built to pace through. +net.core.default_qdisc=fq +net.ipv4.tcp_congestion_control=bbr + # Tune reclaim for swap on zram, which is orders of magnitude faster than the # disk swapfile these defaults assume. diff --git a/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf b/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf new file mode 100644 index 00000000..54107197 --- /dev/null +++ b/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf @@ -0,0 +1,11 @@ +[Service] +User=cups-browsed +Group=cups-browsed +CacheDirectory=cups-browsed +CacheDirectoryMode=0750 +UMask=0027 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +RestrictSUIDSGID=yes diff --git a/etc/sysusers.d/omarchy-cups-browsed.conf b/etc/sysusers.d/omarchy-cups-browsed.conf new file mode 100644 index 00000000..fa602c18 --- /dev/null +++ b/etc/sysusers.d/omarchy-cups-browsed.conf @@ -0,0 +1 @@ +u cups-browsed - "CUPS printer discovery" / - diff --git a/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf new file mode 100644 index 00000000..2c644ff1 --- /dev/null +++ b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf @@ -0,0 +1,5 @@ +# omarchy-sudo-passwordless writes /etc/sudoers.d/99-omarchy-nopasswd- and +# arms a transient systemd-run timer to remove it again. Transient units do not +# survive a reboot, so remove any remaining grant during early boot. Boot-only +# (r!) ensures a later systemd-tmpfiles --remove cannot cut a live grant short. +r! /etc/sudoers.d/99-omarchy-nopasswd-* diff --git a/etc/udev/rules.d/60-omarchy-io-scheduler.rules b/etc/udev/rules.d/60-omarchy-io-scheduler.rules new file mode 100644 index 00000000..1f389f4b --- /dev/null +++ b/etc/udev/rules.d/60-omarchy-io-scheduler.rules @@ -0,0 +1,8 @@ +# Kyber keeps reads in their own queue and throttles the depth it submits to +# hold a 2ms read latency target, so interactive reads keep flowing while a +# large build, copy, or package upgrade floods the disk with writes. The +# kernel's own pick (none or mq-deadline, depending on the device) does not +# regulate latency once the queue fills. Whole disks only: partitions have no +# scheduler of their own, and zram is memory with nothing to schedule. Zoned +# btrfs disks are moved back to mq-deadline by 64-btrfs-zoned.rules. +ACTION=="add|change", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="nvme*|sd*|mmcblk*|vd*", ATTR{queue/scheduler}="kyber" diff --git a/etc/xdg/kitty/kitty.conf b/etc/xdg/kitty/kitty.conf new file mode 100644 index 00000000..485381d0 --- /dev/null +++ b/etc/xdg/kitty/kitty.conf @@ -0,0 +1,35 @@ +# Omarchy defaults. Put personal overrides in ~/.config/kitty/kitty.conf. + +# Font +font_family JetBrainsMono Nerd Font +bold_italic_font auto +font_size 9.0 + +# Window +window_padding_width 14 +hide_window_decorations yes +confirm_os_window_close 0 + +# 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 + +# Allow local cwd lookup, but reject remote control through terminal output. +allow_remote_control socket-only +listen_on unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid} + +# 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 ''} diff --git a/install/config/all.sh b/install/config/all.sh index 91256dc7..a221a398 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -1,4 +1,5 @@ run_logged "$OMARCHY_INSTALL/config/theme-system.sh" +run_logged "$OMARCHY_INSTALL/config/browser-policy.sh" run_logged "$OMARCHY_INSTALL/config/increase-lockout-limit.sh" run_logged "$OMARCHY_INSTALL/config/lockscreen-pam.sh" run_logged "$OMARCHY_INSTALL/config/fix-powerprofilesctl-shebang.sh" @@ -6,6 +7,5 @@ run_logged "$OMARCHY_INSTALL/config/ssh-command-path.sh" run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" -run_logged "$OMARCHY_INSTALL/config/locate.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/browser-policy.sh b/install/config/browser-policy.sh new file mode 100644 index 00000000..fd802c66 --- /dev/null +++ b/install/config/browser-policy.sh @@ -0,0 +1,2 @@ +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" +browser_policy_setup_dir /etc/chromium/policies/managed diff --git a/install/config/docker.sh b/install/config/docker.sh index 1dc17e81..39743905 100644 --- a/install/config/docker.sh +++ b/install/config/docker.sh @@ -1,10 +1,16 @@ -# Record the docker group for provisioning first-boot user creation and factory reset, -# then grant it directly when the install user already exists (deferred-provisioning -# installs create the user at first boot instead). -provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" -mkdir -p "$provisioning_dir" -grep -qxF docker "$provisioning_dir/groups" 2>/dev/null || echo docker >>"$provisioning_dir/groups" - -if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then - usermod -aG docker "$OMARCHY_INSTALL_USER" -fi +# The Docker daemon runs as root and its socket is root-owned, so membership in +# the docker group is equivalent to passwordless root: any process in it can +# `docker run -v /:/host` and rewrite the host as root. We therefore do NOT add +# the install user to the docker group by default, so a single rogue process +# running as the user cannot silently escalate to root. +# +# The daemon is still enabled (docker.socket, in enable-services.sh) for system +# use. The Docker TUI (Super + Shift + D) and the Windows VM reach it through a +# polkit prompt, and the plain `docker` CLI runs under sudo. Users who want the +# convenience back can opt in, behind a warning, with: +# +# omarchy-setup-security-sudoless-docker (Setup > Security > Sudoless Docker) +# +# Nothing to do here now that the group is no longer granted, but the file stays +# as the recorded home of this decision and a hook for future daemon config. +: diff --git a/install/config/enable-services.sh b/install/config/enable-services.sh index b7e2ba8b..964f0867 100644 --- a/install/config/enable-services.sh +++ b/install/config/enable-services.sh @@ -1,16 +1,14 @@ # Enable services only. Installs are followed by reboot, so don't start/reload # daemons mid-install. UFW and hardware-gated services stay in their own scripts. systemctl enable cups.service -systemctl enable cups-browsed.service systemctl enable avahi-daemon.service systemctl enable linux-modules-cleanup.service systemctl enable docker.socket systemctl enable systemd-resolved.service systemctl enable NetworkManager.service -# Don't let network-online.target (pulled in by cups-browsed) hold up -# graphical.target waiting for DHCP/Wi-Fi association. Nothing in the session -# needs to block on the network. Mirrors the systemd-networkd-wait-online mask -# in install/hardware/network.sh. +# Don't let network-online.target hold up graphical.target waiting for +# DHCP/Wi-Fi association. Nothing in the session needs to block on the network. +# Mirrors the systemd-networkd-wait-online mask in install/hardware/network.sh. systemctl mask NetworkManager-wait-online.service systemctl enable power-profiles-daemon.service systemctl enable sddm.service diff --git a/install/config/locate.sh b/install/config/locate.sh deleted file mode 100644 index cc95e9b8..00000000 --- a/install/config/locate.sh +++ /dev/null @@ -1,32 +0,0 @@ -UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}" - -echo "Configuring locate to skip Btrfs snapshots and index Btrfs subvolumes" - -[[ -f $UPDATEDB_CONF_PATH ]] || exit 0 - -# updatedb refuses to run at all on a config that defines a variable twice, so -# every setting here is rewritten where it already stands and only appended -# when the file has no line for it. - -# Btrfs subvolume mounts (like /home) look like bind mounts, so pruning -# bind mounts leaves them out of the index entirely. -if grep -qE '^[[:space:]]*PRUNE_BIND_MOUNTS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then - sed -i -E 's|^[[:space:]]*PRUNE_BIND_MOUNTS[[:space:]]*=.*|PRUNE_BIND_MOUNTS = "no"|' "$UPDATEDB_CONF_PATH" -else - printf '%s\n' 'PRUNE_BIND_MOUNTS = "no"' >>"$UPDATEDB_CONF_PATH" -fi - -# Snapper snapshots are nested subvolumes reached by plain directory -# traversal, so without this updatedb indexes the system once per snapshot. -if grep -qE '^[[:space:]]*PRUNEPATHS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then - # updatedb only accepts quoted values and allows a comment after them. Read - # back what the machine already prunes and write the whole setting out again - # rather than splicing into a line of unknown shape. - pruned=$(sed -nE 's|^[[:space:]]*PRUNEPATHS[[:space:]]*=[[:space:]]*"([^"]*)".*|\1|p' "$UPDATEDB_CONF_PATH" | tail -n 1) - - if [[ " $pruned " != *" /.snapshots "* ]]; then - sed -i -E "s|^[[:space:]]*PRUNEPATHS[[:space:]]*=.*|PRUNEPATHS = \"/.snapshots${pruned:+ $pruned}\"|" "$UPDATEDB_CONF_PATH" - fi -else - printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH" -fi diff --git a/install/config/theme-system.sh b/install/config/theme-system.sh index 2902e1cd..1527e17e 100644 --- a/install/config/theme-system.sh +++ b/install/config/theme-system.sh @@ -6,11 +6,8 @@ ln -snf /usr/share/icons/Adwaita/symbolic/actions/go-next-symbolic.svg \ /usr/share/icons/Yaru/scalable/actions/go-next-symbolic.svg gtk-update-icon-cache /usr/share/icons/Yaru &>/dev/null || true -# Chromium policy directory for theme -mkdir -p /etc/chromium/policies/managed -chmod a+rw /etc/chromium/policies/managed - -# Default Chromium to follow system appearance ("device") instead of dark +# Seed Chromium's first run: follow system appearance ("device") instead of dark, +# and skip the terms-of-service dialog Chromium 151 turned on by default. mkdir -p /usr/lib/chromium -echo '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ +echo '{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ /usr/lib/chromium/initial_preferences diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 6adcff9c..12add110 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -4,7 +4,6 @@ run_logged "$OMARCHY_INSTALL/hardware/dell-xps-touchpad-haptics.sh" run_logged "$OMARCHY_INSTALL/hardware/surface.sh" run_logged "$OMARCHY_INSTALL/hardware/network.sh" -run_logged "$OMARCHY_INSTALL/hardware/input-group.sh" run_logged "$OMARCHY_INSTALL/hardware/set-wireless-regdom.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-fkeys.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-synaptic-touchpad.sh" @@ -15,16 +14,16 @@ run_logged "$OMARCHY_INSTALL/hardware/vulkan.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/video-acceleration.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/lpmd.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/thermald.sh" -# Swap in the Panther Lake kernel before anything pulls DKMS modules in. -# intel-ipu7-camera drags in ipu7-drivers, vision-drivers and v4l2loopback, -# and building all three against the stock kernel only to rebuild them against -# linux-ptl and tear the first set down again cost ~25s of the install. -run_logged "$OMARCHY_INSTALL/hardware/intel/ptl-kernel.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/ipu7-camera.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh" +run_logged "$OMARCHY_INSTALL/hardware/fix-elgato-camlink-4k.sh" + +# Rebuilds the boot image, so it follows camera module setup. +run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-sidecar-amps.sh" + run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-display-backlight.sh" run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-display.sh" run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-touchpad.sh" diff --git a/install/hardware/dell-xps13-sidecar-amps.sh b/install/hardware/dell-xps13-sidecar-amps.sh new file mode 100644 index 00000000..5682b595 --- /dev/null +++ b/install/hardware/dell-xps13-sidecar-amps.sh @@ -0,0 +1,10 @@ +# Enable the temporary sidecar amplifier workaround on the exact Dell XPS 13 model that needs it. +# +# Pacman registers a package even when its post_install scriptlet fails, so the +# apply command runs explicitly here: a failed cleanup or boot-image rebuild has +# to reach the caller rather than hide behind a successfully registered package. + +if omarchy-hw-dell-xps13-sidecar-amps; then + omarchy-pkg-add dell-xps13-sidecar-amps && + sudo dell-xps13-sidecar-amps-apply +fi diff --git a/install/hardware/fix-bcm43xx.sh b/install/hardware/fix-bcm43xx.sh index a788d6f9..0f9cf474 100644 --- a/install/hardware/fix-bcm43xx.sh +++ b/install/hardware/fix-bcm43xx.sh @@ -6,5 +6,5 @@ pci_info=$(lspci -nn) if (echo "$pci_info" | grep -q "14e4:43a0" || echo "$pci_info" | grep -q "14e4:4331"); then echo "BCM4360 / BCM4331 detected" - omarchy-pkg-add broadcom-wl dkms linux-headers + omarchy-pkg-add broadcom-wl-dkms fi diff --git a/install/hardware/fix-elgato-camlink-4k.sh b/install/hardware/fix-elgato-camlink-4k.sh new file mode 100644 index 00000000..9ae9e343 --- /dev/null +++ b/install/hardware/fix-elgato-camlink-4k.sh @@ -0,0 +1,17 @@ +# Expose the Elgato Cam Link 4K as a 16:9-only virtual camera. +# Browsers ask it for 640x480, which it fills by cropping, and the 4:3 frame +# then gets stretched into a 16:9 tile. The raw node is hidden from users and +# v4l2-relayd re-exposes it at 1280x720 under the same name. + +if omarchy-hw-elgato-camlink-4k; then + omarchy-pkg-add v4l2loopback-dkms v4l2loopback-utils v4l2-relayd + + sudo install -Dm644 "$OMARCHY_PATH/default/udev/elgato-camlink-4k.rules" /etc/udev/rules.d/71-elgato-camlink-4k.rules + sudo install -Dm644 "$OMARCHY_PATH/default/v4l2-relayd/camlink.conf" /etc/v4l2-relayd.d/camlink.conf + sudo install -Dm644 "$OMARCHY_PATH/default/systemd/system/camlink-4k-loopback.service" /etc/systemd/system/camlink-4k-loopback.service + sudo install -Dm644 "$OMARCHY_PATH/default/systemd/system/v4l2-relayd@camlink.service.d/camlink.conf" "/etc/systemd/system/v4l2-relayd@camlink.service.d/camlink.conf" + + # The module's own default device would otherwise show up in browsers as + # "Dummy video device" on machines without another relay. + echo "options v4l2loopback exclusive_caps=1" | sudo tee /etc/modprobe.d/v4l2loopback-exclusive-caps.conf >/dev/null +fi diff --git a/install/hardware/fix-synaptic-touchpad.sh b/install/hardware/fix-synaptic-touchpad.sh index d30dd271..00b3396c 100644 --- a/install/hardware/fix-synaptic-touchpad.sh +++ b/install/hardware/fix-synaptic-touchpad.sh @@ -1,6 +1,25 @@ # Enable Synaptics InterTouch for confirmed touchpads if not already loaded - -if grep -qi synaptics /proc/bus/input/devices \ - && ! lsmod | grep -q '^psmouse'; then - modprobe psmouse synaptics_intertouch=1 +# +# Only when modprobe can resolve psmouse for the running kernel, and never +# fatally. Installs run this under arch-chroot, where uname -r still names the +# live ISO's kernel while /lib/modules holds the target's -- the two differ on +# every machine that is not a T2 Mac, so modprobe failed with "Module psmouse +# not found in directory /lib/modules/" and took the whole install +# down with it. Asking modprobe itself first (-qn resolves a module without +# loading it) keeps that mismatch quiet, and the warning below covers every +# other reason it declines. An optional touchpad improvement must not be able to +# halt an install. +# +# Loading a module into the live kernel does nothing for the installed system +# either way, so this only takes effect when it runs on the booted machine. +# Persisting the switch instead would mean writing options psmouse +# synaptics_intertouch=1 to /etc/modprobe.d, which forces the SMBus transport +# past the kernel's own allowlist on every touchpad merely named "synaptics" in +# /proc/bus/input/devices -- a wider change than this one, and not one to make +# blind. +if grep -qi synaptics "${OMARCHY_SYNAPTIC_INPUT_DEVICES:-/proc/bus/input/devices}" \ + && ! lsmod | grep -q '^psmouse' \ + && modprobe -qn psmouse; then + modprobe psmouse synaptics_intertouch=1 || + echo "Warning: could not enable Synaptics InterTouch on psmouse" >&2 fi diff --git a/install/hardware/fix-tuxedo-backlight.sh b/install/hardware/fix-tuxedo-backlight.sh index 9644d310..7fb408e0 100644 --- a/install/hardware/fix-tuxedo-backlight.sh +++ b/install/hardware/fix-tuxedo-backlight.sh @@ -1,7 +1,7 @@ # Install Tuxedo drivers for keyboard backlighting on Tuxedo laptops and # compatible devices like the Slimbook Executive (Clevo/Tuxedo chassis). if cat /sys/class/dmi/id/sys_vendor 2>/dev/null | grep -qi "TUXEDO\|Slimbook"; then - omarchy-pkg-add linux-headers tuxedo-drivers-nocompatcheck-dkms + omarchy-pkg-add tuxedo-drivers-nocompatcheck-dkms # Blacklist the legacy clevo_xsm_wmi module which conflicts with the tuxedo-drivers # clevo_wmi module. When clevo_xsm_wmi loads first, it grabs the Clevo WMI GUIDs, diff --git a/install/hardware/fix-yt6801-ethernet-adapter.sh b/install/hardware/fix-yt6801-ethernet-adapter.sh index 34331a0f..f88a9cec 100644 --- a/install/hardware/fix-yt6801-ethernet-adapter.sh +++ b/install/hardware/fix-yt6801-ethernet-adapter.sh @@ -1,4 +1,4 @@ # Install drivers for Motorcomm YT6801 ethernet adapter used by the Slimbook Executive if lspci | grep -i "YT6801\|Motorcomm.*Ethernet"; then - omarchy-pkg-add linux-headers yt6801-dkms + omarchy-pkg-add yt6801-dkms fi diff --git a/install/hardware/input-group.sh b/install/hardware/input-group.sh deleted file mode 100644 index fac87fcd..00000000 --- a/install/hardware/input-group.sh +++ /dev/null @@ -1,11 +0,0 @@ -# Give this user privileged input access for dictation tools + xbox controllers to work. -# Recorded for provisioning first-boot user creation and factory reset, granted directly -# when the install user already exists (deferred-provisioning installs create the user at -# first boot instead). -provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" -mkdir -p "$provisioning_dir" -grep -qxF input "$provisioning_dir/groups" 2>/dev/null || echo input >>"$provisioning_dir/groups" - -if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then - usermod -aG input "$OMARCHY_INSTALL_USER" -fi diff --git a/install/hardware/intel/ptl-kernel.sh b/install/hardware/intel/ptl-kernel.sh deleted file mode 100644 index 79482724..00000000 --- a/install/hardware/intel/ptl-kernel.sh +++ /dev/null @@ -1,25 +0,0 @@ -# Install Panther Lake kernel for Dell XPS Panther Lake systems -# The linux-ptl kernel includes audio driver patches not yet in mainline. - -if omarchy-hw-match "XPS" && omarchy-hw-intel-ptl; then - echo "Detected Dell XPS Panther Lake, installing PTL kernel..." - - omarchy-pkg-add linux-ptl linux-ptl-headers - pacman -Rdd --noconfirm linux linux-headers || true - - # linux-ptl doesn't provide=linux, so anything depending on linux drags the - # stock kernel back in and the boot menu grows a second, slower entry. - if pacman -Qq linux &>/dev/null; then - echo "WARNING: stock linux kernel still installed alongside linux-ptl:" - pacman -Qi linux | grep -i "required by" - fi - - mkdir -p /etc/limine-entry-tool.d - # Named to sort after omarchy-defaults.conf: drop-ins are read in order and - # the last BOOT_ORDER wins, so an earlier-sorting name is a silent no-op. - rm -f /etc/limine-entry-tool.d/dell-xps-panther-lake.conf - cat > /etc/limine-entry-tool.d/zz-dell-xps-panther-lake.conf <<'EOF' -# Only show Panther Lake kernel in boot menu on Dell XPS Panther Lake -BOOT_ORDER="linux-ptl*, *fallback, Snapshots" -EOF -fi diff --git a/install/hardware/nvidia.sh b/install/hardware/nvidia.sh index e7370995..1c36787b 100644 --- a/install/hardware/nvidia.sh +++ b/install/hardware/nvidia.sh @@ -1,8 +1,4 @@ if lspci | grep -qi 'nvidia'; then - # Check which kernel is installed and set appropriate headers package - KERNEL_PACKAGE=$(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl)?$' | head -1 || true) - [[ -n $KERNEL_PACKAGE ]] && omarchy-pkg-add "$KERNEL_PACKAGE-headers" - if omarchy-hw-nvidia-gsp; then PACKAGES=(nvidia-open-dkms nvidia-utils lib32-nvidia-utils libva-nvidia-driver) elif omarchy-hw-nvidia-without-gsp; then diff --git a/install/helpers/as-root.sh b/install/helpers/as-root.sh new file mode 100644 index 00000000..005ae351 --- /dev/null +++ b/install/helpers/as-root.sh @@ -0,0 +1,7 @@ +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh new file mode 100644 index 00000000..c2c93d8e --- /dev/null +++ b/install/helpers/browser-policy.sh @@ -0,0 +1,168 @@ +# Chromium-family machine policy is mandatory for every profile. Directories +# stay 0755 root:root; omarchy-theme-set-browser-policy is the privileged +# write for color.json. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/as-root.sh" + +BROWSER_POLICY_MANAGED_DIRS=( + /etc/chromium/policies/managed + /etc/opt/chrome/policies/managed + /etc/opt/edge/policies/managed + /etc/brave/policies/managed +) + +# Ancestors of the managed dirs, shortest first. A writable or attacker-owned +# parent can rename the leaf aside; install -d follows a planted symlink. +BROWSER_POLICY_PARENT_DIRS=( + /etc/chromium + /etc/chromium/policies + /etc/opt/chrome + /etc/opt/chrome/policies + /etc/opt/edge + /etc/opt/edge/policies + /etc/brave + /etc/brave/policies +) + +BROWSER_POLICY_FIREFOX_DIRS=( + /usr/lib/firefox/distribution + /opt/zen-browser/distribution +) + +BROWSER_POLICY_DEFAULT_COLOR="#1c2027" + +browser_policy_purge_dir() { + local dir=$1 + + as_root find "$dir" -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} + +} + +browser_policy_parent_hardened() { + local dir=$1 + + [[ -d $dir && ! -L $dir ]] || return 1 + [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 + [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 +} + +browser_policy_dir_hardened() { + browser_policy_parent_hardened "$1" +} + +browser_policy_parents_hardened() { + local dir=$1 + local parent + + for parent in "${BROWSER_POLICY_PARENT_DIRS[@]}"; do + [[ $dir == "$parent"/* ]] || continue + [[ -e $parent || -L $parent ]] || continue + browser_policy_parent_hardened "$parent" || return 1 + done +} + +browser_policy_setup_parent() { + local dir=$1 + + if [[ -L $dir || ( -e $dir && ! -d $dir ) ]]; then + as_root rm -rf -- "$dir" + fi + as_root install -d -m 0755 -o root -g root "$dir" +} + +browser_policy_setup_parents_for() { + local dir=$1 + local parent + + for parent in "${BROWSER_POLICY_PARENT_DIRS[@]}"; do + [[ $dir == "$parent"/* ]] || continue + browser_policy_setup_parent "$parent" + done +} + +browser_policy_setup_dir() { + local dir=$1 + + browser_policy_setup_parents_for "$dir" + browser_policy_setup_parent "$dir" + browser_policy_purge_dir "$dir" +} + +# Themes are user-installed. Accept only three 0-255 components. +browser_policy_theme_hex() { + local theme_rgb=$1 + + if [[ $theme_rgb =~ ^[[:space:]]*([0-9]{1,3})[[:space:]]*,[[:space:]]*([0-9]{1,3})[[:space:]]*,[[:space:]]*([0-9]{1,3})[[:space:]]*$ ]] && + (( 10#${BASH_REMATCH[1]} < 256 && 10#${BASH_REMATCH[2]} < 256 && 10#${BASH_REMATCH[3]} < 256 )); then + printf '#%02x%02x%02x' "$((10#${BASH_REMATCH[1]}))" "$((10#${BASH_REMATCH[2]}))" "$((10#${BASH_REMATCH[3]}))" + return + fi + + printf '%s' "$BROWSER_POLICY_DEFAULT_COLOR" +} + +browser_policy_install_color() { + local policy_dir=$1 + local hex=$2 + local dest=$policy_dir/color.json + local tmp + + [[ -d $policy_dir && ! -L $policy_dir ]] || return 0 + [[ $hex =~ ^#[0-9a-f]{6}$ ]] || return 1 + + tmp=$(mktemp) || return 1 + printf '{"BrowserThemeColor": "%s", "BrowserColorScheme": "device"}\n' "$hex" >"$tmp" + + if [[ -L $dest || -d $dest ]]; then + if ! rm -rf -- "$dest" 2>/dev/null; then + rm -f "$tmp" + return 1 + fi + fi + + if install -m 0644 -T "$tmp" "$dest" 2>/dev/null; then + rm -f "$tmp" + return 0 + fi + + rm -f "$tmp" + return 1 +} + +browser_policy_firefox_policy_file_ok() { + local file=$1 + local mode + local group_write + local other_write + + [[ -f $file && ! -L $file ]] || return 1 + [[ $(stat -c '%U' "$file") == "root" ]] || return 1 + mode=$(stat -c '%a' "$file") + group_write=$((8#${mode: -2:1})) + other_write=$((8#${mode: -1})) + (( (group_write & 2) == 0 && (other_write & 2) == 0 )) +} + +browser_policy_firefox_hardened() { + local dir=$1 + + [[ -d $dir && ! -L $dir ]] || return 1 + [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 + [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 + browser_policy_firefox_policy_file_ok "$dir/policies.json" +} + +browser_policy_install_firefox_policies() { + local distribution_dir=$1 + local policies=${2:-$OMARCHY_PATH/default/firefox/policies.json} + + as_root install -m 644 -o root -g root -T "$policies" "$distribution_dir/policies.json" +} + +browser_policy_setup_firefox_distribution() { + local distribution_dir=$1 + local policies=${2:-$OMARCHY_PATH/default/firefox/policies.json} + + browser_policy_setup_parent "$distribution_dir" + browser_policy_purge_dir "$distribution_dir" + browser_policy_install_firefox_policies "$distribution_dir" "$policies" +} diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 1c01081e..787fb9b9 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -17,9 +17,8 @@ chromium clang cliamp cups -cups-browsed cups-filters -cups-pdf +cups-pk-helper ddcutil docker docker-buildx @@ -27,6 +26,7 @@ docker-compose dosfstools dotnet-runtime dua-cli +elsewhen evince exfatprogs expac @@ -96,6 +96,10 @@ omacalc omacut omawrite omarchy-nvim +omasnap +# OWE owns desktop video backgrounds and feeds the lock screen its frames. +owe +owe-lockfeed pacman-contrib pamixer pinta @@ -109,10 +113,9 @@ ttfx qemu-user-static-binfmt qrencode qt6-imageformats -quickshell-git +quickshell ripgrep ruby -tensaku sddm slurp socat @@ -134,6 +137,7 @@ ufw-docker unzip usage uwsm +vi whois wireless-regdb wireplumber diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index 02ac645e..4a8b128f 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -5,7 +5,7 @@ autoconf-archive asusctl base base-devel -broadcom-wl +broadcom-wl-dkms btrfs-progs dkms egl-wayland @@ -20,11 +20,9 @@ libva-nvidia-driver limine limine-mkinitcpio-hook limine-snapper-sync -linux linux-firmware -linux-headers -linux-ptl -linux-ptl-headers +linux-omarchy +linux-omarchy-headers macbook12-spi-driver-dkms nvidia-580xx-dkms nvidia-dkms @@ -61,6 +59,7 @@ linux-firmware-marvell # Dell laptop support packages dell-xps-touchpad-haptics +dell-xps13-sidecar-amps # Speaker tunings (LV2 limiter every tuning ends in) lsp-plugins-lv2 diff --git a/install/post-install/localdb.sh b/install/post-install/localdb.sh index 8c3f36a8..6600d301 100644 --- a/install/post-install/localdb.sh +++ b/install/post-install/localdb.sh @@ -1,2 +1,3 @@ # Update localdb so locate can find the installed system files immediately. -updatedb +# Match the scheduled service while installation runs without a system manager. +updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index 23580b3c..da84975b 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -3,11 +3,11 @@ cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist -# omarchy-settings skips this override until cups-browsed is actually present -# to avoid pacman creating cups-browsed.conf.pacnew during ISO package install. -if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -d /etc/cups ]]; then - cp -f "$OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf" /etc/cups/cups-browsed.conf - rm -f /etc/cups/cups-browsed.conf.pacnew +# Wait for CUPS to own the file, the way omarchy-settings does, so pacman does +# not turn the override into a .pacnew during ISO package installation. +if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-files.conf && -f /etc/cups/cups-files.conf ]]; then + install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf + rm -f /etc/cups/cups-files.conf.pacnew fi source "$OMARCHY_INSTALL/hardware/pacman.sh" diff --git a/install/provisioning/setup-form.sh b/install/provisioning/setup-form.sh index 909668eb..f4265d1d 100644 --- a/install/provisioning/setup-form.sh +++ b/install/provisioning/setup-form.sh @@ -79,7 +79,7 @@ Turkish|trq Ukrainian|ua' OMARCHY_USERNAME_PATTERN='^[a-z_][a-z0-9_-]*[$]?$' -OMARCHY_RESERVED_USERNAMES='^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$' +OMARCHY_RESERVED_USERNAMES='^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|cups-browsed|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$' OMARCHY_HOSTNAME_PATTERN='^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$' OMARCHY_HOSTNAME_DEFAULT='omarchy' diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index 9865fc20..e08885c9 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -14,8 +14,11 @@ set -euo pipefail systemctl --user daemon-reload systemctl --user enable --now \ bt-agent.service \ + owed.service \ omarchy-recover-internal-monitor.service \ omarchy-sleep-lock.service \ omarchy-migrate-notify.service \ omarchy-fcitx5.service \ omarchy-crash-watch.service + +omarchy-hook-install theme-set /usr/share/owe/10-owe-sync diff --git a/install/user/first-run/install-voxtype.hook b/install/user/first-run/install-voxtype.hook index eb180f81..1d07daa6 100644 --- a/install/user/first-run/install-voxtype.hook +++ b/install/user/first-run/install-voxtype.hook @@ -5,5 +5,5 @@ set -e if omarchy-done ensure voxtype-install-invitation; then omarchy-notification-send -u critical -g  "Install Dictation with Voxtype" \ "Click to install voice dictation for Omarchy." \ - --exec "omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install" + --exec omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install fi diff --git a/install/user/first-run/setup-agent.hook b/install/user/first-run/setup-agent.hook index d69c710c..90596b83 100644 --- a/install/user/first-run/setup-agent.hook +++ b/install/user/first-run/setup-agent.hook @@ -7,5 +7,5 @@ set -e if [[ -z $(omarchy-default-agent) ]] && omarchy-done ensure agent-setup-invitation; then omarchy-notification-send -u critical -g 󰚩 "Set your default agent" \ "Let your favorite agent help with Omarchy." \ - --exec "omarchy menu summon setup.default.agent" + --exec omarchy menu summon setup.default.agent fi diff --git a/install/user/first-run/setup-fingerprint.hook b/install/user/first-run/setup-fingerprint.hook index 1ff17b5d..2861eef0 100644 --- a/install/user/first-run/setup-fingerprint.hook +++ b/install/user/first-run/setup-fingerprint.hook @@ -8,5 +8,5 @@ if omarchy-hw-fingerprint && [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]] && omarchy-done ensure fingerprint-setup-invitation; then omarchy-notification-send -u critical -g 󰈷 "Setup Fingerprint Reader" \ "Enable sudo and unlocking with your fingerprint." \ - --exec "omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint" + --exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint fi diff --git a/install/user/first-run/wifi.sh b/install/user/first-run/wifi.sh index c6d203dd..3b2af23a 100644 --- a/install/user/first-run/wifi.sh +++ b/install/user/first-run/wifi.sh @@ -1,11 +1,11 @@ notify_update() { omarchy-notification-send -u critical -g  "Update System" "Click to update the system." \ - --exec "omarchy-launch-floating-terminal-with-presentation omarchy-update" + --exec omarchy-launch-floating-terminal-with-presentation omarchy-update } notify_wifi() { omarchy-notification-send -u critical -g 󰖩 "Setup Wi-Fi" "Click to configure the wireless network." \ - --exec "omarchy-shell shell toggle omarchy.network" + --exec omarchy-shell shell toggle omarchy.network } announce_network() { diff --git a/install/user/mise-work.sh b/install/user/mise-work.sh index f77d5561..75ca0916 100644 --- a/install/user/mise-work.sh +++ b/install/user/mise-work.sh @@ -2,13 +2,6 @@ mkdir -p "$HOME/Work" mkdir -p "$HOME/Work/tries" -cat >"$HOME/Work/.mise.toml" <<'EOF' -[env] -_.path = "{{ cwd }}/bin" -EOF - -mise trust ~/Work/.mise.toml - # Offline installs unpack the Node tarball bundled by the ISO: from # /opt/packages in the ISO chroot, or from the copy staged in provisioning state when # omarchy-provision-owner finalizes the user at first boot. @@ -37,6 +30,11 @@ if [[ -n $NODE_PACKAGE_DIR ]]; then mkdir -p "$NODE_INSTALL_DIR" tar -xzf "$NODE_TARBALL" --strip-components=1 -C "$NODE_INSTALL_DIR" mise use -g node@"$NODE_VERSION" + + # That pinned the exact bundled version, which would exempt Node from + # mise up forever. Loosen it to latest, like an online install gets: + # mise resolves latest to the installed version while offline. + mise config set tools.node latest --file "$HOME/.config/mise/config.toml" fi else mise use -g node@latest diff --git a/install/user/mise.sh b/install/user/mise.sh index 6653cec4..dfc56aa2 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -1,3 +1,7 @@ +# Upgrades must not delete the version a running process is executing from: +# mise up would prune the old install dir out from under a live session. +mise settings set upgrade.auto_prune false + omarchy-mise-install codex omarchy-mise-install claude omarchy-mise-install crush @@ -9,6 +13,20 @@ omarchy-mise-install npm:playwright playwright omarchy-mise-install pi omarchy-mise-install github:can1357/oh-my-pi omp omarchy-mise-install npm:@xai-official/grok grok +# Cursor's own installer links the same path, so a re-provision keeps it. +omarchy-cmd-missing cursor-agent && omarchy-mise-install cursor-agent omarchy-mise-install npm:@kitlangton/ghui ghui omarchy-mise-install aqua:modem-dev/hunk hunk omarchy-mise-install github:basecamp/hey-cli hey +omarchy-mise-install github:basecamp/basecamp-cli basecamp +omarchy-mise-install npm:cf cf +omarchy-mise-install github:OpenRouterLabs/ori-releases ori +# Every line above writes a stub and cannot fail. This one can: it exits +# non-zero when Hermes Desktop owns Hermes but has not finished setting it up, +# and this leaf is sourced under `bash -eE`, so that would abort the rest of +# omarchy-provision-user -- the default browser, the mailto handler and the +# finalize-user marker all come after it. +omarchy-install-hermes-cli || true +if omarchy-cmd-missing muse; then + omarchy-mise-install "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]" muse +fi diff --git a/manual/03-coming-from-mac-or-windows.md b/manual/03-coming-from-mac-or-windows.md index cb36d6e4..f18189a7 100644 --- a/manual/03-coming-from-mac-or-windows.md +++ b/manual/03-coming-from-mac-or-windows.md @@ -46,7 +46,7 @@ Updates come through one command — _Update > Omarchy_ — that updates Omarchy Software comes from a package manager, not from downloaded installers. -And when you close a window, the app actually quits. There's no macOS limbo where the program keeps running with no windows. `Super + W` means gone. +And when you close a window, the app actually quits. There's no macOS limbo where the program keeps running with no windows. `Super + W` — or `Super + Q`, if that's the finger memory you arrived with — means gone. ### On Mac hardware diff --git a/manual/04-navigation.md b/manual/04-navigation.md index 1d3bc173..0b3404ff 100644 --- a/manual/04-navigation.md +++ b/manual/04-navigation.md @@ -22,7 +22,7 @@ If you hit `Super + Shift + 2`, you'll move the current focused application onto If you hold down `Super` and use the mouse to click on a window, you'll be able to rearrange where it sits. If you hold `Super` and use the right button on the mouse, you can freely resize the window. -You close a window on `Super + W` (and close all windows on `Ctrl + Alt + Delete`). +You close a window on `Super + W` or `Super + Q` (and close all windows on `Ctrl + Alt + Delete`). You can also go full screen with `Super + F` or even just full-width (keeping the top bar) with `Super + Alt + F` or full-screen within a window with `Super + Ctrl + F` (good for YouTube!). @@ -66,6 +66,8 @@ Finally, there's a special scratchpad workspace that drops down over whatever wo It works especially well for a terminal running an agent, or for controls you want to interact with quickly without leaving the current workspace. To move a window off the scratchpad, send it directly to another workspace with something like `Super + Shift + 1`. +While the scratchpad holds a single window, it drops down as a centered panel rather than spanning the screen. Put a second app on it and it goes back to the full width, so the two have room to sit side by side. + ### It takes some getting used to! It takes a little while to get used to navigating your desktop like this, but once you do, it'll be hard to go back to a traditional mouse-driven desktop experience! diff --git a/manual/07-hotkeys.md b/manual/07-hotkeys.md index 77b57e54..72acdc8a 100644 --- a/manual/07-hotkeys.md +++ b/manual/07-hotkeys.md @@ -10,7 +10,7 @@ You can see all the main keyboard bindings with `Super + K` (Tmux bindings with | `Super + Alt + Space` | Apps menu | | `Super + Escape` | System menu (suspend, restart, etc) | | `Super + Ctrl + L` | Lock computer | -| `Super + W` | Close window | +| `Super + W` or `Super + Q` | Close window | | `Ctrl + Alt + Del` | Close all windows | | `Super + T` | Toggle window between tiling/floating | | `Super + J` | Toggle window position (horizontal/vertical) | @@ -192,6 +192,7 @@ All style options are also accessible under _Style_ in the Omarchy menu (`Super | `Shift + Mute` | Switch to next audio output | | `Shift + Play` | Switch to next media source | | `Super + Shift + Backspace` | Toggle window gaps | +| `Super + Ctrl + Alt + F` | Toggle full screen desktop (top bar + window gaps) | ## Reminders diff --git a/manual/12-screenshots-recording.md b/manual/12-screenshots-recording.md index 9c476231..41d6052d 100644 --- a/manual/12-screenshots-recording.md +++ b/manual/12-screenshots-recording.md @@ -13,13 +13,15 @@ Everything you can grab off the screen hangs off the Print Screen key. One key o ## Screenshots -Hit `Print Screen` and the screen freezes so nothing shifts under you while you aim. Drag a box for a freeform region, or just click once and the shot snaps to whatever rectangle you clicked in — a window if you landed on one, the whole monitor if you landed on the bar or in a gap. Changed your mind? Hit `Print Screen` again to dismiss the picker. +Hit `Print Screen` and Omasnap captures the focused monitor before its overlay appears, so nothing shifts under you while you aim. Drag a freeform region, click a window to capture it, or click open space to capture the whole monitor. Press `S` before drawing to capture a scrolling region. Changed your mind? Hit `Print Screen` again to dismiss Omasnap. -The result goes two places at once: a PNG in your pictures directory, and the clipboard, so you can paste it straight into a chat window with `Super + V`. A notification pops up with a thumbnail. Click it (or hit `Super + Alt + ,` to invoke the last notification) and the shot opens in Tensaku, the annotation editor, where you can draw arrows and boxes on it before you send it. +After you select an area, Omasnap copies the capture to the clipboard and shows a preview for 10 seconds. Use the preview's pin button or `Ctrl + P` to keep it on screen, or choose Edit to annotate it. -Files land in `~/Pictures` by default, named `screenshot-2026-08-13_14-22-05.png`. If you'd rather keep them in their own folder, set `OMARCHY_SCREENSHOT_DIR` — see [the FAQ](46-faq.md) for where to put session environment variables. Omarchy creates the directory for you if it isn't there. You can swap the editor too with `OMARCHY_SCREENSHOT_EDITOR`. +The annotation editor can draw arrows, lines, shapes, highlights, numbered markers, text, and secure redactions; crop or cut out part of the image; OCR its text; and add a backdrop. In the editor, press `Enter` to copy and save the finished PNG, `Ctrl + C` to copy it without saving, or `Ctrl + S` to save it without copying. -From the terminal, `omarchy screenshot` takes the same shot, and you can be explicit about it: `omarchy capture screenshot region` for freeform only, `windows` to snap to window and monitor rectangles, or `fullscreen` to skip the picker entirely and grab the focused monitor. A second argument of `copy` puts the shot only on the clipboard, and `save` only on disk. +Saved screenshots land in `~/Pictures/Screenshots` by default, with a name such as `screenshot-2026-08-13_14-22-05-firefox.png`. Set `OMASNAP_SCREENSHOT_DIR` to use another directory — see [the FAQ](46-faq.md) for where to put session environment variables. Omasnap creates the directory when it saves the first shot. + +From the terminal, `omarchy screenshot` opens the same overlay, and you can choose its starting mode: `omarchy capture screenshot region`, `windows`, `fullscreen`, or `scroll`. A second argument of `copy` or `save` skips the preview and sends the shot straight to that destination. To edit before output, use `omarchy screenshot --editor=overlay` for a fullscreen editor or `omarchy screenshot --editor=window` for a separate window. ### Driving the picker from the keyboard @@ -27,16 +29,18 @@ While the selection is up, you don't have to use the mouse at all: | Key | Function | | --- | -------- | +| `S` | Toggle scrolling-region mode | +| `Super + Arrow keys` | Move among windows in Window mode | | `Return` | Capture the highlighted window | -| `Ctrl + Return` | Capture the whole screen | -| `Tab` / `Ctrl + Tab` | Highlight the next / previous window | -| Arrow keys | Highlight the window in that direction | +| `Ctrl + A` | Capture the full focused monitor | +| `R` | Restore the last region drawn in this session | +| `Esc` | Dismiss Omasnap | -The arrows and Tab move the cursor to the window they pick, so the highlight follows along and you can see what you're about to capture. These bindings only exist while a selection is on screen, so they can't collide with anything in your own config. +These bindings only exist while Omasnap is open, so they can't collide with anything in your own config. ## Screen recording -`Alt + Print Screen` opens _Trigger > Capture > Screenrecord_, which asks what you want on the soundtrack: no audio, desktop audio, desktop plus microphone, or desktop plus microphone plus webcam. That last one only shows up if you actually have a camera plugged in. Pick one and you get the same picker as a screenshot: drag a region, or click a window or monitor. +`Alt + Print Screen` opens _Trigger > Capture > Screenrecord_, which asks what you want on the soundtrack: no audio, desktop audio, desktop plus microphone, or desktop plus microphone plus webcam. That last one only shows up if you actually have a camera plugged in. Pick one and Omarchy's recording picker lets you drag a region or click a window or monitor. Recording runs on gpu-screen-recorder, which encodes on the GPU at 60fps and falls back to the CPU if it has to. The result is an MP4 in `~/Videos`, named `screenrecording-2026-08-13_14-22-05.mp4`. Set `OMARCHY_SCREENRECORD_DIR` to change that — but note that unlike the screenshot directory, this one has to exist already, or the recording refuses to start. diff --git a/manual/13-toggles-idle-screensaver.md b/manual/13-toggles-idle-screensaver.md index 6c01398c..2effdac1 100644 --- a/manual/13-toggles-idle-screensaver.md +++ b/manual/13-toggles-idle-screensaver.md @@ -21,7 +21,7 @@ From the terminal, the same switches are `omarchy toggle `. Run `omarchy | Suspend | — | `omarchy toggle suspend` | | Hybrid GPU | — | `omarchy toggle hybrid gpu` | -The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled state is written back out as a small Lua file that Hyprland sources on startup. +The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled device's name is saved to a small state file that Hyprland reads on startup to disable it again. The Toggle menu also carries a few things that aren't `omarchy toggle` commands but behave the same: battery percentage in the bar, workspace layout (`Super + L`), window gaps (`Super + Shift + Backspace`), and the 1-window square aspect (`Super + Ctrl + Backspace`). diff --git a/manual/14-omarchy-cli.md b/manual/14-omarchy-cli.md index fa5d0a05..76f386e8 100644 --- a/manual/14-omarchy-cli.md +++ b/manual/14-omarchy-cli.md @@ -50,7 +50,7 @@ Capture commands — Screenshots and screen recording: omarchy capture qr Decode a QR code from a screenshot region omarchy capture screenrecording [--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=] [--webcam-size=] [--resolution=] [--stop-recording] Start or stop screen recording omarchy capture screenrecording with webcam Pick a webcam and start a screen recording with it - omarchy capture screenshot [smart|region|windows|fullscreen] [slurp|copy|save] [--editor=] Take a screenshot + omarchy capture screenshot [smart|region|windows|fullscreen|scroll] [copy|save] Take a screenshot omarchy capture text Extract text from a screenshot region with OCR omarchy capture webcam resize Resize the active webcam recording overlay ``` diff --git a/manual/17-ai.md b/manual/17-ai.md index a0fc17f4..0d62622d 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -13,6 +13,12 @@ Omarchy treats AI coding agents as first-class citizens, but it doesn't pick a f | `grok` | Grok CLI from xAI | | `pi` | [Mario Zechner's Pi](https://github.com/badlogic/pi-mono) | | `omp` | [Oh My Pi](https://github.com/can1357/oh-my-pi) | +| `ori` | [Ori](https://openrouter.ai/docs/guides/ori/harness), OpenRouter's harness | +| `hermes` | [Hermes](https://hermes-agent.nousresearch.com/), Nous Research's agent | +| `muse` | [Muse Code](https://dev.meta.ai), Meta's coding agent | +| `cursor-agent` | [Cursor CLI](https://cursor.com/cli) | + +`ori` is the odd one out: it runs the other harnesses against OpenRouter's whole model catalog, so `ori claude`, `ori codex`, or `ori opencode` start those agents on whichever model you point them at, and `ori code` is Ori's own agent. To wrap an additional CLI the same way, run `omarchy-mise-install [command-name]`. The stubs are kept current along with everything else mise manages when you run `omarchy update` (or the `mup` alias). @@ -20,9 +26,13 @@ To wrap an additional CLI the same way, run `omarchy-mise-install [com Pick your default agent with `omarchy default agent ` or under _Setup > Defaults > Agent_ in the Omarchy Menu (`Super + Space`). If the agent isn't installed yet, picking it installs it first. A fresh Omarchy will invite you to make this choice with a one-time notification. +Choosing Claude also attempts to set up its browser extension for Chromium, Chrome, Brave, Brave Origin, and Edge. The extension setup applies to all users and may ask for your system password; cancelling or a failed extension install still selects and launches Claude. Restart your browser, enable the extension if prompted, and sign in to Claude to finish connecting it. Run `/chrome` in Claude to check the connection. Firefox and Zen do not support this extension. If Claude was already your default before this setup was added, select it again to install the extension. + +[Muse Code](https://dev.meta.ai) — Meta's `muse` — uses a preinstalled mise stub like the other agents. Picking it as the default installs Meta's official launcher through mise's HTTP backend. The launcher verifies and updates the native binary for your machine. + Once you've chosen, `Super + Shift + Ctrl + A` launches the default agent in a dedicated terminal window (or brings up the picker if you haven't chosen yet). You can also launch it straight into a task with `omarchy agent prompt "Review this project"`. Agents launched this way run unattended in their respective don't-stop-to-ask modes, so be ready for them to actually do things! And since agents refuse to remember trust for your home directory, launches from `$HOME` start in `~/Work` instead. -There are terminal shortcuts too: `a` runs the default agent inline in the current terminal, while `c`, `cx`, and `cy` start OpenCode, Claude Code, and Codex directly (again in their auto-approving modes). Theme changes sync to the agents as well: Claude Code, Pi, and OpenCode all follow along when you switch the Omarchy theme. +There are terminal shortcuts too: `a` runs the default agent inline in the current terminal, while `c`, `cx`, and `cy` start OpenCode, Claude Code, and Codex directly (again in their auto-approving modes). Theme changes sync to the agents as well: Claude Code, Pi, OpenCode, and Hermes (once Hermes Desktop is installed) all follow along when you switch the Omarchy theme. ### The agents panel @@ -36,9 +46,15 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end. `omarchy crash mute hyprland` stops the notifications for that program only, `omarchy crash mute hyprland off` brings them back, and `omarchy crash mute` on its own lists what you've muted. It takes the binary's path as happily as its name, so `omarchy crash mute /usr/bin/hyprland` does the same thing. Quote a name with a space in it, as in `omarchy crash mute 'Some App'`. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. + ### Desktop apps -The _Install > AI_ menu also carries a couple of graphical AI apps: the ChatGPT desktop app, and Grok Bot for chatting with xAI's models. +The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, the Claude desktop app (Anthropic's Linux beta, with Chat, Cowork, and Claude Code tabs), Grok Bot for chatting with xAI's models, Hermes Desktop, OpenClaw, and the Perplexity desktop app. + +Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Installing it also hands Hermes the Omarchy theme as a skin named `omarchy`, which every Hermes surface follows as you switch themes; pick another under Hermes' Appearance settings or with `/skin` if you'd rather it didn't, and Omarchy leaves that choice alone. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too. + +OpenClaw's desktop experience is its Control UI, which opens as a web app backed by its own local gateway. OpenClaw updates arrive through Omarchy's package updates, so skip the Control UI's own "Update Gateway" button: it would try to write into the package-managed install and fail. Removing OpenClaw under _Remove > AI_ takes the gateway service and the app with it and then asks whether `~/.openclaw` should go too, since that holds your chats and credentials alongside the plugin runtimes OpenClaw downloads for itself; the default keeps it. ### Local LLMs @@ -46,6 +62,6 @@ Omarchy recommends two ways of running local LLM models: LM Studio and Ollama. L ### The Omarchy Skill -Agent skills help AI use specific tools in a specific way, and Omarchy ships with a default skill for tailoring the system. Like tweaking your Hyprland config, adjusting the bar, or even creating a new theme from scratch. It's symlinked into the skill directories for Claude Code (`~/.claude/skills`), Codex (`~/.codex/skills`), Pi (`~/.pi/agent/skills`), Antigravity (`~/.gemini/config/skills`), and the generic `~/.agents/skills` location, so most harnesses pick it up automatically. +Agent skills help AI use specific tools in a specific way, and Omarchy ships with a default skill for tailoring the system. Like tweaking your Hyprland config, adjusting the bar, or even creating a new theme from scratch. It's symlinked into the skill directories for Claude Code (`~/.claude/skills`), Codex (`~/.codex/skills`), Pi (`~/.pi/agent/skills`), Antigravity (`~/.gemini/config/skills`), Hermes (`~/.hermes/skills` and each `~/.hermes/profiles/*/skills`), and the generic `~/.agents/skills` location, so most harnesses pick it up automatically. But you should treat this skill as experimental. Different models will use it to different effect. It's best to run in plan mode first, so you have an idea of what the agent would like to change. And then be ready to rollback changes or even invoking `omarchy reinstall configs`, if the agent makes a mess of everything. diff --git a/manual/18-development-tools.md b/manual/18-development-tools.md index a8bb3282..f49125d9 100644 --- a/manual/18-development-tools.md +++ b/manual/18-development-tools.md @@ -4,6 +4,8 @@ Omarchy ships with [Neovim](https://neovim.io/) by default, but if you'd like something a bit more mainstream and familiar, you can run the Omarchy Menu (`Super + Space`) and see the options under _Install > Editor_. We have VSCode, Cursor, Zed, Sublime Text, Helix, Vim, and Emacs listed there. If you don't find what you're looking for, checkout _Install > Package_, and see if it isn't in an Arch package (and if not, try _Install > AUR_ to check the AUR). +The original `vi` editor is also available out of the box. Run `vi filename` to edit a file in the terminal. + Theme matching is offered for `VSCode`, `Cursor`, `VSCodium`, and `Helix`. You can set the system-wide default editor under `Setup > Defaults > Editor`. @@ -18,9 +20,11 @@ To install, say, Ruby, you'd run `mise use -g ruby`, which will both install Rub ## Docker -[Docker](https://www.docker.com/) hardly needs any introduction. It allows you to run isolated containers, and Omarchy installs everything needed to run it well. This includes Docker itself, [Docker Compose](https://docs.docker.com/compose/), and the user group changes needed for you to run Docker as the normal user and not as root. +[Docker](https://www.docker.com/) hardly needs any introduction. It allows you to run isolated containers, and Omarchy installs everything needed to run it well, including Docker itself and [Docker Compose](https://docs.docker.com/compose/). -Remember to checkout the Lazydocker command to manage your containers in a cool TUI using `Super + Shift + D`. +By default your user is *not* in the `docker` group. That group is effectively passwordless root — anything in it can `docker run -v /:/host` and take over the machine — so a single rogue script or dependency running as you would otherwise be one command away from root. So on the command line you run Docker with `sudo` (`sudo docker ps`, `sudo docker compose up`), and the graphical tools that talk to the daemon — the Docker TUI on `Super + Shift + D` and the Windows VM — ask for authorization when they need it. If you want the convenience of a groupless setup back and understand the tradeoff, enable it from **Setup > Security > Sudoless Docker** (or run `omarchy-setup-security-sudoless-docker`), which adds you to the `docker` group after a warning; then plain `docker` and the `d` alias work without `sudo` again. + +Remember to checkout the Lazydocker command to manage your containers in a cool TUI using `Super + Shift + D`; it asks for authorization the first time unless you have enabled sudoless Docker. You can setup the common databases for local development in Docker using _Install > Development > Docker DB_ in the Omarchy menu. diff --git a/manual/19-shell-tools.md b/manual/19-shell-tools.md index 69965fdd..8252d795 100644 --- a/manual/19-shell-tools.md +++ b/manual/19-shell-tools.md @@ -57,3 +57,5 @@ The full manual can be found via `man yt-dlp`. ## try [try](https://github.com/tobi/try) makes it easy to manage programming experiments with date-stamped directories. All experiments live in `~/Work/tries` and you can access them via `try`. + +Omarchy does not add a project's `bin/` directory to `PATH` automatically. Run trusted project-local tools with an explicit relative path, such as `bin/rails` or `./bin/dev`. diff --git a/manual/28-windows-vm.md b/manual/28-windows-vm.md index 128f0e16..0551b23f 100644 --- a/manual/28-windows-vm.md +++ b/manual/28-windows-vm.md @@ -4,7 +4,7 @@ Omarchy offers an easy way to run Windows through a Docker VM. You can install i Your machine needs KVM virtualization for this, which most do — but it's sometimes switched off in the BIOS, and the installer will tell you if that's the case. You'll also want the disk space: whatever you give Windows, plus about 10GB for the image itself. -The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`. +The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`. The browser prompts for the same username and password before opening the console. ![windows-vm](images/windows-vm.webp) @@ -26,9 +26,15 @@ omarchy windows vm launch # start and connect ## Sharing files -The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk lives in `~/.windows`. +The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk is available at `~/.windows`. -The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. +Those familiar home paths stay on their own filesystems. They can also be symlinks to directories you own, which is useful when the virtual disk lives on a larger drive. The installer measures free space on the filesystem that actually contains `~/.windows`, not necessarily the filesystem containing your home directory. + +Keep the disk and shared paths as separate, non-overlapping directories. Removal deliberately empties the disk directory but preserves the shared directory. Immediately before deletion, Omarchy performs a bounded containment check and refuses to remove anything if that check times out or cannot prove the two trees are separate. + +Before the VM starts, Omarchy opens and pins those two directories, then bind-mounts the exact directory inodes onto private per-user anchors below `/var/lib/omarchy/windows/mounts`. Docker only sees those root-protected anchors. This preserves custom disk locations while preventing another process running as you from swapping a checked path before the privileged container consumes it. Existing disk and shared directories are tightened to mode `0700` during migration so other local accounts cannot browse their contents. + +The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. The web console also requires the configured Windows username and password, preventing another local account from driving the VM through port 8006. ## Limits and licensing @@ -38,6 +44,6 @@ The version installed is Windows 11 Pro, unactivated. You'll need your own licen If this computer shipped with Windows, the OEM key is still in firmware even after installing Omarchy. Print it with `omarchy windows key`. That key is bound to this machine — it will activate Windows reinstalled on this hardware, but it usually will not activate the VM. -You can change the resource allocation later by editing `~/.config/windows/docker-compose.yml`, which is also where you'd mount a USB device. See all the options on [the Dockur Windows project](https://github.com/dockur/windows). +You can change the resource allocation later by re-running `omarchy-windows-vm install`, which rewrites the VM's configuration from your answers. The compose file itself now lives at `/var/lib/omarchy/windows/docker-compose.yml` and is owned by root — that is deliberate, so a process running as you cannot rewrite it and have the privileged bring-up mount your whole disk into the container. If you need to hand-edit it (for example to mount a USB device), edit it with `sudo` and see all the options on [the Dockur Windows project](https://github.com/dockur/windows). To get rid of the whole thing, use _Remove > Windows_ from the Omarchy menu. That deletes the VM's disk and all its data, so make sure anything you care about is out of `~/Windows` first. diff --git a/manual/30-updates.md b/manual/30-updates.md index 246138c3..f0e82ced 100644 --- a/manual/30-updates.md +++ b/manual/30-updates.md @@ -2,7 +2,7 @@ Omarchy and your packages are kept up to date via _Update > Omarchy_ in the Omarchy menu (`Super + Space`). -Omarchy itself is installed as regular pacman packages from the [Omarchy Package Repository](https://github.com/omacom-io/omarchy-pkgs), so an update installs [the latest Omarchy release](https://github.com/basecamp/omarchy/releases), runs any pending migrations to get your system in sync with the latest, and updates all system packages from the [Omarchy Arch Mirror](https://github.com/omacom-io/omarchy-mirror) and [AUR](https://aur.archlinux.org/) (if you have installed any AUR packages). +Omarchy itself is installed as regular pacman packages from the [Omarchy Package Repository](https://github.com/omacom-io/omarchy-pkgs), so an update installs [the latest Omarchy release](https://github.com/omacom/omarchy/releases), runs any pending migrations to get your system in sync with the latest, and updates all system packages from the [Omarchy Arch Mirror](https://github.com/omacom-io/omarchy-mirror) and [AUR](https://aur.archlinux.org/) (if you have installed any AUR packages). When new releases are made, a circle arrow icon will appear to the right of your clock. Click it and the update process will start. @@ -10,7 +10,7 @@ When new releases are made, a circle arrow icon will appear to the right of your ### Four channels -Omarchy is updated along four channels: stable, RC, edge, and dev. New installations start on the stable channel, which tracks the [official releases](https://github.com/basecamp/omarchy/releases/), as well as the [stable Omarchy Arch mirror](https://github.com/omacom-io/omarchy-mirror) that's running one month behind the latest, so we can catch any new incompatibilities that require config changes before they cause problems for people. +Omarchy is updated along four channels: stable, RC, edge, and dev. New installations start on the stable channel, which tracks the [official releases](https://github.com/omacom/omarchy/releases/), as well as the [stable Omarchy Arch mirror](https://github.com/omacom-io/omarchy-mirror) that's running one month behind the latest, so we can catch any new incompatibilities that require config changes before they cause problems for people. But if you'd like to help spot those potential issues, you can run on the edge channel. That'll keep your Omarchy packages tracking the latest development builds, and lets you update to the latest Arch packages as soon as they're available. You should only do this if you're experienced with Linux, and know how to recover a system that has problems. diff --git a/manual/31-dotfiles.md b/manual/31-dotfiles.md index 11e0d762..ec148b44 100644 --- a/manual/31-dotfiles.md +++ b/manual/31-dotfiles.md @@ -66,10 +66,11 @@ Look, this is your computer. You can do whatever you want with it, but I would a You can change just about everything that way, like the default keybindings. Just edit `~/.config/hypr/bindings.lua` to, say, replace [Obsidian](https://obsidian.md/) with [Joplin](https://joplinapp.org/) (install with `omarchy-pkg-add joplin-bin`): +```lua +o.rebind("SUPER + SHIFT + O", "Joplin", "joplin-desktop") ``` -hl.unbind("SUPER + SHIFT + O") -o.bind("SUPER + SHIFT + O", "Joplin", "joplin-desktop") -``` + +`o.rebind` removes the existing binding before adding its replacement. It takes the same arguments as `o.bind`, including launch helpers and binding options. Use `o.bind` to add a binding, or `hl.unbind` to remove one without replacing it. If you insist on hacking on the internal Omarchy files, switch to the dev channel via _Update > Channel > Dev_. That links Omarchy to a git checkout of the source code in `~/omarchy`, which you're free to change to your heart's content. Ain't nobody here to tell you what to do! diff --git a/manual/32-shell-plugins.md b/manual/32-shell-plugins.md index d54df3fb..fc83a16f 100644 --- a/manual/32-shell-plugins.md +++ b/manual/32-shell-plugins.md @@ -4,7 +4,7 @@ The Omarchy desktop runs as a single long-lived Quickshell process called `omarc That's not just an implementation detail. It means you can turn pieces of the desktop off, swap them out, or write your own without touching a line of Omarchy's source. -The first-party plugins ship with Omarchy and live in `$OMARCHY_PATH/shell/plugins/`. Anything you add yourself — your own experiments, or something you found on GitHub — lives in `~/.config/omarchy/plugins/`. Both are discovered the same way at startup; the only difference is where they sit on disk. +The first-party plugins ship with Omarchy and live in `$OMARCHY_PATH/shell/plugins/`. Anything you add yourself — your own experiments, or something you found on GitHub — lives in `~/.config/omarchy/plugins/`. Both are discovered the same way at startup, but built-ins receive trusted shell interfaces while third-party plugins receive a limited interface scoped to their own service and lifecycle. Clones of built-ins keep only the source-specific configuration and UI calls needed for the original behavior. ## Seeing what you have @@ -37,7 +37,9 @@ A third-party plugin is just a git repo with a `manifest.json` at its root. omarchy plugin add https://github.com/acme/omarchy-weather.git --enable ``` -Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. A plugin isn't a config file — it's code that runs for as long as your session does, with everything your user account can reach. Only add repos you're willing to run, and read them before you enable them. +Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. The third-party plugin interface does not directly expose authentication services, and a replacement bar receives only limited capabilities for configured non-authentication UI. Visual plugins still share the shell's QML scene and can walk ordinary parent objects, while all plugin code runs with everything your user account can reach. Authentication state is protected separately by keeping those services outside the reachable host object graph. Only add repos you're willing to run, and read them before you enable them. + +A replacement bar can render installed widgets, but service-backed third-party widgets may have reduced functionality there because the bar is not allowed to request another plugin's live service object. Switch back to the built-in `omarchy.bar` if such a widget needs its companion service. Then it clones the repo into a staging directory, validates the manifest, refuses the install if another plugin already claims that id, and moves it into `~/.config/omarchy/plugins//`. Without `--enable` it asks whether you want it on now, and you can say no and go read the code first. It never runs anything from the plugin, never executes an install hook, and never asks for sudo — it clones files, checks the manifest, and flips a bit over IPC. @@ -93,7 +95,7 @@ omarchy plugin validate ./my-plugin That runs the same checks the shell does at load time: the schema version, the required fields, an id that isn't reserved, entry points that are safe relative paths and actually exist, an entry point for every kind you claimed, and no symlinks anywhere inside the folder. -For the full picture, the source is the documentation: `shell/README.md` in the Omarchy repo covers the manifest schema, the shell's IPC contract, and the exact shape of `shell.json`, and `shell/plugins/README.md` lists every first-party plugin with its id, kinds, and entry points. +For plugin development, see the [shell reference](https://github.com/omacom/omarchy/blob/quattro/docs/omarchy-shell.md) and [first-party plugin catalog](https://github.com/omacom/omarchy/blob/quattro/shell/plugins/README.md). ## Sharing yours with the world diff --git a/manual/35-networking.md b/manual/35-networking.md index 1c4b063c..51437723 100644 --- a/manual/35-networking.md +++ b/manual/35-networking.md @@ -36,7 +36,7 @@ SSH is off until you turn it on with _Setup > Security > SSHD_, which starts the [Tailscale](https://tailscale.com/) is a mesh VPN that makes reaching all your computers and servers over the internet simple and secure. Install it with _Install > Service > Tailscale_. -That gives you a Tailscale panel in the bar, which connects and disconnects the tailnet, switches accounts, and picks an exit node — your own machines and Mullvad regions both show up in the list. It also browses your machines, and that's where Taildrop lives: select a machine and press `s` to send it files, or `c`, `n`, and `d` to copy its IP, name, or full DNS name. The terminal equivalent is `omarchy tailscale send [file...]`, and files sent to you land in `~/Downloads` automatically. +That gives you a Tailscale panel in the bar, which connects and disconnects the tailnet, switches accounts, and picks an exit node — your own machines and Mullvad regions both show up in the list. It also browses your machines, and that's where Taildrop lives: select a machine and press `s` to send it files, or `c`, `n`, and `d` to copy its IP, name, or full DNS name. The terminal equivalent is `omarchy tailscale send [file...]`, and files sent to you land in `~/Downloads` automatically. The notification announcing an arrival waits until you click it open or dismiss it, so a file that turns up while you're away from the machine is still there to answer when you get back. Installing it also adds a web app for the Tailscale admin console. diff --git a/manual/39-backgrounds.md b/manual/39-backgrounds.md index 6edec391..3bc1f77b 100644 --- a/manual/39-backgrounds.md +++ b/manual/39-backgrounds.md @@ -4,4 +4,8 @@ Every theme ships with its own set of backgrounds, and you can add extras of you You can do this most easily by going to _Install > Style > Background_ in the Omarchy Menu. That'll bring up the folder where the backgrounds for that theme is stored. Hit `Super + Shift + F` to start another file manager, find your background, copy it over. Now it'll be included in the choices of backgrounds you can select between using `Super + Ctrl + Space`. +Backgrounds can be videos as well as stills. Drop an `mp4`, `m4v`, `mov`, `webm`, `mkv`, or `avi` file in the same folder and it appears alongside the images. Videos are played by the OWE wallpaper engine. It decodes the video once for all monitors and plays its sound through the default audio output, and it stops playback whenever nothing can see it. The lock screen draws the same decode, muted, through OWE. A video wallpaper still costs far more power than a still one. + You can find a huge collection of cool curated backgrounds on https://github.com/dharmx/walls. + +Video backgrounds keep a cached still on the lock screen while playback is paused or unavailable. Animated GIFs play on the desktop and show a still frame on the lock screen. diff --git a/manual/41-branding.md b/manual/41-branding.md index 6121d3da..cdbe828d 100644 --- a/manual/41-branding.md +++ b/manual/41-branding.md @@ -30,6 +30,8 @@ There are three entries in that menu: The same three options are under _Style > About_ for the _About_ screen you get from the Omarchy menu, and they work identically — the file is `~/.config/omarchy/branding/about.txt`, and the About window pops up after each change. The About art is converted to a smaller size than the screensaver's, since it has to fit in a window rather than fill your display. +While the window is open a glint of green leans across the art every few seconds and then leaves it still again. Your own art gets it too, as long as every character in it is one column wide — anything _Set From Image_ produces is. Art built from emoji or double-width characters stays still instead, and so does the screen if you keep a fastfetch config of your own: a still logo in those cases is the animation keeping out of the way rather than failing, since sliding a glint across them would land the rest of the line in the wrong place. + ![branding-about](images/branding-about.webp) ### Converting images yourself @@ -41,3 +43,13 @@ omarchy transcode ascii ~/logo.svg ~/.config/omarchy/branding/screensaver.txt -- ``` It takes `--width` and `--height` in terminal columns and rows, a `--mode` of either `braille` (the default, and much finer) or `block`, a `--threshold` percentage for deciding which pixels count as part of the logo, and `--invert` for when your logo is light on a dark background. If a conversion comes out as a blob, the threshold is usually the knob to turn. + +### Words instead of a logo + +`omarchy ascii` draws text in Delta Corps Priest 1, the FIGlet font the Omarchy wordmark itself is drawn in, so a screensaver can say something rather than show a picture: + +``` +omarchy ascii "Back in five" > ~/.config/omarchy/branding/screensaver.txt +``` + +It takes the text as arguments, or reads it from a pipe when given none. The font carries letters and spaces only — it was drawn without digits or punctuation — so anything else is dropped and named on stderr rather than quietly swallowed. diff --git a/manual/43-making-your-own-theme.md b/manual/43-making-your-own-theme.md index 2563f7c3..c6818a4b 100644 --- a/manual/43-making-your-own-theme.md +++ b/manual/43-making-your-own-theme.md @@ -6,6 +6,16 @@ The main file you have to tweak is `colors.toml`. That defines the color set tha You can also use the included Aether application to create a new theme using a lovely GUI interface to play with colors and search for backgrounds. Just start it via the apps menu on `Super + Alt + Space`. +### What an installed theme can contain + +A theme you write yourself in `~/.config/omarchy/themes` can contain whatever you like — it's your machine and your file, and Omarchy applies all of it. + +A theme you install from someone else's repo with `omarchy theme install` keeps everything that's colour, and loses the handful of files that would run code on your machine: any `.lua` file, the terminal configs (`alacritty.toml`, `foot.ini`, `ghostty.conf`, `kitty.conf`), and `vscode.json`. A theme's `hyprland.lua` is Lua your compositor runs at login, a terminal config names the program your terminal starts, and `vscode.json` names a VSCode extension to install. Installing someone's theme should change what your desktop looks like, never what it runs. + +Everything else still works exactly as the theme author wrote it — `btop.theme`, `chromium.theme`, `helix.toml`, `icons.theme`, `shell.toml`, the backgrounds and the previews are all kept. Only what was dropped gets regenerated from `colors.toml` on your machine. + +Omarchy tells the two apart by whether the theme has its own git repo inside it, which is what `omarchy theme install` leaves behind when it clones. So a theme you wrote stays yours, and one you pulled off the internet stays colours. + ### Light mode If you're making a light mode theme, set `mode = "light"` at the top of your `colors.toml`. Then it'll automatically be paired with light mode for all the apps. (The old way of dropping an empty file called `light.mode` in the root of your theme still works too.) @@ -28,4 +38,8 @@ There's a fully commented `alacritty.toml.tpl.sample` in that folder to copy fro If you want to distribute your theme so others can use it, you need to put it on a public git server, like GitHub. Then people can install it using _Install > Style > Theme_ in the Omarchy menu using that URL. It's recommended that you follow the naming convention of `omarchy-[themename]-theme`, as the theme will show correctly as just `[themename]` in the theme selection menu after installation. +That leftover `[themename]` becomes the theme's directory name, so it has to be one Omarchy can hand around safely: it must start with a letter, a digit, or an underscore, and the rest may hold letters, digits, `.`, `_`, `+`, and `-`. Capitals are lowercased for you, but anything else — a space, a quote, a non-English character — is refused at install time rather than turned into a directory name. So `omarchy-tokyo-night-theme`, `omarchy-flexoki_light-theme`, and `omarchy-c++-theme` all install fine. + +Remember that once it's installed from a repo, any `.lua`, terminal config or `vscode.json` it ships is dropped, so don't build the theme around those. + You can have your theme added to [the extra themes page](https://omarchy.org/themes/) by sending a pull request to [the omarchy-site repo](https://github.com/omacom-io/omarchy-site). diff --git a/manual/46-faq.md b/manual/46-faq.md index 65e1f2e7..5def15d6 100644 --- a/manual/46-faq.md +++ b/manual/46-faq.md @@ -44,19 +44,25 @@ The plain open source Chromium build doesn't ship with the OAuth credentials tha ### How do I add a printer? -Printing is set up and running out of the box, so a printer on your network is usually already discovered. Launch _Print Settings_ from the app launcher (`Super + Space`) to see what's there, add one by hand, or set the default. Printing to a PDF file works without any printer at all. +Printing is set up and running out of the box, and you add each printer yourself from _Print Settings_ in the app launcher (`Super + Space`). + +Choose _Add_, and give it a moment to look: a printer plugged in over USB, and most network printers, are found for you. If yours isn't in the list, pick _Network Printer > Internet Printing Protocol (ipp)_ and enter its address — the printer's own display or its web page will tell you what that is, usually something like `192.168.1.50` with a queue of `ipp/print`. _Forward_ then offers a driver, where a modern printer works best on the driverless _IPP Everywhere_ profile and an older one wants the model's own driver. + +Right-click a printer and choose _Set as Default_ to pick which one your apps reach for first, and _Properties_ to set paper size, duplex and quality. + +Automatic discovery, where printers on the network appear without being added, is temporarily switched off while it's reworked, which is why the first step above is yours rather than automatic. Printing to a PDF file works without any printer at all. ### How do I change where screenshots or screenrecordings are saved? -If you want screenshots to be saved to `~/Pictures/Screenshots` instead of just `~/Pictures`, you can add this to a file under `~/.config/uwsm/env.d/` (like `~/.config/uwsm/env.d/capture`): +Saved screenshots go to `~/Pictures/Screenshots` by default. To use another directory, add this to a file under `~/.config/uwsm/env.d/` (like `~/.config/uwsm/env.d/capture`): ``` -export OMARCHY_SCREENSHOT_DIR="$HOME/Pictures/Screenshots" +export OMASNAP_SCREENSHOT_DIR="$HOME/Pictures/Captures" ``` You can do the same for screenrecordings using `OMARCHY_SCREENRECORD_DIR`. -Just remember to create the directoy you want to save to and restart Omarchy for this to take effect. +Omasnap creates its screenshot directory automatically. Create the screenrecording directory yourself, then restart Omarchy for either environment change to take effect. ### How do I get the speakers + webcam working on my Apple Studio Display? diff --git a/manual/48-security.md b/manual/48-security.md index 94f762ce..45750398 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -20,7 +20,7 @@ It works by restoring the baseline snapshot the installer takes, so it's only av ## Passwordless sudo -Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes with `omarchy-sudo-passwordless 30` if 15 isn't enough. +Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes with `omarchy-sudo-passwordless 30` if 15 isn't enough. A restart removes the passwordless sudo rule as well. Be clear-eyed about this one: while it's on, anything running as your user can do anything as root without being asked. That's the whole point, and it's also the whole risk. diff --git a/manual/49-omarchy-on.md b/manual/49-omarchy-on.md index d5c64f4b..af066999 100644 --- a/manual/49-omarchy-on.md +++ b/manual/49-omarchy-on.md @@ -2,19 +2,19 @@ ### Apple M1/M2 chips -[Asahi Alarm](https://asahi-alarm.org/) is a version of Arch for Apple M1/M2 computers built on top of [Asahi Linux](https://asahilinux.org/). You can get Omarchy running on top of that with some effort. See [the user-driven guide](https://codeberg.org/malik-na/omarchy-mac). +[Asahi Alarm](https://asahi-alarm.org/) is a version of Arch for Apple M1/M2 computers built on top of [Asahi Linux](https://asahilinux.org/). You can get Omarchy running on top of that with some effort. See [the user-driven guide](https://github.com/omarchy-mac/omarchy-mac). ### Apple Virtual Machine -You can also install Omarchy inside a Parallels VM. Quite the cumbersome process, but there's [a user-driven guide](https://github.com/basecamp/omarchy/discussions/452) for that too. +You can also install Omarchy inside a Parallels VM. Quite the cumbersome process, but there's [a user-driven guide](https://github.com/omacom/omarchy/discussions/452) for that too. ### VirtualBox -VirtualBox is a popular VM runner. [You can run Omarchy inside that too](https://github.com/basecamp/omarchy/discussions/176). But performance probably won't be great. +VirtualBox is a popular VM runner. [You can run Omarchy inside that too](https://github.com/omacom/omarchy/discussions/176). But performance probably won't be great. ### VMware Workstation on Windows 11 -Another popular VM runner for Windows. [Omarchy has been setup inside of that as well](https://github.com/basecamp/omarchy/discussions/572). +Another popular VM runner for Windows. [Omarchy has been setup inside of that as well](https://github.com/omacom/omarchy/discussions/572). ### Steam Deck diff --git a/migrations/1784809451.sh b/migrations/1784809451.sh deleted file mode 100644 index bcd6c6de..00000000 --- a/migrations/1784809451.sh +++ /dev/null @@ -1,30 +0,0 @@ -echo "Configure locate to skip Btrfs snapshots and index Btrfs subvolumes" - -OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" -locate_config_script="$OMARCHY_PATH/install/config/locate.sh" -UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}" - -as_root() { - if (( EUID == 0 )); then - "$@" - else - sudo "$@" - fi -} - -[[ -f $UPDATEDB_CONF_PATH ]] || exit 0 -[[ -f $locate_config_script ]] || exit 0 - -if grep -q '^PRUNE_BIND_MOUNTS = "no"' "$UPDATEDB_CONF_PATH" && - grep -E '^PRUNEPATHS' "$UPDATEDB_CONF_PATH" | grep -E '(^|[[:space:]"])/\.snapshots([[:space:]"]|$)' >/dev/null; then - exit 0 -fi - -as_root env OMARCHY_UPDATEDB_CONF_PATH="$UPDATEDB_CONF_PATH" bash -euo pipefail "$locate_config_script" - -# Rebuild the index with the new exclusions; pruning /.snapshots turns -# multi-hour runs on snapshot-heavy systems back into one-minute runs. Restart -# rather than start: the machines this targets are the ones with an updatedb -# already grinding through every snapshot, and a run that started before the -# rewrite keeps using the config it read at startup. -as_root systemctl restart --no-block plocate-updatedb.service >/dev/null 2>&1 || true diff --git a/migrations/1785090473.sh b/migrations/1785090473.sh index 1e64b6ee..ca75cf2c 100644 --- a/migrations/1785090473.sh +++ b/migrations/1785090473.sh @@ -1,17 +1,8 @@ -echo "Switch fingerprint support back to stock libfprint" +echo "Repair fingerprint support left without a libfprint" -# libfprint-git existed to carry the focaltech_moc driver and the FocalTech -# FT9349 device ID (2808:a97a) before any release shipped them. libfprint -# 1.94.100 has both, so fingerprint setups go back to the stock Arch package. - -# The remove/install pair below isn't one transaction: if the install failed -# on a previous run, libfprint-git is already gone but fprintd is left with -# no libfprint — the elif finishes the job on rerun. -if pacman -Q libfprint-git &>/dev/null; then - # Deps-only removal keeps fprintd installed while its libfprint - # dependency is swapped out underneath it. - sudo pacman -Rdd --noconfirm libfprint-git - omarchy-pkg-add libfprint -elif pacman -Q fprintd &>/dev/null && ! pacman -Q libfprint &>/dev/null; then - omarchy-pkg-add libfprint +# An earlier version of this migration swapped libfprint-git for stock +# libfprint in two steps. A run that failed between them left fprintd with +# no library; finish with the driver the fingerprint setup installs now. +if omarchy-pkg-present fprintd && omarchy-pkg-missing libfprint && omarchy-pkg-missing libfprint-git; then + omarchy-pkg-add libfprint-git fi diff --git a/migrations/1786609204.sh b/migrations/1786609204.sh new file mode 100644 index 00000000..ce1442ca --- /dev/null +++ b/migrations/1786609204.sh @@ -0,0 +1 @@ +echo "Native video dependencies are superseded by the OWE migration" diff --git a/migrations/1787215483.sh b/migrations/1787215483.sh new file mode 100644 index 00000000..2e473ef0 --- /dev/null +++ b/migrations/1787215483.sh @@ -0,0 +1,3 @@ +echo "Stop mise upgrades from pruning versions still in use" + +mise settings set upgrade.auto_prune false diff --git a/migrations/1787342993.sh b/migrations/1787342993.sh new file mode 100644 index 00000000..0358b527 --- /dev/null +++ b/migrations/1787342993.sh @@ -0,0 +1,5 @@ +echo "Install ori (OpenRouter's agent harness) via mise wrapper" + +if [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then + omarchy-mise-install github:OpenRouterLabs/ori-releases ori +fi diff --git a/migrations/1787399318.sh b/migrations/1787399318.sh new file mode 100644 index 00000000..fc7f120c --- /dev/null +++ b/migrations/1787399318.sh @@ -0,0 +1,9 @@ +echo "Switch back to the packaged quickshell now that 0.3.1 kills synchronously" + +# 0.3.1 fixes `kill` returning before the instance has exited, which is the only +# reason Omarchy shipped the git build. +if omarchy-pkg-present quickshell-git; then + # One transaction with --ask 4 so pacman accepts replacing the conflicting + # quickshell-git in place; packages depending on quickshell stay satisfied. + sudo pacman -S --noconfirm --ask 4 quickshell +fi diff --git a/migrations/1787481315.sh b/migrations/1787481315.sh new file mode 100644 index 00000000..3aa19dea --- /dev/null +++ b/migrations/1787481315.sh @@ -0,0 +1,25 @@ +echo "Re-stage the current theme so an installed theme's code is dropped" + +# A theme installed from a repo could ship hyprland.lua, gum_env.lua, neovim.lua +# and terminal configs, and they were copied straight into the staged theme that +# Hyprland requires at login and the terminals include at launch. Staging drops +# them now, but an install that already applied such a theme keeps the staged +# copies until something changes the theme, which may be never. Re-stage once so +# the fix reaches the themes already in place rather than only the next one. +theme_name_path="$HOME/.local/state/omarchy/current/theme.name" + +[[ -s $theme_name_path ]] || exit 0 + +theme_name=$(<"$theme_name_path") + +# A theme removed while it was still current leaves theme.name naming it and the +# staged copy behind, so there is nothing left to re-stage from and those staged +# files are exactly the ones this is here to drop. Seed the default instead, +# which is where a fresh install starts and what the removal should have left. +if [[ ! -d $OMARCHY_PATH/themes/$theme_name && ! -d $HOME/.config/omarchy/themes/$theme_name ]]; then + echo "Theme '$theme_name' no longer exists; applying the default instead" + omarchy-theme-set "Tokyo Night" + exit 0 +fi + +omarchy-theme-refresh diff --git a/migrations/1787494718.sh b/migrations/1787494718.sh new file mode 100644 index 00000000..0f11ada6 --- /dev/null +++ b/migrations/1787494718.sh @@ -0,0 +1,116 @@ +echo "Take ownership of the FIDO2 authfile so it cannot be rewritten without root" + +authfile="/etc/fido2/fido2" + +# omarchy-migrate records this migration as complete whenever it exits zero, so +# a line printed here scrolls past once in the update terminal and is never +# shown again. The states below cannot be repaired without deciding what to do +# with a file we do not own, and they are exactly the ones where the authfile +# may already be under someone else's control, so say so where it outlives the +# scrollback as well. +report_unrepairable() { + echo " $1" + echo " $2" + omarchy-notification-send -u critical -g  "FIDO2 authfile needs attention" "$1 $2" || true +} + +# Nothing to repair on any machine that never set FIDO2 up, which is almost all +# of them. Checked before any sudo so those machines never see a password +# prompt. -L as well as -e: a dangling symlink is invisible to -e. +if [[ ! -L $authfile && ! -e $authfile ]]; then + # Absence and "cannot look" are the same answer to the tests above. The old + # setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the + # caller's umask and sudoers' 0022, so anyone registering under `umask 077` + # left it mode 0700 with the user-owned authfile still inside. Escalate for + # that case alone -- a machine that never set FIDO2 up has no directory here + # and still reaches exit 0 without a password prompt. Not through a symlink: + # chmod would act on whatever it points at. + authdir=${authfile%/*} + + if [[ -L $authdir || ! -d $authdir || -x $authdir ]]; then + exit 0 + fi + + # Ask root whether a registration is behind it before touching the directory + # itself. An aborted setup that left an empty 0700 directory, or one an + # administrator deliberately keeps private, must not have its mode widened + # and its group and special bits discarded for a repair it does not need. + if ! sudo test -e "$authfile" && ! sudo test -L "$authfile"; then + exit 0 + fi + + sudo chmod 755 "$authdir" +fi + +# The old privileged move could install a symlink here if its fixed staging path +# was redirected. Reported, not repaired: chown follows symlinks and would take +# ownership of the target instead, and removing it would strip sudo and polkit +# from anyone whose only credential is the token. +if [[ -L $authfile ]]; then + report_unrepairable "$authfile is a symlink, not a regular file." \ + "Leaving it alone. If you did not create it, remove it and re-run Setup > Security > Fido2." + exit 0 +fi + +# A directory or a device here is no more ours to rewrite than a symlink is, +# and changing a directory's mode would alter an object we do not own. +if [[ ! -f $authfile ]]; then + report_unrepairable "$authfile is not a regular file." \ + "Leaving it alone. Remove it and re-run Setup > Security > Fido2." + exit 0 +fi + +# Migration state is per-user, so every account re-runs this. The file's own +# ownership is the state check: the second account finds the repair already +# done and exits without escalating. +owner=$(stat -c %U "$authfile" 2>/dev/null) || owner="" +group=$(stat -c %G "$authfile" 2>/dev/null) || group="" +mode=$(stat -c %a "$authfile" 2>/dev/null) || mode="" +if [[ $owner == "root" && $group == "root" && $mode == "644" ]]; then + exit 0 +fi + +# Setup used to `mv` this in from /tmp, which carried the invoking user's +# ownership into /etc. Root ownership stops that user from rewriting their own +# PAM credential without root. Mode 644 keeps the public credential mapping +# readable when pam_u2f opens an absolute authfile as the authenticating user. +# +# Rename a fresh copy over the path rather than chowning in place. A descriptor +# opened while the file was still the user's own stays writable on that inode +# through any later chmod or chown, since permission is checked at open(2), and +# pam_u2f resolving the path would keep landing on it. Replacing the inode +# leaves that descriptor writing to a file nothing reads. +stage="" + +safe_stage_path() { + local candidate=$1 + local prefix="$authfile.new." + local suffix + + [[ $candidate == "$prefix"* ]] || return 1 + suffix=${candidate#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +cleanup_stage() { + local status=$? + + if safe_stage_path "$stage"; then + sudo rm -f -- "$stage" || true + fi + + return "$status" +} + +trap cleanup_stage EXIT +stage=$(sudo mktemp "$authfile.new.XXXXXX") + +if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then + echo " Could not create a safe staging file beside $authfile." + exit 1 +fi + +sudo install -T -m 644 -o root -g root "$authfile" "$stage" +sudo mv -Tf "$stage" "$authfile" +stage="" +trap - EXIT diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh new file mode 100644 index 00000000..9adae003 --- /dev/null +++ b/migrations/1787515927.sh @@ -0,0 +1,30 @@ +echo "Stop world-writable Chromium and Firefox policy directories" + +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + +repaired=0 +for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ -d $dir || -L $dir ]] || continue + browser_policy_setup_dir "$dir" + repaired=1 +done + +# Repainting the policy color is cosmetic and the next theme change redoes it. +# Under bash -euo pipefail a failure here would abort the migration before the +# Firefox directories below are hardened, and the marker would never be written. +if (( repaired )); then + omarchy-theme-set-browser || true +fi + +for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do + [[ -d $dir || -L $dir ]] || continue + if browser_policy_firefox_hardened "$dir"; then + browser_policy_purge_dir "$dir" + continue + fi + browser_policy_setup_parent "$dir" + browser_policy_purge_dir "$dir" + if ! browser_policy_firefox_policy_file_ok "$dir/policies.json"; then + browser_policy_install_firefox_policies "$dir" + fi +done diff --git a/migrations/1787573629.sh b/migrations/1787573629.sh new file mode 100644 index 00000000..a01cc9c6 --- /dev/null +++ b/migrations/1787573629.sh @@ -0,0 +1,69 @@ +echo "Regenerate mise wrappers that still print mise's own output to stdout" + +# omarchy-mise-install gained `--quiet` on its `mise use -g` line so a wrapper +# stops printing mise's "tools: pkg@version" ahead of the tool's own output. +# That only changes wrappers written from then on, and the migration that +# installed the current ones is already marked complete, so every wrapper on +# disk keeps polluting stdout -- `claude --version` still answers with two +# lines, and a wrapper for a protocol-speaking command answers with a line its +# caller cannot parse. Rewrite them through omarchy-mise-install so the +# template stays in one place. + +# Every generated form that predates --quiet, rebuilt from the package and bin +# the file itself names. Wrappers were written by all of these over time and +# only the ones a later migration happened to regenerate moved forward, so a +# machine can still be carrying any of them. +stale_template() { + local form=$1 package=$2 bin=$3 + + case $form in + cooldown-export) + printf '#!/bin/bash\nexport MISE_MINIMUM_RELEASE_AGE=0\nmise use -g "%s" || exit 1\nexec mise x "%s" -- "%s" "$@"' "$package" "$package" "$bin" ;; + bail-on-failure) + printf '#!/bin/bash\nmise use -g "%s" || exit 1\nexec mise x "%s" -- "%s" "$@"' "$package" "$package" "$bin" ;; + mise-exec) + printf '#!/bin/bash\nmise use -g "%s"\nexec mise exec "%s" -- "%s" "$@"' "$package" "$package" "$bin" ;; + bare-exec) + printf '#!/bin/bash\nmise use -g "%s"\nexec "%s" "$@"' "$package" "$bin" ;; + esac +} + +bin_dir="$HOME/.local/bin" + +[[ -d $bin_dir ]] || exit 0 + +for wrapper in "$bin_dir"/*; do + [[ -f $wrapper && ! -L $wrapper && -r $wrapper ]] || continue + + # A generated wrapper is four short lines. ~/.local/bin also holds real + # binaries -- uv and uvx land here from the Python dev env -- so check the + # size before reading rather than pulling a 30MB executable into memory to + # discover it is not a wrapper. + (($(stat -c%s "$wrapper") <= 1024)) || continue + + contents=$(<"$wrapper") + + package=$(sed -n 's/^mise use -g "\(.*\)"\( || exit 1\)*$/\1/p' <<<"$contents") + bin=$(sed -n \ + -e 's/^exec mise x ".*" -- "\(.*\)" "\$@"$/\1/p' \ + -e 's/^exec mise exec ".*" -- "\(.*\)" "\$@"$/\1/p' \ + -e 's/^exec "\(.*\)" "\$@"$/\1/p' <<<"$contents") + + [[ -n $package && -n $bin ]] || continue + + # The whole file has to be one of those forms exactly. A wrapper someone has + # added a line to is left as it is rather than silently regenerated without + # that line, and one already carrying --quiet matches nothing here, which is + # what makes re-running this a no-op. + stale=0 + for form in cooldown-export bail-on-failure mise-exec bare-exec; do + if [[ $contents == "$(stale_template "$form" "$package" "$bin")" ]]; then + stale=1 + break + fi + done + + ((stale)) || continue + + omarchy-mise-install "$package" "${wrapper##*/}" "$bin" +done diff --git a/migrations/1787580187.sh b/migrations/1787580187.sh new file mode 100644 index 00000000..cf07cc66 --- /dev/null +++ b/migrations/1787580187.sh @@ -0,0 +1,22 @@ +echo "Move this install to the opt-in docker group default (the group is root-equivalent)" + +# The docker group grants passwordless root (a container can bind-mount / and +# rewrite the host), so Omarchy no longer puts users in it by default. Bring +# existing installs in line: remove this user from the group if present. The +# change applies after a reboot, so it stays reachable until then. Anyone who +# wants passwordless docker back can opt in, behind a warning, with +# Setup > Security > Sudoless Docker. Reuses the removal command so there is one +# source of truth for the privileged change and its notice; DEFER_REBOOT keeps +# it from prompting mid-update — omarchy-update-restart handles the reboot once +# the whole update has finished. +if id -nG "$USER" | grep -qw docker; then + OMARCHY_DEFER_REBOOT=1 omarchy-remove-security-sudoless-docker +fi + +# The Docker app entry copied into ~/.local/share/applications used to run +# lazydocker directly; it now needs the wrapper that prompts for daemon access +# (or runs directly under sudoless Docker). Refresh just that file. +dest="$HOME/.local/share/applications/Docker.desktop" +if [[ -f $dest ]]; then + cp "$OMARCHY_PATH/applications/Docker.desktop" "$dest" +fi diff --git a/migrations/1787589206.sh b/migrations/1787589206.sh new file mode 100644 index 00000000..928905a9 --- /dev/null +++ b/migrations/1787589206.sh @@ -0,0 +1,20 @@ +echo "Require signed packages from the Omarchy repository" + +# The [omarchy] repo predates the Omarchy packaging key, so existing installs +# carry a SigLevel override that also accepts unsigned packages. Packages are +# signed now, so drop the override and let the repo inherit the global +# SigLevel = Required DatabaseOptional like every other repo. Machine-wide and +# self-detecting, so another user's rerun no-ops. +omarchy_sig_override='SigLevel = Optional TrustAll' + +if [[ -f /etc/pacman.conf ]] && + sed -n '/^\[omarchy\]/,/^\[/p' /etc/pacman.conf | grep -qxF "$omarchy_sig_override"; then + # Requiring signatures with an untrusted packaging key would fail every + # omarchy transaction, including the one that could repair it. + if omarchy-pkg-missing omarchy-keyring || + ! sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 &>/dev/null; then + omarchy-update-keyring + fi + + sudo sed -i "/^\[omarchy\]/,/^\[/{/^$omarchy_sig_override$/d}" /etc/pacman.conf +fi diff --git a/migrations/1787618700.sh b/migrations/1787618700.sh new file mode 100644 index 00000000..a5e8e6a0 --- /dev/null +++ b/migrations/1787618700.sh @@ -0,0 +1,39 @@ +echo "Store Hyprland input-device names as data instead of generated Lua" + +# omarchy-toggle-input-device used to interpolate hyprctl device names into +# hyprctl eval and a generated Lua file. Those names come from USB descriptors, +# so recover the plain device name as data and delete the generated Lua. A name +# that could have broken out of the old Lua string literal is discarded, not +# trusted. The old script wrote to ~/.local/state regardless of XDG_STATE_HOME. +toggles_dir="$HOME/.local/state/omarchy/toggles/hypr" + +reapply=0 + +for kind in touchpad touchscreen; do + state_file="$toggles_dir/$kind-disabled.lua" + name_file="$toggles_dir/$kind-disabled-name" + + [[ -f $state_file ]] || continue + + if [[ ! -f $name_file && -r $state_file ]]; then + old=$(<"$state_file") + pattern='^hl\.device\(\{ name = "([^"\\[:cntrl:]]+)", enabled = false \}\)$' + if [[ $old =~ $pattern ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" >"$name_file" + fi + fi + + rm -f "$state_file" + + if [[ -f $name_file ]]; then + reapply=1 + fi +done + +# The package hook reloads Hyprland before migrations run, so this session has +# already dropped the disable: the generated Lua is no longer loaded and the +# name file did not exist yet to replace it. Reload once more now that it does, +# or the device the user switched off stays on until their next login. +if (( reapply )); then + hyprctl reload >/dev/null 2>&1 || true +fi diff --git a/migrations/1787666837.sh b/migrations/1787666837.sh new file mode 100644 index 00000000..33c5d9dc --- /dev/null +++ b/migrations/1787666837.sh @@ -0,0 +1,6 @@ +echo "Enable Dell XPS 13 sidecar speaker amplifiers" + +if omarchy-hw-dell-xps13-sidecar-amps; then + source "$OMARCHY_PATH/install/hardware/dell-xps13-sidecar-amps.sh" + omarchy-state set reboot-required +fi diff --git a/migrations/1787691200.sh b/migrations/1787691200.sh new file mode 100644 index 00000000..71c163e5 --- /dev/null +++ b/migrations/1787691200.sh @@ -0,0 +1,18 @@ +echo "Skip Chromium's new first-run EULA on machines already on Quattro" + +# Chromium 151 flipped MasterPrefs::eula_required from false to true, so an +# unconfigured first run now stops on a blank terms-of-service dialog before the +# browser opens. Omarchy answers that in the seed it writes next to the Chromium +# binary, but that seed is only laid down by a fresh install and by the one-time +# 3.x upgrade, so machines already on Quattro never receive it. Retrofit it here. +# +# The literal is deliberately duplicated rather than sourced: a migration repairs +# the state of its own moment, and must not drift when the seed later changes. + +chromium_prefs="/usr/lib/chromium/initial_preferences" +chromium_seed='{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' + +if [[ $(cat "$chromium_prefs" 2>/dev/null) != "$chromium_seed" ]]; then + sudo mkdir -p "$(dirname "$chromium_prefs")" + echo "$chromium_seed" | sudo tee "$chromium_prefs" >/dev/null +fi diff --git a/migrations/1787760281.sh b/migrations/1787760281.sh new file mode 100644 index 00000000..952fa9a2 --- /dev/null +++ b/migrations/1787760281.sh @@ -0,0 +1,25 @@ +echo "Install the Hermes CLI wrapper for existing installs" + +# Users who removed the preinstalls opted out of the mise wrappers, and Hermes +# is one of them. +[[ -f $HOME/.local/state/omarchy/preinstalls-removed ]] && exit 0 + +# Hermes Desktop provides its own Hermes. The installer stands aside for it, +# removing the mise copy and the Omarchy wrapper an earlier install may have +# left beside the app. It also reports when the app has not finished setting +# Hermes up, which is the app's to finish, not this migration's to fail on. +if omarchy-pkg-present hermes-desktop; then + omarchy-install-hermes-cli || true + exit 0 +fi + +# Anything already answering to hermes that this installer did not write -- +# an official install, a hand-rolled wrapper, even a dangling link -- belongs to +# the user and stays exactly as it is. The installer is asked rather than +# matched against here, so there is one answer to who owns that wrapper. +wrapper="$HOME/.local/bin/hermes" +if [[ -e $wrapper || -L $wrapper ]] && ! omarchy-install-hermes-cli --owns; then + exit 0 +fi + +omarchy-install-hermes-cli diff --git a/migrations/1787815267.sh b/migrations/1787815267.sh new file mode 100644 index 00000000..b3f9282a --- /dev/null +++ b/migrations/1787815267.sh @@ -0,0 +1,57 @@ +echo "Separate printer discovery from root and print-filter access" + +machine_marker="${OMARCHY_CUPS_MIGRATION_MARKER:-/var/lib/omarchy/migrations/1787815267}" + +[[ ! -e $machine_marker ]] || exit 0 + +# Existing releases allowed a desktop user or shared group named cups-browsed, +# which systemd-sysusers would silently reuse for passwordless CUPS access. +if omarchy-pkg-present cups; then + cups_browsed_account=$(getent passwd cups-browsed || true) + cups_browsed_group=$(getent group cups-browsed || true) + + if [[ -n $cups_browsed_account || -n $cups_browsed_group ]]; then + IFS=: read -r _ _ cups_browsed_uid cups_browsed_gid cups_browsed_description cups_browsed_home cups_browsed_shell <<<"$cups_browsed_account" + IFS=: read -r _ _ cups_browsed_group_gid cups_browsed_group_members <<<"$cups_browsed_group" + other_primary_user=$(getent passwd | awk -F: -v gid="$cups_browsed_gid" '$1 != "cups-browsed" && $4 == gid { print $1; exit }') + + if [[ ! $cups_browsed_uid =~ ^[0-9]+$ || ! $cups_browsed_group_gid =~ ^[0-9]+$ ]] || + ((cups_browsed_uid <= 0 || cups_browsed_uid >= 1000)) || + [[ $cups_browsed_gid != $cups_browsed_group_gid ]] || + [[ $cups_browsed_description != "CUPS printer discovery" || $cups_browsed_home != "/" || $cups_browsed_shell != "/usr/bin/nologin" ]] || + [[ -n $cups_browsed_group_members || -n $other_primary_user ]]; then + echo "Cannot harden printer discovery: the existing cups-browsed user or group is not a dedicated system account." >&2 + false + fi + fi +fi + +# CUPS-PDF accepts a job-controlled post-processing command in a backend that +# CUPS launches as root. Native application print-to-file support replaces it. +omarchy-pkg-drop cups-pdf + +# system-config-printer uses this helper to request printer administration +# through Polkit now that the desktop user's wheel group is no longer @SYSTEM. +if omarchy-pkg-present cups; then + omarchy-pkg-add cups-pk-helper +fi + +# Stop the root-running daemon before changing the authorization it relies on. +if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl stop cups-browsed.service +fi + +if omarchy-pkg-present cups; then + sudo systemctl daemon-reload + sudo systemctl try-reload-or-restart cups.service +fi + +# Resume on whether the unit is enabled, not on whether it was running when this +# run started: an interrupted earlier run leaves it stopped, and a retry that +# recomputed that would skip the restart and still write the marker below. A +# masked or disabled unit reports not-enabled and is left alone. +if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl restart cups-browsed.service +fi + +sudo install -Dm644 /dev/null "$machine_marker" diff --git a/migrations/1787843905.sh b/migrations/1787843905.sh new file mode 100644 index 00000000..e0e58cd5 --- /dev/null +++ b/migrations/1787843905.sh @@ -0,0 +1,22 @@ +echo "Link Omarchy agent skills into Hermes skill directories" + +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +skills_source="$OMARCHY_PATH/default/agents/skills" + +[[ -d $skills_source ]] || exit 0 + +mkdir -p "$HOME/.hermes/skills" + +for skill in "$skills_source"/*/; do + [[ -d $skill ]] || continue + name=${skill%/} + name=${name##*/} + ln -sfn "$skills_source/$name" "$HOME/.hermes/skills/$name" + if [[ -d $HOME/.hermes/profiles ]]; then + for profile in "$HOME"/.hermes/profiles/*/; do + [[ -d $profile ]] || continue + mkdir -p "$profile/skills" + ln -sfn "$skills_source/$name" "$profile/skills/$name" + done + fi +done diff --git a/migrations/1787865477.sh b/migrations/1787865477.sh new file mode 100644 index 00000000..4727fb02 --- /dev/null +++ b/migrations/1787865477.sh @@ -0,0 +1,18 @@ +echo "Drop the default input group grant, which allowed unprivileged keylogging" + +# Membership of `input` gives raw read/write access to /dev/input/event*: any +# process running as the user can capture keystrokes and synthesize input. The +# blanket grant is unnecessary: the Xbox-controller and ydotool installers add +# the group themselves when those features are deliberately installed. +# +# Preserve membership where one of those opt-in features is present; removing +# it there would break the feature the user chose to install. +if id -nG "$USER" | grep -qw input; then + if pacman -Qq xpadneo-dkms &>/dev/null || pacman -Qq ydotool &>/dev/null; then + echo "Keeping $USER in the input group: controller or ydotool support is installed." + else + sudo gpasswd -d "$USER" input >/dev/null + echo "Removed $USER from the input group. Log out and back in to apply." + omarchy-state set reboot-required + fi +fi diff --git a/migrations/1788009111.sh b/migrations/1788009111.sh new file mode 100644 index 00000000..676fd5d8 --- /dev/null +++ b/migrations/1788009111.sh @@ -0,0 +1,72 @@ +echo "Temporarily remove automatic printer discovery" + +machine_marker="${OMARCHY_CUPS_BROWSED_REMOVAL_MARKER:-/var/lib/omarchy/migrations/1788009111}" + +[[ ! -e $machine_marker ]] || exit 0 +omarchy-pkg-present cups-browsed || exit 0 + +# Check the full removal transaction before changing the service or queues. +pacman -Rs --print cups-browsed >/dev/null + +# Disable the unit while its package still owns the unit file so systemd can +# remove the enable symlink cleanly. +if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl disable --now cups-browsed.service >/dev/null +elif systemctl is-active --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl stop cups-browsed.service >/dev/null +fi + +# cups-browsed leaves its implicitclass queues behind when stopped. Remove idle +# discovery queues before removing the backend they require, but leave queues +# with jobs for the user to resolve. +# +# A healthy CUPS server with no configured printers reports this condition on +# stderr and exits 1. Treat that as an empty queue list; every other failure +# keeps the migration pending so it can be retried. +if queue_report=$(LC_ALL=C lpstat -v 2>&1); then + : +elif [[ $queue_report == "lpstat: No destinations added." ]]; then + queue_report="" +else + printf '%s\n' "$queue_report" >&2 + exit 1 +fi + +generated_queues=$(printf '%s\n' "$queue_report" | + sed -n 's|^device for \(.*\): implicitclass://.*|\1|p') + +while IFS= read -r queue; do + [[ -n $queue ]] || continue + + if ! reject_error=$(sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue" 2>&1); then + if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + printf '%s\n' "$reject_error" >&2 + exit 1 + else + continue + fi + fi + + if job_report=$(LC_ALL=C lpstat -o "$queue" 2>&1); then + [[ -z $job_report ]] || continue + elif LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + printf '%s\n' "$job_report" >&2 + exit 1 + else + # The queue disappeared after the initial snapshot, which is already the + # desired state. + continue + fi + + if ! delete_error=$(sudo lpadmin -x "$queue" 2>&1); then + # Treat a concurrent disappearance as success. A queue that still exists + # means CUPS did not complete the deletion, so retry the migration later. + if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + printf '%s\n' "$delete_error" >&2 + exit 1 + fi + fi +done <<<"$generated_queues" + +omarchy-pkg-drop cups-browsed >/dev/null +sudo install -Dm644 /dev/null "$machine_marker" diff --git a/migrations/1788025225.sh b/migrations/1788025225.sh new file mode 100644 index 00000000..295b71ba --- /dev/null +++ b/migrations/1788025225.sh @@ -0,0 +1,339 @@ +echo "Remove privileged files left behind by retired Omarchy installers" + +sudoers_dir="${OMARCHY_SUDOERS_DIR:-/etc/sudoers.d}" +systemd_dir="${OMARCHY_SYSTEMD_SYSTEM_DIR:-/etc/systemd/system}" +machine_marker="${OMARCHY_RETIRED_INSTALLER_ARTIFACTS_MARKER:-/var/lib/omarchy/migrations/1788025225}" +reload_needed_marker="$machine_marker.daemon-reload" + +[[ ! -e $machine_marker ]] || exit 0 + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +# Three installers that no longer exist each left a root-owned file behind, and +# nothing in Omarchy has ever removed any of them. Each is judged against what +# the installer that wrote it actually produced, so a file of the same name that +# an administrator wrote themselves is left alone. +# +# Emit the lines a parser would act on: comments and blanks dropped, backslash +# continuations joined, and runs of whitespace collapsed so a reformatted copy +# still compares equal. Reads the body on stdin, because /etc/sudoers.d is 0750 +# root:root and the caller has to hand us an elevated read. +# +# Comments are tested before continuations are joined, which is the order every +# consumer here uses: udev's parse_file discards a '#' line without looking at a +# trailing backslash (`udevadm verify` on "# disabled \" plus a bogus key reports +# the error on line 2), sudo's toke.l comment rule consumes to the newline and +# clears its continuation flag, and systemd's config_parse tests the comment +# characters before appending to a continuation. Joining first would let a +# comment ending in a backslash swallow the live line beneath it. +# +# FORMAT is sudoers or systemd. systemd takes ';' as well as '#'. sudo does not +# treat every '#' as a comment: toke.l has INITIAL rules for ^#include and +# ^#includedir, and its comment pattern excludes '#' followed by a digit or +# -digit so those reach the ID token as a numeric uid user spec. Those lines are +# active directives, and a file carrying one must not read as though it held only +# generated lines. +active_lines() { + local format="$1" + local comments='#' + local line logical="" + + [[ $format == "systemd" ]] && comments='#;' + + while IFS= read -r line || [[ -n $line ]]; do + if [[ $line =~ ^[[:space:]]*[$comments] ]] && + ! { [[ $format == "sudoers" ]] && sudoers_hash_is_active "$line"; }; then + # The two consumers part company here. sudo ends the logical line at a + # comment and keeps what came before it, so `visudo -cf` reads a spec + # ending in a backslash, then a comment, then a second spec as two live + # specs; dropping the pending half would hide an administrator's grant and + # let this file read as though the installer had written all of it. systemd + # resumes the continuation instead: `systemd-analyze verify` on "ExecStop=\" + # + "; c" + a path resolves that path, so the pending half has to stay. + if [[ $format == "sudoers" ]]; then + emit_logical "$logical" + logical="" + fi + continue + fi + + if [[ $line == *\\ ]]; then + logical+="${line%\\} " + continue + fi + + emit_logical "$logical$line" + logical="" + done + + # A file whose last line ends in a backslash still carries a live directive for + # systemd: `systemd-analyze verify` resolves an ExecStop= written that way. + # udev ignores the dangling line and sudo rejects the file outright, so emitting + # it costs those two nothing. + emit_logical "$logical" +} + +# One logical line, whitespace collapsed so a reformatted copy still compares +# equal, and nothing at all for a line that held only whitespace. +emit_logical() { + local -a parts + + read -ra parts <<<"$1" + if (( ${#parts[@]} )); then + printf '%s\n' "${parts[*]}" + fi +} + +sudoers_hash_is_active() { + local line="$1" + + [[ $line =~ ^[[:space:]]*#include[[:blank:]] ]] && return 0 + [[ $line =~ ^[[:space:]]*#includedir[[:blank:]] ]] && return 0 + [[ $line =~ ^[[:space:]]*#-?[0-9] ]] && return 0 + + return 1 +} + +# install/preflight/first-run-mode.sh (2025-08-25 to 2026-05-25) granted the +# installing account passwordless sudo for the rest of the first boot, including +# an unrestricted /usr/bin/systemctl from 2025-10-14 on -- enough to link and +# start a unit of the user's own, which is root. bin/omarchy-first-run was meant +# to delete the grant, but it clears its first-run.mode guard as the very first +# statement and only reaches the removal after eight set -e steps, two of which +# touch the network. Any failure in between leaves the grant on the machine with +# nothing left to retry it. +# +# The installer rewrote this file nine times across two locations -- the last two +# bodies came from install/post-install/first-run-mode.sh, whose cleanup alias +# names /usr/bin/rm as well as /bin/rm -- and only the later ones carry both +# Cmnd_Alias lines, so keying on those would walk past the earlier ones. Instead +# require every active line to be one the installer itself emitted, plus at least +# one line that is unmistakably this grant: its own self-cleanup. One +# hand-written line anywhere in the file and it is not ours to delete. +first_run_sudoers_is_generated() { + local spec_pattern='^([^[:space:]]+) ALL=\(ALL\) NOPASSWD: (.+)$' + local marker_pattern='^/bin/rm -f /home/([^/]+)/\.local/state/omarchy/first-run\.mode$' + local line user command marker_user generated_user="" + local seen_any=0 seen_marker=0 seen_spec=0 + + while IFS= read -r line; do + seen_any=1 + + case "$line" in + "Cmnd_Alias SYMLINK_RESOLVED = /usr/bin/ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf") + continue + ;; + "Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run" | \ + "Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/99-omarchy-installer-reboot" | \ + "Cmnd_Alias FIRST_RUN_CLEANUP = /usr/bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/first-run") + seen_marker=1 + continue + ;; + esac + + # Everything else the installer wrote is a user spec naming the installing + # account, whose name cannot be assumed here: it may since have been renamed + # or removed, and a second account runs this migration too. + if [[ ! $line =~ $spec_pattern ]]; then + return 1 + fi + user=${BASH_REMATCH[1]} + command=${BASH_REMATCH[2]} + if [[ -n $generated_user && $user != "$generated_user" ]]; then + return 1 + fi + generated_user=$user + seen_spec=1 + + case "$command" in + "/usr/bin/systemctl" | "/usr/bin/ufw" | "/usr/bin/ufw-docker" | \ + "/usr/bin/gtk-update-icon-cache" | "/usr/bin/udevadm" | \ + "/usr/bin/tee /etc/udev/rules.d/*" | "SYMLINK_RESOLVED") + continue + ;; + "FIRST_RUN_CLEANUP" | "/bin/rm -f /etc/sudoers.d/first-run") + seen_marker=1 + continue + ;; + esac + + if [[ $command =~ $marker_pattern ]]; then + marker_user=${BASH_REMATCH[1]} + [[ $marker_user == "$generated_user" ]] || return 1 + seen_marker=1 + continue + fi + + return 1 + done < <(active_lines sudoers) + + (( seen_any && seen_marker && seen_spec )) +} + +# bin/omarchy-install-tailscale (2025-08-22 to 2026-02-02) ran +# "echo \"\$USER ALL=(ALL) NOPASSWD: \$(which tsui)\" | sudo tee +# /etc/sudoers.d/tsui" one line after installing tsui by piping a vendor script +# to bash with no sudo at all, so the path it resolved was usually the user's own +# ~/.local/bin. Overwrite that file, run sudo tsui, and you are root. The grant +# goes whatever the path turned out to be: the feature was dropped from Omarchy, +# and unrestricted NOPASSWD on a TUI that can shell out is an escalation from a +# root-owned path too. +tsui_sudoers_is_generated() { + local spec_pattern='^[^[:space:]]+ ALL=\(ALL\) NOPASSWD: ([^[:space:]]+)$' + local line command="" count=0 + + while IFS= read -r line; do + count=$(( count + 1 )) + if (( count > 1 )); then + return 1 + fi + if [[ ! $line =~ $spec_pattern ]]; then + return 1 + fi + command=${BASH_REMATCH[1]} + done < <(active_lines sudoers) + + if (( count == 1 )) && [[ ${command##*/} == "tsui" ]]; then + return 0 + fi + + return 1 +} + +# install/plymouth.sh wrote this unit for two days (2025-07-05 to 2025-07-07) +# with an unquoted heredoc, so ExecStop names the installing user's home. The +# unit is enabled WantedBy=multi-user.target, so systemd runs that path as uid 0 +# on every shutdown, with no hardware event needed to reach it. +plymouth_unit_runs_from_home() { + local binary="omarchy-plymouth-shutdown-sync" + local exec_stop_pattern='^ExecStop[[:space:]]*=[[:space:]]*(.*)$' + local home_pattern="^/.+/\\.local/share/omarchy/bin/$binary\$" + local line word + local -a words + local matched=1 + + while IFS= read -r line; do + if [[ ! $line =~ $exec_stop_pattern ]]; then + continue + fi + + read -ra words <<<"${BASH_REMATCH[1]}" + if (( ! ${#words[@]} )); then + # An empty assignment resets the list, so nothing named before it still + # runs. `systemd-analyze verify` reports the missing command for a unit + # with one ExecStop=, and reports nothing once a bare ExecStop= follows it. + # An administrator who neutralised the unit this way is left alone. + matched=1 + continue + fi + + # systemd reads -, @, +, ! and : ahead of the command as flags, not as part + # of the path it runs. + word=${words[0]} + while [[ $word == [-@+!:]* ]]; do + word=${word:1} + done + + if [[ $word =~ $home_pattern ]]; then + # Non-empty ExecStop= assignments append to the command list. Once a + # vulnerable command is present it stays live until an empty assignment + # explicitly resets the list; a later packaged command does not replace it. + matched=0 + fi + done < <(active_lines systemd) + + return $matched +} + +# /etc/sudoers.d is 0750 root:root as shipped, and omarchy-migrate runs as the +# logged-in user, so an unelevated [[ -f ]] on a file in there is false whether or +# not the file exists and an unelevated read returns nothing. Both tests and both +# reads have to be elevated or this migration reports success having done nothing. +first_run_sudoers="$sudoers_dir/first-run" +tsui_sudoers="$sudoers_dir/tsui" + +fail_privileged_repair() { + echo "Cannot complete the privileged installer-artifact repair. An administrator must run omarchy-migrate to repair this machine." >&2 + exit 1 +} + +# This is a machine-wide repair with per-user migration markers. A root-owned, +# readable marker lets later non-sudo users finish their own migration run after +# one privileged account has inspected and repaired the machine. Until then, +# the migration fails loudly and remains pending. After an administrator repairs +# the machine, this marker lets every other account complete without using sudo. +if ! as_root true 2>/dev/null; then + fail_privileged_repair +fi + +# Removing a unit and reloading systemd are one repair. Persist the second half +# before removing the file so a failed daemon-reload cannot be forgotten on a +# retry that now sees no unit on disk. +if [[ -e $reload_needed_marker ]]; then + if ! as_root systemctl daemon-reload >/dev/null 2>&1; then + fail_privileged_repair + fi + if ! as_root rm -f "$reload_needed_marker"; then + fail_privileged_repair + fi +fi + +inspect_sudoers_file() { + local file="$1" predicate="$2" kind content + + # Emit an explicit state from the elevated process. A bare `sudo test -f` in + # an if-condition makes "file missing" indistinguishable from "sudo failed", + # which could mark a live grant repaired without ever reading it. + if ! kind=$(as_root bash -c 'if [[ -f $1 ]]; then printf file; elif [[ -e $1 ]]; then printf other; else printf missing; fi' bash "$file"); then + fail_privileged_repair + fi + + [[ $kind == "file" ]] || return 0 + if ! content=$(as_root cat "$file"); then + fail_privileged_repair + fi + + if "$predicate" <<<"$content"; then + if ! as_root rm -f "$file"; then + fail_privileged_repair + fi + fi +} + +inspect_sudoers_file "$first_run_sudoers" first_run_sudoers_is_generated +inspect_sudoers_file "$tsui_sudoers" tsui_sudoers_is_generated + +# /etc/systemd/system is 0755, so this one needs no elevation to look at. +plymouth_unit="$systemd_dir/omarchy-plymouth-shutdown.service" +if [[ -f $plymouth_unit ]] && plymouth_unit_runs_from_home <"$plymouth_unit"; then + # Disable, never stop. Stopping the unit is precisely what runs ExecStop, and + # ExecStop is the path this migration exists to keep root away from; disabling + # only drops the multi-user.target symlink. + if ! as_root install -Dm644 /dev/null "$reload_needed_marker"; then + fail_privileged_repair + fi + if ! as_root systemctl disable omarchy-plymouth-shutdown.service >/dev/null 2>&1; then + fail_privileged_repair + fi + if ! as_root rm -f "$plymouth_unit"; then + fail_privileged_repair + fi + # systemd keeps serving the copy it already loaded until it rereads the + # directory, so without this the unit is still there to run at shutdown. + if ! as_root systemctl daemon-reload >/dev/null 2>&1; then + fail_privileged_repair + fi + if ! as_root rm -f "$reload_needed_marker"; then + fail_privileged_repair + fi +fi + +if ! as_root install -Dm644 /dev/null "$machine_marker"; then + fail_privileged_repair +fi diff --git a/migrations/1788102906.sh b/migrations/1788102906.sh new file mode 100644 index 00000000..f81c4303 --- /dev/null +++ b/migrations/1788102906.sh @@ -0,0 +1,257 @@ +echo "Repair legacy XCompose and remove vulnerable Omarchy 3 power udev rules" + +xcompose="$HOME/.XCompose" +packaged_xcompose="$OMARCHY_PATH/default/xcompose" +legacy_xcompose_pattern='^[[:space:]]*include[[:space:]]+"[^"]*/\.local/share/omarchy/default/xcompose"[[:space:]]*$' + +# Omarchy 3 pointed the user's compose file through the checkout compatibility +# link. Preserve their own sequences while moving that include to the packaged +# tree. A failed live restart is harmless: the next graphical login reads the +# repaired file. +if [[ -f $xcompose ]] && grep -Eq "$legacy_xcompose_pattern" "$xcompose"; then + xcompose_replacement=${packaged_xcompose//\\/\\\\} + xcompose_replacement=${xcompose_replacement//&/\\&} + xcompose_replacement=${xcompose_replacement//|/\\|} + sed -i -E "s|^([[:space:]]*include[[:space:]]+\")[^\"]*/\\.local/share/omarchy/default/xcompose\"[[:space:]]*$|\\1$xcompose_replacement\"|" "$xcompose" + omarchy-restart-xcompose >/dev/null 2>&1 || true +fi + +rules_dir=/etc/udev/rules.d +reload_marker_prefix=/var/lib/omarchy/migrations/1788102906-udev-reload-needed +udev_control=/run/udev/control +install_command=/usr/bin/install +mv_command=/usr/bin/mv +rm_command=/usr/bin/rm +udevadm_command=/usr/bin/udevadm + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +# Omarchy 3 generated these two rules with an unquoted heredoc, so the installing +# user's $HOME was expanded and the file on disk names that absolute home path. +# udev runs RUN+= as root, and +# ~/.local/share/omarchy is a symlink that same unprivileged user owns: replacing +# it with a tree of their own and provoking a power_supply event runs their code +# as root. Quattro ships the rules as 99-omarchy-*.rules under /usr/bin, but the +# one-shot migration that swept the old filenames was itself dropped, so an +# install that came up through the 3.x line keeps the old file until this +# migration removes it. +# +# Pre-4 layout work normally belongs in the Omarchy 4 upgrade command, but that +# command only runs on a machine still making the crossing, so an install that +# crossed already would never see it. The upgrade command ends by running +# omarchy-migrate, so this covers the installs still to upgrade as well. +# +# Only remove an exact two-line body that one of the retired installers wrote. +# A same-named file with no active vulnerable RUN survives; one that still has +# the vulnerable command but also contains administrator changes is quarantined +# under a non-.rules suffix for review. +rule_runs_from_home() { + local file="$1" binary="$2" + local run_pattern='RUN([[:space:]]*\{[^}]*\})?[[:space:]]*(\+|:)?=[[:space:]]*(e)?"([^"]*)"' + local line logical="" rest command + + while IFS= read -r line || [[ -n $line ]]; do + # udev tests for a comment before it joins continuations, and skipping one + # does not end a continuation already under way. Both halves verified with + # `udevadm verify`: "# disabled \" followed by a bogus key reports the error + # on line 2, so a comment's own trailing backslash swallows nothing, while + # 'SUBSYSTEM=="power_supply" \' + "# c" + ', RUN+="..."' reports its style + # warning on line 1, so the rule spans the comment. Testing the comment after + # the join would hide a live rule; clearing the pending line here would hide + # one just as well. + if [[ $line =~ ^[[:space:]]*# ]]; then + continue + fi + + # A trailing backslash continues the rule on the next line. + if [[ $line == *\\ ]]; then + logical+=${line%\\} + continue + fi + + rest=$logical$line + logical="" + + while [[ $rest =~ $run_pattern ]]; do + command=${BASH_REMATCH[4]} + rest=${rest#*"${BASH_REMATCH[0]}"} + + # This is only the fail-closed detector; wholesale deletion still requires + # an exact historical body below. Match spacing edits and wrapper arguments + # conservatively so a modified active rule is never mistaken for a safe one. + if [[ $command == *"/.local/share/omarchy/bin/$binary"* ]]; then + return 0 + fi + done + done <"$file" + + return 1 +} + +mark_reload_needed() { + if ! as_root "$install_command" -Dm644 /dev/null "$reload_needed_marker"; then + echo "Administrator privileges are required to repair the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi +} + +# The retired installers overwrote each file with one of five known two-line +# bodies. Only those exact bodies are safe to delete wholesale. If a vulnerable +# rule has since been edited or extended, preserve it under an inactive name +# rather than taking unrelated rules with it. +rule_is_exact_generated() { + local file="$1" binary="$2" + local prefix suffix home expected + local -a lines + + mapfile -t lines <"$file" + (( ${#lines[@]} == 2 )) || return 1 + + case "$binary" in + omarchy-powerprofiles-set) + # Initial userland helper: separate AC/battery units and arguments. + prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-battery --property=After=power-profiles-daemon.service ' + suffix='/.local/share/omarchy/bin/omarchy-powerprofiles-set battery"' + if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then + home=${lines[0]#"$prefix"} + home=${home%"$suffix"} + expected='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-ac --property=After=power-profiles-daemon.service '"$home"'/.local/share/omarchy/bin/omarchy-powerprofiles-set ac"' + [[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0 + fi + + # USB-C support: one fixed transient-unit name and no profile argument. + prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service ' + suffix='/.local/share/omarchy/bin/omarchy-powerprofiles-set"' + if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then + home=${lines[0]#"$prefix"} + home=${home%"$suffix"} + expected='SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service '"$home"'/.local/share/omarchy/bin/omarchy-powerprofiles-set"' + [[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0 + fi + + # The final Omarchy 3 revision dropped the fixed transient-unit name. + prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --property=After=power-profiles-daemon.service ' + suffix='/.local/share/omarchy/bin/omarchy-powerprofiles-set"' + if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then + home=${lines[0]#"$prefix"} + home=${home%"$suffix"} + expected='SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --property=After=power-profiles-daemon.service '"$home"'/.local/share/omarchy/bin/omarchy-powerprofiles-set"' + [[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0 + fi + ;; + omarchy-wifi-powersave) + # Initial Wi-Fi helper: invoke the userland command directly. + prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="' + suffix='/.local/share/omarchy/bin/omarchy-wifi-powersave on"' + if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then + home=${lines[0]#"$prefix"} + home=${home%"$suffix"} + expected='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="'"$home"'/.local/share/omarchy/bin/omarchy-wifi-powersave off"' + [[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0 + fi + + # Later revision deferred each change through its own transient unit. + prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-on ' + suffix='/.local/share/omarchy/bin/omarchy-wifi-powersave on"' + if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then + home=${lines[0]#"$prefix"} + home=${home%"$suffix"} + expected='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-off '"$home"'/.local/share/omarchy/bin/omarchy-wifi-powersave off"' + [[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0 + fi + ;; + esac + + return 1 +} + +finish_pending_reload() { + # With no control socket there is no running udevd holding the deleted rule; + # the next daemon start reads the directory from disk. If a daemon is running, + # a failed reload must keep this migration pending so the in-memory root rule + # cannot outlive the per-user completion marker. + if [[ -e $udev_control ]] && ! as_root "$udevadm_command" control --reload 2>/dev/null; then + echo "Could not reload udev after removing a vulnerable legacy rule. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi + + if ! as_root "$rm_command" -f "$reload_needed_marker"; then + echo "Could not finish the legacy udev-rule repair. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi +} + +quarantine_rule() { + local rule_file="$1" + local backup="$rule_file.omarchy-disabled" + local suffix=0 + + # udev only loads files ending in .rules. Preserve an administrator-modified + # file byte-for-byte under a suffix udev ignores instead of either deleting + # their additions or leaving its user-controlled command active as root. + while [[ -e $backup || -L $backup ]]; do + ((++suffix)) + backup="$rule_file.omarchy-disabled.$suffix" + done + + mark_reload_needed + if ! as_root "$mv_command" --no-clobber -- "$rule_file" "$backup"; then + echo "Administrator privileges are required to quarantine the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi + if [[ -e $rule_file || -L $rule_file ]]; then + echo "Could not quarantine the vulnerable legacy udev rule at $rule_file. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi + + finish_pending_reload + echo "Quarantined the modified legacy rule as $backup so udev cannot execute it. Review the preserved file before restoring any safe custom actions." >&2 +} + +if [[ -d $rules_dir && ! -x $rules_dir ]]; then + echo "Could not inspect legacy udev rules under $rules_dir. Ask an administrator to run omarchy-migrate." >&2 + exit 1 +fi + +for legacy_rule in "99-power-profile.rules:omarchy-powerprofiles-set" "99-wifi-powersave.rules:omarchy-wifi-powersave"; do + rule_name="${legacy_rule%%:*}" + rule_file="$rules_dir/$rule_name" + binary="${legacy_rule##*:}" + reload_needed_marker="$reload_marker_prefix-$rule_name" + + # The marker is written before removal so a crash cannot lose the need to + # reload. Only consume it early when the corresponding active file is already + # gone; another user's concurrent run may still be between those two steps. + if [[ -e $reload_needed_marker && ! -e $rule_file && ! -L $rule_file ]]; then + finish_pending_reload + fi + + if [[ -f $rule_file && ! -r $rule_file ]]; then + echo "Could not inspect the legacy udev rule at $rule_file. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi + + if [[ -f $rule_file ]] && rule_is_exact_generated "$rule_file" "$binary"; then + mark_reload_needed + if ! as_root "$rm_command" -f "$rule_file"; then + echo "Administrator privileges are required to remove the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2 + exit 1 + fi + + finish_pending_reload + elif [[ -f $rule_file ]] && rule_runs_from_home "$rule_file" "$binary"; then + quarantine_rule "$rule_file" + fi + + # If an interrupted removal was followed by an administrator installing a + # safe replacement, reload that replacement before clearing the old marker. + if [[ -e $reload_needed_marker ]]; then + finish_pending_reload + fi +done diff --git a/migrations/1788112314.sh b/migrations/1788112314.sh new file mode 100644 index 00000000..402342ab --- /dev/null +++ b/migrations/1788112314.sh @@ -0,0 +1,12 @@ +echo "Point rc-channel installs at the rc package repository" + +# pacman-rc.conf shipped with [omarchy] pointing at the edge repository, a +# leftover from when release candidates published there. Candidates now publish +# to the dedicated rc channel, so a machine on the rc mirror was taking its +# omarchy packages from edge. Repoint only a conf that still carries the +# shipped pairing: an administrator who chose another combination keeps it. +if grep -q "https://rc-mirror.omarchy.org/" /etc/pacman.d/mirrorlist && + grep -q "^Server = https://pkgs.omarchy.org/edge/" /etc/pacman.conf; then + sudo sed -i "s|^Server = https://pkgs.omarchy.org/edge/|Server = https://pkgs.omarchy.org/rc/|" /etc/pacman.conf + echo "Switched the [omarchy] repository to the rc channel to match this machine's rc mirror." +fi diff --git a/migrations/1788124236.sh b/migrations/1788124236.sh new file mode 100644 index 00000000..c8f6519b --- /dev/null +++ b/migrations/1788124236.sh @@ -0,0 +1,133 @@ +echo "Disable SSH password authentication, or sshd itself when no key is authorized" + +config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf +authorized_keys="$HOME/.ssh/authorized_keys" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +# Passwords staying enabled is the state the machine has been living with, so a +# condition this migration cannot repair completes with a notice instead of +# failing and holding up every migration queued behind it. Only missing +# privileges stay pending below, because rerunning from a terminal fixes that. +skip() { + echo "$1 SSH password authentication remains enabled; run omarchy-setup-security-sshd to harden manually." + exit 0 +} + +# The fixed setup command writes this file itself. Its presence is also the +# machine-wide completion state, so migrations run by another account no-op. +if [[ -e $config || -L $config ]]; then + exit 0 +fi + +# Earlier versions enabled sshd before importing the key, but did not leave a +# marker saying that Omarchy configured it. Limit the repair to a daemon that is +# enabled or currently exposed and a user who already has a usable authorized +# key. A machine that never set SSH up exits without prompting for privileges. +if ! systemctl is-enabled --quiet sshd.service 2>/dev/null && + ! systemctl is-active --quiet sshd.service 2>/dev/null; then + exit 0 +fi + +# sshd reads authorized_keys one entry per line, while ssh-keygen -lf +# fingerprints whole files in formats sshd does not accept there — a private +# key copied in by mistake passes the file-level check even though sshd finds +# no usable entry in it. Ask sshd's question instead: does any single line +# parse as a public key? +has_usable_key() { + local line + while IFS= read -r line || [[ -n $line ]]; do + if [[ $line =~ ^[[:space:]]*(#|$) ]]; then + continue + fi + if ssh-keygen -lf /dev/stdin <<<"$line" >/dev/null 2>&1; then + return 0 + fi + done <"$authorized_keys" + return 1 +} + +# A file that exists but cannot be read leaves the key question unanswered; do +# not treat it as proof the machine is password-only. [[ -f ]] and the read +# both follow symlinks on purpose: a dotfiles-managed authorized_keys link with +# a working key must not count as keyless. +if [[ -f $authorized_keys && ! -r $authorized_keys ]]; then + skip "Could not read $authorized_keys to check for a usable key." +fi + +# The old setup command enabled sshd before importing a key, so an aborted run +# left a password-only server exposed. Without a usable key there is nothing to +# harden: close the hole Omarchy opened by disabling the server. Omarchy is a +# desktop distro, so the console remains; re-enabling password SSH afterwards +# is an intentional, informed choice the warning explains how to make. +if [[ ! -f $authorized_keys ]] || ! has_usable_key; then + if ! as_root systemctl disable --now sshd.service; then + echo "Administrator privileges are required to close the password-only SSH server. Run omarchy-migrate again from a terminal." >&2 + exit 1 + fi + echo "No usable SSH key is authorized, so sshd only accepted password logins. The SSH server has been disabled: run omarchy-setup-security-sshd to set it up with key-based authentication, or re-enable sshd to accept password logins anyway." + exit 0 +fi + +# Under StrictModes, sshd's default, a group- or world-writable home directory, +# ~/.ssh, or authorized_keys makes sshd ignore the key that just validated, and +# passwords would then be the only way in. Tighten the two paths the setup +# command owns, exactly as it does; the home directory is not ours to change. +home_mode=$(stat -c '%a' "$HOME" 2>/dev/null) || skip "Could not inspect the permissions on $HOME." +if (( 8#$home_mode & 8#022 )); then + skip "$HOME is group- or world-writable, so sshd would ignore the authorized key." +fi +if ! chmod 700 "$HOME/.ssh" || ! chmod 600 "$authorized_keys"; then + skip "Could not tighten the permissions on $authorized_keys." +fi + +echo "Disabling SSH password authentication on the existing key-based SSH setup..." +if ! as_root install -Dm644 /dev/stdin "$config" <<'CONF' +# Written by Omarchy once an SSH key was already authorized. +# Delete this file and reload sshd to allow password logins again. +PasswordAuthentication no +KbdInteractiveAuthentication no +CONF +then + echo "Administrator privileges are required to harden the existing SSH setup. Run omarchy-migrate again from a terminal." >&2 + exit 1 +fi + +# The drop-in itself is always valid, so a rejection means the configuration +# was already broken before it arrived — the administrator's to repair. +if ! as_root sshd -t; then + as_root rm -f -- "$config" || true + skip "sshd rejected its configuration." +fi + +effective_config=$(as_root sshd -T) || { + as_root rm -f -- "$config" || true + skip "Could not inspect sshd's effective configuration." +} + +# Syntax alone is insufficient because sshd uses the first value it reads. An +# sshd_config predating the packaged sshd_config.d Include never reads the +# drop-in at all, and an earlier administrator rule overrides it. Either way +# the file is ineffective: remove it rather than claiming the machine is +# protected. +if ! grep -qixF "passwordauthentication no" <<<"$effective_config" || + ! grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config"; then + as_root rm -f -- "$config" || true + skip "sshd does not apply the hardening drop-in, so an earlier rule or a config without the sshd_config.d include wins." +fi + +# An enabled but deliberately stopped daemon picks the file up on its next +# start. Reload only a daemon that is currently serving connections so existing +# sessions survive while new ones get the hardened policy. +if systemctl is-active --quiet sshd.service 2>/dev/null; then + if ! as_root systemctl reload sshd.service; then + echo "The hardening config is installed and valid, but sshd did not reload; it takes effect when sshd next restarts." >&2 + exit 0 + fi +fi diff --git a/migrations/1788129995.sh b/migrations/1788129995.sh new file mode 100644 index 00000000..965e01db --- /dev/null +++ b/migrations/1788129995.sh @@ -0,0 +1,18 @@ +echo "Replace Satty and Tensaku with Omasnap" + +omarchy-pkg-add omasnap + +# The old source installer left a NoDisplay entry that overrides the packaged launcher. +rm -f "$HOME/.local/share/applications/omasnap.desktop" + +imv_config="$HOME/.config/imv/config" +if [[ -f $imv_config ]]; then + sed -i --follow-symlinks \ + -e 's/^# Edit the current image in Tensaku and quit the viewer$/# Edit the current image in Omasnap and quit the viewer/' \ + -e 's/^# Edit the current image in Satty and quit the viewer$/# Edit the current image in Omasnap and quit the viewer/' \ + -e 's|^ = exec tensaku-edit "$imv_current_file" & ; quit$| = exec omasnap "$imv_current_file" \& ; quit|' \ + -e 's|^ = exec satty --filename "$imv_current_file" & ; quit$| = exec omasnap "$imv_current_file" \& ; quit|' \ + "$imv_config" +fi + +omarchy-pkg-drop satty tensaku diff --git a/migrations/1788577553.sh b/migrations/1788577553.sh new file mode 100644 index 00000000..cc1a54ad --- /dev/null +++ b/migrations/1788577553.sh @@ -0,0 +1,8 @@ +echo "Install Cursor CLI via mise wrapper" + +# Cursor's own installer links ~/.local/bin/cursor-agent, so an existing +# command is the user's and stays. The wrapper resolves cursor-agent through +# mise's registry, which lists it from 2026.8.15 on. +if omarchy-cmd-missing cursor-agent && [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then + omarchy-mise-install cursor-agent +fi diff --git a/migrations/1788595060.sh b/migrations/1788595060.sh new file mode 100644 index 00000000..750da2dc --- /dev/null +++ b/migrations/1788595060.sh @@ -0,0 +1,4 @@ +echo "Register the Chromium extension native messaging hosts for Brave Origin" + +omarchy-install-chromium-copy-url +omarchy-install-chromium-ytdlp diff --git a/migrations/1788596255.sh b/migrations/1788596255.sh new file mode 100644 index 00000000..e4c7b0c4 --- /dev/null +++ b/migrations/1788596255.sh @@ -0,0 +1,3 @@ +echo "Add vi as a standard terminal editor" + +omarchy-pkg-add vi diff --git a/migrations/1788619462.sh b/migrations/1788619462.sh new file mode 100644 index 00000000..0b491679 --- /dev/null +++ b/migrations/1788619462.sh @@ -0,0 +1,9 @@ +echo "Hand Hermes Desktop the Omarchy theme as a skin" + +# Only the app Omarchy installed under Install > AI follows the theme by itself. +# A Hermes the user set up some other way keeps whatever skin they chose. +omarchy-pkg-present hermes-desktop || exit 0 + +# The same hand-over a fresh install does. A Hermes that is not ready or refuses +# the write is reported and done with there; only Omarchy's own failures return. +omarchy-theme-set-hermes --activate diff --git a/migrations/1788662350.sh b/migrations/1788662350.sh new file mode 100644 index 00000000..c0d4dbf2 --- /dev/null +++ b/migrations/1788662350.sh @@ -0,0 +1,303 @@ +echo "Repair user-owned system-sleep hooks and hybrid GPU service configuration" + +system_sleep_dir=/usr/lib/systemd/system-sleep +supergfxd_drop_in=/etc/systemd/system/supergfxd.service.d/delay-start.conf +quarantine_root=/var/lib/omarchy/migrations/1788662350-system-sleep +reload_needed_marker=/var/lib/omarchy/migrations/1788662350-systemd-reload-needed +keyboard_source="$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" +force_igpu_source="$OMARCHY_PATH/default/systemd/system-sleep/force-igpu" +supergfxd_source="$OMARCHY_PATH/default/systemd/system/supergfxd.service.d/delay-start.conf" +legacy_keyboard_sha256=f313a81e47401f0d38b8602e5997f52c5286d5e97f74027564ddd515b3d16511 +legacy_force_igpu_sha256=d604e7c4903829563e45fc52188fc5602c3f1bc66e247f0a2cc0a974ed6e57db + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +path_is_root_controlled() { + local path="$1" + local current=/ component candidate file_mode link metadata part status uid gid mode + local missing_depth=0 symlink_count=0 + local -a pending resolved link_components + + [[ $path == /* ]] || return 1 + IFS=/ read -r -a pending <<<"$path" + # A non-root group is harmless when neither it nor everyone else can write. + # Resolve symlinks component by component so an indirect link cannot hide an + # intermediate directory controlled by an unprivileged user. + metadata=$(path_metadata /) || return 1 + read -r file_mode uid gid mode <<<"$metadata" + (( uid == 0 && (8#$mode & 8#022) == 0 )) || return 1 + + while ((${#pending[@]})); do + component=${pending[0]} + pending=("${pending[@]:1}") + [[ -n $component ]] || continue + [[ $component == "." ]] && continue + + if [[ $component == ".." ]]; then + if ((${#resolved[@]})); then + unset 'resolved[-1]' + fi + + current=/ + for part in "${resolved[@]}"; do + if [[ $current == "/" ]]; then + current="/$part" + else + current="$current/$part" + fi + done + if (( missing_depth > 0 && ${#resolved[@]} < missing_depth )); then + missing_depth=0 + fi + continue + fi + + if [[ $current == "/" ]]; then + candidate="/$component" + else + candidate="$current/$component" + fi + + if (( missing_depth > 0 )); then + # The first missing component makes descendants inactive today, but keep + # consuming the lexical suffix. A later .. can escape back into an + # existing user-controlled path that would become active if an + # administrator creates the missing directory. + resolved+=("$component") + current=$candidate + continue + elif metadata=$(path_metadata "$candidate"); then + read -r file_mode uid gid mode <<<"$metadata" + else + status=$? + if (( status == 2 )); then + resolved+=("$component") + current=$candidate + missing_depth=${#resolved[@]} + continue + else + return 1 + fi + fi + if (( (16#$file_mode & 16#f000) == 16#a000 )); then + ((++symlink_count <= 40)) || return 1 + link=$(readlink_with_privilege "$candidate") || return 1 + IFS=/ read -r -a link_components <<<"$link" + pending=("${link_components[@]}" "${pending[@]}") + if [[ $link == /* ]]; then + resolved=() + current=/ + fi + continue + fi + + (( uid == 0 && (8#$mode & 8#022) == 0 )) || return 1 + resolved+=("$component") + current=$candidate + done +} + +path_metadata() { + local path="$1" + local metadata parent + + if /usr/bin/stat -c '%f %u %g %a' -- "$path" 2>/dev/null; then + return 0 + elif [[ ! -e $path && ! -L $path ]]; then + parent=${path%/*} + [[ -n $parent ]] || parent=/ + # Avoid asking for sudo for ordinary ENOENT. If the parent is searchable, + # the absence is conclusive; an inaccessible root-only chain still needs a + # privileged metadata check so safe administrator symlinks are preserved. + [[ -x $parent ]] && return 2 + if metadata=$(as_root /usr/bin/stat -c '%f %u %g %a' -- "$path" 2>/dev/null); then + printf '%s\n' "$metadata" + return 0 + elif as_root /usr/bin/test -x "$parent"; then + # The privileged probe could search the protected parent, so stat's + # failure identifies a target that does not exist yet. + return 2 + else + return 1 + fi + else + as_root /usr/bin/stat -c '%f %u %g %a' -- "$path" + fi +} + +readlink_with_privilege() { + local path="$1" + + if /usr/bin/readlink -- "$path" 2>/dev/null; then + return 0 + else + as_root /usr/bin/readlink -- "$path" + fi +} + +privileged_entry_is_safe() { + local path="$1" + + path_is_root_controlled "$path" +} + +file_matches_source() { + local source="$1" + local destination="$2" + + [[ -f $destination && ! -L $destination ]] || return 1 + + if [[ -r $destination ]]; then + /usr/bin/cmp -s -- "$source" "$destination" + else + as_root /usr/bin/cmp -s -- "$source" "$destination" + fi +} + +file_matches_sha256() { + local destination="$1" + local expected="$2" + local digest + + [[ -f $destination && ! -L $destination ]] || return 1 + if [[ -r $destination ]]; then + digest=$(/usr/bin/sha256sum -- "$destination") || return 1 + else + digest=$(as_root /usr/bin/sha256sum -- "$destination") || return 1 + fi + [[ ${digest%% *} == "$expected" ]] +} + +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}$ ]] +} + +install_root_file() { + local source="$1" + local destination="$2" + local mode="$3" + local stage + + stage=$(as_root /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1 + safe_stage_path "$stage" "$destination" || return 1 + + if as_root /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" && + as_root /usr/bin/mv -Tf -- "$stage" "$destination"; then + return 0 + else + safe_stage_path "$stage" "$destination" && as_root /usr/bin/rm -f -- "$stage" + return 1 + fi +} + +preserve_unsafe_customization() { + local path="$1" + local label="$2" + local backup_dir backup + + if ! as_root /usr/bin/install -d -m 0700 -o root -g root "$quarantine_root"; then + echo "Could not create the root-only system-sleep quarantine at $quarantine_root" >&2 + return 1 + fi + if ! backup_dir=$(as_root /usr/bin/mktemp -d -- "$quarantine_root/${label}.XXXXXX"); then + echo "Could not reserve a quarantine path for $path" >&2 + return 1 + fi + backup="$backup_dir/original" + + if as_root /usr/bin/cp -a --no-dereference -T -- "$path" "$backup"; then + printf '%s\n' "$backup" + else + as_root /usr/bin/rm -rf -- "$backup_dir" + echo "Could not preserve unsafe custom content from $path before repairing it" >&2 + return 1 + fi +} + +repair_unsafe_privileged_entry() { + local source="$1" + local destination="$2" + local mode="$3" + local label="$4" + local legacy_sha256="${5:-}" + local backup current_mode + + [[ -e $destination || -L $destination ]] || return 0 + [[ -f $destination || -L $destination ]] || return 0 + + if file_matches_source "$source" "$destination"; then + current_mode=$(/usr/bin/stat -c '%a' -- "$destination" 2>/dev/null) || + current_mode=$(as_root /usr/bin/stat -c '%a' -- "$destination") || return 1 + if privileged_entry_is_safe "$destination" && [[ $current_mode == "${mode#0}" ]]; then + return 0 + fi + elif [[ -n $legacy_sha256 ]] && file_matches_sha256 "$destination" "$legacy_sha256"; then + : + else + privileged_entry_is_safe "$destination" && return 0 + backup=$(preserve_unsafe_customization "$destination" "$label") || return 1 + fi + + if install_root_file "$source" "$destination" "$mode"; then + if [[ -n ${backup:-} ]]; then + echo "Preserved unsafe custom content from $destination at $backup for administrator review" >&2 + fi + else + if [[ -n ${backup:-} ]]; then + echo "Preserved unsafe custom content from $destination at $backup, but could not repair the active path" >&2 + fi + return 1 + fi +} + +# Replace rather than chown an unsafe destination: its current owner may have +# already changed the contents or kept a writable file descriptor open. The +# root-owned staging inode makes the final rename an atomic trust transition. +repair_unsafe_privileged_entry "$keyboard_source" \ + "$system_sleep_dir/keyboard-backlight" 0755 keyboard-backlight "$legacy_keyboard_sha256" + +force_igpu="$system_sleep_dir/force-igpu" +repair_unsafe_privileged_entry "$force_igpu_source" "$force_igpu" 0755 force-igpu "$legacy_force_igpu_sha256" + +systemd_reload_needed=false +if [[ -e $reload_needed_marker || -L $reload_needed_marker ]]; then + systemd_reload_needed=true +fi + +if [[ -e $supergfxd_drop_in || -L $supergfxd_drop_in ]]; then + if ! privileged_entry_is_safe "$supergfxd_drop_in"; then + # Replacing the drop-in and reloading systemd are one repair. Record the + # second half before changing the file so failure or interruption cannot + # be forgotten when a retry sees only the trusted replacement on disk. + if ! as_root /usr/bin/install -Dm0644 -o root -g root /dev/null "$reload_needed_marker"; then + echo "Could not persist the pending systemd reload for the repaired supergfxd configuration" >&2 + exit 1 + fi + systemd_reload_needed=true + repair_unsafe_privileged_entry "$supergfxd_source" "$supergfxd_drop_in" 0644 delay-start.conf + fi +fi + +if $systemd_reload_needed; then + if ! as_root /usr/bin/systemctl daemon-reload; then + echo "Could not reload systemd after repairing the supergfxd configuration; the migration will retry" >&2 + exit 1 + fi + if ! as_root /usr/bin/rm -f -- "$reload_needed_marker"; then + echo "Could not clear the pending systemd reload marker; the migration will retry" >&2 + exit 1 + fi +fi diff --git a/migrations/1788724825.sh b/migrations/1788724825.sh new file mode 100644 index 00000000..cc76dc0f --- /dev/null +++ b/migrations/1788724825.sh @@ -0,0 +1,5 @@ +echo "Install Muse Code via mise wrapper" + +if omarchy-cmd-missing muse && [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then + omarchy-mise-install "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]" muse +fi diff --git a/migrations/1788745941.sh b/migrations/1788745941.sh new file mode 100644 index 00000000..5fe72caa --- /dev/null +++ b/migrations/1788745941.sh @@ -0,0 +1,33 @@ +echo "Update Kitty configuration" + +kitty_config="$HOME/.config/kitty/kitty.conf" +# config/kitty/kitty.conf as shipped after 008f3a22 (Kitty cwd lookup). +stock_sha="856cd466bf568d091cb775c5b90d1852178090a419f492fde02a4eaef6407bf9" +unrestricted='^[[:space:]]*allow_remote_control[[:space:]]+(yes|y|true)[[:space:]]*$' + +if [[ -f $kitty_config ]]; then + changed=false + + if [[ $(sha256sum "$kitty_config" | cut -d ' ' -f 1) == $stock_sha ]]; then + omarchy-refresh-config kitty/kitty.conf + changed=true + elif grep -qE "$unrestricted" "$kitty_config"; then + # Preserve customizations and ordering. An otherwise stock line can be an + # intentional override of an earlier include or mapping. + backup=$(mktemp "$kitty_config.bak.XXXXXX") + cp -p "$kitty_config" "$backup" + sed --follow-symlinks -i -E "s/$unrestricted/# &/" "$kitty_config" + printf '\n%s\n' \ + "Unrestricted remote control disabled." \ + "Your other Kitty settings were preserved." + printf '\nBackup saved to:\n %s\n' "$backup" + changed=true + fi + + if [[ $changed == "true" ]]; then + # Kitty reads allow_remote_control at startup; config reload is insufficient. + gum style --border rounded --border-foreground 3 --padding "1 2" --margin "1 0" \ + "Restart Kitty" "" \ + "Close and reopen all Kitty windows to apply this change." + fi +fi diff --git a/migrations/1788848726.sh b/migrations/1788848726.sh new file mode 100644 index 00000000..ab33ffcc --- /dev/null +++ b/migrations/1788848726.sh @@ -0,0 +1,21 @@ +echo "Retire the stock user icon font missed by the Quattro upgrade" + +legacy_font="$HOME/.local/share/fonts/omarchy.ttf" + +# The upgrader treated this as ~/.config/omarchy.ttf and left the old family +# registered alongside the packaged font. Preserve custom fonts and symlinks. +if [[ -f $legacy_font && ! -L $legacy_font ]]; then + legacy_hash=$(sha256sum "$legacy_font") + if [[ ${legacy_hash%% *} == "e55e67119e82f56f92d90cbf54b7ccc1b2946b32c535a29370439d7ef5215966" ]]; then + if [[ ! -f /usr/share/fonts/omarchy/omarchy.ttf ]]; then + echo "Packaged Omarchy icon font is missing; keeping the legacy font." >&2 + exit 1 + fi + + rm "$legacy_font" + fi +fi + +# Also refresh on retries after removal succeeded but the cache refresh failed. +# The normal update restarts the shell, which reloads Qt's font database. +fc-cache -f diff --git a/migrations/1788862626.sh b/migrations/1788862626.sh new file mode 100644 index 00000000..822d8513 --- /dev/null +++ b/migrations/1788862626.sh @@ -0,0 +1,20 @@ +echo "Expose the Elgato Cam Link 4K as a 16:9 virtual camera" + +if omarchy-hw-elgato-camlink-4k; then + source "$OMARCHY_PATH/install/hardware/fix-elgato-camlink-4k.sh" + + sudo systemctl daemon-reload + sudo udevadm control --reload + + # Re-run the rules for the Cam Link that is plugged in now so it gets hidden + # and relayed right away. The user ACL from its first plug survives a + # re-trigger, so drop it here; a replug or reboot does the same on its own. + sudo udevadm trigger --action=add --subsystem-match=video4linux + sudo udevadm settle + [[ -e /dev/camlink4k ]] && sudo setfacl -b /dev/camlink4k + + # The trigger only starts the relay when the rule is new to the device. + # Start it outright so a failed module build or loopback surfaces here, + # with the raw camera hidden, rather than passing silently. + sudo systemctl start v4l2-relayd@camlink.service +fi diff --git a/migrations/1788941927.sh b/migrations/1788941927.sh new file mode 100644 index 00000000..f0f37865 --- /dev/null +++ b/migrations/1788941927.sh @@ -0,0 +1,5 @@ +echo "Install basecamp (basecamp-cli) via mise wrapper" + +if [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then + omarchy-mise-install github:basecamp/basecamp-cli basecamp +fi diff --git a/migrations/1789091250.sh b/migrations/1789091250.sh new file mode 100644 index 00000000..1060d714 --- /dev/null +++ b/migrations/1789091250.sh @@ -0,0 +1,4 @@ +echo "Activate the Omarchy theme for existing T3 Code installs" + +omarchy-pkg-present t3code-bin || exit 0 +omarchy-install-ai-t3-code diff --git a/migrations/1789095456.sh b/migrations/1789095456.sh new file mode 100644 index 00000000..381557fc --- /dev/null +++ b/migrations/1789095456.sh @@ -0,0 +1,99 @@ +echo "Remove automatic project bin directories from PATH" + +work_dir="$HOME/Work" +mise_config="$work_dir/.mise.toml" +# install/user/mise-work.sh as shipped in Omarchy 4.0.3. +stock_sha="bd04f191d63bbde86920f44f76f0989fad980afc84e268e8474c201ec7149245" +cwd_bin='\{\{[[:space:]]*cwd[[:space:]]*\}\}/bin' +unsafe_path="^[[:space:]]*_[.]path[[:space:]]*=[[:space:]]*(\"$cwd_bin\"|'$cwd_bin')[[:space:]]*(#.*)?$" +env_section='^[[:space:]]*\[[[:space:]]*env[[:space:]]*\][[:space:]]*(#.*)?$' +any_section='^[[:space:]]*\[\[?.*\]\]?[[:space:]]*(#.*)?$' + +remove_empty_work_dir=false +if [[ ! -e $work_dir ]]; then + mkdir -p "$work_dir" + remove_empty_work_dir=true +fi + +was_ignored=false +if [[ -d $work_dir ]]; then + mise_state_dir=${MISE_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mise} + ignored_configs_dir="$mise_state_dir/ignored-configs" + work_target=$(readlink -m "$work_dir") + config_path_target="$work_target/.mise.toml" + config_target=$(readlink -m "$mise_config") + + if [[ -d $ignored_configs_dir ]]; then + for ignored_entry in "$ignored_configs_dir"/*; do + [[ -L $ignored_entry ]] || continue + ignored_target=$(readlink "$ignored_entry") + if [[ $ignored_target == $work_target || $ignored_target == $config_path_target || $ignored_target == $config_target ]]; then + was_ignored=true + break + fi + done + fi + + if [[ $was_ignored == "false" ]]; then + # Normal Mise trust is recorded against the config-root directory, while + # paranoid trust is recorded against the file and its contents. Stage an + # empty, inert config when the legacy file is gone so either trust mode can + # resolve and revoke the original grant. + remove_empty_mise_config=false + if [[ ! -e $mise_config && ! -L $mise_config ]]; then + if (set -o noclobber; : >"$mise_config") 2>/dev/null; then + remove_empty_mise_config=true + fi + fi + + untrust_target="$work_dir" + if [[ -f $mise_config ]]; then + untrust_target="$mise_config" + fi + + if mise trust --untrust "$untrust_target"; then + : + else + if [[ $remove_empty_mise_config == "true" ]]; then + rm -f -- "$mise_config" + fi + exit 1 + fi + + if [[ $remove_empty_mise_config == "true" ]]; then + rm -f -- "$mise_config" + fi + fi +fi + +if [[ -f $mise_config ]]; then + if [[ ! -L $mise_config && $(sha256sum "$mise_config" | cut -d ' ' -f 1) == $stock_sha ]]; then + rm -f -- "$mise_config" + else + unsafe_env_paths=$(sed -n -E "\\%$env_section%,\\%$any_section% { \\%$unsafe_path%p; }" "$mise_config") + if [[ -n $unsafe_env_paths ]]; then + backup=$(mktemp "$mise_config.bak.XXXXXX") + cp -p -- "$mise_config" "$backup" + sed --follow-symlinks -i -E "\\%$env_section%,\\%$any_section% { \\%$unsafe_path%d; }" "$mise_config" + + printf '\n%s\n' \ + "Automatic project bin directories were removed from your Mise PATH." \ + "Your other Mise settings were preserved." + printf '\nBackup saved to:\n %s\n' "$backup" + fi + fi +fi + +if [[ -f $mise_config ]]; then + if [[ $was_ignored == "true" ]]; then + printf '\n%s\n' "This custom config remains ignored by Mise." + else + printf '\n%s\n %s\n' \ + "Mise trust for this custom config was revoked. Review it before trusting it again:" \ + "mise trust $mise_config" + fi +fi + +if [[ $remove_empty_work_dir == "true" ]]; then + rmdir "$work_dir" 2>/dev/null || true +fi diff --git a/migrations/1789130779.sh b/migrations/1789130779.sh new file mode 100644 index 00000000..56d9fe20 --- /dev/null +++ b/migrations/1789130779.sh @@ -0,0 +1,9 @@ +echo "Keep the KEF LSX II LT USB sink from suspending" + +conf="wireplumber/wireplumber.conf.d/kef-lsx-no-suspend.conf" + +if [[ ! -f "$HOME/.config/$conf" ]]; then + omarchy-refresh-config "$conf" + # WirePlumber only reads conf.d at startup; restart it if it is running. + systemctl --user try-restart wireplumber.service 2>/dev/null || true +fi diff --git a/migrations/1789294350.sh b/migrations/1789294350.sh new file mode 100644 index 00000000..9eba0416 --- /dev/null +++ b/migrations/1789294350.sh @@ -0,0 +1,11 @@ +echo "Switch TCP congestion control to BBR with fq pacing" + +# Boot applies the shipped file regardless; this only makes new connections +# use BBR now. Setting the sysctls autoloads tcp_bbr and sch_fq. Qdiscs +# already attached to interfaces stay until they are recreated, normally at +# reboot. +if [[ $(sysctl -n net.ipv4.tcp_congestion_control) == "bbr" && $(sysctl -n net.core.default_qdisc) == "fq" ]]; then + exit 0 +fi + +sudo sysctl -p /etc/sysctl.d/99-omarchy-sysctl.conf >/dev/null || omarchy-state set reboot-required diff --git a/migrations/1789310715.sh b/migrations/1789310715.sh new file mode 100644 index 00000000..a182dc8b --- /dev/null +++ b/migrations/1789310715.sh @@ -0,0 +1,5 @@ +echo "Install cf (Cloudflare CLI) via mise wrapper" + +if [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then + omarchy-mise-install npm:cf cf +fi diff --git a/migrations/1789325478.sh b/migrations/1789325478.sh new file mode 100644 index 00000000..14ba7d98 --- /dev/null +++ b/migrations/1789325478.sh @@ -0,0 +1,44 @@ +echo "Install the Omarchy kernel and make it the first Limine boot entry" + +# linux-omarchy is an x86_64 kernel. T2 Macs must keep their specialized kernel, +# including when other kernels are installed or the running T2 package is gone. +[[ $(uname -m) == "x86_64" ]] || exit 0 +running_kernel=$(uname -r) +if omarchy-pkg-present linux-t2 || [[ ${running_kernel,,} == *-t2* ]]; then + exit 0 +fi + +limine_conf="${OMARCHY_KERNEL_LIMINE_CONF:-/etc/default/limine}" +rebuild_marker="${OMARCHY_KERNEL_REBUILD_MARKER:-/var/lib/omarchy/migrations/1789325478}" +kernel="linux-omarchy" + +# Completion is machine-wide even though migrations run once per user. Leave +# the old kernel installed so it remains available if the new one cannot boot. +# The new filename and marker also reach users who completed 1789095456. +[[ ! -e $rebuild_marker ]] || exit 0 +omarchy-pkg-add "$kernel" "$kernel-headers" + +# /etc/default/limine has priority over every drop-in, including old Dell +# settings and customized package files whose updates landed in a .pacnew. +# Set the exact kernel first: linux-omarchy-* only matches its older variants. +# Preserve unrelated settings, especially the root filesystem's kernel cmdline. +sudo mkdir -p "$(dirname "$limine_conf")" +sudo touch "$limine_conf" +sudo sed -i -E '/^[[:space:]]*BOOT_ORDER[[:space:]]*=/d' "$limine_conf" +printf '\n%s\n' 'BOOT_ORDER="linux-omarchy, linux-omarchy-*, *, *fallback, Snapshots"' | sudo tee -a "$limine_conf" >/dev/null + +# Package hooks ran before the config repair. Rebuild the new kernel's image +# and boot entry explicitly, including on retries after a failed rebuild. +sudo limine-mkinitcpio "$kernel" + +# limine-mkinitcpio can return success after skipping a failed kernel build. +# Do not mark the migration complete unless the new kernel is in the menu. +if ! sudo limine-entry-tool --tree | grep -E "(^|[^[:alnum:]_-])$kernel([^[:alnum:]_-]|$)" >/dev/null; then + echo "The Omarchy kernel has no Limine boot entry; rerun omarchy-migrate after fixing the boot image build." >&2 + exit 1 +fi + +# Keeping the running kernel installed prevents the updater from detecting a +# kernel replacement, so request the reboot explicitly. +omarchy-state set reboot-required +sudo install -Dm644 /dev/null "$rebuild_marker" diff --git a/migrations/1789444024.sh b/migrations/1789444024.sh new file mode 100644 index 00000000..b5bc9149 --- /dev/null +++ b/migrations/1789444024.sh @@ -0,0 +1,10 @@ +echo "Install missing headers for the Omarchy or T2 kernel" + +# Fresh ISO installs mark earlier migrations complete, so the kernel migration +# cannot repair headers omitted by those installers. Package installation is +# idempotent when another user has already applied this repair. +for kernel in linux-omarchy linux-t2; do + if omarchy-pkg-present "$kernel"; then + omarchy-pkg-add "$kernel-headers" + fi +done diff --git a/migrations/1789764927.sh b/migrations/1789764927.sh new file mode 100644 index 00000000..e2d1541e --- /dev/null +++ b/migrations/1789764927.sh @@ -0,0 +1,17 @@ +echo "Enable OWE desktop video backgrounds and lock feed" + +omarchy-pkg-add owe owe-lockfeed +omarchy-hook-install theme-set /usr/share/owe/10-owe-sync + +systemctl --user daemon-reload >/dev/null 2>&1 || true +if ! systemctl --user enable owed.service; then + wants_dir="$HOME/.config/systemd/user/graphical-session.target.wants" + mkdir -p "$wants_dir" + ln -sfn /usr/lib/systemd/user/owed.service "$wants_dir/owed.service" +fi + +# A TTY update enables the next graphical login without starting a renderer +# against a missing Wayland session. A failed live start leaves this pending. +if [[ ${OMARCHY_UPGRADE_TO_QUATTRO_LIVE:-0} != 1 ]] && systemctl --user is-active --quiet graphical-session.target; then + systemctl --user start owed.service +fi diff --git a/migrations/1790042972.sh b/migrations/1790042972.sh new file mode 100644 index 00000000..8dce1c3e --- /dev/null +++ b/migrations/1790042972.sh @@ -0,0 +1,23 @@ +echo "Install Elsewhen, the world clock plugin" + +omarchy-pkg-add elsewhen + +packaged_plugin="/usr/share/omarchy/shell/plugins/omacom.elsewhen" +user_plugin="$HOME/.config/omarchy/plugins/omacom.elsewhen" + +# The package moved from plugins/ to shell/plugins/ once, stranding the link an +# earlier run made to the old path. A link the user made is left alone. +if [[ -L $user_plugin && ! -e $user_plugin && $(readlink "$user_plugin") == /usr/share/omarchy/* && -d $packaged_plugin ]]; then + ln -sfn "$packaged_plugin" "$user_plugin" +fi + +# Dev checkouts do not contain plugins installed by system packages. +if [[ ! $OMARCHY_PATH -ef /usr/share/omarchy && -d $packaged_plugin && ! -e $user_plugin && ! -L $user_plugin ]]; then + mkdir -p "${user_plugin%/*}" + ln -s "$packaged_plugin" "$user_plugin" +fi + +# Best-effort, like the put below: an update whose shell cannot be asked still +# finishes, and restarts the shell once the migrations are through. +omarchy-shell -q shell rescanPlugins +omarchy-bar put omacom.elsewhen --before omarchy.clock diff --git a/plans/remote.md b/plans/remote.md new file mode 100644 index 00000000..3c7354d5 --- /dev/null +++ b/plans/remote.md @@ -0,0 +1,140 @@ +# Plan: Remote — finish Sunshine/Moonlight into a real remote desktop + +Revision 1. + +## Problem + +Omarchy already leans Sunshine/Moonlight: `moonlight-qt` ships preinstalled (`bin/omarchy-install-preinstalls`) with a fullscreen + idle-inhibit window rule (`default/hypr/apps/moonlight.lua`), and `bin/omarchy-install-service-sunshine` installs the host, opens the Moonlight ports for RFC1918 LANs and `tailscale0` via ufw, and drops a "Sunshine Admin" webapp. `manual/26-gaming.md` even advertises `omarchy install service sunshine`. But what exists is a game-streaming bootstrap, not a remote desktop. The gaps, in the order a user hits them: + +- **Two start paths for one daemon.** The installer runs both `systemctl --user enable --now sunshine` *and* appends `o.launch_on_start("sunshine")` to `~/.config/hypr/autostart.lua`. On every login the systemd user manager starts the unit (upstream ships it `WantedBy=graphical-session.target` with `Restart=on-failure`) while Hyprland's autostart launches a second copy through `uwsm-app`; whichever loses the port-bind race flaps and litters the journal. The autostart line was presumably insurance against the unit lacking `WAYLAND_DISPLAY` — insurance Omarchy doesn't need, because uwsm imports the session environment into the user manager before `graphical-session.target` is reached (it's how every other session unit in `default/systemd/user/` works). +- **Pairing is the weak link.** First contact is a browser pointed at `https://localhost:47990` with `--ignore-certificate-errors`, where you invent a username and password over a self-signed cert, then transcribe a PIN from Moonlight into a web form. That's three UI hops and a certificate warning for what is, protocol-wise, one authenticated POST. +- **Streaming hijacks the physical screen.** Sunshine captures a real monitor, so "remote desktop" today means watching your own desk chair's screen — and means nothing at all when no monitor is attached. There is no virtual display, which is the single feature that separates game streaming from remote desktop. +- **Audio is all-or-nothing.** Sunshine captures the default sink's monitor, so the stream hears whatever the speakers at the desk are playing, and the desk hears everything the remote session does. +- **No menu presence.** Despite the manual advertising it, there is no Sunshine entry anywhere in `default/omarchy/omarchy-menu.jsonc` — not under Install > Service, not under Remove. The feature is invisible unless you already know the command. +- **Install-order coupling with Tailscale.** The ufw rules for `tailscale0` are only added if the interface exists at install time; `omarchy-install-service-tailscale` knows nothing about Sunshine's ports. Install Sunshine first, Tailscale second, and the tailnet — the whole point of remote access — is silently closed. +- **Uninstall is only half-symmetric.** `omarchy-remove-service-sunshine` closes ports and removes the webapp and autostart line, but leaves `~/.config/sunshine/` (pairing state, credentials, apps) behind, and knows nothing about the virtual display or audio plumbing this plan adds. + +## Shape + +One blessed path — Sunshine hosting, Moonlight connecting — finished end to end. "End to end" means: pick Sunshine from the menu, pair from a terminal with a PIN, connect from any Moonlight client on the LAN or tailnet, land on a dedicated remote workspace sized to your client (or deliberately mirror the physical screen), hear the session's audio without broadcasting it at the desk, disconnect without orphaning a single window, and uninstall without leaving a trace. All of it inside the posture the installer already got right: ports open to private networks and the tailnet only, never the internet. + +The work is a rework of the two existing scripts plus a small new `sunshine` command group (precedent: the `tailscale` group), a managed slice of Sunshine's configuration, menu entries, a migration for existing installs, tests, and a manual section. No new daemons, no new packages beyond what's already in the omarchy repo. + +## Rejected approaches + +- **wayvnc, or an xrdp/VNC-RDP chain**: wayvnc (0.10.1 in extra) does capture Hyprland via wlr-screencopy, but VNC is unencrypted-by-default, software-encoded, and pointer-laggy; chaining xrdp on top adds a protocol translation layer that breaks whenever Hyprland's wlroots divergence shifts under it. No hardware encode means it loses to Sunshine on every axis Omarchy cares about (latency, battery, fidelity), and we'd still have to solve pairing, virtual displays, and audio ourselves. +- **RustDesk**: packaged (1.4.9 in the omarchy repo) and tempting for its built-in relay story, but Hyprland hosts still fail in practice — it wants the `org.freedesktop.portal.RemoteDesktop` portal, which `xdg-desktop-portal-hyprland` does not implement (hyprwm/xdg-desktop-portal-hyprland#252, still open; rustdesk/rustdesk#9026 documents the Hyprland-specific failure). Blessing a host that can't host on our own compositor is not a v1. +- **Waiting for the RemoteDesktop portal**: same issue. Sunshine sidesteps the portal entirely — capture via wlr-screencopy, input via uinput (the package ships the udev rule) — which is exactly why it's the one stack that works on Hyprland today. The plan builds on that, and can revisit portal-based options if #252 ever lands. +- **waypipe**: forwards individual Wayland apps, not a desktop; a different product. +- **Building a Fluid-equivalent from scratch**: a bespoke streaming stack is a multi-year protocol, codec, and client-matrix project. Omarchy's leverage is integration taste, not reinventing NVENC negotiation. Sunshine/Moonlight is mature, hardware-accelerated, cross-platform on the client side, and already half-wired into the repo. +- **A logged-out remote login mode** (what gnome-remote-desktop grew): Sunshine attaches to a running session, and Hyprland has no system-daemon mode to attach to. Omarchy's SDDM autologin means a booted machine has a session anyway; the honest constraint is full-disk encryption's passphrase prompt, covered under open questions rather than papered over. + +## Design + +### One daemon, one start path + +The systemd user unit wins. It has restart-on-failure, journal logging, a clean `is-active` answer for guards and status, and — because uwsm imports `WAYLAND_DISPLAY` and friends into the user manager — a complete graphical environment by the time `graphical-session.target` pulls it in. The installer keeps `systemctl --user enable --now sunshine` and stops touching `autostart.lua`; a migration (epoch-named under `migrations/`) deletes the `o.launch_on_start("sunshine")` line from existing users' `~/.config/hypr/autostart.lua` so the double start dies everywhere, not just on fresh installs. The remove script keeps its `sed` cleanup for one release as belt-and-braces, then that too can go. + +### Capture backend: wlr-screencopy, declared explicitly + +Omarchy writes `capture = wlr` into its managed section of `~/.config/sunshine/sunshine.conf` instead of letting Sunshine probe. Two reasons, both structural: + +- KMS capture reads real DRM CRTCs, so it needs `CAP_SYS_ADMIN` on the binary *and* it fundamentally cannot see a virtual output — a headless Hyprland output has no CRTC. Sunshine used to crash on headless connectors outright (LizardByte/Sunshine#2955, fixed by skipping them), but "skipped" still means "not capturable". Since the virtual display is the centerpiece of this plan, KMS is disqualified regardless of its marginal latency edge. +- wlr-screencopy is the interface Hyprland actually maintains (`hyprland.portal` declares ScreenCast/Screenshot/GlobalShortcuts/InputCapture — capture is first-class), captures any output including headless ones, needs no capability bits, and feeds VAAPI/NVENC the same dmabufs. + +Input needs no decision: Sunshine injects through uinput with the udev rule its package installs, below the compositor, so the missing RemoteDesktop portal is irrelevant on the host side. + +### Command surface + +The lifecycle pair stays where it is: `omarchy-install-service-sunshine` and `omarchy-remove-service-sunshine` are the Install > Service idiom (1Password, Dropbox, Tailscale all live there), the manual already names the route, and renaming to `setup-` would break it for no gain — `setup-` in this repo means an interactive wizard you re-run (`omarchy-setup-security-sshd`), while install/remove is a one-shot lifecycle, which is what this is. What grows is a `sunshine` group in `GROUP_DESCRIPTIONS` (`GROUP_DESCRIPTIONS[sunshine]="Sunshine remote desktop hosting"`), exactly as `tailscale` earned its group once it had verbs beyond install: + +- `omarchy-sunshine-pair` — the terminal pairing flow (below). +- `omarchy-sunshine-clients` — list paired clients, unpair one or all (gum choose over `/api/clients/list` and `/api/clients/unpair`). +- `omarchy-sunshine-mode` — `dedicated | mirror | --status`, the display-mode switch (below), with `--status` for menu `checked` guards in the nightlight tradition. +- `omarchy-sunshine-display` — `# omarchy:hidden=true`; the prep-cmd do/undo hook Sunshine calls, never users. +- `omarchy-installed-service-sunshine` — hidden predicate in the `installed` group, mirroring `omarchy-installed-service-tailscale`: package present and unit active. + +All carry `# omarchy:summary=` metadata per `agents/skills/command-metadata.md`, and `omarchy commands --check` keeps them honest. + +### Pairing: a PIN in a terminal, not a certificate warning in a browser + +Sunshine's web admin exists because most distros give it nothing better. Omarchy can do better with two facts: credentials can be set non-interactively (`sunshine --creds `), and pairing is one authenticated call (`POST /api/pin` with `{"pin": ..., "name": ...}`, basic auth). + +- **At install**, before the unit ever starts, the installer generates a random password, runs `sunshine --creds omarchy `, and stores it 0600 under `~/.local/state/omarchy/sunshine/credentials`. The user never invents web credentials; the first-run browser ceremony is gone. The managed config also sets `origin_web_ui_allowed = pc` explicitly so :47990 answers localhost only — the ufw rules already exclude it (only 47984/47989/48010 TCP and the stream UDP range are opened), and now Sunshine itself agrees. +- **`omarchy-sunshine-pair`** is bash + gum: prompt for the PIN Moonlight is displaying and a client name, `curl -sk -u "omarchy:$(` gives Hyprland a real output with no glass attached; wlr-screencopy captures it like any other. Wired into Sunshine's per-app prep-cmd do/undo pair, it turns the stream into a workspace of its own. + +Omarchy manages `~/.config/sunshine/apps.json` with two entries, which are also the mode story: + +- **"Remote Desktop"** (the default `omarchy-sunshine-mode dedicated` target): `prep-cmd` do runs `omarchy-sunshine-display up`, undo runs `omarchy-sunshine-display down`. No detached command — the "app" is the desktop itself, so the session lives exactly as long as the client is connected. +- **"This Screen"** (`mirror`): no prep-cmd; Sunshine captures the focused physical monitor. For showing your desk screen to a conference room, or driving a machine you're also sitting at. + +`omarchy-sunshine-display up` does, in order: + +1. `hyprctl output create headless sunshine` — a fixed, predictable name (custom names are supported; without one Hyprland invents HEADLESS-2, -3, … per session, which no config can target). +2. Size it to the client: Sunshine exports `SUNSHINE_CLIENT_WIDTH`, `SUNSHINE_CLIENT_HEIGHT`, and `SUNSHINE_CLIENT_FPS` into prep-cmds (verified against upstream docs — the names in this plan are exact), so `hyprctl keyword monitor "sunshine,${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT}@${SUNSHINE_CLIENT_FPS},auto,1"` gives the phone a phone-shaped desktop and the MacBook a Retina-shaped one. A runtime `hyprctl keyword`, not a `monitors.lua` edit — this output is ephemeral and must never leak into the user's declared monitor config, and Omarchy's default `hl.monitor({ output = "" ... })` catch-all would otherwise claim it with the wrong scale. +3. Move an empty workspace there and focus it — the next-free workspace number, recorded in `~/.local/state/omarchy/sunshine/` alongside the output name, so `down` knows exactly what it created. The stream opens on a clean desk; the physical monitors keep their workspaces and their user. + +`omarchy-sunshine-display down` (the undo, which Sunshine also runs when the client disconnects uncleanly) reverses it with care for Hyprland's output-removal behavior: when an output disappears, Hyprland evacuates its workspaces onto a surviving monitor — that is the safety net, not the plan. Before `hyprctl output remove sunshine`, `down` moves any windows the remote session opened onto the recorded workspace *number* (which survives the output), so nothing lands scattered across whatever monitor Hyprland picks, and then removes the output. If *no* physical monitor exists — the truly headless box — `down` still removes the output; the workspaces sit in limbo until the next `up` recreates a home for them, which is exactly what happens on the next connection. `up` is also self-healing: a leftover `sunshine` output from a crashed session is removed and recreated rather than trusted. + +Two honest caveats, stated here so the rollout verifies them instead of discovering them: Sunshine selects which output to capture via `output_name`, whose wlr-backend matching semantics (numeric enumeration id vs. name string) must be pinned down on the shipped 2026.516 build — the design wants "capture the output named `sunshine` when it exists, the focused physical monitor otherwise," and if `output_name` can't express that per-app, the fallback is `omarchy-sunshine-mode` rewriting the global `output_name` and restarting the unit on mode switch, which is coarser but deterministic. And the headless-with-FDE case (passphrase prompt before SDDM autologin) is a real limit on "connect to a machine nobody is sitting at"; it goes to open questions, not under the rug. + +### Audio + +Mirror mode changes nothing: Sunshine captures the default sink's monitor and both ends hear the same thing, which is what mirroring means. + +Dedicated mode gets a private sink. `omarchy-sunshine-display up` loads a null sink through PipeWire's pulse layer (`pactl load-module module-null-sink sink_name=sunshine-audio ...`, module id recorded in state), and the managed config sets `virtual_sink = sunshine-audio` so Sunshine switches the default sink to it for the duration of the stream and restores the previous default afterward; `down` unloads the module. The consequence is deliberate and worth stating plainly: during a dedicated-mode stream, the machine's audio belongs to the remote session — apps play into the virtual sink, the stream carries it, and the desk's speakers are silent. On a single-user machine that is the correct reading of "not stealing local audio": the person who is actually using the machine is the one holding the Moonlight client. The alternative — splitting audio per-application between two concurrent users of one seat — is a multi-seat feature PipeWire can theoretically express and no one would maintain. + +### Security + +The existing posture is the good part and stays: TCP 47984/47989/48010 and UDP 5353/47998–48010 opened only to 10/8, 172.16/12, 192.168/16 and `tailscale0`, tagged `omarchy-sunshine`, torn down by comment on removal. On top of that: + +- The install-order coupling gets fixed by extraction: the Sunshine ufw rules move into an idempotent internal helper the install script calls, and `omarchy-install-service-tailscale` finishes by re-running it when Sunshine is installed (and vice versa — Sunshine's installer already handles the Tailscale-first order). Whichever service arrives second, the tailnet rules exist. +- `origin_web_ui_allowed = pc` and no ufw rule for 47990, ever: the admin surface is loopback-only by two independent mechanisms. +- `upnp = off` written explicitly in the managed config. Sunshine links miniupnpc; a remote desktop host must never ask the router to open the internet at it, and the manual says in one sentence why the answer for roaming access is Tailscale, not port forwarding. +- Credentials at `~/.local/state/omarchy/sunshine/credentials`, 0600, passed to curl via config-file/stdin rather than argv where it would show in `ps`. +- The pairing trust model is Sunshine's own client-certificate pinning established during the PIN exchange; Omarchy adds no cert of its own and removes the one place users were trained to click through a certificate warning. + +### Managed configuration, user configuration + +Sunshine's config is the user's, like everything in `~/.config`. Omarchy owns a delimited block in `sunshine.conf` (`capture`, `output_name`, `virtual_sink`, `origin_web_ui_allowed`, `upnp`) written idempotently by the installer and `omarchy-sunshine-mode`, and owns the two shipped entries in `apps.json` by name — regenerated on install, left alone otherwise, so a user who adds a Steam Big Picture entry in the admin UI keeps it. The remove script deletes only what Omarchy wrote, then offers (gum confirm) to purge `~/.config/sunshine/` entirely — pairing state included — since "remove the service" usually means "this machine stops being a host," but must not silently destroy pairings someone means to keep across a reinstall. + +### Menu + +Per `docs/menu.md` conventions, no `aliases` on any new entry: + +- `install.service.sunshine` — `{"icon": ..., "label": "Sunshine", "disabled": "omarchy-pkg-present sunshine", "action": "omarchy-launch-floating-terminal-with-presentation omarchy-install-service-sunshine"}` — the catalog row that should always have existed, dimmed-with-✓ once installed like every Install row. +- `remove.service.sunshine` — the mirror image, `when: omarchy-pkg-present sunshine`, hiding what isn't there to remove. +- `setup.sunshine` — a submenu that only exists when installed (`when: omarchy-pkg-present sunshine`): **Pair Client** (floating terminal → `omarchy-sunshine-pair`), **Clients** (→ `omarchy-sunshine-clients`), **Dedicated Display** / **This Screen** rows with `checked` guards reading `omarchy-sunshine-mode --status` (registered in `GUARD_READERS` since two rows read it, as `menu-guards-test.sh` enforces), and **Admin** (the webapp, demoted from installer-auto-open to a menu row). + +### Client side, and the scope line + +Moonlight is preinstalled, has its window rule, and appears in the launcher; pairing *to* an Omarchy host is this plan's terminal flow plus a PIN. A connection helper (`moonlight-qt stream "Remote Desktop"` wrapped in a picker) is cheap but premature — Moonlight's own grid UI already remembers hosts, and wrapping it before anyone asks is the kind of chrome the dots plan warns about. The manual documents the CLI one-liner for keybinding enthusiasts and stops there. + +Reaching a **Mac or Windows** box *from* Omarchy is a real adjacent gap and explicitly not this plan. It is a different protocol stack (FreeRDP/Remmina toward Windows, nothing good toward macOS but VNC), a different trust model, and a different design center (client-only, no host work). Stapling it on here would dilute both; if demand shows up it's `plans/rdp.md`, and this plan's manual section links Remmina by name so the answer today is at least discoverable. RustDesk stays uninstalled by default for the host-side reasons above. + +### Uninstall symmetry + +`omarchy-remove-service-sunshine`, reworked to undo everything this plan creates: stop and disable the unit; run `omarchy-sunshine-display down` if state shows a live session (output removed, null sink unloaded, workspace evacuated); close the tagged ufw rules; remove the webapp; strip the legacy autostart line; delete `~/.local/state/omarchy/sunshine/`; offer the `~/.config/sunshine/` purge; drop the package. The menu needs no teardown — every entry is guarded on package presence and disappears on its own. A re-install after a remove-without-purge comes back paired, which is the correct reading of the two-step teardown. + +## Rollout + +- **Phase 1 — make what exists correct**: single start path + migration removing the autostart line; credentials at install; `origin_web_ui_allowed`/`upnp`/`capture` in the managed config; terminal pairing (`omarchy-sunshine-pair`, `omarchy-sunshine-clients`); the missing Install/Remove menu rows; ufw helper extraction and the Tailscale cross-call. Shippable alone and already a better product. +- **Phase 2 — the remote desktop**: `omarchy-sunshine-display` do/undo, managed `apps.json`, `omarchy-sunshine-mode`, the audio sink, the `setup.sunshine` menu. Gated on the two verification checkpoints from the design: `output_name` semantics against the shipped build, and disconnect/reconnect behavior under monitor add/remove churn on real hardware. +- **Phase 3 — polish**: `omarchy-sunshine-clients` unpair flows, docs, acceptance coverage. +- **Tests**: shell tests in `test/shell.d/sunshine-test.sh` for the pure logic — managed-config block idempotency, apps.json generation preserving user entries, ufw argument construction, state-file round-trips for `display up`/`down`, mode `--status` output; CLI metadata via `omarchy commands --check`; menu guard wiring via the existing `menu-test.sh`/`menu-guards-test.sh` conventions. Graphical acceptance per `agents/skills/acceptance-tests.md` in the ISO VM: install from the menu, unit active, ports open, `display up` creates and sizes the output and `down` leaves no orphaned windows (screenshot each state), remove leaves no unit/rules/state behind. Actual stream negotiation needs a Moonlight client and stays a manual checklist item in the PR, honestly labeled. +- **Docs**: a new `manual/52-remote-desktop.md` — hosting setup, pairing, the two modes, Tailscale-for-roaming, the FDE/headless caveat, and the Remmina pointer for the Windows-target gap; `manual/26-gaming.md` trims to game streaming and links over; `docs/` gets a short reference on the managed-config block and state files so future migrations know what Omarchy owns. + +## Open questions + +1. **`output_name` semantics on the wlr backend** — id or name, and when Sunshine enumerates outputs relative to prep-cmd execution. The design assumes per-session resolution after `up` runs; if the shipped build disagrees, mode switching falls back to config-rewrite-plus-restart. Phase 2 gates on the answer. +2. **Clipboard sync** — upstream has said no: text-only clipboard sync was proposed and closed as not planned (LizardByte/Sunshine#5384), and Moonlight has no transport for it. An Omarchy-side bridge (wl-clipboard over Tailscale between two Omarchy machines) is buildable but is a new sync daemon with a security surface, helps no Mac/Windows/phone client, and smells like scope creep. Leaning: explicit non-goal in v1, revisit only as its own plan. +3. **Headless boot under full-disk encryption** — the LUKS passphrase blocks the session a monitor-less host needs. TPM auto-unlock or remote unlock is its own security design. Document the limit in v1, or fold a TPM story in? +4. **Does the Sunshine Admin webapp survive** once pairing, clients, and modes have first-party surfaces — expert hatch, or the browser habit this plan exists to end? +5. **Mirror-mode geometry** — when client and physical resolutions disagree, let Moonlight scale (default) or mode-switch the physical monitor to match via the same prep-cmd env vars? Scaling is unobtrusive; mode-switching is what the game-streaming crowd expects. +6. **A bar presence** — dropbox/tailscale-style plugin showing "streaming now" with a disconnect action? Genuinely useful the moment someone streams *from* a machine they also sit at, but plugin work is its own discipline; leaning v2. +7. **A "Log out remote session" guard** — should `down` also close the windows the remote session opened instead of re-homing them, as a privacy stance for shared-desk machines? Leaning no: never destroy user windows automatically. diff --git a/shell/Commons/Util.qml b/shell/Commons/Util.qml index 3d5a1f8e..cacc98d2 100644 --- a/shell/Commons/Util.qml +++ b/shell/Commons/Util.qml @@ -44,6 +44,10 @@ QtObject { return "file://" + String(path).split("/").map(encodeURIComponent).join("/") } + function isVideoPath(path) { + return /\.(mp4|m4v|mov|webm|mkv|avi)$/i.test(String(path || "")) + } + // Single-quote a string for bash. The replace handles embedded single // quotes by closing, escaping, and re-opening the literal. function shellQuote(value) { @@ -54,6 +58,15 @@ QtObject { Quickshell.execDetached(["bash", "-lc", command]) } + // Run an argv vector without a shell interpreting it: the constant `exec "$@"` + // means the args only ever land in positional parameters, which bash expands + // without re-tokenizing — so untrusted data ($(id), a filename) stays literal. + // The login shell (-l) keeps the PATH/session env GUI targets (omasnap, mpv, + // xdg-open) need. Prefer this over execDetached for anything built from input. + function execArgv(argv) { + Quickshell.execDetached(["bash", "-lc", 'exec "$@"', "bash"].concat(argv)) + } + function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value) } diff --git a/shell/README.md b/shell/README.md index e72d02ce..5d464a0d 100644 --- a/shell/README.md +++ b/shell/README.md @@ -1,297 +1,9 @@ # Omarchy shell -`omarchy-shell` is a single long-running [Quickshell](https://quickshell.org/) -instance that hosts the Omarchy desktop. Hyprland autostart launches one shell -per graphical session; everything else — the bar, background switcher, panels, -and overlays — runs **inside** the shell as a plugin. +The Omarchy desktop runs in one long-lived Quickshell process. Its bar, panels, overlays, menus, and services are plugins hosted by `shell.qml`. -Hosting everything inside one shell means: - -- shared services and singletons live once, not once per process -- summoning a panel is an IPC call into a process that is already running, - not a fresh `quickshell -p ...` cold start -- third-party plugins can be loaded from disk without changing any source - code in Omarchy itself - -The runtime layout: - -``` -shell/ - shell.qml entry point (ShellRoot) - services/ - PluginRegistry.qml discovers, validates plugins, looks up enabled state in shell.json - BarWidgetRegistry.qml unified registry for bar widgets (1p + 3p) - plugins/ - bar/ first-party plugins (see plugins/README.md) - image-picker/ - menu/ - notifications/ - panels/ - audio/ - bluetooth/ - monitor/ - network/ - power/ - weather/ - agents/ - services/ - battery/ - idle/ - osd/ - polkit/ -``` - -The plugin discovery path is documented in [plugins/README.md](plugins/README.md). - -## Plugin manifest - -Every plugin ships a `manifest.json` describing what it is and how the -shell should load it. Minimal example: - -```json -{ - "schemaVersion": 1, - "id": "my.org.cool-clock", - "name": "Cool clock", - "version": "1.0.0", - "author": "You", - "description": "A clock that does cool things", - "kinds": ["bar-widget"], - "entryPoints": { "barWidget": "Widget.qml" }, - "barWidget": { - "displayName": "Cool clock", - "category": "Time", - "allowMultiple": false, - "defaultSection": "left", - "defaults": { "format": "HH:mm" }, - "schema": [ - { "key": "format", "type": "string", "label": "Format" } - ] - } -} -``` - -Supported `kinds`: - -| Kind | What it is | -|--------------|--------------------------------------------------------------| -| `bar-widget` | A component that the active bar can drop into a section | -| `panel` | A persistent or summoned floating window (e.g. OSD) | -| `overlay` | A fullscreen overlay (e.g. background switcher) | -| `menu` | A summoned menu surface | -| `service` | A headless singleton, no UI | -| `bar` | A full bar option that can replace the built-in `omarchy.bar` | - -Only one `bar` plugin is active at a time. Missing or invalid selections fall -back to the built-in `omarchy.bar`, so users always have a safe path home. -Panels, overlays, and menus are loaded when summoned. Plugins that need -to outlive a single summon can set `keepLoaded: true` (e.g. the image -picker keeps its overlay window mounted between summons). First-party -services are loaded at startup. - -The full schema lives in `services/PluginRegistry.qml`. - -## Installing a third-party plugin - -A plugin is a **git repo** with a `manifest.json` at its root. Adding one -clones it straight into `~/.config/omarchy/plugins//` (named by the -manifest id); updating is a fast-forward pull of that checkout. - -```bash -omarchy plugin add https://github.com/acme/omarchy-weather.git -omarchy plugin update acme.weather # fetches, shows a diff, fast-forwards -omarchy plugin update # updates every git-managed plugin -omarchy plugin remove acme.weather -``` - -> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns -> you before cloning, plugins land disabled so you can review the code before -> enabling, and updates show a diff of the changes before touching anything. -> Only add repos whose code you are willing to run. - -Each command is **interactive** when run bare in a terminal (gum pickers, -confirmation, a diff to review) and fully **non-interactive** when given -arguments. Pass `--yes` to skip every prompt — this is the path for scripts and -AI agents: - -```bash -omarchy plugin add https://github.com/acme/omarchy-weather.git --enable --yes -omarchy plugin update --yes -``` - -The installer never runs plugin code, install hooks, or sudo — it only clones -files, validates the manifest, and toggles enabled state over shell IPC. Since -an installed plugin is a plain git checkout, anything beyond add/update -(pinning a ref, switching branches) is ordinary git in the plugin directory. - -### Installing by hand - -You can still drop a plugin in without git: - -1. Put it in `~/.config/omarchy/plugins//` with a `manifest.json` - plus the QML referenced from its `entryPoints`. -2. `omarchy-shell shell rescanPlugins`. -3. `omarchy plugin enable `. Bar widgets start in - `barWidget.defaultSection`, or in the center when it is omitted, and can be - moved with `omarchy bar move`; a full bar replaces the one in use. - -The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`, -`omarchy-shell shell enablePlugin '{}'`, and `omarchy-shell shell listPlugins`. -The `omarchy plugin` commands wrap those calls. `omarchy bar move` and -`omarchy bar set` edit the persisted widget layout in `shell.json`. - -To hack on a built-in plugin safely, clone it into user config instead of -editing the built-in source. The complete plugin directory is copied, including -every declared kind and local dependency. A built-in id such as -`omarchy.clock` becomes `.clock` (e.g. `dhh.clock`), with `My Clock` -as its display name. The username prefix keeps shared clones from colliding -with each other or with other plugin authors. - -```bash -omarchy plugin clone omarchy.clock -``` - -Cloning switches from the built-in to the new personal plugin, preserving an -existing bar widget's position and settings. Setup > Plugins > Clone provides -the interactive picker, then opens the new `.*` directory in `$EDITOR`. -Existing shortcuts and shell IPC calls made to the built-in id are routed to -the enabled clone, so cloning does not require changing its callers. Removing -an active clone switches back to its built-in source. -Saving a file anywhere under `~/.config/omarchy/plugins/` reloads plugin code -automatically; `omarchy-shell shell rescanPlugins` remains available to force a reload. - -First-party plugins under `shell/plugins/` are discovered the same way and load -by default. Disabling a non-widget records it in `disabledPlugins[]`; disabling -a widget removes it from the bar layout while leaving its component available -to add again. A full bar has no off state and is replaced by enabling another. - -## IPC contract - -The shell exposes a single `shell` IPC target plus whatever extra targets -individual plugins register (e.g. the bar's `bar` target for refresh -hooks, the image picker's `image-selector` target). `omarchy-menu` uses the -shell target to summon the first-party `omarchy.menu` plugin instead of -running a separate Quickshell instance. - -| Method | Returns | Effect | -|------------------------------------------|---------|-------------------------------------------------------| -| `ping` | `ok` | health check | -| `summon ` | `ok` / `unknown` | load + open a panel/overlay plugin | -| `hide ` | — | close a previously-summoned plugin | -| `toggle ` | — | summon if closed, hide if open | -| `call ` | string | call a method on an already-loaded plugin | -| `rescanPlugins` | — | re-walk plugin dirs and hot-reload plugin code | -| `reloadConfig` | `ok` | reload `~/.config/omarchy/shell.json` | -| `setPluginEnabled ` | `ok` / `unknown` | flip the persisted enabled bit (see note) | -| `listPlugins` | JSON | every discovered plugin, sorted by name | - -Direct invocation: - -``` -quickshell ipc -p $OMARCHY_PATH/shell call shell ping -``` - -Hyprland autostart launches the shell directly with `quickshell -p -$OMARCHY_PATH/shell`. Use `omarchy-restart-shell` to stop every running -instance of that config and launch one fresh shell process. - -A convenience wrapper, [`omarchy-shell`](../bin/omarchy-shell), forwards IPC -calls to the running shell. It does not start the shell. - -``` -omarchy-shell shell ping -omarchy-shell shell toggle omarchy.menu '{"menu":"root"}' -omarchy-shell shell listPlugins -omarchy-shell shell rescanPlugins -``` - -**Note on `setPluginEnabled`:** the `enabled` argument is a string. Only the -literal `"true"` enables the plugin; every other value (including `"True"`, -`"1"`, `"yes"`, or omitted) disables it. This keeps the IPC surface -type-stable across QML's `string`-only IPC arguments. - -## Persisted state - -There is one user config file. Everything that distinguishes your -customization from the shipped defaults lives in it. - -| Path | Owner | Purpose | -|-----------------------------------|----------------|--------------------------------------------------------| -| `~/.config/omarchy/shell.json` | the shell | full layout + per-entry settings + enabled plugin list | -| `~/.config/omarchy/plugins//` | user | drop-in third-party plugin source files | - -The `config/omarchy/shell.json` default config describes the -fresh-install state. When the user has no `shell.json`, the shell uses -the defaults verbatim. Once the user customizes anything, `shell.json` -becomes the authoritative file — we do **not** deep-merge defaults back in. - -### shell.json shape - -```json -{ - "version": 1, - "idle": { - "screensaver": 150, - "lock": 300 - }, - "bar": { - "id": "omarchy.bar", - "position": "top", - "transparent": false, - "centerAnchor": "omarchy.clock", - "layout": { - "left": [ { "id": "omarchy.menu" }, { "id": "omarchy.workspaces" } ], - "center": [ { "id": "omarchy.clock", "format": "HH:mm" } ], - "right": [ - { "id": "omarchy.audio" } - ] - } - }, - "plugins": [] -} -``` - -### Storage rules - -1. **The active bar option is `bar.id`.** Omit it or set it to `omarchy.bar` - to use the built-in bar. Set it to another plugin id whose manifest declares - `kind: "bar"` to replace the full bar. -2. **Every plugin instance is one entry.** Either in `bar.layout.
` - for bar widgets, or in `plugins[]` for panels, overlays, services, - menus, and anything else non-bar. -3. **Settings are inline on the entry.** No `config:` sub-object, no - separate per-plugin settings file, no merge layers. The fields on each - entry are the values the plugin sees. -4. **Built-in widget ids are namespaced.** Use ids such as `omarchy.clock`, - `omarchy.audio`, and `omarchy.network`. The migration rewrites older ids - like `Clock` and `AudioPanel` forward. -5. **Third-party enabled ⇔ present.** A third-party plugin is enabled iff - its id appears somewhere in shell.json. For full bar options, that means - `bar.id`; for bar widgets, plugin enable/disable adds/removes layout entries; - other plugin kinds are enabled the same way. First-party non-bar plugins - are enabled unless listed in `disabledPlugins[]`. -6. **Multiple instances** are allowed when a manifest sets - `allowMultiple: true`. Each instance is independent — e.g. two clock - widgets in different timezones are just two `{"id":"omarchy.clock", "timezone": ...}` - entries with their own values. -7. **Idle timings are top-level.** `idle.screensaver` and `idle.lock` - are seconds since user idle began, so the default lock fires at 300s - even if the 150s screensaver starts first. -8. **`version: 1` is required** at the top level. The shell will fall back - to defaults rather than load an unknown version. - -## Implementation history - -Built up in phases on this branch: - -- Phase 1 — `omarchy-shell phase 1: host the existing bar in a single shell` -- Phase 2 — `omarchy-shell phase 2: plugin registry and bar widget registry` -- Phase 3 — `omarchy-shell phase 3: fold bar-settings into the shell as a panel plugin` -- Phase 4 — `omarchy-shell phase 4: absorb background-switcher as a plugin` -- Phase 5 — `omarchy-shell phase 5: docs, cleanup, and migration crumbs` -- Phase 6 — `omarchy-shell phase 6: reviewer cleanup (path traversal, collision, races)` -- Phase 7 — `omarchy-shell phase 7: replace socket with IpcHandler, rename to image-picker` -- Phase 8a — `omarchy-shell phase 8a: unified shell.json with inline plugin settings` - -Shared services and Pipewire/UPower/Hyprland consolidation are explicitly -out of scope here and deferred to a follow-up after a review pass. +- [Shell reference](../docs/omarchy-shell.md) — plugin manifests, IPC, configuration, and shared UI contracts. +- [Plugin user guide](../manual/32-shell-plugins.md) — install, configure, and clone plugins. +- [First-party plugins](plugins/README.md) — bundled plugin IDs, kinds, and entry points. +- [Shell development guide](../agents/skills/shell-dev.md) — conventions for changing the desktop. +- [Theming reference](../docs/theming.md) — theme tokens, templates, and generated configuration. diff --git a/shell/Ui/BackgroundMedia.qml b/shell/Ui/BackgroundMedia.qml new file mode 100644 index 00000000..aabde8d1 --- /dev/null +++ b/shell/Ui/BackgroundMedia.qml @@ -0,0 +1,38 @@ +import QtQuick +import qs.Commons + +// The shell draws stills only. OWE owns video backgrounds on the desktop, and +// it feeds the lock screen through its own socket. +Item { + id: root + + property string path: "" + property int version: 0 + readonly property var current: imageLoader.item + readonly property bool ready: current ? current.ready : false + readonly property bool video: Util.isVideoPath(path) + // Cache-bust images selected in a running lock session, so a theme switch + // that replaces the file behind an unchanged path shows the new pixels. + readonly property url imageUrl: path && !video ? Util.fileUrl(path) + (version ? "?v=" + version : "") : "" + + Loader { + id: imageLoader + anchors.fill: parent + active: root.path !== "" && !root.video + sourceComponent: imageComponent + } + + Component { + id: imageComponent + + Image { + readonly property bool ready: status === Image.Ready + source: root.imageUrl + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: root.version === 0 + sourceSize.width: root.version > 0 ? width : 0 + sourceSize.height: root.version > 0 ? height : 0 + } + } +} diff --git a/shell/Ui/Button.qml b/shell/Ui/Button.qml index 2c093b4b..2b84577a 100644 --- a/shell/Ui/Button.qml +++ b/shell/Ui/Button.qml @@ -138,6 +138,7 @@ BorderSurface { radius: 0 } contentItem: Text { + textFormat: Text.PlainText text: root.tooltipText color: root.tooltipForeground font.family: root.fontFamily @@ -158,6 +159,7 @@ BorderSurface { spacing: Style.spacing.controlGap Text { + textFormat: Text.PlainText visible: root.iconText !== "" text: root.iconText color: root.selected ? root._selectedColor : root.foreground @@ -177,6 +179,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText visible: root.text !== "" text: root.text color: root.selected ? root._selectedColor : root.foreground diff --git a/shell/Ui/ConfirmDialog.qml b/shell/Ui/ConfirmDialog.qml index ed4f8c98..bc108d97 100644 --- a/shell/Ui/ConfirmDialog.qml +++ b/shell/Ui/ConfirmDialog.qml @@ -69,6 +69,7 @@ Item { Text { id: messageText + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top @@ -105,6 +106,7 @@ Item { radius: 0 Text { + textFormat: Text.PlainText anchors.centerIn: parent text: modelData color: destructive ? (selected ? Color.urgent : root.foreground) : (selected ? root.selectedText : root.foreground) diff --git a/shell/Ui/Dropdown.qml b/shell/Ui/Dropdown.qml index 214a7fe4..58386c9b 100644 --- a/shell/Ui/Dropdown.qml +++ b/shell/Ui/Dropdown.qml @@ -71,6 +71,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -110,6 +111,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -214,6 +216,7 @@ Item { : "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter diff --git a/shell/Ui/MultiSelect.qml b/shell/Ui/MultiSelect.qml index f759b66d..85705779 100644 --- a/shell/Ui/MultiSelect.qml +++ b/shell/Ui/MultiSelect.qml @@ -259,6 +259,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -298,6 +299,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -451,6 +453,7 @@ Item { : Border.controlSpec("normal", root.foreground, root.accent) Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.loadingOptions ? "󰦖" : "󰑐" color: root.foreground @@ -486,6 +489,7 @@ Item { height: popup.height - searchHeader.height - Style.spacing.xxs - 1 Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: resultList.count === 0 text: root.loadingOptions ? "Loading…" : (root.optionsError !== "" ? root.optionsError : root.emptyText) @@ -581,6 +585,7 @@ Item { spacing: Style.spacing.xxs Text { + textFormat: Text.PlainText text: modelData.label color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground font.family: root.fontFamily @@ -589,6 +594,7 @@ Item { width: parent.width } Text { + textFormat: Text.PlainText visible: text !== "" text: modelData.description color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/NumberField.qml b/shell/Ui/NumberField.qml index 24b70f5b..985c9f7e 100644 --- a/shell/Ui/NumberField.qml +++ b/shell/Ui/NumberField.qml @@ -25,6 +25,7 @@ Column { spacing: Style.spacing.md Text { + textFormat: Text.PlainText visible: root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) diff --git a/shell/Ui/OpticalGlyph.qml b/shell/Ui/OpticalGlyph.qml index d446a52c..a8881d49 100644 --- a/shell/Ui/OpticalGlyph.qml +++ b/shell/Ui/OpticalGlyph.qml @@ -25,6 +25,7 @@ Item { Text { id: glyph + textFormat: Text.PlainText // Keep the shared line box and baseline intact. Correcting only the // horizontal painted bounds avoids per-glyph vertical drift. anchors.centerIn: parent diff --git a/shell/Ui/PanelActionButton.qml b/shell/Ui/PanelActionButton.qml index 8a1b10bc..05f7d6be 100644 --- a/shell/Ui/PanelActionButton.qml +++ b/shell/Ui/PanelActionButton.qml @@ -69,6 +69,7 @@ BorderSurface { Behavior on color { ColorAnimation { duration: 60 } } Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.iconText color: root.enabled diff --git a/shell/Ui/PanelHero.qml b/shell/Ui/PanelHero.qml index 7d663f37..4d13cf14 100644 --- a/shell/Ui/PanelHero.qml +++ b/shell/Ui/PanelHero.qml @@ -48,6 +48,7 @@ Item { width: parent.width Text { + textFormat: Text.PlainText visible: root.title !== "" text: root.title width: Math.min(implicitWidth, Math.max(0, parent.width - (detailPill.visible ? detailPill.implicitWidth + Style.space(8) : 0))) @@ -75,6 +76,7 @@ Item { Text { id: detailText + textFormat: Text.PlainText anchors.centerIn: parent text: root.detail color: root.dim @@ -87,6 +89,7 @@ Item { Text { id: metaText + textFormat: Text.PlainText width: parent.width text: root.meta.toUpperCase() visible: text !== "" diff --git a/shell/Ui/PanelSectionHeader.qml b/shell/Ui/PanelSectionHeader.qml index 5559248e..f0d54fb9 100644 --- a/shell/Ui/PanelSectionHeader.qml +++ b/shell/Ui/PanelSectionHeader.qml @@ -11,6 +11,10 @@ Text { property string fontFamily: Style.font.family property real fontSize: Style.font.caption + // Callers bind `text` from outside this file, so the default has to be set + // here. AutoText would let a section title that happens to carry a device or + // network name promote itself to rich text. + textFormat: Text.PlainText color: Qt.darker(foreground, 1.4) font.family: fontFamily font.pixelSize: fontSize diff --git a/shell/Ui/PanelToolTip.qml b/shell/Ui/PanelToolTip.qml index 139b6cf0..90d3133a 100644 --- a/shell/Ui/PanelToolTip.qml +++ b/shell/Ui/PanelToolTip.qml @@ -36,6 +36,7 @@ ToolTip { } contentItem: Text { + textFormat: Text.PlainText text: root.text color: root.panelForeground font.family: root.fontFamily diff --git a/shell/Ui/PluginBarApi.qml b/shell/Ui/PluginBarApi.qml new file mode 100644 index 00000000..03c748b1 --- /dev/null +++ b/shell/Ui/PluginBarApi.qml @@ -0,0 +1,87 @@ +import QtQuick + +// Bar surface exposed to an installed third-party widget. Scalar presentation +// state is mirrored by Bar.qml and operations are delegated through scoped +// callbacks. The facade avoids direct host-Bar injection; it cannot isolate a +// visual child from the parent hierarchy of the QML scene that renders it. +QtObject { + id: api + + required property string pluginId + required property string moduleName + property var shell: null + + property color foreground: "transparent" + property color barForeground: "transparent" + property color background: "transparent" + property color urgent: "transparent" + property string fontFamily: "" + property string position: "top" + property bool vertical: false + property int barSize: 0 + property bool transparent: false + property bool foregroundAnimationEnabled: true + property bool centerSectionRevealHeld: false + property bool _centerHoverRevealSuppressed: false + readonly property bool centerHoverRevealSuppressed: _centerHoverRevealSuppressed + property var activePopout: null + property var clickTargets: [] + property var layoutConfig: ({}) + readonly property var foreignPopoutMarker: ({ foreign: true }) + + property var _showTooltip: null + property var _hideTooltip: null + property var _registerClickTarget: null + property var _unregisterClickTarget: null + property var _requestPopout: null + property var _releasePopout: null + property var _switchPanelFrom: null + property var _targetBelongsToWindow: null + property var _moduleWidgets: null + property var _run: null + property var _setCenterHoverRevealSuppressed: null + + function setCenterHoverRevealSuppressed(value) { + if (_setCenterHoverRevealSuppressed) _setCenterHoverRevealSuppressed(!!value) + } + + function showTooltip(target, text) { + if (_showTooltip) _showTooltip(target, String(text || "")) + } + + function hideTooltip(target) { + if (_hideTooltip) _hideTooltip(target) + } + + function registerClickTarget(target) { + if (_registerClickTarget) _registerClickTarget(target) + } + + function unregisterClickTarget(target) { + if (_unregisterClickTarget) _unregisterClickTarget(target) + } + + function requestPopout(owner) { + if (_requestPopout) _requestPopout(owner) + } + + function releasePopout(owner) { + if (_releasePopout) _releasePopout(owner) + } + + function switchPanelFrom(owner, direction) { + return _switchPanelFrom ? _switchPanelFrom(owner, direction) : false + } + + function targetBelongsToWindow(target, window) { + return _targetBelongsToWindow ? _targetBelongsToWindow(target, window) : false + } + + function moduleWidgets(id) { + return _moduleWidgets ? _moduleWidgets(String(id || "")) : [] + } + + function run(command) { + if (_run) _run(String(command || "")) + } +} diff --git a/shell/Ui/SearchableDropdown.qml b/shell/Ui/SearchableDropdown.qml index 9cf0aa49..7728d86b 100644 --- a/shell/Ui/SearchableDropdown.qml +++ b/shell/Ui/SearchableDropdown.qml @@ -93,6 +93,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -132,6 +133,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -246,6 +248,7 @@ Item { height: popup.height - searchHeader.height - Style.spacing.xxs - 1 Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: resultList.count === 0 text: root.emptyText @@ -313,6 +316,7 @@ Item { spacing: Style.spacing.xxs Text { + textFormat: Text.PlainText text: root.optionLabel(modelData) color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground font.family: root.fontFamily @@ -321,6 +325,7 @@ Item { width: parent.width } Text { + textFormat: Text.PlainText visible: text !== "" text: root.optionDescription(modelData) color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/SpeedTestOverlay.qml b/shell/Ui/SpeedTestOverlay.qml index 1216f348..a8f84c7b 100644 --- a/shell/Ui/SpeedTestOverlay.qml +++ b/shell/Ui/SpeedTestOverlay.qml @@ -130,6 +130,7 @@ PanelWindow { spacing: Style.space(16) Text { + textFormat: Text.PlainText visible: root.title !== "" text: root.title.toUpperCase() color: root.onScrimDim @@ -182,6 +183,7 @@ PanelWindow { } Text { + textFormat: Text.PlainText visible: root.failed text: root.error color: root.onScrimUrgent @@ -368,6 +370,7 @@ PanelWindow { spacing: 0 Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter // Both branches go through the locale: a reading is a measurement, so // its separators follow the system's number conventions rather than the @@ -383,6 +386,7 @@ PanelWindow { } Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter text: root.unit color: root.onScrimDim @@ -394,6 +398,7 @@ PanelWindow { // The 90° gap at the bottom of the scale is where a cluster prints its // unit; here it names the direction. Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom text: dial.label diff --git a/shell/Ui/Toggle.qml b/shell/Ui/Toggle.qml index 26a5cf96..b861fec7 100644 --- a/shell/Ui/Toggle.qml +++ b/shell/Ui/Toggle.qml @@ -69,6 +69,7 @@ BorderSurface { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: root.label color: root.foreground font.family: root.fontFamily @@ -79,6 +80,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText visible: root.description !== "" text: root.description color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/WidgetButton.qml b/shell/Ui/WidgetButton.qml index 02d843ab..87d18050 100644 --- a/shell/Ui/WidgetButton.qml +++ b/shell/Ui/WidgetButton.qml @@ -74,6 +74,7 @@ Item { Text { id: label + textFormat: Text.PlainText visible: root.labelVisible anchors.centerIn: parent text: root.text diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index b25bf5ca..464e25b7 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -3,6 +3,7 @@ module qs.Ui BarIndicator 1.0 BarIndicator.qml BarIconButton 1.0 BarIconButton.qml BarWidget 1.0 BarWidget.qml +BackgroundMedia 1.0 BackgroundMedia.qml BorderOverlay 1.0 BorderOverlay.qml BorderSurface 1.0 BorderSurface.qml Button 1.0 Button.qml @@ -23,6 +24,7 @@ PanelSectionHeader 1.0 PanelSectionHeader.qml PanelSeparator 1.0 PanelSeparator.qml PanelSlider 1.0 PanelSlider.qml PanelToolTip 1.0 PanelToolTip.qml +PluginBarApi 1.0 PluginBarApi.qml PointerMoveGate 1.0 PointerMoveGate.qml ScreenMoveRemap 1.0 ScreenMoveRemap.qml PopupCard 1.0 PopupCard.qml diff --git a/shell/plugins/README.md b/shell/plugins/README.md index d4252515..66cb74e1 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -83,6 +83,9 @@ separate PAM services: `omarchy-lock-password` for password auth and, only when fingerprints are enrolled, `omarchy-lock-fingerprint` for fingerprint auth. It mirrors the previous lock screen field dimensions, colors, blurred wallpaper, placeholder, and Hyprland-driven corners. +The plugin sets `keepLoaded: true` so a plugin hot-reload (for example +an installed bar widget changing on disk) does not destroy the lock +client while Hyprland still holds the session lock. ## Polkit agent diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 6637531a..f4ecdd9a 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -434,6 +434,7 @@ Panel { } Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: heroMarkImage.status !== Image.Ready text: button.text @@ -504,6 +505,7 @@ Panel { Text { id: statusText + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -558,6 +560,7 @@ Panel { Text { id: balanceValue + textFormat: Text.PlainText text: root.balance ? root.formatMoney(root.balance.remaining, root.balance.currency) : "" color: root.balanceAlarming ? root.urgent : root.foreground font.family: root.fontFamily @@ -575,6 +578,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: text !== "" width: parent.width text: root.balanceDetailText(root.balance) @@ -680,6 +684,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: text !== "" width: parent.width topPadding: Style.space(2) @@ -710,6 +715,7 @@ Panel { Text { id: limitLabel + textFormat: Text.PlainText // A model-scoped window is titled after its model, and those names run // long enough to reach the percentage, so the title gives way first. text: limitRow.window ? limitRow.window.title : "" @@ -725,6 +731,7 @@ Panel { Text { id: limitValue + textFormat: Text.PlainText text: limitRow.window && limitRow.window.percent >= 0 ? Math.round(limitRow.window.percent * 100) + "%" : "—" @@ -744,6 +751,7 @@ Panel { Text { id: resetText + textFormat: Text.PlainText width: parent.width text: { var remainingMs = root.resetMsFor(limitRow.window) @@ -798,6 +806,7 @@ Panel { Text { id: dayLabel + textFormat: Text.PlainText text: root.dayLabel(dayRow.day ? dayRow.day.date : "", dayRow.today) color: dayRow.today ? root.foreground : root.dim font.family: root.fontFamily @@ -835,6 +844,7 @@ Panel { Text { id: dayValue + textFormat: Text.PlainText text: usage.formatTokenCount(dayRow.day ? Number(dayRow.day.messageCount || 0) : 0) color: dayRow.today ? root.foreground : root.dim font.family: root.fontFamily @@ -890,6 +900,7 @@ Panel { Text { id: modelName + textFormat: Text.PlainText text: modelRow.row ? modelRow.row.name : "" color: root.foreground font.family: root.fontFamily @@ -904,6 +915,7 @@ Panel { Text { id: modelTokens + textFormat: Text.PlainText text: modelRow.row ? usage.formatTokenCount(modelRow.row.total) : "" color: root.dim font.family: root.fontFamily diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index 21e1b0a9..b3dd7dab 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -26,6 +26,10 @@ Item { property string pendingShellRaw: "" property real revealProgress: 1 + function isVideo(path) { + return Util.isVideoPath(path) + } + function imageUrl(path) { return Util.fileUrl(path) } @@ -50,10 +54,12 @@ Item { revealAnimation.stop() finishingTransition = false - if (instant || !displayedBackground) { + // Video frames are not fed through the image-only reveal stack. Switching + // instantly also avoids decoding two full videos during a transition. + if (instant || !displayedBackground || isVideo(path) || isVideo(displayedBackground)) { oldBackground = "" incomingBackground = "" - displayedBackground = path + displayedBackground = finalPath revealProgress = 1 return } @@ -196,9 +202,8 @@ Item { color: "transparent" // Keep render updates enabled. The background layer has been observed to // lose its committed buffer while parked with updatesEnabled=false, - // leaving a black desktop until omarchy-shell is restarted. The wallpaper - // itself is static, so this favors correctness over a small render-loop - // optimization. + // leaving a black desktop until omarchy-shell is restarted. A still + // wallpaper costs nothing to keep enabled. OWE manages video layers. updatesEnabled: true property bool maskReady: false @@ -218,15 +223,14 @@ Item { WlrLayershell.keyboardFocus: WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore - Image { + // OWE owns video backgrounds. This layer draws stills, and stays empty + // behind a video so OWE's own layer shows through. + BackgroundMedia { id: base anchors.fill: parent - source: root.imageUrl(root.displayedBackground) - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: true - onStatusChanged: { - if (status === Image.Ready && root.finishingTransition) { + path: root.displayedBackground + onReadyChanged: { + if (ready && root.finishingTransition) { root.incomingBackground = "" root.oldBackground = "" root.finishingTransition = false diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 5dcd205f..be9fece4 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -12,20 +12,29 @@ Item { id: root // The omarchy-shell host injects omarchyPath from OMARCHY_PATH. - required property string omarchyPath + property string omarchyPath: Quickshell.env("OMARCHY_PATH") // Injected by the host shell so bar slots can resolve enabled widgets. - required property var barWidgetRegistry + property var barWidgetRegistry: fallbackBarWidgetRegistry + // Read-only registry view for third-party full bars; the built-in bar does + // not otherwise need it, but declaring it keeps clone construction atomic. + property var pluginRegistry: null // Injected by the host shell every time shell.json is reloaded. Holds the // `bar:` subtree: position, centerAnchor, layout. The host owns file IO; // the bar just renders whatever it's handed. The bar font follows the // OS-level fontconfig monospace binding — it is not stored in shell.json. - required property var barConfig + property var barConfig: ({}) // Injected by the host shell. Used for shell-wide actions such as opening // settings and persisting inline widget state. property var shell: null // Manifest for the active bar option. Present for custom bars and useful for // diagnostics; the built-in bar does not otherwise need it. property var manifest: null + QtObject { + id: fallbackBarWidgetRegistry + property var widgets: ({}) + property int revision: 0 + function metadataFor(id) { return null } + } // Mirrors the on-disk `bar-off` flag so the user can hide the bar without // killing the entire shell. Hidden panels stay mapped but park off-screen // without an exclusion zone; updated by the FileView watcher further down. @@ -100,6 +109,223 @@ Item { property var barMoveScreen: null property var clickTargets: [] property var moduleSlots: [] + property var pluginBarApis: ({}) + property var pluginObjectOwners: [] + + Component { + id: pluginBarApiComponent + PluginBarApi { } + } + + function publicLayoutConfig() { + return JSON.parse(JSON.stringify(root.layoutConfig || {})) + } + + function bindPluginBarApi(api) { + if (!api) return + api.foreground = Qt.binding(function() { return root.foreground }) + api.barForeground = Qt.binding(function() { return root.barForeground }) + api.background = Qt.binding(function() { return root.background }) + api.urgent = Qt.binding(function() { return root.urgent }) + api.fontFamily = Qt.binding(function() { return root.fontFamily }) + api.position = Qt.binding(function() { return root.position }) + api.vertical = Qt.binding(function() { return root.vertical }) + api.barSize = Qt.binding(function() { return root.barSize }) + api.transparent = Qt.binding(function() { return root.transparent }) + api.foregroundAnimationEnabled = Qt.binding(function() { return root.foregroundAnimationEnabled }) + api.centerSectionRevealHeld = Qt.binding(function() { return root.centerSectionRevealHeld }) + api._centerHoverRevealSuppressed = Qt.binding(function() { return root.centerHoverRevealSuppressed }) + root.syncPluginBarApiObjects(api) + } + + function syncPluginBarApiObjects(api) { + if (!api) return + api.activePopout = root.pluginOwnsBarObject(api.pluginId, root.activePopout) + ? root.activePopout : (root.activePopout ? api.foreignPopoutMarker : null) + api.clickTargets = root.pluginClickTargets(api.pluginId) + api.layoutConfig = root.publicLayoutConfig() + } + + function pluginObjectRecord(target) { + for (var i = 0; i < pluginObjectOwners.length; i++) { + var record = pluginObjectOwners[i] + if (record && record.target === target) return record + } + return null + } + + function markPluginObject(pluginId, target, role) { + var key = String(pluginId || "") + if (!key || !target) return false + var record = root.pluginObjectRecord(target) + if (record && record.pluginId !== key) return false + var next = [] + for (var i = 0; i < pluginObjectOwners.length; i++) { + var existing = pluginObjectOwners[i] + if (!existing || existing.target !== target) next.push(existing) + } + var updated = record || { target: target, pluginId: key, clickTarget: false, popout: false } + updated[role] = true + next.push(updated) + pluginObjectOwners = next + return true + } + + function unmarkPluginObject(pluginId, target, role) { + var key = String(pluginId || "") + var next = [] + for (var i = 0; i < pluginObjectOwners.length; i++) { + var record = pluginObjectOwners[i] + if (!record || record.target !== target || record.pluginId !== key) { + next.push(record) + continue + } + record[role] = false + if (record.clickTarget || record.popout) next.push(record) + } + pluginObjectOwners = next + } + + function pluginOwnsBarObject(pluginId, target) { + var record = target ? root.pluginObjectRecord(target) : null + return !!record && record.pluginId === String(pluginId || "") + } + + function pluginClickTargets(pluginId) { + var out = [] + for (var i = 0; i < root.clickTargets.length; i++) { + var target = root.clickTargets[i] + if (root.pluginOwnsBarObject(pluginId, target)) out.push(target) + } + return out + } + + function syncAllPluginBarApiObjects() { + for (var id in pluginBarApis) root.syncPluginBarApiObjects(pluginBarApis[id]) + } + + function registerPluginClickTarget(pluginId, target) { + if (!root.markPluginObject(pluginId, target, "clickTarget")) return + root.registerClickTarget(target) + } + + function unregisterPluginClickTarget(pluginId, target) { + if (!root.pluginOwnsBarObject(pluginId, target)) return + root.unregisterClickTarget(target) + root.unmarkPluginObject(pluginId, target, "clickTarget") + } + + function requestPluginPopout(pluginId, owner) { + if (!root.markPluginObject(pluginId, owner, "popout")) return + root.requestPopout(owner) + } + + function releasePluginPopout(pluginId, owner) { + if (!root.pluginOwnsBarObject(pluginId, owner)) return + root.releasePopout(owner) + root.unmarkPluginObject(pluginId, owner, "popout") + } + + function pluginBarApiFor(pluginId, moduleName, registered) { + var key = String(pluginId || "") + if (!key) return null + + var pluginShell = null + if (registered && root.shell && typeof root.shell.pluginShellForId === "function") { + // Only the trusted built-in bar receives ShellRoot and can request a + // service-capable facade for the widget it is instantiating. + pluginShell = root.shell.pluginShellForId(moduleName) + } else if (root.shell && typeof root.shell.pluginShellForBarEntry === "function") { + // Replacement bars receive a service-less entry facade. Giving an + // untrusted bar a generic facade factory would let it retrieve another + // third-party plugin's live service object. + pluginShell = root.shell.pluginShellForBarEntry(key, moduleName) + } + + if (pluginBarApis[key]) { + pluginBarApis[key].shell = pluginShell + return pluginBarApis[key] + } + + var api = pluginBarApiComponent.createObject(null, { + pluginId: key, + moduleName: String(moduleName || ""), + shell: pluginShell, + _showTooltip: function(target, text) { root.showTooltip(target, text) }, + _hideTooltip: function(target) { root.hideTooltip(target) }, + _registerClickTarget: function(target) { root.registerPluginClickTarget(key, target) }, + _unregisterClickTarget: function(target) { root.unregisterPluginClickTarget(key, target) }, + _requestPopout: function(owner) { root.requestPluginPopout(key, owner) }, + _releasePopout: function(owner) { root.releasePluginPopout(key, owner) }, + _switchPanelFrom: function(owner, direction) { return root.switchPanelFrom(owner, direction) }, + _targetBelongsToWindow: function(target, window) { return root.targetBelongsToWindow(target, window) }, + _moduleWidgets: function(requestedId) { + return String(requestedId || "") === String(moduleName || "") + ? root.moduleWidgets(moduleName) : [] + }, + _run: function(command) { root.run(command) }, + _setCenterHoverRevealSuppressed: function(value) { + root.centerHoverRevealSuppressed = !!value + } + }) + if (!api) return null + root.bindPluginBarApi(api) + + var next = ({}) + for (var id in pluginBarApis) next[id] = pluginBarApis[id] + next[key] = api + pluginBarApis = next + return api + } + + function pluginBarApiUsed(pluginId) { + for (var i = 0; i < moduleSlots.length; i++) { + var slot = moduleSlots[i] + if (slot && slot.pluginApiId === pluginId) return true + } + return false + } + + function releasePluginObjects(pluginId) { + var owned = pluginObjectOwners.slice() + for (var i = 0; i < owned.length; i++) { + var record = owned[i] + if (!record || record.pluginId !== pluginId) continue + if (record.clickTarget) root.unregisterClickTarget(record.target) + if (record.popout && root.activePopout === record.target) root.releasePopout(record.target) + } + pluginObjectOwners = pluginObjectOwners.filter(function(record) { + return record && record.pluginId !== pluginId + }) + } + + function prunePluginBarApis() { + var next = ({}) + for (var id in pluginBarApis) { + var api = pluginBarApis[id] + if (root.pluginBarApiUsed(id)) { + next[id] = api + continue + } + root.releasePluginObjects(id) + if (api && typeof api.destroy === "function") api.destroy() + } + pluginBarApis = next + } + + onActivePopoutChanged: syncAllPluginBarApiObjects() + onClickTargetsChanged: syncAllPluginBarApiObjects() + onLayoutConfigChanged: syncAllPluginBarApiObjects() + onModuleSlotsChanged: Qt.callLater(prunePluginBarApis) + + Component.onDestruction: { + for (var id in pluginBarApis) { + root.releasePluginObjects(id) + if (pluginBarApis[id] && typeof pluginBarApis[id].destroy === "function") + pluginBarApis[id].destroy() + } + pluginBarApis = ({}) + } function registerClickTarget(target) { if (!target || clickTargets.indexOf(target) !== -1) return @@ -599,6 +825,10 @@ Item { if (barHoverCount === 0) centerSectionRevealTimer.restart() } + function setCenterHoverRevealSuppressed(value) { + centerHoverRevealSuppressed = !!value + } + Timer { id: centerSectionRevealTimer interval: 120 @@ -948,6 +1178,21 @@ Item { onFileChanged: barHiddenProbe.running = true } + // The directory watch can permanently stop delivering events after flag + // changes land in quick succession, stranding the bar off screen until the + // shell restarts. `omarchy-toggle-bar` nudges this after flipping the flag + // so the probe re-reads it even when the watch has gone quiet. + IpcHandler { + target: "omarchy.bar" + + // Start rather than restart: a probe already in flight was launched by the + // directory watch after the flag flipped, so its answer is current, and + // killing it here can swallow the result entirely. + function syncHidden(): void { + barHiddenProbe.running = true + } + } + Variants { model: Quickshell.screens @@ -1090,6 +1335,7 @@ Item { Text { id: tooltipLabel + textFormat: Text.PlainText anchors.centerIn: parent text: root.tooltipText color: Color.tooltip.text @@ -1532,6 +1778,9 @@ Item { readonly property string moduleName: root.entryId(entry) readonly property var moduleSettings: root.entrySettings(entry) readonly property string customType: root.customModuleType(entry) + readonly property var registryMetadata: root.barWidgetRegistry.metadataFor(root.canonicalWidgetId(moduleName)) + readonly property bool firstParty: registryMetadata && registryMetadata.firstParty === true + readonly property string pluginApiId: registered ? root.canonicalWidgetId(moduleName) : "bar-entry:" + moduleName // Re-evaluate when the registry mutates (Component reference changes, // plugin enabled/disabled, etc.). Reading the `widgets` property creates // the binding dependency — the wrapped function call alone wouldn't. @@ -1750,7 +1999,8 @@ Item { function injectProps() { var target = activeItem if (!target) return - if ("bar" in target) target.bar = root + if ("bar" in target) target.bar = firstParty + ? root : root.pluginBarApiFor(pluginApiId, moduleName, registered) if ("moduleName" in target) target.moduleName = moduleName if ("settings" in target) target.settings = moduleSettings } diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index 5741c5f3..d0934f11 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -14,7 +14,7 @@ the shell for its whole session. ## Customizing -The bar config lives under the `bar:` key of [`~/.config/omarchy/shell.json`](../../README.md#shelljson-shape). Out of the box the shell uses [`config/omarchy/shell.json`](../../../config/omarchy/shell.json). Once you customize anything via the bar gestures, `omarchy bar ...`, or by editing shell.json directly, your file is canonical — there is no deep-merge. +The bar config lives under the `bar:` key of [`~/.config/omarchy/shell.json`](../../../docs/omarchy-shell.md#shelljson). Out of the box the shell uses [`config/omarchy/shell.json`](../../../config/omarchy/shell.json). Once you customize anything via the bar gestures, `omarchy bar ...`, or by editing shell.json directly, your file is canonical — there is no deep-merge. The bar is configured directly on the bar itself: drag empty bar space (or click-and-hold) to move the bar to another screen edge, double-left-click empty center-bar space to toggle transparency, and drag widgets to reorder them. The `omarchy bar position`, `omarchy bar transparent`, `omarchy bar move`, and `omarchy bar set` commands do the same from scripts. Enable or disable widgets with `omarchy plugin enable` and `omarchy plugin disable` (widget ids come from `omarchy plugin list`). @@ -172,9 +172,4 @@ richer popup plugins live in feature directories such as `../panels/audio/`, `manifest.json`. Bar layout ids are namespaced, e.g. `omarchy.audio`, `omarchy.network`, and `omarchy.clock`. -Third-party widgets ship as separate plugins under -`~/.config/omarchy/plugins//` with their own `manifest.json` -declaring `kinds: ["bar-widget"]` and a `barWidget` entry point. See -[../../README.md](../../README.md) for the manifest schema. Rescan, enable, -and place third-party plugins with `omarchy-shell shell rescanPlugins`, -`omarchy plugin enable`, and `omarchy bar move`. +Third-party widgets ship as separate plugins under `~/.config/omarchy/plugins//` with their own `manifest.json` declaring `kinds: ["bar-widget"]` and a `barWidget` entry point. See the [shell reference](../../../docs/omarchy-shell.md#plugin-manifest) for the manifest schema. Rescan, enable, and place third-party plugins with `omarchy-shell shell rescanPlugins`, `omarchy plugin enable`, and `omarchy bar move`. diff --git a/shell/plugins/bar/widgets/ActiveWindow.qml b/shell/plugins/bar/widgets/ActiveWindow.qml index 97ccce8d..ff7e83d8 100644 --- a/shell/plugins/bar/widgets/ActiveWindow.qml +++ b/shell/plugins/bar/widgets/ActiveWindow.qml @@ -29,6 +29,7 @@ BarWidget { Text { id: labelText + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left width: parent.width diff --git a/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 5457d88e..6c3e7206 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -27,6 +27,11 @@ BarWidget { // the widget ships on the bar and stays out of the way until there are two. // An older Hyprland that doesn't report the list keeps showing the label. property bool multipleLayouts: true + // Where the reading sits in the layout list, how long that list is, and every + // keyboard sharing it. A switch moves that set together, so it needs all three. + property int layoutIndex: 0 + property int layoutCount: 0 + property var syncNames: [] // Short language code per layout description ("English (US)": "en"), read from // xkb's own table rather than maintained by hand. property var layoutBriefs: ({}) @@ -63,17 +68,31 @@ BarWidget { } // switchxkblayout is a hyprctl command rather than a dispatcher, so it has to - // be run rather than sent over the dispatch socket. It switches the keyboard - // the last reading spoke for, so a click always advances the device the label - // is describing. Switching the seat together would reach the typed keyboard - // without having to name it, but it would also carry the buttons along, and - // the whole read depends on those staying where they started: once a button - // has been advanced too, a toggle that wraps the keyboard back to the first - // layout leaves the button reading as the furthest along, and the label - // follows the button. + // be run rather than sent over the dispatch socket. + // + // Move every keyboard holding the same layout list, rather than the single one + // the last reading spoke for. Naming one device puts the whole switch behind + // UNTYPED_KEYBOARDS recognising every non-keyboard by name, and that list + // cannot keep up with what a seat carries: vendor hotkey blocks + // (intel-hid-events, dell-wmi-hotkeys), HID consumer controls, and Bluetooth + // AVRCP endpoints from a pair of headphones all arrive holding the seat's + // layout list, and they sort ahead of the keyboard being typed on. The click + // then advances a device nobody types on; that device is now the furthest + // along, so it wins the next reading too, and the label describes it while the + // real keyboard never moved. + // + // An absolute index rather than "next", because "next" advances each device + // from wherever it already sits: a seat that has drifted apart stays drifted + // and merely inverts. One index converges them in a single click, and a seat + // in lockstep is what leaves the reading nothing to disagree about afterwards. + // + // Keyboards given their own kb_layout hold a different list and are left out: + // an index into this list would not mean the same layout to them. function cycleLayout() { - if (!root.keyboardName || !root.bar) return - root.bar.run("hyprctl switchxkblayout " + Util.shellQuote(root.keyboardName) + " next") + if (!root.bar || root.layoutCount < 2 || root.syncNames.length === 0) return + const next = (root.layoutIndex + 1) % root.layoutCount + root.bar.run(root.syncNames.map(name => + "hyprctl switchxkblayout " + Util.shellQuote(name) + " " + next).join("; ")) refreshTimer.restart() } @@ -149,6 +168,14 @@ BarWidget { root.keyboardCount = typed.length root.keyboardName = String(kb.name || "") root.multipleLayouts = kb.layout === undefined || String(kb.layout).indexOf(",") !== -1 + root.layoutIndex = kb.active_layout_index || 0 + root.layoutCount = kb.layout === undefined ? 0 : String(kb.layout).split(",").length + // Buttons and virtual keyboards are included on purpose: they hold the + // same list, and leaving them behind is what lets a reading drift onto + // one of them later. + root.syncNames = listed.filter(k => String(k.layout) === String(kb.layout)) + .map(k => String(k.name || "")) + .filter(name => name !== "") root.layoutFull = kb.active_keymap } } diff --git a/shell/plugins/bar/widgets/Tray.qml b/shell/plugins/bar/widgets/Tray.qml index d0d07f57..650358c1 100644 --- a/shell/plugins/bar/widgets/Tray.qml +++ b/shell/plugins/bar/widgets/Tray.qml @@ -467,6 +467,7 @@ BarWidget { } Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: rowIcon.right anchors.leftMargin: Style.space(10) @@ -577,6 +578,7 @@ BarWidget { } Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: Style.space(28) @@ -681,6 +683,7 @@ BarWidget { } Text { + textFormat: Text.PlainText visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left @@ -709,6 +712,7 @@ BarWidget { } Text { + textFormat: Text.PlainText visible: !menuRow.modelData.isSeparator anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index fde49b3f..da969e65 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -37,7 +37,7 @@ Item { property int cardWidth: Math.min(Style.space(875), panel.width - Style.gapsOut * 2) property int cardHeight: Math.min(Style.space(600), panel.height - Style.gapsOut * 2) property int rowHeight: Math.max(Style.space(50), Style.font.body + Style.font.caption + Style.spacing.rowPaddingX * 2) - property int historyLimit: 300 + property int historyLimit: 500 function open(payloadJson) { root.opened = true @@ -432,6 +432,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -500,6 +501,7 @@ Item { } Text { + textFormat: Text.PlainText width: parent.width - (parent.parent.previewImage.length > 0 ? parent.height + parent.spacing : 0) height: parent.height text: parent.parent.previewText @@ -546,6 +548,7 @@ Item { } Text { + textFormat: Text.PlainText visible: parent.activeRow && !parent.activeRow.previewImage anchors.fill: parent anchors.leftMargin: root.contentMargin @@ -593,6 +596,7 @@ Item { } Text { + textFormat: Text.PlainText text: root.history.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/dev-gallery/GalleryPanel.qml b/shell/plugins/dev-gallery/GalleryPanel.qml index 945b6252..680bdbef 100644 --- a/shell/plugins/dev-gallery/GalleryPanel.qml +++ b/shell/plugins/dev-gallery/GalleryPanel.qml @@ -519,12 +519,14 @@ Item { width: Style.space(140) spacing: Style.space(1) Text { + textFormat: Text.PlainText text: "Style.font." + modelData.key color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: modelData.size + " px" color: Qt.darker(root.foreground, 1.5) font.family: root.fontFamily @@ -534,6 +536,7 @@ Item { Text { id: sampleText + textFormat: Text.PlainText anchors.left: metaCol.right anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -574,6 +577,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.family color: root.foreground font.family: root.fontFamily @@ -587,6 +591,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.resolvedFamily color: root.foreground font.family: root.fontFamily @@ -600,6 +605,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.baseSize + " px" color: root.foreground font.family: root.fontFamily @@ -613,6 +619,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.bar.sizeHorizontal + " px" color: root.foreground font.family: root.fontFamily @@ -626,6 +633,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.bar.sizeVertical + " px" color: root.foreground font.family: root.fontFamily @@ -639,6 +647,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.spacing.scale.toFixed(2) color: root.foreground font.family: root.fontFamily @@ -652,6 +661,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.spacing.panelPadding + " px" color: root.foreground font.family: root.fontFamily @@ -818,6 +828,7 @@ Item { Text { id: csLabel + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -1273,6 +1284,7 @@ Item { } Text { + textFormat: Text.PlainText text: Math.round((demoSlider.dragging ? demoSlider.liveValue : sliderRow.demoVolume) * 100) + "%" color: root.foreground font.family: root.fontFamily diff --git a/shell/plugins/emojis/Emojis.qml b/shell/plugins/emojis/Emojis.qml index cbdf541d..376c382e 100644 --- a/shell/plugins/emojis/Emojis.qml +++ b/shell/plugins/emojis/Emojis.qml @@ -247,6 +247,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -284,6 +285,7 @@ Item { color: hasCursor ? root.selectedBackground : "transparent" Text { + textFormat: Text.PlainText text: parent.emoji font.family: root.fontFamily font.pixelSize: Style.font.display @@ -326,6 +328,7 @@ Item { } Text { + textFormat: Text.PlainText text: "No matches for “" + root.filterText + "”" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index 672a5d16..5c002402 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -545,6 +545,7 @@ Item { Text { id: selectedLabel + textFormat: Text.PlainText visible: root.showLabels anchors.top: carousel.bottom anchors.topMargin: Style.space(16) @@ -561,6 +562,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.filterable && root.filterText anchors.top: selectedLabel.bottom anchors.topMargin: Style.space(8) diff --git a/shell/plugins/image-picker/list.sh b/shell/plugins/image-picker/list.sh index a30e4743..0fb74117 100755 --- a/shell/plugins/image-picker/list.sh +++ b/shell/plugins/image-picker/list.sh @@ -3,12 +3,18 @@ image_dirs=${1:-} cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/image-selector index_file="$cache_dir/index.tsv" +pending_video_file=$(mktemp) mkdir -p "$cache_dir" +trap 'rm -f "$pending_video_file"' EXIT -thumbnail_for() { +is_video_path() { + [[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]] +} + +thumbnail_path_for() { local image="$1" - local signature hash thumbnail legacy_hash + local signature hash signature=$(stat -Lc '%s:%Y' "$image") || return hash=$(awk -F '\t' -v path="$image" -v sig="$signature" '$1 == path && $2 == sig { print $3; exit }' "$index_file" 2>/dev/null) @@ -17,9 +23,58 @@ thumbnail_for() { hash=$(printf '%s\t%s' "$image" "$signature" | md5sum | cut -d ' ' -f 1) fi - thumbnail="$cache_dir/$hash.jpg" + printf '%s/%s.jpg' "$cache_dir" "$hash" +} - if [[ ! -f $thumbnail ]]; then +generate_video_thumbnail() { + local image="$1" + local thumbnail="$2" + local lock="$thumbnail.lock" + local lock_fd + local tmp="$thumbnail.$$.jpg" + + if [[ -d $lock ]] && (( $(date +%s) - $(stat -c '%Y' "$lock" 2>/dev/null || date +%s) > 120 )); then + rmdir "$lock" 2>/dev/null + fi + + exec {lock_fd}>"$lock" || return + flock -w 30 "$lock_fd" || return + rm -f "$thumbnail".*.jpg + + [[ -f $thumbnail ]] && return + + if timeout -k 5 10 ffmpegthumbnailer -i "$image" -o "$tmp" -s 1536 -q 8 {lock_fd}>&-; then + mv -f "$tmp" "$thumbnail" + else + status=$? + rm -f "$tmp" "$thumbnail" + # Remember a rejected video so it costs nothing on the next scan; the key + # covers size and mtime, so a repaired file starts clean. A timeout is + # left to retry: the machine may only have been busy. + (( status == 124 || status == 137 )) || : >"$thumbnail.failed" + return 1 + fi +} + +drain_pending_video_thumbnails() { + local video_jobs + + [[ -s $pending_video_file ]] || return 0 + + video_jobs=$(( $(nproc) / 4 )) + (( video_jobs > 0 )) || video_jobs=1 + export -f generate_video_thumbnail + xargs -a "$pending_video_file" -0 -n 2 -P "$video_jobs" \ + bash -c 'generate_video_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true +} + +thumbnail_for() { + local image="$1" + local thumbnail legacy_hash + + thumbnail=$(thumbnail_path_for "$image") || return + + if [[ ! -f $thumbnail ]] && ! is_video_path "$image"; then # Older on-demand picker code keyed fallback thumbnails by file content. # Keep finding those if a user still has them cached. legacy_hash=$(md5sum "$image" 2>/dev/null | cut -d ' ' -f 1) @@ -28,17 +83,31 @@ thumbnail_for() { if [[ -f $thumbnail ]]; then printf '%s' "$thumbnail" - else + elif ! is_video_path "$image"; then printf '%s' "$image" fi } -while IFS= read -r dir; do - [[ -n $dir && -d $dir ]] || continue - find -L "$dir" -maxdepth 1 -type f \ - \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \ - -print0 2>/dev/null -done <<<"$image_dirs" | sort -z | while IFS= read -r -d '' image; do +mapfile -d '' -t images < <( + while IFS= read -r dir; do + [[ -n $dir && -d $dir ]] || continue + find -L "$dir" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + -print0 2>/dev/null + done <<<"$image_dirs" | sort -z +) + +for image in "${images[@]}"; do + if is_video_path "$image"; then + thumbnail=$(thumbnail_path_for "$image") || continue + [[ -f $thumbnail || -f $thumbnail.failed ]] || printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_video_file" + fi +done + +drain_pending_video_thumbnails + +for image in "${images[@]}"; do thumbnail=$(thumbnail_for "$image") [[ -n $thumbnail ]] || continue printf '%s\t%s\n' "$image" "$thumbnail" diff --git a/shell/plugins/lock/LockFeedSurface.qml b/shell/plugins/lock/LockFeedSurface.qml new file mode 100644 index 00000000..ecd1f7d4 --- /dev/null +++ b/shell/plugins/lock/LockFeedSurface.qml @@ -0,0 +1,7 @@ +import QtQuick +import Owe.LockFeed + +// Loaded through a Loader from LockView, so a system without the module shows +// no lock video instead of losing the whole lock screen. +LockFeed { +} diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index 7b0b0ae0..7084fb0f 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -7,6 +7,7 @@ Item { id: root property string backgroundPath: "" + property string videoPosterPath: "" property int backgroundVersion: 0 property bool fingerprintConfigured: false property bool authenticatingPassword: false @@ -14,6 +15,11 @@ Item { property int failedAttempts: 0 property bool inputEnabled: true property bool loadBackground: true + // A locked session blanks the displays after a few seconds. Nothing is + // visible from then until the user wakes it, so a video must not keep + // decoding through what is usually the longest part of a lock. + property bool displaysBlank: false + property bool powerSaverActive: false property string passwordText: "" property bool syncingPasswordText: false @@ -38,20 +44,14 @@ Item { ? Border.surfaceSpec("lock", "border-error", Color.lock.borderError, root.outlineThickness, "border-alpha") : Border.surfaceSpec("lock", "border-active", Color.lock.borderActive, root.outlineThickness, "border-alpha") + readonly property bool video: Util.isVideoPath(root.backgroundPath) + readonly property bool feedActive: root.video && root.loadBackground && !root.displaysBlank && !root.powerSaverActive + signal submitPassword(string password) signal passwordTextEdited(string password) signal clearFailureRequested() signal wakeRequested() - // Cache-busts the lock background by appending `?v=`. Adding a query - // string keeps Image's loader happy while forcing it to reload when the - // user picks a new background mid-session. - function fileUrl(path) { - if (!path) return "" - var encoded = String(path).split("/").map(encodeURIComponent).join("/") - return "file://" + encoded + "?v=" + backgroundVersion - } - function forcePasswordFocus() { passwordInput.forceActiveFocus() } @@ -90,28 +90,44 @@ Item { anchors.fill: parent color: Color.background - Image { + BackgroundMedia { id: wallpaper + objectName: "lockWallpaper" anchors.fill: parent - source: root.loadBackground ? root.fileUrl(root.backgroundPath) : "" - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: false - sourceSize.width: width - sourceSize.height: height + path: root.loadBackground ? (root.video ? root.videoPosterPath : root.backgroundPath) : "" + version: root.backgroundVersion } MultiEffect { anchors.fill: wallpaper source: wallpaper autoPaddingEnabled: false - blurEnabled: root.loadBackground && wallpaper.status === Image.Ready + blurEnabled: root.loadBackground && wallpaper.ready blur: 1.0 blurMax: 128 blurMultiplier: 1.25 contrast: -0.08 } + // The cached poster stays behind the feed when policy pauses playback, + // the module is unavailable, or a new connection has not received a frame. + Loader { + id: feedLoader + objectName: "lockFeedLoader" + anchors.fill: parent + active: root.feedActive + source: "LockFeedSurface.qml" + visible: status === Loader.Ready + } + + // The feed item cannot be sampled by MultiEffect on every renderer. + // Keep video wallpapers visible and darken them slightly for legibility. + Rectangle { + anchors.fill: feedLoader + visible: root.video + color: "#22000000" + } + MouseArea { anchors.fill: parent hoverEnabled: true @@ -184,6 +200,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.fill: passwordInput text: root.authenticatingPassword ? "Checking…" : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText) visible: passwordInput.text.length === 0 diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index 9ecb1cc0..6e1f703e 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -28,14 +28,25 @@ Item { property string failureMessage: "" property int failedAttempts: 0 property string backgroundPath: "" + property string videoPosterPath: "" property int backgroundVersion: 0 property string lastEvent: "init" property string lastEventAt: "" + property bool displaysBlank: false + // displaysBlank tracks what the lock asked for; Hyprland reports what each + // panel actually did. While a video is on show the two are reconciled, so a + // blank that failed keeps playing and a panel woken behind the lock's back + // (a resume that kept the same outputs) resumes instead of freezing. + property var monitorDpms: ({}) + property bool monitorDpmsKnown: false + readonly property bool videoBackground: Util.isVideoPath(backgroundPath) property bool strandedLock: false property bool strandedLockResolved: false readonly property bool locked: lockRequested || sessionLock.locked || sessionLock.secure readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating + readonly property var batteryService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.battery") : null + readonly property bool powerSaverActive: batteryService ? batteryService.powerSaverOnBattery : false function realScreenCount() { var screens = Quickshell.screens || [] @@ -103,6 +114,16 @@ Item { if (!readlinkProc.running) readlinkProc.running = true } + function refreshPoster() { + if (!root.videoBackground) { + root.videoPosterPath = "" + return + } + if (posterProc.running) return + posterProc.sourcePath = root.backgroundPath + posterProc.running = true + } + function refreshFingerprintStatus() { if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true } @@ -165,14 +186,42 @@ Item { } function runWake() { + root.displaysBlank = false + root.monitorDpmsKnown = false if (!wakeProcess.running) wakeProcess.running = true if (lockRequested) armBlankTimer() } function runBlank() { + root.displaysBlank = true + root.monitorDpmsKnown = false if (!blankProcess.running) blankProcess.running = true } + function screenBlank(screenName) { + var name = String(screenName || "") + if (!monitorDpmsKnown || !(name in monitorDpms)) return displaysBlank + return !monitorDpms[name] + } + + function applyMonitorDpms(text) { + var monitors + try { + monitors = JSON.parse(String(text || "")) + } catch (error) { + return + } + if (!Array.isArray(monitors)) return + + var dpms = {} + for (var i = 0; i < monitors.length; i++) { + var monitor = monitors[i] + if (monitor && monitor.name && !monitor.disabled) dpms[String(monitor.name)] = !!monitor.dpmsStatus + } + monitorDpms = dpms + monitorDpmsKnown = true + } + function submitPassword(value) { var password = String(value || "") if (!lockRequested || authenticatingPassword || password.length === 0) return @@ -269,6 +318,7 @@ Item { id: lockView anchors.fill: parent backgroundPath: root.backgroundPath + videoPosterPath: root.videoPosterPath backgroundVersion: root.backgroundVersion fingerprintConfigured: root.fingerprintConfigured authenticatingPassword: root.authenticatingPassword @@ -276,6 +326,8 @@ Item { failedAttempts: root.failedAttempts inputEnabled: root.lockRequested loadBackground: root.locked + displaysBlank: root.screenBlank(lockSurface.screen ? lockSurface.screen.name : "") + powerSaverActive: root.powerSaverActive passwordText: root.enteredPassword onPasswordTextEdited: function(password) { root.enteredPassword = password } onSubmitPassword: function(password) { root.submitPassword(password) } @@ -299,6 +351,7 @@ Item { LockView { anchors.fill: parent backgroundPath: root.backgroundPath + videoPosterPath: root.videoPosterPath backgroundVersion: root.backgroundVersion fingerprintConfigured: root.fingerprintConfigured authenticatingPassword: false @@ -306,6 +359,7 @@ Item { failedAttempts: 0 inputEnabled: false loadBackground: root.previewVisible + powerSaverActive: root.powerSaverActive passwordText: "" } @@ -368,9 +422,25 @@ Item { onStreamFinished: { var next = String(text || "").trim() if (next !== root.backgroundPath) { + root.videoPosterPath = "" root.backgroundPath = next root.backgroundVersion += 1 } + root.refreshPoster() + } + } + } + + Process { + id: posterProc + property string sourcePath: "" + command: ["bash", Quickshell.env("OMARCHY_PATH") + "/shell/plugins/lock/poster.sh", sourcePath] + stdout: StdioCollector { id: posterOutput; waitForEnd: true } + onExited: function(exitCode) { + if (sourcePath !== root.backgroundPath) { + root.refreshPoster() + } else { + root.videoPosterPath = exitCode === 0 ? String(posterOutput.text || "").trim() : "" } } } @@ -411,6 +481,31 @@ Item { command: ["bash", "-c", "omarchy-brightness-keyboard off; omarchy-brightness-display off"] } + // Quickshell exposes no DPMS signal, so the panel state is polled while a + // video is the locked wallpaper. A wake or blank request drops the last + // answer, so its optimistic state applies until the next poll confirms it. + Process { + id: monitorDpmsProcess + command: ["hyprctl", "monitors", "-j"] + stdout: StdioCollector { + onStreamFinished: root.applyMonitorDpms(text) + } + } + + Timer { + id: monitorDpmsTimer + interval: 3000 + repeat: true + triggeredOnStart: true + running: root.locked && root.videoBackground + onTriggered: { + if (!monitorDpmsProcess.running) monitorDpmsProcess.running = true + } + onRunningChanged: { + if (!running) root.monitorDpmsKnown = false + } + } + Timer { id: idleBlankTimer interval: 5000 @@ -467,6 +562,10 @@ Item { Connections { target: Quickshell function onScreensChanged() { + // A panel coming back is a display turning on that runWake did not ask + // for, so the blank state has to be given up here or a visible lock + // wallpaper stays frozen until the next keypress. + root.displaysBlank = false root.requestSessionLock() // A monitor still coming up has no workspace, so cannot answer yet. diff --git a/shell/plugins/lock/manifest.json b/shell/plugins/lock/manifest.json index 87acd939..30809608 100644 --- a/shell/plugins/lock/manifest.json +++ b/shell/plugins/lock/manifest.json @@ -5,6 +5,11 @@ "version": "1.0.0", "author": "Omarchy", "description": "Quickshell session lock with separate password and fingerprint PAM flows.", + "omarchy": { + "capabilities": [ + "authentication" + ] + }, "kinds": [ "service" ], diff --git a/shell/plugins/lock/poster.sh b/shell/plugins/lock/poster.sh new file mode 100755 index 00000000..836d01f0 --- /dev/null +++ b/shell/plugins/lock/poster.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# One cached frame shared by all lock outputs, including when OWE is paused. +set -euo pipefail + +source_path=$1 +cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/lock-poster" +signature=$(stat -Lc '%s:%y:%z' "$source_path") +key=$(printf '%s\n%s' "$source_path" "$signature" | sha256sum | cut -d ' ' -f 1) +poster="$cache_dir/poster-$key.jpg" +mkdir -p "$cache_dir" +exec {lock_fd}>"$cache_dir/.lock" +flock -w 10 "$lock_fd" + +if [[ ! -s $poster ]]; then + temporary=$(mktemp "$cache_dir/.poster-XXXXXX.jpg") + trap 'rm -f "$temporary"' EXIT + timeout -k 1 5 ffmpegthumbnailer -i "$source_path" -o "$temporary" -s 1920 -q 8 {lock_fd}>&- + [[ -s $temporary ]] + mv -f "$temporary" "$poster" + find "$cache_dir" -maxdepth 1 -type f -name 'poster-*.jpg' ! -name "poster-$key.jpg" -delete +fi + +printf '%s\n' "$poster" diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index eeaf2e25..aa879c18 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -1199,6 +1199,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -1287,6 +1288,7 @@ Item { Text { id: iconText + textFormat: Text.PlainText visible: row.hasIcon && !row.isApp text: row.icon color: row.hasCursor ? root.selectedText : root.foreground @@ -1328,6 +1330,7 @@ Item { Text { id: labelText + textFormat: Text.PlainText width: parent.width text: row.label color: row.hasCursor ? root.selectedText : root.foreground @@ -1338,6 +1341,7 @@ Item { } Text { + textFormat: Text.PlainText width: parent.width text: row.detail visible: (root.filterText || row.kind === "dmenu") && row.detail.length > 0 @@ -1358,6 +1362,7 @@ Item { spacing: 0 Text { + textFormat: Text.PlainText visible: false text: row.childCount color: root.foreground @@ -1368,6 +1373,7 @@ Item { } Text { + textFormat: Text.PlainText text: row.kind === "menu" || row.kind === "link" ? "›" : "" color: row.hasCursor ? root.selectedText : root.foreground opacity: row.kind === "menu" || row.kind === "link" ? 0.36 : 0 @@ -1452,6 +1458,7 @@ Item { } Text { + textFormat: Text.PlainText text: root.filterText ? "No matches for “" + root.filterText + "”" : "Nothing here yet" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 1d2ebd43..b5a6f3ea 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -5,8 +5,92 @@ function isChromiumDerived(app, appIcon) { source.indexOf("opera") >= 0 } +// True when a `<...>` run is an image tag, so the name is read the way Qt's +// parser reads it: after the `<`, the leading run of letters and digits. +// +// Skip everything up to that run rather than matching the separator, because +// there is no JavaScript expression for what Qt skips. QQuickStyledText calls +// skipSpace(), which is QChar::isSpace(), and that set is not `\s`: Qt counts +// U+0085 NEL and `\s` does not, while `\s` counts U+FEFF and Qt does not. A +// name read with `\s` therefore misses a tag written as `<`, U+0085, `img`: +// Qt skips the NEL, reads `img` and issues the GET, while the regex finds no +// name at all and the tag is kept. Measured against Qt 6.11.2. +// +// Over-skipping is the safe direction. It can only classify more runs as +// images, and dropping a run never manufactures a tag: a dropped run joins two +// stretches of text that each contain no `<`. +function isImageTag(tag) { + var name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag) + return !!name && name[1].toLowerCase() === "img" +} + +// The body renders as StyledText so notifications can use the markup the +// body-markup capability advertises (see Service.qml). StyledText honours +// , and a remote src makes the shell issue an unauthenticated GET +// with no user action, so image tags go before the renderer sees them. +// +// Work in whole tags, never in substrings of one. A `<` opens a tag that runs +// to the next `>`, nested `<` and all, and only a tag whose own name is `img` +// is dropped. +// +// That is the conservative bound, not Qt's exact one: Qt lets a `>` inside a +// quoted attribute value pass without closing the tag, so a Qt tag can be +// longer than the run taken here. Do not "correct" this to match Qt. Taking +// the shorter run only ever splits one Qt tag into several, and a split can +// only expose an `` through. +// +// Deleting a substring is what makes a naive `/]*>/g` unsafe. Given +// +// g src="http://a/beacon.png"> +// +// Qt reads ONE malformed tag named `im` and renders nothing, but removing the +// inner match closes the surviving halves up into `` +// — a live tag the input never contained. The stripper would be manufacturing +// the very thing it exists to remove. +// +// Because every `<` opens a tag, the text between tags never contains one, so +// dropping a tag cannot splice its neighbours into a new one. That makes a +// single pass sufficient, with no re-scanning and no input bound to police. +function stripImageTags(text) { + var out = "" + var i = 0 + + while (i < text.length) { + var open = text.indexOf("<", i) + if (open === -1) { + out += text.slice(i) + break + } + + out += text.slice(i, open) + + // An unterminated tag at the end of the string still reaches the renderer, + // which closes it itself, so treat the remainder as one tag. + var close = text.indexOf(">", open) + var tag = close === -1 ? text.slice(open) : text.slice(open, close + 1) + + if (!isImageTag(tag)) out += tag + i = close === -1 ? text.length : close + 1 + } + + return out +} + +// What the card renders, and the last thing to touch the string before Qt parses +// it. The newline rewrite belongs here rather than in the card because it inserts +// `
` into text stripImageTags chose to KEEP, and a kept tag may hold a `<` of +// its own: `` is one tag named `x` to both the +// stripper and Qt, until the rewrite splits it into `` and a live image tag +// the input never contained. Measured against Qt 6.11.2 — the rewritten form +// fetches, the original does not. So strip again after, and what Qt parses is what +// was checked last. +function styledBody(body, app, appIcon) { + return stripImageTags(sanitizeBody(body, app, appIcon).replace(/\r\n|\r|\n/g, "
")) +} + function sanitizeBody(body, app, appIcon) { - var text = String(body || "").replace(/]*>/gi, "") + var text = stripImageTags(String(body || "")) if (!isChromiumDerived(app, appIcon)) return text return text @@ -57,14 +141,39 @@ function glyphFromHints(hints) { return stringHint(hints, "omarchy-glyph") } -// Shell command to run when the card is clicked, sent by -// omarchy-notification-send --exec. Carrying the action as data means it -// travels with the popup through the persistence files, so a toast restored -// after a shell restart clicks through exactly like a live one. A libnotify -// action can't: its sender is still waiting on an id from a server generation -// that no longer exists. -function execFromHints(hints) { - return stringHint(hints, "omarchy-exec") +// The click action: a JSON argv string from omarchy-notification-send +// --exec. Carried as data so a toast restored after a shell restart stays +// clickable (a libnotify action can't — its sender is gone). Run via +// Util.execArgv as bash positional parameters, never a shell string, so +// attacker-controlled values (a title, a filename) can't become commands. +function execArgvFromHints(hints) { + return stringHint(hints, "omarchy-exec-argv") +} + +// Validate a persisted omarchy-exec-argv into a runnable argv, or null. This is +// a STRUCTURAL check only: it fails closed on a malformed hint (non-array, a +// non-string or empty program, or a leading-dash program that argv would read as +// an option). It does not judge intent — a well-formed ["bash","-c",…] is +// accepted. WHICH senders may set this hint is a separate boundary: any +// session-bus process can, by the freedesktop protocol's design (see +// docs/notifications.md), which is equivalent to same-uid code execution. +function parseExecArgv(value) { + var text = String(value || "") + if (!text) return null + + var parsed + try { + parsed = JSON.parse(text) + } catch (e) { + return null + } + + if (!Array.isArray(parsed) || parsed.length === 0) return null + for (var i = 0; i < parsed.length; i++) { + if (typeof parsed[i] !== "string") return null + } + if (!parsed[0] || parsed[0].charAt(0) === "-") return null + return parsed } function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) { @@ -85,7 +194,7 @@ function snapshotOf(notification, timestamp) { body: n.body || "", image: n.image || "", glyph: glyphFromHints(n.hints), - exec: execFromHints(n.hints), + execArgv: execArgvFromHints(n.hints), urgency: n.urgency, expireTimeout: expireTimeout, timestamp: timestamp === undefined ? Date.now() : timestamp @@ -94,7 +203,7 @@ function snapshotOf(notification, timestamp) { // Everything the popup card draws, and therefore everything an in-place // update has to write through to the row and its file. -var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "exec", "urgency", "expireTimeout"] +var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "urgency", "expireTimeout"] function popupRoles() { return POPUP_ROLES @@ -136,7 +245,7 @@ function historyEntry(value, normalUrgency) { body: e.body || "", image: e.image || "", glyph: e.glyph || "", - exec: e.exec || "", + execArgv: e.execArgv || "", urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency, expireTimeout: 0, timestamp: e.timestamp || 0 @@ -341,12 +450,14 @@ if (typeof module !== "undefined") { module.exports = { isChromiumDerived: isChromiumDerived, sanitizeBody: sanitizeBody, + styledBody: styledBody, summaryStartsWithGlyph: summaryStartsWithGlyph, shouldBypassDnd: shouldBypassDnd, isEphemeralApp: isEphemeralApp, stringHint: stringHint, glyphFromHints: glyphFromHints, - execFromHints: execFromHints, + execArgvFromHints: execArgvFromHints, + parseExecArgv: parseExecArgv, shouldRenderCompactGlyph: shouldRenderCompactGlyph, snapshotOf: snapshotOf, popupRoles: popupRoles, diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index 88ad0544..0122310e 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -353,18 +353,19 @@ Item { } // Run the popup's click action, then dismiss. Omarchy's own toasts carry the - // action as a command in the `exec` role (see execFromHints), which the - // persistence files preserve, so restored toasts stay clickable. Third-party - // clients register a libnotify action under the canonical identifier - // "default" instead; that one only works while the sender is still live. + // action as an argv vector in the `execArgv` role (see execArgvFromHints), + // which the persistence files preserve, so restored toasts stay clickable. + // Third-party clients register a libnotify action under the canonical + // identifier "default" instead; that one only works while the sender is live. function invokePopupDefault(index) { if (index < 0 || index >= popupModel.count) return var entry = popupModel.get(index) - var command = entry ? String(entry.exec || "") : "" - if (command) { - // Detached so the launched command outlives the shell process, which the - // installer toasts depend on: they restart the shell as their first act. - Util.execDetached(command) + + // Run the argv (via Util.execArgv, no shell interpretation). Detached so it + // outlives the shell, which installer toasts depend on: they restart it. + var argv = NotificationLogic.parseExecArgv(entry ? entry.execArgv : "") + if (argv) { + Util.execArgv(argv) dismissPopup(index) return } @@ -665,7 +666,7 @@ Item { body: row.body, image: row.image, glyph: row.glyph || "", - exec: row.exec || "", + execArgv: row.execArgv || "", urgency: row.urgency, timestamp: row.timestamp }, imagesDir).entry) @@ -689,7 +690,7 @@ Item { body: "", image: "", glyph: "󰂚", - exec: "", + execArgv: "", urgency: NotificationUrgency.Low, expireTimeout: 0, timestamp: Date.now() diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index cf88f23e..64e3870b 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -44,7 +44,7 @@ BorderSurface { readonly property bool singleLineToast: sanitizedBody.length === 0 readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph readonly property string sanitizedBody: sanitizeBody(body) - readonly property string styledBody: sanitizedBody.replace(/\r\n|\r|\n/g, "
") + readonly property string styledBody: NotificationLogic.styledBody(body, app, appIcon) readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4) readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15) @@ -133,6 +133,7 @@ BorderSurface { // Glyph fallback (Nerd Font character) when no image icon is // available. Used by omarchy-notification-send's `-g` flag. Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: root.hasGlyph && smallIconImage.status !== Image.Ready text: root.glyph @@ -143,6 +144,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText Layout.alignment: Qt.AlignVCenter visible: root.compactGlyph text: root.glyph @@ -159,6 +161,11 @@ BorderSurface { spacing: Style.space(2) Text { + // The spec defines the summary as a single line of plain text, so + // AutoText could only ever promote a hostile string to rich text. + // The body below is StyledText on purpose — see Service.qml's + // bodyMarkupSupported — and is stripped in NotificationLogic. + textFormat: Text.PlainText Layout.fillWidth: true visible: root.summary.length > 0 text: root.summary diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index abf53e22..581bfa3c 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -159,6 +159,7 @@ Item { width: root.iconWidth height: parent.height Text { + textFormat: Text.PlainText // Sit the glyph's ink flush in the column, centered when the // column is wider than this particular glyph. x: Math.round((root.iconWidth - root.iconInkWidth) / 2 - iconMetrics.tightBoundingRect.x) @@ -186,6 +187,7 @@ Item { } } Text { + textFormat: Text.PlainText visible: root.message !== "" width: root.hasProgress ? root.valueWidth : root.messageWidth // The readout hugs the card edge so a short percentage doesn't leave diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index f8a86c6f..26d0c58d 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -711,6 +711,7 @@ Panel { // Status only — the switch owns muting, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText text: root.outputIcon() color: root.bar.foreground font.family: root.bar.fontFamily @@ -761,6 +762,7 @@ Panel { Text { id: heroLabel + textFormat: Text.PlainText text: root.outputVolumeName( outputSlider.dragging ? outputSlider.liveValue : root.outputVolume, root.outputMuted @@ -800,6 +802,7 @@ Panel { Text { id: outputPercent + textFormat: Text.PlainText text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -886,6 +889,7 @@ Panel { Text { id: microphonePercent + textFormat: Text.PlainText text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -1030,6 +1034,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: root.sinkGlyph(sinkRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1040,6 +1045,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.nodeLabel(sinkRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1089,6 +1095,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: root.sourceGlyph(sourceRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1099,6 +1106,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.nodeLabel(sourceRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1159,6 +1167,7 @@ Panel { Text { id: streamMuteIcon + textFormat: Text.PlainText text: streamRow.streamMuted ? "󰝟" : "󰕾" color: root.bar.foreground font.family: root.bar.fontFamily @@ -1179,6 +1188,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.streamLabel(streamRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1191,6 +1201,7 @@ Panel { Text { id: streamPct + textFormat: Text.PlainText text: Math.round(streamRow.streamVolume * 100) + "%" color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index 343357b4..b0078f84 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -698,6 +698,7 @@ Panel { // Status only — the switch owns toggling, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: root.icon @@ -748,6 +749,7 @@ Panel { Text { id: heroStatus + textFormat: Text.PlainText text: root.heroStatusText.toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -863,6 +865,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: root.connectedDevices.length === 0 && root.scrollRows.length === 0 text: !root.adapter ? "No Bluetooth adapter" : !root.adapter.enabled ? "Turn Bluetooth on to scan" @@ -971,6 +974,7 @@ Panel { Text { id: deviceIcon + textFormat: Text.PlainText text: row.isConnected ? "󰂱" : "󰂯" color: row.statusColor font.family: root.bar.fontFamily @@ -989,6 +993,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: root.deviceLabel(row.dev) || "Device" color: root.bar.foreground font.family: root.bar.fontFamily @@ -997,6 +1002,7 @@ Panel { width: parent.width } Text { + textFormat: Text.PlainText visible: row.statusText !== "" text: row.statusText color: row.statusColor diff --git a/shell/plugins/panels/clock/BarWidget.qml b/shell/plugins/panels/clock/BarWidget.qml index e33486e5..61cf0bf5 100644 --- a/shell/plugins/panels/clock/BarWidget.qml +++ b/shell/plugins/panels/clock/BarWidget.qml @@ -153,6 +153,7 @@ BarWidget { fixedHeight: root.vertical ? root.verticalLines.length * Style.bar.iconSlot : -1 horizontalMargin: 8.75 verticalPadding: 8.75 + tooltipText: "Right-click to toggle format" onPressed: function(b) { if (b === Qt.RightButton) root.cycleFormat() diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index f0dff3ae..ec3990e2 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -119,7 +119,9 @@ Panel { // Summoning by hotkey moves no pointer, so a hover the bar was still // holding must not keep the center indicators revealed behind the panel. function setCenterHoverRevealSuppressed(value) { - if (root.bar && "centerHoverRevealSuppressed" in root.bar) + if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function") + root.bar.setCenterHoverRevealSuppressed(value) + else if (root.bar && "centerHoverRevealSuppressed" in root.bar) root.bar.centerHoverRevealSuppressed = value } @@ -311,6 +313,7 @@ Panel { Text { id: heroDate + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: Qt.formatDate(root.today, "MMMM d") color: heroMouse.containsMouse @@ -413,6 +416,7 @@ Panel { Text { id: yearLabel + textFormat: Text.PlainText visible: !root.editingLife anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter @@ -425,6 +429,7 @@ Panel { Text { id: yearPercent + textFormat: Text.PlainText visible: !root.editingLife anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -485,6 +490,7 @@ Panel { Text { id: lifePercent + textFormat: Text.PlainText anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.lifeDonePercent + "%" @@ -608,6 +614,7 @@ Panel { model: root.weekdays Text { + textFormat: Text.PlainText required property var modelData width: root.cellWidth height: Style.space(16) @@ -631,6 +638,7 @@ Panel { spacing: root.cellSpacing Text { + textFormat: Text.PlainText width: root.weekColumnWidth height: root.cellHeight horizontalAlignment: Text.AlignHCenter @@ -662,6 +670,7 @@ Panel { border.color: Style.normalBorderFor(root.contentForeground, Color.accent) Text { + textFormat: Text.PlainText anchors.centerIn: parent text: modelData.day color: modelData.inMonth @@ -707,6 +716,7 @@ Panel { Text { id: monthLabel + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter // Fixed width so the chevrons hold still between a diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index f1dc2301..515b43ce 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -281,6 +281,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: dropbox.actionStatus !== "" || dropbox.lastError !== "" width: parent.width text: dropbox.actionStatus !== "" ? dropbox.actionStatus : dropbox.lastError @@ -421,6 +422,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: dropbox.installed ? "Login to Dropbox" : "Dropbox CLI is not installed" color: root.foreground @@ -430,6 +432,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: dropbox.installed ? "Start the authentication flow" : "Install Dropbox from the service menu" color: root.dim @@ -478,6 +481,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: Model.fileGlyph(fileRow.fileName) color: root.foreground font.family: root.fontFamily @@ -491,6 +495,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: fileRow.fileName color: root.foreground @@ -500,6 +505,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: Model.fileMeta(fileRow.file) color: root.dim @@ -524,6 +530,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.foreground opacity: 0.6 font.family: root.fontFamily @@ -531,6 +538,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index 1753906e..bec38820 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -531,6 +531,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText text: root.displays.length > 1 ? "󰍺" : "󰍹" color: root.bar.foreground font.family: root.bar.fontFamily @@ -559,6 +560,7 @@ Panel { Text { id: heroLabel + textFormat: Text.PlainText text: { if (root.brightnessAvailable) { return root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase() @@ -602,6 +604,7 @@ Panel { Text { id: brightnessPercent + textFormat: Text.PlainText text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -674,6 +677,7 @@ Panel { Text { id: textSizePx + textFormat: Text.PlainText text: (textSizeSlider.dragging ? root.textSizeStops[Math.round(textSizeSlider.liveValue)] : root.displayedTextPx()) + "px" @@ -747,6 +751,7 @@ Panel { // focused one. Text { id: scaleMonitor + textFormat: Text.PlainText text: root.focusedMonitor // Only worth naming when more than one display is in play. visible: root.focusedMonitor !== "" && root.enabledDisplayCount > 1 @@ -887,6 +892,7 @@ Panel { } Text { + textFormat: Text.PlainText text: monitorRow.display.name + (monitorRow.display.focused ? " · focused" : "") color: root.bar.foreground font.family: root.bar.fontFamily @@ -897,6 +903,7 @@ Panel { } Text { + textFormat: Text.PlainText text: monitorRow.display.enabled ? "󰄬" : "" color: root.bar.foreground font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index b4c84c68..5ed2c0d6 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -14,9 +14,25 @@ function wifiIconFor(strength) { return icons[index] } -function connectionIcon(kind, signalStrength) { - if (kind === "wifi") return wifiIconFor(signalStrength) - if (kind === "ethernet") return "󰈀" +// A known plain-HTTP endpoint lets the network redirect the browser to its +// login page. Never execute or automatically open an untrusted Location header. +var captivePortalUrl = "http://ping.archlinux.org/nm-check.txt" + +function connectivityState(kind, connectivity, states, checksEnabled) { + if (kind === "disconnected") return "none" + // Ignore stale cached results when the operator has disabled probing. + if (!checksEnabled) return "unknown" + if (connectivity === states.Portal) return "portal" + if (connectivity === states.Limited) return "limited" + if (connectivity === states.Full) return "full" + if (connectivity === states.None) return "none" + return "unknown" +} + +function connectionIcon(kind, signalStrength, connectivity) { + var restricted = connectivity === "portal" || connectivity === "limited" + if (kind === "wifi") return restricted ? "󰤩" : wifiIconFor(signalStrength) + if (kind === "ethernet") return restricted ? "󰈂" : "󰈀" return "󰤮" } @@ -352,6 +368,8 @@ if (typeof module !== "undefined") { parseNetworkStatus: parseNetworkStatus, wifiIconFor: wifiIconFor, connectionIcon: connectionIcon, + connectivityState: connectivityState, + captivePortalUrl: captivePortalUrl, formatHeaderSpeed: formatHeaderSpeed, formatHeaderFreq: formatHeaderFreq, headerDetail: headerDetail, diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index dea1d280..97729dcc 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -119,9 +119,9 @@ Panel { property bool cursorActive: false // Keyboard focus zone for the panel. j/k crosses row boundaries: - // header actions ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move + // header actions ⇄ portal ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move // within header actions, band pills, or DNS providers. - property string focusSection: "dns" // "header" | "band" | "dns" | "wifi" + property string focusSection: "dns" // "header" | "portal" | "band" | "dns" | "wifi" property int headerIndex: 0 readonly property bool canDisconnect: !!connectedWifiNetwork readonly property bool headerHasDisconnect: false @@ -220,6 +220,8 @@ Panel { // network target; both cards are their own plugins now. function showQr() { root.summonWifiQr(true) } function speedTest() { root.summonSpeedTest() } + function openCaptivePortal() { root.openCaptivePortal() } + function checkConnectivity() { root.checkConnectivity() } } function activateHeader() { @@ -322,11 +324,11 @@ Panel { refresh(true) selectedIndex = wifiNetworks.length > 0 ? 0 : -1 wifiActionFocused = false - focusSection = wifiNetworks.length > 0 ? "wifi" : "dns" + focusSection = hasCaptivePortal ? "portal" : (wifiNetworks.length > 0 ? "wifi" : "dns") var idx = dnsProviders.indexOf(dnsProvider) dnsIndex = idx >= 0 ? idx : 0 syncBandIndex() - cursorActive = false + cursorActive = hasCaptivePortal } else { // Drop a restart armed by this open: without it a close/reopen inside // the 100ms window reuses the running timer and re-enables the scanner @@ -450,7 +452,59 @@ Panel { Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(value) + " | wl-copy"]) } - readonly property string icon: Model.connectionIcon(kind, signalStrength) + // NetworkManager performs the HTTP probe (including unexpected page bodies, + // not just redirects). Consume its native notifications rather than running + // a second curl loop or mistaking an ordinary timeout for a captive portal. + readonly property bool connectivityChecksEnabled: networkManagerAvailable + && Networking.canCheckConnectivity && Networking.connectivityCheckEnabled + readonly property string connectivity: Model.connectivityState(kind, Networking.connectivity, { + Portal: NetworkConnectivity.Portal, Limited: NetworkConnectivity.Limited, + Full: NetworkConnectivity.Full, None: NetworkConnectivity.None + }, connectivityChecksEnabled) + readonly property bool hasCaptivePortal: connectivity === "portal" + readonly property bool restricted: hasCaptivePortal || connectivity === "limited" + readonly property string icon: Model.connectionIcon(kind, signalStrength, connectivity) + readonly property string connectionKey: kind === "wifi" && wifiDevice && connectedWifiNetwork + ? kind + ":" + wifiDevice.name + ":" + connectedWifiNetwork.name + : (kind === "ethernet" && wiredDevice ? kind + ":" + wiredDevice.name : "") + + onConnectionKeyChanged: Qt.callLater(checkConnectivity) + onConnectivityChecksEnabledChanged: Qt.callLater(checkConnectivity) + onHasCaptivePortalChanged: { + if (hasCaptivePortal && opened && passwordSsid === "") { + focusSection = "portal" + cursorActive = true + } else if (!hasCaptivePortal && focusSection === "portal") { + focusSection = headerActionCount > 0 ? "header" : "dns" + headerIndex = 0 + } + } + onRestrictedChanged: { + connectionPhraseSwap.stop() + heroMeta.opacity = 1.0 + } + + function checkConnectivity() { + if (connectivityChecksEnabled && kind !== "disconnected") Networking.checkConnectivity() + } + + function openCaptivePortal() { + if (!hasCaptivePortal) return + // Explicit user action only. argv (not a shell string), and a fixed HTTP + // URL: let the browser handle the redirect without trusting portal input. + Quickshell.execDetached(["omarchy-launch-browser", Model.captivePortalUrl]) + close() + } + + // Keep checking while login is needed, even with the panel closed in favour + // of the browser. Normal connected operation relies on NM's own schedule. + Timer { + id: connectivityPoll + interval: 10000 + repeat: true + running: root.restricted && root.connectivityChecksEnabled + onTriggered: root.checkConnectivity() + } // The share card is its own panel plugin (omarchy.wifiqr) so a replacement // design can take it over; summon() routes to whichever implementation is @@ -469,6 +523,7 @@ Panel { } function refresh(scanWifi) { + checkConnectivity() if (scanWifi === undefined) scanWifi = false if (!detailsProc.running) detailsProc.running = true if (!dnsProc.running) { @@ -901,7 +956,7 @@ Panel { Timer { id: connectionPhraseTimer interval: 2800 - running: root.opened && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect)) + running: root.opened && !root.restricted && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect)) repeat: true onTriggered: connectionPhraseSwap.restart() } @@ -958,6 +1013,9 @@ Panel { anchors.fill: parent bar: root.bar text: root.icon + active: root.restricted + tooltipText: root.hasCaptivePortal ? "Sign in to this network" + : (root.restricted ? "Limited internet access" : "") onPressed: function(b) { if (root.opened) root.close() @@ -1001,23 +1059,34 @@ Panel { if (dy >= 0) return } if (dy !== 0) { - // Vertical order is header ⇄ band ⇄ DNS ⇄ wifi, with the band section - // dropping out of the chain entirely when it isn't on screen. + // Hidden sections drop out of the keyboard chain entirely. if (root.focusSection === "header") { if (dy > 0) { - if (root.canSelectBand) { + if (root.hasCaptivePortal) { + root.focusSection = "portal" + } else if (root.canSelectBand) { root.focusSection = "band" root.bandAutoFocused = true } else { root.focusSection = "dns" } } + } else if (root.focusSection === "portal") { + if (dy < 0 && root.headerActionCount > 0) { + root.focusSection = "header" + root.headerIndex = 0 + } else if (dy > 0) { + root.focusSection = root.canSelectBand ? "band" : "dns" + root.bandAutoFocused = true + } } else if (root.focusSection === "band") { // Automatic on the header line, then the pills -- which collapse // away under Automatic, leaving a single row to walk. if (dy < 0) { if (!root.bandAutoFocused) { root.bandAutoFocused = true + } else if (root.hasCaptivePortal) { + root.focusSection = "portal" } else if (root.headerActionCount > 0) { root.focusSection = "header" root.headerIndex = 0 @@ -1035,6 +1104,8 @@ Panel { if (root.canSelectBand) { root.focusSection = "band" root.bandAutoFocused = !root.bandPillsVisible + } else if (root.hasCaptivePortal) { + root.focusSection = "portal" } else if (root.headerActionCount > 0) { root.focusSection = "header" root.headerIndex = 0 @@ -1063,6 +1134,7 @@ Panel { onActivateRequested: { if (root.cursorActive) { if (root.focusSection === "header") root.activateHeader() + else if (root.focusSection === "portal") root.openCaptivePortal() else if (root.focusSection === "band") root.activateBand() else if (root.focusSection === "dns") root.activateDns() else root.activateSelected() @@ -1090,8 +1162,9 @@ Panel { // Status only — the switch owns toggling, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText text: root.icon - color: root.bar.foreground + color: root.restricted ? root.bar.urgent : root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.display opacity: root.networkManagerAvailable ? 1.0 : 0.5 @@ -1170,9 +1243,13 @@ Panel { // rather than in a pill, which crowded the on/off switch. Text { id: heroSsid + textFormat: Text.PlainText width: parent.width readonly property string title: { + // The HTTP restriction does not undo association. Show the live + // SSID even before route/details polling has returned anything. + if (root.kind === "wifi" && root.connectedWifiNetwork) return root.connectedWifiNetwork.name || "Wi-Fi" if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi" if (root.info.type === "ethernet") return "Ethernet" return root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection") @@ -1189,8 +1266,11 @@ Panel { Text { id: heroMeta + textFormat: Text.PlainText width: parent.width text: { + if (root.hasCaptivePortal) return "SIGN-IN REQUIRED" + if (root.restricted) return "LIMITED INTERNET ACCESS" if (root.info.type === "wifi") { if (root.canDisconnect) return root.connectionPhrase.toUpperCase() if (root.kind === "disconnected") return "NOT CONNECTED" @@ -1201,7 +1281,7 @@ Panel { return "" } visible: text !== "" - color: Qt.darker(root.bar.foreground, 1.4) + color: root.restricted ? root.bar.urgent : Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true @@ -1212,6 +1292,43 @@ Panel { } + Column { + visible: root.hasCaptivePortal + width: parent.width + spacing: Style.space(6) + + Button { + id: portalAction + width: parent.width + text: "Open Captive Portal" + iconText: "󰏌" + foreground: root.bar.urgent + accent: root.bar.urgent + fontFamily: root.bar.fontFamily + verticalPadding: Style.space(10) + bordered: true + active: true + hasCursor: root.cursorActive && root.focusSection === "portal" + onHovered: function(on) { + if (!on) return + root.cursorActive = true + root.focusSection = "portal" + } + onClicked: root.openCaptivePortal() + } + + Text { + width: parent.width + text: "Sign in or accept this network’s terms to access the internet." + textFormat: Text.PlainText + wrapMode: Text.WordWrap + color: root.bar.foreground + opacity: 0.7 + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall + } + } + // Connection details: transfer metrics first, then IP/Gateway. Column { visible: !!root.info.iface @@ -1651,6 +1768,7 @@ Panel { if (isBusy && root.actionKind === "disconnect") return "Disconnecting…" if (isBusy && root.actionKind === "forget") return "Forgetting…" if (isFailed) return root.failureReason || "Failed" + if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return "Sign-in required" if (isConnected) return "Connected" return "" } @@ -1658,6 +1776,7 @@ Panel { readonly property color statusColor: { if (isFailed) return root.bar.urgent if (isBusy) return root.bar.foreground + if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return root.bar.urgent if (isConnected) return root.bar.foreground return Qt.darker(root.bar.foreground, 1.5) } @@ -1711,7 +1830,9 @@ Panel { Text { id: networkIcon - text: row.net ? root.wifiIconFor(row.net.signal) : "" + textFormat: Text.PlainText + text: row.net ? Model.connectionIcon("wifi", row.net.signal, + row.isConnected && root.kind === "wifi" ? root.connectivity : "") : "" color: row.statusColor font.family: root.bar.fontFamily font.pixelSize: Style.font.title @@ -1732,6 +1853,7 @@ Panel { Text { id: lockIndicator + textFormat: Text.PlainText visible: row.requiresCredentials || row.forgetVisible width: parent.width anchors.verticalCenter: parent.verticalCenter @@ -1779,6 +1901,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: row.net ? (row.net.ssid || "Hidden") : "" color: root.bar.foreground font.family: root.bar.fontFamily @@ -1787,6 +1910,7 @@ Panel { width: parent.width } Text { + textFormat: Text.PlainText // Signal strength is conveyed by the wifi-bars icon and the // right-edge glyph/buttons carry protection or forget affordances, // so the second line only carries action status (Connecting…, @@ -1893,6 +2017,7 @@ Panel { radius: Style.cornerRadius Text { + textFormat: Text.PlainText anchors.fill: parent horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -1946,6 +2071,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.bar.foreground opacity: 0.6 font.family: root.bar.fontFamily @@ -1953,6 +2079,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index 871ee2f6..b1c34da2 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -325,6 +325,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText text: root.batteryIcon() color: root.bar.foreground font.family: root.bar.fontFamily @@ -356,6 +357,7 @@ Panel { Text { id: heroStatus + textFormat: Text.PlainText text: root.heroStatusText.toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -369,6 +371,7 @@ Panel { Text { id: heroPercent + textFormat: Text.PlainText text: root.batteryInfo.percentage || "—" color: root.bar.foreground font.family: root.bar.fontFamily @@ -517,6 +520,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.bar.foreground opacity: 0.6 font.family: root.bar.fontFamily @@ -524,6 +528,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index 34278eda..6a976307 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -498,6 +498,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: tailscale.actionStatus !== "" || tailscale.lastError !== "" width: parent.width text: tailscale.actionStatus !== "" ? tailscale.actionStatus : tailscale.lastError @@ -841,6 +842,7 @@ Panel { } Text { + textFormat: Text.PlainText text: accountRow.accountText color: root.foreground font.family: root.fontFamily @@ -933,6 +935,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: tailscale.osIcon(peer ? peer.OS : "") color: root.foreground font.family: root.fontFamily @@ -946,6 +949,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: peerRow.peerName color: root.foreground @@ -955,6 +959,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: { var parts = [] @@ -1087,6 +1092,7 @@ Panel { spacing: Style.space(10) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: copyChoice.label color: root.foreground @@ -1134,6 +1140,7 @@ Panel { Text { id: exitNodeGlyph + textFormat: Text.PlainText text: exitNodeRow.addMullvad ? "+" : (peer && peer.Mullvad === true ? "󰖂" : "󱇢") color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode || exitNodeRow.addMullvad ? root.foreground : root.dim font.family: root.fontFamily @@ -1154,6 +1161,7 @@ Panel { } Text { + textFormat: Text.PlainText text: exitNodeRow.peerName color: root.foreground font.family: root.fontFamily @@ -1224,6 +1232,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText width: parent.width text: regionRow.regionName color: root.foreground @@ -1234,6 +1243,7 @@ Panel { } Text { + textFormat: Text.PlainText width: parent.width text: regionRow.regionDetail visible: text !== "" diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index dacb4ac9..d9d43b68 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -63,7 +63,9 @@ Panel { } function setCenterHoverRevealSuppressed(value) { - if (root.bar && "centerHoverRevealSuppressed" in root.bar) + if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function") + root.bar.setCenterHoverRevealSuppressed(value) + else if (root.bar && "centerHoverRevealSuppressed" in root.bar) root.bar.centerHoverRevealSuppressed = value } @@ -531,6 +533,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: 5 text: root.label || "—" @@ -547,6 +550,7 @@ Panel { Text { id: tempBig + textFormat: Text.PlainText text: root.reportTempNum || "—" color: root.bar.foreground font.family: root.bar.fontFamily @@ -556,6 +560,7 @@ Panel { font.bold: true } Text { + textFormat: Text.PlainText text: root.current ? root.tempUnit : "" color: root.bar.foreground font.family: root.bar.fontFamily @@ -593,6 +598,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter } Text { + textFormat: Text.PlainText text: (root.reportLocation || "").toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -643,6 +649,7 @@ Panel { color: !root.savingLocation && clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent" Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.savingLocation ? "󰦖" : "✕" font.family: root.bar.fontFamily @@ -683,6 +690,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportFeels color: root.bar.foreground font.family: root.bar.fontFamily @@ -700,6 +708,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportWind color: root.bar.foreground font.family: root.bar.fontFamily @@ -717,6 +726,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportHumidity color: root.bar.foreground font.family: root.bar.fontFamily @@ -752,12 +762,14 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: modelData.name color: index === root.suggestionIndex ? Style.hoverStateColor(root.bar.foreground, Color.accent) : root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body } Text { + textFormat: Text.PlainText visible: text !== "" text: modelData.description color: Qt.darker(root.bar.foreground, 1.5) @@ -817,6 +829,7 @@ Panel { spacing: Style.space(10) Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: root.dayIcon(modelData) color: root.bar.foreground @@ -829,6 +842,7 @@ Panel { spacing: Style.space(2) Text { + textFormat: Text.PlainText text: root.dayName(modelData.date).toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -840,12 +854,14 @@ Panel { spacing: Style.space(6) Text { + textFormat: Text.PlainText text: root.bareTempForDay(modelData, "max") color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body } Text { + textFormat: Text.PlainText text: root.bareTempForDay(modelData, "min") color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/wifiqr/Panel.qml b/shell/plugins/panels/wifiqr/Panel.qml index 276434a8..1426b5fd 100644 --- a/shell/plugins/panels/wifiqr/Panel.qml +++ b/shell/plugins/panels/wifiqr/Panel.qml @@ -257,6 +257,7 @@ Item { spacing: Style.space(16) Text { + textFormat: Text.PlainText text: (root.ssid || "Wi-Fi").toUpperCase() color: root.onScrimDim font.family: root.fontFamily @@ -318,6 +319,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.error !== "" text: root.error color: root.onScrimUrgent @@ -340,6 +342,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.showingQr && root.secured text: root.passwordError !== "" ? root.passwordError : root.passwordVisible ? root.password diff --git a/shell/plugins/polkit/PolkitAgent.qml b/shell/plugins/polkit/PolkitAgent.qml index 8ce95973..8786eeeb 100644 --- a/shell/plugins/polkit/PolkitAgent.qml +++ b/shell/plugins/polkit/PolkitAgent.qml @@ -332,6 +332,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -374,6 +375,7 @@ Item { Text { id: justificationText + textFormat: Text.PlainText anchors.fill: parent anchors.leftMargin: Style.space(12) anchors.rightMargin: Style.space(12) diff --git a/shell/plugins/polkit/manifest.json b/shell/plugins/polkit/manifest.json index 1e80b655..5d1f037e 100644 --- a/shell/plugins/polkit/manifest.json +++ b/shell/plugins/polkit/manifest.json @@ -5,6 +5,11 @@ "version": "1.0.0", "author": "Omarchy", "description": "Theme-aware authentication dialog for privileged actions.", + "omarchy": { + "capabilities": [ + "authentication" + ] + }, "kinds": [ "service" ], diff --git a/shell/plugins/reminders/ReminderFlow.qml b/shell/plugins/reminders/ReminderFlow.qml index bc6616db..fef95cf0 100644 --- a/shell/plugins/reminders/ReminderFlow.qml +++ b/shell/plugins/reminders/ReminderFlow.qml @@ -156,6 +156,7 @@ Item { anchors.leftMargin: card.contentLeftInset Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter diff --git a/shell/plugins/services/battery/Service.qml b/shell/plugins/services/battery/Service.qml index 37fdf5c7..eb7561f1 100644 --- a/shell/plugins/services/battery/Service.qml +++ b/shell/plugins/services/battery/Service.qml @@ -12,6 +12,8 @@ Item { readonly property int batteryThreshold: 10 property string pendingPowerSource: "" + property string activePowerProfile: "" + readonly property bool powerSaverOnBattery: UPower.onBattery && activePowerProfile === "power-saver" PersistentProperties { id: persisted @@ -53,11 +55,54 @@ Item { powerProfileProcess.running = true } + function refreshPowerProfile() { + if (!powerProfileReadProcess.running) powerProfileReadProcess.running = true + } + + function parseActiveProfile(text) { + // busctl --json=short prints {"type":"s","data":"balanced"}; an empty or + // malformed reply (daemon not running) reads as no active profile. + try { + return String(JSON.parse(text).data || "").trim() + } catch (e) { + return "" + } + } + Process { id: warningProcess } Process { id: powerProfileProcess - onExited: if (root.pendingPowerSource !== "") root.runPendingPowerProfile() + onExited: { + if (root.pendingPowerSource !== "") root.runPendingPowerProfile() + root.refreshPowerProfile() + } + } + + Process { + id: powerProfileReadProcess + // Read the property straight from the daemon rather than via + // `powerprofilesctl get`. That is a PyGObject script, and spawning a Python + // interpreter for it every two seconds trips a CPython 3.14 shutdown race + // (python/cpython#124619): the GLib D-Bus worker thread re-enters the + // interpreter after finalization and the process dies with SIGSEGV, + // leaving a core dump and a crash notification behind roughly daily. + command: ["busctl", "--json=short", "get-property", "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles", "net.hadess.PowerProfiles", "ActiveProfile"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.activePowerProfile = root.parseActiveProfile(text) + } + } + + Timer { + // There is no portable way to subscribe to profile changes from QML; keep + // them visible to consumers such as the wallpaper service without requiring + // the power panel to be open. + interval: 2000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.refreshPowerProfile() } Timer { @@ -73,6 +118,9 @@ Item { function onOnBatteryChanged() { root.checkBattery() root.applyPowerProfile() + root.refreshPowerProfile() } } + + Component.onCompleted: root.refreshPowerProfile() } diff --git a/shell/plugins/services/idle/Service.qml b/shell/plugins/services/idle/Service.qml index 488f2961..1453920d 100644 --- a/shell/plugins/services/idle/Service.qml +++ b/shell/plugins/services/idle/Service.qml @@ -16,7 +16,8 @@ Item { readonly property string stayAwakeStatePath: stayAwakeStateDir + "/stay-awake" readonly property int defaultScreensaverSeconds: 150 readonly property int defaultLockSeconds: 300 - readonly property var idleConfig: shell && shell.shellConfig && shell.shellConfig.idle ? shell.shellConfig.idle : ({}) + readonly property var idleConfig: shell && shell.shellConfig && shell.shellConfig.idle + ? shell.shellConfig.idle : (shell && shell.idleConfig ? shell.idleConfig : ({})) readonly property int screensaverTimeoutSeconds: secondsFromConfig(idleConfig.screensaver, defaultScreensaverSeconds) readonly property int lockTimeoutSeconds: secondsFromConfig(idleConfig.lock, defaultLockSeconds) readonly property int firstIdleTimeoutSeconds: Math.min(screensaverTimeoutSeconds, lockTimeoutSeconds) diff --git a/shell/plugins/services/media/BarWidget.qml b/shell/plugins/services/media/BarWidget.qml index 52793c16..02650efc 100644 --- a/shell/plugins/services/media/BarWidget.qml +++ b/shell/plugins/services/media/BarWidget.qml @@ -32,6 +32,7 @@ BarWidget { Text { id: glyph + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: root.playIcon color: activePlayer && activePlayer.isPlaying ? root.bar.barForeground : Qt.darker(root.bar.barForeground, 1.5) @@ -53,6 +54,7 @@ BarWidget { Text { id: labelText + textFormat: Text.PlainText text: root.title + (root.artist ? " · " + root.artist : "") color: root.bar.barForeground font.family: root.bar.fontFamily @@ -148,6 +150,7 @@ BarWidget { width: parent.width - Style.space(74) Text { + textFormat: Text.PlainText text: root.title || "Nothing playing" color: root.bar.foreground font.family: root.bar.fontFamily @@ -158,6 +161,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: root.artist color: Qt.darker(root.bar.foreground, 1.3) font.family: root.bar.fontFamily @@ -168,6 +172,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: root.activePlayer && root.activePlayer.trackAlbum ? root.activePlayer.trackAlbum : "" color: Qt.darker(root.bar.foreground, 1.6) font.family: root.bar.fontFamily @@ -255,6 +260,7 @@ BarWidget { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: sourceRow.player && sourceRow.player.isPlaying ? "󰏤" : "󰐊" color: root.bar.foreground font.family: root.bar.fontFamily @@ -270,6 +276,7 @@ BarWidget { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: sourceRow.sourceTitle color: root.bar.foreground font.family: root.bar.fontFamily @@ -280,6 +287,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: sourceRow.sourceDetail color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/shell/services/AuthServiceStore.js b/shell/services/AuthServiceStore.js new file mode 100644 index 00000000..d3eb5a66 --- /dev/null +++ b/shell/services/AuthServiceStore.js @@ -0,0 +1,45 @@ +// Intentionally not `.pragma library`: QML JavaScript imports get a private +// module instance per importing component. shell.qml's instance retains the +// authentication services; a third-party plugin importing this file receives +// a separate empty store rather than a shared path to credential-bearing QML. + +var services = ({}) +var trustedIds = ({}) + +function has(id) { + return services[String(id || "")] !== undefined +} + +function put(id, service) { + var key = String(id || "") + if (!key || !service) return + trustedIds[key] = true + if (services[key] && services[key] !== service && typeof services[key].destroy === "function") + services[key].destroy() + services[key] = service +} + +function isTrusted(id) { + return trustedIds[String(id || "")] === true +} + +function ids() { + return Object.keys(services) +} + +function updateManifest(id, manifest) { + var service = services[String(id || "")] + if (service && "manifest" in service) service.manifest = manifest +} + +function destroy(id) { + var key = String(id || "") + var service = services[key] + if (service && typeof service.destroy === "function") service.destroy() + delete services[key] +} + +function destroyAll() { + var keys = ids() + for (var i = 0; i < keys.length; i++) destroy(keys[i]) +} diff --git a/shell/services/PluginAppLibraryApi.qml b/shell/services/PluginAppLibraryApi.qml new file mode 100644 index 00000000..00d5e8f0 --- /dev/null +++ b/shell/services/PluginAppLibraryApi.qml @@ -0,0 +1,46 @@ +import QtQuick + +// Detached application-library capability for third-party menus. Callbacks +// expose the supported app-list operations without retaining AppLibrary or its +// ShellRoot parent in the plugin-visible object graph. +QtObject { + required property string ownerPluginId + + signal appsChanged() + + property var _entryName: null + property var _entrySubtext: null + property var _sortedEntries: null + property var _iconSource: null + property var _refreshIcons: null + property var _launch: null + property var _remove: null + + function entryName(entry) { + return _entryName ? _entryName(entry) : "" + } + + function entrySubtext(entry) { + return _entrySubtext ? _entrySubtext(entry) : "" + } + + function sortedEntries(query) { + return _sortedEntries ? _sortedEntries(String(query || "")) : [] + } + + function iconSource(icon) { + return _iconSource ? _iconSource(icon) : "" + } + + function refreshIcons() { + if (_refreshIcons) _refreshIcons() + } + + function launch(desktopId, name) { + if (_launch) _launch(String(desktopId || ""), String(name || "")) + } + + function remove(desktopId, name) { + if (_remove) _remove(String(desktopId || ""), String(name || "")) + } +} diff --git a/shell/services/PluginBarStateApi.qml b/shell/services/PluginBarStateApi.qml new file mode 100644 index 00000000..db802b77 --- /dev/null +++ b/shell/services/PluginBarStateApi.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Scalar-only view of the active bar for plugins that position independent +// windows. The active Bar QObject is never retained here. +QtObject { + required property string ownerPluginId + + property bool barHidden: false + property int barSize: 0 + property string fontFamily: "" + property string position: "top" +} diff --git a/shell/services/PluginBarWidgetRegistryApi.qml b/shell/services/PluginBarWidgetRegistryApi.qml new file mode 100644 index 00000000..238729f1 --- /dev/null +++ b/shell/services/PluginBarWidgetRegistryApi.qml @@ -0,0 +1,24 @@ +import QtQuick + +// Detached widget-catalogue snapshot for third-party full-bar implementations. +// Plugins can render the referenced components, but mutating this local view +// cannot replace a registration in the host registry. +QtObject { + id: api + + property var widgets: ({}) + property int revision: 0 + + function metadataFor(id) { + var entry = widgets[String(id || "")] + return entry ? entry.metadata : null + } + + function availableIds() { + return Object.keys(widgets) + } + + function has(id) { + return widgets[String(id || "")] !== undefined + } +} diff --git a/shell/services/PluginFirstPartyServiceApi.qml b/shell/services/PluginFirstPartyServiceApi.qml new file mode 100644 index 00000000..1f052629 --- /dev/null +++ b/shell/services/PluginFirstPartyServiceApi.qml @@ -0,0 +1,46 @@ +import QtQuick + +// Narrow proxy for the non-authentication first-party services used by the +// built-in bar. It intentionally has no generic property or method forwarding. +QtObject { + required property string ownerPluginId + required property string serviceId + + property bool stayAwake: false + property bool enabled: false + property bool doNotDisturb: false + property var activePlayer: null + property var sourcePlayers: [] + + property var _setIdleEnabled: null + property var _setNightlight: null + property var _setDoNotDisturb: null + property var _runAction: null + property var _playerKey: null + property var _selectPlayer: null + + function setIdleEnabled(value) { + if (serviceId === "omarchy.idle" && _setIdleEnabled) _setIdleEnabled(!!value) + } + + function setNightlight(value) { + if (serviceId === "omarchy.nightlight" && _setNightlight) _setNightlight(!!value) + } + + function setDoNotDisturb(value) { + if (serviceId === "omarchy.notifications" && _setDoNotDisturb) _setDoNotDisturb(!!value) + } + + function runAction(action, showFeedback, playerId) { + if (serviceId === "omarchy.media" && _runAction) + _runAction(String(action || ""), !!showFeedback, String(playerId || "")) + } + + function playerKey(player) { + return serviceId === "omarchy.media" && _playerKey ? _playerKey(player) : "" + } + + function selectPlayer(playerId) { + if (serviceId === "omarchy.media" && _selectPlayer) _selectPlayer(String(playerId || "")) + } +} diff --git a/shell/services/PluginRegistry.qml b/shell/services/PluginRegistry.qml index f08bf765..e8b8ad36 100644 --- a/shell/services/PluginRegistry.qml +++ b/shell/services/PluginRegistry.qml @@ -20,7 +20,7 @@ QtObject { property var shellConfigProvider: null property var shellConfigMutator: null - // { pluginId: manifest } — manifests have __sourceDir and __isFirstParty stamped in. + // { pluginId: manifest } — manifests have source/trust metadata stamped in. property var installedPlugins: ({}) property int registryRevision: 0 property bool scanning: false @@ -78,8 +78,7 @@ QtObject { } } // Every entry point must be a relative path inside the plugin's source - // directory. Reject the whole manifest if anything looks like an attempt - // to escape the plugin's sandbox. + // directory. Reject the whole manifest if an entry point escapes it. for (var key in manifest.entryPoints) { if (!isSafeEntryPoint(manifest.entryPoints[key])) { console.warn("PluginRegistry: unsafe entryPoint '" + key + "'='" @@ -90,6 +89,32 @@ QtObject { return manifest } + function trustedCapabilities(manifest) { + if (!manifest || !manifest.__isFirstParty) return [] + var metadata = Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null + var declared = metadata && Array.isArray(metadata.capabilities) ? metadata.capabilities : [] + var out = [] + for (var i = 0; i < declared.length; i++) { + var capability = String(declared[i] || "") + if (capability && out.indexOf(capability) === -1) out.push(capability) + } + return out + } + + function stampHostCapabilities(firstParty, thirdParty) { + for (var firstPartyId in firstParty) + firstParty[firstPartyId].__hostCapabilities = trustedCapabilities(firstParty[firstPartyId]) + + for (var thirdPartyId in thirdParty) { + var manifest = thirdParty[thirdPartyId] + var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null + var clonedFrom = metadata ? String(metadata.clonedFrom || "") : "" + var source = clonedFrom ? firstParty[clonedFrom] : null + manifest.__hostCapabilities = source && Array.isArray(source.__hostCapabilities) + ? source.__hostCapabilities.slice() : [] + } + } + function entryPointUrl(manifest, kind) { if (!Util.isPlainObject(manifest)) return "" var ep = manifest.entryPoints ? manifest.entryPoints[kind] : null @@ -594,6 +619,8 @@ QtObject { } flush() + stampHostCapabilities(firstParty, thirdParty) + var merged = {} for (var fk in firstParty) merged[fk] = firstParty[fk] // Third-party plugins never shadow first-party ids. The whole diff --git a/shell/services/PluginRegistryApi.qml b/shell/services/PluginRegistryApi.qml new file mode 100644 index 00000000..32033c2b --- /dev/null +++ b/shell/services/PluginRegistryApi.qml @@ -0,0 +1,32 @@ +import QtQuick + +// Read-only, self-scoped registry view for an installed third-party plugin. +// The host updates manifest/enabled when it rescans; no host registry object is +// retained here, so `parent` and property traversal cannot reach ShellRoot. +QtObject { + id: api + + required property string pluginId + property var manifest: null + property bool enabled: false + property var _entryPointUrl: null + + readonly property var installedPlugins: { + var out = ({}) + if (manifest) out[pluginId] = manifest + return out + } + + function isEnabled(id) { + return String(id || "") === pluginId && enabled + } + + function resolveEnabledId(id) { + return String(id || "") === pluginId ? pluginId : "" + } + + function entryPointUrl(candidate, kind) { + if (!candidate || String(candidate.id || "") !== pluginId) return "" + return _entryPointUrl ? _entryPointUrl(String(kind || "")) : "" + } +} diff --git a/shell/services/PluginShellApi.qml b/shell/services/PluginShellApi.qml new file mode 100644 index 00000000..0202aa01 --- /dev/null +++ b/shell/services/PluginShellApi.qml @@ -0,0 +1,70 @@ +import QtQuick + +// Capability-scoped shell surface for installed third-party plugins. +// +// The callbacks are closed over one plugin id by shell.qml. A plugin can call +// them directly, but it cannot widen their scope: ordinary plugins are limited +// to their own id, and full-bar callbacks independently enforce their explicit +// non-authentication UI scope. This object avoids directly injecting the host +// shell, but it is not a QML sandbox: visual plugins share the host object tree. +QtObject { + id: api + + required property string pluginId + + property var appLibrary: null + property var bar: null + property var barConfig: ({}) + property var idleConfig: ({}) + + property var _serviceLookup: null + property var _firstPartyServiceLookup: null + property var _barEntryShellLookup: null + property var _summon: null + property var _hide: null + property var _toggle: null + property var _isOpen: null + property var _updateSettings: null + property var _mutateBarConfig: null + + function serviceFor(id) { + return _serviceLookup ? _serviceLookup(String(id || "")) : null + } + + // Only full-bar facades receive narrow proxies for the specific + // non-authentication services used by the built-in bar widgets. + function firstPartyServiceFor(id) { + return _firstPartyServiceLookup + ? _firstPartyServiceLookup(String(id || "")) : null + } + + function pluginShellForBarEntry(ownerId, moduleName) { + return _barEntryShellLookup + ? _barEntryShellLookup(String(ownerId || ""), String(moduleName || "")) : null + } + + function summon(id, payloadJson) { + return _summon ? _summon(String(id || ""), String(payloadJson || "")) : false + } + + function hide(id) { + return _hide ? _hide(String(id || "")) : false + } + + function toggle(id, payloadJson) { + return _toggle ? _toggle(String(id || ""), String(payloadJson || "")) : false + } + + function isPluginOpen(id) { + return _isOpen ? _isOpen(String(id || "")) : false + } + + function updateEntryInline(id, settings) { + return _updateSettings ? _updateSettings(String(id || ""), settings) : false + } + + function mutateShellConfig(mutator) { + return _mutateBarConfig && typeof mutator === "function" + ? _mutateBarConfig(mutator) : false + } +} diff --git a/shell/shell.qml b/shell/shell.qml index 71a9834f..82080624 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -7,6 +7,7 @@ import qs.Commons import "plugins/bar" import "services" +import "services/AuthServiceStore.js" as AuthServiceStore ShellRoot { id: shell @@ -113,7 +114,10 @@ ShellRoot { } readonly property var barConfig: shellConfig && Util.isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar - onBarConfigChanged: if (bar && "barConfig" in bar) bar.barConfig = shell.barConfig + onBarConfigChanged: { + if (bar && "barConfig" in bar) + bar.barConfig = shell.barConfigFor(shell.activeBarManifest) + } FileView { id: defaultsFile path: shell.defaultsPath @@ -214,11 +218,11 @@ ShellRoot { function configureBar(target, manifest) { if (!target) return if ("omarchyPath" in target) target.omarchyPath = shell.omarchyPath - if ("shell" in target) target.shell = shell - if ("manifest" in target) target.manifest = manifest - if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.barWidgetRegistry - if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistry - if ("barConfig" in target) target.barConfig = shell.barConfig + if ("shell" in target) target.shell = shell.pluginShellFor(manifest) + if ("manifest" in target) target.manifest = shell.publicPluginManifest(manifest) + if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest) + if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistryFor(manifest) + if ("barConfig" in target) target.barConfig = shell.barConfigFor(manifest) shell.bar = target } @@ -253,8 +257,7 @@ ShellRoot { onActiveChanged: if (!active) shell.bar = null onStatusChanged: { if (status === Loader.Error) { - var detail = errorString && errorString() ? errorString() : "" - console.warn("bar option " + shell.activeBarId + " failed to load, falling back to " + shell.defaultBarId + ":", detail) + console.warn("bar option " + shell.activeBarId + " failed to load, falling back to " + shell.defaultBarId) shell.failedBarId = shell.activeBarId } } @@ -271,13 +274,617 @@ ShellRoot { } property var _services: ({}) + property var _pluginShellApis: ({}) + property var _pluginShellApiDescriptors: ({}) + property var _pluginBarEntryShellApis: ({}) + property var _pluginRegistryApis: ({}) + property var _pluginBarWidgetRegistryApis: ({}) + property var _pluginAppLibraryApis: ({}) + property var _pluginBarStateApis: ({}) + property var _pluginFirstPartyServiceApis: ({}) + + Component { + id: pluginShellApiComponent + PluginShellApi { } + } + + Component { + id: pluginRegistryApiComponent + PluginRegistryApi { } + } + + Component { + id: pluginBarWidgetRegistryApiComponent + PluginBarWidgetRegistryApi { } + } + + Component { + id: pluginAppLibraryApiComponent + PluginAppLibraryApi { } + } + + Component { + id: pluginBarStateApiComponent + PluginBarStateApi { } + } + + Component { + id: pluginFirstPartyServiceApiComponent + PluginFirstPartyServiceApi { } + } + + function publicPluginManifest(manifest) { + if (!manifest) return null + if (manifest.__isFirstParty) return manifest + var copy = JSON.parse(JSON.stringify(manifest)) + delete copy.__sourceDir + delete copy.__isFirstParty + delete copy.__hostCapabilities + return copy + } + + function publicBarConfig() { + return JSON.parse(JSON.stringify(shell.barConfig || {})) + } + + function barConfigFor(manifest) { + return !manifest || manifest.__isFirstParty + ? shell.barConfig : shell.publicBarConfig() + } + + function publicBarWidgetSnapshot() { + var source = shell.barWidgetRegistry.widgets || {} + var snapshot = {} + for (var id in source) { + var entry = source[id] + if (!entry) continue + snapshot[id] = { + component: entry.component, + metadata: JSON.parse(JSON.stringify(entry.metadata || {})) + } + } + return snapshot + } + + function manifestHasKind(manifest, kind) { + return !!manifest && Array.isArray(manifest.kinds) + && manifest.kinds.indexOf(kind) !== -1 + } + + function pluginHasBarCapabilities(manifest) { + return shell.manifestHasKind(manifest, "bar") + } + + function publicIdleConfigFor(manifest) { + var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null + if (!metadata || String(metadata.clonedFrom || "") !== "omarchy.idle") return ({}) + var idle = shell.shellConfig && Util.isPlainObject(shell.shellConfig.idle) + ? shell.shellConfig.idle : ({}) + return JSON.parse(JSON.stringify(idle)) + } + + function pluginCloneMaySummon(manifest, requestedId) { + var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null + var sourceId = metadata ? String(metadata.clonedFrom || "") : "" + var allowed = { + "omarchy.audio": ["omarchy.osd"], + "omarchy.media": ["omarchy.osd"], + "omarchy.monitor": ["omarchy.osd"], + "omarchy.network": ["omarchy.speedtest", "omarchy.wifiqr"] + } + var targets = allowed[sourceId] || [] + return targets.indexOf(String(requestedId || "")) !== -1 + } + + function pluginOwnsTarget(pluginId, requestedId) { + var caller = String(pluginId || "") + if (!caller) return false + return shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) === caller + } + + function pluginServiceFor(pluginId, requestedId) { + if (!shell.pluginOwnsTarget(pluginId, requestedId)) return null + return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(requestedId)) + } + + function barEntryConfigured(pluginId) { + var location = shell.pluginRegistry.findEntryLocation(shell.shellConfig, pluginId) + return location && location.kind === "bar" + } + + function barPluginMayControl(manifest, requestedId) { + if (!shell.pluginHasBarCapabilities(manifest)) return false + var id = shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) + var target = shell.pluginRegistry.installedPlugins[id] + if (!target || shell.isAuthenticationService(target, id)) return false + if (shell.barEntryConfigured(id)) return true + var uiKinds = ["bar-widget", "panel", "overlay", "menu"] + for (var i = 0; i < uiKinds.length; i++) + if (shell.manifestHasKind(target, uiKinds[i])) return true + return false + } + + function mutatePluginBarConfig(mutator) { + if (typeof mutator !== "function") return false + shell.mutateShellConfig(function(config) { + var scoped = { bar: JSON.parse(JSON.stringify(config.bar || {})) } + mutator(scoped) + if (Util.isPlainObject(scoped.bar)) config.bar = JSON.parse(JSON.stringify(scoped.bar)) + }) + return true + } + + function pluginAppLibraryFor(cacheKey, pluginId) { + if (_pluginAppLibraryApis[cacheKey]) return _pluginAppLibraryApis[cacheKey] + var api = pluginAppLibraryApiComponent.createObject(null, { + ownerPluginId: pluginId, + _entryName: function(entry) { return shell.appLibrary.entryName(entry) }, + _entrySubtext: function(entry) { return shell.appLibrary.entrySubtext(entry) }, + _sortedEntries: function(query) { return shell.appLibrary.sortedEntries(query) }, + _iconSource: function(icon) { return shell.appLibrary.iconSource(icon) }, + _refreshIcons: function() { shell.appLibrary.refreshIcons() }, + _launch: function(desktopId, name) { shell.appLibrary.launch(desktopId, name) }, + _remove: function(desktopId, name) { shell.appLibrary.remove(desktopId, name) } + }) + if (!api) return null + var next = ({}) + for (var id in _pluginAppLibraryApis) next[id] = _pluginAppLibraryApis[id] + next[cacheKey] = api + _pluginAppLibraryApis = next + return api + } + + function pluginBarStateFor(cacheKey, pluginId) { + if (_pluginBarStateApis[cacheKey]) return _pluginBarStateApis[cacheKey] + var api = pluginBarStateApiComponent.createObject(null, { ownerPluginId: pluginId }) + if (!api) return null + api.barHidden = Qt.binding(function() { return shell.bar ? shell.bar.barHidden === true : false }) + api.barSize = Qt.binding(function() { return shell.bar ? Math.max(0, shell.bar.barSize || 0) : 0 }) + api.fontFamily = Qt.binding(function() { return shell.bar ? String(shell.bar.fontFamily || "") : "" }) + api.position = Qt.binding(function() { return shell.bar ? String(shell.bar.position || "top") : "top" }) + var next = ({}) + for (var id in _pluginBarStateApis) next[id] = _pluginBarStateApis[id] + next[cacheKey] = api + _pluginBarStateApis = next + return api + } + + function pluginFirstPartyServiceFor(cacheKey, pluginId, requestedId) { + var id = String(requestedId || "") + var allowed = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"] + if (allowed.indexOf(id) === -1) return null + var proxyKey = cacheKey + "::" + id + if (_pluginFirstPartyServiceApis[proxyKey]) return _pluginFirstPartyServiceApis[proxyKey] + + function service() { + return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(id)) + } + var api = pluginFirstPartyServiceApiComponent.createObject(null, { + ownerPluginId: pluginId, + serviceId: id, + _setIdleEnabled: function(value) { + var target = service() + if (target && typeof target.setIdleEnabled === "function") target.setIdleEnabled(value) + }, + _setNightlight: function(value) { + var target = service() + if (target && typeof target.setNightlight === "function") target.setNightlight(value) + }, + _setDoNotDisturb: function(value) { + var target = service() + if (target && typeof target.setDoNotDisturb === "function") target.setDoNotDisturb(value) + }, + _runAction: function(action, showFeedback, targetKey) { + var target = service() + if (target && typeof target.runAction === "function") target.runAction(action, showFeedback, targetKey) + }, + _playerKey: function(player) { + var target = service() + return target && typeof target.playerKey === "function" ? target.playerKey(player) : "" + }, + _selectPlayer: function(playerKey) { + var target = service() + if (target && typeof target.selectPlayer === "function") target.selectPlayer(playerKey) + } + }) + if (!api) return null + api.stayAwake = Qt.binding(function() { + var target = service() + return target ? target.stayAwake === true : false + }) + api.enabled = Qt.binding(function() { + var target = service() + return target ? target.enabled === true : false + }) + api.doNotDisturb = Qt.binding(function() { + var target = service() + return target ? target.doNotDisturb === true : false + }) + api.activePlayer = Qt.binding(function() { + var target = service() + return target ? target.activePlayer : null + }) + api.sourcePlayers = Qt.binding(function() { + var target = service() + return target && Array.isArray(target.sourcePlayers) ? target.sourcePlayers : [] + }) + var next = ({}) + for (var existing in _pluginFirstPartyServiceApis) next[existing] = _pluginFirstPartyServiceApis[existing] + next[proxyKey] = api + _pluginFirstPartyServiceApis = next + return api + } + + function pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities) { + return [ + allowOwnService ? "own-service" : "no-own-service", + barCapabilities ? "bar" : "no-bar", + shell.manifestHasKind(manifest, "menu") ? "menu" : "no-menu" + ].join("|") + } + + function cacheWithoutKey(cache, key, destroyValue) { + var next = ({}) + for (var existing in cache) { + if (existing === key) { + var value = cache[existing] + if (destroyValue && value && typeof value.destroy === "function") value.destroy() + } else { + next[existing] = cache[existing] + } + } + return next + } + + function cacheWithoutPrefix(cache, prefix) { + var next = ({}) + for (var existing in cache) { + if (existing.indexOf(prefix) === 0) { + var value = cache[existing] + if (value && typeof value.destroy === "function") value.destroy() + } else { + next[existing] = cache[existing] + } + } + return next + } + + function revokePluginShellApi(cacheKey) { + var key = String(cacheKey || "") + if (!key) return + _pluginAppLibraryApis = shell.cacheWithoutKey(_pluginAppLibraryApis, key, true) + _pluginFirstPartyServiceApis = shell.cacheWithoutPrefix(_pluginFirstPartyServiceApis, key + "::") + _pluginBarEntryShellApis = shell.cacheWithoutPrefix(_pluginBarEntryShellApis, key + ":") + _pluginShellApis = shell.cacheWithoutKey(_pluginShellApis, key, true) + _pluginShellApiDescriptors = shell.cacheWithoutKey(_pluginShellApiDescriptors, key, false) + } + + function createScopedPluginShell(manifest, cacheKey, allowOwnService, barCapabilities) { + var key = String(manifest && manifest.id || "") + if (!key) return null + var profile = shell.pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities) + var cached = _pluginShellApis[cacheKey] + var descriptor = _pluginShellApiDescriptors[cacheKey] + if (cached && descriptor && descriptor.pluginId === key + && descriptor.profile === profile) return cached + if (cached || descriptor) shell.revokePluginShellApi(cacheKey) + + function currentManifest() { + return shell.pluginRegistry.installedPlugins[key] || null + } + + function hasCurrentBarCapabilities() { + return barCapabilities && shell.pluginHasBarCapabilities(currentManifest()) + } + + // Construct the narrow service proxies before any plugin binding can call + // firstPartyServiceFor(). Creating a QObject while evaluating that binding + // makes QML re-enter the binding and report a loop on the caller's service + // property, even though the resulting proxy is otherwise acyclic. + var firstPartyServices = ({}) + if (barCapabilities) { + var serviceIds = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"] + for (var i = 0; i < serviceIds.length; i++) { + var serviceId = serviceIds[i] + firstPartyServices[serviceId] = shell.pluginFirstPartyServiceFor(cacheKey, key, serviceId) + } + } + + var api = pluginShellApiComponent.createObject(null, { + pluginId: key, + appLibrary: shell.manifestHasKind(manifest, "menu") + ? shell.pluginAppLibraryFor(cacheKey, key) : null, + bar: shell.pluginBarStateFor(cacheKey, key), + barConfig: shell.publicBarConfig(), + idleConfig: shell.publicIdleConfigFor(manifest), + _serviceLookup: function(requestedId) { + return allowOwnService ? shell.pluginServiceFor(key, requestedId) : null + }, + _firstPartyServiceLookup: function(requestedId) { + if (allowOwnService && shell.pluginOwnsTarget(key, requestedId)) + return shell.pluginServiceFor(key, requestedId) + return hasCurrentBarCapabilities() ? (firstPartyServices[requestedId] || null) : null + }, + _barEntryShellLookup: function(ownerId, moduleName) { + return hasCurrentBarCapabilities() + ? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null + }, + _summon: function(requestedId, payloadJson) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(currentManifest(), requestedId) + && !shell.pluginCloneMaySummon(currentManifest(), requestedId)) return false + return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) + }, + _hide: function(requestedId) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false + return shell.hide(shell.pluginRegistry.resolveEnabledId(requestedId)) + }, + _toggle: function(requestedId, payloadJson) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false + return shell.toggle(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) + }, + _isOpen: function(requestedId) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false + return shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(requestedId)) + }, + _updateSettings: function(requestedId, settings) { + if (shell.pluginOwnsTarget(key, requestedId)) return shell.updateEntryInline(key, settings) + if (hasCurrentBarCapabilities() && shell.barEntryConfigured(requestedId)) + return shell.updateEntryInline(requestedId, settings) + return false + }, + _mutateBarConfig: function(mutator) { + return hasCurrentBarCapabilities() ? shell.mutatePluginBarConfig(mutator) : false + } + }) + if (!api) return null + + var next = ({}) + for (var id in _pluginShellApis) next[id] = _pluginShellApis[id] + next[cacheKey] = api + _pluginShellApis = next + var descriptorNext = ({}) + for (var descriptorKey in _pluginShellApiDescriptors) + descriptorNext[descriptorKey] = _pluginShellApiDescriptors[descriptorKey] + descriptorNext[cacheKey] = { + pluginId: key, + allowOwnService: allowOwnService === true, + profile: profile + } + _pluginShellApiDescriptors = descriptorNext + return api + } + + function scopedPluginShellForId(pluginId) { + var key = String(pluginId || "") + var manifest = shell.pluginRegistry.installedPlugins[key] + if (!manifest) return null + if (!manifest.__isFirstParty) return shell.pluginShellFor(manifest) + return shell.createScopedPluginShell(manifest, "hosted:" + key, false, false) + } + + function pluginShellForId(pluginId) { + return shell.scopedPluginShellForId(pluginId) + } + + function pluginShellForBarEntry(ownerId, moduleName) { + var owner = String(ownerId || "") + var target = String(moduleName || "") + if (!owner || !target) return null + if (!shell.barEntryConfigured(target)) return null + var cacheKey = owner + "::" + target + if (_pluginBarEntryShellApis[cacheKey]) return _pluginBarEntryShellApis[cacheKey] + + function owns(requestedId) { + return shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) + === shell.pluginRegistry.resolveEnabledId(target) + } + + function currentManifest() { + var id = shell.pluginRegistry.resolveEnabledId(target) + return shell.pluginRegistry.installedPlugins[id] || null + } + + var api = pluginShellApiComponent.createObject(null, { + pluginId: target, + barConfig: shell.publicBarConfig(), + _summon: function(requestedId, payloadJson) { + if (!owns(requestedId) + && !shell.pluginCloneMaySummon(currentManifest(), requestedId)) return false + return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) + }, + _hide: function(requestedId) { + return owns(requestedId) + ? shell.hide(shell.pluginRegistry.resolveEnabledId(target)) : false + }, + _toggle: function(requestedId, payloadJson) { + return owns(requestedId) + ? shell.toggle(shell.pluginRegistry.resolveEnabledId(target), payloadJson) : false + }, + _isOpen: function(requestedId) { + return owns(requestedId) + ? shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(target)) : false + }, + _updateSettings: function(requestedId, settings) { + return String(requestedId || "") === target + ? shell.updateEntryInline(target, settings) : false + } + }) + if (!api) return null + var next = ({}) + for (var id in _pluginBarEntryShellApis) next[id] = _pluginBarEntryShellApis[id] + next[cacheKey] = api + _pluginBarEntryShellApis = next + return api + } + + function pluginShellFor(manifest) { + if (!manifest || manifest.__isFirstParty) return shell + var key = String(manifest.id || "") + if (!key) return null + return shell.createScopedPluginShell(manifest, key, true, shell.pluginHasBarCapabilities(manifest)) + } + + function pluginRegistryFor(manifest) { + if (!manifest || manifest.__isFirstParty) return shell.pluginRegistry + var key = String(manifest.id || "") + if (!key) return null + if (_pluginRegistryApis[key]) return _pluginRegistryApis[key] + + var api = pluginRegistryApiComponent.createObject(null, { + pluginId: key, + manifest: shell.publicPluginManifest(manifest), + enabled: shell.pluginRegistry.isEnabled(key), + _entryPointUrl: function(kind) { + var current = shell.pluginRegistry.installedPlugins[key] + return current ? shell.pluginRegistry.entryPointUrl(current, kind) : "" + } + }) + if (!api) return null + + var next = ({}) + for (var id in _pluginRegistryApis) next[id] = _pluginRegistryApis[id] + next[key] = api + _pluginRegistryApis = next + return api + } + + function pluginBarWidgetRegistryFor(manifest) { + if (!manifest || manifest.__isFirstParty) return shell.barWidgetRegistry + var key = String(manifest.id || "") + if (!key) return null + if (_pluginBarWidgetRegistryApis[key]) return _pluginBarWidgetRegistryApis[key] + + var api = pluginBarWidgetRegistryApiComponent.createObject(null, { + widgets: shell.publicBarWidgetSnapshot(), + revision: shell.barWidgetRegistry.revision + }) + if (!api) return null + + var next = ({}) + for (var id in _pluginBarWidgetRegistryApis) next[id] = _pluginBarWidgetRegistryApis[id] + next[key] = api + _pluginBarWidgetRegistryApis = next + return api + } + + function pluginApiActive(api, plugins) { + var id = api ? String(api.pluginId || api.ownerPluginId || "") : "" + var manifest = id ? plugins[id] : null + return !!manifest && shell.pluginRegistry.isEnabled(id) + } + + function prunePluginApis() { + var plugins = shell.pluginRegistry.installedPlugins + var shellKeys = Object.keys(_pluginShellApis) + for (var si = 0; si < shellKeys.length; si++) { + var shellKey = shellKeys[si] + var shellApi = _pluginShellApis[shellKey] + var descriptor = _pluginShellApiDescriptors[shellKey] + var manifest = descriptor ? plugins[descriptor.pluginId] : null + var barCapabilities = descriptor && descriptor.allowOwnService + && shell.pluginHasBarCapabilities(manifest) + var expectedProfile = descriptor + ? shell.pluginShellCapabilityProfile(manifest, descriptor.allowOwnService, barCapabilities) : "" + var active = descriptor && manifest && shell.pluginRegistry.isEnabled(descriptor.pluginId) + if (!active || descriptor.profile !== expectedProfile) + shell.revokePluginShellApi(shellKey) + } + + var registryNext = ({}) + for (var registryKey in _pluginRegistryApis) { + var registryApi = _pluginRegistryApis[registryKey] + if (shell.pluginApiActive(registryApi, plugins)) registryNext[registryKey] = registryApi + else if (registryApi && typeof registryApi.destroy === "function") registryApi.destroy() + } + _pluginRegistryApis = registryNext + + var widgetNext = ({}) + for (var widgetKey in _pluginBarWidgetRegistryApis) { + var widgetApi = _pluginBarWidgetRegistryApis[widgetKey] + if (plugins[widgetKey] && shell.pluginRegistry.isEnabled(widgetKey)) widgetNext[widgetKey] = widgetApi + else if (widgetApi && typeof widgetApi.destroy === "function") widgetApi.destroy() + } + _pluginBarWidgetRegistryApis = widgetNext + + var appNext = ({}) + for (var appKey in _pluginAppLibraryApis) { + var appApi = _pluginAppLibraryApis[appKey] + if (shell.pluginApiActive(appApi, plugins)) appNext[appKey] = appApi + else if (appApi && typeof appApi.destroy === "function") appApi.destroy() + } + _pluginAppLibraryApis = appNext + + var barStateNext = ({}) + for (var barStateKey in _pluginBarStateApis) { + var barStateApi = _pluginBarStateApis[barStateKey] + if (shell.pluginApiActive(barStateApi, plugins)) barStateNext[barStateKey] = barStateApi + else if (barStateApi && typeof barStateApi.destroy === "function") barStateApi.destroy() + } + _pluginBarStateApis = barStateNext + + var serviceNext = ({}) + for (var serviceKey in _pluginFirstPartyServiceApis) { + var serviceApi = _pluginFirstPartyServiceApis[serviceKey] + if (shell.pluginApiActive(serviceApi, plugins)) serviceNext[serviceKey] = serviceApi + else if (serviceApi && typeof serviceApi.destroy === "function") serviceApi.destroy() + } + _pluginFirstPartyServiceApis = serviceNext + + var entryNext = ({}) + for (var entryKey in _pluginBarEntryShellApis) { + var entryApi = _pluginBarEntryShellApis[entryKey] + if (entryApi && shell.barEntryConfigured(entryApi.pluginId)) entryNext[entryKey] = entryApi + else if (entryApi && typeof entryApi.destroy === "function") entryApi.destroy() + } + _pluginBarEntryShellApis = entryNext + } + + function syncPluginApis() { + shell.prunePluginApis() + var plugins = shell.pluginRegistry.installedPlugins + for (var id in _pluginRegistryApis) { + var registryApi = _pluginRegistryApis[id] + var manifest = plugins[id] + registryApi.manifest = shell.publicPluginManifest(manifest) + registryApi.enabled = !!manifest && shell.pluginRegistry.isEnabled(id) + } + for (var widgetId in _pluginBarWidgetRegistryApis) { + var widgetApi = _pluginBarWidgetRegistryApis[widgetId] + widgetApi.widgets = shell.publicBarWidgetSnapshot() + widgetApi.revision = shell.barWidgetRegistry.revision + } + for (var shellKey in _pluginShellApis) { + var shellApi = _pluginShellApis[shellKey] + var descriptor = _pluginShellApiDescriptors[shellKey] + var shellManifest = descriptor ? plugins[descriptor.pluginId] : null + shellApi.barConfig = shell.publicBarConfig() + shellApi.idleConfig = shell.publicIdleConfigFor(shellManifest) + } + for (var entryKey in _pluginBarEntryShellApis) + _pluginBarEntryShellApis[entryKey].barConfig = shell.publicBarConfig() + } + + // Reassigned as each service registers, so a binding that reads this before + // looking a service up by id re-evaluates once that service exists. + readonly property var services: _services function serviceFor(pluginId) { return _services[String(pluginId)] || null } function firstPartyServiceFor(pluginId) { - return serviceFor(pluginId) + return serviceFor(shell.pluginRegistry.resolveEnabledId(pluginId)) + } + + function isAuthenticationService(manifest, pluginId) { + var key = String(pluginId || (manifest && manifest.id) || "") + return AuthServiceStore.isTrusted(key) + || (!!manifest && Array.isArray(manifest.__hostCapabilities) + && manifest.__hostCapabilities.indexOf("authentication") !== -1) } function ensureService(pluginId) { @@ -290,6 +897,8 @@ ShellRoot { if (!manifest.entryPoints || !manifest.entryPoints.service) return null var url = pluginRegistry.entryPointUrl(manifest, "service") if (!url) return null + var authenticationService = shell.isAuthenticationService(manifest, key) + if (authenticationService && AuthServiceStore.has(key)) return null var comp = Qt.createComponent(url, Component.PreferSynchronous) function finalize() { @@ -297,27 +906,37 @@ ShellRoot { console.warn("service plugin load failed for " + key + ": " + comp.errorString()) return } - var inst = comp.createObject(serviceHost) + // Authentication services and third-party services have no visual + // parent. Parenting either to serviceHost would let a plugin's object + // traversal walk between the host and credential-bearing QML. + var inst = comp.createObject(manifest.__isFirstParty && !authenticationService ? serviceHost : null) if (!inst) { console.warn("service plugin createObject returned null for", key) return } if ("omarchyPath" in inst) inst.omarchyPath = shell.omarchyPath - if ("shell" in inst) inst.shell = shell - if ("manifest" in inst) inst.manifest = manifest - if ("barWidgetRegistry" in inst) inst.barWidgetRegistry = shell.barWidgetRegistry - if ("pluginRegistry" in inst) inst.pluginRegistry = shell.pluginRegistry - var snext = ({}) - for (var sk in _services) snext[sk] = _services[sk] - snext[key] = inst - _services = snext + if ("shell" in inst) inst.shell = shell.pluginShellFor(manifest) + if ("manifest" in inst) inst.manifest = shell.publicPluginManifest(manifest) + if ("barWidgetRegistry" in inst) inst.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest) + if ("pluginRegistry" in inst) inst.pluginRegistry = shell.pluginRegistryFor(manifest) + if (authenticationService) { + // Never publish lock/polkit through ShellRoot._services. The private JS + // import retains their lifetime without adding a traversable property + // or QObject parent back to the host shell. + AuthServiceStore.put(key, inst) + } else { + var snext = ({}) + for (var sk in _services) snext[sk] = _services[sk] + snext[key] = inst + _services = snext + } } if (comp.status === Component.Loading) { comp.statusChanged.connect(finalize) return null } finalize() - return _services[key] || null + return authenticationService ? null : (_services[key] || null) } function _syncServices() { @@ -329,33 +948,115 @@ ShellRoot { if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue if (!m.entryPoints || !m.entryPoints.service) continue if (!pluginRegistry.isEnabled(id)) continue - if (_services[id]) continue + var authenticationService = shell.isAuthenticationService(m, id) + if (_services[id]) { + if (authenticationService) { + // A service that gains a trusted authentication capability must move + // out of the host's public service map before it is recreated. + var published = _services[id] + if (published && typeof published.destroy === "function") published.destroy() + var withoutPublished = ({}) + for (var publishedId in _services) + if (publishedId !== id) withoutPublished[publishedId] = _services[publishedId] + _services = withoutPublished + } else { + // A kept instance outlives the rescan; hand it the fresh manifest. + var kept = _services[id] + if (kept && "shell" in kept) kept.shell = shell.pluginShellFor(m) + if (kept && "manifest" in kept) kept.manifest = shell.publicPluginManifest(m) + continue + } + } + if (AuthServiceStore.has(id)) { + if (authenticationService) { + AuthServiceStore.updateManifest(id, shell.publicPluginManifest(m)) + continue + } + // A service that loses its trusted authentication capability can move + // back to the ordinary service map only after the isolated copy dies. + AuthServiceStore.destroy(id) + } ensureService(id) } - // Drop services for plugins that have been disabled or removed. + // Drop services for plugins that have been disabled or removed, or that + // no longer declare a service entry point. for (var existingId in _services) { var stillThere = plugins[existingId] + var stillService = stillThere && Array.isArray(stillThere.kinds) + && stillThere.kinds.indexOf("service") !== -1 + && stillThere.entryPoints && stillThere.entryPoints.service var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId) - if (stillThere && stillEnabled) continue + if (stillService && stillEnabled) continue var inst = _services[existingId] if (inst && typeof inst.destroy === "function") inst.destroy() var next = ({}) for (var k in _services) if (k !== existingId) next[k] = _services[k] _services = next } + // Authentication services are retained outside the root object graph, so + // reconcile their disable/remove lifecycle separately from _services. + var authenticationIds = AuthServiceStore.ids() + for (var ai = 0; ai < authenticationIds.length; ai++) { + var authenticationId = authenticationIds[ai] + var authenticationManifest = plugins[authenticationId] + var stillAuthenticationService = authenticationManifest + && Array.isArray(authenticationManifest.kinds) + && authenticationManifest.kinds.indexOf("service") !== -1 + && authenticationManifest.entryPoints + && authenticationManifest.entryPoints.service + if (stillAuthenticationService && pluginRegistry.isEnabled(authenticationId) + && shell.isAuthenticationService(authenticationManifest, authenticationId)) continue + AuthServiceStore.destroy(authenticationId) + } } + function serviceKeepLoaded(pluginId) { + var plugins = pluginRegistry && pluginRegistry.installedPlugins + var manifest = plugins ? plugins[pluginId] : null + return !!(manifest && manifest.keepLoaded === true) + } + + // keepLoaded services (lock, idle, polkit) must survive plugin hot-reload. + // Destroying omarchy.lock drops the ext-session-lock client while Hyprland + // still holds the lock, which surfaces the crashed-lockscreen fallback. function unloadPluginServices() { + var next = ({}) for (var existingId in _services) { + if (serviceKeepLoaded(existingId)) { + next[existingId] = _services[existingId] + continue + } var inst = _services[existingId] if (inst && typeof inst.destroy === "function") inst.destroy() } - _services = ({}) + _services = next + var authenticationIds = AuthServiceStore.ids() + for (var ai = 0; ai < authenticationIds.length; ai++) { + var authenticationId = authenticationIds[ai] + if (!serviceKeepLoaded(authenticationId)) + AuthServiceStore.destroy(authenticationId) + } } Connections { target: shell.pluginRegistry - function onPluginsChanged() { if (!shell.pluginReloading) shell._syncServices() } + function onPluginsChanged() { + shell.syncPluginApis() + if (!shell.pluginReloading) shell._syncServices() + } + } + + Connections { + target: shell.barWidgetRegistry + function onChanged() { shell.syncPluginApis() } + } + + Connections { + target: shell.appLibrary + function onAppsChanged() { + for (var id in shell._pluginAppLibraryApis) + shell._pluginAppLibraryApis[id].appsChanged() + } } // Writes inline settings to a bar layout entry or top-level plugin entry in @@ -627,10 +1328,10 @@ ShellRoot { onLoaded: { if (!item) return if ("omarchyPath" in item) item.omarchyPath = shell.omarchyPath - if ("shell" in item) item.shell = shell - if ("manifest" in item) item.manifest = panelEntry.manifest - if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.barWidgetRegistry - if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistry + if ("shell" in item) item.shell = shell.pluginShellFor(panelEntry.manifest) + if ("manifest" in item) item.manifest = shell.publicPluginManifest(panelEntry.manifest) + if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(panelEntry.manifest) + if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistryFor(panelEntry.manifest) // Plugins that pair a panel UI with a service entry read shared // state off `service`. Hand them the matching singleton if one was // loaded. @@ -696,7 +1397,8 @@ ShellRoot { schema: meta.schema || [], pluginId: manifest.id, sourceDir: manifest.__sourceDir || "", - source: "plugin" + source: "plugin", + firstParty: !!manifest.__isFirstParty } // A load already in flight for this URL registers itself when it diff --git a/test/acceptance b/test/acceptance index 192e38f0..ef2da6b0 100755 --- a/test/acceptance +++ b/test/acceptance @@ -19,7 +19,13 @@ mkdir -p "$OMARCHY_ACCEPTANCE_DIR" # the session environment is inherited. export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}" -export OMARCHY_PATH="${OMARCHY_PATH:-$ROOT}" +# The suite verifies the installed product the session is running, so default +# OMARCHY_PATH to the installed tree — never this checkout, which may hold +# only test/ (omarchy-iso-test's --sync-omarchy). qs matches shell instances +# by config path, so a suite pointed at any other tree reads the session +# shell as "not running". Callers testing a different tree pass it explicitly. +export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" + export PATH="$OMARCHY_PATH/bin:$PATH" if [[ -z ${DISPLAY:-} ]]; then diff --git a/test/acceptance.d/base-test.sh b/test/acceptance.d/base-test.sh index 717007c9..023223a1 100644 --- a/test/acceptance.d/base-test.sh +++ b/test/acceptance.d/base-test.sh @@ -36,7 +36,9 @@ screen_contains() { local text="$1" local snapshot="/tmp/omarchy-acceptance-ocr-$$.png" - if ! timeout 10 grim "$snapshot" 2>/dev/null; then + # Capture at 2x scale: tesseract routinely drops small caption text at + # native resolution (the weather panel's detail labels, for one). + if ! timeout 10 grim -s 2 "$snapshot" 2>/dev/null; then rm -f "$snapshot" return 1 fi diff --git a/test/acceptance.d/cups-test.sh b/test/acceptance.d/cups-test.sh new file mode 100644 index 00000000..b2c82fe7 --- /dev/null +++ b/test/acceptance.d/cups-test.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +for package in cups cups-filters system-config-printer cups-pk-helper; do + pacman -Q "$package" >/dev/null 2>&1 || fail "printing packages are installed" "$package is missing" +done +pass "printing packages are installed" + +! pacman -Q cups-pdf >/dev/null 2>&1 || fail "the root CUPS-PDF backend is absent" +pass "the root CUPS-PDF backend is absent" + +! pacman -Q cups-browsed >/dev/null 2>&1 || fail "automatic printer discovery is absent" +! systemctl is-enabled --quiet cups-browsed.service 2>/dev/null || + fail "automatic printer discovery is not enabled" +! systemctl is-active --quiet cups-browsed.service 2>/dev/null || + fail "automatic printer discovery is not running" +! pgrep -x cups-browsed >/dev/null 2>&1 || fail "no cups-browsed process exists" +pass "automatic printer discovery is not installed or running" + +for path in \ + /etc/cups/cups-browsed.conf \ + /etc/cups/cups-browsed.conf.pacsave \ + /etc/cups/cups-browsed.conf.pacnew \ + /usr/bin/cups-browsed \ + /usr/lib/cups/backend/implicitclass \ + /usr/lib/systemd/system/cups-browsed.service \ + /etc/systemd/system/multi-user.target.wants/cups-browsed.service; do + [[ ! -e $path && ! -L $path ]] || + fail "automatic printer discovery leaves no package files" "$path still exists" +done +pass "automatic printer discovery leaves no package files" + +systemctl is-enabled --quiet cups.service || fail "CUPS is enabled" +systemctl is-active --quiet cups.service || fail "CUPS is running" +timeout 10 lpstat -r >/dev/null 2>&1 || fail "the CUPS scheduler answers" +pass "CUPS is enabled, running, and answering" + +policy_metadata=$(stat -c '%U:%G %a' /etc/cups/cups-files.conf) +[[ $policy_metadata == "root:cups 640" ]] || + fail "the CUPS authorization policy is protected" "$policy_metadata" +pass "the CUPS authorization policy is protected" + +if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then + fail "the desktop user cannot administer CUPS without authentication" +elif [[ $lpinfo_output != *"Forbidden"* ]]; then + fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" +fi +pass "CUPS denies unauthenticated desktop administration" diff --git a/test/acceptance.d/menu-test.sh b/test/acceptance.d/menu-test.sh index 39f20d67..500dc1e4 100644 --- a/test/acceptance.d/menu-test.sh +++ b/test/acceptance.d/menu-test.sh @@ -75,7 +75,7 @@ wtype -k Return wait_until "style submenu is visible" 15 screen_contains "Theme" screenshot "success-menu-03-style-submenu" -wtype -k Down -k Down -k Down -k Return +wtype -k Down -k Down -k Down -k Down -k Return sleep 1 screenshot "success-menu-04-menu-bar-submenu" diff --git a/test/acceptance.d/security-test.sh b/test/acceptance.d/security-test.sh new file mode 100755 index 00000000..4dc96ad9 --- /dev/null +++ b/test/acceptance.d/security-test.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# +# Verifies the security posture of an installed system: the unprivileged +# session-to-root paths closed for 4.0.2 (blanket input-group grant, shipped +# asdcontrol sudoers authorization) and the SSH hardening flow. +# +# The sshd section reconfigures the machine (enables sshd, opens the firewall, +# disables password logins), so it demands explicit opt-in: it only runs when +# OMARCHY_ACCEPTANCE_SUDO_PASSWORD is set, which omarchy-iso-test does for its +# throwaway VMs. A cached sudo timestamp alone never triggers it, so running +# the suite on a machine you care about cannot reconfigure sshd by accident. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Membership of `input` gives raw access to /dev/input/event*: any process +# running as the user could log keystrokes. Only the opt-in controller and +# ydotool features may grant it. +verify_input_group() { + if id -nG | grep -qw input; then + if pacman -Q xpadneo-dkms &>/dev/null || pacman -Q ydotool &>/dev/null; then + pass "input group membership is backed by an opt-in feature" + else + fail "user is not in the input group" "no controller or ydotool support installed to justify it" + fi + else + pass "user is not in the input group" + fi +} + +sudo_available() { + if sudo -n true 2>/dev/null; then + return 0 + fi + + if [[ -n ${OMARCHY_ACCEPTANCE_SUDO_PASSWORD:-} ]]; then + printf '%s\n' "$OMARCHY_ACCEPTANCE_SUDO_PASSWORD" | sudo -S -v 2>/dev/null + return $? + fi + + return 1 +} + +verify_asdcontrol_sudoers() { + # Omarchy used to ship a passwordless sudoers grant for asdcontrol; that + # authorization now belongs to the package alone. + if sudo -n test -e /etc/sudoers.d/omarchy-asdcontrol; then + fail "no omarchy asdcontrol sudoers grant is shipped" "/etc/sudoers.d/omarchy-asdcontrol exists" + fi + pass "no omarchy asdcontrol sudoers grant is shipped" +} + +verify_sshd_hardening() { + local key_file=/tmp/omarchy-acceptance-sshd-key + local effective_config + + rm -f "$key_file" "$key_file.pub" + ssh-keygen -t ed25519 -N "" -q -C "omarchy-acceptance" -f "$key_file" + + # sudo keys its cached credential on the calling terminal and, absent one, on + # the caller's parent process alone, so a timestamp validated in this shell + # never reaches the setup command's own sudo calls when the suite runs + # without a terminal (omarchy-iso-test drives it over ssh with no pty). Give + # the exercise a pseudo-terminal and validate the password on it first, so + # every sudo underneath shares that terminal's credential. + if ! OMARCHY_ACCEPTANCE_SUDO_PASSWORD="$OMARCHY_ACCEPTANCE_SUDO_PASSWORD" \ + OMARCHY_ACCEPTANCE_SSHD_KEY="$(cat "$key_file.pub")" SHELL=/bin/bash \ + script -qec 'printf "%s\n" "$OMARCHY_ACCEPTANCE_SUDO_PASSWORD" | sudo -S -v 2>/dev/null && + omarchy-setup-security-sshd --key="$OMARCHY_ACCEPTANCE_SSHD_KEY"' /dev/null \ + "$ARTIFACTS/setup-security-sshd.log" 2>&1; then + fail "omarchy-setup-security-sshd completes unattended" "$(tail -5 "$ARTIFACTS/setup-security-sshd.log")" + fi + pass "omarchy-setup-security-sshd completes unattended" + + systemctl is-active sshd.service >/dev/null || fail "sshd is running after setup" + pass "sshd is running after setup" + + grep -qxF "$(cat "$key_file.pub")" "$HOME/.ssh/authorized_keys" || fail "the key is authorized" + pass "the key is authorized" + + # The command verifies its own hardening before keeping it, but assert the + # effective config independently: sshd honors the first value it reads, and + # regressions here reopen password logins. Keywords match case-insensitively + # because OpenSSH 9.x dumps them lowercase and 10.x in CamelCase. + effective_config=$(sudo -n sshd -T) || fail "sshd reports its effective config" + grep -qixF "passwordauthentication no" <<<"$effective_config" || fail "password authentication is off" + pass "password authentication is off" + grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config" || fail "keyboard-interactive authentication is off" + pass "keyboard-interactive authentication is off" + + if omarchy-cmd-present ufw; then + sudo -n ufw status | grep -qE '^22/tcp\s+LIMIT' || fail "the SSH port is rate limited in the firewall" + pass "the SSH port is rate limited in the firewall" + fi + + # Leave the machine as found where cheap: the throwaway key stays useless + # once removed, while the hardening itself is the state under test. + sed -i "\#$(cat "$key_file.pub" | cut -d' ' -f2)#d" "$HOME/.ssh/authorized_keys" + rm -f "$key_file" "$key_file.pub" +} + +verify_input_group + +if sudo_available; then + verify_asdcontrol_sudoers +else + pass "asdcontrol sudoers check skipped: sudo needs a password" +fi + +if [[ -n ${OMARCHY_ACCEPTANCE_SUDO_PASSWORD:-} ]] && sudo_available; then + verify_sshd_hardening +else + pass "sshd hardening exercise skipped: set OMARCHY_ACCEPTANCE_SUDO_PASSWORD to run it" +fi diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index e8e4944e..af0d5204 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -8,17 +8,36 @@ status=0 verify_core_packages() { local package + local manifest="$OMARCHY_PATH/install/omarchy-base.packages" local -a missing=() + # Without this, a missing manifest reads as an empty package list and the + # audit passes having checked nothing. + [[ -f $manifest ]] || fail "all Omarchy core packages are installed" "package manifest not found: $manifest" + while IFS= read -r package; do [[ -z $package || $package == \#* ]] && continue pacman -Q "$package" >/dev/null 2>&1 || missing+=("$package") - done <"$OMARCHY_PATH/install/omarchy-base.packages" + done <"$manifest" (( ${#missing[@]} == 0 )) || fail "all Omarchy core packages are installed" "missing packages: ${missing[*]}" pass "all Omarchy core packages are installed (${#missing[@]} missing)" } +verify_kernel_headers() { + local kernel=linux-omarchy + local release + release=$(uname -r) + omarchy-pkg-present linux-t2 && kernel=linux-t2 + + [[ $(cat "/usr/lib/modules/$release/pkgbase") == "$kernel" ]] || + fail "the installed system boots the supported kernel" "$release is not $kernel" + omarchy-pkg-present "$kernel-headers" || fail "kernel headers are installed" "$kernel-headers is missing" + [[ $(cat "/usr/lib/modules/$release/build/include/config/kernel.release") == "$release" ]] || + fail "headers match the running kernel" "$release has missing or mismatched headers" + pass "the running $kernel kernel has matching headers ($release)" +} + verify_defaults() { [[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium is the default browser" pass "Chromium is the default browser" @@ -47,7 +66,7 @@ verify_services() { local unit for unit in \ - avahi-daemon.service cups.service cups-browsed.service docker.socket \ + avahi-daemon.service docker.socket \ NetworkManager.service power-profiles-daemon.service sddm.service \ systemd-resolved.service ufw.service; do systemctl is-enabled --quiet "$unit" || fail "core system services are enabled" "$unit is not enabled" @@ -65,8 +84,20 @@ verify_services() { } verify_runtime_tools() { - timeout 20 docker info >/dev/null 2>&1 || fail "Docker is usable by the desktop user" - pass "Docker is usable by the desktop user" + # Docker access is intentionally NOT granted to the desktop user: the docker + # group is root-equivalent, so a rogue process running as the user could + # otherwise `docker run -v /:/host` its way to passwordless root. The daemon is + # still enabled (docker.socket, checked in verify_services) and reached through + # a polkit/sudo prompt; opting into sudoless Docker is a separate, warned step. + command -v docker >/dev/null 2>&1 || fail "Docker CLI is installed" + ! id -nG | grep -qw docker || fail "desktop user must not be in the docker group" + # The group name being absent is not sufficient — a world-writable socket or an + # ACL would still hand the user the root daemon. Prove it is actually + # unreachable without elevation. + if timeout 10 docker info >/dev/null 2>&1; then + fail "desktop user must not reach the Docker daemon without elevation" + fi + pass "Docker is installed but unreachable by the desktop user without elevation" nvim --headless '+qa' >/dev/null 2>&1 || fail "Neovim starts headlessly" pass "Neovim starts headlessly" @@ -95,7 +126,7 @@ verify_user_setup() { pass "Omarchy user state and shell configuration exist" } -for check in verify_core_packages verify_defaults verify_services verify_runtime_tools verify_user_setup; do +for check in verify_core_packages verify_kernel_headers verify_defaults verify_services verify_runtime_tools verify_user_setup; do if ! ("$check"); then status=1 fi diff --git a/test/cli b/test/cli index 80b981d5..1643d2db 100755 --- a/test/cli +++ b/test/cli @@ -519,101 +519,6 @@ expected_background="$THEME_TMPDIR/.local/state/omarchy/current/theme/background [[ ! -e $THEME_TMPDIR/.config/omarchy/current ]] || fail "headless theme set avoids config current state" pass "headless theme set creates current background symlink" -make_tmpdir MIGRATION_TMPDIR -mkdir -p \ - "$MIGRATION_TMPDIR/.config/omarchy/current/theme" \ - "$MIGRATION_TMPDIR/.config/alacritty" \ - "$MIGRATION_TMPDIR/.config/hypr" \ - "$MIGRATION_TMPDIR/.config/hyprland-preview-share-picker" \ - "$MIGRATION_TMPDIR/.config/btop/themes" -printf 'tokyo-night\n' >"$MIGRATION_TMPDIR/.config/omarchy/current/theme.name" -printf 'x\n' >"$MIGRATION_TMPDIR/.config/omarchy/current/theme/colors.toml" -printf 'general.import = [ "~/.config/omarchy/current/theme/alacritty.toml" ]\n' >"$MIGRATION_TMPDIR/.config/alacritty/alacritty.toml" -printf 'stylesheets: ["../omarchy/current/theme/hyprland-preview-share-picker.css"]\n' >"$MIGRATION_TMPDIR/.config/hyprland-preview-share-picker/config.yaml" -cat >"$MIGRATION_TMPDIR/.config/hypr/hyprland.lua" <<'LUA' -package.path = os.getenv("HOME") - .. "/.config/?.lua;" - .. (os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") - .. "/?.lua;" - .. package.path -LUA -ln -s "$MIGRATION_TMPDIR/.config/omarchy/current/theme/btop.theme" "$MIGRATION_TMPDIR/.config/btop/themes/current.theme" -HOME="$MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1781043107.sh" >/dev/null -[[ -f $MIGRATION_TMPDIR/.local/state/omarchy/current/theme.name ]] || fail "current theme migration moves theme name" -[[ ! -e $MIGRATION_TMPDIR/.config/omarchy/current ]] || fail "current theme migration removes legacy config state" -grep -Fq '~/.local/state/omarchy/current/theme/alacritty.toml' "$MIGRATION_TMPDIR/.config/alacritty/alacritty.toml" || fail "current theme migration updates alacritty import" -grep -Fq '../../.local/state/omarchy/current/theme/hyprland-preview-share-picker.css' "$MIGRATION_TMPDIR/.config/hyprland-preview-share-picker/config.yaml" || fail "current theme migration updates relative stylesheet" -grep -Fq '/.local/state/?.lua;' "$MIGRATION_TMPDIR/.config/hypr/hyprland.lua" || fail "current theme migration keeps Hyprland theme modules discoverable" -[[ $(readlink "$MIGRATION_TMPDIR/.config/btop/themes/current.theme") == "$MIGRATION_TMPDIR/.local/state/omarchy/current/theme/btop.theme" ]] || fail "current theme migration updates btop symlink" -pass "current theme migration moves state and rewrites shipped references" - -make_tmpdir NVIM_MIGRATION_TMPDIR -nvim_theme_link="$NVIM_MIGRATION_TMPDIR/.config/nvim/lua/plugins/theme.lua" -nvim_expected_target="../../../../.local/state/omarchy/current/theme/neovim.lua" -nvim_expected_resolved_target="$NVIM_MIGRATION_TMPDIR/.local/state/omarchy/current/theme/neovim.lua" -mkdir -p \ - "$(dirname "$nvim_theme_link")" \ - "$NVIM_MIGRATION_TMPDIR/.config/omarchy/current/theme" \ - "$NVIM_MIGRATION_TMPDIR/.local/state/omarchy/current/theme" -touch "$NVIM_MIGRATION_TMPDIR/.local/state/omarchy/current/theme/neovim.lua" -for nvim_legacy_target in \ - "../../../omarchy/current/theme/neovim.lua" \ - "$NVIM_MIGRATION_TMPDIR/.config/omarchy/current/theme/neovim.lua"; do - ln -sfn "$nvim_legacy_target" "$nvim_theme_link" - HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1781158082.sh" >/dev/null - nvim_actual_target=$(readlink "$nvim_theme_link") - nvim_resolved_target=$(readlink -f "$nvim_theme_link") - [[ $nvim_actual_target == $nvim_expected_target ]] || fail "nvim theme migration updates theme symlink" - [[ $nvim_resolved_target == $nvim_expected_resolved_target ]] || fail "nvim theme migration points to current theme" -done -pass "nvim theme migration relinks current theme" - -for nvim_legacy_target in \ - "../../../omarchy/current/theme/neovim.lua" \ - "../../../../.config/omarchy/current/theme/neovim.lua" \ - "~/.config/omarchy/current/theme/neovim.lua" \ - "$NVIM_MIGRATION_TMPDIR/.config/omarchy/current/theme/neovim.lua"; do - ln -sfn "$nvim_legacy_target" "$nvim_theme_link" - HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null - nvim_actual_target=$(readlink "$nvim_theme_link") - nvim_resolved_target=$(readlink -f "$nvim_theme_link") - [[ $nvim_actual_target == $nvim_expected_target ]] || fail "nvim theme repair migration updates $nvim_legacy_target" - [[ $nvim_resolved_target == $nvim_expected_resolved_target ]] || fail "nvim theme repair migration points to current theme" -done - -HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null -[[ $(readlink "$nvim_theme_link") == $nvim_expected_target ]] || fail "nvim theme repair migration is idempotent" - -touch "$NVIM_MIGRATION_TMPDIR/custom-theme.lua" -ln -sfn "../../../../custom-theme.lua" "$nvim_theme_link" -HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null -[[ $(readlink "$nvim_theme_link") == "../../../../custom-theme.lua" ]] || fail "nvim theme repair migration leaves custom symlinks alone" -pass "nvim theme repair migration relinks every legacy spelling" - -make_tmpdir HYPR_MIGRATION_TMPDIR -mkdir -p "$HYPR_MIGRATION_TMPDIR/.config/hypr" -cat >"$HYPR_MIGRATION_TMPDIR/.config/hypr/hyprland.lua" <<'LUA' --- Learn how to configure Hyprland: https://wiki.hypr.land/Configuring/Start/ - --- Load user modules from ~/.config and Omarchy defaults from $OMARCHY_PATH. -package.path = os.getenv("HOME") - .. "/.config/?.lua;" - .. (os.getenv("OMARCHY_PATH") or (os.getenv("HOME") .. "/.local/share/omarchy")) - .. "/?.lua;" - .. package.path - --- All Omarchy default setups -require("default.hypr.omarchy") - --- Add any other personal Hyprland configuration below. -o.window("Example", { workspace = "1" }) -LUA -HOME="$HYPR_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1781063758.sh" >/dev/null -grep -Fq 'dofile((os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") .. "/default/hypr/bootstrap.lua")' "$HYPR_MIGRATION_TMPDIR/.config/hypr/hyprland.lua" || fail "hyprland bootstrap migration adds bootstrap" -! grep -Fq 'package.path = os.getenv("HOME")' "$HYPR_MIGRATION_TMPDIR/.config/hypr/hyprland.lua" || fail "hyprland bootstrap migration removes legacy path block" -grep -Fq 'o.window("Example", { workspace = "1" })' "$HYPR_MIGRATION_TMPDIR/.config/hypr/hyprland.lua" || fail "hyprland bootstrap migration preserves custom config" -pass "hyprland bootstrap migration updates stale user entrypoint" - cp "$NEXT_THEME/colors.toml" "$CURRENT_THEME/colors.toml" cp "$NEXT_THEME/vscode-theme.json" "$CURRENT_THEME/vscode-theme.json" diff --git a/test/shell b/test/shell index c7d7e5fc..34a2ca49 100755 --- a/test/shell +++ b/test/shell @@ -21,16 +21,31 @@ fi # Keep going after a failing file. A test file exits at its first failed # assertion, so aborting the run there too would hide every file after it -- # one packaging failure was masking 114 of 134 files. +output=$(mktemp) +trap 'rm -f "$output"' EXIT failed=() +skipped=() for test in "${tests[@]}"; do printf '==> %s\n' "${test#$ROOT/}" - bash "$test" || failed+=("${test#$ROOT/}") + bash "$test" | tee "$output" || failed+=("${test#$ROOT/}") + if grep -q '^ok - .* # SKIP$' "$output"; then + skipped+=("${test#$ROOT/}") + fi done +if (( ${#skipped[@]} > 0 )); then + printf '\nSkipped checks in %d of %d test files:\n' "${#skipped[@]}" "${#tests[@]}" + printf ' %s\n' "${skipped[@]}" +fi + if (( ${#failed[@]} > 0 )); then printf '\n%d of %d test files failed:\n' "${#failed[@]}" "${#tests[@]}" >&2 printf ' %s\n' "${failed[@]}" >&2 exit 1 fi -printf '\nAll %d test files passed.\n' "${#tests[@]}" +if (( ${#skipped[@]} == 0 )); then + printf '\nAll %d test files passed.\n' "${#tests[@]}" +else + printf '\n%d test files completed without failures; %d had skipped checks.\n' "${#tests[@]}" "${#skipped[@]}" +fi diff --git a/test/shell.d/agent-invitation-test.sh b/test/shell.d/agent-invitation-test.sh index 11de87d9..e54603fd 100644 --- a/test/shell.d/agent-invitation-test.sh +++ b/test/shell.d/agent-invitation-test.sh @@ -18,10 +18,12 @@ mkdir -p "$(dirname "$hook_path")" cat >"$test_bin/omarchy-notification-send" <<'EOF' #!/bin/bash echo notification >>"$TEST_LOG" +exec_args=() while (($# > 0)); do - [[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG" + if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi shift done +((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG" EOF chmod +x "$test_bin/omarchy-notification-send" diff --git a/test/shell.d/agent-usage-codex-scanner-test.sh b/test/shell.d/agent-usage-codex-scanner-test.sh index f3f9aa1e..3ce4883c 100644 --- a/test/shell.d/agent-usage-codex-scanner-test.sh +++ b/test/shell.d/agent-usage-codex-scanner-test.sh @@ -13,6 +13,10 @@ mkdir -p "$TEST_HOME/.codex/sessions/$(date +%Y/%m/%d)" "$TEST_HOME/bin" cat >"$TEST_HOME/bin/codex" <<'EOF' #!/bin/bash +if [[ -n ${CODEX_ARGS_FILE:-} ]]; then + printf '%s\0' "$@" >"$CODEX_ARGS_FILE" +fi + while read -r request; do id=$(jq -r '.id // empty' <<<"$request") method=$(jq -r '.method // empty' <<<"$request") @@ -40,9 +44,18 @@ cat >"$session" <"$test_dir/bin/omarchy-restart-shell" <<'STUB' -#!/bin/bash - -echo restart >>"$SHELL_RESTARTS" -STUB - -chmod +x "$test_dir/bin/"* - -export SHELL_RESTARTS="$test_dir/shell-restarts" - -home="$test_dir/home" -config="$home/.config/omarchy/shell.json" - -run_migration() { - : >"$SHELL_RESTARTS" - HOME="$home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null -} - -# The shipped default minus the widget is what every machine installed before -# this migration has on disk. -write_config() { - rm -rf "$home" - mkdir -p "$home/.config/omarchy" - jq "${1:-.}" "$ROOT/config/omarchy/shell.json" >"$config" -} - -without_widget='del(.bar.layout[][] | select((if type == "object" then .id else . end) == "omarchy.agents"))' - -ids() { - jq -c --arg section "$1" '[.bar.layout[$section][]? | if type == "object" then .id else . end]' "$config" -} - -# ------------------------------------------------------------------ shipped default - -jq -e '[.bar.layout.right[].id] | index("omarchy.agents")' "$ROOT/config/omarchy/shell.json" >/dev/null || - fail "shipped config puts the agents widget in the bar" -pass "shipped config puts the agents widget in the bar" - -# ------------------------------------------------------------------ placement - -write_config "$without_widget" -run_migration - -[[ $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.bluetooth","omarchy.network","omarchy.audio","omarchy.monitor","omarchy.power"]' ]] || - fail "migration inserts the agents widget after the tray" "$(ids right)" -pass "migration inserts the agents widget after the tray" - -(($(wc -l <"$SHELL_RESTARTS") == 0)) || fail "migration leaves the shell restart to omarchy update" -pass "migration leaves the shell restart to omarchy update" - -before=$(sha256sum "$config") -run_migration -[[ $before == $(sha256sum "$config") ]] || fail "migration is idempotent" "$(ids right)" -pass "migration is idempotent" - -# ------------------------------------------------------------------ curated bars - -# A user who already placed the widget keeps it exactly where they put it, in -# whichever section, and never gets a second copy. -write_config "$without_widget | .bar.layout.center += [{ id: \"omarchy.agents\" }]" -run_migration - -[[ $(ids center) == *'"omarchy.agents"'* ]] || fail "migration leaves a user-placed widget alone" "$(ids center)" -[[ $(ids right) != *'"omarchy.agents"'* ]] || fail "migration does not add a second copy" "$(ids right)" -pass "migration respects a widget the user already placed" - -# Layouts written before entries grew options are bare id strings. -write_config "$without_widget | .bar.layout.right = [\"omarchy.tray\", \"omarchy.agents\", \"omarchy.power\"]" -run_migration - -[[ $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.power"]' ]] || - fail "migration reads string-form entries" "$(ids right)" -pass "migration reads string-form entries" - -# A tray dropped from the right section must not strand the widget or drop it. -write_config "$without_widget | del(.bar.layout.right[] | select(.id == \"omarchy.tray\"))" -run_migration - -[[ $(ids right) == '["omarchy.agents",'* ]] || fail "migration places the widget without a tray" "$(ids right)" -pass "migration places the widget without a tray" - -# ------------------------------------------------------------------ everything else - -write_config "$without_widget" -cp "$config" "$test_dir/before.json" -run_migration - -diff <(jq -S 'del(.bar.layout.right)' "$test_dir/before.json") <(jq -S 'del(.bar.layout.right)' "$config") >/dev/null || - fail "migration touches nothing but the right section" "$(diff <(jq -S . "$test_dir/before.json") <(jq -S . "$config"))" -pass "migration touches nothing but the right section" - -# A config the migration cannot parse is left alone rather than truncated. -rm -rf "$home" -mkdir -p "$home/.config/omarchy" -printf '{ not json' >"$config" -run_migration - -[[ $(cat "$config") == '{ not json' ]] || fail "migration leaves an unparsable config untouched" "$(cat "$config")" -pass "migration leaves an unparsable config untouched" diff --git a/test/shell.d/app-search-test.sh b/test/shell.d/app-search-test.sh index d0b6a3d1..5ebd1cfe 100644 --- a/test/shell.d/app-search-test.sh +++ b/test/shell.d/app-search-test.sh @@ -55,6 +55,14 @@ const entries = [ } ] +// Keep the packaged launcher when upstream rebuilds register their own entry. +const configuredHides = new Set(fs.readFileSync(path.join(root, 'default/omarchy/launcher.hides'), 'utf8').trim().split(/\n/)) +const hermesEntries = [{ name: 'Hermes', id: 'hermes' }, { name: 'Hermes', id: 'hermes-desktop' }] +for (const query of ['', 'hermes']) { + const visible = search.sortedEntries(hermesEntries, query, entry => configuredHides.has(entry.id)) + assertDeepEqual(visible.map(row => row.entry.id), ['hermes-desktop'], 'only the packaged Hermes launcher is visible') +} + const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry)) assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches') diff --git a/test/shell.d/apply-lock-test.sh b/test/shell.d/apply-lock-test.sh new file mode 100644 index 00000000..490e7c8c --- /dev/null +++ b/test/shell.d/apply-lock-test.sh @@ -0,0 +1,206 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +apply_lock="$ROOT/bin/omarchy-apply-lock" + +root_path_guard=$(awk ' + /^if \(\( EUID == 0 \)\); then$/ { inside = 1 } + inside { print } + inside && /^fi$/ { exit } +' "$apply_lock") +grep -Fx ' export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin' <<<"$root_path_guard" >/dev/null || + fail "the root lock helper replaces its inherited command path" +if grep -E '(\.local/bin|target_user|target_home)' <<<"$root_path_guard" >/dev/null; then + fail "the root lock helper does not retain a user-controlled command directory" +fi +pass "the root lock helper uses only trusted command directories" + +grep -F '[[ -x /usr/bin/fprintd-list ]]' "$apply_lock" >/dev/null || + fail "the lock helper checks the trusted fprintd-list executable" +grep -F '/usr/bin/fprintd-list "$target_user"' "$apply_lock" >/dev/null || + fail "the lock helper invokes fprintd-list by its trusted absolute path" +if grep -F 'omarchy-cmd-present fprintd-list' "$apply_lock" >/dev/null || + grep -E '(^|[[:space:];&|])fprintd-list([[:space:]]|$)' "$apply_lock" >/dev/null || + grep -E 'command[[:space:]]+-v[[:space:]]+fprintd-list' "$apply_lock" >/dev/null; then + fail "the lock helper does not resolve fprintd-list through PATH" +fi +pass "the lock helper pins fprintd-list to its packaged system path" + +# Exercise the helper as real root when the suite already has it, or as root in +# an unprivileged user namespace otherwise. A hardened kernel can disable user +# namespaces, so preserve the static coverage above and skip only this probe. +root_runner=() +root_runtime_available=1 +if (( EUID != 0 )); then + if command -v unshare >/dev/null && unshare --user --map-root-user true 2>/dev/null; then + root_runner=(unshare --user --map-root-user) + else + root_runtime_available=0 + fi +fi + +if (( ! root_runtime_available )); then + skip "no unprivileged user namespace; skipping the root lock-helper lookup matrix" + exit 0 +fi + +# Retarget the two PAM files, the trusted fprintd-list binary, and the final +# shell status query in copies under this scratch directory. The production +# files and service stay untouched even when this suite itself runs as root. +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +poison_bin="$test_tmp/poison-bin" +trusted_root_bin="$test_tmp/trusted-root-bin" +trusted_fprintd="$test_tmp/trusted-fprintd-list" +password_pam="$test_tmp/omarchy-lock-password" +fingerprint_pam="$test_tmp/omarchy-lock-fingerprint" +attack_marker="$test_tmp/user-fprintd-list-ran" +trusted_uid="$test_tmp/trusted-fprintd-list.uid" +trusted_args="$test_tmp/trusted-fprintd-list.args" +attack_args="$test_tmp/user-fprintd-list.args" +patched_helper="$test_tmp/omarchy-apply-lock-patched" +absolute_only_helper="$test_tmp/omarchy-apply-lock-absolute-only" +root_path_only_helper="$test_tmp/omarchy-apply-lock-root-path-only" +unprotected_helper="$test_tmp/omarchy-apply-lock-unprotected" +target_user=omarchy-regression-user +mkdir -p "$poison_bin" "$trusted_root_bin" + +# The runtime copy pins to this isolated root path. It contains every bare +# command the exercised helper needs, but deliberately no fprintd-list. +for helper in grep rm tee; do + ln -s "/usr/bin/$helper" "$trusted_root_bin/$helper" +done + +export TEST_ATTACK_ARGS="$attack_args" +export TEST_ATTACK_MARKER="$attack_marker" +export TEST_TRUSTED_ARGS="$trusted_args" +export TEST_TRUSTED_UID="$trusted_uid" + +cat >"$trusted_fprintd" <<'EOF' +#!/bin/bash + +printf '%s\n' "$EUID" >"$TEST_TRUSTED_UID" +printf '%s\n' "$*" >"$TEST_TRUSTED_ARGS" +echo "Fingerprints are enrolled" +EOF + +cat >"$poison_bin/fprintd-list" <<'EOF' +#!/bin/bash + +printf '%s\n' "$EUID" >"$TEST_ATTACK_MARKER" +printf '%s\n' "$*" >"$TEST_ATTACK_ARGS" +echo "Fingerprints are enrolled" +EOF + +chmod +x "$trusted_fprintd" "$poison_bin/fprintd-list" + +prepare_helper() { + local destination="$1" keep_root_path="$2" use_absolute_fprintd="$3" + + awk \ + -v password_pam="$password_pam" \ + -v fingerprint_pam="$fingerprint_pam" \ + -v trusted_root_bin="$trusted_root_bin" \ + -v trusted_fprintd="$trusted_fprintd" \ + -v keep_root_path="$keep_root_path" \ + -v use_absolute_fprintd="$use_absolute_fprintd" ' + { + line = $0 + gsub("/etc/pam\\.d/omarchy-lock-password", "\"" password_pam "\"", line) + gsub("/etc/pam\\.d/omarchy-lock-fingerprint", "\"" fingerprint_pam "\"", line) + + if (line == "if (( EUID == 0 )); then" && keep_root_path == 0) { + print "if (( 0 )); then" + next + } + if (line == " export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin") { + print " export PATH=\"" trusted_root_bin "\"" + next + } + if (line == "if [[ -x /usr/bin/fprintd-list ]] &&") { + if (use_absolute_fprintd == 1) { + print "if [[ -x \"" trusted_fprintd "\" ]] &&" + } else { + print "if command -v fprintd-list >/dev/null 2>&1 &&" + } + next + } + if (line == " /usr/bin/fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then") { + if (use_absolute_fprintd == 1) { + print " \"" trusted_fprintd "\" \"$target_user\" 2>/dev/null | grep -qi finger; then" + } else { + print " fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then" + } + next + } + if (line == "if omarchy-shell lock status >/dev/null 2>&1; then") { + print "if false; then" + next + } + + print line + } + ' "$apply_lock" >"$destination" + chmod +x "$destination" +} + +prepare_helper "$patched_helper" 1 1 +prepare_helper "$absolute_only_helper" 0 1 +prepare_helper "$root_path_only_helper" 1 0 +prepare_helper "$unprotected_helper" 0 0 + +for helper in "$patched_helper" "$absolute_only_helper" "$root_path_only_helper" "$unprotected_helper"; do + if grep -F '/etc/pam.d/' "$helper" >/dev/null || + grep -F '/usr/bin/fprintd-list' "$helper" >/dev/null || + grep -F 'omarchy-shell lock status' "$helper" >/dev/null; then + fail "the isolated root fixture redirects every live-system lock-helper target" + fi +done + +reset_runtime_files() { + rm -f "$password_pam" "$fingerprint_pam" "$trusted_uid" "$trusted_args" "$attack_marker" "$attack_args" +} + +run_as_root() { + local helper="$1" description="$2" output + + if ! output=$(PATH="$poison_bin:/usr/bin:/bin" OMARCHY_INSTALL_USER="$target_user" \ + "${root_runner[@]}" /bin/bash "$helper" 2>&1); then + fail "$description" "$output" + fi +} + +reset_runtime_files +run_as_root "$patched_helper" "the fully hardened lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "the hardened root lock helper executes the user-planted fprintd-list" +grep -Fx '0' "$trusted_uid" >/dev/null || fail "the trusted fprintd-list probe runs with EUID 0" +grep -Fx "$target_user" "$trusted_args" >/dev/null || fail "the trusted fprintd-list probe receives the target user" +[[ -s $password_pam && -s $fingerprint_pam ]] || + fail "the isolated root lock-helper run writes both scratch PAM fixtures" +pass "the hardened root lock helper uses the trusted fingerprint probe" + +reset_runtime_files +run_as_root "$absolute_only_helper" "the absolute-path-only lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "an absolute fprintd-list path permits the user-planted command" +grep -Fx '0' "$trusted_uid" >/dev/null || fail "the absolute-path defense runs the trusted probe as root" +pass "the absolute fprintd-list path independently blocks the user-planted command" + +reset_runtime_files +run_as_root "$root_path_only_helper" "the root-PATH-only lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "the trusted root path permits the user-planted fprintd-list" +pass "the trusted root path independently blocks the user-planted command" + +# Mutation control: removing both protections must execute the planted command +# as UID 0, proving the matrix detects the original privilege-boundary failure. +reset_runtime_files +run_as_root "$unprotected_helper" "the unprotected mutation runs in an isolated root context" +grep -Fx '0' "$attack_marker" >/dev/null || + fail "the root lock-helper fixture detects a PATH-resolved fprintd-list regression" +grep -Fx "$target_user" "$attack_args" >/dev/null || + fail "the planted fprintd-list receives the target user" +[[ -s $fingerprint_pam ]] || fail "the planted fprintd-list controls the fingerprint PAM branch" +pass "the root lock-helper matrix rejects the vulnerable PATH lookup" diff --git a/test/shell.d/ascii-test.sh b/test/shell.d/ascii-test.sh new file mode 100755 index 00000000..e30e3ea0 --- /dev/null +++ b/test/shell.d/ascii-test.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +export PATH="$ROOT/bin:$PATH" + +columns() { + awk 'NR == 1 { print length($0) }' +} + +# The wordmark FIGlet itself draws for this font, so a change to the embedded +# font or to the kerning shows up here rather than in someone's terminal. +expected=$( + cat <<'WORDMARK' + ▄██████▄ ▄▄▄▄███▄▄▄▄ ▄████████ ▄████████ ▄████████ ▄█ █▄ ▄██ ▄ +███ ███ ▄██▀▀▀███▀▀▀██▄ ███ ███ ███ ███ ███ ███ ███ ███ ███ ██▄ +███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▀ ███ ███ ███▄▄▄███ +███ ███ ███ ███ ███ ███ ███ ▄███▄▄▄▄██▀ ███ ▄███▄▄▄▄███▄▄ ▀▀▀▀▀▀███ +███ ███ ███ ███ ███ ▀███████████ ▀▀███▀▀▀▀▀ ███ ▀▀███▀▀▀▀███▀ ▄██ ███ +███ ███ ███ ███ ███ ███ ███ ▀███████████ ███ █▄ ███ ███ ███ ███ +███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ + ▀██████▀ ▀█ ███ █▀ ███ █▀ ███ ███ ████████▀ ███ █▀ ▀█████▀ + ███ ███ +WORDMARK +) + +output=$(omarchy-ascii Omarchy) +[[ $output == "$expected" ]] || fail "the wordmark matches the reference rendering" "expected: +$expected +actual: +$output" +pass "the wordmark matches the reference rendering" + +# figlet.js and figlet.c disagree about a first M: the C one trims the column of +# blanks every row of that glyph shares, the JavaScript one keeps it. asciiart.eu +# runs figlet.js, so this pins the leading blanks rather than leaving the next +# reader to "fix" them. +expected_m=$( + cat <<'LETTER_M' + ▄▄▄▄███▄▄▄▄ + ▄██▀▀▀███▀▀▀██▄ + ███ ███ ███ + ███ ███ ███ + ███ ███ ███ + ███ ███ ███ + ███ ███ ███ + ▀█ ███ █▀ + +LETTER_M +) + +output=$(omarchy-ascii M) +[[ $output == "$expected_m" ]] || fail "a leading M keeps the blanks figlet.js gives it" "expected: +$expected_m +actual: +$output" +pass "a leading M keeps the blanks figlet.js gives it" + +output=$(printf 'Omarchy' | omarchy-ascii) +[[ $output == "$expected" ]] || fail "text can arrive on stdin" +pass "text can arrive on stdin" + +# The route is the way a user reaches this, and it dispatches on whether the +# metadata says an argument is required -- so a piped run has to be exercised +# through `omarchy` itself, not just through the binary. +output=$(printf 'Omarchy' | omarchy ascii) +[[ $output == "$expected" ]] || fail "piped text renders through the omarchy route" "got: +$output" +pass "piped text renders through the omarchy route" + +# The block characters are three bytes each, so the column arithmetic has to +# count columns rather than bytes wherever the locale lands. +output=$(LC_ALL=C omarchy-ascii Omarchy) +[[ $output == "$expected" ]] || fail "a byte-only locale draws the same wordmark" +pass "a byte-only locale draws the same wordmark" + +blanks=$(omarchy-ascii Omarchy | grep -c ' $' || true) +[[ $blanks == "0" ]] || fail "no line is padded with trailing blanks" "$blanks lines end in a blank" +pass "no line is padded with trailing blanks" + +# The space is a glyph of five hardblank columns, so it is worth exactly five +# columns of art -- not merely more than none. +tight=$(omarchy-ascii "Hi" | columns) +spaced=$(omarchy-ascii "H i" | columns) +(( tight == 18 )) || fail "Hi is 18 columns wide" "got $tight" +(( spaced == tight + 5 )) || fail "a space between words is five columns" "expected $((tight + 5)), got $spaced" +pass "a space between words is five columns" + +rows=$(omarchy-ascii Hi | wc -l) +(( rows == 9 )) || fail "a block is nine rows" "got $rows" +pass "a block is nine rows" + +# An empty line is a block of its own in figlet, and dropping it would silently +# close up the gap someone put there on purpose. +rows=$(printf 'A\n\nB\n' | omarchy-ascii | wc -l) +(( rows == 27 )) || fail "an empty line still draws its block" "expected 27 rows, got $rows" +pass "an empty line still draws its block" + +# awk reads escapes in a variable it is given on the command line, so text has +# to reach it as input instead: a backslash is a character the font lacks, not +# the start of an escape. +rows=$(omarchy-ascii 'A\nB' 2>/dev/null | wc -l) +(( rows == 9 )) || fail "a backslash in the text is not an escape" "expected 9 rows, got $rows" +pass "a backslash in the text is not an escape" + +# Delta Corps Priest 1 carries letters and spaces only. Dropping the rest in +# silence would leave a version number looking like a bug in the renderer. +status=0 +warning=$(omarchy-ascii "Omarchy 4.0" 2>&1 >/dev/null) || status=$? +(( status == 0 )) || fail "text with unusable characters still draws" "exited $status" +[[ $warning == *"Skipped"* && $warning == *"4"* && $warning == *"."* && $warning == *"0"* ]] || + fail "skipped characters are named on stderr" "got: $warning" +pass "skipped characters are named on stderr" + +output=$(omarchy-ascii "Omarchy 4.0" 2>/dev/null) +[[ $output == "$expected" ]] || fail "the drawable characters still render" +pass "the drawable characters still render" + +# Naming a skipped control character by writing it out would send it to the +# terminal as a control character. +warning=$(printf 'A\001B\n' | omarchy-ascii 2>&1 >/dev/null) +[[ $warning == *'\x01'* ]] || fail "a skipped control character is named by code" "got: $warning" +[[ $warning != *$'\001'* ]] || fail "a skipped control character is not written out raw" +pass "a skipped control character is named by code" + +status=0 +output=$(omarchy-ascii "4.0" 2>/dev/null) || status=$? +(( status == 1 )) || fail "text the font cannot draw at all fails" "exited $status" +[[ -z $output ]] || fail "text the font cannot draw at all prints nothing" +pass "text the font cannot draw at all fails" + +# A mistyped option would otherwise be drawn as art, silently. +status=0 +output=$(omarchy-ascii --width 40 2>&1 >/dev/null) || status=$? +(( status == 1 )) || fail "an unknown option is refused" "exited $status" +[[ $output == *"Unknown option: --width"* ]] || fail "an unknown option is named" "got: $output" +pass "an unknown option is refused" + +output=$(omarchy-ascii -- Hi | columns) +(( output == 18 )) || fail "text after -- is still text" "got $output columns" +pass "text after -- is still text" + +output=$(omarchy-ascii --help) +[[ $output == *"Usage: omarchy-ascii"* ]] || fail "help renders" +pass "help renders" diff --git a/test/shell.d/bar-icon-geometry-test.sh b/test/shell.d/bar-icon-geometry-test.sh index 1bfcdf0c..6f54692c 100644 --- a/test/shell.d/bar-icon-geometry-test.sh +++ b/test/shell.d/bar-icon-geometry-test.sh @@ -7,7 +7,7 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" require_compositor "bar icon geometry test" if ! command -v quickshell >/dev/null 2>&1; then - pass "quickshell not installed; skipping bar icon geometry test" + skip "quickshell not installed; skipping bar icon geometry test" exit 0 fi diff --git a/test/shell.d/bar-text-color-test.sh b/test/shell.d/bar-text-color-test.sh index b7626c2b..d22a289c 100755 --- a/test/shell.d/bar-text-color-test.sh +++ b/test/shell.d/bar-text-color-test.sh @@ -5,6 +5,7 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" require_command magick +require_command ffmpeg TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT @@ -28,3 +29,15 @@ pass "transparent bar text keeps text color on dark wallpaper" result=$(HOME="$TMPDIR" omarchy-bar-text-color top 20 '#ffffff' '#101010' --background "$TMPDIR/missing.png" --screen 100x100) [[ $result == "#ffffff" ]] || fail "transparent bar text falls back to text color when sampling fails" "expected #ffffff, got $result" pass "transparent bar text falls back to text color when sampling fails" + +# A video background must be sampled one frame at a time. Reading the whole file +# emits a value per frame, which parses as nothing and silently falls back — +# and decodes the entire wallpaper to find that out. +light_top_video="$TMPDIR/light-top.mp4" +ffmpeg -y -f lavfi -i "testsrc=size=640x360:rate=10:duration=2" \ + -vf "drawbox=x=0:y=0:w=640:h=40:color=0xf5f5f5:t=fill" \ + -c:v libx264 -preset ultrafast -pix_fmt yuv420p "$light_top_video" -loglevel error + +result=$(HOME="$TMPDIR" omarchy-bar-text-color top 40 '#ffffff' '#101010' --background "$light_top_video" --screen 640x360) +[[ $result == "#101010" ]] || fail "transparent bar text samples one frame of a video wallpaper" "expected #101010, got $result" +pass "transparent bar text samples one frame of a video wallpaper" diff --git a/test/shell.d/bar-widget-contract-test.sh b/test/shell.d/bar-widget-contract-test.sh index 95fd52b9..1a022ed5 100755 --- a/test/shell.d/bar-widget-contract-test.sh +++ b/test/shell.d/bar-widget-contract-test.sh @@ -21,7 +21,7 @@ trap cleanup EXIT require_compositor "bar widget contract test" if ! command -v quickshell >/dev/null 2>&1; then - pass "quickshell not installed; skipping bar widget contract test" + skip "quickshell not installed; skipping bar widget contract test" exit 0 fi diff --git a/test/shell.d/base-test.sh b/test/shell.d/base-test.sh index 9de42cf3..670ec82d 100644 --- a/test/shell.d/base-test.sh +++ b/test/shell.d/base-test.sh @@ -14,6 +14,10 @@ pass() { printf 'ok - %s\n' "$1" } +skip() { + printf 'ok - %s # SKIP\n' "$1" +} + fail() { local description="$1" local detail="${2:-}" @@ -72,7 +76,7 @@ require_compositor() { return 0 fi - pass "no Wayland compositor; skipping $description" + skip "no Wayland compositor; skipping $description" exit 0 } diff --git a/test/shell.d/bin-style-test.sh b/test/shell.d/bin-style-test.sh index e7824e9b..3f216734 100644 --- a/test/shell.d/bin-style-test.sh +++ b/test/shell.d/bin-style-test.sh @@ -9,7 +9,6 @@ raw_command_checks=$(rg -l 'command -v' "$ROOT/bin" \ [[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks" pass "bin commands use command helpers" -raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" \ - | rg -v '/omarchy-notification-send$' || true) -[[ -z $raw_notifications ]] || fail "bin commands use the notification helper" "$raw_notifications" +raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" || true) +[[ -z $raw_notifications ]] || fail "bin commands use the notification helper, never notify-send" "$raw_notifications" pass "bin commands use the notification helper" diff --git a/test/shell.d/bluetooth-migration-test.sh b/test/shell.d/bluetooth-migration-test.sh deleted file mode 100644 index 95d5b116..00000000 --- a/test/shell.d/bluetooth-migration-test.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(dirname "$0")/base-test.sh" - -migration="$ROOT/migrations/1786380259.sh" -test_dir=$(mktemp -d) -trap 'rm -rf "$test_dir"' EXIT - -mkdir -p "$test_dir/bin" - -# sudo runs the real command, so sed acts on the redirected main.conf below and -# the elevated power calls land in the stub beside it. -cat >"$test_dir/bin/sudo" <<'STUB' -#!/bin/bash - -printf 'sudo %s\n' "$*" >>"$CALLS" -exec "$@" -STUB - -cat >"$test_dir/bin/omarchy-bluetooth-power" <<'STUB' -#!/bin/bash - -printf 'omarchy-bluetooth-power %s\n' "$*" >>"$CALLS" -[[ $1 == "is-on" ]] || exit 0 -[[ ${POWERED:-} == "yes" ]] -STUB - -chmod +x "$test_dir/bin/"* - -export CALLS="$test_dir/calls" - -marker="$test_dir/marker" -main_conf="$test_dir/main.conf" - -reset_machine() { - rm -f "$marker" - printf '[Policy]\nAutoEnable=false\n' >"$main_conf" -} - -run_migration() { - : >"$CALLS" - - OMARCHY_BLUETOOTH_MIGRATION_MARKER="$marker" \ - OMARCHY_BLUETOOTH_MAIN_CONF="$main_conf" \ - PATH="$test_dir/bin:$PATH" \ - bash -euo pipefail "$migration" >/dev/null -} - -# An adapter that is powered right now is one the user turned on, so it stays on. -reset_machine -POWERED=yes run_migration - -grep -qx 'omarchy-bluetooth-power on' "$CALLS" || - fail "migration keeps a powered adapter on" "$(cat "$CALLS")" -pass "migration keeps a powered adapter on" - -grep -qx '#AutoEnable=true' "$main_conf" || - fail "migration puts AutoEnable back to its default" "$(cat "$main_conf")" -pass "migration puts AutoEnable back to its default" - -[[ -e $marker ]] || fail "migration records the machine as done" -pass "migration records the machine as done" - -# Anything else is a machine that has been booting with Bluetooth off, and the -# block is what carries that over now AutoEnable no longer holds the adapter down. -reset_machine -POWERED=no run_migration - -grep -qx 'omarchy-bluetooth-power off' "$CALLS" || - fail "migration carries an unpowered adapter over to the block" "$(cat "$CALLS")" -pass "migration carries an unpowered adapter over to the block" - -# No daemon to ask reads the same way: off is what the machine has been doing. -reset_machine -run_migration - -grep -qx 'omarchy-bluetooth-power off' "$CALLS" || - fail "migration blocks when no adapter can be read" "$(cat "$CALLS")" -pass "migration blocks when no adapter can be read" - -# /dev/rfkill is only writable unelevated from an active graphical seat, so an -# update run over SSH would abort here and abort again on every retry. -grep -qx 'sudo omarchy-bluetooth-power off' "$CALLS" || - fail "migration changes the radio through sudo" "$(cat "$CALLS")" -pass "migration changes the radio through sudo" - -# A second account must not undo an administrator's later choice, since migration -# completion is recorded per user. -printf '[Policy]\nAutoEnable=false\n' >"$main_conf" -POWERED=yes run_migration - -grep -qx 'AutoEnable=false' "$main_conf" || - fail "migration leaves a later opt-out alone" "$(cat "$main_conf")" -pass "migration leaves a later opt-out alone" - -[[ ! -s $CALLS ]] || - fail "migration touches no radio state on a second run" "$(cat "$CALLS")" -pass "migration touches no radio state on a second run" - -# Only the exact line Omarchy wrote is reverted, so a hand-edited opt-out stands. -reset_machine -printf '[Policy]\nAutoEnable = false\n' >"$main_conf" -POWERED=yes run_migration - -grep -qx 'AutoEnable = false' "$main_conf" || - fail "migration keeps a hand-edited AutoEnable" "$(cat "$main_conf")" -pass "migration keeps a hand-edited AutoEnable" diff --git a/test/shell.d/branding-about-animation-test.sh b/test/shell.d/branding-about-animation-test.sh new file mode 100755 index 00000000..23d6b458 --- /dev/null +++ b/test/shell.d/branding-about-animation-test.sh @@ -0,0 +1,148 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +animation="$ROOT/bin/omarchy-branding-about-animation" +source "$animation" + +logo="$tmp_dir/logo.txt" +write_logo() { printf '%s\n' "$@" >"$logo"; } +base=$'\e[0m\e[1m\e[32m' +top=3 left=3 columns=120 + +build() { sheen_build "$logo" "$top" "$left" "$base" "$columns"; } +refuses() { + if build; then + fail "$1" + else + pass "$1" + fi +} + +write_logo '████████████████████████' '████████ ████████' '████████████████████████' +build || fail "a logo of plain block art animates" +pass "a logo of plain block art animates" + +mapfile -t expected <"$logo" +(( ${#SHEEN_FRAMES[@]} > ${#expected[0]} )) || fail "the glint takes more frames than the logo is wide" "${#SHEEN_FRAMES[@]}" +pass "the glint takes more frames than the logo is wide" + +# Every frame is the same logo in different colours. A frame that changed a +# character, reached past the logo, or lit more than the band is wide would be +# drawing over whatever the caller put beside it, and nothing on screen would say so. +esc=$'\e' +band_width=$(( SHEEN_HALF * 2 + 1 )) +misplaced=0 rewritten=0 overrun=0 washed=0 unbased=0 widest_glint=0 first_lit=0 +lit_rows="" +for index in "${!SHEEN_FRAMES[@]}"; do + row=0 + frame_lit=0 + while IFS= read -r drawn; do + [[ -n $drawn ]] || continue + [[ $drawn == "$esc[$((top + row));${left}H"* ]] || misplaced=$((misplaced + 1)) + + rest=${drawn#*H} + [[ $rest == "$base"* ]] || unbased=$((unbased + 1)) + rest=${rest#"$base"} + head=${rest%%"$SHEEN_BAND"*} && rest=${rest#*"$SHEEN_BAND"} + lit=${rest%%"$base"*} && tail=${rest#*"$base"} + + [[ $head$lit$tail == "${expected[row]}" ]] || rewritten=$((rewritten + 1)) + (( ${#head} + ${#lit} + ${#tail} > ${#expected[row]} )) && overrun=$((overrun + 1)) + (( ${#lit} > band_width )) && washed=$((washed + 1)) + if (( ${#lit} > 0 )); then + lit_rows+="$row " + frame_lit=$((frame_lit + 1)) + fi + row=$((row + 1)) + done < <(printf '%s\n' "${SHEEN_FRAMES[index]}" | sed "s/$esc\[[0-9]*;[0-9]*H/\n&/g") + (( row == ${#expected[@]} )) || fail "a frame draws every row of the logo" "drew $row of ${#expected[@]}" + (( frame_lit > widest_glint )) && widest_glint=$frame_lit + (( index == 0 )) && first_lit=$frame_lit +done + +(( misplaced == 0 )) || fail "every row is drawn on the cell it was given" "$misplaced misplaced" +pass "every row is drawn on the cell it was given" + +(( unbased == 0 )) || fail "every row starts in the colour it was handed" "$unbased rows" +pass "every row starts in the colour it was handed" + +(( rewritten == 0 )) || fail "the sheen only recolours the logo, never rewrites it" "$rewritten rows changed" +pass "the sheen only recolours the logo, never rewrites it" + +(( overrun == 0 )) || fail "no frame reaches past the logo" "$overrun overruns" +pass "no frame reaches past the logo" + +# A band of light leaning across the logo, not a wash over half of it. +(( washed == 0 )) || fail "the glint stays a band the whole way across" "$washed rows lit wider than $band_width" +pass "the glint stays a band the whole way across" + +for row in "${!expected[@]}"; do + [[ " $lit_rows" == *" $row "* ]] || fail "the glint crosses every row of the logo" "row $row is never lit" +done +pass "the glint crosses every row of the logo" + +(( widest_glint == ${#expected[@]} )) || fail "the glint leans across the whole logo at once" "widest frame lit $widest_glint of ${#expected[@]}" +pass "the glint leans across the whole logo at once" + +(( first_lit == 1 )) || fail "a glint arrives from off the logo" "the first frame lights $first_lit rows" +pass "a glint arrives from off the logo" + +settled="" +for row in "${!expected[@]}"; do + settled+="$esc[$((top + row));${left}H$base${expected[row]}$SHEEN_BAND$base" +done +[[ ${SHEEN_FRAMES[-1]} == "$settled" ]] || fail "a glint settles back to the logo it was given" +pass "a glint settles back to the logo it was given" + +# A terminal that renders bold text in bright colours — foot's bold-text-in-bright +# does exactly this — maps a bold regular colour to its bright counterpart, so a +# band using one of those vanishes into a logo drawn in the matching regular +# colour, and nobody sees the animation at all. +[[ ! $SHEEN_BAND =~ \[9[0-6]m ]] || fail "the band avoids the colours a bold logo can brighten into" "$(printf '%q' "$SHEEN_BAND")" +pass "the band avoids the colours a bold logo can brighten into" + +# One character has to be one cell, or putting a row back moves what follows it. +# Everything that breaks that is refused by the one check, so everything that +# breaks it is tested against the one check. +write_logo '$1████' ' ████' +refuses "a logo built from colour placeholders is left still" +write_logo "$(printf 'A\tB')" 'CC' +refuses "a logo with a tab someone else expands is left still" +write_logo "$(printf 'A\033[31mB')" 'CCCCC' +refuses "a logo carrying an escape is left still" +write_logo 'AAA中文BBB' 'CCCCCCCCCC' +refuses "a logo with double-width glyphs is left still" +write_logo "$(printf 'AAAe\xcc\x81BBB')" 'CCCCCCCC' +refuses "a logo with a combining mark is left still" +write_logo "$(printf 'AA\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9BB')" 'CCCCCCC' +refuses "a logo with a joined emoji is left still" + +# The same check answers for a shell that is counting bytes rather than +# characters, which is the only thing that would make a block logo unsafe here. +write_logo '████████' '████████' +byte_counting=$(LC_ALL=C bash -c 'source "$1"; sheen_build "$2" 3 3 "" 120 && echo animated || echo still' _ "$animation" "$logo") +[[ $byte_counting == "still" ]] || fail "a shell counting bytes leaves a block logo still" "$byte_counting" +pass "a shell counting bytes leaves a block logo still" + +ascii_counting=$(LC_ALL=C bash -c 'printf "%s\n" AAAA BBBB > "$2"; source "$1"; sheen_build "$2" 3 3 "" 120 && echo animated || echo still' _ "$animation" "$tmp_dir/ascii.txt") +[[ $ascii_counting == "animated" ]] || fail "a shell counting bytes still animates plain ASCII" "$ascii_counting" +pass "a shell counting bytes still animates plain ASCII" + +write_logo '████████████████████████' '████████ ████████' +columns=10 +refuses "a logo wider than the columns it was given is left still" +columns=120 + +: >"$logo" +refuses "an empty logo is left still" + +rm -f "$logo" +missing=$(build 2>&1 >/dev/null || true) +[[ -z $missing ]] || fail "a missing logo says nothing on the terminal it would draw on" "$missing" +pass "a missing logo says nothing on the terminal it would draw on" diff --git a/test/shell.d/brightness-display-apple-cache-test.sh b/test/shell.d/brightness-display-apple-cache-test.sh new file mode 100755 index 00000000..0ed281cb --- /dev/null +++ b/test/shell.d/brightness-display-apple-cache-test.sh @@ -0,0 +1,152 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +# The /tmp-fallback case (below) must place its decoy at exactly the fixed path the +# old wrapper would have formed, so it cannot use a random mktemp name. Track whether +# we created it and remove it on exit only then -- never touch a path we did not create. +tmp_cache="/tmp/omarchy-brightness-display-apple.device" +created_tmp_cache=0 + +cleanup() { + rm -rf "$TMPDIR" + # Remove the /tmp decoy only if this test is the one that created it. + if (( created_tmp_cache )); then + rm -f "$tmp_cache" + fi +} +trap cleanup EXIT + +# Stubs on PATH: drop sudo so asdcontrol runs directly, record every asdcontrol +# invocation, make detection deterministic by having --detect report no device, +# and no-op the OSD. On a host without any /dev/*hiddev* node the wrapper's +# detect_apple_display_device returns before it ever runs asdcontrol, so the +# reject cases assert on the negative: a refused cache value is never handed to +# `asdcontrol -- `. Blind-trust validation would hand it over and be +# caught here. +stub_dir="$TMPDIR/stubs" +mkdir -p "$stub_dir" + +asd_log="$TMPDIR/asdcontrol.log" + +cat >"$stub_dir/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +chmod +x "$stub_dir/sudo" + +cat >"$stub_dir/asdcontrol" <>"$asd_log" +# --detect reports nothing, so detection never yields a device. +if [[ \$1 == "--detect" ]]; then + exit 0 +fi +# A brightness read (a lone device arg) returns a plausible value; a set +# ( -- ) just succeeds. +if [[ \$# -eq 1 ]]; then + printf '%s: BRIGHTNESS=30000\n' "\$1" +fi +exit 0 +STUB +chmod +x "$stub_dir/asdcontrol" + +cat >"$stub_dir/omarchy-osd" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod +x "$stub_dir/omarchy-osd" + +run_wrapper() { + # $1: value for XDG_RUNTIME_DIR ("" means unset); remaining args go to the wrapper. + local xdg="$1" + shift + : >"$asd_log" + if [[ -n $xdg ]]; then + XDG_RUNTIME_DIR="$xdg" PATH="$stub_dir:$ROOT/bin:$PATH" \ + omarchy-brightness-display-apple "$@" 2>&1 || true + else + env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \ + omarchy-brightness-display-apple "$@" 2>&1 || true + fi +} + +# --- A cache value that is not a hiddev character device is rejected ---------- +xdg_dir="$TMPDIR/xdg" +mkdir -p "$xdg_dir" +cache_file="$xdg_dir/omarchy-brightness-display-apple.device" + +regular_file="$TMPDIR/not-a-device" +: >"$regular_file" + +poisons=("/dev/null" "$regular_file" "/tmp/omarchy-evil") + +# The cases above all fail on the pathname prefix, so none of them reaches the -c +# test -- drop `&& -c $cached` from the wrapper and they all still pass. A path +# that matches the hiddev glob but is not a character device is what -c is for, +# and it is the realistic stale cache: the display replugs, the interface +# renumbers, and the cached node is simply gone. Add it only when the host really +# has no such node, so a machine with the display attached cannot fail here. +if [[ ! -e /dev/hiddev999 ]]; then + poisons+=("/dev/hiddev999") +fi + +for poison in "${poisons[@]}"; do + printf '%s\n' "$poison" >"$cache_file" + output=$(run_wrapper "$xdg_dir" "+5%") + if grep -qF -- "$poison -- +5%" "$asd_log"; then + fail "wrapper handed a non-hiddev cache value to asdcontrol: $poison" "$output" + fi +done +pass "wrapper rejects a cached path that is not a hiddev character device" + +# NOTE: the /dev/hiddev999 case above covers the -c test for a glob-matching path +# that does not exist. The remaining arm -- a path under /dev that exists, matches +# the glob, and is not a character device -- cannot be built without root, since +# only real device nodes live there. + +# --- A legitimate cached hiddev node is trusted (only where HW is present) ---- +real_hiddev="" +for candidate in /dev/usb/hiddev* /dev/hiddev*; do + if [[ -c $candidate ]]; then + real_hiddev="$candidate" + break + fi +done +if [[ -n $real_hiddev ]]; then + printf '%s\n' "$real_hiddev" >"$cache_file" + run_wrapper "$xdg_dir" "+5%" >/dev/null + grep -qF -- "$real_hiddev -- +5%" "$asd_log" || + fail "wrapper did not trust a valid cached hiddev node: $real_hiddev" + pass "wrapper trusts a cached hiddev character device without re-detecting" +else + skip "no /dev/hiddev* character device present; skipping the valid-cache case" +fi + +# --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------ +# Assert on the open, not on the contents. A decoy holding a rejectable path proves +# nothing: the validation above refuses it whether or not the /tmp fallback is still +# there, so that assertion passes against both wrappers. A FIFO with no writer blocks +# whoever opens it, so a wrapper that consults the path hangs and one that ignores it +# exits -- which separates the two. mkfifo is atomic and fails outright if the path is +# taken, so it neither overwrites a file nor follows a symlink; the fixed path is +# required, being exactly the path the old code would have formed. Clear the flag as +# soon as the decoy is gone, so a concurrent run's decoy cannot be removed by this +# run's EXIT trap. +if mkfifo "$tmp_cache" 2>/dev/null; then + created_tmp_cache=1 + status=0 + env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \ + timeout 5 omarchy-brightness-display-apple "+5%" >/dev/null 2>&1 || status=$? + rm -f "$tmp_cache" + created_tmp_cache=0 + (( status != 124 )) || + fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" \ + "it blocked reading the FIFO decoy at $tmp_cache" + pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset" +else + skip "$tmp_cache already present or not safely creatable; skipping the /tmp-fallback case" +fi diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh new file mode 100755 index 00000000..fd679f89 --- /dev/null +++ b/test/shell.d/browser-policy-dir-test.sh @@ -0,0 +1,330 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +export OMARCHY_PATH="$ROOT" +export OMARCHY_PROVISIONING_DIR="$test_tmp/provisioning" + +source "$ROOT/install/helpers/browser-policy.sh" + +# Temp dirs are user-owned; drop -o/-g so install(1) can run unprivileged. +unprivileged_as_root() { + if [[ $1 == "install" ]]; then + shift + local args=() + local skip=0 + local arg + for arg in "$@"; do + if (( skip )); then + skip=0 + continue + fi + case $arg in + -o|-g) skip=1 ;; + *) args+=("$arg") ;; + esac + done + command install "${args[@]}" + else + "$@" + fi +} + +write_dir=$test_tmp/writable +mkdir -p "$write_dir" +browser_policy_install_color "$write_dir" "#aabbcc" || + fail "theme colour writes into a writable policy directory" +grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null || + fail "theme colour writes BrowserThemeColor" +mode=$(stat -c '%a' "$write_dir/color.json") +[[ $mode == "644" ]] || fail "theme colour creates a root-mode policy file" "mode=$mode" +pass "theme colour writes a 0644 color.json" + +if (( EUID == 0 )); then + skip "running as root; skipping the mktemp-failure check" +else + chmod u+w "$write_dir" + export TMPDIR=$test_tmp/missing-tmp + if browser_policy_install_color "$write_dir" "#dead00" 2>/dev/null; then + fail "theme colour fails when mktemp cannot create a file" + fi + unset TMPDIR + grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null || + fail "a failed mktemp leaves an existing color.json intact" + pass "a failed mktemp does not truncate color.json" +fi + +printf 'original\n' >"$test_tmp/pwn" +rm -f "$write_dir/color.json" +ln -s "$test_tmp/pwn" "$write_dir/color.json" +browser_policy_install_color "$write_dir" "#aabbcc" || + fail "theme colour replaces a planted color.json symlink" +[[ -f $write_dir/color.json && ! -L $write_dir/color.json ]] || + fail "theme colour unlinks a planted color.json symlink instead of writing through it" +grep -Fxq 'original' "$test_tmp/pwn" || fail "theme colour leaves the symlink target unchanged" +pass "theme colour does not follow a planted color.json symlink" + +plant_write=$test_tmp/plant-dir +mkdir -p "$plant_write/color.json/nested" +printf 'inside\n' >"$plant_write/color.json/nested/x" +browser_policy_install_color "$plant_write" "#aabbcc" || + fail "theme colour replaces a planted color.json directory" +[[ -f $plant_write/color.json && ! -d $plant_write/color.json ]] || + fail "theme colour does not write into a planted color.json directory" +pass "theme colour does not write into a planted color.json directory" + +missing_dir=$test_tmp/missing +browser_policy_install_color "$missing_dir" "#aabbcc" || + fail "theme colour skips a policy directory that does not exist" +[[ ! -e $missing_dir ]] || fail "theme colour does not create a missing policy directory" +pass "theme colour skips a missing policy directory" + +if browser_policy_install_color "$write_dir" "aabbcc" 2>/dev/null; then + fail "theme colour rejects hex without a leading #" +fi +if browser_policy_install_color "$write_dir" "#AABBCC" 2>/dev/null; then + fail "theme colour rejects uppercase hex" +fi +pass "theme colour accepts only # plus six lowercase hex digits" + +planted_dir=$test_tmp/planted +mkdir -p "$planted_dir/evil" +printf 'evil\n' >"$planted_dir/evil/f" +printf 'old\n' >"$planted_dir/color.json" +as_root() { unprivileged_as_root "$@"; } +browser_policy_setup_dir "$planted_dir" +[[ ! -e $planted_dir/evil ]] || fail "policy setup drops a non-empty non-root subdirectory" +[[ ! -e $planted_dir/color.json ]] || fail "policy setup drops a non-root color.json" +[[ -d $planted_dir ]] || fail "policy setup leaves the managed directory in place" +mode=$(stat -c '%a' "$planted_dir") +[[ $mode == "755" ]] || fail "policy setup leaves the managed directory 0755" "mode=$mode" +pass "policy setup drops non-root files and non-empty subdirectories" + +owned=$test_tmp/not-root +mkdir -p "$owned" +chmod 755 "$owned" +if browser_policy_dir_hardened "$owned"; then + fail "a user-owned 0755 directory is not treated as hardened" +fi +pass "a hardened directory must be root-owned" + +saved_parent_dirs=("${BROWSER_POLICY_PARENT_DIRS[@]}") +parent_root=$test_tmp/parents +mkdir -p "$parent_root/etc/chromium/policies/managed/keep" +printf 'keep\n' >"$parent_root/etc/chromium/policies/managed/keep/x" +chmod 0777 "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies" +chmod 755 "$parent_root/etc/chromium/policies/managed" +BROWSER_POLICY_PARENT_DIRS=( + "$parent_root/etc/chromium" + "$parent_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +if browser_policy_parents_hardened "$parent_root/etc/chromium/policies/managed"; then + fail "a world-writable policy parent is not treated as hardened" +fi +browser_policy_setup_parents_for "$parent_root/etc/chromium/policies/managed" +mode=$(stat -c '%a' "$parent_root/etc/chromium") +[[ $mode == "755" ]] || fail "setup tightens /etc/chromium" "mode=$mode" +mode=$(stat -c '%a' "$parent_root/etc/chromium/policies") +[[ $mode == "755" ]] || fail "setup tightens /etc/chromium/policies" "mode=$mode" +[[ -d $parent_root/etc/chromium/policies/managed/keep ]] || + fail "parent repair does not purge the managed directory" +pass "policy parent directories are tightened to 0755 without purging the leaf" + +symlink_root=$test_tmp/symlink-parents +mkdir -p "$symlink_root/etc" "$symlink_root/attacker/policies/managed" +printf 'planted\n' >"$symlink_root/attacker/policies/managed/evil.json" +ln -s "$symlink_root/attacker" "$symlink_root/etc/chromium" +BROWSER_POLICY_PARENT_DIRS=( + "$symlink_root/etc/chromium" + "$symlink_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +browser_policy_setup_dir "$symlink_root/etc/chromium/policies/managed" +[[ ! -L $symlink_root/etc/chromium ]] || fail "setup replaces a planted /etc/chromium symlink" +[[ -d $symlink_root/etc/chromium && ! -L $symlink_root/etc/chromium ]] || + fail "setup recreates /etc/chromium as a real directory" +[[ -d $symlink_root/etc/chromium/policies && ! -L $symlink_root/etc/chromium/policies ]] || + fail "setup recreates /etc/chromium/policies as a real directory" +[[ ! -e $symlink_root/etc/chromium/policies/managed/evil.json ]] || + fail "setup does not keep policy that lived behind a planted parent symlink" +grep -Fxq 'planted' "$symlink_root/attacker/policies/managed/evil.json" || + fail "replacing a parent symlink does not delete the symlink target" +BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}") +pass "policy setup does not follow a planted parent symlink" + +leaf_link_root=$test_tmp/leaf-link +mkdir -p "$leaf_link_root/etc/chromium/policies" "$leaf_link_root/attacker" +printf 'planted\n' >"$leaf_link_root/attacker/evil.json" +chmod 755 "$leaf_link_root/etc/chromium" "$leaf_link_root/etc/chromium/policies" +ln -s "$leaf_link_root/attacker" "$leaf_link_root/etc/chromium/policies/managed" +BROWSER_POLICY_PARENT_DIRS=( + "$leaf_link_root/etc/chromium" + "$leaf_link_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +if browser_policy_dir_hardened "$leaf_link_root/etc/chromium/policies/managed"; then + fail "a planted managed symlink is not treated as hardened" +fi +browser_policy_setup_dir "$leaf_link_root/etc/chromium/policies/managed" +[[ ! -L $leaf_link_root/etc/chromium/policies/managed ]] || + fail "setup replaces a planted managed symlink" +[[ -d $leaf_link_root/etc/chromium/policies/managed && ! -L $leaf_link_root/etc/chromium/policies/managed ]] || + fail "setup recreates managed as a real directory" +[[ ! -e $leaf_link_root/etc/chromium/policies/managed/evil.json ]] || + fail "setup does not keep policy that lived behind a planted managed symlink" +grep -Fxq 'planted' "$leaf_link_root/attacker/evil.json" || + fail "replacing a managed symlink does not delete the symlink target" +BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}") +pass "policy setup does not follow a planted managed symlink" + +fx_link_root=$test_tmp/fx-link +mkdir -p "$fx_link_root/attacker" "$fx_link_root/opt" +printf 'planted\n' >"$fx_link_root/attacker/policies.json" +ln -s "$fx_link_root/attacker" "$fx_link_root/opt/zen" +as_root() { unprivileged_as_root "$@"; } +if browser_policy_firefox_hardened "$fx_link_root/opt/zen"; then + fail "a planted Firefox distribution symlink is not treated as hardened" +fi +browser_policy_setup_firefox_distribution "$fx_link_root/opt/zen" || + fail "Firefox setup replaces a planted distribution symlink" +[[ ! -L $fx_link_root/opt/zen ]] || fail "Firefox setup unlinks a planted distribution symlink" +[[ -d $fx_link_root/opt/zen && ! -L $fx_link_root/opt/zen ]] || + fail "Firefox setup recreates the distribution directory" +[[ -f $fx_link_root/opt/zen/policies.json && ! -L $fx_link_root/opt/zen/policies.json ]] || + fail "Firefox setup writes policies.json into the recreated directory" +grep -Fxq 'planted' "$fx_link_root/attacker/policies.json" || + fail "replacing a Firefox distribution symlink does not delete the symlink target" +pass "Firefox setup does not follow a planted distribution symlink" + +[[ $(browser_policy_theme_hex "242,240,229") == "#f2f0e5" ]] || + fail "theme colour converts an RGB triple to hex" +[[ $(browser_policy_theme_hex $'14,31,41\n') == "#0e1f29" ]] || + fail "theme colour accepts a trailing newline" +[[ $(browser_policy_theme_hex "0,0,0") == "#000000" ]] || + fail "theme colour pads single-digit components" +[[ $(browser_policy_theme_hex " 12 , 11 , 12 ") == "#0c0b0c" ]] || + fail "theme colour tolerates surrounding whitespace" +[[ $(browser_policy_theme_hex "08,09,10") == "#08090a" ]] || + fail "theme colour treats leading zeros as decimal" +for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \ + "1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do + [[ $(browser_policy_theme_hex "$malformed") == "#1c2027" ]] || + fail "theme colour falls back to the stock grey for '$malformed'" +done +pass "theme colour is six hex digits or the stock grey" + +for theme in "$ROOT"/themes/*/chromium.theme; do + [[ -f $theme ]] || continue + rgb=$(<$theme) + hex=$(browser_policy_theme_hex "$rgb") + [[ $hex =~ ^#[0-9a-f]{6}$ ]] || + fail "shipped $(basename "$(dirname "$theme")") chromium.theme parses as hex" "got: $hex from $(printf %q "$rgb")" + if [[ $hex == "#1c2027" && ! $rgb =~ ^[[:space:]]*28[[:space:]]*,[[:space:]]*32[[:space:]]*,[[:space:]]*39[[:space:]]*$ ]]; then + fail "shipped $(basename "$(dirname "$theme")") chromium.theme is a valid RGB triple" "got: $(printf %q "$rgb")" + fi +done +pass "shipped chromium.theme files parse as RGB triples" + +grep -F 'browser_policy_theme_hex' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser parses chromium.theme through browser_policy_theme_hex" +grep -F 'omarchy-theme-set-browser-policy' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser writes colour through omarchy-theme-set-browser-policy" +if grep -E 'printf.*THEME_RGB_COLOR' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null; then + fail "omarchy-theme-set-browser does not hand unvetted theme words to printf" +fi +pass "omarchy-theme-set-browser validates the theme colour" + +fx_policy=$test_tmp/policies.json +printf '%s\n' '{"policies":{}}' >"$fx_policy" +chmod 644 "$fx_policy" +if browser_policy_firefox_policy_file_ok "$fx_policy"; then + fail "a user-owned policies.json is not treated as hardened" +fi +ln -sf "$fx_policy" "$test_tmp/policies-link.json" +if browser_policy_firefox_policy_file_ok "$test_tmp/policies-link.json"; then + fail "a policies.json symlink is not treated as hardened" +fi +pass "Firefox policy files must be root-owned regular files without group or other write" + +dist=$test_tmp/distribution +mkdir -p "$dist" +printf 'original\n' >"$test_tmp/firefox-pwn" +ln -s "$test_tmp/firefox-pwn" "$dist/policies.json" +as_root() { unprivileged_as_root "$@"; } +browser_policy_install_firefox_policies "$dist" || + fail "Firefox policy install replaces a planted policies.json symlink" +[[ -f $dist/policies.json && ! -L $dist/policies.json ]] || + fail "Firefox policy install unlinks a planted policies.json symlink instead of writing through it" +grep -Fxq 'original' "$test_tmp/firefox-pwn" || fail "Firefox policy install leaves the symlink target unchanged" +grep -q '"policies"' "$dist/policies.json" || fail "Firefox policy install writes the stock policies" +pass "Firefox policy install does not follow a planted policies.json symlink" + +dir_dist=$test_tmp/distribution-dir +mkdir -p "$dir_dist" +mkdir "$dir_dist/policies.json" +as_root() { unprivileged_as_root "$@"; } +if browser_policy_install_firefox_policies "$dir_dist" 2>/dev/null; then + fail "Firefox policy install refuses a planted policies.json directory" +fi +[[ -d $dir_dist/policies.json ]] || fail "Firefox policy install leaves a planted policies.json directory in place" +pass "Firefox policy install does not write into a planted policies.json directory" + +grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser exits non-zero when a policy write fails" +pass "omarchy-theme-set-browser exits non-zero when a policy write fails" + +# Bash 5.3 adopts the EXIT trap's last status as the script's exit status, so a +# handler ending on a false test turns a clean run into a failure and aborts the +# migration that calls this through omarchy-theme-set-browser. +policy_cleanup=$(sed -n '/^cleanup() {/,/^}/p' "$ROOT/bin/omarchy-theme-set-browser-policy") +[[ -n $policy_cleanup ]] || fail "omarchy-theme-set-browser-policy defines an EXIT cleanup handler" +eval "$policy_cleanup" +staged="" +cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with nothing staged" +staged=$test_tmp/staged-policy +: >"$staged" +cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with a staged file" +[[ ! -e $staged ]] || fail "omarchy-theme-set-browser-policy's EXIT trap removes the staged file" +unset -f cleanup +pass "omarchy-theme-set-browser-policy's EXIT trap never leaks a failure status" + +grep -F 'omarchy-theme-set-browser || true' "$ROOT/migrations/1787515927.sh" >/dev/null || + fail "the policy-directory migration hardens Firefox even when the theme refresh fails" +pass "the policy-directory migration does not abort on a failed theme refresh" + +policy_files=( + "$ROOT/bin/omarchy-install-browser" + "$ROOT/bin/omarchy-provision-owner" + "$ROOT/bin/omarchy-theme-set-browser" + "$ROOT/bin/omarchy-theme-set-browser-policy" + "$ROOT/bin/omarchy-upgrade-to-quattro" + "$ROOT/install/config/theme-system.sh" + "$ROOT/install/config/browser-policy.sh" + "$ROOT/install/helpers/browser-policy.sh" + "$ROOT/migrations/1787515927.sh" +) +if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2775\b|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777|omarchy-browser-policy' "${policy_files[@]}" >/dev/null; then + fail "browser policy setup is not world-writable and does not use omarchy-browser-policy" +fi +pass "browser policy setup is not world-writable" + +mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy directories' "$ROOT/migrations") +(( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}" +grep -F 'browser_policy_setup_dir' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration repairs managed directories" +if grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null; then + fail "the policy-directory migration does not grant a browser-policy group" +fi +grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration covers Firefox and Zen" +grep -F 'browser_policy_firefox_policy_file_ok' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration keeps a trusted Firefox policies.json" +grep -F '/opt/zen-browser/distribution' "$ROOT/install/helpers/browser-policy.sh" >/dev/null || + fail "the shared helper names the Zen distribution directory" +pass "a migration locks existing policy directories" diff --git a/test/shell.d/browser-policy-sudoers-test.sh b/test/shell.d/browser-policy-sudoers-test.sh new file mode 100755 index 00000000..3ddf3757 --- /dev/null +++ b/test/shell.d/browser-policy-sudoers-test.sh @@ -0,0 +1,184 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +helper="$ROOT/bin/omarchy-theme-set-browser-policy" +setter="$ROOT/bin/omarchy-theme-set-browser" +sudoers_file="$ROOT/etc/sudoers.d/omarchy-theme-browser" +rule='%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-theme-set-browser-policy [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' + +# Exactly one rule, matched whole. Dropping the argument -- which sudoers reads +# as "any arguments" -- or widening the glob to `*` would let the grant carry +# something other than a color while leaving this line looking right. +rules=$(grep -vE '^[[:space:]]*(#|$)' "$sudoers_file") +[[ $rules == "$rule" ]] || + fail "browser policy sudoers file carries exactly the six-hex-digit rule and nothing else" "got: $rules" + +if command -v visudo >/dev/null; then + visudo -cf "$sudoers_file" >/dev/null || fail "browser policy sudoers rule parses" +fi + +grep -Fx 'PACKAGED_PATH=/usr/bin/omarchy-theme-set-browser-policy' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy elevates the path the sudoers rule names" + +grep -E 'sudo -n -l -l' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy reads the grant from the long sudo listing" + +grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$helper" || + fail "omarchy-theme-set-browser-policy pins PATH to trusted system directories when it holds root" +gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$helper" || true) +[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] || + fail "omarchy-theme-set-browser-policy gates the trusted-PATH pin on holding root" + +pass "browser policy sudoers rule is scoped to a single color argument" + +for dir in /etc/chromium/policies/managed /etc/opt/chrome/policies/managed \ + /etc/opt/edge/policies/managed /etc/brave/policies/managed; do + grep -Fx " $dir" "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy names $dir in its fixed policy directory list" +done + +policy_dir_count=$(sed -n '/^POLICY_DIRS=(/,/^)/p' "$helper" | grep -c '^ /') +((policy_dir_count == 4)) || + fail "omarchy-theme-set-browser-policy writes only the four known policy directories" \ + "got: $policy_dir_count" + +grep -F 'install -m 0644 -o root -g root -T' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy installs color.json with install -T" +if grep -E 'mv -f' "$helper" >/dev/null; then + fail "omarchy-theme-set-browser-policy does not mv into a planted color.json directory" +fi + +pass "browser policy helper writes a fixed set of policy directories" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/pkexec" <<'SH' +#!/bin/bash +printf 'pkexec %s\n' "$*" >"$ELEVATION_LOG" +SH +chmod +x "$stub_bin/pkexec" + +# STUB_GRANTED empty stands for an install whose omarchy-settings predates the +# sudoers file. The default is granted, matching a current Omarchy. +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash +if [[ $1 == -n && $2 == -l ]]; then + if [[ ${STUB_GRANTED-granted} == "granted" ]]; then + echo " Options: !authenticate" + else + echo " Matched: ${!#}" + fi + exit 0 +fi +printf 'sudo %s\n' "$*" >"$ELEVATION_LOG" +SH +chmod +x "$stub_bin/sudo" + +if ((EUID == 0)); then + skip "running as root; skipping the elevation checks, which would rewrite this machine's browser policy" +else + elevation_for() { + : >"$test_tmp/elevation" + ELEVATION_LOG="$test_tmp/elevation" \ + PATH="$stub_bin:$PATH" \ + bash "$helper" "$@" /dev/null 2>&1 || true + cat "$test_tmp/elevation" + } + + elevation=$(elevation_for 1c2027) + [[ $elevation == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy takes the passwordless sudo grant without a terminal" \ + "got: $elevation" + + dev_linked=$(OMARCHY_PATH="$test_tmp/checkout" elevation_for 1c2027) + [[ $dev_linked == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy elevates the system install wherever OMARCHY_PATH points" \ + "got: $dev_linked" + + pass "browser policy helper elevates a valid color through the sudo grant" + + ungranted=$(STUB_GRANTED="" elevation_for 1c2027) + [[ $ungranted == "pkexec /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy falls back to polkit where the grant does not reach" \ + "got: $ungranted" + + pass "browser policy helper falls back to polkit wherever the grant does not reach" + + for bad in "" "1C2027" "abc12" "abc1234" "1c202g" "../../etc/passwd" "1c2027 1c2027" \ + '$(id)' "1c2027;id" "#1c2027"; do + if PATH="$stub_bin:$PATH" ELEVATION_LOG="$test_tmp/elevation" \ + bash "$helper" "$bad" /dev/null 2>&1; then + fail "omarchy-theme-set-browser-policy rejects '$bad'" + fi + + rejected=$(elevation_for "$bad") + [[ -z $rejected ]] || + fail "omarchy-theme-set-browser-policy rejects '$bad' before elevating" "got: $rejected" + done + + if PATH="$stub_bin:$PATH" bash "$helper" 1c2027 ffffff /dev/null 2>&1; then + fail "omarchy-theme-set-browser-policy rejects more than one argument" + fi + + pass "browser policy helper accepts nothing but six lowercase hex digits" +fi + +setter_bin="$test_tmp/setter-bin" +mkdir -p "$setter_bin" + +cat >"$setter_bin/omarchy-theme-set-browser-policy" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >"$COLOR_LOG" +SH +chmod +x "$setter_bin/omarchy-theme-set-browser-policy" + +cat >"$setter_bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$setter_bin/omarchy-cmd-present" + +setter_home="$test_tmp/home" +theme_dir="$setter_home/.local/state/omarchy/current/theme" +mkdir -p "$theme_dir" + +color_for_theme() { + : >"$test_tmp/color" + if [[ $# -gt 0 ]]; then + printf '%s' "$1" >"$theme_dir/chromium.theme" + else + rm -f "$theme_dir/chromium.theme" + fi + + HOME="$setter_home" COLOR_LOG="$test_tmp/color" PATH="$setter_bin:$stub_bin:$PATH" \ + OMARCHY_PATH="$ROOT" bash "$setter" /dev/null 2>&1 || true + cat "$test_tmp/color" +} + +[[ $(color_for_theme "242,240,229") == "f2f0e5" ]] || + fail "omarchy-theme-set-browser converts an RGB triple to six hex digits" +[[ $(color_for_theme $'14,31,41\n') == "0e1f29" ]] || + fail "omarchy-theme-set-browser accepts a trailing newline" +[[ $(color_for_theme "0,0,0") == "000000" ]] || + fail "omarchy-theme-set-browser pads single-digit components" +[[ $(color_for_theme " 12 , 11 , 12 ") == "0c0b0c" ]] || + fail "omarchy-theme-set-browser tolerates surrounding whitespace" + +for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \ + "1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do + color=$(color_for_theme "$malformed") + [[ $color == "1c2027" ]] || + fail "omarchy-theme-set-browser falls back to the stock colour for '$malformed'" "got: $color" +done + +[[ $(color_for_theme) == "1c2027" ]] || + fail "omarchy-theme-set-browser falls back to the stock colour with no theme file" + +pass "browser theme color is derived as six hex digits or falls back to the stock grey" diff --git a/test/shell.d/button-border-stability-test.sh b/test/shell.d/button-border-stability-test.sh index a120f8ef..22b56dc0 100644 --- a/test/shell.d/button-border-stability-test.sh +++ b/test/shell.d/button-border-stability-test.sh @@ -20,7 +20,7 @@ JS require_compositor "Button hover geometry runtime test" if ! command -v quickshell >/dev/null 2>&1; then - pass "quickshell not installed; skipping Button hover geometry runtime test" + skip "quickshell not installed; skipping Button hover geometry runtime test" exit 0 fi diff --git a/test/shell.d/channel-test.sh b/test/shell.d/channel-test.sh index 664e17c5..7e7d4170 100644 --- a/test/shell.d/channel-test.sh +++ b/test/shell.d/channel-test.sh @@ -31,6 +31,12 @@ for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" ' +write_stub omarchy-update-pacman '#!/bin/bash +printf "update-pacman" >>"$OMARCHY_CHANNEL_TEST_LOG" +for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done +printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" +' + write_stub omarchy-dev-unlink '#!/bin/bash printf "unlink" >>"$OMARCHY_CHANNEL_TEST_LOG" for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done @@ -106,7 +112,7 @@ assert_log_line() { run_channel stable assert_log_line $'refresh\tstable' "stable refreshes the stable pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages" +assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages" assert_log_line $'unlink\t--no-reboot' "stable restores the package-backed Omarchy path without an early reboot prompt" assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "stable runs the normal update pipeline from the package-backed path" if grep -q $'^state\tset\treboot-required$' "$log_file"; then @@ -116,17 +122,17 @@ pass "stable does not require reboot when already package-backed" run_channel rc assert_log_line $'refresh\trc' "rc refreshes the rc pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages" +assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages" assert_log_line $'unlink\t--no-reboot' "rc restores the package-backed Omarchy path without an early reboot prompt" assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "rc runs the normal update pipeline from the package-backed path" OMARCHY_TEST_PATH="$ROOT" run_channel edge assert_log_line $'refresh\tedge' "edge refreshes the edge pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages" +assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages" assert_log_line $'unlink\t--no-reboot' "edge unlinks dev without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "edge marks reboot required when leaving dev" assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "edge runs the normal update pipeline from the package-backed path" -[[ $(grep -E '^(unlink|state|update)' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH=/usr/share/omarchy' ]] || +[[ $(grep -E $'^(unlink|state|update)\t' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH=/usr/share/omarchy' ]] || fail "edge defers the reboot prompt until the update restart stage" "$(cat "$log_file")" pass "edge defers the reboot prompt until the update restart stage" @@ -146,12 +152,12 @@ rmdir "$checkout" run_channel dev assert_log_line $'gum\tconfirm\t--default=false\tSwitch to dev channel?' "dev asks for confirmation" assert_log_line $'refresh\tedge' "dev refreshes the edge pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" -assert_log_line $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy" +assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" +assert_log_line $'git\tclone\thttps://github.com/omacom/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy" assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "dev links ~/omarchy without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "dev defers the reboot prompt to the update pipeline" assert_log_line $'update\t-y\tOMARCHY_PATH='"$checkout" "dev runs the normal update pipeline from the source checkout" -[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nsudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || +[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/omacom/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nupdate-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || fail "dev activates the checkout before changing or updating packages" "$(cat "$log_file")" pass "dev activates the checkout before changing or updating packages" diff --git a/test/shell.d/chromium-claude-test.sh b/test/shell.d/chromium-claude-test.sh new file mode 100644 index 00000000..04ad51d3 --- /dev/null +++ b/test/shell.d/chromium-claude-test.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Exercise the privileged installer without writing to the host's /usr/share. +if ! command -v bwrap >/dev/null || ! bwrap --ro-bind / / --unshare-user --uid 0 --gid 0 true 2>/dev/null; then + skip "user namespaces unavailable; skipping isolated Claude extension installation" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/share" "$test_tmp/bin" +installer="$ROOT/bin/omarchy-install-chromium-claude" +extension_id=fcoeoabgfenejglbffodgkkbkcdhcgfn +sandbox=(bwrap --ro-bind / / --bind "$test_tmp" "$test_tmp" --dev /dev --proc /proc --unshare-user) + +"${sandbox[@]}" --uid 0 --gid 0 --bind "$test_tmp/share" /usr/share bash "$installer" +for browser in chromium google-chrome microsoft-edge; do + file="$test_tmp/share/$browser/extensions/$extension_id.json" + jq -e '.external_update_url == "https://clients2.google.com/service/update2/crx"' "$file" >/dev/null || + fail "$browser registers the official Claude Web Store extension" + [[ $(stat -c '%a' "$file") == "644" ]] || fail "$browser extension registration is readable" +done +pass "Claude extension installer registers all supported browser families" + +cat >"$test_tmp/bin/pkexec" <<'SH' +#!/bin/bash +printf '%s\n' "$@" >"$AUTH_LOG" +exit 42 +SH +chmod +x "$test_tmp/bin/pkexec" +export AUTH_LOG="$test_tmp/auth-log" +export PATH="$test_tmp/bin:$PATH" + +# A read-only /usr/share and a failing auth stub prove that a repeated run +# neither rewrites the files nor requests authentication. +"${sandbox[@]}" --uid 1000 --gid 1000 --ro-bind "$test_tmp/share" /usr/share bash "$installer" /dev/null || fail "copy-url native host manifest uses Omarchy host path and extension id" pass "copy-url native host installer registers the stable extension id" +[[ -f $test_home/.config/BraveSoftware/Brave-Origin/NativeMessagingHosts/com.omarchy.copy_url.json ]] || + fail "copy-url native host installer covers Brave Origin" +pass "copy-url native host installer covers Brave Origin" + # Chromium ships in the base packages, so fresh installs do not go through # omarchy-install-browser, and they mark every migration as already applied. # The user install still has to register the host itself. diff --git a/test/shell.d/chromium-ytdlp-test.sh b/test/shell.d/chromium-ytdlp-test.sh index 3e17cb86..a2872699 100755 --- a/test/shell.d/chromium-ytdlp-test.sh +++ b/test/shell.d/chromium-ytdlp-test.sh @@ -33,6 +33,10 @@ jq -e --arg path "$ROOT/bin/omarchy-chromium-ytdlp-host" ' ' "$manifest_path" >/dev/null pass "yt-dlp native host manifest uses Omarchy host path and extension id" +[[ -f $test_home/.config/BraveSoftware/Brave-Origin/NativeMessagingHosts/com.omarchy.ytdlp.json ]] || + fail "yt-dlp native host installer covers Brave Origin" +pass "yt-dlp native host installer covers Brave Origin" + parse_result=$(bash -c ' OMARCHY_PATH="$3" source "$1" @@ -50,3 +54,202 @@ bash -c ' ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "javascript:alert(1)" "$ROOT" && fail "yt-dlp native host rejects non-web URLs" pass "yt-dlp native host rejects non-web URLs" + +host_fn() { + OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="${download_dir:-$TMPDIR}" bash -c ' + source "$1" + shift + "$@" + ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "$@" +} + +download_dir="$TMPDIR/videos" +mkdir -p "$download_dir" "$TMPDIR/outside" +good_file="$download_dir/clip [id].mp4" +printf 'x' >"$good_file" +printf 'x' >"$TMPDIR/outside/secret" +ln -s "$TMPDIR/outside/secret" "$download_dir/escape.mp4" + +resolved=$(host_fn resolve_download_file "$good_file") +expected=$(realpath -e -- "$good_file") +[[ $resolved == "$expected" ]] || + fail "yt-dlp native host accepts a regular file in the download dir" "$resolved" +pass "yt-dlp native host accepts a regular file in the download dir" + +host_fn resolve_download_file "--include=not-a-file" && + fail "yt-dlp native host rejects a forged mpv option as the download path" +pass "yt-dlp native host rejects a forged mpv option as the download path" + +host_fn resolve_download_file "$TMPDIR/outside/secret" && + fail "yt-dlp native host rejects a path outside the download dir" +pass "yt-dlp native host rejects a path outside the download dir" + +host_fn resolve_download_file "$download_dir/escape.mp4" && + fail "yt-dlp native host rejects a symlink that escapes the download dir" +pass "yt-dlp native host rejects a symlink that escapes the download dir" + +host_fn resolve_download_file $'clip.mp4\nOMARCHY_FILE\t--include=not-a-file' && + fail "yt-dlp native host rejects a path containing control characters" +pass "yt-dlp native host rejects a path containing control characters" + +newline_target="$download_dir/target"$'\n' +printf 'x' >"$newline_target" +# The decoy is the point: dropping the trailing newline lands on a real, different +# file, so a resolver that strips it resolves to the wrong one instead of failing. +printf 'x' >"$download_dir/target" +ln -s "target"$'\n' "$download_dir/newline-link.mp4" + +host_fn resolve_download_file "$download_dir/newline-link.mp4" && + fail "yt-dlp native host rejects a symlink whose target name ends in a newline" +pass "yt-dlp native host rejects a symlink whose target name ends in a newline" + +ln -s / "$TMPDIR/root-link" +root_resolved=$(download_dir="$TMPDIR/root-link" host_fn resolve_download_file /etc/passwd) +[[ $root_resolved == "/etc/passwd" ]] || + fail "yt-dlp native host accepts a file when the download dir resolves to /" "$root_resolved" +pass "yt-dlp native host accepts a file when the download dir resolves to /" + +title=$(host_fn title_from_file "$good_file") +[[ $title == "clip [id]" ]] || fail "yt-dlp native host titles the toast from the filename" "$title" +pass "yt-dlp native host titles the toast from the filename" + +decoded_title=$(host_fn decode_title '"My Great Clip"') +[[ $decoded_title == "My Great Clip" ]] || + fail "yt-dlp native host shows the page title on the toast" "$decoded_title" +pass "yt-dlp native host shows the page title on the toast" + +forged_title=$(host_fn decode_title '"Clip\nOMARCHY_FILE\tPlay me\t--include=not-a-file"') +[[ $forged_title == "Clip" ]] || + fail "yt-dlp native host keeps only the readable part of a forged title" "$forged_title" +pass "yt-dlp native host keeps only the readable part of a forged title" + +host_fn decode_title '"--include=not-a-file"' && + fail "yt-dlp native host refuses a leading-dash title the notifier would reject as an option" +pass "yt-dlp native host refuses a leading-dash title the notifier would reject as an option" + +host_fn decode_title 'null' && + fail "yt-dlp native host refuses a title that is not a JSON string" +pass "yt-dlp native host refuses a title that is not a JSON string" + +dash_title=$(host_fn title_from_file "$download_dir/--include.mp4") +[[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to the notifier" "$dash_title" +pass "yt-dlp native host does not pass a leading-dash title to the notifier" + +# The click action passes the path as a discrete --exec argument (asserted +# end-to-end below against the real download); `--` keeps mpv from parsing a +# leading-dash filename as an option. + +parse_script="$TMPDIR/parse-ytdlp-lines.sh" +cat >"$parse_script" <<'EOF' +source "$1" +filepath="" +while IFS= read -r line; do + case $line in + OMARCHY_FILE*) + resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue + filepath=$resolved + ;; + esac +done +printf '%s' "$filepath" +EOF + +# A title that ends in a newline closes its own record, so the forged record is the +# last one and the real path lands on a line the loop ignores. +poisoned=$( + printf '%s\n' \ + $'OMARCHY_FILE\t'"$good_file" \ + $'OMARCHY_FILE\tPlay me\t--include=not-a-file' \ + $'\t'"$good_file" | + OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="$download_dir" bash "$parse_script" "$ROOT/bin/omarchy-chromium-ytdlp-host" +) + +[[ $poisoned == "$expected" ]] || + fail "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record" "$poisoned" +pass "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record" + +# Everything above tests the helpers in isolation. Drive the real download_url with +# stubbed tools so the yt-dlp invocation and the toast's click command are covered +# too: a record template that carried the title again would pass every test above. +fake_root="$TMPDIR/fake" +mkdir -p "$fake_root/bin" +fake_dir="$TMPDIR/fake-videos" +mkdir -p "$fake_dir" +fake_file="$fake_dir/Real_Clip [id].mp4" +printf 'x' >"$fake_file" +ytdlp_argv="$TMPDIR/ytdlp-argv" +notify_argv="$TMPDIR/notify-argv" + +cat >"$fake_root/bin/yt-dlp" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >>"$YTDLP_ARGV_LOG" +for arg in "$@"; do + if [[ $arg == "--no-simulate" ]]; then + printf 'OMARCHY_FILE\t%s\n' "$YTDLP_FAKE_FILE" + [[ -n ${YTDLP_SKIP_TITLE:-} ]] || printf 'OMARCHY_TITLE\t%s\n' '"My Great Clip"' + exit 0 + fi +done +exit 0 +EOF + +cat >"$fake_root/bin/omarchy-notification-send" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >>"$NOTIFY_ARGV_LOG" +EOF + +for stub in omarchy-osd omarchy-shell ffmpeg; do + printf '#!/bin/bash\nexit 0\n' >"$fake_root/bin/$stub" +done +chmod +x "$fake_root/bin/"* + +YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \ + OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \ + bash -c ' + source "$1" + download_url "$2" + ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1 + +(($(grep -c -- '--no-exec-before-download' "$ytdlp_argv") == 2)) || + fail "yt-dlp native host disarms configured exec hooks on both yt-dlp runs" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host disarms configured exec hooks on both yt-dlp runs" + +# The stub answers with a title record whatever it is asked for, so assert the request +# as well as the reply: without this the template could be dropped and nothing notice. +grep -qF -- $'OMARCHY_TITLE\t%(title)j' "$ytdlp_argv" || + fail "yt-dlp native host asks for the title JSON-encoded" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host asks for the title JSON-encoded" + +grep -qF -- "-o %(title)s.%(ext)s" "$ytdlp_argv" || + fail "yt-dlp native host names the saved file after the page title" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host names the saved file after the page title" + +grep -q -- '--restrict-filenames' "$ytdlp_argv" && + fail "yt-dlp native host keeps spaces in the saved filename" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host keeps spaces in the saved filename" + +grep -qF -- $'OMARCHY_FILE\t%(title)s' "$ytdlp_argv" && + fail "yt-dlp native host never prints the title into the file record" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host never prints the title into the file record" + +grep -q -- "--exec mpv -- " "$notify_argv" || + fail "yt-dlp native host builds the click command as mpv -- " "$(cat "$notify_argv")" +pass "yt-dlp native host builds the click command as mpv -- " + +grep -qF -- "Download complete My Great Clip" "$notify_argv" || + fail "yt-dlp native host toasts the page title, not the sanitised filename" "$(cat "$notify_argv")" +pass "yt-dlp native host toasts the page title, not the sanitised filename" + +# A page that gives no usable title falls back to the filename rather than an empty toast. +: >"$notify_argv" +: >"$ytdlp_argv" +YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \ + YTDLP_SKIP_TITLE=1 OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \ + bash -c ' + source "$1" + download_url "$2" + ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1 + +grep -qF -- "Download complete Real_Clip [id]" "$notify_argv" || + fail "yt-dlp native host falls back to the filename when no title record arrives" "$(cat "$notify_argv")" +pass "yt-dlp native host falls back to the filename when no title record arrives" diff --git a/test/shell.d/clipboard-test.sh b/test/shell.d/clipboard-test.sh index 962d529e..5580a694 100644 --- a/test/shell.d/clipboard-test.sh +++ b/test/shell.d/clipboard-test.sh @@ -277,12 +277,12 @@ printf '%s\n' "$1" >"$EDITOR_PATH_OUT" cat "$1" >"$EDITOR_TEXT_OUT" SH -cat >"$TMPDIR/bin/tensaku-edit" <<'SH' +cat >"$TMPDIR/bin/omasnap" <<'SH' #!/bin/bash -printf '%s\n' "$*" >"$TENSAKU_OUT" +printf '%s\n' "$*" >"$OMASNAP_OUT" SH -chmod +x "$TMPDIR/bin/wl-copy" "$TMPDIR/bin/wl-paste" "$TMPDIR/bin/wtype" "$TMPDIR/bin/omarchy-launch-browser" "$TMPDIR/bin/omarchy-launch-editor" "$TMPDIR/bin/tensaku-edit" +chmod +x "$TMPDIR/bin/wl-copy" "$TMPDIR/bin/wl-paste" "$TMPDIR/bin/wtype" "$TMPDIR/bin/omarchy-launch-browser" "$TMPDIR/bin/omarchy-launch-editor" "$TMPDIR/bin/omasnap" capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") [[ $capture_output == '{"type":"text","text":"terminal copy"}' ]] || fail "clipboard capture records normal text events" @@ -513,8 +513,8 @@ pass "clipboard open helper opens text entries in editor" [[ $(<"$TMPDIR/editor-path") == "$TMPDIR"/state/omarchy/clipboard-open/clipboard.*.txt ]] || fail "clipboard open helper writes text entries to a temporary file" pass "clipboard open helper writes text entries to a temporary file" -TENSAKU_OUT="$TMPDIR/tensaku" HOME="$TMPDIR/home" PATH="$TMPDIR/bin:$PATH" \ +OMASNAP_OUT="$TMPDIR/omasnap" HOME="$TMPDIR/home" PATH="$TMPDIR/bin:$PATH" \ "$ROOT/bin/omarchy-clipboard-open" --history-index 2 -[[ $(<"$TMPDIR/tensaku") == "$TMPDIR/image.png" ]] || fail "clipboard open helper opens image entries in Tensaku" -pass "clipboard open helper opens image entries in Tensaku" +[[ $(<"$TMPDIR/omasnap") == "$TMPDIR/image.png" ]] || fail "clipboard open helper opens image entries in Omasnap" +pass "clipboard open helper opens image entries in Omasnap" diff --git a/test/shell.d/command-operand-boundaries-test.sh b/test/shell.d/command-operand-boundaries-test.sh new file mode 100755 index 00000000..652b1ce7 --- /dev/null +++ b/test/shell.d/command-operand-boundaries-test.sh @@ -0,0 +1,259 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +export OMARCHY_PATH="$ROOT" + +if "$ROOT/bin/omarchy-cmd-present" -p; then + fail "cmd-present treats an option-shaped command name literally" +fi +pass "cmd-present treats an option-shaped command name literally" + +"$ROOT/bin/omarchy-cmd-missing" -p || + fail "cmd-missing treats an option-shaped command name literally" +pass "cmd-missing treats an option-shaped command name literally" + +"$ROOT/bin/omarchy-cmd-present" bash || fail "cmd-present still finds an ordinary command" +pass "cmd-present still finds an ordinary command" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" + +cat >"$mock_bin/pacman" <<'STUB' +#!/bin/bash + +if [[ -n ${OMARCHY_TEST_PACKAGE_CALLS:-} ]]; then + { + printf 'pacman' + printf ' <%s>' "$@" + printf '\n' + } >>"$OMARCHY_TEST_PACKAGE_CALLS" +fi + +if [[ $1 == "-S" ]]; then + exit 0 +elif [[ $1 != "-Q" ]]; then + exit 2 +fi +shift + +if [[ -n ${OMARCHY_TEST_PACKAGE_CALLS:-} ]]; then + exit 0 +elif [[ ${1:-} == "--" ]]; then + shift + [[ ${1:-} == "installed" ]] +elif [[ ${1:-} == "--help" ]]; then + exit 0 +else + [[ ${1:-} == "installed" ]] +fi +STUB +chmod +x "$mock_bin/pacman" + +if PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-pkg-present" --help; then + fail "pkg-present treats an option-shaped package name literally" +fi +pass "pkg-present treats an option-shaped package name literally" + +PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-pkg-missing" --help || + fail "pkg-missing treats an option-shaped package name literally" +pass "pkg-missing treats an option-shaped package name literally" + +PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-pkg-present" installed || + fail "pkg-present still finds an installed package" +pass "pkg-present still finds an installed package" + +package_calls="$test_tmp/package-calls" +cat >"$mock_bin/omarchy-pkg-missing" <<'STUB' +#!/bin/bash + +exit 0 +STUB +cat >"$mock_bin/sudo" <<'STUB' +#!/bin/bash + +exec "$@" +STUB +cat >"$mock_bin/yay" <<'STUB' +#!/bin/bash + +: "${OMARCHY_TEST_PACKAGE_CALLS:?}" +{ + printf 'yay' + printf ' <%s>' "$@" + printf '\n' +} >>"$OMARCHY_TEST_PACKAGE_CALLS" +STUB +chmod +x "$mock_bin/omarchy-pkg-missing" "$mock_bin/sudo" "$mock_bin/yay" + +: >"$package_calls" +PATH="$mock_bin:$ROOT/bin:$PATH" OMARCHY_TEST_PACKAGE_CALLS="$package_calls" \ + "$ROOT/bin/omarchy-pkg-add" --hookdir=/tmp/hooks + +grep -Fx 'pacman <-S> <--noconfirm> <--needed> <--> <--hookdir=/tmp/hooks>' "$package_calls" >/dev/null || + fail "pkg-add separates package operands from pacman options" "$(<"$package_calls")" +grep -Fx 'pacman <-Q> <--> <--hookdir=/tmp/hooks>' "$package_calls" >/dev/null || + fail "pkg-add verifies an option-shaped package name literally" "$(<"$package_calls")" +pass "pkg-add keeps option-shaped package names out of pacman option parsing" + +: >"$package_calls" +PATH="$mock_bin:$ROOT/bin:$PATH" OMARCHY_TEST_PACKAGE_CALLS="$package_calls" \ + "$ROOT/bin/omarchy-pkg-aur-add" --hookdir=/tmp/hooks + +grep -Fx 'yay <-S> <--noconfirm> <--needed> <--> <--hookdir=/tmp/hooks>' "$package_calls" >/dev/null || + fail "pkg-aur-add separates package operands from yay options" "$(<"$package_calls")" +grep -Fx 'pacman <-Q> <--> <--hookdir=/tmp/hooks>' "$package_calls" >/dev/null || + fail "pkg-aur-add verifies an option-shaped package name literally" "$(<"$package_calls")" +pass "pkg-aur-add keeps option-shaped package names out of package-manager option parsing" + +cat >"$mock_bin/grep" <<'STUB' +#!/bin/bash + +if [[ $1 != "-qi" ]]; then + exit 2 +fi +shift + +if [[ ${1:-} == "--help" ]]; then + exit 0 +elif [[ ${1:-} == "--" ]]; then + shift +fi +[[ ${1:-} == "known-hardware" ]] +STUB +chmod +x "$mock_bin/grep" + +if PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-hw-match" --help; then + fail "hw-match treats an option-shaped hardware pattern literally" +fi +pass "hw-match treats an option-shaped hardware pattern literally" + +PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-hw-match" known-hardware || + fail "hw-match still accepts an ordinary hardware pattern" +pass "hw-match still accepts an ordinary hardware pattern" + +pkill_calls="$test_tmp/pkill-calls" +cat >"$mock_bin/pkill" <<'STUB' +#!/bin/bash + +: "${OMARCHY_TEST_PKILL_CALLS:?}" +printf '<%s>\n' "$@" >"$OMARCHY_TEST_PKILL_CALLS" +STUB +cat >"$mock_bin/setsid" <<'STUB' +#!/bin/bash + +if [[ -n ${OMARCHY_TEST_SETSID_CALLS:-} ]]; then + printf '<%s>\n' "$@" >"$OMARCHY_TEST_SETSID_CALLS" +fi +exit 0 +STUB +chmod +x "$mock_bin/pkill" "$mock_bin/setsid" + +PATH="$mock_bin:$PATH" OMARCHY_TEST_PKILL_CALLS="$pkill_calls" \ + "$ROOT/bin/omarchy-restart-app" "-9 kitty" + +pkill_argv=$(<"$pkill_calls") +[[ $pkill_argv == $'<-x>\n<-->\n<-9 kitty>' ]] || + fail "restart-app treats the application name as one literal pkill pattern" "$pkill_argv" +pass "restart-app treats the application name as one literal pkill pattern" + +fake_home="$test_tmp/home" +editor_calls="$test_tmp/editor-calls" +mkdir -p "$fake_home/.local/state/omarchy/defaults" +printf 'nvim\n' >"$fake_home/.local/state/omarchy/defaults/editor" + +cat >"$mock_bin/nvim" <<'STUB' +#!/bin/bash + +: "${OMARCHY_TEST_EDITOR_CALLS:?}" +printf '<%s>\n' "$@" >"$OMARCHY_TEST_EDITOR_CALLS" +STUB +chmod +x "$mock_bin/nvim" + +HOME="$fake_home" PATH="$mock_bin:$ROOT/bin:$PATH" OMARCHY_TEST_EDITOR_CALLS="$editor_calls" \ + "$ROOT/bin/omarchy-launch-editor" --inline -cquit + +editor_argv=$(<"$editor_calls") +[[ $editor_argv == $'<-->\n<-cquit>' ]] || + fail "launch-editor separates an option-shaped path from editor options" "$editor_argv" +pass "launch-editor separates an option-shaped path from editor options" + +cat >"$mock_bin/omarchy-launch-tui" <<'STUB' +#!/bin/bash + +: "${OMARCHY_TEST_EDITOR_CALLS:?}" +printf '<%s>\n' "$@" >"$OMARCHY_TEST_EDITOR_CALLS" +STUB +chmod +x "$mock_bin/omarchy-launch-tui" + +HOME="$fake_home" PATH="$mock_bin:$ROOT/bin:$PATH" OMARCHY_TEST_EDITOR_CALLS="$editor_calls" \ + "$ROOT/bin/omarchy-launch-editor" -cquit + +editor_argv=$(<"$editor_calls") +[[ $editor_argv == $'\n<-->\n<-cquit>' ]] || + fail "launch-editor separates a terminal editor path from editor options" "$editor_argv" +pass "launch-editor separates a terminal editor path from editor options" + +setsid_calls="$test_tmp/setsid-calls" +printf 'code\n' >"$fake_home/.local/state/omarchy/defaults/editor" +cat >"$mock_bin/code" <<'STUB' +#!/bin/bash + +exit 0 +STUB +chmod +x "$mock_bin/code" + +HOME="$fake_home" PATH="$mock_bin:$ROOT/bin:$PATH" OMARCHY_TEST_SETSID_CALLS="$setsid_calls" \ + "$ROOT/bin/omarchy-launch-editor" -cquit + +setsid_argv=$(<"$setsid_calls") +[[ $setsid_argv == $'\n<-->\n\n<-->\n<-cquit>' ]] || + fail "launch-editor separates a graphical editor path from editor options" "$setsid_argv" +pass "launch-editor separates a graphical editor path from editor options" + +hook_calls="$test_tmp/hook-calls" +hook_home="$test_tmp/hook-home" +printf '#!/bin/bash\n' >"$test_tmp/--help" +cat >"$mock_bin/basename" <<'STUB' +#!/bin/bash + +: "${OMARCHY_TEST_HOOK_CALLS:?}" +{ + printf 'basename' + printf ' <%s>' "$@" + printf '\n' +} >>"$OMARCHY_TEST_HOOK_CALLS" +printf 'literal-hook\n' +STUB +cat >"$mock_bin/cp" <<'STUB' +#!/bin/bash + +: "${OMARCHY_TEST_HOOK_CALLS:?}" +{ + printf 'cp' + printf ' <%s>' "$@" + printf '\n' +} >>"$OMARCHY_TEST_HOOK_CALLS" +STUB +for command in mkdir chmod; do + printf '#!/bin/bash\nexit 0\n' >"$mock_bin/$command" +done +chmod +x "$mock_bin/basename" "$mock_bin/cp" "$mock_bin/mkdir" "$mock_bin/chmod" + +: >"$hook_calls" +( + cd -- "$test_tmp" + HOME="$hook_home" PATH="$mock_bin:$PATH" OMARCHY_TEST_HOOK_CALLS="$hook_calls" \ + "$ROOT/bin/omarchy-hook-install" post-update --help >/dev/null +) + +hook_argv=$(<"$hook_calls") +expected_hook_argv=$'basename <--> <--help>\ncp <--> <--help> <'"$hook_home"'/.config/omarchy/hooks/post-update.d/literal-hook>' +[[ $hook_argv == $expected_hook_argv ]] || + fail "hook-install treats its source file as an operand" "$hook_argv" +pass "hook-install treats its source file as an operand" diff --git a/test/shell.d/compositor-guard-test.sh b/test/shell.d/compositor-guard-test.sh index 5ea10358..e50d2b17 100644 --- a/test/shell.d/compositor-guard-test.sh +++ b/test/shell.d/compositor-guard-test.sh @@ -62,7 +62,7 @@ run_guard() { ' bash "$SHELL_TEST_DIR" 2>&1 || printf 'guard exited %s\n' "$?" } -skipped="ok - no Wayland compositor; skipping sample runtime test" +skipped="ok - no Wayland compositor; skipping sample runtime test # SKIP" output=$(run_guard -u WAYLAND_DISPLAY XDG_RUNTIME_DIR="$runtime_dir") [[ $output == "$skipped" ]] || fail "guard skips without a display" "$output" @@ -76,7 +76,7 @@ pass "guard skips when the socket is unreachable" # Everything below needs a socket to stand in for a live or abandoned compositor. if (( ! socket_bound )); then - pass "cannot bind a Unix socket here; skipping the cases that need one" + skip "cannot bind a Unix socket here; skipping the cases that need one" exit 0 fi diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 3add6e30..82a51e6f 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -33,11 +33,29 @@ jq -e ' ' "$ROOT/config/omarchy/shell.json" >/dev/null pass "default center anchor exists in center layout" +jq -e ' + def ids: map(.id // .); + (.bar.layout.center | ids) as $ids | + ($ids | index("omacom.elsewhen")) as $elsewhen | + ($ids | index("omarchy.clock")) as $clock | + $elsewhen != null and $clock == $elsewhen + 1 +' "$ROOT/config/omarchy/shell.json" >/dev/null +pass "default center layout puts elsewhen immediately before the clock" + jq -e ' any(.bar.layout.center[]; (.id // .) == "omarchy.clock" and (.formatAlt // "") == "d MMMM \u0027W\u0027ww yyyy") ' "$ROOT/config/omarchy/shell.json" >/dev/null pass "default clock date format has no leading zero" +jq -e ' + def ids: map(.id // .); + (.bar.layout.right | ids) as $ids | + ($ids | index("omarchy.tray")) as $tray | + ($ids | index("omarchy.agents")) as $agents | + $tray != null and $agents == $tray + 1 +' "$ROOT/config/omarchy/shell.json" >/dev/null +pass "default right layout keeps agents next to the tray" + ROOT="$ROOT" python3 <<'PY' import json import os @@ -142,6 +160,7 @@ package_defaults = [ ("default/systemd/user/omarchy-fcitx5.service", "/usr/lib/systemd/user/omarchy-fcitx5.service", "systemd/user/omarchy-fcitx5.service"), ("default/systemd/user/omarchy-crash-watch.service", "/usr/lib/systemd/user/omarchy-crash-watch.service", "systemd/user/omarchy-crash-watch.service"), ("default/systemd/zram-generator.conf.d/90-omarchy.conf", "/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf", "systemd/zram-generator.conf.d/90-omarchy.conf"), + ("default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf", "/usr/lib/systemd/system/plocate-updatedb.service.d/10-omarchy.conf", "systemd/system/plocate-updatedb.service.d/10-omarchy.conf"), ("default/fonts/omarchy/omarchy.ttf", "/usr/share/fonts/omarchy/omarchy.ttf", "omarchy.ttf"), ("default/snapper/root", "/etc/snapper/config-templates/omarchy", "snapper/root"), ] @@ -418,39 +437,3 @@ if grep -RIl 'upgrade-to-quattro\|Omarchy 4\.0 is upgraded' "$ROOT/migrations" > fail "4.0 upgrade is not modeled as a migration" fi pass "4.0 upgrade is handled outside the migration runner" - -clock_migration=$(grep -rl 'Remove leading zero from bar clock date' "$ROOT/migrations" | head -n 1 || true) -[[ -n $clock_migration ]] || fail "clock date format user migration exists" - -cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON' -{ - "version": 1, - "bar": { - "layout": { - "left": [], - "center": [ - { "id": "omarchy.clock", "formatAlt": "dd MMMM 'W'ww yyyy" }, - { "id": "omarchy.weather" } - ], - "right": [ - { "id": "local.clock", "formatAlt": "dd MMMM 'W'ww yyyy" } - ] - } - }, - "plugins": [] -} -JSON - -HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" bash "$clock_migration" - -jq -e ' - .bar.layout.center[0].formatAlt == "d MMMM \u0027W\u0027ww yyyy" and - .bar.layout.right[0].formatAlt == "dd MMMM \u0027W\u0027ww yyyy" -' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null -pass "clock date format migration removes leading zero from clock" - -before=$(sha256sum "$TMPDIR/home/.config/omarchy/shell.json" | awk '{print $1}') -HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" bash "$clock_migration" -after=$(sha256sum "$TMPDIR/home/.config/omarchy/shell.json" | awk '{print $1}') -[[ $before == "$after" ]] || fail "clock date format migration is idempotent" -pass "clock date format migration is idempotent" diff --git a/test/shell.d/copy-url-shortcut-migration-test.sh b/test/shell.d/copy-url-shortcut-migration-test.sh deleted file mode 100644 index f8cf3497..00000000 --- a/test/shell.d/copy-url-shortcut-migration-test.sh +++ /dev/null @@ -1,228 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" - -require_command jq -require_command python3 - -migration="$ROOT/migrations/1786643346.sh" -test_dir=$(mktemp -d) -trap 'rm -rf "$test_dir"' EXIT - -home="$test_dir/home" -profile_root="$home/.config/chromium" -preferences="$profile_root/Default/Preferences" -mkdir -p "$(dirname "$preferences")" - -# Any id Chromium once derived from the extension's keyless load path; the -# repair keys off the registered command name, not the id. -ghost_id="ikkebdkaanlebnifjnbeiaklodhbjcci" -pinned_id="bgpiichlckmfanooecilcjemknkcpngb" - -write_stale_preferences() { - jq -n --arg ghost "$ghost_id" --arg pinned "$pinned_id" '{extensions: {commands: {"linux:Alt+Shift+L": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {($ghost): {commands: {"copy-url": {suggested_key: "Alt+Shift+L", was_assigned: true}}}, ($pinned): {commands: {"copy-url": {suggested_key: "Alt+Shift+L"}}}}}}' >"$preferences" -} - -stub_bin="$test_dir/bin" -mkdir -p "$stub_bin" - -REAL_PYTHON=$(command -v python3) -export REAL_PYTHON - -run_migration() { - HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 -} - -# A running Chromium-family browser marks its profile root with a SingletonLock -# symlink to -, a target that never exists on disk. That lock — -# not the mere presence of a browser process — is what the migration waits on. -open_browser() { - mkdir -p "$profile_root" - ln -sfn "test-host-1234" "$profile_root/SingletonLock" -} -close_browser() { - rm -f "$profile_root/SingletonLock" -} - -# The affected profile being open prompts for the windows to be closed; -# declining (or having no terminal to ask in) defers the repair so a -# rewrite-on-exit cannot revert it. -printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum" -chmod +x "$stub_bin/gum" -write_stale_preferences -open_browser - -before_hash=$(sha256sum "$preferences" | cut -d' ' -f1) - -run_migration && fail "migration defers while the affected profile is open" -[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$before_hash" ]] || - fail "migration leaves preferences alone while the affected profile is open" -pass "migration defers the repair while the affected profile is open" - -# gum paints its prompt on stderr, so that stream has to stay attached: -# suppressing it leaves gum reading keys behind an unpainted screen, which -# reads as a hung update. -cat >"$stub_bin/gum" <<'STUB' -#!/bin/bash -echo "gum-prompt-painted" >&2 -exit 1 -STUB -prompt_stderr="$test_dir/prompt-stderr" -HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>"$prompt_stderr" && - fail "migration defers when the browser prompt is declined" -grep -q "gum-prompt-painted" "$prompt_stderr" || fail "migration keeps the browser prompt visible" -pass "migration keeps the browser prompt visible" - -# A browser holding a different profile root cannot revert this repair, so it -# must not hold the update: the repair goes through without ever reaching the -# prompt, which the still-declining gum stub would otherwise fail. -close_browser -mkdir -p "$home/.config/google-chrome" -ln -sfn "test-host-1234" "$home/.config/google-chrome/SingletonLock" -write_stale_preferences -run_migration || fail "migration repairs while a different profile root is open" -jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null || - fail "migration repairs the shortcut while a different profile root is open" -pass "migration ignores a browser on a different profile root" -rm -f "$home/.config/google-chrome/SingletonLock" "$preferences.omarchy-copy-url-repair.bak" - -# Closing the affected profile and confirming the prompt lets the repair -# proceed. -write_stale_preferences -open_browser -cat >"$stub_bin/gum" <<'STUB' -#!/bin/bash -"$CLOSE_BROWSER" -touch "${GUM_CALLED:?}" -exit 0 -STUB -cat >"$stub_bin/close-browser" <<'STUB' -#!/bin/bash -rm -f "$HOME/.config/chromium/SingletonLock" -STUB -chmod +x "$stub_bin/gum" "$stub_bin/close-browser" -GUM_CALLED="$test_dir/gum-called" CLOSE_BROWSER="$stub_bin/close-browser" \ - HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 || - fail "migration proceeds once the profile is closed and the prompt confirmed" -[[ -e $test_dir/gum-called ]] || fail "migration asks before repairing under a running browser" -jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null || - fail "migration repairs after the browser prompt is confirmed" -pass "migration asks to close the browser and repairs on confirmation" -rm -f "$preferences.omarchy-copy-url-repair.bak" - -# With the affected profile closed the ghost registration moves to the pinned id. -printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum" -close_browser -write_stale_preferences -run_migration || fail "migration repairs the shortcut when no browser is running" - -jq -e --arg ghost "$ghost_id" --arg pinned "$pinned_id" ' - .extensions.commands["linux:Alt+Shift+L"].extension == $pinned and - (.extensions.settings | has($ghost) | not) and - .extensions.settings[$pinned].commands["copy-url"].was_assigned == true -' "$preferences" >/dev/null || fail "migration rebinds the Copy URL shortcut to the pinned extension id" -[[ -f $preferences.omarchy-copy-url-repair.bak ]] || - fail "migration backs up preferences before the repair" -pass "migration rebinds the Copy URL shortcut to the pinned extension id" - -# A repaired profile has no ghost registration left, so nothing is pending — -# even while that same profile is open. -rm "$preferences.omarchy-copy-url-repair.bak" -repaired_hash=$(sha256sum "$preferences" | cut -d' ' -f1) -open_browser -run_migration || fail "migration reruns cleanly after the repair" -[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$repaired_hash" && ! -e $preferences.omarchy-copy-url-repair.bak ]] || - fail "migration is idempotent after the repair" -pass "migration is idempotent after the repair" -close_browser - -# A remapped shortcut keeps the user's chosen key while moving to the pinned id. -jq -n --arg ghost "$ghost_id" '{extensions: {commands: {"linux:Ctrl+Alt+P": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {}}}' >"$preferences" -run_migration || fail "migration repairs remapped shortcuts" -jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Ctrl+Alt+P"].extension == $pinned' "$preferences" >/dev/null || - fail "migration keeps the remapped key while rebinding to the pinned id" -pass "migration keeps remapped shortcut keys" - -# When the pinned extension already holds a copy-url binding (the user fixed -# it by hand), the ghost is dropped rather than doubled into a second binding. -jq -n --arg ghost "$ghost_id" --arg pinned "$pinned_id" '{extensions: {commands: {"linux:Ctrl+Alt+P": {command_name: "copy-url", extension: $pinned, global: false}, "linux:Alt+Shift+L": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {}}}' >"$preferences" -run_migration || fail "migration cleans ghosts alongside a manual repair" -jq -e --arg pinned "$pinned_id" ' - (.extensions.commands | has("linux:Alt+Shift+L") | not) and - .extensions.commands["linux:Ctrl+Alt+P"].extension == $pinned -' "$preferences" >/dev/null || fail "migration drops the ghost instead of double-binding the pinned extension" -pass "migration never double-binds the pinned extension" - -# A browser starting mid-repair may write stale Preferences back on exit, so -# the migration must stay pending for a later browser-free run to verify. A -# stub hands the repair call through and opens the profile right after it. -write_stale_preferences -close_browser -rm -f "$preferences.omarchy-copy-url-repair.bak" -cat >"$stub_bin/python3" <<'STUB' -#!/bin/bash -# Called as `python3 -c