diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..8de9215b5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ + +# Python bytecode (orchestrator) +__pycache__/ +*.pyc diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000000..ff0e3599fb --- /dev/null +++ b/.luarc.json @@ -0,0 +1,11 @@ +{ + "workspace": { + "library": [ + "/usr/share/hypr/stubs" + ], + "checkThirdParty": false + }, + "diagnostics": { + "globals": ["hl"] + } +} diff --git a/AGENTS.md b/AGENTS.md index 7d61ac118a..e4a0084ead 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,32 @@ +# Task Guides + +Deeper instructions for specific kinds of work live in `agents/skills/`. Read the +matching guide before starting: + +- [`agents/skills/command-metadata.md`](agents/skills/command-metadata.md) - adding or changing commands in `bin/` +- [`agents/skills/install-scripts.md`](agents/skills/install-scripts.md) - working under `install/` or on system/user setup commands +- [`agents/skills/shell-dev.md`](agents/skills/shell-dev.md) - editing the Quickshell desktop under `shell/` +- [`agents/skills/icon-font.md`](agents/skills/icon-font.md) - adding branded glyphs to `default/fonts/omarchy/omarchy.ttf` +- [`agents/skills/acceptance-tests.md`](agents/skills/acceptance-tests.md) - writing or running graphical acceptance tests under `test/acceptance.d/` +- [`agents/skills/visual-verification.md`](agents/skills/visual-verification.md) - verifying any change with a visual effect in the running UI +- [`agents/skills/migrations.md`](agents/skills/migrations.md) - creating or changing migrations under `migrations/` + +# Documentation Layout + +Three documentation trees, split by genre and audience: + +- `agents/skills/` - task procedure ("do this when doing X"), for anyone working on the codebase +- `docs/` - reference on how the system is shaped (file layout, update pipeline, theming, shell architecture), for anyone working on the codebase; skills link here for depth +- `manual/` - end-user documentation for using Omarchy, published; never codebase internals + # Style +- In markdown documents (`plans/`, `docs/`, `manual/`), write full lines — no hard wrapping at 80 columns; break only at structural boundaries like headings and list items - Two spaces for indentation, no tabs - Use bash 5 conditionals: use `[[ ]]` for string/file tests and `(( ))` for numeric tests - In `[[ ]]`, don't quote variables, but do quote string literals when comparing values (e.g., `[[ $branch == "dev" ]]`) - Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`) +- Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable - For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`) - Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`) - Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs @@ -12,7 +35,9 @@ All commands start with `omarchy-`. Prefixes indicate purpose. -The authoritative command group list lives in `bin/omarchy` in `GROUP_DESCRIPTIONS`. Keep `GROUP_DESCRIPTIONS` updated when adding a new command prefix. +The authoritative list of user-facing command groups lives in `bin/omarchy` in `GROUP_DESCRIPTIONS`. Keep `GROUP_DESCRIPTIONS` updated when adding a new command prefix users are meant to browse to. + +A group whose commands are all `# omarchy:hidden=true` gets no entry. That table drives the top-level group listing on its own, so an entry there advertises the group even when every command in it is hidden. `apply-` and `provision-` are deliberately absent for that reason; both still route, and `omarchy ` still prints a group header without one. Common prefixes include: @@ -29,103 +54,80 @@ Common prefixes include: - `theme-` - theme management - `update-` - update components -Other current prefixes include: - -- `ac-`, `audio-`, `battery-`, `branch-`, `brightness-`, `channel-`, `config-`, `debug-`, `dev-`, `drive-`, `first-`, `font-`, `haptic-`, `hibernation-`, `hook-`, `hyprland-`, `menu-`, `migrate-`, `notification-`, `npx-`, `plymouth-`, `powerprofiles-`, `reinstall-`, `remove-`, `screensaver-`, `show-`, `snapshot-`, `state-`, `sudo-`, `swayosd-`, `system-`, `transcode-`, `tui-`, `tz-`, `upload-`, `version-`, `voxtype-`, `webapp-`, `wifi-`, `windows-` +Do not maintain a second exhaustive prefix list here. Consult +`GROUP_DESCRIPTIONS` when selecting or checking a command group so this +guidance does not drift from the router. -# Command Metadata - -Commands in `bin/` can declare CLI metadata in comments near the top of the file. `bin/omarchy` scans the first 80 lines, and tests expect command metadata to remain valid. - -Supported metadata keys: - -- `# omarchy:summary=...` - short help text -- `# omarchy:group=...` - command group when it differs from the filename-derived prefix -- `# omarchy:name=...` - command name within the group -- `# omarchy:args=...` - usage arguments -- `# omarchy:examples=...` - examples separated with ` | ` -- `# omarchy:alias=...` / `# omarchy:aliases=...` - alternate routes -- `# omarchy:hidden=true` - hide from default command listings -- `# omarchy:requires-sudo=true` - mark commands that require sudo - -Prefer explicit metadata for user-facing commands. Keep routes consistent with the filename unless there is a deliberate alias or compatibility route. - -Example: - -```bash -# omarchy:summary=Take a screenshot -# omarchy:group=capture -# omarchy:args=[smart|region|windows|fullscreen] [slurp|copy] -# omarchy:examples=omarchy screenshot | omarchy capture screenshot region -# omarchy:aliases=omarchy screenshot -``` +# Runtime Environment -# Install Scripts +- `$OMARCHY_PATH` is set at the top level by the uwsm session environment and is always available to Omarchy runtime code. +- Commands in `bin/` and Quickshell QML should rely on `$OMARCHY_PATH` / `Quickshell.env("OMARCHY_PATH")`; do not derive fallback paths from `HOME`, `Quickshell.shellDir`, or re-export/default `OMARCHY_PATH` manually. -Install entry points (`install.sh`, `boot.sh`) use `#!/bin/bash`. Many scripts under `install/` are sourced via `run_logged` and intentionally do not have shebangs. +# Privileged Commands -Install stage files follow this pattern: +- Follow the "Privilege Escalation" section of `default/agents/skills/omarchy/SKILL.md`. It draws the + `sudo`/`pkexec` line by whether the caller has a terminal to enter a password in, and the repo's + own scripts follow it. -- `install/*/all.sh` lists scripts in execution order -- leaf scripts are sourced by `run_logged $OMARCHY_INSTALL/path/to/script.sh` -- avoid `exit` in sourced install scripts unless intentionally aborting the install -- use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths -- keep hardware-specific logic under `install/config/hardware/` -- prefer helper commands for package and command checks where available +# Git -Raw `command -v`, `pacman`, and `pacman-key` are acceptable in bootstrap/preflight/package-helper contexts where the helper commands may not be available yet or where direct package-manager behavior is the point of the script. +- Commits should be atomic: include only one coherent change or fix, and do not mix unrelated work. +- Commit messages should be succinct and describe the change being made. # Helper Commands Use these instead of raw shell commands: - `omarchy-cmd-missing` / `omarchy-cmd-present` - check for commands -- `omarchy-pkg-missing` / `omarchy-pkg-present` - check for packages +- `omarchy-pkg-missing` / `omarchy-pkg-present` - check for packages (don't use these if you can just use `omarchy-pkg-add`/`omarchy-pkg-drop`) - `omarchy-pkg-add` - install packages (handles both pacman and AUR) +- `omarchy-pkg-drop` - remove packages; use this instead of raw `pacman -R*` +- `omarchy-notification-send` - send desktop notifications; do not call `notify-send` directly - `omarchy-hw-asus-rog` - detect ASUS ROG hardware (and similar `hw-*` commands) -Exceptions are allowed for bootstrap, preflight, migration, and package-helper scripts where the helper may not be available yet, where the helper itself is being implemented, or where direct package-manager behavior is required. +Commands installed by Omarchy's default package set are runtime invariants. Invoke them directly; do not add defensive `omarchy-cmd-present` / `omarchy-cmd-missing` checks around them. Use command-presence helpers only for genuinely optional dependencies or code that can run before the default package set is installed. + +Exceptions are allowed for migration and package-helper scripts where the helper may not be available yet, where the helper itself is being implemented, or where direct package-manager behavior is required. + +# Menu + +- The menu definition lives in `default/omarchy/omarchy-menu.jsonc`; + [`docs/menu.md`](docs/menu.md) covers the schema, guards, and providers. +- Do not add `aliases` to new menu entries. Aliases are reserved for + established alternate names users already type, kept for compatibility. # Config Structure - `config/` - default configs copied to `~/.config/` - `default/themed/*.tpl` - templates with `{{ variable }}` placeholders for theme colors -- `themes/*/colors.toml` - theme color definitions (accent, background, foreground, color0-15) +- `themes/*/colors.toml` - theme color definitions (accent, background, foreground, red/green/yellow/blue/magenta/cyan and bright_* variants) -# Visual Changes +# Tests -When making visual changes, such as Waybar styles or desktop appearance, always take and analyze a screenshot after applying the change to verify the result. Use `omarchy capture screenshot fullscreen save` for fullscreen screenshots. +Run focused automated tests for the area you changed; +[`docs/testing.md`](docs/testing.md) covers how the suites are shaped. Current +test entry points: -# Refresh Pattern +- `./test/all` - aggregate runner for CLI and shell tests; it intentionally does not run graphical acceptance tests +- `./test/cli` - CLI routing, command metadata, theme helpers, and safe dispatch coverage +- `./test/shell` - all Omarchy shell tests under `test/shell.d/` -To copy a default config to user config with automatic backup: +New Omarchy shell tests should live in `test/shell.d/*-test.sh` so `./test/shell` picks them up automatically. Source `test/shell.d/base-test.sh` for shared root-path discovery, assertions, and Node test helpers. -```bash -omarchy-refresh-config hypr/hyprlock.conf -``` +The graphical acceptance suite runs in a disposable VM, not in the active +development session; see [`agents/skills/acceptance-tests.md`](agents/skills/acceptance-tests.md). -This copies `~/.local/share/omarchy/config/hypr/hyprlock.conf` to `~/.config/hypr/hyprlock.conf`. +Visual changes must be verified in the running UI in addition to automated +tests; follow [`agents/skills/visual-verification.md`](agents/skills/visual-verification.md). -# Migrations - -To create a new migration, run `omarchy-dev-add-migration --no-edit`. This creates a migration file named after the unix timestamp of the last commit. - -New migration format: -- File permissions must be `0644` (`-rw-r--r--`); migrations are sourced, not executed directly -- No shebang line -- Start with an `echo` describing what the migration does -- Use `$OMARCHY_PATH` to reference the omarchy directory -- Prefer helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`, `omarchy-pkg-present`, and `omarchy-pkg-missing` - -Some older migrations predate these rules. Do not copy older migrations that start with shebangs, omit the leading `echo`, or hard-code `~/.local/share/omarchy`. +# Refresh Pattern -Migrations may use raw `pacman`, `command -v`, or direct config edits when needed for historical compatibility or one-off repair work. +To copy a default config to user config with automatic backup: -Example: ```bash -echo "Disable fingerprint in hyprlock if fingerprint auth is not configured" - -if omarchy-cmd-missing fprintd-list || ! fprintd-list "$USER" 2>/dev/null | grep -q "finger"; then - sed -i 's/fingerprint:enabled = .*/fingerprint:enabled = false/' ~/.config/hypr/hyprlock.conf -fi +omarchy-refresh-config hypr/hyprland.lua ``` + +This copies `$OMARCHY_PATH/config/hypr/hyprland.lua` to `~/.config/hypr/hyprland.lua`. The argument +is interpolated into both paths and only checked with `[[ -e ]]`, so pass a plain relative path: a +name containing `..` resolves and copies, landing outside `~/.config` rather than being rejected. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index a630d13cea..c7e541a2d5 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,77 @@ Omarchy is a beautiful, modern & opinionated Linux distribution by DHH. Read more at [omarchy.org](https://omarchy.org). +## The Omarchy Manual + +The manual lives in [`manual/`](manual/), which is its authoritative source. It's +mirrored to [learn.omacom.io](https://learn.omacom.io/2/the-omarchy-manual), where +its screenshots are also hosted. + +- [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md) + +**The Basics** + +- [Getting Started](manual/02-getting-started.md) +- [Coming From Mac or Windows](manual/03-coming-from-mac-or-windows.md) +- [Navigation](manual/04-navigation.md) +- [The top bar](manual/05-the-top-bar.md) +- [Themes](manual/06-themes.md) +- [Hotkeys](manual/07-hotkeys.md) +- [Unified Clipboard & History](manual/08-unified-clipboard-history.md) +- [Reminders](manual/09-reminders.md) +- [Notices](manual/10-notices.md) +- [Text Extraction & Dictation](manual/11-text-extraction-dictation.md) +- [Screenshots & Recording](manual/12-screenshots-recording.md) +- [Toggles, idle & screensaver](manual/13-toggles-idle-screensaver.md) +- [Omarchy CLI](manual/14-omarchy-cli.md) + +**The Applications** + +- [Terminal](manual/15-terminal.md) +- [Neovim](manual/16-neovim.md) +- [AI](manual/17-ai.md) +- [Development Tools](manual/18-development-tools.md) +- [Shell Tools](manual/19-shell-tools.md) +- [Shell Functions](manual/20-shell-functions.md) +- [TUIs](manual/21-tuis.md) +- [GUIs](manual/22-guis.md) +- [Browsers](manual/23-browsers.md) +- [Commercial apps/services](manual/24-commercial-apps-services.md) +- [Web Apps](manual/25-web-apps.md) +- [Gaming](manual/26-gaming.md) +- [Filling out PDFs](manual/27-filling-out-pdfs.md) +- [Windows VM](manual/28-windows-vm.md) +- [Other Packages](manual/29-other-packages.md) + +**Configuration** + +- [Updates](manual/30-updates.md) +- [Dotfiles](manual/31-dotfiles.md) +- [Shell plugins](manual/32-shell-plugins.md) +- [Monitors](manual/33-monitors.md) +- [Keyboard, Mouse, Trackpad](manual/34-keyboard-mouse-trackpad.md) +- [Networking](manual/35-networking.md) +- [System sleep](manual/36-system-sleep.md) +- [Hardware authentication](manual/37-hardware-authentication.md) +- [Fonts](manual/38-fonts.md) +- [Backgrounds](manual/39-backgrounds.md) +- [Prompt](manual/40-prompt.md) +- [Branding](manual/41-branding.md) +- [Common tweaks](manual/42-common-tweaks.md) +- [Extra themes](manual/43-extra-themes.md) +- [Making your own theme](manual/44-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) + ## License Omarchy is released under the [MIT License](https://opensource.org/licenses/MIT). diff --git a/agents/skills/acceptance-tests.md b/agents/skills/acceptance-tests.md new file mode 100644 index 0000000000..b2fc499798 --- /dev/null +++ b/agents/skills/acceptance-tests.md @@ -0,0 +1,46 @@ +# Acceptance Tests + +Read this before writing or running the graphical acceptance suite under +`test/acceptance.d/`. + +The graphical acceptance suite lives in `test/acceptance` with test files under +`test/acceptance.d/*-test.sh`. It exercises a real installed Omarchy desktop, +including session health, shell surfaces, panels, keyboard navigation, +representative applications, and system setup. Source +`test/acceptance.d/base-test.sh` for the shared helpers. + +Run acceptance tests in a disposable VM through the sibling `omarchy-iso` +repository, not in the active development session. The suite opens and closes +applications and temporarily changes desktop configuration. + +For acceptance-test-only changes, reuse an installed base and sync the suite: + +```bash +cd ../omarchy-iso +./bin/omarchy-iso-test release/.iso --reuse-base --sync-omarchy ../omarchy --no-preview +``` + +Use `--sync-all ../omarchy` instead of `--sync-omarchy ../omarchy` when the +acceptance run must exercise local `bin/`, `config/`, or `shell/` source too. +Changes to package manifests, installation, finalization, or shipped defaults +require a fresh ISO built from the local checkouts and a run without +`--reuse-base`: + +```bash +cd ../omarchy-iso +./bin/omarchy-iso-make --no-boot-offer --local-source ../omarchy ../omarchy-pkgs +./bin/omarchy-iso-test release/.iso --no-preview +``` + +Keep unrelated acceptance workflows in separate test files. The runner records +a failed file and continues with the remaining files, which preserves as much +diagnostic coverage as possible. Restore modified user state with traps, close +anything the test opens, and capture every visually distinct state (including +entered input where relevant) as `success-.png`; failure helpers capture +`failure-.png`. The ISO harness collects the screenshots and logs under +its timestamped `test-runs/` directory and opens the screenshots after the run +unless `--no-preview` is passed. + +The ISO harness exercises compositor-level shortcuts with QMP virtual keyboard +input. In-guest `wtype` is suitable for typing into focused controls, but it +does not reliably prove that a global Hyprland keybinding works. diff --git a/agents/skills/command-metadata.md b/agents/skills/command-metadata.md new file mode 100644 index 0000000000..9b90cf5130 --- /dev/null +++ b/agents/skills/command-metadata.md @@ -0,0 +1,31 @@ +# Command Metadata + +Read this before adding or changing commands in `bin/`. + +Commands in `bin/` can declare CLI metadata in comments near the top of the +file. `bin/omarchy` scans the first 80 lines, and tests expect command metadata +to remain valid. + +Supported metadata keys: + +- `# omarchy:group=...` - override the command group inferred from the filename +- `# omarchy:name=...` - override the command name inferred from the filename +- `# omarchy:summary=...` - short help text +- `# omarchy:args=...` - usage arguments +- `# omarchy:examples=...` - examples separated with ` | ` +- `# omarchy:alias=...` / `# omarchy:aliases=...` - alternate routes +- `# omarchy:hidden=true` - hide from default command listings +- `# omarchy:requires-sudo=true` - mark commands that require sudo + +Only use `omarchy:examples` where there are args that need explaining. + +Prefer explicit metadata for user-facing commands. Keep routes consistent with +the filename unless there is a deliberate alias or compatibility route. + +Example: + +```bash +# omarchy:summary=Take a screenshot +# omarchy:args=[smart|region|windows|fullscreen] [slurp|copy] +# omarchy:examples=omarchy screenshot | omarchy capture screenshot region +``` diff --git a/agents/skills/icon-font.md b/agents/skills/icon-font.md new file mode 100644 index 0000000000..333f8752a6 --- /dev/null +++ b/agents/skills/icon-font.md @@ -0,0 +1,79 @@ +# Omarchy Icon Font + +Read this before adding a branded glyph to `default/fonts/omarchy/omarchy.ttf`. + +The Omarchy icon font is a small private-use font carrying the marks Nerd +Fonts does not have: the Omarchy logo and the agent and app brand marks. The +menu draws one by naming the font on an entry: + +```jsonc +"setup.default.agent.grok": {"icon":"","iconFont":"omarchy","label":"Grok", ...} +``` + +Without `iconFont`, an entry's `icon` is drawn in the menu font, so reach for +this font only when a Nerd Font glyph would misrepresent the thing. A generic +robot for four different AI apps is the case that justifies a real mark; a +folder or a microphone is not. + +`default/fonts/omarchy/README.md` lists every glyph with the URL its artwork +came from. Keep that list accurate — it is the only record of provenance. + +## Adding a glyph + +`omarchy dev font` does the work: + +```bash +omarchy dev font list +omarchy dev font add ollama https://simpleicons.org/icons/ollama.svg +``` + +`add` fetches the SVG, scales it into the same 64..960 box the existing marks +use so it lands at their optical size, appends it at the next free private-use +codepoint, and adds a line to the font README. It prints the codepoint and the +glyph itself. + +The source must be a **monochrome SVG with a single ``**, because the +menu recolors the glyph with the active theme's foreground and selection +colors. Brand icon sets such as publish exactly that +shape. App favicons usually do not: they are multi-color, carry a container +tile, or split across several paths. Prefer the official mark when it is +published as flat monochrome art, and fall back to an icon set's redraw when it +is not. + +Two-tone marks are a trap. Every path becomes solid foreground, so a logo whose +meaning depends on lighter and darker halves turns into an unreadable blob. +Pick a source whose silhouette alone reads. + +## After adding + +The command prints these, and all of them matter: + +- Point the menu entry at the new codepoint with `"iconFont":"omarchy"`. +- Bump the charset range asserted in `test/shell.d/menu-test.sh`; that test + pins the font's coverage and fails until it matches. +- Check the README line the command added, and give it a proper display name + with `--label` if the glyph name is not the brand's name. + +The font is package-owned: `omarchy-settings` installs it to +`/usr/share/fonts/omarchy/omarchy.ttf`, so a new glyph reaches the desktop +through a settings release, not through `omarchy update`. Between the merge and +that release, a pulled checkout renders the new entry with no icon. + +## Verifying + +Render the whole font and look at it, which catches inverted contours and +filled counters that a glyph list cannot show: + +```bash +python3 -c "open('/tmp/row.txt','w').write(' '.join(chr(c) for c in range(0xE900, 0xE910)))" +magick -background white -fill black -font default/fonts/omarchy/omarchy.ttf \ + -pointsize 110 label:@/tmp/row.txt /tmp/font-row.png +``` + +Then confirm it in the running menu per +[`visual-verification.md`](visual-verification.md). Fontconfig prefers the +packaged font over a copy in `~/.local/share/fonts` for the same family, so a +preview needs either the real file replaced or a `` 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. diff --git a/agents/skills/install-scripts.md b/agents/skills/install-scripts.md new file mode 100644 index 0000000000..999a6e9ca0 --- /dev/null +++ b/agents/skills/install-scripts.md @@ -0,0 +1,19 @@ +# Install Scripts + +Read this before working under `install/` or on the system/user setup commands. + +The ISO owns installation orchestration. This repo ships target-side setup +commands and reusable setup leaves: + +- `bin/omarchy-apply-system` runs root-owned system setup during ISO finalization. +- `bin/omarchy-apply-hardware` runs idempotent hardware-specific setup and is called by `omarchy-apply-system`. +- `bin/omarchy-finalize-user` runs the per-user runtime finalization (skill symlinks, xdg-user-dirs, mime defaults, `install/user/all.sh`). Shipped user defaults are seeded by `/etc/skel` from `omarchy-settings`, not by this command. `bin/omarchy-reinstall-configs` is the explicit destructive resync of those defaults into an existing user's `$HOME`. +- leaf scripts under `install/` are sourced by `run_logged $OMARCHY_INSTALL/path/to/script.sh` and intentionally do not have shebangs. +- avoid `exit` in sourced setup scripts unless intentionally aborting setup. +- 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. +- prefer helper commands for package and command checks where available. + +Raw `command -v`, `pacman`, and `pacman-key` are acceptable in package-helper +contexts where direct package-manager behavior is the point of the script. diff --git a/agents/skills/migrations.md b/agents/skills/migrations.md new file mode 100644 index 0000000000..bb6cf2b83a --- /dev/null +++ b/agents/skills/migrations.md @@ -0,0 +1,167 @@ +# Omarchy migrations + +Read this before creating or changing migrations under `migrations/`. + +Omarchy migrations are one-time repair scripts for existing installs. They are +used when a package update needs to change state that pacman cannot safely own by +itself. + +## Migration model + +Migrations live in: + +```text +migrations/*.sh +``` + +They run as the current Omarchy user through `omarchy-migrate`, normally during +`omarchy update`. A migration may touch user/session state (`~/.config`, +`~/.local`, user systemd, browser/editor prefs, DBus/session state), and may also +perform machine-wide repairs when needed. + +Completion state is per-user: + +```text +~/.local/state/omarchy/migrations/ +``` + +That means every user gets a chance to run every migration. Migrations run as the +user; privileged operations should invoke the appropriate helper or privilege +prompt themselves. Migrations must be idempotent: if one user already applied a +machine-wide repair, the same migration running for another user should detect +that and no-op. + +## When migrations run + +### During `omarchy update` + +`omarchy update` is the normal update path. It runs package updates, then: + +```bash +omarchy-migrate +omarchy-hook post-update +``` + +`omarchy-migrate` waits for any active pacman transaction to finish, then runs +all pending migrations for the current user in the visible update terminal. + +### At login + +Every graphical login starts `omarchy-migrate-notify.service` after +`graphical-session.target`. The notifier checks: + +```bash +omarchy-migrate --pending +``` + +It stays silent while `omarchy update` holds its lock, since that update applies +the pending migrations itself. + +If that user has pending migrations, it shows a notification that opens a +terminal for: + +```bash +omarchy-migrate +``` + +The notifier never runs migrations silently in the background. + +This is what covers users who did not run the update themselves: someone who +bypassed the pacman guard with `sudo env OMARCHY_ALLOW_DIRECT_PACMAN=1 pacman +-Syu`, and any second user on the machine, whose migration markers are per-user +and therefore still missing after another user updated. + +Login is the only trigger on purpose. Watching the packaged migration directory +also fires during a normal `omarchy update`, which prompts for migrations that +`omarchy-migrate` is about to run in the visible update terminal. + +### Manually + +Users can safely run: + +```bash +omarchy-migrate +``` + +at any time. Already-completed migrations are skipped. + +## Inspecting pending migrations + +Use: + +```bash +omarchy-migrate --pending +``` + +Exit behavior: + +- `0` — one or more migrations are pending +- non-zero — no migrations are pending + +Output is one pending migration per line: + +```text +1781158082.sh +``` + +## Creating a migration + +Use the helper: + +```bash +omarchy-dev-add-migration --no-edit +``` + +This creates: + +```text +migrations/.sh +``` + +New migration format: + +- File permissions must be `0644` (`-rw-r--r--`). Migration runners execute them + with `bash -euo pipefail`, not through executable bits. +- No shebang line. +- 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. +- 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. +- Never restart the Omarchy shell. `omarchy update` restarts it unconditionally + after migrations run, and the login-time shell already runs current code and + hot-reloads `shell.json` edits. +- Raw `pacman`, `command -v`, and direct config edits are acceptable when + needed for one-off repair work. + +Example: + +```bash +echo "Relink Neovim theme to Omarchy current state" + +theme_link="$HOME/.config/nvim/lua/plugins/theme.lua" +current_relative_target="../../../../.local/state/omarchy/current/theme/neovim.lua" + +[[ -L $theme_link ]] || exit 0 +ln -sfn "$current_relative_target" "$theme_link" +``` + +## Testing migrations + +Run a migration against a temporary home when possible: + +```bash +HOME=$(mktemp -d) bash -euo pipefail migrations/.sh +``` + +To rerun a migration locally, remove its marker and run the migrator: + +```bash +rm ~/.local/state/omarchy/migrations/.sh +omarchy-migrate +``` + +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. diff --git a/agents/skills/shell-dev.md b/agents/skills/shell-dev.md new file mode 100644 index 0000000000..d33f21ef6f --- /dev/null +++ b/agents/skills/shell-dev.md @@ -0,0 +1,49 @@ +# Omarchy Shell Development + +Read this before editing the Quickshell desktop under `shell/`. + +The Quickshell desktop runs as a single long-running process out of +`shell/`. Hyprland autostart launches it directly with `quickshell -n -p`; +do not start additional standalone Quickshell instances for individual +components. + +Run `omarchy-restart-shell` after making changes to QML files. + +## Plugin contract + +- First-party plugins live directly under `shell/plugins/` or one category + level deeper, such as `shell/plugins/panels/weather/`. First-party bar-only + widgets may use adjacent `*.manifest.json` files. Third-party plugins live + at `~/.config/omarchy/plugins//` with a `manifest.json` at the root. +- Every plugin manifest declares `schemaVersion`, `id`, `name`, `version`, + `kinds`, and `entryPoints`. See + [`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. +- Panel / overlay / menu plugins must expose `open(payloadJson)` and + `close()` lifecycle methods for `shell summon` and `shell hide`. + +## IPC + +- `bin/omarchy-shell` is the canonical IPC entry point. It forwards to + the running shell and does not start it. Prefer it over re-implementing + direct Quickshell socket calls in every CLI. +- The `shell` IPC target exposes lifecycle and configuration methods including + `ping`, `summon`, `hide`, `toggle`, `call`, `rescanPlugins`, `reloadConfig`, + `setPluginEnabled`, and `listPlugins`. `shell.qml` also registers + `image-selector`, which drives the `omarchy.image-picker` panel. +- Individual plugins register their own IPC targets, named for the plugin rather + than for where they appear: the background switcher registers `background`, and + bar widgets register one target each — `omarchy.indicators`, + `omarchy.system-update`, `omarchy.clock`. There is no `bar` target. + +## Editing widget files with glyphs + +Widget files in `shell/plugins/bar/widgets/` contain Nerd Font glyphs as raw +unicode characters. Agent file-editing tools can strip multi-byte codepoints +in some positions — do **not** rewrite widget files wholesale through those +tools. For glyph fixes, make a targeted edit with the surrounding context, or +use a Python script that inserts codepoints via `chr(0xXXXXX)`. diff --git a/agents/skills/visual-verification.md b/agents/skills/visual-verification.md new file mode 100644 index 0000000000..56dc8e7841 --- /dev/null +++ b/agents/skills/visual-verification.md @@ -0,0 +1,44 @@ +# Visual Verification + +Read this before finishing any change with a visual effect: Omarchy shell +styling and layout, panels, menus, notifications, desktop appearance, +animations, transitions, screenshots, and screen recording flows. + +Visual changes must be verified in the running UI in addition to automated +tests. Creating an artifact is not sufficient: inspect it for clipping, +overlap, incorrect spacing, stale state, focus problems, and visual +regressions before finishing. + +Take a full-screen screenshot without opening the editor: + +```bash +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. + +Record a short full-screen video for animation, transition, timing, capture, or +screen-recording changes: + +```bash +omarchy screenrecord --fullscreen +# Exercise the changed behavior. +omarchy screenrecord --stop-recording +``` + +The stop command prints the saved video path in the configured Videos +directory. Review the recording before finishing, and keep it short and focused +on the changed behavior. + +For interactive UI work, use `wtype` to simulate keyboard input when available. +Example: start the UI in the background, wait briefly for focus, then run +`wtype -k Right -k Return` to exercise keyboard selection and confirm the +resulting command output or state change. Prefer this over manual-only +verification when a UI returns a selected value or changes a symlink/config. + +If a launched UI would otherwise remain open, keep track of its PID and stop it +after the screenshot or recording; avoid broad process kills unless checking +with `ps` first. diff --git a/applications/Basecamp.desktop b/applications/Basecamp.desktop new file mode 100644 index 0000000000..7c518ba435 --- /dev/null +++ b/applications/Basecamp.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Version=1.0 +Name=Basecamp +Exec=omarchy-launch-webapp https://launchpad.37signals.com +Terminal=false +Type=Application +Icon=basecamp +StartupNotify=true diff --git a/applications/Discord.desktop b/applications/Discord.desktop new file mode 100644 index 0000000000..461867d666 --- /dev/null +++ b/applications/Discord.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Version=1.0 +Name=Discord +Exec=omarchy-launch-webapp https://discord.com/channels/@me +Terminal=false +Type=Application +Icon=omarchy-discord +StartupNotify=true diff --git a/applications/Disk Usage.desktop b/applications/Disk Usage.desktop new file mode 100644 index 0000000000..1de4054600 --- /dev/null +++ b/applications/Disk Usage.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Version=1.0 +Name=Disk Usage +Exec=xdg-terminal-exec --app-id=TUI.float -e bash -c "dua i /" +Terminal=false +Type=Application +Icon=disk-usage +StartupNotify=true diff --git a/applications/Docker.desktop b/applications/Docker.desktop new file mode 100644 index 0000000000..4f9831c045 --- /dev/null +++ b/applications/Docker.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Version=1.0 +Name=Docker +Exec=xdg-terminal-exec --app-id=TUI.tile -e lazydocker +Terminal=false +Type=Application +Icon=docker +StartupNotify=true diff --git a/applications/Google Contacts.desktop b/applications/Google Contacts.desktop new file mode 100644 index 0000000000..8f92c8828e --- /dev/null +++ b/applications/Google Contacts.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Version=1.0 +Name=Google Contacts +Exec=omarchy-launch-webapp https://contacts.google.com/ +Terminal=false +Type=Application +Icon=google-contacts +StartupNotify=true diff --git a/applications/Google Maps.desktop b/applications/Google Maps.desktop new file mode 100644 index 0000000000..3cf546b6c4 --- /dev/null +++ b/applications/Google Maps.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Version=1.0 +Name=Google Maps +Exec=omarchy-launch-webapp https://maps.google.com +Terminal=false +Type=Application +Icon=google-maps +StartupNotify=true diff --git a/applications/Google Messages.desktop b/applications/Google Messages.desktop new file mode 100644 index 0000000000..1847455525 --- /dev/null +++ b/applications/Google Messages.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Version=1.0 +Name=Google Messages +Exec=omarchy-launch-webapp https://messages.google.com/web/conversations +Terminal=false +Type=Application +Icon=google-messages +StartupNotify=true diff --git a/applications/Google Photos.desktop b/applications/Google Photos.desktop new file mode 100644 index 0000000000..44ccd2546f --- /dev/null +++ b/applications/Google Photos.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Version=1.0 +Name=Google Photos +Exec=omarchy-launch-webapp https://photos.google.com/ +Terminal=false +Type=Application +Icon=google-photos +StartupNotify=true diff --git a/applications/HEY.desktop b/applications/HEY.desktop new file mode 100644 index 0000000000..e59bd0ea2a --- /dev/null +++ b/applications/HEY.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Version=1.0 +Name=HEY +Exec=omarchy-webapp-handler-hey %u +Terminal=false +Type=Application +Icon=hey +StartupNotify=true +MimeType=x-scheme-handler/mailto diff --git a/applications/WhatsApp.desktop b/applications/WhatsApp.desktop new file mode 100644 index 0000000000..f193616def --- /dev/null +++ b/applications/WhatsApp.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Version=1.0 +Name=WhatsApp +Exec=omarchy-launch-webapp https://web.whatsapp.com/ +Terminal=false +Type=Application +Icon=whatsapp +StartupNotify=true diff --git a/applications/X.desktop b/applications/X.desktop new file mode 100644 index 0000000000..02fe0424ca --- /dev/null +++ b/applications/X.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Version=1.0 +Name=X +Exec=omarchy-launch-webapp https://x.com/ +Terminal=false +Type=Application +Icon=x +StartupNotify=true diff --git a/applications/YouTube.desktop b/applications/YouTube.desktop new file mode 100644 index 0000000000..d6659d75ac --- /dev/null +++ b/applications/YouTube.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Version=1.0 +Name=YouTube +Exec=omarchy-launch-webapp https://youtube.com/ +Terminal=false +Type=Application +Icon=youtube +StartupNotify=true diff --git a/applications/Zoom.desktop b/applications/Zoom.desktop new file mode 100644 index 0000000000..227abaa8bb --- /dev/null +++ b/applications/Zoom.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Version=1.0 +Name=Zoom +Exec=omarchy-webapp-handler-zoom %u +Terminal=false +Type=Application +Icon=zoom +StartupNotify=true +MimeType=x-scheme-handler/zoommtg;x-scheme-handler/zoomus diff --git a/default/foot/foot.desktop b/applications/foot.desktop similarity index 100% rename from default/foot/foot.desktop rename to applications/foot.desktop diff --git a/applications/hidden/btop.desktop b/applications/hidden/btop.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/btop.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/bvnc.desktop b/applications/hidden/bvnc.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/bvnc.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/cmake-gui.desktop b/applications/hidden/cmake-gui.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/cmake-gui.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/cups.desktop b/applications/hidden/cups.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/cups.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/dropbox.desktop b/applications/hidden/dropbox.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/dropbox.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/electron34.desktop b/applications/hidden/electron34.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/electron34.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/electron36.desktop b/applications/hidden/electron36.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/electron36.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/electron37.desktop b/applications/hidden/electron37.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/electron37.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/fcitx5-configtool.desktop b/applications/hidden/fcitx5-configtool.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/fcitx5-configtool.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/fcitx5-wayland-launcher.desktop b/applications/hidden/fcitx5-wayland-launcher.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/fcitx5-wayland-launcher.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/foot-server.desktop b/applications/hidden/foot-server.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/foot-server.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/footclient.desktop b/applications/hidden/footclient.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/footclient.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/java-java-openjdk.desktop b/applications/hidden/java-java-openjdk.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/java-java-openjdk.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/jconsole-java-openjdk.desktop b/applications/hidden/jconsole-java-openjdk.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/jconsole-java-openjdk.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/jshell-java-openjdk.desktop b/applications/hidden/jshell-java-openjdk.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/jshell-java-openjdk.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/kbd-layout-viewer5.desktop b/applications/hidden/kbd-layout-viewer5.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/kbd-layout-viewer5.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/kcm_fcitx5.desktop b/applications/hidden/kcm_fcitx5.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/kcm_fcitx5.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/kcm_kaccounts.desktop b/applications/hidden/kcm_kaccounts.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/kcm_kaccounts.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/kvantummanager.desktop b/applications/hidden/kvantummanager.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/kvantummanager.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/limine-snapper-restore.desktop b/applications/hidden/limine-snapper-restore.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/limine-snapper-restore.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/lstopo.desktop b/applications/hidden/lstopo.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/lstopo.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/org.fcitx.Fcitx5.desktop b/applications/hidden/org.fcitx.Fcitx5.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/org.fcitx.Fcitx5.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/org.fcitx.fcitx5-config-qt.desktop b/applications/hidden/org.fcitx.fcitx5-config-qt.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/org.fcitx.fcitx5-config-qt.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/org.fcitx.fcitx5-migrator.desktop b/applications/hidden/org.fcitx.fcitx5-migrator.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/org.fcitx.fcitx5-migrator.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/org.fcitx.fcitx5-qt5-gui-wrapper.desktop b/applications/hidden/org.fcitx.fcitx5-qt5-gui-wrapper.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/org.fcitx.fcitx5-qt5-gui-wrapper.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/org.fcitx.fcitx5-qt6-gui-wrapper.desktop b/applications/hidden/org.fcitx.fcitx5-qt6-gui-wrapper.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/org.fcitx.fcitx5-qt6-gui-wrapper.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/qv4l2.desktop b/applications/hidden/qv4l2.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/qv4l2.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/qvidcap.desktop b/applications/hidden/qvidcap.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/qvidcap.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/uuctl.desktop b/applications/hidden/uuctl.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/uuctl.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/wiremix.desktop b/applications/hidden/wiremix.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/wiremix.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/xgps.desktop b/applications/hidden/xgps.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/xgps.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/hidden/xgpsspeed.desktop b/applications/hidden/xgpsspeed.desktop deleted file mode 100644 index e1e3e17323..0000000000 --- a/applications/hidden/xgpsspeed.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/applications/icons/Basecamp.png b/applications/icons/Basecamp.png index 3edec48c04..732c7ab0ad 100644 Binary files a/applications/icons/Basecamp.png and b/applications/icons/Basecamp.png differ diff --git a/applications/icons/Battle.net.png b/applications/icons/Battle.net.png new file mode 100644 index 0000000000..a18e41c7d8 Binary files /dev/null and b/applications/icons/Battle.net.png differ diff --git a/applications/icons/Figma.png b/applications/icons/Figma.png deleted file mode 100644 index 62ffa9037c..0000000000 Binary files a/applications/icons/Figma.png and /dev/null differ diff --git a/applications/icons/Fizzy.png b/applications/icons/Fizzy.png deleted file mode 100644 index c68957e433..0000000000 Binary files a/applications/icons/Fizzy.png and /dev/null differ diff --git a/applications/icons/GitHub.png b/applications/icons/GitHub.png deleted file mode 100644 index 5a4295c245..0000000000 Binary files a/applications/icons/GitHub.png and /dev/null differ diff --git a/applications/icons/Retro Gaming.png b/applications/icons/Retro Gaming.png new file mode 100644 index 0000000000..313172bc21 Binary files /dev/null and b/applications/icons/Retro Gaming.png differ diff --git a/applications/icons/Discord.png b/applications/icons/omarchy-discord.png similarity index 100% rename from applications/icons/Discord.png rename to applications/icons/omarchy-discord.png diff --git a/applications/typora.desktop b/applications/typora.desktop deleted file mode 100644 index c1af035678..0000000000 --- a/applications/typora.desktop +++ /dev/null @@ -1,10 +0,0 @@ -[Desktop Entry] -Name=Typora -GenericName=Markdown Editor -Exec=typora --enable-wayland-ime %U -Icon=typora -Type=Application -StartupNotify=true -Categories=Office;WordProcessor; -MimeType=text/markdown;text/x-markdown; - diff --git a/bin/omarchy b/bin/omarchy index c23a645717..994172efb9 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -26,32 +26,48 @@ declare -A ROUTE_IS_ALIAS declare -A BINARY_TO_KEY declare -A GROUP_DESCRIPTIONS -GROUP_DESCRIPTIONS[ac]="AC power detection" +GROUP_DESCRIPTIONS[agent]="AI coding agent usage data" +GROUP_DESCRIPTIONS[audio]="Audio input and output controls" +GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings" GROUP_DESCRIPTIONS[battery]="Battery status helpers" +GROUP_DESCRIPTIONS[bluetooth]="Bluetooth device controls" GROUP_DESCRIPTIONS[branch]="Omarchy git branch management" GROUP_DESCRIPTIONS[branding]="About and screensaver branding" GROUP_DESCRIPTIONS[brightness]="Display and keyboard brightness" GROUP_DESCRIPTIONS[capture]="Screenshots and screen recording" 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[debug]="Diagnostics and support logs" +GROUP_DESCRIPTIONS[finalize]="Finalize user setup" GROUP_DESCRIPTIONS[default]="Default application selection" GROUP_DESCRIPTIONS[dev]="Omarchy development tools" +GROUP_DESCRIPTIONS[disk]="Disk performance helpers" +GROUP_DESCRIPTIONS[display]="Display and text scaling" +GROUP_DESCRIPTIONS[dns]="DNS resolver configuration" GROUP_DESCRIPTIONS[drive]="Drive selection and encryption" +GROUP_DESCRIPTIONS[file]="File selection helpers" GROUP_DESCRIPTIONS[font]="Font management" +GROUP_DESCRIPTIONS[games]="Game launchers and helpers" GROUP_DESCRIPTIONS[hibernation]="Hibernation setup and removal" GROUP_DESCRIPTIONS[hook]="User hook runner" GROUP_DESCRIPTIONS[hw]="Hardware detection and controls" GROUP_DESCRIPTIONS[hyprland]="Hyprland window, monitor, and toggle controls" GROUP_DESCRIPTIONS[install]="Optional software installers" +GROUP_DESCRIPTIONS[installed]="Installed optional service checks" GROUP_DESCRIPTIONS[launch]="Application launchers" GROUP_DESCRIPTIONS[menu]="Omarchy menu commands" GROUP_DESCRIPTIONS[migrate]="Migration runner" +GROUP_DESCRIPTIONS[monitor]="Monitor status helpers" +GROUP_DESCRIPTIONS[network]="Network status helpers" GROUP_DESCRIPTIONS[notification]="Notification helpers" -GROUP_DESCRIPTIONS[npx]="NPX package wrappers" +GROUP_DESCRIPTIONS[mise]="Mise tool wrappers" +GROUP_DESCRIPTIONS[osd]="On-screen display status helpers" GROUP_DESCRIPTIONS[pkg]="Package management helpers" +GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management" GROUP_DESCRIPTIONS[plymouth]="Plymouth boot theme management" +GROUP_DESCRIPTIONS[power]="Power supply detection" GROUP_DESCRIPTIONS[powerprofiles]="Power profile management" GROUP_DESCRIPTIONS[refresh]="Reset config to defaults" GROUP_DESCRIPTIONS[reinstall]="Reinstall and reset workflows" @@ -59,16 +75,16 @@ GROUP_DESCRIPTIONS[reminder]="Desktop notification reminders" GROUP_DESCRIPTIONS[remove]="Removal workflows" GROUP_DESCRIPTIONS[restart]="Restart Omarchy components" GROUP_DESCRIPTIONS[setup]="Interactive setup wizards" +GROUP_DESCRIPTIONS[shell]="Omarchy shell IPC helpers" GROUP_DESCRIPTIONS[screensaver]="Screensaver branding and animation" GROUP_DESCRIPTIONS[snapshot]="System snapshots" GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers" -GROUP_DESCRIPTIONS[swayosd]="SwayOSD status display helpers" -GROUP_DESCRIPTIONS[system]="Reboot, shutdown, logout, and lock" +GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock" +GROUP_DESCRIPTIONS[tailscale]="Tailscale helpers" GROUP_DESCRIPTIONS[theme]="Theme management" GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features" GROUP_DESCRIPTIONS[transcode]="Image and video transcoding" GROUP_DESCRIPTIONS[tui]="Terminal UI launchers" -GROUP_DESCRIPTIONS[tz]="Timezone selection" GROUP_DESCRIPTIONS[update]="Omarchy and system updates" GROUP_DESCRIPTIONS[version]="Version and channel information" GROUP_DESCRIPTIONS[voxtype]="Voxtype dictation" @@ -104,6 +120,31 @@ append_pipe_value() { fi } +# A route can resolve to a binary before all args are consumed (`update aur` only matches the `update` binary, leaving `aur` and `--help` as leftovers) +# --help must never be lost among those leftovers and get forwarded into the real command, so this checks the whole remainder rather than just the first token. +# A `--` marks everything after it as belonging to the command itself, so scanning stops there. +remaining_has_help_flag() { + local token="" + + for token in "$@"; do + [[ $token == "--" ]] && break + [[ $token == "--help" || $token == "-h" ]] && return 0 + done + + return 1 +} + +remaining_has_json_flag() { + local token="" + + for token in "$@"; do + [[ $token == "--" ]] && break + [[ $token == "--json" ]] && return 0 + done + + return 1 +} + register_route() { local route="$1" local key="$2" @@ -129,7 +170,6 @@ register_command() { local name="" local summary="" local usage="" - local binary="" local args="" local examples="" local aliases="" @@ -219,7 +259,6 @@ register_command() { fallback_name="${fallback_name//-/ }" fi - [[ -z $binary ]] && binary="$file_binary" [[ -z $group ]] && group="$fallback_group" [[ $name_seen != "true" ]] && name="$fallback_name" [[ -z $summary && -n $fallback_summary ]] && summary="$fallback_summary" @@ -229,11 +268,9 @@ register_command() { route+=" $name" fi - if [[ -z $usage ]]; then - usage="$route" - if [[ -n $args ]]; then - usage+=" $args" - fi + usage="$route" + if [[ -n $args ]]; then + usage+=" $args" fi [[ $requires_sudo == "true" ]] || requires_sudo="false" @@ -243,7 +280,7 @@ register_command() { COMMAND_KEYS+=("$key") COMMAND_ROUTE["$key"]="$route" COMMAND_FALLBACK_ROUTE["$key"]="$fallback_route" - COMMAND_BINARY["$key"]="$binary" + COMMAND_BINARY["$key"]="$file_binary" COMMAND_GROUP["$key"]="$group" COMMAND_NAME["$key"]="$name" COMMAND_SUMMARY["$key"]="$summary" @@ -255,7 +292,7 @@ register_command() { COMMAND_HAS_SUMMARY["$key"]="$has_summary" COMMAND_METADATA_ERRORS["$key"]="$metadata_errors" - BINARY_TO_KEY["$binary"]="$key" + BINARY_TO_KEY["$file_binary"]="$key" register_route "$route" "$key" register_route "$fallback_route" "$key" @@ -601,25 +638,31 @@ show_commands_markdown() { done < <(sorted_keys "$include_all") } +emit_command_record() { + local key="$1" + + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "${COMMAND_ROUTE[$key]}" \ + "${COMMAND_BINARY[$key]}" \ + "${COMMAND_GROUP[$key]}" \ + "${COMMAND_NAME[$key]}" \ + "${COMMAND_SUMMARY[$key]}" \ + "${COMMAND_REQUIRES_SUDO[$key]}" \ + "${COMMAND_HIDDEN[$key]}" \ + "${COMMAND_ARGS[$key]}" \ + "${COMMAND_EXAMPLES[$key]}" \ + "${COMMAND_ALIASES[$key]}" \ + "${COMMAND_FALLBACK_ROUTE[$key]}" \ + "${COMMAND_USAGE[$key]}" +} + emit_command_records() { local include_all="$1" local key="" while IFS= read -r key; do [[ -n $key ]] || continue - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "${COMMAND_ROUTE[$key]}" \ - "${COMMAND_BINARY[$key]}" \ - "${COMMAND_GROUP[$key]}" \ - "${COMMAND_NAME[$key]}" \ - "${COMMAND_SUMMARY[$key]}" \ - "${COMMAND_REQUIRES_SUDO[$key]}" \ - "${COMMAND_HIDDEN[$key]}" \ - "${COMMAND_ARGS[$key]}" \ - "${COMMAND_EXAMPLES[$key]}" \ - "${COMMAND_ALIASES[$key]}" \ - "${COMMAND_FALLBACK_ROUTE[$key]}" \ - "${COMMAND_USAGE[$key]}" + emit_command_record "$key" done < <(sorted_keys "$include_all") } @@ -688,23 +731,7 @@ show_commands_check() { } show_command_json() { - local key="$1" - - printf '%s\n' "$key" | while IFS= read -r key; do - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "${COMMAND_ROUTE[$key]}" \ - "${COMMAND_BINARY[$key]}" \ - "${COMMAND_GROUP[$key]}" \ - "${COMMAND_NAME[$key]}" \ - "${COMMAND_SUMMARY[$key]}" \ - "${COMMAND_REQUIRES_SUDO[$key]}" \ - "${COMMAND_HIDDEN[$key]}" \ - "${COMMAND_ARGS[$key]}" \ - "${COMMAND_EXAMPLES[$key]}" \ - "${COMMAND_ALIASES[$key]}" \ - "${COMMAND_FALLBACK_ROUTE[$key]}" \ - "${COMMAND_USAGE[$key]}" - done | jq -Rn "$(commands_json_filter) | {ok: true, command: .commands[0]}" + emit_command_record "$1" | jq -Rn "$(commands_json_filter) | {ok: true, command: .commands[0]}" } parse_commands_args() { @@ -926,7 +953,7 @@ dispatch_fast_or_help() { remaining=("${args[@]:DIRECT_RESOLVED_COUNT}") binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY" - if (( ${#remaining[@]} > 0 )) && [[ ${remaining[0]} == "--help" || ${remaining[0]} == "-h" ]]; then + if remaining_has_help_flag "${remaining[@]}"; then if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2 return 127 @@ -937,7 +964,7 @@ dispatch_fast_or_help() { fi key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}" - if [[ " ${remaining[*]} " == *" --json "* ]]; then + if remaining_has_json_flag "${remaining[@]}"; then show_command_json "$key" else show_command_help "$key" @@ -990,8 +1017,8 @@ dispatch_or_help() { key="$RESOLVED_KEY" remaining=("${args[@]:RESOLVED_COUNT}") - if (( ${#remaining[@]} > 0 )) && [[ ${remaining[0]} == "--help" || ${remaining[0]} == "-h" ]]; then - if [[ " ${remaining[*]} " == *" --json "* ]]; then + if remaining_has_help_flag "${remaining[@]}"; then + if remaining_has_json_flag "${remaining[@]}"; then show_command_json "$key" else show_command_help "$key" diff --git a/bin/omarchy-ac-present b/bin/omarchy-ac-present deleted file mode 100755 index df205ae810..0000000000 --- a/bin/omarchy-ac-present +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Returns true if AC power is connected. - -for ac in /sys/class/power_supply/AC* /sys/class/power_supply/ADP*; do - [[ -r $ac/online && $(cat "$ac/online") == "1" ]] && exit 0 -done - -exit 1 diff --git a/bin/omarchy-agent b/bin/omarchy-agent new file mode 100755 index 0000000000..3c00946047 --- /dev/null +++ b/bin/omarchy-agent @@ -0,0 +1,114 @@ +#!/bin/bash + +# omarchy:summary=Launch the default coding agent in a terminal +# omarchy:args=[--inline] [--pick] +# omarchy:examples=omarchy agent | omarchy agent --inline + +inline=false +pick=false + +# Flags only. A bare prompt would shadow the subcommands under this group, so +# prompts go through omarchy-agent-prompt, which passes one here as --prompt. +while (($#)); do + case "$1" in + --inline) + inline=true + shift + ;; + --pick) + pick=true + shift + ;; + --prompt) + prompt=${2:?--prompt needs a value} + shift 2 + ;; + *) + echo "Unexpected argument: $1" >&2 + echo "To pass a prompt: omarchy agent prompt \"$*\"" >&2 + exit 1 + ;; + esac +done + +# Agents refuse to remember trust for $HOME, so launches from the keybinding or +# menu start in the work directory instead of re-asking on every session. +[[ $PWD == "$HOME" && -d $HOME/Work ]] && cd "$HOME/Work" + +agent=$(omarchy-default-agent) + +# Omarchy ships without a default, so there is nothing to launch until one is +# picked. --pick offers the choice instead of the error, which is what the +# keybinding wants: a keypress that opens nothing explains nothing. +if [[ -z $agent ]]; then + [[ $pick == "true" ]] && exec omarchy-menu summon setup.default.agent + echo "Choose default agent with: omarchy default agent " >&2 + exit 1 +fi + +if omarchy-cmd-missing "$agent"; then + echo "$agent is not installed. Choose an installed agent with: omarchy default agent " >&2 + exit 1 +fi + +# Agents launched from the keybinding or menu run unattended, so each one starts +# with its own spelling of "don't stop to ask". Pi and Ori have none to skip. +case "$agent" in +opencode) + command=(opencode --auto) + [[ -n ${prompt:-} ]] && command+=(--prompt "$prompt") + ;; +agy) + command=(agy --dangerously-skip-permissions) + [[ -n ${prompt:-} ]] && command+=(--prompt-interactive "$prompt") + ;; +copilot) + command=(copilot --allow-all) + [[ -n ${prompt:-} ]] && command+=(--interactive "$prompt") + ;; +crush) + # --yolo belongs to the interactive command only; `crush run` never prompts. + if [[ -n ${prompt:-} ]]; then + command=(crush run "$prompt") + else + command=(crush --yolo) + fi + ;; +claude) + command=(claude --permission-mode auto) + [[ -n ${prompt:-} ]] && command+=(-- "$prompt") + ;; +grok) + command=(grok --permission-mode bypassPermissions) + [[ -n ${prompt:-} ]] && command+=(-- "$prompt") + ;; +codex) + command=(codex --approve-for-me) + [[ -n ${prompt:-} ]] && command+=(-- "$prompt") + ;; +omp) + command=(omp --auto-approve) + [[ -n ${prompt:-} ]] && command+=(-- "$prompt") + ;; +ori) + # Ori is a harness launcher, and `ori code` is the agent it runs itself. + command=(ori code) + [[ -n ${prompt:-} ]] && command+=(--prompt "$prompt") + ;; +pi) + command=(pi) + [[ -n ${prompt:-} ]] && command+=("$prompt") + ;; +*) + echo "Unsupported default agent: $agent" >&2 + exit 1 + ;; +esac + +if [[ $inline == "true" ]]; then + exec "${command[@]}" +else + # A fixed app-id rather than the default org.omarchy., so every agent + # window shares one class for window rules and themes to single out. + exec omarchy-launch-tui --app-id=org.omarchy.agent "${command[@]}" +fi diff --git a/bin/omarchy-agent-crash b/bin/omarchy-agent-crash new file mode 100755 index 0000000000..49a1147e61 --- /dev/null +++ b/bin/omarchy-agent-crash @@ -0,0 +1,52 @@ +#!/bin/bash + +# omarchy:summary=Diagnose a crashed process with the default coding agent +# omarchy:args= [comm] [exe] [signal] +# omarchy:examples=omarchy agent crash 1516893 + +# Clicked from a "Process crashed:" notification, or run by hand against any PID +# in `coredumpctl list`. The method lives in the diagnose-crash skill so it is +# edited in one place and works with whichever agent is default; this only +# gathers the facts and points at it. + +set -euo pipefail + +pid=${1:?usage: omarchy-agent-crash [comm] [exe] [signal]} + +if [[ ! $pid =~ ^[0-9]+$ ]]; then + echo "Not a PID: $pid" >&2 + echo "Usage: omarchy agent crash (see: coredumpctl list)" >&2 + exit 1 +fi + +comm=${2:-unknown} +exe=${3:-unknown} +signal=${4:-unknown} + +skill="$OMARCHY_PATH/default/agents/skills/diagnose-crash/SKILL.md" + +# Looked up live so a hand-run PID still gets a timestamp. A rotated-away core +# only costs the timestamp, so failure is tolerated. +when=$(coredumpctl list "$pid" --no-pager --no-legend 2>/dev/null | tail -1 | cut -d' ' -f1-4) || true +when=${when:-unknown} + +prompt=$( + cat < +# omarchy:examples=omarchy agent prompt "Review this project" + +# Prompts live here rather than on `omarchy agent`, where a bare prompt would +# shadow the subcommands under that group. + +set -euo pipefail + +inline=() +if [[ ${1:-} == "--inline" ]]; then + inline=(--inline) + shift +fi + +if (($# == 0)); then + echo "Usage: omarchy agent prompt [--inline] " >&2 + exit 1 +fi + +exec omarchy-agent "${inline[@]}" --prompt "$*" diff --git a/bin/omarchy-agent-usage-claude b/bin/omarchy-agent-usage-claude new file mode 100755 index 0000000000..5b3634aef1 --- /dev/null +++ b/bin/omarchy-agent-usage-claude @@ -0,0 +1,905 @@ +#!/usr/bin/python3 +# omarchy:summary=Print the Claude Code usage record as JSON +# omarchy:args=[--force] [--limits-only] +# omarchy:hidden=true +"""Collect Claude Code usage into one display-ready JSON record. + +Everything the agents panel shows for Claude comes from this one +command: local transcript stats from ~/.claude/projects, the stats-cache and +history fallbacks for machines without transcripts, pi/omp and opencode +sessions that ran on an Anthropic provider, and the authoritative rate +limits from Anthropic's OAuth usage endpoint. The panel itself only ever +reads the JSON this prints; it never talks to disk formats or endpoints. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import fcntl +import hashlib +import json +import os +import re +import sqlite3 +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +AGENT_ID = "claude" +AGENT_NAME = "Claude Code" +AUTH_HELP = "Run `claude auth login` to restore authoritative usage." +USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage" +PROBE_MIN_INTERVAL_SECONDS = 15 + + +def config_dir() -> Path: + return expand_path(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude") + + +def expand_path(value: str) -> Path: + return Path(os.path.expandvars(os.path.expanduser(value))).resolve() + + +def cache_root() -> Path: + root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "omarchy" / "agent-usage" + root.mkdir(parents=True, exist_ok=True) + return root + + +def date_string(value: dt.date) -> str: + return value.strftime("%Y-%m-%d") + + +def recent_date_strings() -> list[str]: + today = dt.datetime.now().date() + return [date_string(today - dt.timedelta(days=offset)) for offset in range(6, -1, -1)] + + +def local_date_string() -> str: + return date_string(dt.datetime.now().date()) + + +def local_date_from_timestamp(value: Any) -> str: + if value is None: + return local_date_string() + + if isinstance(value, (int, float)): + try: + seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value) + return date_string(dt.datetime.fromtimestamp(seconds).date()) + except Exception: + return local_date_string() + + raw = str(value).strip() + if not raw: + return local_date_string() + + # Claude JSONL timestamps are usually ISO-8601. Python accepts offsets but + # not a trailing Z until we normalize it to +00:00. + try: + parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + if parsed.tzinfo is not None: + parsed = parsed.astimezone() + return date_string(parsed.date()) + except Exception: + return local_date_string() + + +def usage_token(usage: dict[str, Any], snake_key: str, camel_key: str) -> int: + value = usage.get(snake_key, usage.get(camel_key, 0)) + try: + return round(float(value or 0)) + except Exception: + return 0 + + +def number(value: Any) -> int: + try: + n = float(value or 0) + return round(n) if n == n else 0 + except Exception: + return 0 + + +def empty_bucket() -> dict[str, int]: + return { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + } + + +# ---------------------------------------------------------------- local scan + + +def scan_projects(projects_path: Path) -> dict[str, Any]: + today = local_date_string() + recent_dates = recent_date_strings() + recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} + + seen: set[str] = set() + sessions: set[str] = set() + active_days: set[str] = set() + today_sessions: set[str] = set() + today_tokens: dict[str, int] = {} + usage_by_model: dict[str, dict[str, int]] = {} + prompts = 0 + today_prompt_count = 0 + today_token_total = 0 + + files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else [] + for path in files: + try: + with path.open("r", encoding="utf-8", errors="replace") as handle: + for line_number, line in enumerate(handle, 1): + # Cheap pre-filter before JSON parsing keeps files with unrelated + # lines inexpensive. + if '"usage":' not in line: + continue + + try: + entry = json.loads(line) + except Exception: + continue + + message = entry.get("message") if isinstance(entry.get("message"), dict) else {} + if entry.get("type") != "assistant" and message.get("role") != "assistant": + continue + + usage = message.get("usage") or entry.get("usage") + if not isinstance(usage, dict): + continue + + message_id = message.get("id") or entry.get("messageId") or "" + unique_key = str(message_id) if message_id else f"{path}:{entry.get('uuid') or entry.get('requestId') or line_number}" + if unique_key in seen: + continue + seen.add(unique_key) + + input_tokens = usage_token(usage, "input_tokens", "inputTokens") + output_tokens = usage_token(usage, "output_tokens", "outputTokens") + cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens") + cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens") + total = input_tokens + output_tokens + cache_read + cache_write + if total <= 0: + continue + + model = str(message.get("model") or entry.get("model") or "claude") + day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp")) + session_key = str(entry.get("sessionId") or path) + sessions.add(session_key) + active_days.add(day) + prompts += 1 + + bucket = usage_by_model.setdefault(model, empty_bucket()) + bucket["inputTokens"] += input_tokens + bucket["outputTokens"] += output_tokens + bucket["cacheReadInputTokens"] += cache_read + bucket["cacheCreationInputTokens"] += cache_write + + if day in recent: + # recentDays.messageCount is actually a token total, despite the + # legacy name shared with synced snapshots. + recent[day]["messageCount"] += total + + if day == today: + today_prompt_count += 1 + today_sessions.add(session_key) + today_token_total += total + today_tokens[model] = today_tokens.get(model, 0) + total + except Exception as exc: + print(f"Ignoring unreadable Claude project file {path}: {exc}", file=sys.stderr) + + return { + "todayPrompts": today_prompt_count, + "todaySessions": len(today_sessions), + "todayTotalTokens": today_token_total, + "todayTokensByModel": today_tokens, + "recentDays": [recent[day] for day in recent_dates], + "modelUsage": usage_by_model, + "totalPrompts": prompts, + "totalSessions": len(sessions), + # Days with any recorded usage, for the all-time "N days" summary. The + # dates travel too: merging snapshots from several machines needs their + # union, which a count alone cannot give. + "activeDays": len(active_days), + "activeDates": sorted(active_days), + } + + +def scan_cache_paths(projects_path: Path) -> tuple[Path, Path]: + digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16] + root = cache_root() + return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock" + + +def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None: + if max_age_seconds <= 0 or not path.exists(): + return None + try: + if time.time() - path.stat().st_mtime <= max_age_seconds: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return None + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + # A temp name unique to this writer, not derived from the target: several + # collectors can run at once (the update command backgrounds one per agent, + # the panel refreshes on its own), and a shared temp path means the second + # replace finds the first one's file already moved away. + handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(handle_fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n") + # mkstemp opens at 0600; these caches were world-readable before. + tmp.chmod(0o644) + tmp.replace(path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def cached_scan(projects_path: Path, max_age_seconds: float) -> dict[str, Any]: + cache_file, lock_file = scan_cache_paths(projects_path) + + cached = read_fresh_json(cache_file, max_age_seconds) + if cached is not None: + return cached + + with lock_file.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + cached = read_fresh_json(cache_file, max_age_seconds) + if cached is not None: + return cached + summary = scan_projects(projects_path) + write_json(cache_file, summary) + return summary + + +# ------------------------------------------------------------- local fallback +# +# A machine without transcripts on disk can still know its history: Claude +# Code keeps aggregate counters in stats-cache.json and per-prompt history in +# history.jsonl. Only consulted when the project scan comes back empty. + + +def stats_cache_fallback(claude_dir: Path) -> dict[str, Any] | None: + try: + data = json.loads((claude_dir / "stats-cache.json").read_text(encoding="utf-8")) + except Exception: + return None + + today = local_date_string() + daily_model_tokens = data.get("dailyModelTokens") or [] + today_tokens = {} + for entry in daily_model_tokens: + if isinstance(entry, dict) and entry.get("date") == today: + today_tokens = entry.get("tokensByModel") or {} + break + + daily_activity = [day for day in (data.get("dailyActivity") or []) if isinstance(day, dict)] + active_dates = sorted({str(day.get("date")) for day in daily_activity if number(day.get("messageCount")) > 0 and day.get("date")}) + today_prompts, today_sessions = today_prompts_from_history(claude_dir) + + return { + "todayPrompts": today_prompts, + "todaySessions": today_sessions, + "todayTotalTokens": sum(number(v) for v in today_tokens.values()), + "todayTokensByModel": today_tokens, + "recentDays": daily_activity[-7:], + "modelUsage": data.get("modelUsage") or {}, + "totalPrompts": number(data.get("totalMessages")), + "totalSessions": number(data.get("totalSessions")), + "activeDays": len(active_dates), + "activeDates": active_dates, + } + + +def today_prompts_from_history(claude_dir: Path) -> tuple[int, int]: + prompts = 0 + sessions: set[str] = set() + start_of_day = dt.datetime.combine(dt.datetime.now().date(), dt.time.min).timestamp() * 1000 + try: + with (claude_dir / "history.jsonl").open("r", encoding="utf-8", errors="replace") as handle: + lines = handle.readlines() + except Exception: + return 0, 0 + + for line in reversed(lines): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except Exception: + continue + if number(entry.get("timestamp")) < start_of_day: + break + prompts += 1 + if entry.get("sessionId"): + sessions.add(str(entry.get("sessionId"))) + return prompts, len(sessions) + + +# --------------------------------------------------------------- pi and omp +# +# These agents can consume a Claude subscription without writing native +# Claude Code transcripts. Their compatible JSONL session formats carry the +# provider, model, and token usage on every assistant message. + + +def scan_pi_usage(max_age_seconds: float) -> dict[str, Any] | None: + roots = [ + Path.home() / ".pi" / "agent" / "sessions", + Path.home() / ".omp" / "agent" / "sessions", + ] + cache_file = cache_root() / "claude-pi-sessions.json" + cached = read_fresh_json(cache_file, max_age_seconds) + if cached is not None: + return cached.get("stats") + + today = local_date_string() + recent_dates = recent_date_strings() + recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} + sessions: set[str] = set() + active_days: set[str] = set() + today_sessions: set[str] = set() + today_tokens: dict[str, int] = {} + usage_by_model: dict[str, dict[str, int]] = {} + seen: set[str] = set() + prompts = 0 + today_prompt_count = 0 + today_token_total = 0 + + for root in roots: + files = root.rglob("*.jsonl") if root.is_dir() else [] + for path in files: + try: + with path.open("r", encoding="utf-8", errors="replace") as handle: + for line_number, line in enumerate(handle, 1): + if '"usage"' not in line or '"assistant"' not in line: + continue + try: + entry = json.loads(line) + message = entry.get("message") if isinstance(entry.get("message"), dict) else {} + if entry.get("type") != "message" or message.get("role") != "assistant": + continue + provider = str(message.get("provider") or "") + if provider != "anthropic": + continue + unique_key = f"{path}:{entry.get('id') or line_number}" + if unique_key in seen: + continue + seen.add(unique_key) + usage = message.get("usage") or {} + input_tokens = usage_token(usage, "input", "inputTokens") + output_tokens = usage_token(usage, "output", "outputTokens") + cache_read = usage_token(usage, "cacheRead", "cache_read_input_tokens") + cache_write = usage_token(usage, "cacheWrite", "cache_creation_input_tokens") + total = input_tokens + output_tokens + cache_read + cache_write + if total <= 0: + total = number(usage.get("totalTokens")) + input_tokens = total + if total <= 0: + continue + model = str(message.get("model") or "claude") + day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp")) + except Exception: + continue + + session_key = str(path) + sessions.add(session_key) + active_days.add(day) + prompts += 1 + bucket = usage_by_model.setdefault(model, empty_bucket()) + bucket["inputTokens"] += input_tokens + bucket["outputTokens"] += output_tokens + bucket["cacheReadInputTokens"] += cache_read + bucket["cacheCreationInputTokens"] += cache_write + if day in recent: + recent[day]["messageCount"] += total + if day == today: + today_prompt_count += 1 + today_sessions.add(session_key) + today_token_total += total + today_tokens[model] = today_tokens.get(model, 0) + total + except OSError: + continue + + stats = None + if prompts > 0: + stats = { + "todayPrompts": today_prompt_count, + "todaySessions": len(today_sessions), + "todayTotalTokens": today_token_total, + "todayTokensByModel": today_tokens, + "recentDays": [recent[day] for day in recent_dates], + "modelUsage": usage_by_model, + "totalPrompts": prompts, + "totalSessions": len(sessions), + "activeDays": len(active_days), + "activeDates": sorted(active_days), + } + write_json(cache_file, {"stats": stats}) + return stats + + +# ---------------------------------------------------------------- opencode +# +# A Claude subscription burned entirely through opencode never writes a +# transcript under ~/.claude, but opencode records per-message provider, +# model, and token usage in its own database. Scan it for Anthropic-provider +# messages and merge the result into whatever the transcript scan found. + + +def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None: + db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db" + if not db.is_file(): + return None + + # Same freshness contract as the transcript scan: --limits-only promises to + # reuse recent local stats, and a big opencode history walked on every panel + # open would break that promise. + cache_file = cache_root() / f"claude-opencode-{hashlib.sha1(str(db).encode('utf-8')).hexdigest()[:16]}.json" + cached = read_fresh_json(cache_file, max_age_seconds) + if cached is not None: + return cached.get("stats") + + today = local_date_string() + recent_dates = recent_date_strings() + recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} + sessions: set[str] = set() + active_days: set[str] = set() + today_sessions: set[str] = set() + today_tokens: dict[str, int] = {} + usage_by_model: dict[str, dict[str, int]] = {} + prompts = 0 + today_prompt_count = 0 + today_token_total = 0 + + try: + # Read-only: opencode may be writing right now. + conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) + except sqlite3.Error: + return None + try: + conn.execute("PRAGMA query_only = ON") + for session_id, raw in conn.execute("SELECT session_id, data FROM message"): + # One malformed row must not abort the scan, so every shape assumption + # lives inside the try. + try: + entry = json.loads(raw) + # Exact match: opencode provider ids are free-form, and a custom + # "anthropic-proxy" gateway is not this subscription. + if not isinstance(entry, dict) or entry.get("role") != "assistant": + continue + if str(entry.get("providerID") or "") != "anthropic": + continue + tokens = entry.get("tokens") or {} + cache = tokens.get("cache") or {} + input_tokens = number(tokens.get("input")) + # opencode keeps thinking tokens out of output; both are generated. + output_tokens = number(tokens.get("output")) + number(tokens.get("reasoning")) + cache_read = number(cache.get("read")) + cache_write = number(cache.get("write")) + total = input_tokens + output_tokens + cache_read + cache_write + if total <= 0: + continue + + created = number((entry.get("time") or {}).get("created")) + day = dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") if created > 0 else today + model = str(entry.get("modelID") or "claude").rstrip("/").split("/")[-1] + except Exception: + continue + session_key = "opencode:" + str(session_id) + sessions.add(session_key) + active_days.add(day) + prompts += 1 + + bucket = usage_by_model.setdefault(model, empty_bucket()) + bucket["inputTokens"] += input_tokens + bucket["outputTokens"] += output_tokens + bucket["cacheReadInputTokens"] += cache_read + bucket["cacheCreationInputTokens"] += cache_write + + if day in recent: + recent[day]["messageCount"] += total + if day == today: + today_prompt_count += 1 + today_sessions.add(session_key) + today_token_total += total + today_tokens[model] = today_tokens.get(model, 0) + total + except sqlite3.Error: + return None + finally: + conn.close() + + stats = None + if prompts > 0: + stats = { + "todayPrompts": today_prompt_count, + "todaySessions": len(today_sessions), + "todayTotalTokens": today_token_total, + "todayTokensByModel": today_tokens, + "recentDays": [recent[day] for day in recent_dates], + "modelUsage": usage_by_model, + "totalPrompts": prompts, + "totalSessions": len(sessions), + "activeDays": len(active_days), + "activeDates": sorted(active_days), + } + write_json(cache_file, {"stats": stats}) + return stats + + +def merge_stats(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]: + merged = dict(base) + for key in ("todayPrompts", "todaySessions", "todayTotalTokens", "totalPrompts", "totalSessions"): + merged[key] = number(base.get(key)) + number(extra.get(key)) + + combined = dict(base.get("todayTokensByModel") or {}) + for model, count in (extra.get("todayTokensByModel") or {}).items(): + combined[model] = number(combined.get(model)) + number(count) + merged["todayTokensByModel"] = combined + + usage = {model: dict(bucket) for model, bucket in (base.get("modelUsage") or {}).items()} + for model, bucket in (extra.get("modelUsage") or {}).items(): + target = usage.setdefault(model, empty_bucket()) + for field, count in (bucket or {}).items(): + target[field] = number(target.get(field)) + number(count) + merged["modelUsage"] = usage + + by_date: dict[str, int] = {} + for source in (base.get("recentDays") or [], extra.get("recentDays") or []): + for day in source: + date = str((day or {}).get("date") or "") + if date: + by_date[date] = by_date.get(date, 0) + number((day or {}).get("messageCount")) + merged["recentDays"] = [{"date": date, "messageCount": by_date[date]} for date in sorted(by_date)] + + # Sources overlap in time, so union dates rather than summing counts. A + # fallback that only knows a count still bounds the answer from below. + dates = set(base.get("activeDates") or []) | set(extra.get("activeDates") or []) + merged["activeDates"] = sorted(dates) + merged["activeDays"] = max(len(dates), number(base.get("activeDays")), number(extra.get("activeDays"))) + return merged + + +# ------------------------------------------------------------------- limits + + +# The access token, its expiry, and the display-safe plan label from the +# CLI's login. Nothing else leaves the credential store: the token goes +# nowhere but the Authorization header of the limits probe, and only the +# plan label may travel into the printed record. +def oauth_login(claude_dir: Path) -> tuple[str, int, str]: + try: + data = json.loads((claude_dir / ".credentials.json").read_text(encoding="utf-8")) + except Exception: + return "", 0, "" + login = data.get("claudeAiOauth") + if not isinstance(login, dict): + return "", 0, "" + plan = plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or "")) + return str(login.get("accessToken") or ""), number(login.get("expiresAt")), plan + + +def plan_label(tier: str, subscription: str) -> str: + if tier: + match = re.search(r"max_(\d+x)", tier, re.IGNORECASE) + if match: + return "Max " + match.group(1) + if subscription: + return subscription[0].upper() + subscription[1:] + return "" + + +def parse_utilization(value: Any) -> float: + try: + return float(str(value).strip().replace("%", "")) + except Exception: + return float("nan") + + +def normalize_utilization(value: Any, percent_scale: bool) -> float: + n = parse_utilization(value) + if not (n >= 0): + return -1.0 + # Anthropic's OAuth usage endpoint currently reports percentages (for + # example 37.0 or 1.0). Older payloads sometimes used fractions (0.37). + # A payload containing any value >= 1 is percent-scaled, so 1.0 renders + # as 1%, not 100%. + if percent_scale or n > 1: + return min(1.0, n / 100.0) + return min(1.0, n) + + +def normalize_reset_at(value: Any) -> str: + if value is None: + return "" + raw = str(value).strip() + if raw == "": + return "" + if raw.isdigit(): + ts = int(raw) + if ts < 1e12: + ts *= 1000 + try: + return dt.datetime.fromtimestamp(ts / 1000, dt.timezone.utc).isoformat() + except Exception: + return raw + try: + parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + return parsed.isoformat() + except Exception: + return raw + + +def usage_bucket(payload: dict[str, Any], key: str) -> dict[str, Any] | None: + bucket = payload.get(key) + return bucket if isinstance(bucket, dict) else None + + +# An entry's `kind` names its window the way the flat buckets' keys do +# ("weekly_scoped", "five_hour_scoped"). The panel reads a window out of free +# text, which cannot survive a model name like "Opus 5 (1M context)" — the +# "1M" reads as a one-minute window — so the window is settled here instead +# and travels as an explicit title. It is capitalized the way the flat windows +# title themselves, so "Fable Weekly" sits beside "Weekly" rather than under it. +def scoped_window(kind: str) -> str: + text = kind.lower() + if "month" in text: + return "Monthly" + if "week" in text or "day" in text: + return "Weekly" + if "hour" in text or "session" in text: + return "Session" + return "" + + +# Alongside the flat buckets, the payload carries a `limits` array, and that +# array is the only place a model-scoped allowance shows up — a weekly window +# that only Fable draws from, say. The matching legacy keys +# (`seven_day_opus`, `seven_day_sonnet`, …) stayed behind at null, so a +# collector that reads buckets alone silently drops a limit the account is +# actually spending against. A model can hold more than one scoped window, and +# only the pair of model and window tells them apart, so both make the title +# and both make the key that keeps a repeat out. +def scoped_limits(payload: dict[str, Any], percent_scale: bool) -> list[dict[str, Any]]: + entries = payload.get("limits") + if not isinstance(entries, list): + return [] + out: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for entry in entries: + if not isinstance(entry, dict): + continue + scope = entry.get("scope") + model = scope.get("model") if isinstance(scope, dict) else None + if not isinstance(model, dict): + continue + # A display name is what the panel wants, but an entry carrying only an id + # still names a window worth showing. + name = str(model.get("display_name") or model.get("id") or "").strip() + kind = str(entry.get("kind") or "").strip() + if name == "" or (name, kind) in seen: + continue + percent = normalize_utilization(entry.get("percent"), percent_scale) + if percent < 0: + continue + seen.add((name, kind)) + window = scoped_window(kind) + title = name + " " + window if window else name + out.append({ + "label": title, + "title": title, + "percent": percent, + "resetsAt": normalize_reset_at(entry.get("resets_at")), + }) + return out + + +def probe_limits(access_token: str) -> dict[str, Any]: + request = urllib.request.Request( + USAGE_ENDPOINT, + headers={ + "Authorization": "Bearer " + access_token, + "anthropic-beta": "oauth-2025-04-20", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8", errors="replace")) + except urllib.error.HTTPError as error: + retry_after = error.headers.get("retry-after", "") if error.headers else "" + if error.code == 429: + help_text = "Anthropic's usage endpoint is rate limiting checks right now" + ( + f" (retry after {retry_after}s)" if retry_after else "" + ) + ". Local Claude Code stats are still shown." + else: + help_text = f"Anthropic's usage endpoint returned status {error.code}. Local Claude Code stats are still shown." + return {"ok": False, "helpText": help_text} + except Exception: + # A transport failure reached no server at all — no route, no DNS. Any + # real answer, including an error status, is a server we should stop + # pestering; this is not. + return { + "ok": False, + "transport": True, + "helpText": "Couldn't reach Anthropic's usage endpoint. Retrying shortly. Local Claude Code stats are still shown.", + } + + weekly = usage_bucket(payload, "seven_day_oauth_apps") or usage_bucket(payload, "seven_day") + session = usage_bucket(payload, "five_hour") + raw = [session.get("utilization") if session else None, weekly.get("utilization") if weekly else None] + # One payload speaks one convention, so the scoped entries settle the scale + # alongside the buckets rather than assuming their own. + entries = payload.get("limits") + if isinstance(entries, list): + raw += [entry.get("percent") for entry in entries if isinstance(entry, dict)] + percent_scale = any(parse_utilization(v) >= 1 for v in raw) + + limits = [] + if session is not None: + percent = normalize_utilization(session.get("utilization"), percent_scale) + if percent >= 0: + limits.append({"label": "Session (5-hour)", "percent": percent, "resetsAt": normalize_reset_at(session.get("resets_at"))}) + if weekly is not None: + percent = normalize_utilization(weekly.get("utilization"), percent_scale) + if percent >= 0: + limits.append({"label": "Weekly (7-day)", "percent": percent, "resetsAt": normalize_reset_at(weekly.get("resets_at"))}) + limits.extend(scoped_limits(payload, percent_scale)) + + if not limits: + return {"ok": False, "helpText": "Anthropic's usage endpoint returned no limits. Local Claude Code stats are still shown."} + return {"ok": True, "limits": limits} + + +# A cached percentage outlives the probe that measured it, but only until its +# window rolls over: once a window has reset, the figure describes a period +# that is over, and a stale 78% would misreport an allowance that is now +# untouched. A window with no reset time, or one that will not parse, is kept +# — an unreadable timestamp is no reason to throw away a real number. +def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool: + raw = str(entry.get("resetsAt") or "") + if raw == "": + return True + try: + resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return True + if resets_at.tzinfo is None: + resets_at = resets_at.replace(tzinfo=dt.timezone.utc) + return resets_at > now + + +def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]: + entries = cached.get("limits") + if not isinstance(entries, list): + return [] + now = dt.datetime.now(dt.timezone.utc) + return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)] + + +def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[str, Any]: + result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP} + + # A panel that is opened and shut repeatedly must not turn into a request + # per flick, so recent probe results are reused for a short window — and + # kept as the answer of record when a later probe fails. + probe_cache = cache_root() / "claude-limits.json" + cached = read_fresh_json(probe_cache, float("inf")) or {} + fallback = usable_cached_limits(cached) + + # Probing needs a live token and only the Claude Code CLI can mint one: it + # refreshes the credential file when it runs, so a machine left alone long + # enough finds the saved token lapsed. Say so — an empty limits list with + # nothing else set hides the whole section and explains nothing — and keep + # showing the last numbers whose window has not since reset. + if access_token == "": + result["limits"] = fallback + result["usageStatusText"] = "Waiting for auth" + return result + if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000: + result["limits"] = fallback + result["usageStatusText"] = "Sign-in expired" + result["authHelpText"] = ( + "Claude Code's saved sign-in expired" + + (" — showing the last known limits." if fallback else ".") + + " Start Claude Code, or run `claude auth login`, to refresh it." + ) + return result + + # --force is a person asking for fresh numbers, so it skips the reuse window + # entirely; the interval is there to absorb repeated panel opens, not to + # overrule someone who pressed refresh. + fetched_at = number(cached.get("fetchedAtMs")) / 1000 + if fallback and not force and time.time() - fetched_at < PROBE_MIN_INTERVAL_SECONDS: + result["limits"] = fallback + return result + + probe = probe_limits(access_token) + if probe["ok"]: + result["limits"] = probe["limits"] + write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": probe["limits"]}) + return result + + # The first probe after login often fires before DHCP has handed out a + # route. Ask the shell to try again sooner than its regular interval. + if probe.get("transport"): + result["retryAdvised"] = True + if fallback: + result["limits"] = fallback + else: + result["usageStatusText"] = "Claude limits unavailable" + result["authHelpText"] = probe["helpText"] + return result + + +# -------------------------------------------------------------------- record + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--force", action="store_true", help="rescan transcripts and re-probe limits, ignoring caches") + parser.add_argument("--limits-only", action="store_true", help="reuse any recent transcript scan; only the limits probe must be fresh") + parser.add_argument("--cache-seconds", type=float, default=20) + args = parser.parse_args() + + claude_dir = config_dir() + scan_age = 0 if args.force else (900 if args.limits_only else args.cache_seconds) + stats = cached_scan(claude_dir / "projects", scan_age) + + if number(stats.get("totalPrompts")) <= 0: + fallback = stats_cache_fallback(claude_dir) + if fallback is not None: + stats = fallback + else: + # No transcripts and no aggregate cache, but history.jsonl alone can + # still put numbers on today. + today_prompts, today_sessions = today_prompts_from_history(claude_dir) + if today_prompts or today_sessions: + stats = dict(stats, todayPrompts=today_prompts, todaySessions=today_sessions) + + pi_usage = scan_pi_usage(scan_age) + if pi_usage is not None: + stats = merge_stats(stats, pi_usage) + + opencode = scan_opencode_usage(scan_age) + if opencode is not None: + stats = merge_stats(stats, opencode) + + access_token, expires_at_ms, plan = oauth_login(claude_dir) + limits = collect_limits(access_token, expires_at_ms, args.force) + + record = { + "schemaVersion": 1, + "id": AGENT_ID, + "name": AGENT_NAME, + "updatedAt": dt.datetime.now(dt.timezone.utc).isoformat(), + "ready": number(stats.get("totalPrompts")) > 0 or len(limits["limits"]) > 0, + "hasLocalStats": True, + "tierLabel": plan, + "usageStatusText": limits["usageStatusText"], + "authHelpText": limits["authHelpText"], + "limits": limits["limits"], + } + if limits.get("retryAdvised"): + record["retryAdvised"] = True + record.update(stats) + print(json.dumps(record, separators=(",", ":"), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bin/omarchy-agent-usage-codex b/bin/omarchy-agent-usage-codex new file mode 100755 index 0000000000..e020053520 --- /dev/null +++ b/bin/omarchy-agent-usage-codex @@ -0,0 +1,603 @@ +#!/usr/bin/python3 +# omarchy:summary=Print the Codex usage record as JSON +# omarchy:args=[--force] [--limits-only] +# omarchy:hidden=true +"""Collect Codex usage into one display-ready JSON record. + +Local stats come from native Codex CLI session files, pi/omp sessions that +ran through openai-codex, and opencode sessions that ran on an OpenAI +provider; rate limits and the plan come from the Codex app-server RPC. The agents +panel only ever reads the JSON this prints. +""" + +import argparse +import fcntl +import hashlib +import json +import os +import select +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + +AGENT_ID = "codex" +AGENT_NAME = "Codex" +AUTH_HELP = "Run `codex login` to authenticate." + +# A scan this recent is only reused to dedup concurrent collector runs (the +# update command backgrounds one per agent while the panel refreshes on its +# own); every periodic widget refresh lands a real rescan, however low +# refreshIntervalSec is set. --limits-only promises only fresh limits, so it +# may reuse a scan for up to 15 minutes. +SCAN_REUSE_SECONDS = 20 +LIMITS_ONLY_REUSE_SECONDS = 900 + + +def local_day(value): + if value is None: + return datetime.now().strftime("%Y-%m-%d") + if isinstance(value, (int, float)): + # pi message timestamps are milliseconds; Codex timestamps are usually seconds. + if value > 10_000_000_000: + value = value / 1000 + return datetime.fromtimestamp(value).strftime("%Y-%m-%d") + text = str(value) + try: + if text.endswith("Z"): + dt = datetime.fromisoformat(text[:-1] + "+00:00") + else: + dt = datetime.fromisoformat(text) + if dt.tzinfo is not None: + dt = dt.astimezone() + return dt.strftime("%Y-%m-%d") + except Exception: + return datetime.now().strftime("%Y-%m-%d") + + +def number(value): + try: + return int(value or 0) + except Exception: + return 0 + + +def model_name(raw): + value = str(raw or "codex") + return value if value else "codex" + + +def runtime_env(): + home = str(Path.home()) + path_parts = [ + os.environ.get("PATH", ""), + f"{home}/.local/bin", + f"{home}/.npm-global/bin", + f"{home}/.local/share/mise/shims", + ] + env = os.environ.copy() + env["PATH"] = os.pathsep.join(part for part in path_parts if part) + return env + + +ENV = runtime_env() + + +def find_command(name): + return shutil.which(name, path=ENV.get("PATH")) + + +now = datetime.now() +today = now.strftime("%Y-%m-%d") +recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)] +recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} +today_tokens_by_model = {} +model_usage = {} +today_sessions = set() +active_days = set() + +today_prompts = 0 +today_total_tokens = 0 +total_prompts = 0 +total_sessions = set() +seen_pi_messages = set() + + +def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write): + global today_prompts, today_total_tokens, total_prompts + total = input_tokens + output_tokens + cache_read + cache_write + total_prompts += 1 + total_sessions.add(session_key) + active_days.add(day) + + bucket = model_usage.setdefault(model, { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + }) + bucket["inputTokens"] += input_tokens + bucket["outputTokens"] += output_tokens + bucket["cacheReadInputTokens"] += cache_read + bucket["cacheCreationInputTokens"] += cache_write + + if day in recent: + recent[day]["messageCount"] += total + + if day == today: + today_prompts += 1 + today_sessions.add(session_key) + today_total_tokens += total + today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total + + +def scan_pi_sessions(): + roots = [ + Path.home() / ".pi" / "agent" / "sessions", + Path.home() / ".omp" / "agent" / "sessions", + ] + rg = find_command("rg") or "rg" + for root in roots: + if not root.exists(): + continue + try: + proc = subprocess.Popen( + [rg, "--json", "-e", r'"provider"\s*:\s*"openai-codex"', "-e", r'"api"\s*:\s*"openai-codex', str(root)], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + errors="replace", + env=ENV, + ) + except FileNotFoundError: + return + + assert proc.stdout is not None + for raw in proc.stdout: + try: + event = json.loads(raw) + if event.get("type") != "match": + continue + line = event.get("data", {}).get("lines", {}).get("text", "") + path = event.get("data", {}).get("path", {}).get("text", "pi-session") + entry = json.loads(line) + except Exception: + continue + + if entry.get("type") != "message": + continue + message_key = path + ":" + str(entry.get("id") or "") + if message_key in seen_pi_messages: + continue + seen_pi_messages.add(message_key) + message = entry.get("message") or {} + if message.get("role") != "assistant": + continue + provider = str(message.get("provider") or "") + api = str(message.get("api") or "") + if provider != "openai-codex" and not api.startswith("openai-codex"): + continue + + usage = message.get("usage") or {} + if not usage: + continue + total = number(usage.get("totalTokens")) + input_tokens = number(usage.get("input")) + output_tokens = number(usage.get("output")) + cache_read = number(usage.get("cacheRead")) + cache_write = number(usage.get("cacheWrite")) + if total and not (input_tokens or output_tokens or cache_read or cache_write): + input_tokens = total + if not (input_tokens or output_tokens or cache_read or cache_write): + continue + + day = local_day(entry.get("timestamp") or message.get("timestamp")) + session_key = path + add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write) + + try: + proc.wait(timeout=1) + except Exception: + proc.kill() + + +def scan_opencode_sessions(): + # A subscription burned entirely through opencode leaves no native session + # files, but opencode records per-message provider, model, and token usage + # in its own database. Read-only: opencode may be writing right now. + # Returns whether the scan ran to completion: a scan cut short by a + # database error still contributes what it read, but must not be cached + # as if it were the whole story. + db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db" + if not db.is_file(): + return True + try: + conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) + except sqlite3.Error: + return False + try: + conn.execute("PRAGMA query_only = ON") + # OpenCode DBs grow huge (every historical message, JSON included), and + # Python-side json.loads of every row wasted ~600 MB of RSS on machines + # whose subscription never ran on OpenAI. The json_extract conditions are + # the authority for the rows that reach them: role == "assistant" and + # providerID == "openai", the same exact-match values the Python filter + # below checks. The guards in front are pure acceleration, not a perfect + # proxy for the Python filter: + # - The LIKE gates skip rows whose JSON cannot contain the two + # key/value pairs, avoiding the JSON parse of tens-of-MB blobs. They + # can differ from json.loads on duplicate keys (Python keeps the + # last, SQLite json_extract keeps the first) and ASCII-escaped + # values (\"ass\\u0069stant\" decodes for Python but not for LIKE), + # so a row the authority would accept can be gated out. Both cases + # are vanishingly rare in real opencode data. + # - json_valid guards the parse itself: json_extract() RAISES on + # malformed JSON instead of returning NULL, and one such row would + # otherwise abort the whole scan. SQLite does not promise that AND + # terms evaluate left to right, so the guard is a CASE around each + # json_extract rather than a separate AND term. Rows that are not + # well-formed JSON are skipped here; the per-row try/except below + # stays as the final safety net for rows that pass the SQL filter + # but fail json.loads. + for session_id, raw in conn.execute( + "SELECT session_id, data FROM message" + " WHERE data LIKE '%\"role\"%:%\"assistant\"%'" + " AND data LIKE '%\"providerID\"%:%\"openai\"%'" + " AND CASE WHEN json_valid(data) THEN json_extract(data, '$.role') END = 'assistant'" + " AND CASE WHEN json_valid(data) THEN json_extract(data, '$.providerID') END = 'openai'" + ): + # One malformed row must not abort the scan, so every shape assumption + # lives inside the try. + try: + entry = json.loads(raw) + # Exact match: opencode provider ids are free-form, and a custom + # "openai-local" gateway is not this subscription. + if not isinstance(entry, dict) or entry.get("role") != "assistant": + continue + if str(entry.get("providerID") or "") != "openai": + continue + tokens = entry.get("tokens") or {} + cache = tokens.get("cache") or {} + input_tokens = number(tokens.get("input")) + # opencode keeps thinking tokens out of output; both are generated. + output_tokens = number(tokens.get("output")) + number(tokens.get("reasoning")) + cache_read = number(cache.get("read")) + cache_write = number(cache.get("write")) + if not (input_tokens or output_tokens or cache_read or cache_write): + continue + day = local_day((entry.get("time") or {}).get("created")) + model = model_name(str(entry.get("modelID") or "").rstrip("/").split("/")[-1]) + except Exception: + continue + add_usage(day, "opencode:" + str(session_id), model, input_tokens, output_tokens, cache_read, cache_write) + except sqlite3.Error: + # Transient lock, schema migration, corruption: the numbers stop here, + # incomplete. + return False + finally: + conn.close() + return True + + +def scan_native_codex_sessions(): + codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) + roots = [codex_home / "sessions", codex_home / "archived_sessions"] + files = [] + cutoff = time.time() - 30 * 24 * 60 * 60 + for root in roots: + if not root.exists(): + continue + for path in root.rglob("*.jsonl"): + try: + if path.stat().st_mtime >= cutoff: + files.append(path) + except OSError: + pass + + for path in files: + current_model = "codex" + try: + with path.open(errors="replace") as handle: + for raw in handle: + try: + entry = json.loads(raw) + except Exception: + continue + if entry.get("type") == "turn_context": + payload = entry.get("payload") or {} + current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model) + continue + payload = entry.get("payload") or entry + if entry.get("type") == "response_item" and isinstance(payload, dict): + payload = payload.get("payload") or payload + if not isinstance(payload, dict): + continue + if payload.get("type") != "token_count": + continue + info = payload.get("info") or {} + # total_token_usage is cumulative for the session. Adding every + # snapshot makes usage grow quadratically, so count the last turn. + usage = info.get("last_token_usage") or {} + cache_read = number(usage.get("cached_input_tokens")) + cache_write = number(usage.get("cache_write_input_tokens")) + # Cached tokens are included in input_tokens, and reasoning tokens + # are included in output_tokens. Keep the cache split without + # counting either category twice. + input_tokens = max(0, number(usage.get("input_tokens")) - cache_read - cache_write) + output_tokens = number(usage.get("output_tokens")) + if not (input_tokens or output_tokens or cache_read or cache_write): + continue + day = local_day(entry.get("timestamp") or path.stat().st_mtime) + add_usage(day, str(path), current_model, input_tokens, output_tokens, cache_read, cache_write) + except Exception: + continue + + +def cache_root(): + root = Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "omarchy" / "agent-usage" + root.mkdir(parents=True, exist_ok=True) + return root + + +def scan_cache_paths(): + codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) + db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db" + # The digest covers every data path the scan reads: the codex session + # roots, the opencode DB, and (via Path.home()) the pi/omp session roots. + digest = hashlib.sha1((str(Path.home()) + "\n" + str(codex_home) + "\n" + str(db)).encode("utf-8")).hexdigest()[:16] + root = cache_root() + return root / f"codex-scan-{digest}.json", root / f"codex-scan-{digest}.lock" + + +def read_fresh_json(path, max_age_seconds): + if max_age_seconds <= 0 or not path.exists(): + return None + try: + # A negative age means the mtime is in the future: the clock moved + # backwards since the write, so the cache's freshness cannot be trusted. + age = time.time() - path.stat().st_mtime + if 0 <= age <= max_age_seconds: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return None + + +def write_json(path, payload): + # A temp name unique to this writer, not derived from the target: several + # collectors can run at once (the update command backgrounds one per agent, + # the panel refreshes on its own), and a shared temp path means the second + # replace finds the first one's file already moved away. + handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(handle_fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(payload, separators=(",", ":")) + "\n") + # mkstemp opens at 0600; nothing in the cache is sensitive, so open it + # up to the usual 0644. + tmp.chmod(0o644) + tmp.replace(path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +# The cache payload is a versioned envelope around the local-stats dict, so a +# corrupted or foreign-shaped file is a cache miss (rescan + rewrite) instead +# of a crash or a garbage record. +def read_cached_stats(cache_file, max_age_seconds): + cached = read_fresh_json(cache_file, max_age_seconds) + if not isinstance(cached, dict) or cached.get("schemaVersion") != 1: + return None + # today* fields only mean "today" on the day they were scanned. A cache + # from another local date (midnight passed, or the clock moved) is a miss, + # not merely old, whatever its mtime says. + if cached.get("scanDate") != today: + return None + stats = cached.get("stats") + if not isinstance(stats, dict): + return None + if not all(key in stats for key in ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage")): + return None + return stats + + +def write_cached_stats(cache_file, stats): + try: + write_json(cache_file, {"schemaVersion": 1, "scanDate": today, "stats": stats}) + except Exception as exc: + print(f"omarchy-agent-usage-codex: could not write usage cache ({exc})", file=sys.stderr) + + +def local_stats(): + """Snapshot the aggregated local usage into the record's stats dict.""" + return { + "todayPrompts": today_prompts, + "todaySessions": len(today_sessions), + "todayTotalTokens": today_total_tokens, + "todayTokensByModel": today_tokens_by_model, + "recentDays": [recent[day] for day in recent_dates], + "totalPrompts": total_prompts, + "totalSessions": len(total_sessions), + # Days with any recorded usage, for the all-time "N days" summary. The + # dates travel too: merging snapshots from several machines needs their + # union, which a count alone cannot give. + "activeDays": len(active_days), + "activeDates": sorted(active_days), + "modelUsage": model_usage, + } + + +def run_local_scans(): + scan_pi_sessions() + scan_native_codex_sessions() + complete = scan_opencode_sessions() + return local_stats(), complete + + +def cached_local_stats(max_age): + """Local stats, with the cache as a pure optimization. + + The cache must never take the collector down: any cache-layer failure + (unwritable cache root, lock errors, disk full) degrades to a direct scan + and a warning on stderr. The JSON record is the contract; the cache is not. + """ + try: + return _cached_local_stats(max_age) + except Exception as exc: + print(f"omarchy-agent-usage-codex: cache unavailable ({exc}); scanning directly", file=sys.stderr) + stats, _ = run_local_scans() + return stats + + +def _cached_local_stats(max_age): + cache_file, lock_file = scan_cache_paths() + + cached = read_cached_stats(cache_file, max_age) + if cached is not None: + return cached + + with lock_file.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + cached = read_cached_stats(cache_file, max_age) + if cached is not None: + return cached + stats, complete = run_local_scans() + # An interrupted scan still serves this run, but caching it would + # suppress the missing usage for every reader until the cache expires. + if complete: + write_cached_stats(cache_file, stats) + return stats + + +def rpc_request(proc, request_id, method, params=None, timeout=8): + payload = {"id": request_id, "method": method, "params": params or {}} + proc.stdin.write(json.dumps(payload) + "\n") + proc.stdin.flush() + deadline = time.time() + timeout + while time.time() < deadline: + ready, _, _ = select.select([proc.stdout], [], [], 0.25) + if not ready: + continue + line = proc.stdout.readline() + if not line: + break + try: + message = json.loads(line) + except Exception: + continue + if message.get("id") == request_id: + return message + raise TimeoutError(method) + + +def limit_window(window): + if not isinstance(window, dict): + return None + used = window.get("usedPercent") + if used is None: + return None + mins = number(window.get("windowDurationMins")) + if mins == 10080: + label = "Weekly (7-day)" + elif mins and mins % 60 == 0: + label = f"{mins // 60}h window" + elif mins: + label = f"{mins}m window" + else: + label = "Limit" + reset = window.get("resetsAt") + return { + "label": label, + "percent": float(used) / 100.0, + "resetsAt": datetime.fromtimestamp(number(reset), timezone.utc).isoformat() if reset else "", + } + + +def fetch_codex_rpc(): + result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP} + codex = find_command("codex") + if not codex: + result["usageStatusText"] = "Codex unavailable" + result["authHelpText"] = "codex not found in PATH" + return result + + try: + proc = subprocess.Popen( + [codex, "-s", "read-only", "-a", "untrusted", "app-server"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + env=ENV, + ) + except Exception as exc: + result["usageStatusText"] = "Codex unavailable" + result["authHelpText"] = str(exc) + return result + + try: + rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-agent-usage", "version": "1"}}, timeout=8) + proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n") + proc.stdin.flush() + account_msg = rpc_request(proc, 2, "account/read", timeout=4) + limits_msg = rpc_request(proc, 3, "account/rateLimits/read", timeout=4) + + account = (account_msg.get("result") or {}).get("account") or {} + limits = (limits_msg.get("result") or {}).get("rateLimits") or {} + plan = limits.get("planType") or account.get("planType") or account.get("type") or "" + result["tierLabel"] = str(plan) if plan else "" + + for window in (limits.get("primary"), limits.get("secondary")): + entry = limit_window(window) + if entry: + result["limits"].append(entry) + except Exception as exc: + result["usageStatusText"] = "Codex limits unavailable" + result["authHelpText"] = str(exc) + finally: + try: + proc.terminate() + proc.wait(timeout=1) + except Exception: + try: + proc.kill() + except Exception: + pass + return result + + +def main(): + parser = argparse.ArgumentParser() + # --force rescans everything and rewrites the cache. --limits-only is kept + # for CLI compatibility with the panel's refreshLimits() call: only the + # limits probe must be fresh, so it may reuse a scan for far longer than a + # normal run, whose short window exists purely to dedup concurrent + # collector runs. + parser.add_argument("--force", action="store_true") + parser.add_argument("--limits-only", action="store_true") + args = parser.parse_args() + + max_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS) + stats = cached_local_stats(max_age) + rpc = fetch_codex_rpc() + + record = { + "schemaVersion": 1, + "id": AGENT_ID, + "name": AGENT_NAME, + "updatedAt": datetime.now(timezone.utc).isoformat(), + "ready": True, + "hasLocalStats": True, + } + record.update(stats) + record.update(rpc) + print(json.dumps(record, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/bin/omarchy-agent-usage-fireworks b/bin/omarchy-agent-usage-fireworks new file mode 100755 index 0000000000..e2c9b632b6 --- /dev/null +++ b/bin/omarchy-agent-usage-fireworks @@ -0,0 +1,511 @@ +#!/usr/bin/python3 +# omarchy:summary=Print the Fireworks usage record as JSON +# omarchy:args=[--force] [--limits-only] +# omarchy:hidden=true +"""Collect Fireworks serverless usage into one display-ready JSON record. + +Token stats come from the Fireworks billing API grouped by day and model for +the last 30 days. Fireworks does not expose its prepaid ledger, so the record +carries an estimated balance instead of rate limits: the credits configured in +~/.config/omarchy/agents/fireworks.json minus rated account costs since the +funding date. The agents panel only ever reads the JSON this prints. +""" + +from __future__ import annotations + +import argparse +import configparser +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import date, datetime, time, timedelta, timezone +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any + +AGENT_ID = "fireworks" +AGENT_NAME = "Fireworks" +AUTH_HELP = "Set FIREWORKS_API_KEY, run `firectl set-api-key`, or sign in to Fireworks in opencode." +API_BASE_URL = "https://api.fireworks.ai" + + +class FireworksError(Exception): + pass + + +def number(value: Any) -> int: + try: + return max(0, round(float(value or 0))) + except (TypeError, ValueError): + return 0 + + +def money_value(value: Any) -> Decimal: + if not isinstance(value, dict): + return Decimal("0") + try: + units = Decimal(str(value.get("units", 0) or 0)) + nanos = Decimal(str(value.get("nanos", 0) or 0)) / Decimal("1000000000") + return units + nanos + except (InvalidOperation, TypeError, ValueError): + return Decimal("0") + + +def model_id(row: dict[str, Any]) -> str: + group = row.get("group") if isinstance(row.get("group"), dict) else {} + raw = group.get("model_name") or row.get("modelName") or "unknown" + name = str(raw).rstrip("/").split("/")[-1] or "unknown" + return re.sub(r"(?<=\d)p(?=\d)", ".", name) + + +def row_date(row: dict[str, Any]) -> str: + # The query asks for day buckets in the local timezone, but the API reports + # each bucket's boundary in UTC: local Aug 7 starts at Aug 6 22:00Z east of + # Greenwich. Convert back to local time to recover the day the bucket names — + # taking the raw date prefix would file every day under its predecessor. + raw = str(row.get("startTime") or "") + if not raw: + return "" + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return raw[:10] if len(raw) >= 10 else "" + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone().date().isoformat() + + +def empty_bucket() -> dict[str, int]: + return { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + } + + +def empty_stats() -> dict[str, Any]: + return { + "todayPrompts": 0, + "todaySessions": 0, + "todayTotalTokens": 0, + "todayTokensByModel": {}, + "recentDays": [], + "totalPrompts": 0, + "totalSessions": 0, + "activeDays": 0, + "activeDates": [], + "modelUsage": {}, + } + + +def base_record(**overrides: Any) -> dict[str, Any]: + record: dict[str, Any] = { + "schemaVersion": 1, + "id": AGENT_ID, + "name": AGENT_NAME, + "updatedAt": datetime.now(timezone.utc).isoformat(), + "ready": False, + "hasLocalStats": False, + # Billing-API numbers are account-global, not machine-local: every synced + # device reports the same truth, so aggregation must not sum them. + "scope": "account", + # The billing API reports tokens, never prompt or session counts; the + # panel keeps those numbers out of today's tooltip when this is false. + "hasPromptStats": False, + "tierLabel": "Prepaid", + "usageStatusText": "", + "authHelpText": "", + "limits": [], + } + record.update(empty_stats()) + record.update(overrides) + return record + + +def summarize_usage(payload: dict[str, Any], today: date | None = None) -> dict[str, Any]: + today = today or datetime.now().astimezone().date() + recent_dates = [(today - timedelta(days=offset)).isoformat() for offset in range(6, -1, -1)] + recent = {day: 0 for day in recent_dates} + today_by_model: dict[str, int] = {} + model_usage: dict[str, dict[str, int]] = {} + active_dates: set[str] = set() + + rows = payload.get("serverlessCosts") + if not isinstance(rows, list): + rows = [] + + for raw_row in rows: + if not isinstance(raw_row, dict): + continue + day = row_date(raw_row) + model = model_id(raw_row) + prompt = number(raw_row.get("promptTokens")) + cached = min(prompt, number(raw_row.get("cachedPromptTokens"))) + uncached = number(raw_row.get("uncachedPromptTokens")) + if "uncachedPromptTokens" not in raw_row: + uncached = max(0, prompt - cached) + output = number(raw_row.get("completionTokens")) + total = uncached + cached + output + if total <= 0: + continue + + bucket = model_usage.setdefault(model, empty_bucket()) + bucket["inputTokens"] += uncached + bucket["outputTokens"] += output + bucket["cacheReadInputTokens"] += cached + + if day: + active_dates.add(day) + if day in recent: + recent[day] += total + if day == today.isoformat(): + today_by_model[model] = today_by_model.get(model, 0) + total + + return { + "todayTotalTokens": sum(today_by_model.values()), + "todayTokensByModel": today_by_model, + "recentDays": [{"date": day, "messageCount": recent[day]} for day in recent_dates], + "activeDays": len(active_dates), + "activeDates": sorted(active_dates), + "modelUsage": model_usage, + } + + +def read_auth_file(path: Path) -> tuple[str, str]: + if not path.is_file(): + return "", "" + + parser = configparser.ConfigParser(interpolation=None) + try: + parser.read(path) + except configparser.Error: + return "", "" + + api_key = "" + account_id = "" + sections = [parser.defaults()] + sections.extend(parser[section] for section in parser.sections()) + for values in sections: + api_key = api_key or str(values.get("api_key", values.get("api-key", ""))).strip() + account_id = account_id or str(values.get("account_id", values.get("account-id", ""))).strip() + return api_key, account_id + + +def opencode_auth_path() -> Path: + data_home = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) + return data_home / "opencode" / "auth.json" + + +def read_opencode_key(path: Path) -> str: + try: + parsed = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return "" + entry = parsed.get("fireworks-ai") if isinstance(parsed, dict) else None + if not isinstance(entry, dict): + return "" + return str(entry.get("key") or "").strip() + + +def config_path() -> Path: + config_home = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) + return config_home / "omarchy" / "agents" / "fireworks.json" + + +def read_config() -> dict[str, Any]: + try: + parsed = json.loads(config_path().read_text()) + return parsed if isinstance(parsed, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def credentials(auth_path: Path, config: dict[str, Any]) -> tuple[str, str]: + file_key, file_account = read_auth_file(auth_path) + # opencode is the last resort: an explicit key or a firectl login should + # win over whatever another tool happens to be signed in with. + api_key = ( + str(os.environ.get("FIREWORKS_API_KEY", "")).strip() + or file_key + or read_opencode_key(opencode_auth_path()) + ) + account_id = ( + str(os.environ.get("FIREWORKS_ACCOUNT_ID", "")).strip() + or str(config.get("accountId") or "").strip() + or file_account + ) + return api_key, account_id + + +def normalize_account_id(value: str) -> str: + return str(value or "").strip().removeprefix("accounts/").strip("/") + + +def timezone_name() -> str: + configured = str(os.environ.get("TZ", "")).strip() + if configured: + return configured + try: + target = (Path("/etc/localtime").resolve()).as_posix() + marker = "/zoneinfo/" + if marker in target: + return target.split(marker, 1)[1] + except OSError: + pass + return "UTC" + + +def local_midnight_utc(day: date) -> str: + # The API buckets by the requested timezone, so the window must run between + # local midnights — expressed in UTC, since a bare date with a Z suffix + # shifts the window by the UTC offset and clips today's tail west of + # Greenwich. + return datetime.combine(day, time.min).astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def iso_timestamp(value: str) -> str: + raw = str(value or "").strip() + if not raw: + return "" + try: + if len(raw) == 10: + parsed = datetime.combine(date.fromisoformat(raw), time.min, tzinfo=timezone.utc) + else: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + except ValueError: + raise FireworksError("Fireworks fundedAt must be an ISO date such as 2026-07-01") + + +class FireworksClient: + def __init__(self, api_key: str, base_url: str = API_BASE_URL): + self.api_key = api_key + self.base_url = base_url.rstrip("/") + + def request( + self, + path: str, + query: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + url = self.base_url + path + if query: + url += "?" + urllib.parse.urlencode(query, doseq=True) + data = None if body is None else json.dumps(body).encode("utf-8") + request = urllib.request.Request( + url, + data=data, + method="POST" if body is not None else "GET", + headers={ + "Authorization": "Bearer " + self.api_key, + "Accept": "application/json", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + decoded = json.load(response) + return decoded if isinstance(decoded, dict) else {} + except urllib.error.HTTPError as error: + if error.code == 401: + raise FireworksError("Fireworks rejected the API key") + if error.code == 403: + raise FireworksError("The Fireworks API key cannot read billing data") + if error.code == 404: + raise FireworksError("Fireworks account not found") + raise FireworksError(f"Fireworks API returned HTTP {error.code}") + except urllib.error.URLError as error: + raise FireworksError("Could not reach the Fireworks API") from error + except (json.JSONDecodeError, TimeoutError) as error: + raise FireworksError("Fireworks returned an invalid billing response") from error + + def discover_account(self) -> tuple[str, dict[str, Any]]: + payload = self.request("/v1/accounts", query={"pageSize": 100}) + accounts = [item for item in payload.get("accounts", []) if isinstance(item, dict)] + if len(accounts) == 1: + account = accounts[0] + return normalize_account_id(str(account.get("name") or "")), account + if not accounts: + raise FireworksError("No Fireworks account is available for this API key") + raise FireworksError("Set accountId in fireworks.json when the API key can access multiple accounts") + + def account(self, account_id: str) -> dict[str, Any]: + quoted = urllib.parse.quote(normalize_account_id(account_id), safe="") + return self.request(f"/v1/accounts/{quoted}") + + def usage(self, account_id: str, start_day: date, end_day: date) -> dict[str, Any]: + quoted = urllib.parse.quote(normalize_account_id(account_id), safe="") + query = { + "startTime": local_midnight_utc(start_day), + "endTime": local_midnight_utc(end_day), + "usageType": "SERVERLESS", + "timezone": timezone_name(), + "groupBy": ["model_name"], + } + # 30 days grouped by model can exceed one page; follow the continuation + # tokens or heavy accounts lose their tail. The bound is a runaway stop. + rows: list[Any] = [] + for _ in range(20): + payload = self.request(f"/v1/accounts/{quoted}/billingUsage", query=query) + page = payload.get("serverlessCosts") + if isinstance(page, list): + rows.extend(page) + token = str(payload.get("nextPageToken") or "") + if not token: + break + query = dict(query, pageToken=token) + return {"serverlessCosts": rows} + + def spent(self, account_id: str, start_at: str, end_at: str) -> Decimal: + quoted = urllib.parse.quote(normalize_account_id(account_id), safe="") + body = { + "startTime": start_at, + "endTime": end_at, + "scope": "ACCOUNT", + } + try: + payload = self.request(f"/v1/accounts/{quoted}/usageCosts:query", body=body) + if not isinstance(payload.get("subtotal"), dict): + raise FireworksError("Fireworks cost response did not include a subtotal") + return money_value(payload.get("subtotal")) + except FireworksError: + parsed_end = datetime.fromisoformat(end_at.replace("Z", "+00:00")) + summary_end = (parsed_end.date() + timedelta(days=1)).isoformat() + "T00:00:00Z" + payload = self.request( + f"/v1/accounts/{quoted}/billing/summary", + query={"startTime": start_at, "endTime": summary_end}, + ) + return sum( + (money_value(item.get("totalCost")) for item in payload.get("lineItems", []) if isinstance(item, dict)), + Decimal("0"), + ) + + +def live_balance(client: FireworksClient, account_id: str) -> Decimal | None: + # accounts/{id}:getBalance exists but is permission-gated: keys without the + # billing role get PERMISSION_DENIED, and then the configured estimate below + # is the best we can do. The response shape is undocumented, so accept a + # Money object at the top level or under any plausible field name. + quoted = urllib.parse.quote(normalize_account_id(account_id), safe="") + try: + payload = client.request(f"/v1/accounts/{quoted}:getBalance") + except FireworksError: + return None + candidates = [payload] + [payload.get(field) for field in ("balance", "creditBalance", "prepaidBalance", "amount")] + for value in candidates: + if isinstance(value, dict) and ("units" in value or "nanos" in value): + return money_value(value) + return None + + +def estimated_balance( + client: FireworksClient, + account_id: str, + account: dict[str, Any], + config: dict[str, Any], +) -> dict[str, Any] | None: + try: + funded = Decimal(str(config.get("fundedAmount") or "0")) + except InvalidOperation: + raise FireworksError("Fireworks fundedAmount must be a number") + if not funded.is_finite(): + raise FireworksError("Fireworks fundedAmount must be a finite number") + if funded <= 0: + return None + + funded_at = iso_timestamp(str(config.get("fundedAt") or "")) + if not funded_at: + if not account: + account = client.account(account_id) + funded_at = iso_timestamp(str(account.get("createTime") or "")) + if not funded_at: + raise FireworksError("Set fundedAt because the Fireworks account creation date is unavailable") + + end_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + spent = max(Decimal("0"), client.spent(account_id, funded_at, end_at)) + return { + "remaining": float(max(Decimal("0"), funded - spent)), + "funded": float(funded), + "spent": float(spent), + "currency": "USD", + "estimated": True, + } + + +def scan(api_base_url: str, auth_path: Path) -> dict[str, Any]: + config = read_config() + api_key, account_id = credentials(auth_path, config) + if not api_key: + return base_record(usageStatusText="Fireworks unavailable", authHelpText=AUTH_HELP) + + client = FireworksClient(api_key, api_base_url) + account: dict[str, Any] = {} + if account_id: + account_id = normalize_account_id(account_id) + else: + account_id, account = client.discover_account() + + today = datetime.now().astimezone().date() + usage = client.usage(account_id, today - timedelta(days=29), today + timedelta(days=1)) + record = base_record(ready=True, hasLocalStats=True) + record.update(summarize_usage(usage, today)) + + live = live_balance(client, account_id) + if live is not None: + try: + funded = Decimal(str(config.get("fundedAmount") or "0")) + if not funded.is_finite() or funded < 0: + funded = Decimal("0") + except InvalidOperation: + funded = Decimal("0") + record["balance"] = { + "remaining": float(live), + "funded": float(funded), + "spent": float(max(Decimal("0"), funded - live)), + "currency": "USD", + "estimated": False, + } + return record + + try: + balance = estimated_balance(client, account_id, account, config) + if balance: + record["balance"] = balance + except FireworksError as error: + record["usageStatusText"] = "Balance unavailable" + record["authHelpText"] = str(error) + + return record + + +def main() -> int: + parser = argparse.ArgumentParser(description="Print the Fireworks usage record as JSON") + # Stats and balance come from the same few API calls, so there is no cache + # to force past and no faster limits-only path. The flags exist so every + # collector accepts the same invocation. + parser.add_argument("--force", action="store_true") + parser.add_argument("--limits-only", action="store_true") + parser.add_argument("--auth-path", default=os.environ.get("FIREWORKS_AUTH_PATH", "~/.fireworks/auth.ini")) + parser.add_argument("--api-base-url", default=os.environ.get("FIREWORKS_API_BASE_URL", API_BASE_URL)) + args = parser.parse_args() + + try: + record = scan(args.api_base_url, Path(args.auth_path).expanduser()) + except FireworksError as error: + record = base_record(usageStatusText="Fireworks unavailable", authHelpText=str(error)) + except Exception as error: + record = base_record(usageStatusText="Fireworks unavailable", authHelpText="Fireworks usage scan failed") + print(f"omarchy-agent-usage-fireworks: {type(error).__name__}", file=sys.stderr) + print(json.dumps(record, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bin/omarchy-agent-usage-update b/bin/omarchy-agent-usage-update new file mode 100755 index 0000000000..fe45285553 --- /dev/null +++ b/bin/omarchy-agent-usage-update @@ -0,0 +1,68 @@ +#!/bin/bash + +# omarchy:summary=Regenerate the AI agent usage data files +# omarchy:args=[--force] [--limits-only] [--except ] [agent...] +# omarchy:examples=omarchy agent usage-update | omarchy agent usage-update claude | omarchy agent usage-update --except codex + +# Each omarchy-agent-usage- collector prints one display-ready JSON +# record; this writes them to ~/.local/state/omarchy/agents/usage/ where the +# agents panel watches them. Adding an agent is adding a collector — the +# panel picks up any record that appears here. + +USAGE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/usage" +mkdir -p "$USAGE_DIR" + +flags=() +only=() +declare -A excluded + +while (( $# > 0 )); do + case "$1" in + --force | --limits-only) flags+=("$1") ;; + --except) + excluded[$2]=1 + shift + ;; + *) only+=("$1") ;; + esac + shift +done + +wanted() { + local agent="$1" + [[ -n ${excluded[$agent]} ]] && return 1 + (( ${#only[@]} == 0 )) && return 0 + local candidate + for candidate in "${only[@]}"; do + [[ $candidate == "$agent" ]] && return 0 + done + return 1 +} + +collect() { + local collector="$1" agent="$2" + local record tmp + if ! record=$("$collector" "${flags[@]}") || [[ -z $record ]] || ! jq -e . >/dev/null 2>&1 <<<"$record"; then + echo "omarchy-agent-usage-update: $agent collector failed" >&2 + return 1 + fi + tmp=$(mktemp "$USAGE_DIR/.$agent.XXXXXX") + printf '%s\n' "$record" >"$tmp" + mv "$tmp" "$USAGE_DIR/$agent.json" +} + +pids=() +for collector in "$OMARCHY_PATH"/bin/omarchy-agent-usage-*; do + [[ -x $collector ]] || continue + agent="${collector##*/omarchy-agent-usage-}" + [[ $agent == "update" ]] && continue + wanted "$agent" || continue + collect "$collector" "$agent" & + pids+=($!) +done + +status=0 +for pid in "${pids[@]}"; do + wait "$pid" || status=1 +done +exit $status diff --git a/bin/omarchy-apply-hardware b/bin/omarchy-apply-hardware new file mode 100755 index 0000000000..89eb8ab12f --- /dev/null +++ b/bin/omarchy-apply-hardware @@ -0,0 +1,75 @@ +#!/bin/bash + +# omarchy:summary=Apply Omarchy hardware-specific packages and system configuration +# omarchy:group=apply +# omarchy:requires-sudo=true +# omarchy:examples=omarchy apply hardware --install-user dhh +# omarchy:hidden=true + +set -euo pipefail + +usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac +done + +if (( EUID != 0 )); then + echo "Error: omarchy-apply-hardware must run as root" >&2 + exit 1 +fi + +if (( defer_provisioning )); then + install_user="" +else + if [[ -z $install_user || $install_user == "root" ]]; then + echo "Error: --install-user must name the target non-root user" >&2 + exit 1 + fi + + if ! getent passwd "$install_user" >/dev/null; then + echo "Error: user '$install_user' does not exist" >&2 + exit 1 + fi +fi + +export OMARCHY_INSTALL_USER="$install_user" +export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +export OMARCHY_INSTALL="${OMARCHY_INSTALL:-$OMARCHY_PATH/install}" +export OMARCHY_INSTALL_LOG_FILE="${OMARCHY_INSTALL_LOG_FILE:-/var/log/omarchy-install.log}" +export PATH="$OMARCHY_PATH/bin:$PATH" + +source "$OMARCHY_INSTALL/helpers/logging.sh" +source "$OMARCHY_INSTALL/hardware/all.sh" diff --git a/bin/omarchy-apply-lock b/bin/omarchy-apply-lock new file mode 100755 index 0000000000..9b97c0db69 --- /dev/null +++ b/bin/omarchy-apply-lock @@ -0,0 +1,53 @@ +#!/bin/bash + +# omarchy:summary=Configure Quickshell lock screen authentication +# omarchy:requires-sudo=true +# omarchy:hidden=true + +set -e + +target_user=${OMARCHY_INSTALL_USER:-${SUDO_USER:-}} +if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then + target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) +fi +target_user=${target_user:-$USER} + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +echo "Configuring lock screen password authentication..." + +as_root tee /etc/pam.d/omarchy-lock-password >/dev/null <<'EOF' +#%PAM-1.0 +auth required pam_faillock.so preauth silent deny=10 unlock_time=120 +-auth [success=2 default=ignore] pam_systemd_home.so +auth [success=1 default=bad] pam_unix.so try_first_pass nullok +auth [default=die] pam_faillock.so authfail deny=10 unlock_time=120 +auth optional pam_permit.so +auth required pam_env.so +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 + echo "Configuring lock screen fingerprint authentication..." + as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF' +#%PAM-1.0 +auth required pam_fprintd.so +account include system-local-login +EOF +else + as_root rm -f /etc/pam.d/omarchy-lock-fingerprint +fi + +# omarchy-shell can't reach a running shell during chroot install. The echo +# is just confirmation, so swallow the failure rather than letting it become +# the script's exit code. +if omarchy-shell lock status >/dev/null 2>&1; then + echo "Lock screen authentication configured." +fi diff --git a/bin/omarchy-apply-system b/bin/omarchy-apply-system new file mode 100755 index 0000000000..15e1d32ffe --- /dev/null +++ b/bin/omarchy-apply-system @@ -0,0 +1,102 @@ +#!/bin/bash + +# omarchy:summary=Apply Omarchy system setup in the installed target +# omarchy:group=apply +# omarchy:requires-sudo=true +# omarchy:examples=omarchy apply system --install-user dhh --first-install +# omarchy:hidden=true + +set -euo pipefail + +usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac +done + +if (( EUID != 0 )); then + echo "Error: omarchy-apply-system must run as root" >&2 + exit 1 +fi + +if (( defer_provisioning )); then + install_user="" +else + if [[ -z $install_user || $install_user == "root" ]]; then + echo "Error: --install-user must name the target non-root user" >&2 + exit 1 + fi + + if ! getent passwd "$install_user" >/dev/null; then + echo "Error: user '$install_user' does not exist" >&2 + exit 1 + fi +fi + +export OMARCHY_INSTALL_USER="$install_user" +export OMARCHY_FIRST_INSTALL="$first_install" +export OMARCHY_UPGRADE="$upgrade" +export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +export OMARCHY_INSTALL="${OMARCHY_INSTALL:-$OMARCHY_PATH/install}" +export OMARCHY_INSTALL_LOG_FILE="${OMARCHY_INSTALL_LOG_FILE:-/var/log/omarchy-install.log}" +export PATH="$OMARCHY_PATH/bin:$PATH" + +source "$OMARCHY_INSTALL/helpers/logging.sh" +start_install_log + +source "$OMARCHY_INSTALL/config/all.sh" + +if (( defer_provisioning )); then + omarchy-apply-hardware --defer-provisioning +else + omarchy-apply-hardware --install-user "$install_user" +fi + +source "$OMARCHY_INSTALL/login/all.sh" +source "$OMARCHY_INSTALL/post-install/all.sh" + +stop_install_log diff --git a/bin/omarchy-audio-input-mute b/bin/omarchy-audio-input-mute index 19e12f2363..554eae44bb 100755 --- a/bin/omarchy-audio-input-mute +++ b/bin/omarchy-audio-input-mute @@ -4,18 +4,10 @@ wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle >/dev/null -if pactl get-source-mute @DEFAULT_SOURCE@ | rg -q 'yes'; then - led=on - osd_message='Microphone muted' - osd_icon='microphone-sensitivity-muted-symbolic' +if wpctl get-volume @DEFAULT_AUDIO_SOURCE@ | grep -q MUTED; then + omarchy-brightness-keyboard-mute on + omarchy-osd -i microphone-muted -m "Microphone muted" else - led=off - osd_message='Microphone on' - osd_icon='audio-input-microphone-symbolic' + omarchy-brightness-keyboard-mute off + omarchy-osd -i microphone -m "Microphone on" fi - -omarchy-brightness-keyboard-mute "$led" - -omarchy-swayosd-client \ - --custom-message "$osd_message" \ - --custom-icon "$osd_icon" diff --git a/bin/omarchy-audio-input-set-default b/bin/omarchy-audio-input-set-default new file mode 100755 index 0000000000..280042d2da --- /dev/null +++ b/bin/omarchy-audio-input-set-default @@ -0,0 +1,20 @@ +#!/bin/bash + +# omarchy:summary=Set the default audio input and move active streams +# omarchy:args= +# omarchy:examples=omarchy audio input set default 43 alsa_input.pci-0000_00_1f.3.analog-stereo + +node_id=${1:-} +source_name=${2:-} + +if [[ -z $node_id || -z $source_name ]]; then + echo "Usage: omarchy-audio-input-set-default " >&2 + exit 1 +fi + +wpctl set-default "$node_id" 2>/dev/null || true +pactl set-default-source "$source_name" 2>/dev/null || true + +pactl list short source-outputs 2>/dev/null | awk '{ print $1 }' | while read -r output; do + [[ -n $output ]] && pactl move-source-output "$output" "$source_name" 2>/dev/null || true +done diff --git a/bin/omarchy-audio-output-set-default b/bin/omarchy-audio-output-set-default new file mode 100755 index 0000000000..a742a2b371 --- /dev/null +++ b/bin/omarchy-audio-output-set-default @@ -0,0 +1,31 @@ +#!/bin/bash + +# omarchy:summary=Set the default audio output and move active streams +# omarchy:args= +# omarchy:examples=omarchy audio output set default 42 alsa_output.pci-0000_00_1f.3.analog-stereo + +node_id=${1:-} +sink_name=${2:-} + +if [[ -z $node_id || -z $sink_name ]]; then + echo "Usage: omarchy-audio-output-set-default " >&2 + exit 1 +fi + +timeout 2 wpctl set-default "$node_id" 2>/dev/null || true +timeout 2 pactl set-default-sink "$sink_name" 2>/dev/null || true + +# Move only real application streams. A DSP filter-chain's own output is also a +# sink input but carries no application.name, and moving it would rewire the +# processing itself -- onto headphones, or into its own virtual sink, which is a +# cycle. EasyEffects' output stream must stay put for the same reason. +timeout 2 pactl list sink-inputs 2>/dev/null | awk ' + /^Sink Input #/ {id = substr($3, 2)} + /application\.name = / { + app = $0 + sub(/.*application\.name = "/, "", app) + sub(/"$/, "", app) + if (app != "EasyEffects") print id + }' | while read -r input; do + [[ -n $input ]] && timeout 2 pactl move-sink-input "$input" "$sink_name" 2>/dev/null || true +done diff --git a/bin/omarchy-audio-output-sink b/bin/omarchy-audio-output-sink new file mode 100755 index 0000000000..f47bf98444 --- /dev/null +++ b/bin/omarchy-audio-output-sink @@ -0,0 +1,55 @@ +#!/bin/bash + +# omarchy:summary=Print the sink whose volume and mute a given output really uses +# omarchy:args=[sink-name] +# omarchy:group=audio +# omarchy:examples=omarchy audio output sink | omarchy audio output sink omarchy_speaker_tuning + +set -uo pipefail + +# A DSP sink -- a speaker tuning filter-chain, or EasyEffects -- can be the +# selected output without being where loudness lives. Changing its volume alters +# the level going *into* the processing: the display moves while the speakers do +# not, and on a chain with a compressor or limiter the tone changes too. Resolve +# through it to the physical sink it feeds. +# +# With no argument this resolves the current default output, so when headphones or +# HDMI are selected it returns those, not the speakers a tuning happens to front. +# Callers that need to describe some *other* output -- an output switcher naming +# the next one in the rotation -- pass that sink explicitly. + +sink="${1:-$(pactl get-default-sink 2>/dev/null)}" + +if [[ -z $sink || $sink == alsa_output.* ]]; then + printf '%s\n' "$sink" + exit 0 +fi + +# A DSP sink feeds its physical output through a stream of its own; follow that +# stream down to the sink underneath. +downstream="$(pactl list sink-inputs 2>/dev/null | + awk -v virt="$sink" ' + /^Sink Input #/ {target = ""} + /^[[:space:]]*Sink:/ {target = $2} + /node\.name = / { + name = $0 + sub(/.*node\.name = "/, "", name) + sub(/"$/, "", name) + if (index(name, virt) == 1 && target != "") {print target; exit} + } + /application\.name = "EasyEffects"/ { + if (virt == "easyeffects_sink" && target != "") {print target; exit} + }')" + +if [[ -n $downstream ]]; then + name="$(pactl list sinks short 2>/dev/null | + awk -v id="$downstream" '$1 == id {print $2; exit}')" + if [[ -n $name ]]; then + printf '%s\n' "$name" + exit 0 + fi +fi + +# Nothing resolvable downstream -- the DSP sink may simply be idle and unlinked. +# Fall back to the sink itself so callers still have something to act on. +printf '%s\n' "$sink" diff --git a/bin/omarchy-audio-output-switch b/bin/omarchy-audio-output-switch index f5f94d44b7..11d2a3567a 100755 --- a/bin/omarchy-audio-output-switch +++ b/bin/omarchy-audio-output-switch @@ -1,17 +1,24 @@ #!/bin/bash -# omarchy:summary=Switch between audio outputs while preserving the mute status. By default mapped to Super + Mute. +# omarchy:summary=Switch between audio outputs while preserving the mute status -sinks=$(pactl -f json list sinks | jq '[.[] | select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any))]') -sinks_count=$(echo "$sinks" | jq '. | length') +# Skip the physical sink an active speaker tuning fronts: rotating onto it would +# silently bypass the tuning rather than pick a different output. +fronted=$(omarchy-audio-tuning fronted-sink 2>/dev/null || true) + +sinks=$(timeout 2 pactl -f json list sinks | + jq --arg fronted "$fronted" '[.[] + | select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any)) + | select($fronted == "" or .name != $fronted)]') +sinks_count=$(jq 'length' <<<"$sinks") if (( sinks_count == 0 )); then - omarchy-swayosd-client --custom-message "No audio devices found" + omarchy-osd -m "No audio devices found" exit 1 fi -current_sink_name=$(pactl get-default-sink) -current_sink_index=$(echo "$sinks" | jq -r --arg name "$current_sink_name" 'map(.name) | index($name)') +current_sink_name=$(timeout 2 pactl get-default-sink) +current_sink_index=$(jq -r --arg name "$current_sink_name" 'map(.name) | index($name)' <<<"$sinks") if [[ $current_sink_index != "null" ]]; then next_sink_index=$(((current_sink_index + 1) % sinks_count)) @@ -19,28 +26,23 @@ else next_sink_index=0 fi -next_sink=$(echo "$sinks" | jq -r ".[$next_sink_index]") -next_sink_name=$(echo "$next_sink" | jq -r '.name') - -next_sink_description=$(echo "$next_sink" | jq -r '.description') -if [[ $next_sink_description == "(null)" ]] || [[ $next_sink_description == "null" ]] || [[ -z $next_sink_description ]]; then - # For Bluetooth devices, the friendly name is on the Device entry (device.id), not the Sink entry (object.id) - device_id=$(echo "$next_sink" | jq -r '.properties."device.id"') - if [[ $device_id != "null" ]] && [[ -n $device_id ]]; then - next_sink_description=$(wpctl status | grep -E "^\s*│?\s+${device_id}\." | sed -E 's/^.*[0-9]+\.\s+//' | sed -E 's/\s+\[.*$//') - fi - # Fall back to object.id lookup if device.id didn't yield a result - if [[ -z $next_sink_description ]]; then - sink_id=$(echo "$next_sink" | jq -r '.properties."object.id"') - next_sink_description=$(wpctl status | grep -E "\s+\*?\s+${sink_id}\." | sed -E 's/^.*[0-9]+\.\s+//' | sed -E 's/\s+\[.*$//') - fi +next_sink=$(jq -c ".[$next_sink_index]" <<<"$sinks") +next_sink_name=$(jq -r '.name' <<<"$next_sink") +next_sink_description=$(jq -r '.description // .properties."device.description" // .name' <<<"$next_sink") +# A tuning sink sits at a fixed 100% and unmuted while real loudness lives on the +# physical sink beneath it, so read the level from whichever sink actually carries +# it or the OSD contradicts the volume keys. +next_sink_effective=$(omarchy-audio-output-sink "$next_sink_name") +next_sink_volume=$(timeout 2 pactl get-sink-volume "$next_sink_effective" 2>/dev/null | + awk 'NR == 1 {for (i = 1; i <= NF; i++) if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}}') +[[ -n $next_sink_volume ]] || next_sink_volume=$(jq -r '.volume | to_entries[0].value.value_percent | sub("%"; "") | tonumber' <<<"$next_sink") +if [[ $(timeout 2 pactl get-sink-mute "$next_sink_effective" 2>/dev/null) == *yes ]]; then + next_sink_is_muted=true +else + next_sink_is_muted=false fi -next_sink_volume=$(echo "$next_sink" | jq -r \ - '.volume | to_entries[0].value.value_percent | sub("%"; "")') -next_sink_is_muted=$(echo "$next_sink" | jq -r '.mute') - -if [[ $next_sink_is_muted = "true" ]] || (( next_sink_volume == 0 )); then +if [[ $next_sink_is_muted == "true" ]] || (( next_sink_volume == 0 )); then icon_state="muted" elif (( next_sink_volume <= 33 )); then icon_state="low" @@ -50,13 +52,8 @@ else icon_state="high" fi -next_sink_volume_icon="sink-volume-${icon_state}-symbolic" - if [[ $next_sink_name != $current_sink_name ]]; then - next_sink_wpid=$(echo "$next_sink" | jq -r '.properties."object.id"') - wpctl set-default "$next_sink_wpid" + omarchy-audio-output-set-default "$(jq -r '.index' <<<"$next_sink")" "$next_sink_name" fi -omarchy-swayosd-client \ - --custom-message "$next_sink_description" \ - --custom-icon "$next_sink_volume_icon" +omarchy-osd -i "volume-${icon_state}" -m "$next_sink_description" diff --git a/bin/omarchy-audio-output-volume b/bin/omarchy-audio-output-volume new file mode 100755 index 0000000000..22a24aa57a --- /dev/null +++ b/bin/omarchy-audio-output-volume @@ -0,0 +1,86 @@ +#!/bin/bash + +# omarchy:summary=Adjust output volume and show the Omarchy OSD +# omarchy:args= +# omarchy:examples=omarchy audio output volume raise | omarchy audio output volume lower | omarchy audio output volume mute-toggle | omarchy audio output volume +1 + +action="${1:-}" + +if [[ -z $action ]]; then + echo "Usage: omarchy-audio-output-volume " + exit 1 +fi + +# Resolve through any DSP sink to the physical one, so the keys always move real +# loudness and the processing always sees full-scale input. Shared with the audio +# panel and the output switcher. +sink="$(omarchy-audio-output-sink)" +if [[ -z $sink ]]; then + echo "Could not resolve an audio sink to control." >&2 + exit 1 +fi + +# pactl reports the same percentage scale wpctl does (both are the raw volume +# over PA_VOLUME_NORM), so the OSD reads identically either way. +volume_percent() { + pactl get-sink-volume "$sink" 2>/dev/null | + awk 'NR == 1 { + for (i = 1; i <= NF; i++) + if ($i ~ /%$/) {sub("%", "", $i); print $i; exit} + }' +} + +volume_muted() { + [[ $(pactl get-sink-mute "$sink" 2>/dev/null) == *yes ]] +} + +case "$action" in + raise) action="+5" ;; + lower) action="-5" ;; +esac + +if [[ $action == "mute-toggle" ]]; then + runtime_dir="${XDG_RUNTIME_DIR:-/tmp}" + debounce_file="$runtime_dir/omarchy-audio-output-volume-mute-toggle.last" + now=$(date +%s%3N) + last=0 + [[ -r $debounce_file ]] && read -r last <"$debounce_file" || true + if ((now - last < 250)); then + exit 0 + fi + printf '%s\n' "$now" >"$debounce_file" + + pactl set-sink-mute "$sink" toggle +elif [[ $action =~ ^([+-])([0-9]+)$ ]]; then + direction="${BASH_REMATCH[1]}" + step="${BASH_REMATCH[2]}" + + current="$(volume_percent)" + if [[ -z $current ]]; then + echo "Could not read volume for $sink." >&2 + exit 1 + fi + + if [[ $direction == "+" ]]; then + next=$((current + step)) + ((next <= 100)) || next=100 + else + next=$((current - step)) + ((next >= 0)) || next=0 + fi + + pactl set-sink-mute "$sink" 0 + pactl set-sink-volume "$sink" "${next}%" +else + echo "Unknown volume action: $action" + exit 1 +fi + +percent=$(volume_percent) +if volume_muted || ((${percent:-0} == 0)); then + icon="volume-muted" +else + icon="volume-high" +fi + +omarchy-osd -i "$icon" -p "${percent:-0}" diff --git a/bin/omarchy-audio-sink-availability b/bin/omarchy-audio-sink-availability new file mode 100755 index 0000000000..531b5bffaf --- /dev/null +++ b/bin/omarchy-audio-sink-availability @@ -0,0 +1,54 @@ +#!/bin/bash + +# omarchy:summary=Print PulseAudio sink availability for the shell +# omarchy:group=audio + +# A speaker tuning is a virtual sink in front of the real speakers. Both exist +# in the graph, but selecting the physical one would only bypass the tuning, so +# report it unavailable and keep it out of the output list. +fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)" + +pactl list sinks 2>/dev/null | awk -v fronted="$fronted" ' + function emit_sink() { + if (name == "") return + if (fronted != "" && name == fronted) { + print name "\t0" + return + } + print name "\t" ((port_count == 0 || available) ? 1 : 0) + } + + /^Sink #/ { + emit_sink() + name = "" + in_ports = 0 + port_count = 0 + available = 0 + next + } + + /^[[:space:]]*Name:/ { + name = $2 + next + } + + /^[[:space:]]*Ports:$/ { + in_ports = 1 + next + } + + in_ports && /^\tActive Port:/ { + in_ports = 0 + next + } + + in_ports && /^\t\t/ { + port_count++ + if ($0 !~ /not available/) available = 1 + next + } + + END { + emit_sink() + } +' diff --git a/bin/omarchy-audio-source-switch b/bin/omarchy-audio-source-switch new file mode 100755 index 0000000000..1b4521330e --- /dev/null +++ b/bin/omarchy-audio-source-switch @@ -0,0 +1,20 @@ +#!/bin/bash + +# omarchy:summary=Cycle to the next media source and transfer playback when the current source is playing +# omarchy:args=[next|previous] +# omarchy:examples=omarchy audio source switch | omarchy-audio-source-switch previous + +direction="${1:-next}" + +case "$direction" in + next) + omarchy-shell media sourceSwitch + ;; + previous) + omarchy-shell media sourceSwitchPrevious + ;; + *) + echo "Usage: omarchy-audio-source-switch [next|previous]" >&2 + exit 1 + ;; +esac diff --git a/bin/omarchy-audio-tuning b/bin/omarchy-audio-tuning new file mode 100755 index 0000000000..75b301df55 --- /dev/null +++ b/bin/omarchy-audio-tuning @@ -0,0 +1,357 @@ +#!/bin/bash + +# omarchy:summary=Manage the speaker tuning for this laptop +# omarchy:args= [--force] +# omarchy:group=audio +# omarchy:examples=omarchy audio tuning status | omarchy audio tuning on | omarchy audio tuning off + +set -uo pipefail + +tunings_dir="$OMARCHY_PATH/default/audio/tunings" +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + +# The tuning is hosted by its own PipeWire client, under its own config name, so +# switching it needs no audio restart -- a restart drops every PulseAudio client's +# connection, and applications that do not reconnect (Spotify) then have to be +# restarted by hand. The name is deliberately not PipeWire's stock +# filter-chain.conf, which merges every fragment in filter-chain.conf.d/ and would +# make this service host unrelated user filters too. +host_config_name=omarchy-speaker-tuning.conf +host_config="$config_home/pipewire/$host_config_name" +host_source="$OMARCHY_PATH/default/audio/filter-chain-host.conf" +fragment="$config_home/pipewire/$host_config_name.d/90-tuning.conf" +unit_name=omarchy-speaker-tuning.service +unit="$config_home/systemd/user/$unit_name" +unit_source="$OMARCHY_PATH/default/systemd/user/$unit_name" + +# Earlier revisions loaded the tuning into the daemon, as a WirePlumber smart +# filter, or into the shared filter-chain.conf.d namespace. Remove all three so +# they cannot be loaded alongside the current one. +stale_daemon="$config_home/pipewire/pipewire.conf.d/90-omarchy-speaker-tuning.conf" +stale_wireplumber="$config_home/wireplumber/wireplumber.conf.d/90-omarchy-speaker-tuning.conf" +stale_shared="$config_home/pipewire/filter-chain.conf.d/90-omarchy-speaker-tuning.conf" + +sink_name=omarchy_speaker_tuning + +action="${1:-status}" +force=0 +[[ ${2:-} == "--force" ]] && force=1 + +sink_matching() { + pactl list sinks short 2>/dev/null | awk -v p="$1" '$2 ~ p {print $2; exit}' +} + +# Dell keys its Cirrus speaker firmware on the DMI product SKU, which makes it the +# most precise identifier available for these machines -- narrower than a product +# name, and it distinguishes models whose names differ only by marketing. Compared +# case-insensitively against an exact SKU, never a substring, so a tuning cannot +# accidentally widen to a whole product line. +sku_matches() { + local sku want + sku="$(cat /sys/class/dmi/id/product_sku 2>/dev/null)" + [[ -n $sku ]] || return 1 + for want in "$@"; do + [[ ${sku,,} == "${want,,}" ]] && return 0 + done + return 1 +} + +dmi_matches() { + local want + for want in "$@"; do + omarchy-hw-match "$want" 2>/dev/null && return 0 + done + return 1 +} + +# Print the tuning directory matching this laptop, if any. Matching is data, not +# code: a tuning declares the DMI string it belongs to and the sink it expects, so +# most tunings can be added as a directory with no new script. A tuning whose +# hardware needs a sharper test can set match_command to any predicate instead. +tuning_match() { + local dir + for dir in "$tunings_dir"/*/; do + [[ -r $dir/tuning.conf ]] || continue + + unset match_dmi match_sku match_command sink_pattern + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + + # Deliberately does not look at the live audio graph. The install hooks run in + # the ISO chroot with no audio server, and a match that depended on a present + # sink would come back empty there -- so the machine would get neither the LV2 + # dependency nor the tuning, and nothing would retry. + # A tuning may list several models it has been validated on. match_dmi and + # match_sku are arrays, so a plain string still works as a single entry. + if [[ -n ${match_command:-} ]]; then + "$match_command" 2>/dev/null || continue + elif [[ -n ${match_sku:-} ]]; then + sku_matches "${match_sku[@]}" || continue + elif [[ -n ${match_dmi:-} ]]; then + dmi_matches "${match_dmi[@]}" || continue + else + continue + fi + + # Required whichever way the tuning matched: the graph's target sink is + # substituted from it, so a tuning without one cannot be installed and must + # not be reported as a match. + [[ -n ${sink_pattern:-} ]] || continue + + printf '%s\n' "${dir%/}" + return 0 + done + return 1 +} + +# The physical sink the matched tuning is built for, taken from the tuning's own +# sink_pattern rather than a hard-coded regex, so hardware with a different sink +# name needs no change here. +tuned_hardware_sink() { + local dir found + dir="$(tuning_match)" || return 1 + unset sink_pattern + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + [[ -n ${sink_pattern:-} ]] || return 1 + found="$(sink_matching "$sink_pattern")" + [[ -n $found ]] || return 1 + printf '%s\n' "$found" +} + +tuning_present() { + pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -x "$sink_name" >/dev/null +} + +# Only real application streams may be moved. A filter-chain's own output is also +# a sink input but carries no application.name, and moving it would rewire the +# tuning itself. +app_streams() { + pactl list sink-inputs 2>/dev/null | awk ' + /^Sink Input #/ {id = substr($3, 2)} + /application\.name = / { + app = $0 + sub(/.*application\.name = "/, "", app) + sub(/"$/, "", app) + if (app != "EasyEffects") print id + }' +} + +move_apps_to() { + local target="$1" id + for id in $(app_streams); do + pactl move-sink-input "$id" "$target" 2>/dev/null || true + done +} + +# WirePlumber can link the output elsewhere if the target is missing when the host +# starts. node.dont-fallback guards against it, but verify rather than assume. +tuning_downstream_sink() { + omarchy-audio-output-sink "$sink_name" 2>/dev/null +} + +easyeffects_running() { + pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -x easyeffects_sink >/dev/null || + pgrep -u "$(id -u)" -x easyeffects >/dev/null 2>&1 || + systemctl --user is-active --quiet easyeffects.service 2>/dev/null +} + +# Unloading a daemon-loaded drop-in is the one case that still needs an audio +# restart, because the daemon only reads its own config at startup. +drop_stale_daemon_config() { + [[ -e $stale_daemon || -e $stale_wireplumber ]] || return 0 + rm -f "$stale_daemon" "$stale_wireplumber" + omarchy-restart-audio >/dev/null 2>&1 + local _ + for _ in {1..40}; do + pactl info >/dev/null 2>&1 && break + sleep 0.25 + done +} + +case "$action" in + match) + tuning_match + ;; + + fronted-sink) + # The tuning is a virtual sink in front of the real speakers, so both exist in + # the graph. Selecting the physical one would only bypass the tuning, so + # callers keep it out of the output list while the tuning is up. This answers + # "is a tuning in place", not "where should volume go" -- for the latter see + # omarchy-audio-output-sink, which follows the current default output. + tuning_present || exit 1 + tuned_hardware_sink + ;; + + status) + if [[ -r $fragment ]]; then + echo "Installed: yes ($fragment)" + else + echo "Installed: no" + fi + # Both is-active and is-enabled print their answer *and* exit non-zero when + # negative, so a "|| echo" fallback prints it twice. + host_state="$(systemctl --user is-active "$unit_name" 2>/dev/null)" + host_enabled="$(systemctl --user is-enabled "$unit_name" 2>/dev/null)" + echo "Host service: ${host_state:-inactive} (${host_enabled:-disabled})" + if tuning_present; then + echo "Tuning sink: present" + else + echo "Tuning sink: absent" + fi + echo "Default sink: $(pactl get-default-sink 2>/dev/null)" + if dir="$(tuning_match)"; then + unset description + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + echo "Matches: ${description:-?} ($(basename "$dir"))" + else + echo "Matches: nothing ships for this laptop" + fi + ;; + + off) + if [[ ! -r $fragment && ! -r $unit && ! -r $stale_daemon && ! -r $stale_wireplumber && + ! -r $stale_shared ]]; then + echo "No speaker tuning installed." + exit 0 + fi + + speakers="$(tuned_hardware_sink)" || speakers="" + + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" "$stale_shared" + rmdir "$config_home/pipewire/$host_config_name.d" 2>/dev/null + systemctl --user daemon-reload >/dev/null 2>&1 + drop_stale_daemon_config + + for _ in {1..20}; do + tuning_present || break + sleep 0.25 + done + + if [[ -n $speakers ]]; then + pactl set-default-sink "$speakers" >/dev/null 2>&1 + # Streams left on the vanished tuning sink reconnect wherever PipeWire puts + # them, which is not necessarily the speakers. + move_apps_to "$speakers" + fi + echo "Speaker tuning removed." + ;; + + on) + [[ -d $tunings_dir ]] || { + echo "No tunings shipped at $tunings_dir" >&2 + exit 1 + } + + selected="$(tuning_match)" || { + echo "No speaker tuning matches this laptop." + exit 0 + } + + unset description sink_pattern + # shellcheck disable=SC1090 + source "$selected/tuning.conf" + + # At first-run the session is up but the sink can still be settling. + for _ in {1..20}; do + speaker_sink="$(sink_matching "$sink_pattern")" + [[ -n $speaker_sink ]] && break + sleep 0.5 + done + [[ -n ${speaker_sink:-} ]] || { + echo "A tuning applies to this laptop but no sink matching $sink_pattern" >&2 + echo "is present, so there is no audio server yet. Re-run after login:" >&2 + echo " omarchy audio tuning on" >&2 + exit 1 + } + + if easyeffects_running; then + cat >&2 <<'EOF' +EasyEffects is running. It moves any stream that follows the default sink to its +own sink, so a tuning installed now would be bypassed. + +Stop it first: systemctl --user disable --now easyeffects.service +EOF + exit 1 + fi + + # Every tuning ends in a limiter, which is an LV2 plugin. Without it the graph + # fails to instantiate and the tuning sink never appears. + ls /usr/lib/lv2/lsp-plugins.lv2/limiter_stereo.ttl >/dev/null 2>&1 || { + echo "lsp-plugins-lv2 is required for the tuning limiter." >&2 + exit 1 + } + + rendered="$(mktemp)" + trap 'rm -f "$rendered"' EXIT + sed "s|@SPEAKER_SINK@|$speaker_sink|g" "$selected/filter-chain.conf" >"$rendered" + + # Everything that makes the tuning current has to match, not just the graph: + # an active-but-disabled service disappears at next login, and a stale unit + # file would shadow later fixes to the shipped one indefinitely. + if ((!force)) && [[ -r $fragment ]] && cmp -s "$rendered" "$fragment" && + [[ -r $host_config ]] && cmp -s "$host_source" "$host_config" && + [[ -r $unit ]] && cmp -s "$unit_source" "$unit" && + systemctl --user is-active --quiet "$unit_name" 2>/dev/null && + systemctl --user is-enabled --quiet "$unit_name" 2>/dev/null && + [[ "$(tuning_downstream_sink)" == "$speaker_sink" ]]; then + echo "Speaker tuning already current: $description" + exit 0 + fi + + drop_stale_daemon_config + + rm -f "$stale_shared" + install -Dm644 "$host_source" "$host_config" + install -Dm644 "$rendered" "$fragment" + install -Dm644 "$unit_source" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + systemctl --user enable "$unit_name" >/dev/null 2>&1 + systemctl --user restart "$unit_name" >/dev/null 2>&1 + echo "Installed speaker tuning: $description" + + for _ in {1..40}; do + tuning_present && break + sleep 0.25 + done + if ! tuning_present; then + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + echo "Tuning sink never appeared, so it was removed. Audio is untouched." >&2 + echo "Check: systemctl --user status $unit_name" >&2 + exit 1 + fi + + # Confirm the output really landed on the sink this tuning was measured for. + for _ in {1..20}; do + [[ "$(tuning_downstream_sink)" == "$speaker_sink" ]] && break + sleep 0.25 + done + downstream="$(tuning_downstream_sink)" + if [[ $downstream != "$speaker_sink" ]]; then + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + echo "The tuning output linked to ${downstream:-nothing} instead of" >&2 + echo "$speaker_sink, so it was removed rather than left tuning the wrong" >&2 + echo "device. Audio is untouched." >&2 + exit 1 + fi + + pactl set-default-sink "$sink_name" >/dev/null 2>&1 + # A default sink only captures newly created streams, so anything already + # playing would keep bypassing the tuning until its app was restarted. + move_apps_to "$sink_name" + + echo "Speakers now play through the tuning." + ;; + + *) + echo "Usage: omarchy-audio-tuning [--force]" >&2 + exit 2 + ;; +esac diff --git a/bin/omarchy-bar b/bin/omarchy-bar new file mode 100755 index 0000000000..2a29c634fb --- /dev/null +++ b/bin/omarchy-bar @@ -0,0 +1,403 @@ +#!/bin/bash + +# omarchy:summary=Configure the bar and its widget layout +# omarchy:group=bar +# omarchy:args=use | reset | defaults | position | transparent | put [placement] | move [placement] | set [--json] [placement] +# omarchy:examples=omarchy bar use local.neon-bar | omarchy bar put omarchy.keyboard-layout --after omarchy.clock | omarchy bar move omarchy.clock --section center --index 0 | omarchy bar set omarchy.clock format HH:mm + +set -euo pipefail + +source omarchy-shell-config + +usage() { + cat < [args...] + + use Use a bar option as the active bar + reset Return to the built-in Omarchy bar + defaults Restore the default bar and service widgets + position Bar position + transparent Bar transparency + put [placement] Put a widget on the bar, leaving one + that is already there where it is + move [placement] Move a widget within or between sections + set [--json] [placement] + Set a per-widget option + +Placement: + --section Target section + --index Target index + --before Insert before a widget + --after Insert after a widget + --from-section
Source section + --from-index Source index + +Enable and disable widgets with 'omarchy plugin enable' and +'omarchy plugin disable'. + +'put' places a widget the way 'plugin enable' does, but leaves one that is +already on the bar where it is, and falls back to the widget's usual spot when +--before / --after names a widget the bar does not carry. + +Examples: + omarchy bar use local.neon-bar + omarchy bar put omarchy.keyboard-layout --after omarchy.clock + omarchy bar move omarchy.media left + omarchy bar move omarchy.clock --section center --index 0 + omarchy bar set omarchy.clock format HH:mm +USAGE +} + +# ------------------------------------------------------------------ validation + +bar_option_exists() { + omarchy-plugin-catalog | jq -e --arg id "$1" ' + any(.[]; (.kinds | index("bar")) and .barPath != null and .id == $id) + ' >/dev/null +} + +validate_section() { + [[ $1 =~ ^(left|center|right)$ ]] || fail "section must be left, center, or right" +} + +validate_index() { + [[ $1 =~ ^[0-9]+$ ]] || fail "index must be a non-negative integer" +} + +# --------------------------------------------------------------------- placement + +PLACEMENT_SECTION="" +PLACEMENT_INDEX="" +PLACEMENT_BEFORE="" +PLACEMENT_AFTER="" +PLACEMENT_FROM_SECTION="" +PLACEMENT_FROM_INDEX="" + +parse_placement() { + while (( $# > 0 )); do + case "$1" in + --section) + PLACEMENT_SECTION="${2:-}" + validate_section "$PLACEMENT_SECTION" + shift 2 + ;; + --index) + PLACEMENT_INDEX="${2:-}" + validate_index "$PLACEMENT_INDEX" + shift 2 + ;; + --before) + PLACEMENT_BEFORE="${2:-}" + [[ -n $PLACEMENT_BEFORE ]] || fail "--before requires a widget id" + shift 2 + ;; + --after) + PLACEMENT_AFTER="${2:-}" + [[ -n $PLACEMENT_AFTER ]] || fail "--after requires a widget id" + shift 2 + ;; + --from-section) + PLACEMENT_FROM_SECTION="${2:-}" + validate_section "$PLACEMENT_FROM_SECTION" + shift 2 + ;; + --from-index) + PLACEMENT_FROM_INDEX="${2:-}" + validate_index "$PLACEMENT_FROM_INDEX" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac + done + + [[ -z $PLACEMENT_BEFORE || -z $PLACEMENT_AFTER ]] || fail "use only one of --before or --after" +} + +placement_json() { + jq -cn \ + --arg section "$PLACEMENT_SECTION" \ + --arg index "$PLACEMENT_INDEX" \ + --arg before "$PLACEMENT_BEFORE" \ + --arg after "$PLACEMENT_AFTER" \ + --arg fromSection "$PLACEMENT_FROM_SECTION" \ + --arg fromIndex "$PLACEMENT_FROM_INDEX" ' + {} + + (if $section == "" then {} else {section: $section} end) + + (if $index == "" then {} else {index: ($index | tonumber)} end) + + (if $before == "" then {} else {before: $before} end) + + (if $after == "" then {} else {after: $after} end) + + (if $fromSection == "" then {} else {fromSection: $fromSection} end) + + (if $fromIndex == "" then {} else {fromIndex: ($fromIndex | tonumber)} end) + ' +} + +# -------------------------------------------------------------------- commands + +cmd_use() { + local plugin="${1:-}" + [[ -n $plugin ]] || fail "bar option id is required" + (( $# == 1 )) || fail "use takes a single bar option id" + if [[ $plugin == "default" || $plugin == "built-in" ]]; then + plugin="omarchy.bar" + fi + bar_option_exists "$plugin" || fail "$plugin is not a known bar option; run 'omarchy plugin list'" + + if [[ $plugin == "omarchy.bar" ]]; then + commit "$NORMALIZE | del(.bar.id)" + else + commit "$NORMALIZE | .bar.id = \$plugin" --arg plugin "$plugin" + fi + echo "Using $plugin as the active bar" +} + +cmd_defaults() { + (( $# == 0 )) || fail "defaults does not take arguments" + + local optional_widgets="[]" + local service widget + for service in dropbox tailscale; do + if "omarchy-installed-service-$service"; then + widget=$(jq -cn \ + --arg id "omarchy.$service" \ + --arg section "$(bar_widget_default_section "omarchy.$service")" \ + '{id: $id, section: $section}') + optional_widgets=$(jq -c --argjson widget "$widget" '. + [$widget]' <<<"$optional_widgets") + fi + done + + # This remains one file mutation so it also works during the headless + # Quattro upgrade and cannot race the shell's in-memory config. + commit "$NORMALIZE + | .bar = \$defaults[0].bar + | def entry_id: if type == \"object\" then (.id // \"\" | tostring) else tostring end; + def anchor_for(\$section): { left: \"omarchy.workspaces\", center: \"omarchy.weather\", right: \"omarchy.tray\" }[\$section]; + reduce \$widgets[] as \$widget (.; + .bar.layout.left = (.bar.layout.left | map(select(entry_id != \$widget.id))) + | .bar.layout.center = (.bar.layout.center | map(select(entry_id != \$widget.id))) + | .bar.layout.right = (.bar.layout.right | map(select(entry_id != \$widget.id))) + | (.bar.layout[\$widget.section] | map(entry_id) | index(anchor_for(\$widget.section))) as \$anchor + | (\$anchor | if . == null then (.bar.layout[\$widget.section] | length) else . + 1 end) as \$index + | .bar.layout[\$widget.section] = ( + .bar.layout[\$widget.section][0:\$index] + + [{id: \$widget.id}] + + .bar.layout[\$widget.section][\$index:] + ) + ) + " \ + --slurpfile defaults "$DEFAULTS_FILE" \ + --argjson widgets "$optional_widgets" + echo "Restored the default Omarchy bar" +} + +cmd_position() { + local position="${1:-}" + [[ -n $position ]] || fail "position is required" + (( $# == 1 )) || fail "position takes a single value" + [[ $position =~ ^(top|bottom|left|right)$ ]] || fail "position must be top, bottom, left, or right" + commit "$NORMALIZE | .bar.position = \$position" --arg position "$position" + echo "Bar position set to $position" +} + +cmd_transparent() { + local transparent="${1:-}" + [[ -n $transparent ]] || fail "transparent is required" + (( $# == 1 )) || fail "transparent takes a single value" + [[ $transparent =~ ^(true|false|toggle)$ ]] || fail "transparent must be true, false, or toggle" + if [[ $transparent == "toggle" ]]; then + commit "$NORMALIZE | .bar.transparent = (.bar.transparent != true)" + echo "Bar transparency toggled" + else + commit "$NORMALIZE | .bar.transparent = \$transparent" --argjson transparent "$transparent" + echo "Bar transparency set to $transparent" + fi +} + +bar_widget_default_section() { + local catalog + catalog=$(omarchy-plugin-catalog 2>/dev/null) || { + echo "center" + return 0 + } + jq -r --arg id "$1" ' + map(select(.id == $id))[0].barWidget.defaultSection // "center" + | if IN("left", "center", "right") then . else "center" end + ' <<<"$catalog" +} + +# Asks the shell to place a widget, waiting out one still coming up. Answers 0 +# with the reply in PUT_RESULT, 1 when there was no shell to ask. Only an +# absent shell is carried on from: omarchy-migrate marks a 0 return as done. +PUT_RESULT="" +SHELL_ANSWERED=0 +ask_to_put() { + local id="$1" placement="$2" attempt absent=0 + for (( attempt = 0; attempt < ${OMARCHY_SHELL_READY_ATTEMPTS:-50}; attempt++ )); do + if PUT_RESULT=$(omarchy-shell shell putBarWidget "$id" "$placement" 2>&1); then + SHELL_ANSWERED=1 + [[ $PUT_RESULT == "not ready" ]] || return 0 + elif [[ $PUT_RESULT == *"not ready"* ]]; then + SHELL_ANSWERED=1 + elif [[ $PUT_RESULT == *"is not running"* ]]; then + # Answered once and now gone: it stopped mid-request. + if (( SHELL_ANSWERED )); then + fail "omarchy-shell did not become ready; $id was not put on the bar" + fi + # A shell being spawned has no socket yet, and nothing says a launch is + # under way, so give one a few seconds to turn up. + if (( ++absent >= ${OMARCHY_SHELL_ABSENT_ATTEMPTS:-30} )); then + echo "omarchy-shell is not running; $id was not put on the bar" >&2 + return 1 + fi + else + fail "could not put $id on the bar: $PUT_RESULT" + fi + sleep 0.1 + done + fail "omarchy-shell did not become ready; $id was not put on the bar" +} + +# Placement lives in the shell, which owns the config it has in memory. Putting +# a widget therefore asks the shell rather than editing the file behind it. +cmd_put() { + local id="${1:-}" + [[ -n $id ]] || fail "put requires a widget id" + shift + + local positional_section="" + if (( $# > 0 )) && [[ $1 != --* ]]; then + positional_section="$1" + validate_section "$positional_section" + shift + fi + + parse_placement "$@" + [[ -z $positional_section || -z $PLACEMENT_SECTION ]] || + fail "specify a section positionally or with --section, not both" + [[ -z $positional_section ]] || PLACEMENT_SECTION="$positional_section" + [[ -z $PLACEMENT_FROM_SECTION && -z $PLACEMENT_FROM_INDEX ]] || + fail "put does not accept --from-section or --from-index" + + local placement + placement=$(placement_json) + ask_to_put "$id" "$placement" || return 0 + + # An update runs migrations before it restarts the shell, so this one can + # predate the fallback. Ask it again without the neighbour it cannot find. + if [[ $PUT_RESULT == "could not find target widget"* ]]; then + PLACEMENT_BEFORE="" + PLACEMENT_AFTER="" + ask_to_put "$id" "$(placement_json)" || return 0 + fi + + [[ $PUT_RESULT != "unknown" ]] || fail "$id is not a known widget; run 'omarchy plugin list'" + [[ $PUT_RESULT == "ok" ]] || fail "$PUT_RESULT" + # Says nothing about whether it had to be placed: a widget already on the bar + # is left where it is, and both outcomes are the same answer to the caller. + echo "$id is on the bar" +} + +cmd_move() { + local id="${1:-}" + [[ -n $id ]] || fail "move requires a widget id" + shift + + local positional_section="" + if (( $# > 0 )) && [[ $1 != --* ]]; then + positional_section="$1" + validate_section "$positional_section" + shift + fi + + parse_placement "$@" + [[ -z $positional_section || -z $PLACEMENT_SECTION ]] || + fail "specify a section positionally or with --section, not both" + [[ -z $positional_section || -z $PLACEMENT_INDEX ]] || + fail "specify a section positionally or use --index, not both" + [[ -z $positional_section || -z $PLACEMENT_BEFORE ]] || + fail "specify a section positionally or use --before, not both" + [[ -z $positional_section || -z $PLACEMENT_AFTER ]] || + fail "specify a section positionally or use --after, not both" + [[ -z $positional_section ]] || PLACEMENT_SECTION="$positional_section" + + local result + result=$(omarchy-shell shell moveBarWidget "$id" "$(placement_json)") + [[ $result == "ok" ]] || fail "$result" + echo "Moved $id" +} + +cmd_set() { + local id="${1:-}" + local key="${2:-}" + local value="${3:-}" + [[ -n $id ]] || fail "set requires a widget id" + [[ -n $key ]] || fail "set requires a setting key" + (( $# >= 3 )) || fail "set requires a value" + shift 3 + + local value_is_json="false" + if (( $# > 0 )) && [[ $1 == "--json" ]]; then + value_is_json="true" + shift + fi + parse_placement "$@" + [[ -z $PLACEMENT_BEFORE && -z $PLACEMENT_AFTER ]] || + fail "set does not accept --before or --after" + + local value_json + if [[ $value_is_json == "true" ]]; then + value_json=$(jq -cn --argjson value "$value" '$value') || + fail "invalid JSON value: $value" + else + value_json=$(jq -cn --arg value "$value" '$value') + fi + + local result + result=$(omarchy-shell shell setBarWidget "$id" "$key" "$value_json" "$(placement_json)") + [[ $result == "ok" ]] || fail "$result" + echo "Set $key on $id" +} + +# --------------------------------------------------------------------- dispatch + +command="${1:-}" +(( $# > 0 )) && shift || true + +case "$command" in + use) + cmd_use "$@" + ;; + reset) + (( $# == 0 )) || fail "reset does not take arguments" + cmd_use omarchy.bar + ;; + defaults) + cmd_defaults "$@" + ;; + position) + cmd_position "$@" + ;; + transparent) + cmd_transparent "$@" + ;; + put) + cmd_put "$@" + ;; + move) + cmd_move "$@" + ;; + set) + cmd_set "$@" + ;; + -h | --help | help | "") + usage + ;; + *) + fail "unknown command: $command" + ;; +esac diff --git a/bin/omarchy-bar-text-color b/bin/omarchy-bar-text-color new file mode 100755 index 0000000000..a2ce8cdef1 --- /dev/null +++ b/bin/omarchy-bar-text-color @@ -0,0 +1,125 @@ +#!/bin/bash + +# omarchy:summary=Choose a legible transparent bar text color +# omarchy:hidden=true + +set -e + +position=${1:-top} +bar_size=${2:-} +text_color=${3:-} +background_color=${4:-} +background_path="" +screen_size="" + +shift 4 2>/dev/null || true +while (($# > 0)); do + case "$1" in + --background) + background_path=${2:-} + shift 2 + ;; + --screen) + screen_size=${2:-} + shift 2 + ;; + *) + shift + ;; + esac +done + +valid_hex() { + [[ $1 =~ ^#[0-9A-Fa-f]{6}$ ]] +} + +fallback() { + printf '%s\n' "$text_color" + exit 0 +} + +contrast() { + local color="$1" + local sample="$2" + + awk -v fg="$color" -v bg="$sample" ' + function channel(hex, start) { + return strtonum("0x" substr(hex, start, 2)) / 255 + } + function linear(c) { + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ^ 2.4 + } + function luminance(hex, r, g, b) { + r = linear(channel(hex, 2)) + g = linear(channel(hex, 4)) + b = linear(channel(hex, 6)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + BEGIN { + l1 = luminance(fg) + l2 = luminance(bg) + if (l1 < l2) { + t = l1 + l1 = l2 + l2 = t + } + printf "%.6f\n", (l1 + 0.05) / (l2 + 0.05) + } + ' +} + +valid_hex "$text_color" || fallback +valid_hex "$background_color" || fallback +[[ $position =~ ^(top|bottom|left|right)$ ]] || fallback +[[ $bar_size =~ ^[0-9]+$ ]] || fallback +omarchy-cmd-present magick || fallback + +if [[ -z $background_path ]]; then + background_path=$(readlink -f "$HOME/.local/state/omarchy/current/background" 2>/dev/null || true) +fi +[[ -f $background_path ]] || fallback + +if [[ -z $screen_size ]]; then + if omarchy-cmd-present hyprctl && omarchy-cmd-present jq; then + screen_size=$(hyprctl monitors -j 2>/dev/null | jq -r '.[0] | "\(.width)x\(.height)"' 2>/dev/null || true) + fi +fi +[[ $screen_size =~ ^([0-9]+)x([0-9]+)$ ]] || fallback + +screen_width=${BASH_REMATCH[1]} +screen_height=${BASH_REMATCH[2]} +((screen_width > 0 && screen_height > 0 && bar_size > 0)) || fallback + +case "$position" in +top) + crop="${screen_width}x${bar_size}+0+0" + ;; +bottom) + crop_y=$((screen_height - bar_size)) + ((crop_y >= 0)) || fallback + crop="${screen_width}x${bar_size}+0+${crop_y}" + ;; +left) + crop="${bar_size}x${screen_height}+0+0" + ;; +right) + crop_x=$((screen_width - bar_size)) + ((crop_x >= 0)) || fallback + crop="${bar_size}x${screen_height}+${crop_x}+0" + ;; +esac + +pixel=$(magick "$background_path" -auto-orient \ + -resize "${screen_width}x${screen_height}^" \ + -gravity center -extent "${screen_width}x${screen_height}" \ + -gravity NorthWest -crop "$crop" +repage \ + -resize '1x1!' -format '%[fx:int(255*r)],%[fx:int(255*g)],%[fx:int(255*b)]' info:- 2>/dev/null || true) +[[ $pixel =~ ^([0-9]+),([0-9]+),([0-9]+)$ ]] || fallback + +sample=$(printf '#%02x%02x%02x' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}") +text_contrast=$(contrast "$text_color" "$sample") +background_contrast=$(contrast "$background_color" "$sample") + +awk -v text="$text_contrast" -v background="$background_contrast" 'BEGIN { exit !(background > text) }' \ + && printf '%s\n' "$background_color" \ + || printf '%s\n' "$text_color" diff --git a/bin/omarchy-battery-capacity b/bin/omarchy-battery-capacity deleted file mode 100755 index 10e54794e4..0000000000 --- a/bin/omarchy-battery-capacity +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Returns the battery full capacity in Wh (rounded to whole number). - -battery_info=$(upower -i $(upower -e | grep BAT)) - -echo "$battery_info" | awk '/energy-full:/ { - printf "%d", $2 - exit -}' diff --git a/bin/omarchy-battery-low b/bin/omarchy-battery-low new file mode 100755 index 0000000000..d107d4f8dc --- /dev/null +++ b/bin/omarchy-battery-low @@ -0,0 +1,17 @@ +#!/bin/bash + +# omarchy:summary=Send the low battery warning notification and run battery-low hooks. +# omarchy:args= +# omarchy:hidden=true + +set -euo pipefail + +if (($# != 1)); then + echo "Usage: omarchy-battery-low " >&2 + exit 1 +fi + +level=$1 + +omarchy-notification-send -g 󱐋 -u critical "Time to recharge!" "Battery is down to ${level}%" -i battery-caution -t 30000 +omarchy-hook battery-low "$level" diff --git a/bin/omarchy-battery-monitor b/bin/omarchy-battery-monitor deleted file mode 100755 index 1238791eb0..0000000000 --- a/bin/omarchy-battery-monitor +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Designed to be run by systemd timer every 30 seconds and alerts if battery is low -# omarchy:hidden=true - -BATTERY_THRESHOLD=10 -NOTIFICATION_FLAG="/run/user/$UID/omarchy_battery_notified" -BATTERY_LEVEL=$(omarchy-battery-remaining) -BATTERY_STATE=$(upower -i $(upower -e | grep 'BAT') | grep -E "state" | awk '{print $2}') - -send_notification() { - notify-send -u critical "󱐋 Time to recharge!" "Battery is down to ${1}%" -i battery-caution -t 30000 - omarchy-hook battery-low "$1" -} - -if [[ -n $BATTERY_LEVEL && $BATTERY_LEVEL =~ ^[0-9]+$ ]]; then - if [[ $BATTERY_STATE == "discharging" ]] && (( BATTERY_LEVEL <= BATTERY_THRESHOLD )); then - if [[ ! -f $NOTIFICATION_FLAG ]]; then - send_notification $BATTERY_LEVEL - touch $NOTIFICATION_FLAG - fi - else - rm -f $NOTIFICATION_FLAG - fi -fi diff --git a/bin/omarchy-battery-remaining b/bin/omarchy-battery-remaining deleted file mode 100755 index d9b451f2c9..0000000000 --- a/bin/omarchy-battery-remaining +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Returns the battery percentage remaining as an integer. - -upower -i $(upower -e | grep BAT) | awk '/percentage/ { - print int($2) - exit -}' diff --git a/bin/omarchy-battery-remaining-time b/bin/omarchy-battery-remaining-time deleted file mode 100755 index 7aa77b11f9..0000000000 --- a/bin/omarchy-battery-remaining-time +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Returns the battery time remaining (to empty or full) in a compact format. - -battery_info=$(upower -i $(upower -e | grep BAT)) - -echo "$battery_info" | awk '/time to (empty|full)/ { - value = $4 - unit = $5 - if (unit ~ /^minute/) { - printf "%dm", int(value) - } else { - hours = int(value) - minutes = int((value - hours) * 60) - if (minutes > 0) { - printf "%dh %dm", hours, minutes - } else { - printf "%dh", hours - } - } - exit -}' diff --git a/bin/omarchy-battery-status b/bin/omarchy-battery-status index 0390df1f9b..cc0a6024fb 100755 --- a/bin/omarchy-battery-status +++ b/bin/omarchy-battery-status @@ -1,27 +1,137 @@ #!/bin/bash # omarchy:summary=Returns a formatted battery status string with percentage and power draw/charge. +# omarchy:args=[--shell] -battery_info=$(upower -i $(upower -e | grep BAT)) +shell_output=false +power_supply_path="${OMARCHY_POWER_SUPPLY_PATH:-/sys/class/power_supply}" -percentage=$(echo "$battery_info" | awk '/percentage/ { - print int($2) - exit -}') +case "${1:-}" in + "") + ;; + --shell) + shell_output=true + ;; + *) + echo "Usage: omarchy-battery-status [--shell]" >&2 + exit 2 + ;; +esac + +battery=$(upower -e 2>/dev/null | grep BAT | head -n 1) +[[ -z $battery ]] && exit 0 + +battery_info=$(upower -i "$battery") + +percentage=$(awk '/percentage/ { print int($2); exit }' <<<"$battery_info") +capacity=$(awk '/energy-full:/ { printf "%d", $2; exit }' <<<"$battery_info") +time_remaining=$(awk '/time to (empty|full)/ { + value = $4 + unit = $5 + if (unit ~ /^minute/) { + printf "%dm", int(value) + } else { + hours = int(value) + minutes = int((value - hours) * 60) + if (minutes > 0) { + printf "%dh %dm", hours, minutes + } else { + printf "%dh", hours + } + } + exit +}' <<<"$battery_info") +power_rate_raw=$(awk '/energy-rate/ { print $2; exit }' <<<"$battery_info") +native_path=$(awk '/native-path/ { print $2; exit }' <<<"$battery_info") +battery_path="$power_supply_path/$native_path" + +# UPower's energy-rate can lag the kernel telemetry by tens of seconds. Use +# the instantaneous sysfs reading when available so the open panel stays live. +if [[ -r $battery_path/power_now ]]; then + power_rate_raw=$(awk -v microwatts="$(<"$battery_path/power_now")" 'BEGIN { print microwatts / 1000000 }') +elif [[ -r $battery_path/current_now && -r $battery_path/voltage_now ]]; then + power_rate_raw=$(awk \ + -v microamps="$(<"$battery_path/current_now")" \ + -v microvolts="$(<"$battery_path/voltage_now")" \ + 'BEGIN { print microamps * microvolts / 1000000000000 }') +fi -power_rate=$(echo "$battery_info" | awk '/energy-rate/ { - rounded = sprintf("%.1f", $2) - sub(/\.0$/, "", rounded) - print rounded - exit +power_rate=$(awk -v rate="${power_rate_raw:-0}" 'BEGIN { + rounded = sprintf("%.1f", rate) + sub(/\.0$/, "", rounded) + print rounded }') +state=$(awk '/state/ { print $2; exit }' <<<"$battery_info") +threshold_start=$(awk '/charge-start-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info") +threshold_end=$(awk '/charge-end-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info") + +[[ -z $threshold_end ]] && threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1) +[[ -z $threshold_start ]] && threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1) + +ac_online=false +for supply in "$power_supply_path"/*; do + [[ -r $supply/type ]] || continue + [[ $(<"$supply/type") == "Mains" ]] || continue + [[ -r $supply/online ]] || continue + + if [[ $(<"$supply/online") == "1" ]]; then + ac_online=true + break + fi +done + +charge_idle=false +if awk -v rate="${power_rate_raw:-0}" 'BEGIN { exit !(rate <= 0.2) }'; then + charge_idle=true +fi + +charge_holding=false +if [[ $ac_online == "true" && -n $threshold_end ]]; then + if [[ $state == "pending-charge" ]]; then + charge_holding=true + elif [[ $state == "fully-charged" ]] && (( percentage < 99 )); then + charge_holding=true + elif [[ $state == "charging" && $charge_idle == "true" ]] && (( threshold_end < 99 && percentage >= threshold_end )); then + charge_holding=true + fi +fi + +if [[ $shell_output == "true" ]]; then + printf 'percentage\t%s\n' "${percentage}%" + if [[ $charge_holding == "true" ]]; then + printf 'state\tholding\n' + else + printf 'state\t%s\n' "$state" + fi + printf 'rate\t%s\n' "${power_rate}W" + printf 'size\t%s\n' "${capacity}Wh" + printf 'time\t%s\n' "$time_remaining" + + cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1) + + [[ -n $cycles ]] && printf 'cycles\t%s\n' "$cycles" + + if [[ -n $threshold_end ]]; then + if [[ -n $threshold_start && $threshold_start != $threshold_end ]]; then + printf 'threshold\t%s-%s%%\n' "$threshold_start" "$threshold_end" + else + printf 'threshold\t%s%%\n' "$threshold_end" + fi + fi + + exit 0 +fi -state=$(echo "$battery_info" | awk '/state/ { print $2; exit }') -time_remaining=$(omarchy-battery-remaining-time) -capacity=$(omarchy-battery-capacity) +if [[ $charge_holding == "true" ]]; then + if [[ -n $threshold_start && $threshold_start != $threshold_end ]]; then + threshold_label="${threshold_start}-${threshold_end}%" + else + threshold_label="${threshold_end}%" + fi -if [[ $state == "charging" ]]; then - echo "󰁹 Battery ${percentage}% · ${time_remaining} to full ·  ${power_rate}W / ${capacity}Wh" + echo "Battery ${percentage}% · Holding at ${threshold_label} · ${power_rate}W / ${capacity}Wh" +elif [[ $state == "charging" ]]; then + echo "Battery ${percentage}% · ${time_remaining} to full ·  ${power_rate}W / ${capacity}Wh" else - echo "󰁹 Battery ${percentage}% · ${time_remaining} left ·  ${power_rate}W / ${capacity}Wh" + echo "Battery ${percentage}% · ${time_remaining} left ·  ${power_rate}W / ${capacity}Wh" fi diff --git a/bin/omarchy-bluetooth-device b/bin/omarchy-bluetooth-device new file mode 100755 index 0000000000..d1f0b3705e --- /dev/null +++ b/bin/omarchy-bluetooth-device @@ -0,0 +1,52 @@ +#!/bin/bash + +# omarchy:summary=Control a Bluetooth device +# omarchy:group=bluetooth +# omarchy:args=[pair|connect|disconnect|forget]
+# omarchy:examples=omarchy bluetooth device connect 00:11:22:33:44:55 + +set -e + +usage() { + echo "Usage: omarchy-bluetooth-device [pair|connect|disconnect|forget]
" >&2 + exit 1 +} + +action=${1:-} +address=${2:-} + +[[ $action == "pair" || $action == "connect" || $action == "disconnect" || $action == "forget" ]] || usage +[[ $address =~ ^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$ ]] || usage + +power_on() { + [[ $(timeout 2s bluetoothctl show 2>/dev/null) == *"Powered: yes"* ]] && return + # Not bluetoothctl directly: Bluetooth is turned off by an rfkill soft block, + # and BlueZ refuses to power an adapter up while one is set. + omarchy-bluetooth-power on || true +} + +trust_device() { + bluetoothctl trust "$address" >/dev/null 2>&1 || true +} + +case "$action" in + pair) + power_on + timeout 20s bluetoothctl pair "$address" >/dev/null 2>&1 || true + trust_device + timeout 20s bluetoothctl connect "$address" >/dev/null 2>&1 || true + ;; + connect) + power_on + trust_device + timeout 20s bluetoothctl connect "$address" >/dev/null 2>&1 || true + ;; + disconnect) + timeout 10s bluetoothctl disconnect "$address" >/dev/null 2>&1 || true + ;; + forget) + power_on + timeout 10s bluetoothctl disconnect "$address" >/dev/null 2>&1 || true + timeout 10s bluetoothctl remove "$address" >/dev/null 2>&1 || true + ;; +esac diff --git a/bin/omarchy-bluetooth-power b/bin/omarchy-bluetooth-power new file mode 100755 index 0000000000..7f30533beb --- /dev/null +++ b/bin/omarchy-bluetooth-power @@ -0,0 +1,89 @@ +#!/bin/bash + +# omarchy:summary=Turn Bluetooth on or off, remembered across reboots +# omarchy:group=bluetooth +# omarchy:args= + +# BlueZ never persists an adapter's Powered property, so turning Bluetooth off +# through bluetoothctl lasts only until the next boot. The rfkill soft block does +# persist: systemd-rfkill saves every switch under /var/lib/systemd/rfkill and +# restores it early on the next boot, which is its entire job. Blocking is also +# what the kernel hands every radio at once, so a machine with two controllers +# gets both, where bluetoothctl only ever addresses the default one. +# +# So the block is the state, and BlueZ follows it: unblocking leaves AutoEnable +# at its stock default and bluetoothd powers the adapter up by itself. Every +# Omarchy path that turns Bluetooth on or off goes through here, because a plain +# `bluetoothctl power on` fails outright while the block is set. + +POWER_WAIT_SECONDS=${OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS:-2} + +controllers() { + timeout 2s bluetoothctl list 2>/dev/null | awk '{print $2}' +} + +# Any controller counts. The block is all-or-nothing across the radios, so the +# state has to be read the same way; a bare `bluetoothctl show` would report the +# default controller and miss a powered dongle sitting behind it. +powered() { + local controller + + for controller in $(controllers); do + [[ $(timeout 2s bluetoothctl show "$controller" 2>/dev/null) == *"Powered: yes"* ]] && return 0 + done + + return 1 +} + +# One deadline around the whole wait rather than a fixed number of probes: every +# probe can sit on its own timeout when D-Bus is wedged, and counting probes then +# stretches a two-second wait into half a minute. +wait_powered() { + local deadline=$((SECONDS + POWER_WAIT_SECONDS)) + + while :; do + powered && return 0 + ((SECONDS < deadline)) || return 1 + sleep 0.2 + done +} + +power_on() { + rfkill unblock bluetooth + + # Usually all it takes: with AutoEnable at its default, bluetoothd powers the + # adapter up on its own once the block is gone. It will not do that for an + # adapter powered down without a block, so ask directly before giving up. + wait_powered && return 0 + + timeout 5s bluetoothctl power on >/dev/null 2>&1 + wait_powered && return 0 + + echo "omarchy-bluetooth-power: adapter did not come up" >&2 + return 1 +} + +case "${1:-}" in + on) + power_on + ;; + off) + # No bluetoothctl power off to go with this: the block already drops the + # adapter to Powered: no, and it is the half that survives the reboot. + rfkill block bluetooth + ;; + toggle) + if powered; then + rfkill block bluetooth + else + power_on + fi + ;; + is-on) + powered + ;; + *) + echo "Usage: omarchy-bluetooth-power " >&2 + exit 1 + ;; +esac diff --git a/bin/omarchy-branch-set b/bin/omarchy-branch-set deleted file mode 100755 index 931bca25f5..0000000000 --- a/bin/omarchy-branch-set +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Set the branch for Omarchy's git repository. -# omarchy:args= - -if (($# == 0)); then - echo "Usage: omarchy-branch-set [master|rc|dev]" - exit 1 -else - branch="$1" -fi - -if [[ $branch != "master" && $branch != "rc" && $branch != "dev" ]]; then - echo "Error: Invalid branch '$branch'. Must be one of: master, rc, dev" - exit 1 -fi - -git -C $OMARCHY_PATH switch $branch diff --git a/bin/omarchy-branding-about b/bin/omarchy-branding-about index aca234de64..a0dad2e212 100755 --- a/bin/omarchy-branding-about +++ b/bin/omarchy-branding-about @@ -10,8 +10,8 @@ set -euo pipefail case "${1:-}" in image) - image=$(omarchy-menu-file "Logo image" "$HOME" "svg png") - if [[ -n $image ]] && omarchy-transcode-ascii "$image" ~/.config/omarchy/branding/about.txt --width 54 --height 26 --mode block; then + image=$(omarchy-file-select --title "Pick PNG or SVG for About" --extensions "png svg") + if omarchy-transcode-ascii "$image" ~/.config/omarchy/branding/about.txt --width 54 --height 26; then omarchy-launch-about >/dev/null 2>&1 fi ;; diff --git a/bin/omarchy-branding-about-animation b/bin/omarchy-branding-about-animation new file mode 100755 index 0000000000..460f04e00f --- /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-branding-screensaver b/bin/omarchy-branding-screensaver index 57012af81d..cf031a6117 100755 --- a/bin/omarchy-branding-screensaver +++ b/bin/omarchy-branding-screensaver @@ -10,8 +10,8 @@ set -euo pipefail case "${1:-}" in image) - image=$(omarchy-menu-file "Logo image" "$HOME" "svg png") - if [[ -n $image ]] && omarchy-transcode-ascii "$image" ~/.config/omarchy/branding/screensaver.txt; then + image=$(omarchy-file-select --title "Pick PNG or SVG for screensaver" --extensions "png svg") + if omarchy-transcode-ascii "$image" ~/.config/omarchy/branding/screensaver.txt; then omarchy-launch-screensaver force >/dev/null 2>&1 fi ;; diff --git a/bin/omarchy-brightness-display b/bin/omarchy-brightness-display index 193bdb1b4d..a6a1e8304b 100755 --- a/bin/omarchy-brightness-display +++ b/bin/omarchy-brightness-display @@ -1,60 +1,122 @@ #!/bin/bash -# omarchy:summary=Adjust brightness on the most likely display device. -# omarchy:args=<+N%|N%-|N%|off|on> -# omarchy:examples=omarchy brightness display +5% | omarchy brightness display 5%- | omarchy brightness display 50% | omarchy brightness display off | omarchy brightness display on +# omarchy:summary=Show or adjust brightness on the focused display. +# omarchy:args=[--no-osd] [--monitor name] [+N%|N%-|N%|off|on] +# omarchy:examples=omarchy brightness display | omarchy brightness display +5% | omarchy brightness display --monitor DP-1 50% | omarchy brightness display off | omarchy brightness display on -step="${1:-+5%}" +no_osd=0 +monitor="" -# Start with the first possible output, then refine to the most likely given an order heuristic. -device="$(ls -1 /sys/class/backlight 2>/dev/null | head -n1)" -for candidate in amdgpu_bl* intel_backlight acpi_video*; do - if [[ -e /sys/class/backlight/$candidate ]]; then - device="$candidate" +while (( $# > 0 )); do + case "$1" in + --no-osd) + no_osd=1 + shift + ;; + --monitor) + (( $# >= 2 )) || exit 1 + monitor="$2" + shift 2 + ;; + *) break - fi + ;; + esac done +# Get the brightness of the passed display +backlight_brightness() { + brightnessctl -d "$1" -m 2>/dev/null | awk -F, '{ gsub("%", "", $4); print $4; found=1 } END{ exit !found }' +} + +[[ -n $monitor ]] || monitor="$(omarchy-hyprland-monitor-focused 2>/dev/null || true)" + +monitor_is_internal() { + [[ $monitor =~ ^(eDP|LVDS|DSI)- ]] +} + +use_apple_display() { + omarchy-hyprland-monitor-focused-apple "$monitor" +} + +use_ddc_display() { + [[ -n $monitor ]] && ! monitor_is_internal +} + +if (( $# == 0 )); then + if use_apple_display; then + omarchy-brightness-display-apple + exit + elif use_ddc_display; then + omarchy-brightness-display-ddc "$monitor" + exit + fi + + device="$(omarchy-hw-display)" || exit 1 + backlight_brightness "$device" + exit +fi + +step="$1" + if [[ $step == "off" ]]; then - hyprctl dispatch dpms off >/dev/null 2>&1 + hyprctl dispatch 'hl.dsp.dpms({ action = "disable" })' >/dev/null 2>&1 exit 0 elif [[ $step == "on" ]]; then - hyprctl dispatch dpms on >/dev/null 2>&1 + # Skip the dispatch when every active display is already lit: a redundant + # DPMS enable right after system resume forces another modeset, which blanks + # the panel for a beat (visible flash at the unlock screen). + hyprctl monitors -j 2>/dev/null | jq -e '[.[] | select(.disabled == false)] | length > 0 and all(.dpmsStatus)' >/dev/null 2>&1 && exit 0 + hyprctl dispatch 'hl.dsp.dpms({ action = "enable" })' >/dev/null 2>&1 exit 0 fi -if omarchy-hyprland-monitor-focused-apple; then - omarchy-brightness-display-apple "$step" - exit -fi - -# Current brightness percentage -current=$(brightnessctl -d "$device" -m | cut -d',' -f4 | tr -d '%') +# Drop overlapping brightness key events so concurrent invocations do not race. +# Hardware key repeat present on some devices can otherwise glitch OSD rendering. +exec {lock_fd}>"${XDG_RUNTIME_DIR:-/tmp}/omarchy-brightness-display.lock" +flock -n "$lock_fd" || exit 0 -# Apply non-uniform step size: 1% steps if at or below 5%, otherwise set an -# absolute target percentage to avoid raw backlight rounding causing uneven OSD steps. -if [[ $step == "+5%" ]]; then - if (( current < 5 )); then - (( target = current + 1 )) +if use_apple_display; then + if (( no_osd )); then + omarchy-brightness-display-apple --no-osd "$step" else - (( target = current + 5 )) + omarchy-brightness-display-apple "$step" fi +elif use_ddc_display; then + brightness="$(omarchy-brightness-display-ddc "$monitor" "$step")" || exit 1 + (( no_osd )) || omarchy-osd -i brightness -p "$brightness" +else + # Current device highlighted + device="$(omarchy-hw-display)" || exit 1 - (( target > 100 )) && target=100 - step="$target%" -elif [[ $step == "5%-" ]]; then - if (( current <= 5 )); then - (( target = current - 1 )) - else - (( target = current - 5 )) - fi + # Current brightness percentage + current=$(backlight_brightness "$device") || exit 1 - (( target < 1 )) && target=1 - step="$target%" -fi + # Apply non-uniform step size: 1% steps if at or below 5%, otherwise set an + # absolute target percentage to avoid raw backlight rounding causing uneven OSD steps. + if [[ $step == "+5%" ]]; then + if (( current < 5 )); then + (( target = current + 1 )) + else + (( target = current + 5 )) + fi + + (( target > 100 )) && target=100 + step="$target%" + elif [[ $step == "5%-" ]]; then + if (( current <= 5 )); then + (( target = current - 1 )) + else + (( target = current - 5 )) + fi -# Set the actual brightness of the display device. -brightnessctl -d "$device" set "$step" >/dev/null + (( target < 1 )) && target=1 + step="$target%" + fi -# Use SwayOSD to display the new brightness setting. -omarchy-swayosd-brightness "$(brightnessctl -d "$device" -m | cut -d',' -f4 | tr -d '%')" + # Set brightness of the display device. + brightnessctl -d "$device" set "$step" >/dev/null + + # Show the new brightness in OSD + (( no_osd )) || omarchy-osd -i brightness -p "$(backlight_brightness "$device")" +fi diff --git a/bin/omarchy-brightness-display-apple b/bin/omarchy-brightness-display-apple index 480b9fe755..81202b8792 100755 --- a/bin/omarchy-brightness-display-apple +++ b/bin/omarchy-brightness-display-apple @@ -1,34 +1,94 @@ #!/bin/bash -# omarchy:summary=Adjust the brightness on Apple Studio Displays and Apple XDR Displays using asdcontrol. -# omarchy:args=<+N%|N%-|N%> -# omarchy:examples=omarchy brightness display apple +5% | omarchy brightness display apple 5%- | omarchy brightness display apple 50% +# omarchy:summary=Show or adjust Apple Studio Display and Apple XDR Display brightness using asdcontrol. +# 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% -if (( $# == 0 )); then - echo "Adjust Apple Display brightness by passing +5%, 5%-, or 100%" -else - step="$1" - if [[ $step =~ ^([0-9]+)%-$ ]]; then - step="-${BASH_REMATCH[1]}%" - fi +device_cache="${XDG_RUNTIME_DIR:-/tmp}/omarchy-brightness-display-apple.device" +no_osd=0 +if [[ ${1:-} == "--no-osd" ]]; then + no_osd=1 + shift +fi + +detect_apple_display_device() { + local devices=() + local path="" - devices=() for path in /dev/usb/hiddev* /dev/hiddev*; do [[ -e $path ]] && devices+=("$path") done - if (( ${#devices[@]} == 0 )); then - echo "No Apple Display HID device found" - exit 1 + (( ${#devices[@]} > 0 )) || return 1 + + sudo asdcontrol --detect "${devices[@]}" 2>/dev/null | awk -F: '/^\/dev\/(usb\/)?hiddev/{ print $1; exit }' +} + +find_apple_display_device() { + local cached="" + local device="" + + if [[ -r $device_cache ]]; then + read -r cached <"$device_cache" || true + if [[ -n $cached && -e $cached ]]; then + printf '%s\n' "$cached" + return 0 + fi fi - device="$(sudo asdcontrol --detect "${devices[@]}" | grep -E '^/dev/(usb/)?hiddev' | cut -d: -f1 | head -n1)" + device="$(detect_apple_display_device)" || return 1 + [[ -n $device ]] || return 1 + + printf '%s\n' "$device" >"$device_cache" + printf '%s\n' "$device" +} + +current_brightness() { + local device="$1" + + sudo asdcontrol "$device" 2>/dev/null | awk -F= ' + /BRIGHTNESS=/ { + print int($2 * 100 / 60000) + found = 1 + } + + END { exit !found } + ' +} + +retry_with_fresh_device() { + rm -f "$device_cache" + device="$(find_apple_display_device || true)" if [[ -z $device ]]; then - echo "No Apple Display HID device found" + echo "No Apple Display HID device found" >&2 exit 1 fi +} + +device="$(find_apple_display_device || true)" +if [[ -z $device ]]; then + echo "No Apple Display HID device found" >&2 + exit 1 +fi +if (( $# == 0 )); then + if current_brightness "$device"; then + exit + else + retry_with_fresh_device + current_brightness "$device" + exit + fi +fi + +step="$1" +if [[ $step =~ ^([0-9]+)%-$ ]]; then + step="-${BASH_REMATCH[1]}%" +fi + +if ! sudo asdcontrol "$device" -- "$step" >/dev/null; then + retry_with_fresh_device sudo asdcontrol "$device" -- "$step" >/dev/null - value="$(sudo asdcontrol "$device" | awk -F= '/BRIGHTNESS=/{print $2+0}')" - omarchy-swayosd-brightness "$(( value * 100 / 60000 ))" fi + +(( no_osd )) || omarchy-osd -i brightness -p "$(current_brightness "$device")" diff --git a/bin/omarchy-brightness-display-ddc b/bin/omarchy-brightness-display-ddc new file mode 100755 index 0000000000..aab9127860 --- /dev/null +++ b/bin/omarchy-brightness-display-ddc @@ -0,0 +1,168 @@ +#!/bin/bash + +# omarchy:summary=Show or adjust DDC/CI display brightness for a Hyprland monitor. +# omarchy:args= [+N%|N%-|N%] +# omarchy:examples=omarchy-brightness-display-ddc DP-1 | omarchy-brightness-display-ddc DP-1 50% + +monitor="${1:-}" +step="${2:-}" + +[[ -n $monitor ]] || exit 1 + +cache_dir="${XDG_RUNTIME_DIR:-/tmp}/omarchy-brightness-display-ddc" +cache_name="${monitor//[^[:alnum:]_.-]/_}" +cache_file="$cache_dir/$cache_name.bus" +unavailable_cache_seconds=60 +range_cache_seconds=10 + +cache_unavailable() { + mkdir -p "$cache_dir" 2>/dev/null || true + printf 'unavailable %s\n' "$(date +%s)" >"$cache_file" 2>/dev/null || true +} + +detect_bus() { + ddcutil --skip-ddc-checks detect --brief 2>/dev/null | awk -v monitor="$monitor" ' + /I2C bus:/ { + bus = $NF + sub(/^.*\/i2c-/, "", bus) + } + + /DRM connector:/ { + connector = $NF + sub(/^card[0-9]+-/, "", connector) + if (connector == monitor && bus != "") { + print bus + exit + } + bus = "" + } + ' +} + +find_bus() { + local bus="" + local cached_value="" + local now=0 + + if [[ -r $cache_file ]]; then + read -r bus cached_value <"$cache_file" || true + fi + + if [[ $bus == "unavailable" ]]; then + now=$(date +%s) + if [[ $cached_value =~ ^[0-9]+$ ]] && (( now - cached_value < unavailable_cache_seconds )); then + return 1 + fi + bus="" + rm -f "$cache_file" + fi + + if [[ -z $bus ]]; then + bus="$(detect_bus)" || return 1 + if [[ ! $bus =~ ^[0-9]+$ ]]; then + cache_unavailable + return 1 + fi + mkdir -p "$cache_dir" 2>/dev/null || true + printf '%s\n' "$bus" >"$cache_file" 2>/dev/null || true + fi + + printf '%s\n' "$bus" +} + +read_vcp() { + local bus="$1" + + ddcutil --bus "$bus" --skip-ddc-checks getvcp 10 --brief 2>/dev/null | awk ' + $1 == "VCP" && toupper($2) == "10" && $3 == "C" && $4 ~ /^[0-9]+$/ && $5 ~ /^[0-9]+$/ && $5 > 0 { + print $4, $5 + found = 1 + exit + } + + END { exit !found } + ' +} + +read_brightness() { + local bus="" + local now=0 + local values="" + + bus="$(find_bus)" || return 1 + values="$(read_vcp "$bus")" || { + rm -f "$cache_file" + return 1 + } + + now=$(date +%s) + mkdir -p "$cache_dir" 2>/dev/null || true + printf '%s %s %s\n' "$bus" "${values##* }" "$now" >"$cache_file" 2>/dev/null || true + printf '%s %s\n' "$bus" "$values" +} + +bus="" +current="" +maximum="" +percent="" +range_cached_at="" + +if [[ -z $step ]]; then + read -r bus current maximum < <(read_brightness) || exit 1 + [[ -n ${bus:-} && -n ${current:-} && -n ${maximum:-} ]] || exit 1 + (( percent = (current * 100 + maximum / 2) / maximum )) + printf '%s\n' "$percent" + exit 0 +fi + +if [[ $step =~ ^([0-9]+)%$ ]]; then + target="${BASH_REMATCH[1]}" + range_cache_fresh=0 + if [[ -r $cache_file ]]; then + read -r bus maximum range_cached_at <"$cache_file" || true + fi + + if [[ $bus =~ ^[0-9]+$ && $maximum =~ ^[0-9]+$ && $range_cached_at =~ ^[0-9]+$ ]] && (( maximum > 0 )); then + now=$(date +%s) + if (( range_cached_at <= now && now - range_cached_at < range_cache_seconds )); then + range_cache_fresh=1 + fi + fi + + if (( ! range_cache_fresh )); then + read -r bus current maximum < <(read_brightness) || exit 1 + fi +elif [[ $step =~ ^\+([0-9]+)%$ ]]; then + read -r bus current maximum < <(read_brightness) || exit 1 + [[ -n ${bus:-} && -n ${current:-} && -n ${maximum:-} ]] || exit 1 + (( percent = (current * 100 + maximum / 2) / maximum )) + amount="${BASH_REMATCH[1]}" + if (( amount == 5 && percent < 5 )); then + (( target = percent + 1 )) + else + (( target = percent + amount )) + fi +elif [[ $step =~ ^([0-9]+)%-$ ]]; then + read -r bus current maximum < <(read_brightness) || exit 1 + [[ -n ${bus:-} && -n ${current:-} && -n ${maximum:-} ]] || exit 1 + (( percent = (current * 100 + maximum / 2) / maximum )) + amount="${BASH_REMATCH[1]}" + if (( amount == 5 && percent <= 5 )); then + (( target = percent - 1 )) + else + (( target = percent - amount )) + fi +else + exit 1 +fi + +(( target < 1 )) && target=1 +(( target > 100 )) && target=100 +(( raw_target = (target * maximum + 50) / 100 )) + +if ! ddcutil --bus "$bus" --skip-ddc-checks --noverify setvcp 10 "$raw_target" >/dev/null 2>&1; then + rm -f "$cache_file" + exit 1 +fi + +printf '%s\n' "$target" diff --git a/bin/omarchy-brightness-keyboard b/bin/omarchy-brightness-keyboard index 7e5febe664..f323b94f53 100755 --- a/bin/omarchy-brightness-keyboard +++ b/bin/omarchy-brightness-keyboard @@ -1,7 +1,13 @@ #!/bin/bash # omarchy:summary=Adjust keyboard backlight brightness using available steps. -# omarchy:args= +# omarchy:args=[--no-osd] + +no_osd=0 +if [[ ${1:-} == "--no-osd" ]]; then + no_osd=1 + shift +fi direction="${1:-up}" @@ -49,7 +55,4 @@ fi # Set the new brightness. brightnessctl -d "$device" set "$new_brightness" >/dev/null - -# Use SwayOSD to display the new brightness setting. -percent=$((new_brightness * 100 / max_brightness)) -omarchy-swayosd-kbd-brightness "$percent" +(( no_osd )) || omarchy-osd -i keyboard -p "$(( new_brightness * 100 / max_brightness ))" diff --git a/bin/omarchy-capture-qr b/bin/omarchy-capture-qr new file mode 100755 index 0000000000..178973874b --- /dev/null +++ b/bin/omarchy-capture-qr @@ -0,0 +1,36 @@ +#!/bin/bash + +# omarchy:summary=Decode a QR code from a screenshot region +# omarchy:group=capture +# omarchy:examples=omarchy capture qr + +# Keep hyprpicker alive until after grim captures so the screenshot sees the +# frozen overlay rather than live content shifting during teardown. +cleanup_freeze() { + [[ -n $PID ]] && kill $PID 2>/dev/null +} +trap cleanup_freeze EXIT + +hyprpicker -r -z >/dev/null 2>&1 & +PID=$! +sleep .1 +SELECTION=$(slurp 2>/dev/null) + +[[ -z $SELECTION ]] && exit 0 + +# Decode QR codes only. Leaving the other symbologies enabled lets dense screen +# content false-positive as an EAN or Code 39 barcode and take over the clipboard. +RESULT=$(grim -g "$SELECTION" - | zbarimg -q --raw -Sdisable -Sqrcode.enable - 2>/dev/null) + +if [[ -z $RESULT ]]; then + omarchy-notification-send -g 󰐲 -u critical "No QR code found" "Select a region containing a QR code" + exit 1 +fi + +# QR codes routinely carry secrets, like the otpauth:// URIs behind 2FA setup +# codes, so the decoded value goes to the clipboard and nowhere else. Printing it +# or putting it in the notification would leak it to the session journal and to +# the notification history, and an unmarked copy would be retained by clipboard +# history. Pasting still works; only the recorded copy is given up. +printf '%s' "$RESULT" | wl-copy --sensitive +omarchy-notification-send -g 󰐲 "QR code copied to clipboard" diff --git a/bin/omarchy-capture-region b/bin/omarchy-capture-region new file mode 100755 index 0000000000..50b7e246e1 --- /dev/null +++ b/bin/omarchy-capture-region @@ -0,0 +1,370 @@ +#!/bin/bash + +# omarchy:summary=Pick a screen region over frozen screen content +# omarchy:args=[region|windows|smart|fullscreen] [--keep-freeze] [--match-monitor] | --take-fullscreen | --take-window | --select-window +# omarchy:hidden=true + +# Prints the picked geometry in slurp's "X,Y WxH" format, or exits 1 when the +# pick is cancelled. Shared by screenshot and screen recording so the picker +# UX stays identical. +# +# region freeform selection +# windows snap selection to a monitor or window rectangle +# smart freeform with window/monitor rects hinted; a bare click +# (area < 20px^2) snaps to the rectangle it landed in +# fullscreen the focused monitor, no interaction +# +# --keep-freeze leave the hyprpicker screen freeze running and print its +# PID as the first output line (empty when no freeze was +# started); the caller owns killing it +# --match-monitor print "monitor:NAME" instead when the picked geometry +# exactly matches a monitor + +FULLSCREEN_MARKER="${XDG_RUNTIME_DIR:-/tmp}/omarchy-capture-region-fullscreen" +WINDOW_MARKER="${XDG_RUNTIME_DIR:-/tmp}/omarchy-capture-region-window" + +# accounting for portrait/transformed displays +JQ_MONITOR_GEO=' + def format_geo: + .x as $x | .y as $y | + (.width / .scale | floor) as $w | + (.height / .scale | floor) as $h | + .transform as $t | + if $t == 1 or $t == 3 then + "\($x),\($y) \($h)x\($w)" + else + "\($x),\($y) \($w)x\($h)" + end; +' + +active_workspace() { + hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .activeWorkspace.id' +} + +# Hidden group members and windows stacked at identical geometry collapse to +# one rectangle: slurp cannot tell them apart, and duplicates would stall the +# Tab cycle on the first copy. +window_rects() { + hyprctl clients -j | jq -r --arg ws "$(active_workspace)" \ + '[.[] | select(.workspace.id == ($ws | tonumber) and .hidden != true) | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"] | unique[]' +} + +monitor_rects() { + hyprctl monitors -j | jq -r --arg ws "$(active_workspace)" "${JQ_MONITOR_GEO} .[] | select(.activeWorkspace.id == (\$ws | tonumber)) | format_geo" +} + +get_rectangles() { + monitor_rects + window_rects +} + +focused_monitor_geo() { + hyprctl monitors -j | jq -r "${JQ_MONITOR_GEO} .[] | select(.focused == true) | format_geo" +} + +# slurp highlights the smallest box containing the point and keeps the first +# one on a tie, so overlapping rectangles resolve the same way here (e.g. +# floating over tiled). Reads candidates on stdin and leaves the answer in +# RESOLVED_RECT; returns 1 when no candidate contains the point. Assigning to +# a global rather than printing keeps the probing in warp_point_in fork-free. +resolve_rect_at() { + local x=$1 y=$2 + local rect area + local smallest_area=0 + + RESOLVED_RECT="" + + while IFS= read -r rect; do + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue + ((x >= BASH_REMATCH[1] && x < BASH_REMATCH[1] + BASH_REMATCH[3] && y >= BASH_REMATCH[2] && y < BASH_REMATCH[2] + BASH_REMATCH[4])) || continue + + area=$((BASH_REMATCH[3] * BASH_REMATCH[4])) + if [[ -z $RESOLVED_RECT ]] || ((area < smallest_area)); then + RESOLVED_RECT=$rect + smallest_area=$area + fi + done + + [[ -n $RESOLVED_RECT ]] +} + +# A rectangle whose center is covered by a smaller one cannot be selected by +# warping to that center: slurp would go on highlighting the coverer. Probe +# points inside the rectangle, nearest its center first, for one that resolves +# back to it, and leave that point in WARP_X / WARP_Y. Returns 1 when the +# rectangle is buried well enough that no point resolves to it, in which case +# hovering could not reach it either. +warp_point_in() { + local rect=$1 candidates=$2 + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || return 1 + local rect_x=${BASH_REMATCH[1]} rect_y=${BASH_REMATCH[2]} + local rect_width=${BASH_REMATCH[3]} rect_height=${BASH_REMATCH[4]} + local probe x y + + for probe in "${WARP_PROBES[@]}"; do + x=$((rect_x + rect_width * ${probe% *} / 8)) + y=$((rect_y + rect_height * ${probe#* } / 8)) + + resolve_rect_at "$x" "$y" <<<"$candidates" || continue + [[ $RESOLVED_RECT == "$rect" ]] || continue + + WARP_X=$x + WARP_Y=$y + return 0 + done + + return 1 +} + +# Whatever slurp is highlighting under the cursor. It is fed monitor rects as +# well as window rects, so a cursor in a gap or over the bar highlights the +# monitor rather than any window. +geo_at_cursor() { + local pos=$(hyprctl cursorpos) + local x=${pos%,*} + local y=${pos#*, } + + if resolve_rect_at "$x" "$y" < <(window_rects) || resolve_rect_at "$x" "$y" < <(monitor_rects); then + echo "$RESOLVED_RECT" + else + focused_monitor_geo + fi +} + +# Keyboard control while slurp is open: binds scoped to slurp's layer +# surface (default/hypr/bindings/utilities.lua) invoke these modes. The +# --take-* modes flag the intent with a marker file and dismiss slurp. +if [[ ${1:-} == "--take-fullscreen" ]]; then + pgrep -x slurp >/dev/null || exit 0 + touch "$FULLSCREEN_MARKER" + pkill -x slurp + exit 0 +fi + +if [[ ${1:-} == "--take-window" ]]; then + pgrep -x slurp >/dev/null || exit 0 + touch "$WINDOW_MARKER" + pkill -x slurp + exit 0 +fi + +# Warps the cursor to another window's center, so slurp's own hover +# highlight tracks the selection. +if [[ ${1:-} == "--select-window" ]]; then + pgrep -x slurp >/dev/null || exit 0 + + direction=${2:-} + pos=$(hyprctl cursorpos) + origin_x=${pos%,*} + origin_y=${pos#*, } + + candidates=$(window_rects) + + # Eighth fractions of a rectangle's width and height, ordered by distance + # from its center so warp_point_in prefers the most central point it can use. + mapfile -t WARP_PROBES < <( + for fx in {1..7}; do + for fy in {1..7}; do + printf '%d %d %d\n' $(((fx - 4) * (fx - 4) + (fy - 4) * (fy - 4))) "$fx" "$fy" + done + done | sort -n | cut -d' ' -f2- + ) + + # Only rectangles that hovering could actually reach take part in navigation, + # each paired with the point to warp to. + declare -A warp_points + reachable="" + while IFS= read -r rect; do + warp_point_in "$rect" "$candidates" || continue + warp_points[$rect]="$WARP_X $WARP_Y" + reachable+="$rect"$'\n' + done <<<"$candidates" + [[ -n $reachable ]] || exit 0 + + # Reading order: top-to-bottom, then left-to-right. + rects=$(while IFS= read -r rect; do + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue + printf '%d\t%d\t%s\n' "${BASH_REMATCH[2]}" "${BASH_REMATCH[1]}" "$rect" + done <<<"$reachable" | sort -n -k1,1 -k2,2 | cut -f3-) + + # The selection to move from is the one slurp highlights, resolved from the + # same list in the same order as --take-window so navigation and capture + # never disagree. Measure from its center. + current="" + resolve_rect_at "$origin_x" "$origin_y" <<<"$candidates" && current=$RESOLVED_RECT + + if [[ $current =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]]; then + origin_x=$((BASH_REMATCH[1] + BASH_REMATCH[3] / 2)) + origin_y=$((BASH_REMATCH[2] + BASH_REMATCH[4] / 2)) + fi + + target="" + + case $direction in + next | prev) + mapfile -t ordered <<<"$rects" + count=${#ordered[@]} + current_index=-1 + + for i in "${!ordered[@]}"; do + if [[ -n $current && ${ordered[i]} == "$current" ]]; then + current_index=$i + break + fi + done + + if [[ $direction == next ]]; then + target=${ordered[$(((current_index + 1) % count))]} + elif ((current_index == -1)); then + target=${ordered[count - 1]} + else + target=${ordered[$(((current_index - 1 + count) % count))]} + fi + ;; + left | right | up | down) + best_score="" + + while IFS= read -r rect; do + [[ $rect == "$current" ]] && continue + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue + center_x=$((BASH_REMATCH[1] + BASH_REMATCH[3] / 2)) + center_y=$((BASH_REMATCH[2] + BASH_REMATCH[4] / 2)) + + case $direction in + left) + primary=$((origin_x - center_x)) + perp=$((center_y - origin_y)) + ;; + right) + primary=$((center_x - origin_x)) + perp=$((center_y - origin_y)) + ;; + up) + primary=$((origin_y - center_y)) + perp=$((center_x - origin_x)) + ;; + down) + primary=$((center_y - origin_y)) + perp=$((center_x - origin_x)) + ;; + esac + + ((primary > 0)) || continue + ((perp < 0)) && perp=$((-perp)) + score=$((primary + perp * 2)) + + if [[ -z $best_score ]] || ((score < best_score)); then + best_score=$score + target=$rect + fi + done <<<"$rects" + ;; + *) + exit 1 + ;; + esac + + if [[ -n $target && -n ${warp_points[$target]} ]]; then + read -r target_x target_y <<<"${warp_points[$target]}" + hyprctl eval "hl.dispatch(hl.dsp.cursor.move({ x = $target_x, y = $target_y }))" >/dev/null + fi + exit 0 +fi + +MODE=smart +KEEP_FREEZE=false +MATCH_MONITOR=false + +for arg in "$@"; do + case $arg in + --keep-freeze) KEEP_FREEZE=true ;; + --match-monitor) MATCH_MONITOR=true ;; + *) MODE=$arg ;; + esac +done + +# Runs slurp; an empty result with a marker present means one of the --take-* +# binds was pressed, so the highlighted rectangle or the monitor is the +# selection. +pick() { + local selection + rm -f "$FULLSCREEN_MARKER" "$WINDOW_MARKER" + selection=$(slurp "$@" 2>/dev/null) + + if [[ -z $selection && -e $FULLSCREEN_MARKER ]]; then + rm -f "$FULLSCREEN_MARKER" + selection=$(focused_monitor_geo) + elif [[ -z $selection && -e $WINDOW_MARKER ]]; then + rm -f "$WINDOW_MARKER" + selection=$(geo_at_cursor) + fi + + printf '%s' "$selection" +} + +FREEZE_PID="" +freeze_screen() { + hyprpicker -r -z >/dev/null 2>&1 & + FREEZE_PID=$! + sleep .1 +} + +cleanup_freeze() { + [[ $KEEP_FREEZE == true ]] && return + [[ -n $FREEZE_PID ]] && kill $FREEZE_PID 2>/dev/null +} +trap cleanup_freeze EXIT + +case "$MODE" in +region) + freeze_screen + SELECTION=$(pick) + ;; +windows) + freeze_screen + SELECTION=$(get_rectangles | pick -r) + ;; +fullscreen) + SELECTION=$(focused_monitor_geo) + ;; +smart | *) + RECTS=$(get_rectangles) + freeze_screen + SELECTION=$(echo "$RECTS" | pick) + + # A bare click (area < 20px^2) snaps to whichever rectangle it landed in, + # so users don't end up with accidental 2px captures. X and Y can be + # negative (Hyprland monitor positions in multi-display layouts). + if [[ $SELECTION =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] && ((BASH_REMATCH[3] * BASH_REMATCH[4] < 20)); then + click_x=${BASH_REMATCH[1]} + click_y=${BASH_REMATCH[2]} + + while IFS= read -r rect; do + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue + rect_x=${BASH_REMATCH[1]} + rect_y=${BASH_REMATCH[2]} + rect_width=${BASH_REMATCH[3]} + rect_height=${BASH_REMATCH[4]} + + if ((click_x >= rect_x && click_x < rect_x + rect_width && click_y >= rect_y && click_y < rect_y + rect_height)); then + SELECTION="${rect_x},${rect_y} ${rect_width}x${rect_height}" + break + fi + done <<<"$RECTS" + fi + ;; +esac + +[[ $KEEP_FREEZE == true ]] && echo "$FREEZE_PID" + +[[ -n $SELECTION ]] || exit 1 + +if [[ $MATCH_MONITOR == true ]]; then + monitor=$(hyprctl monitors -j | jq -r --arg geo "$SELECTION" "${JQ_MONITOR_GEO} .[] | select(format_geo == \$geo) | .name" | head -1) + if [[ -n $monitor ]]; then + echo "monitor:$monitor" + exit 0 + fi +fi + +echo "$SELECTION" diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index 75a5c20722..7666e1e683 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -2,8 +2,8 @@ # omarchy:summary=Start or stop screen recording # omarchy:group=capture -# omarchy:args=[--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=] [--resolution=] [--stop-recording] -# omarchy:examples=omarchy screenrecord | omarchy capture screenrecord --with-desktop-audio +# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=] [--webcam-size=] [--resolution=] [--stop-recording] +# omarchy:examples=omarchy screenrecord | omarchy capture screenrecording --with-desktop-audio # omarchy:aliases=omarchy screenrecord # # Env: OMARCHY_SCREENRECORD_USE_PORTAL=true skips the built-in slurp picker and @@ -22,7 +22,7 @@ OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}" if [[ ! -d $OUTPUT_DIR ]]; then - notify-send "Screen recording directory does not exist: $OUTPUT_DIR" -u critical -t 3000 + omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR" exit 1 fi @@ -30,9 +30,12 @@ DESKTOP_AUDIO="false" MICROPHONE_AUDIO="false" WEBCAM="false" WEBCAM_DEVICE="" +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") for arg in "$@"; do @@ -41,53 +44,72 @@ for arg in "$@"; do --with-microphone-audio) MICROPHONE_AUDIO="true" ;; --with-webcam) WEBCAM="true" ;; --webcam-device=*) WEBCAM_DEVICE="${arg#*=}" ;; + --webcam-size=*) WEBCAM_SIZE="${arg#*=}" ;; --resolution=*) RESOLUTION="${arg#*=}" ;; + --fullscreen) FULLSCREEN="true" ;; --stop-recording) STOP_RECORDING="true" ;; esac done +case $WEBCAM_SIZE in +small | medium | large) ;; +*) + echo "Invalid webcam size: $WEBCAM_SIZE (expected small, medium, or large)" >&2 + exit 1 + ;; +esac + start_webcam_overlay() { cleanup_webcam # Auto-detect first available webcam if none specified if [[ -z $WEBCAM_DEVICE ]]; then - WEBCAM_DEVICE=$(v4l2-ctl --list-devices 2>/dev/null | grep -m1 "^[[:space:]]*/dev/video" | tr -d '\t') + WEBCAM_DEVICE=$(omarchy-capture-webcam-list | sed -n '1s/[[:space:]].*//p') if [[ -z $WEBCAM_DEVICE ]]; then - notify-send "No webcam devices found" -u critical -t 3000 + omarchy-notification-send -u critical -t 3000 "No webcam devices found" return 1 fi fi - # Get monitor scale - local scale=$(hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .scale') - - # Target width (base 360px, scaled to monitor) - local target_width=$(awk "BEGIN {printf \"%.0f\", 360 * $scale}") - # Try preferred 16:9 resolutions in order, use first available local preferred_resolutions=("640x360" "1280x720" "1920x1080") - local video_size_arg="" + local capture_options="framerate=30" local available_formats=$(v4l2-ctl --list-formats-ext -d "$WEBCAM_DEVICE" 2>/dev/null) for resolution in "${preferred_resolutions[@]}"; do if echo "$available_formats" | grep -q "$resolution"; then - video_size_arg="-video_size $resolution" + capture_options="video_size=$resolution,$capture_options" break fi done - ffplay -f v4l2 $video_size_arg -framerate 30 "$WEBCAM_DEVICE" \ - -vf "crop=iw/2:ih,scale=${target_width}:-1" \ - -window_title "WebcamOverlay" \ - -noborder \ - -fflags nobuffer -flags low_delay \ - -probesize 32 -analyzeduration 0 \ - -loglevel quiet & - sleep 1 + mpv "av://v4l2:$WEBCAM_DEVICE" \ + --profile=low-latency --untimed --no-cache \ + --demuxer-lavf-o="$capture_options" \ + '--vf=lavfi=[crop=ih*8/9:ih]' \ + --title="WebcamOverlay" --wayland-app-id="WebcamOverlay-$WEBCAM_SIZE" \ + --no-border --no-audio --no-osc --osd-level=0 \ + --really-quiet &>/dev/null & + + # The move has to settle before gpu-screen-recorder starts, or the camera is + # recorded sliding into its corner. Waiting for the map is what the blind + # second was partly guessing at, so the remainder is trimmed to hold the + # pre-capture delay where it was: starting later costs the first words spoken. + local waited=0 + while ((waited < 40)) && ! hyprctl clients -j | jq -e 'any(.[]; .title == "WebcamOverlay")' >/dev/null 2>&1; do + sleep 0.05 + ((waited++)) + done + + [[ ${1:-} == region:* ]] && echo "${1#region:}" >"$REGION_FILE" + omarchy-capture-webcam-resize "$WEBCAM_SIZE" + + sleep 0.6 } cleanup_webcam() { pkill -f "WebcamOverlay" 2>/dev/null + rm -f "$REGION_FILE" } default_resolution() { @@ -100,63 +122,25 @@ default_resolution() { fi } -# Monitor + window rectangles on the focused workspace, in slurp's "X,Y WxH" format. -# Mirrors omarchy-capture-screenshot so the picker UX is identical. -get_rectangles() { - local active_workspace=$(hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .activeWorkspace.id') - hyprctl monitors -j | jq -r --arg ws "$active_workspace" ' - .[] | select(.activeWorkspace.id == ($ws | tonumber)) | - "\(.x),\(.y) \(.width / .scale | floor)x\(.height / .scale | floor)"' - hyprctl clients -j | jq -r --arg ws "$active_workspace" ' - .[] | select(.workspace.id == ($ws | tonumber)) | - "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"' -} - -# Echoes "monitor:NAME" when the selection matches an entire monitor, otherwise -# "region:WxH+X+Y" with physical-pixel coordinates ready for gpu-screen-recorder. -# Returns non-zero if the user cancelled the picker. +# Echoes "monitor:NAME" when the selection matches an entire monitor (prefer +# -w over a region capture — same kms backend, but no scaling math +# and full native res), otherwise "region:WxH+X+Y". Returns non-zero if the +# user cancelled the picker. select_capture_target() { - local rects=$(get_rectangles) - hyprpicker -r -z >/dev/null 2>&1 & - local picker_pid=$! - sleep .1 - local selection=$(echo "$rects" | slurp 2>/dev/null) - kill $picker_pid 2>/dev/null - - # X and Y can be negative (Hyprland monitor positions in multi-display layouts); - # widths and heights are always positive. - [[ $selection =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || return 1 - local sx=${BASH_REMATCH[1]} sy=${BASH_REMATCH[2]} - local sw=${BASH_REMATCH[3]} sh=${BASH_REMATCH[4]} - - # A bare click (area < 20px²) snaps to whichever rectangle the click landed - # inside, so users don't end up with accidental 2px recordings. - if ((sw * sh < 20)); then - while IFS= read -r rect; do - [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue - local rx=${BASH_REMATCH[1]} ry=${BASH_REMATCH[2]} - local rw=${BASH_REMATCH[3]} rh=${BASH_REMATCH[4]} - if ((sx >= rx && sx < rx + rw && sy >= ry && sy < ry + rh)); then - sx=$rx sy=$ry sw=$rw sh=$rh - break - fi - done <<<"$rects" - fi - - # When the selection exactly matches a monitor, prefer -w over a - # region capture — same kms backend, but no scaling math and full native res. - local monitor=$(hyprctl monitors -j | jq -r --argjson x "$sx" --argjson y "$sy" --argjson w "$sw" --argjson h "$sh" ' - .[] | select(.x == $x and .y == $y and (.width / .scale | floor) == $w and (.height / .scale | floor) == $h) | .name' | head -1) + local target + target=$(omarchy-capture-region smart --match-monitor) || return 1 - if [[ -n $monitor ]]; then - echo "monitor:$monitor" + if [[ $target == monitor:* ]]; then + echo "$target" return fi + [[ $target =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || return 1 + # gpu-screen-recorder wants region geometry in the compositor's logical # coordinate space — same space slurp returns — so pass the values through # untouched. (gsr scales to physical pixels itself based on the monitor.) - echo "region:${sw}x${sh}+${sx}+${sy}" + echo "region:${BASH_REMATCH[3]}x${BASH_REMATCH[4]}+${BASH_REMATCH[1]}+${BASH_REMATCH[2]}" } start_screenrecording() { @@ -167,7 +151,10 @@ start_screenrecording() { # the portal backend supports and the kms backend doesn't). Default flow uses # slurp + the kms backend, which avoids the EGL DMA-BUF modifier import # failures the portal path can hit on some configurations. - if [[ ${OMARCHY_SCREENRECORD_USE_PORTAL:-false} == "true" ]]; then + if [[ $FULLSCREEN == "true" ]]; then + target="monitor:$(omarchy-hyprland-monitor-focused)" + capture_args=(-w "${target#monitor:}" -s "${RESOLUTION:-$(default_resolution)}") + elif [[ ${OMARCHY_SCREENRECORD_USE_PORTAL:-false} == "true" ]]; then target="portal" capture_args=(-w portal -s "${RESOLUTION:-$(default_resolution)}") else @@ -184,7 +171,7 @@ start_screenrecording() { esac fi - [[ $WEBCAM == "true" ]] && start_webcam_overlay + [[ $WEBCAM == "true" ]] && start_webcam_overlay "$target" local filename="$OUTPUT_DIR/screenrecording-$(date +'%Y-%m-%d_%H-%M-%S').mp4" local audio_devices="" @@ -229,7 +216,7 @@ stop_screenrecording() { if pgrep -f "^gpu-screen-recorder" >/dev/null; then pkill -9 -f "^gpu-screen-recorder" - notify-send "Screen recording error" "Recording process had to be force-killed. Video may be corrupted." -u critical -t 5000 + omarchy-notification-send -u critical -t 5000 "Screen recording error" "Recording process had to be force-killed. Video may be corrupted." else finalize_recording local filename=$(cat "$RECORDING_FILE" 2>/dev/null) @@ -239,9 +226,15 @@ stop_screenrecording() { # Generate a preview thumbnail from the first frame ffmpeg -y -i "$filename" -ss 00:00:00.1 -vframes 1 -q:v 2 "$preview" -loglevel quiet 2>/dev/null + omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \ + -t 10000 --image "${preview:-$filename}" \ + --exec "$(printf 'mpv %q' "$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 + # toast. Clear it out of the recordings directory a moment later. ( - ACTION=$(notify-send "Screen recording saved" "Open with Super + Alt + , (or click this)" -t 10000 -i "${preview:-$filename}" -A "default=open") - [[ $ACTION == "default" ]] && mpv "$filename" + sleep 2 rm -f "$preview" ) & fi @@ -250,7 +243,7 @@ stop_screenrecording() { } toggle_screenrecording_indicator() { - pkill -RTMIN+8 waybar + omarchy-shell -q omarchy.indicators refresh } screenrecording_active() { diff --git a/bin/omarchy-capture-screenrecording-with-webcam b/bin/omarchy-capture-screenrecording-with-webcam new file mode 100755 index 0000000000..e5b91bfd90 --- /dev/null +++ b/bin/omarchy-capture-screenrecording-with-webcam @@ -0,0 +1,21 @@ +#!/bin/bash + +# omarchy:summary=Pick a webcam and start a screen recording with it +# omarchy:examples=omarchy capture screenrecording-with-webcam + +mapfile -t devices < <(omarchy-capture-webcam-list) +if (( ${#devices[@]} == 0 )); then + omarchy-notification-send "No webcam devices found" -u critical -t 3000 + exit 1 +fi + +if (( ${#devices[@]} == 1 )); then + device="${devices[0]%%[[:space:]]*}" +else + selection=$(omarchy-menu-select "Select Webcam" "${devices[@]}" -- --width 520 --maxheight 520) || exit 1 + device="${selection%%[[:space:]]*}" +fi + +exec omarchy-capture-screenrecording \ + --with-desktop-audio --with-microphone-audio \ + --with-webcam --webcam-device="$device" diff --git a/bin/omarchy-capture-screenshot b/bin/omarchy-capture-screenshot index 572330f78d..8b2ac7d07e 100755 --- a/bin/omarchy-capture-screenshot +++ b/bin/omarchy-capture-screenshot @@ -11,12 +11,12 @@ OUTPUT_DIR="${OMARCHY_SCREENSHOT_DIR:-${XDG_PICTURES_DIR:-$HOME/Pictures}}" if [[ ! -d $OUTPUT_DIR ]]; then mkdir -p "$OUTPUT_DIR" - notify-send "Created screenshot directory: $OUTPUT_DIR" -u normal -t 2000 + omarchy-notification-send "Created screenshot directory: $OUTPUT_DIR" -t 2000 fi pkill slurp && exit 0 -SCREENSHOT_EDITOR="${OMARCHY_SCREENSHOT_EDITOR:-satty}" +SCREENSHOT_EDITOR="${OMARCHY_SCREENSHOT_EDITOR:-tensaku-edit}" # Parse --editor flag from any position ARGS=() @@ -29,97 +29,31 @@ for arg in "$@"; do done set -- "${ARGS[@]}" -open_editor() { - local filepath="$1" - if [[ $SCREENSHOT_EDITOR == "satty" ]]; then - satty --filename "$filepath" \ - --output-filename "$filepath" \ - --actions-on-enter save-to-clipboard \ - --save-after-copy \ - --copy-command 'wl-copy' - else - $SCREENSHOT_EDITOR "$filepath" - fi -} - MODE="${1:-smart}" PROCESSING="${2:-slurp}" -# accounting for portrait/transformed displays -JQ_MONITOR_GEO=' - def format_geo: - .x as $x | .y as $y | - (.width / .scale | floor) as $w | - (.height / .scale | floor) as $h | - .transform as $t | - if $t == 1 or $t == 3 then - "\($x),\($y) \($h)x\($w)" - else - "\($x),\($y) \($w)x\($h)" - end; -' - -get_rectangles() { - local active_workspace=$(hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .activeWorkspace.id') - hyprctl monitors -j | jq -r --arg ws "$active_workspace" "${JQ_MONITOR_GEO} .[] | select(.activeWorkspace.id == (\$ws | tonumber)) | format_geo" - hyprctl clients -j | jq -r --arg ws "$active_workspace" '.[] | select(.workspace.id == ($ws | tonumber)) | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"' +# 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 } -# Keep hyprpicker alive until after grim captures so the screenshot sees the -# frozen overlay rather than live content shifting during teardown. -cleanup_freeze() { - [[ -n $PID ]] && kill $PID 2>/dev/null +cleanup() { + [[ -n $FREEZE_PID ]] && kill $FREEZE_PID 2>/dev/null + set_no_hw_cursors "$NO_HW_CURSORS" } -trap cleanup_freeze EXIT +trap cleanup EXIT -# Select based on mode -case "$MODE" in -region) - hyprpicker -r -z >/dev/null 2>&1 & - PID=$! - sleep .1 - SELECTION=$(slurp 2>/dev/null) - ;; -windows) - hyprpicker -r -z >/dev/null 2>&1 & - PID=$! - sleep .1 - SELECTION=$(get_rectangles | slurp -r 2>/dev/null) - ;; -fullscreen) - SELECTION=$(hyprctl monitors -j | jq -r "${JQ_MONITOR_GEO} .[] | select(.focused == true) | format_geo") - ;; -smart | *) - RECTS=$(get_rectangles) - hyprpicker -r -z >/dev/null 2>&1 & - PID=$! - sleep .1 - SELECTION=$(echo "$RECTS" | slurp 2>/dev/null) - - # If the selection area is L * W < 20, we'll assume you were trying to select whichever - # window or output it was inside of to prevent accidental 2px snapshots - if [[ $SELECTION =~ ^([0-9]+),([0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]]; then - if ((${BASH_REMATCH[3]} * ${BASH_REMATCH[4]} < 20)); then - click_x="${BASH_REMATCH[1]}" - click_y="${BASH_REMATCH[2]}" - - while IFS= read -r rect; do - if [[ $rect =~ ^([0-9]+),([0-9]+)[[:space:]]([0-9]+)x([0-9]+) ]]; then - rect_x="${BASH_REMATCH[1]}" - rect_y="${BASH_REMATCH[2]}" - rect_width="${BASH_REMATCH[3]}" - rect_height="${BASH_REMATCH[4]}" - - if ((click_x >= rect_x && click_x < rect_x + rect_width && click_y >= rect_y && click_y < rect_y + rect_height)); then - SELECTION="${rect_x},${rect_y} ${rect_width}x${rect_height}" - break - fi - fi - done <<<"$RECTS" - fi - fi - ;; -esac +set_no_hw_cursors 0 +{ read -r FREEZE_PID; read -r SELECTION; } < <(omarchy-capture-region "$MODE" --keep-freeze) [[ -z $SELECTION ]] && exit 0 @@ -130,15 +64,16 @@ case "$PROCESSING" in slurp) grim -g "$SELECTION" "$FILEPATH" || exit 1 echo "$FILEPATH" - wl-copy <"$FILEPATH" + wl-copy --type image/png <"$FILEPATH" - ( - ACTION=$(notify-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" -t 10000 -i "$FILEPATH" -A "default=edit") - [[ $ACTION == "default" ]] && open_editor "$FILEPATH" - ) >/dev/null 2>&1 & + # 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 + grim -g "$SELECTION" - | wl-copy --type image/png ;; save) grim -g "$SELECTION" "$FILEPATH" || exit 1 diff --git a/bin/omarchy-capture-text b/bin/omarchy-capture-text new file mode 100755 index 0000000000..84260c4eff --- /dev/null +++ b/bin/omarchy-capture-text @@ -0,0 +1,26 @@ +#!/bin/bash + +# omarchy:summary=Extract text from a screenshot region with OCR +# omarchy:group=capture +# omarchy:examples=omarchy capture text + +# Keep hyprpicker alive until after grim captures so the screenshot sees the +# frozen overlay rather than live content shifting during teardown. +cleanup_freeze() { + [[ -n $PID ]] && kill $PID 2>/dev/null +} +trap cleanup_freeze EXIT + +hyprpicker -r -z >/dev/null 2>&1 & +PID=$! +sleep .1 +SELECTION=$(slurp 2>/dev/null) + +[[ -z $SELECTION ]] && exit 0 + +TEXT=$(grim -g "$SELECTION" - | tesseract stdin stdout --oem 1 --psm 6 -l "${OMARCHY_OCR_LANGS:-eng}" --dpi 300 -c preserve_interword_spaces=1 2>/dev/null) || exit 1 + +[[ -z $TEXT ]] && exit 1 + +printf "%s" "$TEXT" | wl-copy +omarchy-notification-send -g 󰴑 "Copied text from selection to clipboard" diff --git a/bin/omarchy-capture-text-extraction b/bin/omarchy-capture-text-extraction deleted file mode 100755 index 4b49b16bf9..0000000000 --- a/bin/omarchy-capture-text-extraction +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Extract text from a screenshot region with OCR -# omarchy:group=capture -# omarchy:examples=omarchy capture ocr - -# Keep hyprpicker alive until after grim captures so the screenshot sees the -# frozen overlay rather than live content shifting during teardown. -cleanup_freeze() { - [[ -n $PID ]] && kill $PID 2>/dev/null -} -trap cleanup_freeze EXIT - -hyprpicker -r -z >/dev/null 2>&1 & -PID=$! -sleep .1 -SELECTION=$(slurp 2>/dev/null) - -[[ -z $SELECTION ]] && exit 0 - -TEXT=$(grim -g "$SELECTION" - | tesseract stdin stdout --oem 1 --psm 6 -l "${OMARCHY_OCR_LANGS:-eng}" --dpi 300 -c preserve_interword_spaces=1 2>/dev/null) || exit 1 - -[[ -z $TEXT ]] && exit 1 - -printf "%s" "$TEXT" | wl-copy -notify-send "󰴑 Copied text from selection to clipboard" diff --git a/bin/omarchy-capture-webcam-list b/bin/omarchy-capture-webcam-list new file mode 100755 index 0000000000..bc01f6b9b2 --- /dev/null +++ b/bin/omarchy-capture-webcam-list @@ -0,0 +1,33 @@ +#!/bin/bash + +# omarchy:summary=List webcam devices that support video capture +# omarchy:hidden=true + +capture_capable() { + local device="$1" + + v4l2-ctl --device "$device" --info 2>/dev/null | awk ' + /^[[:space:]]*Device Caps[[:space:]]*:/ { inspect = 1; next } + inspect && /^[[:space:]]*Video Capture/ { found = 1 } + END { exit !found } + ' +} + +name="" +emitted=0 + +while IFS= read -r line; do + if [[ -n $line && $line != [[:space:]]* ]]; then + name="$line" + emitted=0 + elif (( ! emitted )); then + device="${line#"${line%%[![:space:]]*}"}" + + if [[ $device == /dev/video* ]] && capture_capable "$device"; then + emitted=1 + printf '%s %s\n' "$device" "$name" + fi + fi +done < <(v4l2-ctl --list-devices 2>/dev/null) + +exit 0 diff --git a/bin/omarchy-capture-webcam-resize b/bin/omarchy-capture-webcam-resize new file mode 100755 index 0000000000..cde61e087c --- /dev/null +++ b/bin/omarchy-capture-webcam-resize @@ -0,0 +1,149 @@ +#!/bin/bash + +# omarchy:summary=Resize the active webcam recording overlay +# omarchy:group=capture +# omarchy:args= +# omarchy:examples=omarchy capture webcam resize smaller | omarchy-capture-webcam-resize reset + +set -euo pipefail + +readonly MARGIN=40 +readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region" + +usage() { + echo "Usage: omarchy-capture-webcam-resize " >&2 + exit 1 +} + +hypr_dispatch() { + local lua="$1" + shift + + hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null +} + +action=${1:-} +case $action in +smaller | larger | reset | small | medium | large) ;; +*) usage ;; +esac + +if ! client=$(hyprctl clients -j 2>/dev/null | jq -cer 'first(.[] | select(.title == "WebcamOverlay")) // empty' 2>/dev/null); then + exit 0 +fi + +read -r address current_width current_height monitor_id < <( + jq -r '[.address, .size[0], .size[1], .monitor] | @tsv' <<<"$client" +) + +[[ -n $address && $current_width =~ ^[0-9]+$ && $current_height =~ ^[0-9]+$ && $monitor_id =~ ^[0-9]+$ ]] || exit 0 +((current_width > 0 && current_height > 0)) || exit 0 + +if ! monitor=$(hyprctl monitors -j 2>/dev/null | jq -cer --argjson id "$monitor_id" 'first(.[] | select(.id == $id)) // empty' 2>/dev/null); then + exit 0 +fi + +read -r monitor_x monitor_y monitor_width monitor_height < <( + jq -r ' + . as $monitor | + (($monitor.transform // 0) % 2 == 1) as $rotated | + [ + .x, + .y, + (((if $rotated then .height else .width end) / .scale) | floor), + (((if $rotated then .width else .height end) / .scale) | floor) + ] | @tsv + ' <<<"$monitor" +) + +[[ $monitor_x =~ ^-?[0-9]+$ && $monitor_y =~ ^-?[0-9]+$ && $monitor_width =~ ^[0-9]+$ && $monitor_height =~ ^[0-9]+$ ]] || exit 0 + +# Anchor to the recorded region when there is one, so a window picked on a wide +# display keeps the camera in its own corner. Full-monitor captures, the portal +# backend, and resizes outside a recording publish none and fall back here. +anchor_x=$monitor_x +anchor_y=$monitor_y +anchor_width=$monitor_width +anchor_height=$monitor_height + +if [[ -f $REGION_FILE ]] && region=$(<"$REGION_FILE"); then + if [[ $region =~ ^([0-9]+)x([0-9]+)\+(-?[0-9]+)\+(-?[0-9]+)$ ]]; then + anchor_width=${BASH_REMATCH[1]} + anchor_height=${BASH_REMATCH[2]} + anchor_x=${BASH_REMATCH[3]} + anchor_y=${BASH_REMATCH[4]} + fi +fi + +# A tall, narrow region can't fit presets scaled from its own height, so cap the +# height they scale from to what the width allows — the large preset is the +# widest at 3/10 of it. Scaling the ladder as a whole leaves small, medium and +# large distinct sizes for smaller and larger to step between. +scale_height=$anchor_height +available_width=$((anchor_width - 2 * MARGIN)) +((available_width > 0 && scale_height * 3 / 10 > available_width)) && + scale_height=$((available_width * 10 / 3)) + +# Scale the 8:9 portrait presets from that height so they occupy the same +# proportion of a 1080p, HiDPI, ultrawide, or 6K recording. +small_height=$(((scale_height * 9 + 25) / 50)) +small_width=$(((small_height * 8 + 4) / 9)) +medium_height=$(((scale_height + 2) / 4)) +medium_width=$(((medium_height * 8 + 4) / 9)) +large_height=$(((scale_height * 27 + 40) / 80)) +large_width=$(((large_height * 8 + 4) / 9)) + +target_width=$current_width +target_height=$current_height +case $action in +small) + target_width=$small_width + target_height=$small_height + ;; +medium | reset) + target_width=$medium_width + target_height=$medium_height + ;; +large) + target_width=$large_width + target_height=$large_height + ;; +smaller) + if ((large_width < current_width)); then + target_width=$large_width + target_height=$large_height + elif ((medium_width < current_width)); then + target_width=$medium_width + target_height=$medium_height + elif ((small_width < current_width)); then + target_width=$small_width + target_height=$small_height + fi + ;; +larger) + if ((small_width > current_width)); then + target_width=$small_width + target_height=$small_height + elif ((medium_width > current_width)); then + target_width=$medium_width + target_height=$medium_height + elif ((large_width > current_width)); then + target_width=$large_width + target_height=$large_height + fi + ;; +esac + +target_x=$((anchor_x + anchor_width - target_width - MARGIN)) +target_y=$((anchor_y + anchor_height - target_height - MARGIN)) + +((target_x < anchor_x + MARGIN)) && target_x=$((anchor_x + MARGIN)) +((target_y < anchor_y + MARGIN)) && target_y=$((anchor_y + MARGIN)) + +window="address:$address" +hypr_dispatch \ + "hl.dsp.window.resize({ window = \"$window\", x = $target_width, y = $target_height })" \ + resizewindowpixel "exact $target_width $target_height,$window" +hypr_dispatch \ + "hl.dsp.window.move({ window = \"$window\", x = $target_x, y = $target_y })" \ + movewindowpixel "exact $target_x $target_y,$window" diff --git a/bin/omarchy-channel-current b/bin/omarchy-channel-current new file mode 100755 index 0000000000..cc35e13c2e --- /dev/null +++ b/bin/omarchy-channel-current @@ -0,0 +1,30 @@ +#!/bin/bash + +# omarchy:summary=Print the active Omarchy package channel + +set -euo pipefail + +# Dev is a linked source checkout: OMARCHY_PATH points somewhere other than +# the package-backed install at /usr/share/omarchy. This takes precedence over +# the package channels, since the checkout overrides whatever is installed. +if [[ $OMARCHY_PATH != "/usr/share/omarchy" ]]; then + echo dev + exit 0 +fi + +# omarchy-dev / omarchy-settings-dev are only shipped from the edge package +# repo, so their presence alone means edge. omarchy / omarchy-settings come +# from either the stable or rc mirror, distinguished by omarchy-version-channel +# (which reports the mirror as its first token). +if pacman -Q omarchy-dev omarchy-settings-dev >/dev/null 2>&1; then + echo edge +elif pacman -Q omarchy omarchy-settings >/dev/null 2>&1; then + channel=$(omarchy-version-channel 2>/dev/null | awk '{ print $1 }') + case "$channel" in + stable) echo stable ;; + rc) echo rc ;; + *) echo unknown ;; + esac +else + echo unknown +fi diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index b6c15d7131..7e11238850 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -1,22 +1,99 @@ #!/bin/bash -# omarchy:summary=Set the Omarchy channel, which dictates what git branch and package repository is used. +# omarchy:summary=Set the Omarchy package channel. # omarchy:args= # omarchy:requires-sudo=true -if (($# == 0)); then - echo "Usage: omarchy-channel-set [stable|rc|edge|dev]" - exit 1 -else - channel="$1" -fi +set -euo pipefail + +usage() { echo "Usage: omarchy-channel-set [stable|rc|edge|dev]"; } +fail() { echo "Error: $*" >&2; exit 1; } + +confirm_dev() { + cat <<'WARNING' + +The dev channel links Omarchy directly to a checkout of the source in ~/omarchy. +It's exclusively intended for developers working on Omarchy itself. + +WARNING + + gum confirm --default=false "Switch to dev channel?" +} + +validate_dev_checkout() { + local checkout="$1" + + if [[ -e $checkout && ! -d $checkout/.git ]]; then + fail "$checkout already exists and is not a git checkout." + fi + + if [[ -d $checkout/.git && ( ! -d $checkout/bin || ! -d $checkout/default || ! -d $checkout/shell ) ]]; then + fail "$checkout is a git checkout, but it does not look like Omarchy." + fi +} + +link_dev_checkout() { + local checkout="$1" + [[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout" + + omarchy-dev-link "$checkout" --no-reboot +} + +(( $# > 0 )) || { usage; exit 1; } + +dev_checkout="" +channel="$1" +leaving_dev=0 case "$channel" in -"stable") omarchy-branch-set "master" && omarchy-refresh-pacman "stable" ;; -"rc") omarchy-branch-set "rc" && omarchy-refresh-pacman "rc" ;; -"edge") omarchy-branch-set "master" && omarchy-refresh-pacman "edge" ;; -"dev") omarchy-branch-set "dev" && omarchy-refresh-pacman "edge" ;; -*) echo "Unknown channel: $channel"; exit 1; ;; + stable) + pacman_channel=stable + packages=(omarchy omarchy-settings) + ;; + rc) + pacman_channel=rc + packages=(omarchy omarchy-settings) + ;; + edge) + pacman_channel=edge + packages=(omarchy-dev omarchy-settings-dev) + ;; + dev) + confirm_dev || { echo "Cancelled."; exit 0; } + dev_checkout="$HOME/omarchy" + validate_dev_checkout "$dev_checkout" + pacman_channel=edge + packages=(omarchy-dev omarchy-settings-dev) + ;; + *) + echo "Unknown channel: $channel" >&2 + usage >&2 + exit 1 + ;; esac +if [[ -z $dev_checkout && $OMARCHY_PATH != "/usr/share/omarchy" ]]; then + leaving_dev=1 +fi + +if [[ -n $dev_checkout ]]; then + link_dev_checkout "$dev_checkout" + export OMARCHY_PATH="$dev_checkout" + export PATH="$OMARCHY_PATH/bin:$PATH" + omarchy-state set reboot-required +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[@]}" + +if [[ -z $dev_checkout ]]; then + omarchy-dev-unlink --no-reboot + export OMARCHY_PATH=/usr/share/omarchy + + if (( leaving_dev )); then + omarchy-state set reboot-required + fi +fi + omarchy-update -y diff --git a/bin/omarchy-chromium-copy-url-host b/bin/omarchy-chromium-copy-url-host new file mode 100755 index 0000000000..d097ee265d --- /dev/null +++ b/bin/omarchy-chromium-copy-url-host @@ -0,0 +1,50 @@ +#!/bin/bash + +# omarchy:summary=Native messaging host: copy a Chromium tab URL to the clipboard +# omarchy:hidden=true + +set -euo pipefail + +SCRIPT_PATH="${BASH_SOURCE[0]}" +export OMARCHY_PATH="${OMARCHY_PATH:-$(cd -- "$(dirname -- "$SCRIPT_PATH")/.." && pwd)}" +export PATH="$OMARCHY_PATH/bin:/usr/local/bin:/usr/bin:$PATH" + +parse_url() { + jq -r '.url // empty' 2>/dev/null <<<"$1" || true +} + +copy_url() { + local url="$1" + + [[ -n $url ]] || return 1 + printf '%s' "$url" | wl-copy --type text/plain + omarchy-notification-send -g 󰅍 "URL copied to clipboard" +} + +reply_copied() { + if [[ $1 == "true" ]]; then + printf '\x0f\x00\x00\x00{"copied":true}' + else + printf '\x10\x00\x00\x00{"copied":false}' + fi +} + +main() { + local length payload url + + length=$(head -c4 | od -An -v -tu4 --endian=little | tr -d ' ') + [[ -n ${length:-} ]] && (( length > 0 )) || exit 0 + + payload=$(head -c "$length") + url=$(parse_url "$payload") + + if copy_url "$url"; then + reply_copied true + else + reply_copied false + fi +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/bin/omarchy-chromium-ytdlp-host b/bin/omarchy-chromium-ytdlp-host new file mode 100755 index 0000000000..ce50635a5c --- /dev/null +++ b/bin/omarchy-chromium-ytdlp-host @@ -0,0 +1,199 @@ +#!/bin/bash + +# omarchy:summary=Native messaging host: download the URL sent by the yt-dlp Chromium extension +# omarchy:hidden=true + +set -euo pipefail + +SCRIPT_PATH="${BASH_SOURCE[0]}" + +# The browser launches us without Omarchy's environment, so locate the repo from +# our own path when OMARCHY_PATH isn't already set. Export it — omarchy-shell (used +# by omarchy-osd) needs it to find the running shell, and silently no-ops without it. +export OMARCHY_PATH="${OMARCHY_PATH:-$(cd -- "$(dirname -- "$SCRIPT_PATH")/.." && pwd)}" + +# Make sure the Omarchy bin and yt-dlp are reachable when launched by the browser. +export PATH="$OMARCHY_PATH/bin:/usr/local/bin:/usr/bin:$PATH" + +DOWNLOAD_DIR="${OMARCHY_YTDLP_DIR:-$HOME/Videos}" + +parse_url() { + jq -r '.url // empty' 2>/dev/null <<<"$1" || true +} + +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 --exec. +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 +} + +# `--` keeps a path that starts with `-` from being parsed as an mpv option. +playback_command() { + printf 'mpv -- %q' "$1" +} + +# 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() { + omarchy-osd -i 󰇚 -p "$1" -d 8000 >/dev/null 2>&1 || true +} + +osd_close() { + omarchy-shell -q osd close >/dev/null 2>&1 || true +} + +download_url() { + local url="$1" + + 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 --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), 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*) + pct=${line#OMARCHY_PROG$'\t'} + intpct=${pct%%.*} + intpct=${intpct//[^0-9]/} + [[ -n $intpct && $intpct != "$last" ]] || continue # skip no-op repeats + # Throttle to ~4 redraws/sec so fast downloads don't spawn a flurry of processes. + er=$EPOCHREALTIME + nowms=$((${er%[.,]*} * 1000 + 10#${er##*[.,]} / 1000)) + ((nowms - lastms >= 250)) || continue + last=$intpct + lastms=$nowms + osd_progress "$intpct" + ;; + OMARCHY_FILE*) + 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 --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.%(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. + local preview + preview="$(mktemp --suffix=.jpg)" + ffmpeg -y -i "$filepath" -ss 00:00:00.1 -vframes 1 \ + -vf "crop='min(iw,ih)':'min(iw,ih)',scale=256:256" -q:v 2 \ + "$preview" -loglevel quiet 2>/dev/null || true + + # Best-effort: the download already succeeded, and under `set -e` a failed + # toast would exit before the thumbnail cleanup below is ever scheduled. + omarchy-notification-send -g 󰄬 "Download complete" "$title" \ + -t 10000 --image "${preview:-$filepath}" \ + --exec "$(playback_command "$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 + # toast. + ( + sleep 2 + rm -f "$preview" + ) & + else + omarchy-notification-send -u critical -g 󰅖 "Download failed" "$url" + fi + + exit 0 +} + +main() { + local length payload url + + # Detached worker: this is what actually runs yt-dlp and fires notifications. + if [[ ${1:-} == "--download" ]]; then + download_url "$2" + fi + + # Native messaging frame: 4-byte little-endian length prefix, then UTF-8 JSON. + length=$(head -c4 | od -An -v -tu4 --endian=little | tr -d ' ') + [[ -n ${length:-} ]] && ((length > 0)) || exit 0 + + payload=$(head -c "$length") + + # Ack with an empty message so the extension's sendNativeMessage callback resolves cleanly. + printf '\x02\x00\x00\x00{}' + + url=$(parse_url "$payload") + [[ -n $url ]] || exit 0 + valid_url "$url" || exit 0 + + # Detach the download so this host exits promptly and frees the browser's port. + setsid -f "$SCRIPT_PATH" --download "$url" /dev/null 2>&1 +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/bin/omarchy-clipboard-open b/bin/omarchy-clipboard-open new file mode 100755 index 0000000000..98f26bb2ba --- /dev/null +++ b/bin/omarchy-clipboard-open @@ -0,0 +1,70 @@ +#!/bin/bash + +# omarchy:summary=Open a clipboard history entry +# omarchy:group=clipboard +# omarchy:args=--history-index +# omarchy:hidden=true + +history_index="" +history_path="$HOME/.local/state/omarchy/clipboard-history.json" + +while (( $# > 0 )); do + case "$1" in + --history-index) + history_index="${2:-}" + shift 2 + ;; + *) + echo "Usage: omarchy-clipboard-open --history-index " >&2 + exit 1 + ;; + esac +done + +[[ $history_index =~ ^[0-9]+$ ]] || exit 1 +[[ -r $history_path ]] || exit 1 + +entry_type=$(jq -er --argjson index "$history_index" '.[$index].type' "$history_path") || exit 1 + +open_image() { + local path="$1" + + [[ -r $path ]] || exit 1 + exec tensaku-edit "$path" +} + +open_text() { + local text="$1" + local url="" + local open_dir="" + local open_file="" + + url=$(grep -Eom1 'https?://[^[:space:]"'\''<>]+' <<<"$text" || true) + if [[ -z $url && $text =~ ^[[:space:]]*([[:alnum:]][[:alnum:].-]+\.[[:alpha:]]{2,})(/[^[:space:]]*)?[[:space:]]*$ ]]; then + url="https://${BASH_REMATCH[1]}${BASH_REMATCH[2]}" + fi + + if [[ -n $url ]]; then + exec omarchy-launch-browser "$url" + fi + + open_dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/clipboard-open" + mkdir -p "$open_dir" + open_file=$(mktemp --tmpdir="$open_dir" clipboard.XXXXXX.txt) || exit 1 + printf '%s' "$text" >"$open_file" + exec omarchy-launch-editor "$open_file" +} + +case "$entry_type" in + image) + path=$(jq -er --argjson index "$history_index" '.[$index].path' "$history_path") || exit 1 + open_image "$path" + ;; + text) + text=$(jq -er --argjson index "$history_index" '.[$index].text' "$history_path") || exit 1 + open_text "$text" + ;; + *) + exit 1 + ;; +esac diff --git a/bin/omarchy-clipboard-paste-file b/bin/omarchy-clipboard-paste-file new file mode 100755 index 0000000000..431d6c2b57 --- /dev/null +++ b/bin/omarchy-clipboard-paste-file @@ -0,0 +1,33 @@ +#!/bin/bash + +# omarchy:summary=Copy a file to the clipboard and paste it +# omarchy:group=clipboard +# omarchy:args=[--copy-only] +# omarchy:examples=omarchy clipboard paste file image/png /tmp/screenshot.png +# omarchy:hidden=true + +copy_only=false + +if [[ ${1:-} == "--copy-only" ]]; then + copy_only=true + shift +fi + +mime=${1:-} +path=${2:-} + +if [[ -z $mime || -z $path ]]; then + echo "Usage: omarchy-clipboard-paste-file [--copy-only] " >&2 + exit 1 +fi + +[[ -r $path ]] || exit 1 + +wl-copy --type "$mime" < "$path" + +if [[ $copy_only == "true" ]]; then + exit +fi + +sleep 0.15 +wtype -M shift -k Insert -m shift 2>/dev/null || true diff --git a/bin/omarchy-clipboard-paste-text b/bin/omarchy-clipboard-paste-text new file mode 100755 index 0000000000..b8ac8e3127 --- /dev/null +++ b/bin/omarchy-clipboard-paste-text @@ -0,0 +1,63 @@ +#!/bin/bash + +# omarchy:summary=Copy text to the clipboard and type or paste it +# omarchy:group=clipboard +# omarchy:args=[--shift-insert] [--copy-only] [--history-index |] +# omarchy:examples=omarchy clipboard paste text "hello" | omarchy clipboard paste text --shift-insert "hello" +# omarchy:hidden=true + +use_shift_insert=false +copy_only=false +history_index="" +text="" + +while (( $# > 0 )); do + case "$1" in + --shift-insert) + use_shift_insert=true + shift + ;; + --copy-only) + copy_only=true + shift + ;; + --history-index) + history_index="${2:-}" + shift 2 + ;; + *) + break + ;; + esac +done + +copy_history_entry() { + local history_path="$HOME/.local/state/omarchy/clipboard-history.json" + + [[ $history_index =~ ^[0-9]+$ ]] || exit + jq -e --argjson index "$history_index" '.[$index].type == "text" and (.[$index].text | type == "string")' "$history_path" >/dev/null || exit + jq -j --argjson index "$history_index" '.[$index].text' "$history_path" | wl-copy +} + +if [[ -n $history_index ]]; then + copy_history_entry + if [[ $copy_only != "true" ]]; then + use_shift_insert=true + fi +else + text=${1:-} + [[ -n $text ]] || exit + printf '%s' "$text" | wl-copy +fi + +if [[ $copy_only == "true" ]]; then + exit +fi + +sleep 0.15 + +if [[ $use_shift_insert == "true" ]]; then + wtype -M shift -k Insert -m shift 2>/dev/null || true +else + wtype "$text" 2>/dev/null || true +fi diff --git a/bin/omarchy-cmd-terminal-cwd b/bin/omarchy-cmd-terminal-cwd index 88da9a69bc..afb7d622c7 100755 --- a/bin/omarchy-cmd-terminal-cwd +++ b/bin/omarchy-cmd-terminal-cwd @@ -4,18 +4,24 @@ # omarchy:hidden=true terminal_pid=$(hyprctl activewindow | awk '/pid:/ {print $2}') -shell_pid=$(pgrep -P "$terminal_pid" | tail -n1) +kitty_socket="$XDG_RUNTIME_DIR/omarchy-kitty-$terminal_pid" +cwd="" -if [[ -n $shell_pid ]]; then - cwd=$(readlink -f "/proc/$shell_pid/cwd" 2>/dev/null) - shell=$(readlink -f "/proc/$shell_pid/exe" 2>/dev/null) +if [[ -S $kitty_socket ]]; then + cwd=$(kitten @ --to "unix:$kitty_socket" ls --match "state:focused" 2>/dev/null | + jq -r '.[].tabs[].windows[].cwd // empty') +else + shell_pid=$(pgrep -P "$terminal_pid" | tail -n1) - # Check if $shell is a valid shell and $cwd is a directory. - if grep -qs "$shell" /etc/shells && [[ -d $cwd ]]; then - echo "$cwd" - else - echo "$HOME" + if [[ -n $shell_pid ]]; then + cwd=$(readlink -f "/proc/$shell_pid/cwd" 2>/dev/null) + shell=$(readlink -f "/proc/$shell_pid/exe" 2>/dev/null) + grep -Fqsx "$shell" /etc/shells || cwd="" fi +fi + +if [[ -d $cwd ]]; then + echo "$cwd" else echo "$HOME" fi diff --git a/bin/omarchy-config-direct-boot b/bin/omarchy-config-direct-boot deleted file mode 100755 index ebd618b9d3..0000000000 --- a/bin/omarchy-config-direct-boot +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Add or remove an EFI boot entry for the Omarchy UKI, allowing the system to boot directly -# omarchy:requires-sudo=true - -if [[ ! -d /sys/firmware/efi ]]; then - echo "Error: System is not booted in UEFI mode" >&2 - exit 1 -fi - -if ! efibootmgr &>/dev/null; then - echo "Error: efibootmgr is not available or not functional" >&2 - exit 1 -fi - -if cat /sys/class/dmi/id/bios_vendor 2>/dev/null | grep -qi "American Megatrends"; then - echo "Error: American Megatrends firmware may not safely support custom EFI entries" >&2 - exit 1 -fi - -if cat /sys/class/dmi/id/bios_vendor 2>/dev/null | grep -qi "Apple"; then - echo "Error: Apple firmware uses its own boot manager" >&2 - exit 1 -fi - -existing_entry=$(efibootmgr | grep -E "^Boot[0-9A-Fa-f]+\*? Omarchy([[:space:]]|$)" | head -1) - -if [[ -n $existing_entry ]]; then - boot_num=$(echo "$existing_entry" | sed -n 's/^Boot\([0-9A-Fa-f]\+\).*/\1/p') - - if gum confirm "Disable direct boot (remove Omarchy EFI entry)?"; then - echo "Removing EFI boot entry $boot_num" - sudo efibootmgr --bootnum "$boot_num" --delete-bootnum >/dev/null - fi - - exit 0 -else - uki_file=$(find /boot/EFI/Linux/ -name "omarchy*.efi" -printf "%f\n" 2>/dev/null | head -1) - - if [[ -z $uki_file ]]; then - echo "Error: No Omarchy UKI found in /boot/EFI/Linux/" >&2 - exit 1 - fi - - boot_source=$(findmnt -n -o SOURCE /boot) - disk=$(echo "$boot_source" | sed 's/p\?[0-9]*$//') - part=$(echo "$boot_source" | grep -o 'p\?[0-9]*$' | sed 's/^p//') - - if gum confirm "Setup direct boot (so snapshot booting must be done via bios)?"; then - echo "Creating EFI boot entry for $uki_file" - - sudo efibootmgr --create \ - --disk "$disk" \ - --part "$part" \ - --label "Omarchy" \ - --loader "\\EFI\\Linux\\$uki_file" - fi -fi diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch new file mode 100755 index 0000000000..f58247a09b --- /dev/null +++ b/bin/omarchy-crash-watch @@ -0,0 +1,86 @@ +#!/bin/bash + +# omarchy:summary=Watch for process crashes and offer an AI diagnosis +# omarchy:hidden=true + +# systemd-coredump journals every core dump under a known MESSAGE_ID with +# structured COREDUMP_* fields, which carry more than the core filenames do. + +set -uo pipefail + +# See systemd.journal-fields(7). +readonly COREDUMP_MESSAGE_ID=fc2e22bc6ee647b6b90729ab34a250b1 + +# nf-md-robot_dead, escaped so this file reads without a Nerd Font. +readonly CRASH_GLYPH=$'\U000f16a1' + +# Crash loops dump core repeatedly, so announce each program at most once a +# window. +readonly dedupe_seconds=${OMARCHY_CRASH_DEDUPE_SECONDS:-60} + +# Extended regex of process names never worth announcing. +readonly ignore_pattern=${OMARCHY_CRASH_IGNORE:-} + +declare -A last_notified + +announce() { + local comm=$1 pid=$2 exe=$3 signal=$4 exec_command + + exec_command=$(printf 'omarchy-agent-crash %q %q %q %q' "$pid" "$comm" "$exe" "$signal") + + # The shell owns org.freedesktop.Notifications, so a shell crash takes the + # notification server down with it and a toast sent into that gap is lost. + # Wait for the restarted shell to claim the name again: the crash least + # likely to be delivered is the one most worth reporting. + omarchy-notification-wait || return 1 + + # --exec rather than a libnotify action: the shell runs clicks from its own + # omarchy-exec hint and never emits ActionInvoked. Keeps the default + # "omarchy-action" app name too, the only one shouldBypassDnd() lets through. + omarchy-notification-send \ + --urgency critical \ + --glyph "$CRASH_GLYPH" \ + --exec "$exec_command" \ + "Process crashed: $comm" \ + "Click to diagnose with AI" +} + +# -n 0 so a restart does not re-announce crashes already dealt with. +journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | + while IFS= read -r entry; do + IFS=$'\t' read -r uid comm pid exe signal < <( + jq -r '[(._UID // "-"), + (.COREDUMP_COMM // "-"), + (.COREDUMP_PID // "-"), + (.COREDUMP_EXE // "-"), + (.COREDUMP_SIGNAL_NAME // "-")] | @tsv' <<<"$entry" 2>/dev/null + ) + + [[ $pid =~ ^[0-9]+$ ]] || continue + + # The toast only offers a diagnosis, so it has nothing to offer until an + # agent is chosen. Checked per crash, not at startup, so picking one takes + # effect without restarting this service. + [[ -n $(omarchy-default-agent) ]] || continue + + # Only this user's crashes; a daemon dumping core is a sysadmin's problem. + [[ $uid =~ ^[0-9]+$ ]] || continue + ((uid == UID)) || continue + + # comm is truncated to 15 characters, so prefer the executable's basename. + name=$comm + [[ $exe == /* ]] && name=${exe##*/} + + [[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue + + # Never announce our own machinery, or it notifies about itself. + [[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue + + now=$EPOCHSECONDS + (((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue + + # Only a delivered toast starts the dedupe window. A failed send that + # counted would suppress the rest of a crash loop for a minute, and + # `journalctl -n 0` never replays what was missed. + announce "$name" "$pid" "$exe" "$signal" && last_notified[$name]=$now + done diff --git a/bin/omarchy-debug b/bin/omarchy-debug index c163e17172..59fbc4fce4 100755 --- a/bin/omarchy-debug +++ b/bin/omarchy-debug @@ -37,7 +37,7 @@ fi cat > "$LOG_FILE" </dev/null || echo "unknown") +Omarchy Package: $(pacman -Q omarchy-dev 2>/dev/null || pacman -Q omarchy 2>/dev/null || echo "unknown") ========================================= SYSTEM INFORMATION @@ -75,7 +75,7 @@ ACTION=$(gum choose "${OPTIONS[@]}") case "$ACTION" in "Upload log") echo "Uploading debug log to logs.omarchy.org..." - URL=$(curl -sf -F "file=@$LOG_FILE" https://logs.omarchy.org/) + URL=$(curl -sf -F "file=@$LOG_FILE" -Fexpires=24 https://logs.omarchy.org/) if (( $? == 0 )) && [[ -n $URL ]]; then echo "✓ Log uploaded successfully!" echo "Share this URL:" diff --git a/bin/omarchy-debug-idle b/bin/omarchy-debug-idle new file mode 100755 index 0000000000..62a38cf10a --- /dev/null +++ b/bin/omarchy-debug-idle @@ -0,0 +1,61 @@ +#!/bin/bash + +# omarchy:summary=Show idle, screensaver, and lock diagnostics +# omarchy:group=debug +# omarchy:args=[log-lines] +# omarchy:examples=omarchy debug idle | omarchy-debug-idle 400 + +lines=${1:-200} +if [[ ! $lines =~ ^[0-9]+$ ]]; then + lines=200 +fi + +section() { + printf '\n== %s ==\n' "$1" +} + +section "Time" +date -Is + +section "Idle IPC status" +omarchy-shell idle status 2>&1 | jq . 2>/dev/null || omarchy-shell idle status 2>&1 || true + +section "Quickshell instances" +quickshell list -p "$OMARCHY_PATH/shell" --any-display 2>&1 || true + +section "Recent idle logs" +quickshell --no-color log -p "$OMARCHY_PATH/shell" --any-display --tail "$lines" --log-times -r 'quickshell.wayland.idle_notify=true' 2>&1 \ + | grep -Ei 'omarchy idle|idle_notify|screensaver|lock|error|warn|failed' || true + +section "Persisted shell log" +journalctl -t omarchy-shell -n "$lines" --no-pager --quiet 2>/dev/null || true + +section "Relevant processes" +ps -eo pid=,args= \ + | grep -E 'quickshell -n -p|omarchy-system-sleep-monitor|systemd-inhibit.*Lock screen before suspend|org\.omarchy\.screensaver|omarchy-screensaver|(^|/| )ttfx( |$)' \ + | grep -v grep || true + +section "Sleep lock service" +systemctl --user status omarchy-sleep-lock.service --no-pager 2>/dev/null || true + +section "Hyprland screensaver clients" +hyprctl clients -j 2>/dev/null \ + | jq -r '.[] | select(.class == "org.omarchy.screensaver" or .initialClass == "org.omarchy.screensaver") | [.pid,.class,.initialClass,.title,.focusHistoryID] | @tsv' || true + +section "Idle inhibitors" +hyprctl clients -j 2>/dev/null \ + | jq -r '.[] | select(.inhibitingIdle == true or ((.tags // []) | index("noidle"))) | [.pid,.class,.title,((.tags // []) | join(",")),.inhibitingIdle] | @tsv' || true + +section "Screensaver detector" +if hyprctl clients -j 2>/dev/null | jq -e '.[] | select(.class == "org.omarchy.screensaver" or .initialClass == "org.omarchy.screensaver")' >/dev/null; then + echo "running-window" +elif pgrep -f '[o]rg.omarchy.screensaver' >/dev/null; then + echo "running-process" +elif omarchy-toggle-enabled screensaver-off; then + echo "disabled" +else + echo "stopped" +fi + +section "Lock detector" +omarchy-shell lock status 2>&1 | jq . 2>/dev/null || omarchy-shell lock status 2>&1 || true diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent new file mode 100755 index 0000000000..1f89b7652b --- /dev/null +++ b/bin/omarchy-default-agent @@ -0,0 +1,66 @@ +#!/bin/bash + +# omarchy:summary=Set and launch the default coding agent +# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush] +# omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude + +installing=false +if [[ ${1:-} == "--install" ]]; then + installing=true + shift +fi + +agent_file="$HOME/.config/omarchy/defaults/agent" + +if (($# == 0)); then + if [[ -f $agent_file ]]; then + read -r agent <"$agent_file" + fi + + # Silent when unset rather than defaulting: Omarchy picks no agent for you, so + # the menu leaves every entry unchecked until one is chosen. + [[ -n ${agent:-} ]] && echo "$agent" + exit 0 +fi + +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" ;; +agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;; +copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;; +*) + echo "Usage: omarchy-default-agent " + exit 1 + ;; +esac + +agent_package=${agent_package:-$agent} + +if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then + exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent" +fi + +if ! mise use -g "$agent_package"; then + if [[ $installing == "true" ]]; then + echo "Could not install $name with mise" >&2 + else + echo "Could not set $name as the default coding agent" >&2 + fi + exit 1 +fi + +mkdir -p "$(dirname "$agent_file")" +printf '%s\n' "$agent" >"$agent_file" + +if [[ $installing == "true" ]]; then + printf '\033[2J\033[3J\033[H' + exec omarchy-agent --inline +else + exec omarchy-agent +fi diff --git a/bin/omarchy-default-browser b/bin/omarchy-default-browser index 0b38d7600b..3e54bd3b7d 100755 --- a/bin/omarchy-default-browser +++ b/bin/omarchy-default-browser @@ -4,37 +4,48 @@ # omarchy:args=[chromium|chrome|brave|brave-origin|edge|firefox|zen] # omarchy:examples=omarchy default browser firefox | omarchy default browser brave +installing=false +if [[ ${1:-} == "--install" ]]; then + installing=true + shift +fi + if (($# == 0)); then - case "$(xdg-settings get default-web-browser)" in + case "$(env -u BROWSER xdg-settings get default-web-browser)" in chromium.desktop) echo "chromium" ;; google-chrome.desktop) echo "chrome" ;; brave-browser.desktop) echo "brave" ;; - brave-origin-beta.desktop) echo "brave-origin" ;; + brave-origin.desktop) echo "brave-origin" ;; microsoft-edge.desktop) echo "edge" ;; firefox.desktop) echo "firefox" ;; zen.desktop) echo "zen" ;; - *) xdg-settings get default-web-browser ;; + *) env -u BROWSER xdg-settings get default-web-browser ;; esac exit 0 fi case "$1" in -chromium) desktop_id="chromium.desktop"; name="Chromium"; glyph="" ;; -chrome) desktop_id="google-chrome.desktop"; name="Chrome"; glyph="󰊯" ;; -brave) desktop_id="brave-browser.desktop"; name="Brave"; glyph="󰖟" ;; -brave-origin) desktop_id="brave-origin-beta.desktop"; name="Brave Origin"; glyph="󰖟" ;; -edge) desktop_id="microsoft-edge.desktop"; name="Edge"; glyph="󰇩" ;; -firefox) desktop_id="firefox.desktop"; name="Firefox"; glyph="󰈹" ;; -zen) desktop_id="zen.desktop"; name="Zen"; glyph="󰰷" ;; +chromium) browser="chromium"; command="chromium"; desktop_id="chromium.desktop"; name="Chromium"; glyph= ;; +chrome) browser="chrome"; command="google-chrome-stable"; desktop_id="google-chrome.desktop"; name="Chrome"; glyph=󰊯 ;; +brave) browser="brave"; command="brave"; desktop_id="brave-browser.desktop"; name="Brave"; glyph=󰖟 ;; +brave-origin) browser="brave-origin"; command="brave-origin"; desktop_id="brave-origin.desktop"; name="Brave Origin"; glyph=󰖟 ;; +edge) browser="edge"; command="microsoft-edge-stable"; desktop_id="microsoft-edge.desktop"; name="Edge"; glyph=󰇩 ;; +firefox) browser="firefox"; command="firefox"; desktop_id="firefox.desktop"; name="Firefox"; glyph=󰈹 ;; +zen) browser="zen"; command="zen-browser"; desktop_id="zen.desktop"; name="Zen"; glyph=󰖟 ;; *) echo "Usage: omarchy-default-browser " exit 1 ;; esac -xdg-settings set default-web-browser "$desktop_id" -xdg-mime default "$desktop_id" x-scheme-handler/http -xdg-mime default "$desktop_id" x-scheme-handler/https -xdg-mime default "$desktop_id" text/html +if omarchy-cmd-missing "$command"; then + if [[ $installing == "false" ]]; then + exec omarchy-launch-floating-terminal-with-presentation omarchy-default-browser --install "$browser" + else + omarchy-install-browser "$browser" || exit 1 + fi +fi + +env -u BROWSER xdg-settings set default-web-browser "$desktop_id" || exit 1 -notify-send -u low "$glyph $name is now the default browser" +omarchy-notification-send -g $glyph "$name is now the default browser" diff --git a/bin/omarchy-default-editor b/bin/omarchy-default-editor index 3184645078..25134081d3 100755 --- a/bin/omarchy-default-editor +++ b/bin/omarchy-default-editor @@ -1,30 +1,59 @@ #!/bin/bash -# omarchy:summary=Set the default editor for $EDITOR +# omarchy:summary=Set the default editor used by omarchy-launch-editor # omarchy:args=[code|cursor|zed|sublime_text|helix|vim|emacs|nvim] # omarchy:examples=omarchy default editor | omarchy default editor code | omarchy default editor helix +installing=false +if [[ ${1:-} == "--install" ]]; then + installing=true + shift +fi + +editor_file="$HOME/.local/state/omarchy/defaults/editor" + if (($# == 0)); then - sed -n 's/^export EDITOR=//p' ~/.config/uwsm/default | head -n 1 + if [[ -f $editor_file ]]; then + read -r editor <"$editor_file" + fi + + [[ -n $editor ]] && echo "$editor" || echo "nvim" exit 0 fi case "$1" in -code) editor="code"; name="VSCode"; glyph="" ;; -cursor) editor="cursor"; name="Cursor"; glyph="" ;; -zed | zeditor) editor="zeditor"; name="Zed"; glyph="" ;; -sublime_text) editor="sublime_text"; name="Sublime Text"; glyph="" ;; -helix) editor="helix"; name="Helix"; glyph="" ;; -vim) editor="vim"; name="Vim"; glyph="" ;; -emacs) editor="emacs"; name="Emacs"; glyph="" ;; -nvim) editor="nvim"; name="Neovim"; glyph="" ;; +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= ;; +helix) selection="helix"; editor="helix"; name="Helix"; glyph= ;; +vim) selection="vim"; editor="vim"; name="Vim"; glyph= ;; +emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;; +nvim) selection="nvim"; editor="nvim"; name="Neovim"; glyph= ;; *) echo "Usage: omarchy-default-editor " exit 1 ;; esac -sed -i "s/^export EDITOR=.*/export EDITOR=$editor/" ~/.config/uwsm/default +if omarchy-cmd-missing "$editor"; then + if [[ $installing == "false" ]]; then + exec omarchy-launch-floating-terminal-with-presentation omarchy-default-editor --install "$selection" + else + case "$selection" in + code) omarchy-install-editor-vscode ;; + cursor) omarchy-pkg-add cursor-bin ;; + zed) omarchy-install-editor-zed ;; + sublime_text) omarchy-pkg-add sublime-text-4 ;; + helix) omarchy-install-editor-helix ;; + vim) omarchy-pkg-add vim ;; + emacs) omarchy-install-editor-emacs ;; + nvim) omarchy-pkg-add neovim ;; + esac || exit 1 + fi +fi + +mkdir -p "$(dirname "$editor_file")" +printf '%s\n' "$editor" >"$editor_file" -export EDITOR="$editor" -notify-send -u low "$glyph $name is now the default editor" " Effective after logging out" +omarchy-notification-send -g $glyph "$name is now the default editor" diff --git a/bin/omarchy-default-terminal b/bin/omarchy-default-terminal index b44b051154..ff0e5df2c8 100755 --- a/bin/omarchy-default-terminal +++ b/bin/omarchy-default-terminal @@ -4,8 +4,15 @@ # omarchy:args=[alacritty|foot|ghostty|kitty] # omarchy:examples=omarchy default terminal ghostty | omarchy default terminal kitty +installing=false +if [[ ${1:-} == "--install" ]]; then + installing=true + shift +fi + if (($# == 0)); then - desktop_id=$(grep -vE '^($|#)' ~/.config/xdg-terminals.list 2>/dev/null | head -n 1) + desktop_id=$(xdg-terminal-exec --print-id 2>/dev/null || true) + desktop_id=${desktop_id%%:*} case "$desktop_id" in Alacritty.desktop) echo "alacritty" ;; foot.desktop) echo "foot" ;; @@ -17,20 +24,28 @@ if (($# == 0)); then fi case "$1" in -alacritty) desktop_id="Alacritty.desktop"; name="Alacritty"; glyph="" ;; -foot) desktop_id="foot.desktop"; name="Foot"; glyph="" ;; -ghostty) desktop_id="com.mitchellh.ghostty.desktop"; name="Ghostty"; glyph="" ;; -kitty) desktop_id="kitty.desktop"; name="Kitty"; glyph="" ;; +alacritty) terminal="alacritty"; desktop_id="Alacritty.desktop"; name="Alacritty"; glyph= ;; +foot) terminal="foot"; desktop_id="foot.desktop"; name="Foot"; glyph= ;; +ghostty) terminal="ghostty"; desktop_id="com.mitchellh.ghostty.desktop"; name="Ghostty"; glyph= ;; +kitty) terminal="kitty"; desktop_id="kitty.desktop"; name="Kitty"; glyph= ;; *) echo "Usage: omarchy-default-terminal " exit 1 ;; esac +if omarchy-cmd-missing "$terminal"; then + if [[ $installing == "false" ]]; then + exec omarchy-launch-floating-terminal-with-presentation omarchy-default-terminal --install "$terminal" + else + omarchy-install-terminal "$terminal" || exit 1 + fi +fi + cat >~/.config/xdg-terminals.list <&2 + shift + ;; + -h|--help) + echo "Usage: omarchy-dev-add-migration [--no-edit]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + echo "Usage: omarchy-dev-add-migration [--no-edit]" >&2 + exit 1 + ;; + esac +done + +cd "${OMARCHY_PATH:-$(pwd)}" +mkdir -p migrations +migration_file="migrations/$(git log -1 --format=%cd --date=unix).sh" +touch "$migration_file" + +if (( ! no_edit )); then + nvim "$migration_file" fi -echo $migration_file +printf '%s\n' "$PWD/$migration_file" diff --git a/bin/omarchy-dev-benchmark b/bin/omarchy-dev-benchmark deleted file mode 100755 index 1108e798c4..0000000000 --- a/bin/omarchy-dev-benchmark +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Measure Omarchy CLI response times -# omarchy:args=[--repeat=] -# omarchy:examples=omarchy dev benchmark | omarchy dev benchmark --repeat=10 - -set -euo pipefail - -OMARCHY_BIN_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -CLI="$OMARCHY_BIN_DIR/omarchy" -REPEAT=5 - -show_help() { - cat <<'EOF' -Usage: - omarchy dev benchmark [--repeat=] - -Measure response times for common Omarchy CLI surfaces. - -Options: - --repeat= Number of times to run each case (default: 5) -EOF -} - -now_us() { - local now="${EPOCHREALTIME/./}" - printf '%s' "$now" -} - -format_ms() { - local us="$1" - printf '%d.%03d' "$(( us / 1000 ))" "$(( us % 1000 ))" -} - -run_case() { - local label="$1" - shift - local total_us=0 - local min_us=0 - local max_us=0 - local elapsed_us=0 - local start_us=0 - local end_us=0 - local status=0 - - for (( i = 1; i <= REPEAT; i++ )); do - start_us=$(now_us) - if "$@" >/dev/null; then - status=0 - else - status=$? - fi - end_us=$(now_us) - - if (( status != 0 )); then - printf '%-34s failed (exit %d)\n' "$label" "$status" - return "$status" - fi - - elapsed_us=$(( end_us - start_us )) - total_us=$(( total_us + elapsed_us )) - - if (( i == 1 || elapsed_us < min_us )); then - min_us=$elapsed_us - fi - - if (( elapsed_us > max_us )); then - max_us=$elapsed_us - fi - done - - printf '%-34s avg %8s ms min %8s ms max %8s ms\n' \ - "$label" \ - "$(format_ms "$(( total_us / REPEAT ))")" \ - "$(format_ms "$min_us")" \ - "$(format_ms "$max_us")" -} - -while (( $# > 0 )); do - case "$1" in - --repeat=*) - REPEAT="${1#*=}" - ;; - --help | -h) - show_help - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - show_help >&2 - exit 2 - ;; - esac - shift -done - -if [[ ! $REPEAT =~ ^[0-9]+$ ]] || (( REPEAT < 1 )); then - echo "--repeat must be a positive integer" >&2 - exit 2 -fi - -printf 'Omarchy CLI benchmark (%d runs each)\n\n' "$REPEAT" -run_case "omarchy" "$CLI" -run_case "omarchy --help" "$CLI" --help -run_case "omarchy commands" "$CLI" commands -run_case "omarchy commands --json" "$CLI" commands --json -run_case "omarchy commands --all --json" "$CLI" commands --all --json -run_case "omarchy theme set --help" "$CLI" theme set --help -run_case "omarchy screenshot --help" "$CLI" screenshot --help -run_case "omarchy restart --help" "$CLI" restart --help -run_case "omarchy theme current" "$CLI" theme current diff --git a/bin/omarchy-dev-benchmark-cli b/bin/omarchy-dev-benchmark-cli new file mode 100755 index 0000000000..f5dc3882ac --- /dev/null +++ b/bin/omarchy-dev-benchmark-cli @@ -0,0 +1,111 @@ +#!/bin/bash + +# omarchy:summary=Measure Omarchy CLI response times +# omarchy:args=[--repeat=] +# omarchy:examples=omarchy dev benchmark cli | omarchy dev benchmark cli --repeat=10 + +set -euo pipefail + +OMARCHY_BIN_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +CLI="$OMARCHY_BIN_DIR/omarchy" +REPEAT=5 + +show_help() { + cat <<'EOF' +Usage: + omarchy dev benchmark cli [--repeat=] + +Measure response times for common Omarchy CLI surfaces. + +Options: + --repeat= Number of times to run each case (default: 5) +EOF +} + +now_us() { + local now="${EPOCHREALTIME/./}" + printf '%s' "$now" +} + +format_ms() { + local us="$1" + printf '%d.%03d' "$(( us / 1000 ))" "$(( us % 1000 ))" +} + +run_case() { + local label="$1" + shift + local total_us=0 + local min_us=0 + local max_us=0 + local elapsed_us=0 + local start_us=0 + local end_us=0 + local status=0 + + for (( i = 1; i <= REPEAT; i++ )); do + start_us=$(now_us) + if "$@" >/dev/null; then + status=0 + else + status=$? + fi + end_us=$(now_us) + + if (( status != 0 )); then + printf '%-34s failed (exit %d)\n' "$label" "$status" + return "$status" + fi + + elapsed_us=$(( end_us - start_us )) + total_us=$(( total_us + elapsed_us )) + + if (( i == 1 || elapsed_us < min_us )); then + min_us=$elapsed_us + fi + + if (( elapsed_us > max_us )); then + max_us=$elapsed_us + fi + done + + printf '%-34s avg %8s ms min %8s ms max %8s ms\n' \ + "$label" \ + "$(format_ms "$(( total_us / REPEAT ))")" \ + "$(format_ms "$min_us")" \ + "$(format_ms "$max_us")" +} + +while (( $# > 0 )); do + case "$1" in + --repeat=*) + REPEAT="${1#*=}" + ;; + --help | -h) + show_help + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + show_help >&2 + exit 2 + ;; + esac + shift +done + +if [[ ! $REPEAT =~ ^[0-9]+$ ]] || (( REPEAT < 1 )); then + echo "--repeat must be a positive integer" >&2 + exit 2 +fi + +printf 'Omarchy CLI benchmark (%d runs each)\n\n' "$REPEAT" +run_case "omarchy" "$CLI" +run_case "omarchy --help" "$CLI" --help +run_case "omarchy commands" "$CLI" commands +run_case "omarchy commands --json" "$CLI" commands --json +run_case "omarchy commands --all --json" "$CLI" commands --all --json +run_case "omarchy theme set --help" "$CLI" theme set --help +run_case "omarchy screenshot --help" "$CLI" screenshot --help +run_case "omarchy restart --help" "$CLI" restart --help +run_case "omarchy theme current" "$CLI" theme current diff --git a/bin/omarchy-dev-benchmark-theme-switcher b/bin/omarchy-dev-benchmark-theme-switcher new file mode 100755 index 0000000000..9bbc042463 --- /dev/null +++ b/bin/omarchy-dev-benchmark-theme-switcher @@ -0,0 +1,160 @@ +#!/bin/bash + +# omarchy:summary=Measure theme switcher cache and selector prep times +# omarchy:args=[--repeat=] [--keep-cache] +# omarchy:examples=omarchy dev benchmark theme switcher | omarchy dev benchmark theme-switcher --repeat=10 +# omarchy:aliases=omarchy dev benchmark theme-switcher + +set -euo pipefail + +OMARCHY_BIN_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPEAT=5 +KEEP_CACHE=false + +show_help() { + cat <<'EOF' +Usage: + omarchy dev benchmark theme switcher [--repeat=] [--keep-cache] + +Measure the non-interactive parts of the theme switcher: +- theme preview index build (omarchy-theme-switcher before UI handoff) +- lazy selector row prep used by the interactive theme switcher +- full thumbnail cache warmup cost (omarchy-menu-images --cache-only) + +Options: + --repeat= Number of warm runs to measure for each case (default: 5) + --keep-cache Keep the temporary benchmark cache and print its path +EOF +} + +now_us() { + local now="${EPOCHREALTIME/./}" + printf '%s' "$now" +} + +format_ms() { + local us="$1" + printf '%d.%03d' "$(( us / 1000 ))" "$(( us % 1000 ))" +} + +measure_once() { + local start_us end_us + start_us=$(now_us) + "$@" >/dev/null + end_us=$(now_us) + printf '%s' "$(( end_us - start_us ))" +} + +run_case() { + local label="$1" + shift + local total_us=0 + local min_us=0 + local max_us=0 + local elapsed_us=0 + + for (( i = 1; i <= REPEAT; i++ )); do + elapsed_us=$(measure_once "$@") + total_us=$(( total_us + elapsed_us )) + + if (( i == 1 || elapsed_us < min_us )); then + min_us=$elapsed_us + fi + + if (( elapsed_us > max_us )); then + max_us=$elapsed_us + fi + done + + printf '%-34s avg %8s ms min %8s ms max %8s ms\n' \ + "$label" \ + "$(format_ms "$(( total_us / REPEAT ))")" \ + "$(format_ms "$min_us")" \ + "$(format_ms "$max_us")" +} + +while (( $# > 0 )); do + case "$1" in + --repeat=*) + REPEAT="${1#*=}" + ;; + --keep-cache) + KEEP_CACHE=true + ;; + --help | -h) + show_help + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + show_help >&2 + exit 2 + ;; + esac + shift +done + +if [[ ! $REPEAT =~ ^[0-9]+$ ]] || (( REPEAT < 1 )); then + echo "--repeat must be a positive integer" >&2 + exit 2 +fi + +benchmark_cache=$(mktemp -d) +thumbnail_cache=$(mktemp -d) +stub_bin=$(mktemp -d) + +cleanup() { + rm -rf "$stub_bin" + + if [[ $KEEP_CACHE == "true" ]]; then + printf 'Benchmark cache: %s\n' "$benchmark_cache" + printf 'Thumbnail cache: %s\n' "$thumbnail_cache" + else + rm -rf "$benchmark_cache" "$thumbnail_cache" + fi +} +trap cleanup EXIT + +cat >"$stub_bin/omarchy-menu-images" <<'EOF' +#!/bin/bash +exit 0 +EOF +chmod +x "$stub_bin/omarchy-menu-images" + +benchmark_env=( + env + "XDG_CACHE_HOME=$benchmark_cache" + "PATH=$stub_bin:$OMARCHY_BIN_DIR:$PATH" +) + +thumbnail_env=( + env + "XDG_CACHE_HOME=$thumbnail_cache" + "PATH=$stub_bin:$OMARCHY_BIN_DIR:$PATH" +) + +preview_dir="$benchmark_cache/omarchy/theme-selector/previews" +thumbnail_preview_dir="$thumbnail_cache/omarchy/theme-selector/previews" + +build_theme_index() { + "${benchmark_env[@]}" "$OMARCHY_BIN_DIR/omarchy-theme-switcher" +} + +prepare_selector_lazy() { + "${benchmark_env[@]}" "$OMARCHY_BIN_DIR/omarchy-menu-images" --prepare-only --lazy-thumbnails --show-labels --filterable "$preview_dir" +} + +prepare_image_cache() { + "${thumbnail_env[@]}" "$OMARCHY_BIN_DIR/omarchy-menu-images" --cache-only "$thumbnail_preview_dir" +} + +printf 'Theme switcher benchmark (%d warm runs each)\n\n' "$REPEAT" +printf '%-34s %s ms\n' "theme index cold" "$(format_ms "$(measure_once build_theme_index)")" +run_case "theme index warm" build_theme_index +printf '%-34s %s ms\n' "selector prep cold (lazy)" "$(format_ms "$(measure_once prepare_selector_lazy)")" +run_case "selector prep warm (lazy)" prepare_selector_lazy +"${thumbnail_env[@]}" "$OMARCHY_BIN_DIR/omarchy-theme-switcher" >/dev/null +printf '%-34s %s ms\n' "thumbnail cache cold" "$(format_ms "$(measure_once prepare_image_cache)")" +run_case "thumbnail cache warm" prepare_image_cache + +printf '\nTheme previews: %d\n' "$(find -L "$preview_dir" -maxdepth 1 -type f 2>/dev/null | wc -l)" diff --git a/bin/omarchy-dev-bin-metadata b/bin/omarchy-dev-bin-metadata deleted file mode 100755 index 99e87e8680..0000000000 --- a/bin/omarchy-dev-bin-metadata +++ /dev/null @@ -1,89 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Show Omarchy bin metadata fields and defaults -# omarchy:args=[--json] - -set -euo pipefail - -show_json() { - jq -n '{ - ok: true, - defaults: { - group: "first filename segment after omarchy-", - name: "remaining filename segments with dashes converted to spaces", - route: "omarchy ", - binary: "filename", - requires_sudo: false - }, - fields: [ - {name: "summary", required: true, type: "string", note: "One-line human and agent-facing description."}, - {name: "group", required: false, type: "string", note: "Only set when the route group should differ from the filename-derived group."}, - {name: "name", required: false, type: "string", note: "Only set when the route name should differ from the filename-derived name. May be empty for root commands."}, - {name: "args", required: false, type: "string", note: "Only set when the command accepts arguments."}, - {name: "examples", required: false, type: "string", note: "Pipe-separated examples."}, - {name: "aliases", required: false, type: "string", note: "Pipe-separated alternate routes, e.g. omarchy screenshot."}, - {name: "requires-sudo", required: false, type: "true", default: false, note: "Only include when true."}, - {name: "hidden", required: false, type: "true", default: false, note: "Hide from default command listings; visible with --all."} - ] - }' -} - -show_help() { - cat <<'EOF' -Omarchy bin metadata - -Metadata lives in the top comment block of each executable bin/omarchy-* file. -Keep it slim: define only fields that are required or override defaults. - -Required: - # omarchy:summary= - -Inferred defaults: - group first filename segment after omarchy- - name remaining filename segments, with dashes converted to spaces - route omarchy - binary filename - requires-sudo false - hidden false - -Optional fields: - # omarchy:group= route override only - # omarchy:name= route override only; may be empty - # omarchy:args= only if the command accepts args - # omarchy:examples= | pipe-separated examples - # omarchy:aliases= | pipe-separated alternate routes - # omarchy:requires-sudo=true only when true - # omarchy:hidden=true hide from default command listings - -Do not define: - binary inferred from filename - usage derived from route + args - false flags or empty args - -Examples: - # omarchy:summary=Restart Walker and related user services - - # omarchy:summary=Take a screenshot - # omarchy:group=capture - # omarchy:args=[smart|region|windows|fullscreen] [slurp|copy] [--editor=] - # omarchy:examples=omarchy screenshot | omarchy capture screenshot region - # omarchy:aliases=omarchy screenshot -EOF -} - -case "${1:-}" in ---json) - show_json - ;; ---help | -h) - show_help - ;; -"") - show_help - ;; -*) - echo "Unknown option: $1" >&2 - show_help >&2 - exit 2 - ;; -esac diff --git a/bin/omarchy-dev-font b/bin/omarchy-dev-font new file mode 100755 index 0000000000..eedab0f622 --- /dev/null +++ b/bin/omarchy-dev-font @@ -0,0 +1,652 @@ +#!/usr/bin/python3 +# omarchy:summary=Add branded glyphs to the Omarchy icon font +# omarchy:args=[list|add] [--codepoint U+E9xx] [--font PATH] +# omarchy:examples=omarchy dev font list | omarchy dev font add ollama https://simpleicons.org/icons/ollama.svg +"""Append monochrome SVG marks to default/fonts/omarchy/omarchy.ttf. + +The font is a private-use icon font: the menu renders a mark by setting +"iconFont":"omarchy" on an entry and using the glyph's codepoint as its +icon. Adding a mark means appending a glyph here, then pointing a menu +entry at the codepoint this prints. + +Marks must be monochrome single-path SVGs so the menu can draw them in the +active theme's foreground and selection colors. Brand icon sets like +simpleicons.org publish exactly that shape; app favicons often do not. + +The glyph is scaled into the same 64..960 box the existing marks use, so a +new mark lands at the same optical size as the ones already in the font. +""" + +import argparse +import math +import os +import re +import struct +import sys +import urllib.request + +UPEM = 1024 +ART_BOX = (64, 64, 960, 960) # the box every existing mark is drawn in +TOL = 0.6 # cubic -> quadratic error tolerance, in font units +PUA_FIRST = 0xE900 + + +# --- SVG path parsing ------------------------------------------------------ + +NUM = re.compile(r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?') +CMD = re.compile(r'[MmZzLlHhVvCcSsQqTtAa]') + + +def tokenize(d): + out, i = [], 0 + while i < len(d): + c = d[i] + if CMD.match(c): + out.append(c) + i += 1 + elif c in ' ,\t\r\n': + i += 1 + else: + m = NUM.match(d, i) + if not m: + raise ValueError('bad path data at %d: %r' % (i, d[i:i + 20])) + out.append(float(m.group())) + i = m.end() + return out + + +def arc_to_cubics(p0, rx, ry, phi, large, sweep, p1): + """Endpoint-parameterized SVG arc -> cubic segments.""" + if p0 == p1: + return [] + rx, ry = abs(rx), abs(ry) + if rx == 0 or ry == 0: + return [('L', p1)] + phi = math.radians(phi % 360) + cosp, sinp = math.cos(phi), math.sin(phi) + dx2, dy2 = (p0[0] - p1[0]) / 2.0, (p0[1] - p1[1]) / 2.0 + x1 = cosp * dx2 + sinp * dy2 + y1 = -sinp * dx2 + cosp * dy2 + lam = x1 * x1 / (rx * rx) + y1 * y1 / (ry * ry) + if lam > 1: + s = math.sqrt(lam) + rx, ry = rx * s, ry * s + num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1 + den = rx * rx * y1 * y1 + ry * ry * x1 * x1 + co = math.sqrt(max(0.0, num / den)) if den else 0.0 + if large == sweep: + co = -co + cx1 = co * rx * y1 / ry + cy1 = -co * ry * x1 / rx + cx = cosp * cx1 - sinp * cy1 + (p0[0] + p1[0]) / 2.0 + cy = sinp * cx1 + cosp * cy1 + (p0[1] + p1[1]) / 2.0 + + def angle(ux, uy, vx, vy): + dot = ux * vx + uy * vy + n = math.hypot(ux, uy) * math.hypot(vx, vy) + a = math.acos(max(-1.0, min(1.0, dot / n))) if n else 0.0 + return -a if ux * vy - uy * vx < 0 else a + + theta = angle(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry) + delta = angle((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry) + if not sweep and delta > 0: + delta -= 2 * math.pi + elif sweep and delta < 0: + delta += 2 * math.pi + + segs = [] + n = max(1, int(math.ceil(abs(delta) / (math.pi / 2) - 1e-9))) + step = delta / n + k = 4.0 / 3.0 * math.tan(step / 4.0) + + def at(t): + x, y = rx * math.cos(t), ry * math.sin(t) + return (cosp * x - sinp * y + cx, sinp * x + cosp * y + cy) + + def deriv(t): + x, y = -rx * math.sin(t), ry * math.cos(t) + return (cosp * x - sinp * y, sinp * x + cosp * y) + + for i in range(n): + t0 = theta + i * step + t1 = t0 + step + a, b = at(t0), at(t1) + da, db = deriv(t0), deriv(t1) + segs.append(('C', + (a[0] + k * da[0], a[1] + k * da[1]), + (b[0] - k * db[0], b[1] - k * db[1]), + b)) + return segs + + +def parse_path(d): + """Return one list of absolute segments per subpath.""" + t = tokenize(d) + subs, cur = [], None + pos = start = (0.0, 0.0) + prev_c = prev_q = None + i, cmd = 0, None + while i < len(t): + if isinstance(t[i], str): + cmd = t[i] + i += 1 + rel = cmd.islower() + c = cmd.upper() + + def take(n): + nonlocal i + v = t[i:i + n] + i += n + return v + + def abspt(x, y): + return (pos[0] + x, pos[1] + y) if rel else (x, y) + + if c == 'M': + x, y = take(2) + pos = start = abspt(x, y) + if cur: + subs.append(cur) + cur = [('M', pos)] + cmd = 'l' if rel else 'L' # implicit lineto for extra pairs + prev_c = prev_q = None + elif c == 'Z': + if cur: + subs.append(cur) + cur = None + pos = start + prev_c = prev_q = None + elif c in 'LHV': + if c == 'L': + x, y = take(2) + p = abspt(x, y) + elif c == 'H': + x = take(1)[0] + p = (pos[0] + x, pos[1]) if rel else (x, pos[1]) + else: + y = take(1)[0] + p = (pos[0], pos[1] + y) if rel else (pos[0], y) + cur.append(('L', p)) + pos = p + prev_c = prev_q = None + elif c in 'CS': + if c == 'C': + x1, y1, x2, y2, x, y = take(6) + c1, c2, p = abspt(x1, y1), abspt(x2, y2), abspt(x, y) + else: + x2, y2, x, y = take(4) + c2, p = abspt(x2, y2), abspt(x, y) + c1 = (2 * pos[0] - prev_c[0], 2 * pos[1] - prev_c[1]) if prev_c else pos + cur.append(('C', c1, c2, p)) + prev_c, prev_q = c2, None + pos = p + elif c in 'QT': + if c == 'Q': + x1, y1, x, y = take(4) + q, p = abspt(x1, y1), abspt(x, y) + else: + x, y = take(2) + p = abspt(x, y) + q = (2 * pos[0] - prev_q[0], 2 * pos[1] - prev_q[1]) if prev_q else pos + cur.append(('C', + (pos[0] + 2.0 / 3 * (q[0] - pos[0]), pos[1] + 2.0 / 3 * (q[1] - pos[1])), + (p[0] + 2.0 / 3 * (q[0] - p[0]), p[1] + 2.0 / 3 * (q[1] - p[1])), + p)) + prev_q, prev_c = q, None + pos = p + elif c == 'A': + rx, ry, rot, large, sweep, x, y = take(7) + p = abspt(x, y) + cur.extend(arc_to_cubics(pos, rx, ry, rot, int(large), int(sweep), p)) + pos = p + prev_c = prev_q = None + else: + raise ValueError('unsupported path command %r' % cmd) + if cur: + subs.append(cur) + return subs + + +# --- outline conversion ---------------------------------------------------- + +def cubic_to_quads(p0, p1, p2, p3, tol, depth=0): + """Approximate one cubic with quadratics: [(control, end), ...].""" + ex = p0[0] - 3 * p1[0] + 3 * p2[0] - p3[0] + ey = p0[1] - 3 * p1[1] + 3 * p2[1] - p3[1] + if math.sqrt(3) / 36 * math.hypot(ex, ey) <= tol or depth >= 8: + return [(((3 * p1[0] - p0[0] + 3 * p2[0] - p3[0]) / 4.0, + (3 * p1[1] - p0[1] + 3 * p2[1] - p3[1]) / 4.0), p3)] + + def mid(a, b): + return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0) + + p01, p12, p23 = mid(p0, p1), mid(p1, p2), mid(p2, p3) + p012, p123 = mid(p01, p12), mid(p12, p23) + m = mid(p012, p123) + return (cubic_to_quads(p0, p01, p012, m, tol, depth + 1) + + cubic_to_quads(m, p123, p23, p3, tol, depth + 1)) + + +def contours_from_svg(path_d, view): + """SVG path -> TrueType contours [[(x, y, on_curve), ...], ...].""" + vbx, vby, vbw, vbh = view + ax0, ay0, ax1, ay1 = ART_BOX + scale = min((ax1 - ax0) / vbw, (ay1 - ay0) / vbh) + ox = ax0 + ((ax1 - ax0) - vbw * scale) / 2.0 + oy = ay0 + ((ay1 - ay0) - vbh * scale) / 2.0 + + def tf(p): + # SVG y grows down, font y grows up. + return (ox + (p[0] - vbx) * scale, oy + (vbh - (p[1] - vby)) * scale) + + contours = [] + for sub in parse_path(path_d): + pts, cursor = [], None + for seg in sub: + if seg[0] in ('M', 'L'): + cursor = tf(seg[1]) + pts.append((cursor[0], cursor[1], True)) + elif seg[0] == 'C': + c1, c2, p = tf(seg[1]), tf(seg[2]), tf(seg[3]) + for q, end in cubic_to_quads(cursor, c1, c2, p, TOL): + pts.append((q[0], q[1], False)) + pts.append((end[0], end[1], True)) + cursor = p + if len(pts) > 2: + contours.append(clean(pts)) + return orient(contours) + + +def clean(pts): + """Round to integers, drop duplicates and the redundant closing point.""" + out = [] + for x, y, on in pts: + p = (int(round(x)), int(round(y)), on) + if not out or out[-1] != p: + out.append(p) + while len(out) > 1 and out[-1][:2] == out[0][:2] and out[-1][2] and out[0][2]: + out.pop() + return out + + +def area(pts): + a = 0.0 + for i in range(len(pts)): + x0, y0 = pts[i][0], pts[i][1] + x1, y1 = pts[(i + 1) % len(pts)][0], pts[(i + 1) % len(pts)][1] + a += x0 * y1 - x1 * y0 + return a / 2.0 + + +def orient(contours): + """TrueType draws outer contours clockwise (negative shoelace area). + + Flipping the whole set preserves each hole's direction relative to its + outer contour, which is what keeps counters (eyes, cutouts) unfilled. + """ + if contours and area(max(contours, key=lambda c: abs(area(c)))) > 0: + return [list(reversed(c)) for c in contours] + return contours + + +# --- glyph and table encoding ---------------------------------------------- + +def encode_glyph(contours): + if not contours: + return b'' + xs = [p[0] for c in contours for p in c] + ys = [p[1] for c in contours for p in c] + out = [struct.pack('>hhhhh', len(contours), min(xs), min(ys), max(xs), max(ys))] + ends, n = [], 0 + for c in contours: + n += len(c) + ends.append(n - 1) + out.append(struct.pack('>%dH' % len(ends), *ends)) + out.append(struct.pack('>H', 0)) # no instructions + + flags, xdel, ydel = [], [], [] + px = py = 0 + for c in contours: + for x, y, on in c: + dx, dy = x - px, y - py + px, py = x, y + f = 1 if on else 0 + if dx == 0: + f |= 0x10 + elif -255 <= dx <= 255: + f |= 0x02 | (0x10 if dx > 0 else 0) + xdel.append(struct.pack('>B', abs(dx))) + else: + xdel.append(struct.pack('>h', dx)) + if dy == 0: + f |= 0x20 + elif -255 <= dy <= 255: + f |= 0x04 | (0x20 if dy > 0 else 0) + ydel.append(struct.pack('>B', abs(dy))) + else: + ydel.append(struct.pack('>h', dy)) + flags.append(struct.pack('>B', f)) + data = b''.join(out + flags + xdel + ydel) + return data + b'\0' * (-len(data) % 4) + + +def read_tables(d): + t = {} + for i in range(struct.unpack('>H', d[4:6])[0]): + off = 12 + i * 16 + tag = d[off:off + 4].decode('latin-1') + s, l = struct.unpack('>II', d[off + 8:off + 16]) + t[tag] = (s, l) + return t + + +def checksum(data): + data += b'\0' * (-len(data) % 4) + return sum(struct.unpack('>%dI' % (len(data) // 4), data)) & 0xFFFFFFFF + + +def read_loca(d, t, ng): + ls = t['loca'][0] + if struct.unpack('>h', d[t['head'][0] + 50:t['head'][0] + 52])[0] == 0: + return [x * 2 for x in struct.unpack('>%dH' % (ng + 1), d[ls:ls + 2 * (ng + 1)])] + return list(struct.unpack('>%dI' % (ng + 1), d[ls:ls + 4 * (ng + 1)])) + + +def read_cmap(d, cs): + """Read the format 12 subtable; it carries every mapping the font has.""" + m = {} + for i in range(struct.unpack('>H', d[cs + 2:cs + 4])[0]): + pid, eid, off = struct.unpack('>HHI', d[cs + 4 + i * 8:cs + 12 + i * 8]) + sub = cs + off + if struct.unpack('>H', d[sub:sub + 2])[0] != 12: + continue + for j in range(struct.unpack('>I', d[sub + 12:sub + 16])[0]): + s, e, g = struct.unpack('>III', d[sub + 16 + j * 12:sub + 28 + j * 12]) + for c in range(s, e + 1): + m[c] = g + (c - s) + return m + + +def read_names(d, ps, ng): + n = struct.unpack('>H', d[ps + 32:ps + 34])[0] + idx = list(struct.unpack('>%dH' % n, d[ps + 34:ps + 34 + n * 2])) + p = ps + 34 + n * 2 + pool = [] + need = max([i - 258 + 1 for i in idx if i >= 258] or [0]) + while len(pool) < need: + ln = d[p] + pool.append(d[p + 1:p + 1 + ln].decode('latin-1')) + p += 1 + ln + out = [pool[i - 258] if i >= 258 else '#mac%d' % i for i in idx] + return out + ['#mac0'] * (ng - len(out)) + + +def build_post(d, ps, names): + idx, pool = [], [] + for nm in names: + if nm.startswith('#mac'): + idx.append(int(nm[4:])) + else: + idx.append(258 + len(pool)) + pool.append(nm) + body = struct.pack('>H', len(idx)) + struct.pack('>%dH' % len(idx), *idx) + for nm in pool: + body += struct.pack('>B', len(nm)) + nm.encode('latin-1') + return d[ps:ps + 32] + body + + +def build_cmap(m): + """Emit format 4 and 12 subtables under the usual encoding records.""" + groups = [] + for c in sorted(m): + if groups and c == groups[-1][1] + 1 and m[c] == groups[-1][2] + (c - groups[-1][0]): + groups[-1][1] = c + else: + groups.append([c, c, m[c]]) + + segs = [(a, b, g) for a, b, g in groups if b <= 0xFFFF] + [(0xFFFF, 0xFFFF, 0)] + count = len(segs) + pow2 = 2 ** (count.bit_length() - 1) * 2 + f4 = struct.pack('>HHHHHHH', 4, 16 + count * 8, 0, count * 2, + pow2, count.bit_length() - 1, count * 2 - pow2) + f4 += struct.pack('>%dH' % count, *[s[1] for s in segs]) + f4 += struct.pack('>H', 0) + f4 += struct.pack('>%dH' % count, *[s[0] for s in segs]) + deltas = [((s[2] - s[0]) & 0xFFFF) if s[0] != 0xFFFF else 1 for s in segs] + f4 += struct.pack('>%dh' % count, *[x - 65536 if x > 32767 else x for x in deltas]) + f4 += struct.pack('>%dH' % count, *([0] * count)) + + f12 = struct.pack('>HHIII', 12, 0, 16 + len(groups) * 12, 0, len(groups)) + for a, b, g in groups: + f12 += struct.pack('>III', a, b, g) + + f0 = struct.pack('>HHH', 0, 262, 0) + bytes(256) + + body, offsets = b'', {} + for data in (f4, f12, f0): + offsets[id(data)] = len(body) + body += data + records = [(0, 3, f4), (0, 4, f12), (1, 0, f0), (3, 1, f4), (3, 10, f12)] + head = struct.pack('>HH', 0, len(records)) + base = 4 + len(records) * 8 + for pid, eid, data in records: + head += struct.pack('>HHI', pid, eid, base + offsets[id(data)]) + return head + body + + +def assemble(tables): + tags = sorted(tables) + n = len(tags) + sr = 2 ** (n.bit_length() - 1) * 16 + font = struct.pack('>IHHHH', 0x00010000, n, sr, n.bit_length() - 1, n * 16 - sr) + offset = 12 + n * 16 + body, records = b'', [] + for tag in tags: + data = tables[tag] + records.append((tag, checksum(data), offset + len(body), len(data))) + body += data + b'\0' * (-len(data) % 4) + font += b''.join(struct.pack('>4sIII', t.encode('latin-1'), c, o, l) + for t, c, o, l in records) + font += body + adj = (0xB1B0AFBA - checksum(font)) & 0xFFFFFFFF + hs = [o for t, c, o, l in records if t == 'head'][0] + return font[:hs + 8] + struct.pack('>I', adj) + font[hs + 12:] + + +# --- commands -------------------------------------------------------------- + +def load_font(path): + d = open(path, 'rb').read() + t = read_tables(d) + ng = struct.unpack('>H', d[t['maxp'][0] + 4:t['maxp'][0] + 6])[0] + return d, t, ng + + +def glyph_list(path): + d, t, ng = load_font(path) + loca = read_loca(d, t, ng) + names = read_names(d, t['post'][0], ng) + gs = t['glyf'][0] + rows = [] + for cp, gid in sorted(read_cmap(d, t['cmap'][0]).items()): + if cp < PUA_FIRST: + continue + a, b = loca[gid], loca[gid + 1] + box = '' + if b > a: + _, x0, y0, x1, y1 = struct.unpack('>hhhhh', d[gs + a:gs + a + 10]) + box = '%d x %d' % (x1 - x0, y1 - y0) + name = names[gid] + rows.append((cp, '' if name.startswith('#mac') else name, box)) + return rows + + +def add_glyph(font_path, codepoint, name, contours): + d, t, ng = load_font(font_path) + loca = read_loca(d, t, ng) + gs = t['glyf'][0] + glyphs = [d[gs + loca[i]:gs + loca[i + 1]] for i in range(ng)] + + nhm = struct.unpack('>H', d[t['hhea'][0] + 34:t['hhea'][0] + 36])[0] + hm = t['hmtx'][0] + adv, lsb = [], [] + for i in range(ng): + if i < nhm: + a, b = struct.unpack('>Hh', d[hm + i * 4:hm + i * 4 + 4]) + else: + a = adv[-1] + b = struct.unpack('>h', d[hm + nhm * 4 + (i - nhm) * 2:][:2])[0] + adv.append(a) + lsb.append(b) + + names = read_names(d, t['post'][0], ng) + cmap = read_cmap(d, t['cmap'][0]) + + gid = len(glyphs) + glyphs.append(encode_glyph(contours)) + xs = [p[0] for c in contours for p in c] + adv.append(UPEM) + lsb.append(min(xs) if xs else 0) + names.append(name) + cmap[codepoint] = gid + ng = len(glyphs) + + offs, o = [0], 0 + for g in glyphs: + o += len(g) + offs.append(o) + short = offs[-1] <= 0x1FFFE and all(x % 2 == 0 for x in offs) + + hhea = bytearray(d[t['hhea'][0]:t['hhea'][0] + t['hhea'][1]]) + struct.pack_into('>H', hhea, 34, ng) + + maxp = bytearray(d[t['maxp'][0]:t['maxp'][0] + t['maxp'][1]]) + struct.pack_into('>H', maxp, 4, ng) + struct.pack_into('>H', maxp, 6, max(struct.unpack('>H', bytes(maxp[6:8]))[0], + sum(len(c) for c in contours))) + struct.pack_into('>H', maxp, 8, max(struct.unpack('>H', bytes(maxp[8:10]))[0], + len(contours))) + + boxes = [struct.unpack('>hhhhh', g[:10])[1:] for g in glyphs if len(g) >= 10] + head = bytearray(d[t['head'][0]:t['head'][0] + t['head'][1]]) + struct.pack_into('>hhhh', head, 36, + min(b[0] for b in boxes), min(b[1] for b in boxes), + max(b[2] for b in boxes), max(b[3] for b in boxes)) + struct.pack_into('>h', head, 50, 0 if short else 1) + struct.pack_into('>I', head, 8, 0) # checkSumAdjustment, recomputed below + + tables = {tag: d[s:s + l] for tag, (s, l) in t.items()} + tables['glyf'] = b''.join(glyphs) + tables['loca'] = (struct.pack('>%dH' % len(offs), *[x // 2 for x in offs]) if short + else struct.pack('>%dI' % len(offs), *offs)) + tables['hmtx'] = b''.join(struct.pack('>Hh', adv[i], lsb[i]) for i in range(ng)) + tables['hhea'] = bytes(hhea) + tables['maxp'] = bytes(maxp) + tables['cmap'] = build_cmap(cmap) + tables['post'] = build_post(d, t['post'][0], names) + tables['head'] = bytes(head) + + open(font_path, 'wb').write(assemble(tables)) + + +def read_svg(location): + if not location.startswith(('http://', 'https://')): + return open(location, encoding='utf-8').read() + # Icon sites reject the stock urllib agent, so ask like a browser would. + req = urllib.request.Request(location, headers={'User-Agent': 'omarchy-dev-font'}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return r.read().decode('utf-8') + except OSError as e: + sys.exit('could not fetch %s: %s' % (location, e)) + + +def svg_contours(svg, location): + vb = re.search(r'viewBox="([^"]+)"', svg) + if not vb: + sys.exit('%s: no viewBox; need an SVG with a viewBox' % location) + view = tuple(float(x) for x in vb.group(1).replace(',', ' ').split()) + paths = re.findall(r']*\bd="([^"]+)"', svg) + if len(paths) != 1: + sys.exit('%s: expected a single , found %d — flatten the mark to ' + 'one monochrome path first' % (location, len(paths))) + return contours_from_svg(paths[0], view) + + +def default_font(): + root = os.environ.get('OMARCHY_PATH') + if not root: + sys.exit('OMARCHY_PATH is not set') + return os.path.join(root, 'default/fonts/omarchy/omarchy.ttf') + + +def readme_for(font_path): + return os.path.join(os.path.dirname(font_path), 'README.md') + + +def record_source(font_path, codepoint, label, location): + """Append the new mark to the font's README so sources stay tracked.""" + readme = readme_for(font_path) + if not os.path.exists(readme): + return None + lines = open(readme, encoding='utf-8').read().split('\n') + last = max(i for i, l in enumerate(lines) if l.startswith('- `U+')) + entry = '- `U+%04X` — %s' % (codepoint, label) + if location.startswith(('http://', 'https://')): + entry += ', from <%s>' % location + lines.insert(last + 1, entry) + open(readme, 'w', encoding='utf-8').write('\n'.join(lines)) + return readme + + +def main(): + common = argparse.ArgumentParser(add_help=False) + common.add_argument('--font', help='font to edit ' + '(default: $OMARCHY_PATH/default/fonts/omarchy/omarchy.ttf)') + + p = argparse.ArgumentParser(prog='omarchy dev font', parents=[common], + description=__doc__.split('\n')[0]) + sub = p.add_subparsers(dest='command') + sub.add_parser('list', parents=[common], help='show the marks the font carries') + add = sub.add_parser('add', parents=[common], + help='append a mark from a monochrome SVG') + add.add_argument('name', help='glyph name, e.g. ollama') + add.add_argument('svg', help='SVG file or URL') + add.add_argument('--codepoint', help='private-use codepoint (default: next free)') + add.add_argument('--label', help='README label (default: the glyph name)') + args = p.parse_args() + + font = args.font or default_font() + if not os.path.exists(font): + sys.exit('no font at %s' % font) + + if args.command in (None, 'list'): + for cp, name, box in glyph_list(font): + print('U+%04X %s %-12s %s' % (cp, chr(cp), name, box)) + return + + taken = {cp for cp, _, _ in glyph_list(font)} + if args.codepoint: + cp = int(args.codepoint.upper().replace('U+', ''), 16) + if cp in taken: + sys.exit('U+%04X is already used' % cp) + else: + cp = max(taken) + 1 if taken else PUA_FIRST + + contours = svg_contours(read_svg(args.svg), args.svg) + add_glyph(font, cp, args.name, contours) + readme = record_source(font, cp, args.label or args.name, args.svg) + + print('Added %s as U+%04X (%s)' % (args.name, cp, chr(cp))) + print() + print('Next:') + if readme: + print(' - check the new line in %s' % os.path.relpath(readme)) + print(' - point a menu entry at it: "icon":"%s","iconFont":"omarchy"' % chr(cp)) + print(' - bump the charset range in test/shell.d/menu-test.sh to e900-%x' % cp) + print(' - the font is package-owned, so it reaches the desktop through an') + print(' omarchy-settings release, not through omarchy update') + + +if __name__ == '__main__': + main() diff --git a/bin/omarchy-dev-install-ydoo b/bin/omarchy-dev-install-ydoo new file mode 100755 index 0000000000..8a773f9764 --- /dev/null +++ b/bin/omarchy-dev-install-ydoo @@ -0,0 +1,50 @@ +#!/bin/bash + +# omarchy:summary=Install and enable ydotool mouse automation for Omarchy development +# omarchy:requires-sudo=true + +set -euo pipefail + +RULE_FILE="/etc/udev/rules.d/80-uinput.rules" + +if ! getent group input >/dev/null; then + echo "omarchy-dev-install-ydoo: input group does not exist" >&2 + exit 1 +fi + +if ! id -nG "$USER" | tr ' ' '\n' | grep -x input >/dev/null; then + echo "Adding $USER to the input group. You may need to log out and back in before this applies." + pkexec usermod -aG input "$USER" +fi + +if omarchy-cmd-missing ydotool || omarchy-pkg-missing ydotool; then + omarchy-pkg-add ydotool +fi + +pkexec /bin/bash -c ' +set -euo pipefail + +cat >"'"$RULE_FILE"'" <<'"'"'EOF'"'"' +KERNEL=="uinput", GROUP="input", MODE="0660", OPTIONS+="static_node=uinput" +EOF + +modprobe uinput +udevadm control --reload-rules +udevadm trigger /dev/uinput 2>/dev/null || true + +if [[ -e /dev/uinput ]]; then + chgrp input /dev/uinput + chmod 0660 /dev/uinput +fi +' + +systemctl --user reset-failed ydotool.service >/dev/null 2>&1 || true +systemctl --user start ydotool.service + +if ! systemctl --user is-active --quiet ydotool.service; then + echo "omarchy-dev-install-ydoo: ydotool.service did not start" >&2 + systemctl --user status ydotool.service --no-pager >&2 || true + exit 1 +fi + +echo "ydotool is ready." diff --git a/bin/omarchy-dev-link b/bin/omarchy-dev-link new file mode 100755 index 0000000000..ccaa404f0f --- /dev/null +++ b/bin/omarchy-dev-link @@ -0,0 +1,119 @@ +#!/bin/bash + +# omarchy:summary=Point Omarchy at a local checkout after reboot +# omarchy:group=dev +# omarchy:args= [--no-reboot] +# omarchy:examples=omarchy dev link ~/omarchy + +set -euo pipefail + +if (( EUID == 0 )); then + echo "Error: run omarchy-dev-link as your user, not under sudo." >&2 + exit 1 +fi + +prompt_reboot=1 + +# sudo resolves a bare command name against secure_path, never the caller's +# PATH, so a dev-linked checkout is invisible to `sudo omarchy-*`: a command the +# package does not ship yet fails outright, and one it does ship silently runs +# the packaged copy while every unprivileged call runs the checkout. Prepending +# the checkout's bin keeps root on the code being edited — the same trust the +# link already extends to every system script Omarchy runs out of $OMARCHY_PATH. +sudoers_file="/etc/sudoers.d/omarchy-dev-path" +system_secure_path="/usr/local/sbin:/usr/local/bin:/usr/bin" + +if (( $# < 1 || $# > 2 )) || [[ $1 == "-h" || $1 == "--help" ]]; then + cat < [--no-reboot] + +Writes /etc/omarchy.conf so OMARCHY_PATH resolves to +after reboot. This intentionally does not rewrite the running Hyprland, +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. + +Also writes $sudoers_file so sudo resolves omarchy-* +from the checkout instead of the packaged copies. That part takes effect +immediately, no reboot needed. + +Use --no-reboot when another command will handle the reboot prompt. +USAGE + exit 0 +fi + +if (( $# == 2 )); then + if [[ $2 == "--no-reboot" ]]; then + prompt_reboot=0 + else + echo "Usage: omarchy dev link [--no-reboot]" >&2 + exit 1 + fi +fi + +omarchy_conf_quote() { + local value="$1" + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//\$/\\\$} + value=${value//\`/\\\`} + printf '"%s"' "$value" +} + +# A double-quoted sudoers string takes a backslash escape for a literal +# backslash or quote, and nothing else — a checkout path with a space in it is +# already covered by the quotes. +sudoers_quote() { + local value="$1" + value=${value//\\/\\\\} + value=${value//\"/\\\"} + printf '"%s"' "$value" +} + +target=$(realpath -e "$1" 2>/dev/null) || { + echo "Error: path does not exist: $1" >&2 + exit 1 +} + +for required in bin default shell; do + if [[ ! -d $target/$required ]]; then + echo "Warning: $target/$required not found — does this look like an Omarchy source checkout?" >&2 + fi +done + +# Staged and parsed before anything is installed: a sudoers file sudo refuses to +# read takes every rule after it down with it, including the %wheel grant, and +# the password prompt needed to undo that is on the other side of the breakage. +staged_sudoers=$(mktemp) +trap 'rm -f "$staged_sudoers"' EXIT + +{ + printf 'Defaults secure_path=' + sudoers_quote "$target/bin:$system_secure_path" + printf '\n' +} >"$staged_sudoers" + +if ! visudo -cf "$staged_sudoers" >/dev/null; then + echo "Error: refusing to install an invalid $sudoers_file for $target" >&2 + exit 1 +fi + +{ + printf 'export OMARCHY_PATH=' + omarchy_conf_quote "$target" + printf '\n' +} | sudo tee /etc/omarchy.conf >/dev/null + +sudo install -Dm440 -o root -g root "$staged_sudoers" "$sudoers_file" + +echo "Pointed Omarchy at $target" +echo "sudo now resolves omarchy-* from $target/bin" +echo + +if (( prompt_reboot )) && gum confirm "Reboot now to activate?"; then + omarchy-system-reboot +fi diff --git a/bin/omarchy-dev-pkg-test b/bin/omarchy-dev-pkg-test new file mode 100755 index 0000000000..514d56ed9d --- /dev/null +++ b/bin/omarchy-dev-pkg-test @@ -0,0 +1,137 @@ +#!/bin/bash + +# omarchy:summary=Build and install an Omarchy package from a local checkout +# omarchy:group=dev +# omarchy:args=[package-name] [path-to-checkout] +# omarchy:examples=omarchy dev pkg-test | omarchy dev pkg-test omarchy-dev ~/Work/omarchy/omarchy-installer + +set -euo pipefail + +if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then + cat <[.dirty]' so +'pacman -Q' makes it obvious where the installed version came from. + +PKGBUILDs are read from \${OMARCHY_PKGBUILDS_DIR:-~/Work/omarchy/omarchy-pkgs/pkgbuilds}//. +USAGE + exit 0 +fi + +remove_pkgver_function() { + local pkgbuild="$1" + local tmp="$pkgbuild.tmp" + + awk ' + /^pkgver\(\)[[:space:]]*\{/ { + in_pkgver = 1 + depth = 0 + } + in_pkgver { + line = $0 + opens = gsub(/\{/, "{", line) + line = $0 + closes = gsub(/\}/, "}", line) + depth += opens - closes + if (depth <= 0) { + in_pkgver = 0 + } + next + } + { print } + ' "$pkgbuild" >"$tmp" + mv "$tmp" "$pkgbuild" +} + +dev_package_name() { + local pkg="$1" + + case "$pkg" in + omarchy | omarchy-settings) + printf '%s-dev\n' "$pkg" + ;; + *) + printf '%s\n' "$pkg" + ;; + esac +} + +if (( $# == 0 )); then + PKGS=(omarchy-settings-dev omarchy-dev) + CHECKOUT="$HOME/Work/omarchy/omarchy-installer" + MAKEPKG_ARGS=() +else + PKGS=("$(dev_package_name "$1")") + CHECKOUT="${2:-$HOME/Work/omarchy/omarchy-installer}" + MAKEPKG_ARGS=("${@:3}") +fi +PKGBUILDS_ROOT="${OMARCHY_PKGBUILDS_DIR:-$HOME/Work/omarchy/omarchy-pkgs/pkgbuilds}" + +if [[ ! -d "$CHECKOUT" ]]; then + echo "Error: checkout not found at $CHECKOUT" >&2 + exit 1 +fi +for PKG in "${PKGS[@]}"; do + PKGBUILD_DIR="$PKGBUILDS_ROOT/$PKG" + if [[ ! -f "$PKGBUILD_DIR/PKGBUILD" ]]; then + echo "Error: PKGBUILD not found at $PKGBUILD_DIR/PKGBUILD" >&2 + echo " Pass a different package name as arg 1, or set OMARCHY_PKGBUILDS_DIR." >&2 + exit 1 + fi +done + +build_dir=$(mktemp -d -t omarchy-dev-pkg-test.XXXXXX) +trap 'rm -rf "$build_dir"' EXIT + +# pkgver=dev.[.dirty] so pacman -Q makes the source obvious. +short_sha=$(git -C "$CHECKOUT" rev-parse --short HEAD 2>/dev/null || echo "local") +dirty="" +if [[ -d "$CHECKOUT/.git" ]] && [[ -n "$(git -C "$CHECKOUT" status --porcelain)" ]]; then + dirty=".dirty" +fi +new_pkgver="dev.${short_sha}${dirty}" + +for PKG in "${PKGS[@]}"; do + PKGBUILD_DIR="$PKGBUILDS_ROOT/$PKG" + package_build_dir="$build_dir/$PKG" + mkdir -p "$package_build_dir" + cp -a "$PKGBUILD_DIR/." "$package_build_dir/" + + remove_pkgver_function "$package_build_dir/PKGBUILD" + sed -i "s/^pkgver=.*/pkgver=${new_pkgver}/" "$package_build_dir/PKGBUILD" + + echo "Building $PKG ${new_pkgver} from $CHECKOUT" + echo " build dir: $package_build_dir" + echo " PKGBUILD : $PKGBUILD_DIR/PKGBUILD" + echo + + ( + cd "$package_build_dir" + OMARCHY_SRC="$CHECKOUT" makepkg -s --skipchecksums --noconfirm "${MAKEPKG_ARGS[@]}" + ) + + # Install separately so we can pass --overwrite='*' (makepkg -i can't). + # Dev builds frequently conflict with files left behind by previous + # script-installed Omarchy versions; the build is the authoritative state. + built_pkg=$(ls -t "$package_build_dir"/*.pkg.tar.* 2>/dev/null | grep -v '\.sig$' | head -1) + if [[ -z $built_pkg ]]; then + echo "Error: no built package found in $package_build_dir" >&2 + exit 1 + fi + sudo pacman -U --noconfirm --overwrite='*' "$built_pkg" +done diff --git a/bin/omarchy-dev-status b/bin/omarchy-dev-status new file mode 100755 index 0000000000..19817a1b42 --- /dev/null +++ b/bin/omarchy-dev-status @@ -0,0 +1,64 @@ +#!/bin/bash + +# omarchy:summary=Show the current Omarchy dev-link state +# omarchy:group=dev + +set -euo pipefail + +default_target="/usr/share/omarchy" +sudoers_file="/etc/sudoers.d/omarchy-dev-path" +configured="$default_target" +conf_present=0 +linked=0 + +if [[ -f /etc/omarchy.conf ]]; then + conf_present=1 + configured=$( + OMARCHY_PATH= + # shellcheck disable=SC1091 + . /etc/omarchy.conf + printf '%s' "${OMARCHY_PATH:-}" + ) + if [[ $configured != "$default_target" ]]; then + linked=1 + fi +fi + +# /etc/sudoers.d is root-only, so report what sudo resolves rather than reading +# the drop-in — and say so plainly instead of guessing when there is no cached +# credential to ask with. A missing entry here is what makes `sudo omarchy-*` +# run the packaged copy of a command the checkout has changed. +sudo_bin_dir() { + local resolved + + if ! sudo -n true 2>/dev/null; then + echo "unknown (needs sudo)" + return + fi + + resolved=$(sudo -n bash -c 'type -P omarchy-dev-status' 2>/dev/null) || { + echo "not on sudo's PATH" + return + } + + dirname "$resolved" +} + +if (( linked )); then + echo "dev-link: configured" + echo " /etc/omarchy.conf -> OMARCHY_PATH=$configured" + echo " sudo resolves omarchy-* from: $(sudo_bin_dir)" + echo " status: reboot required before all session layers use this checkout" +else + echo "dev-link: inactive" + if (( conf_present )); then + echo " /etc/omarchy.conf -> OMARCHY_PATH=$configured (default guard)" + fi +fi + +echo " current shell: OMARCHY_PATH=${OMARCHY_PATH:-}" + +if [[ ${OMARCHY_PATH:-$default_target} != "$configured" ]]; then + echo + echo "Note: the running session does not match /etc/omarchy.conf. Reboot to settle it." +fi diff --git a/bin/omarchy-dev-theme-preview b/bin/omarchy-dev-theme-preview new file mode 100755 index 0000000000..fa6946a2dd --- /dev/null +++ b/bin/omarchy-dev-theme-preview @@ -0,0 +1,439 @@ +#!/bin/bash + +# omarchy:summary=Preview an Omarchy theme palette in the terminal +# omarchy:args=[theme-name|theme-dir|colors.toml] [--no-color] [--no-osc|--osc] +# omarchy:examples=omarchy dev theme-preview | omarchy dev theme-preview tokyo-night | omarchy dev theme-preview themes/gruvbox/colors.toml --no-color --no-osc + +set -o pipefail + +CURRENT_THEME_PATH="$HOME/.local/state/omarchy/current/theme" +USER_THEMES_PATH="$HOME/.config/omarchy/themes" +OMARCHY_THEMES_PATH="$OMARCHY_PATH/themes" + +COLOR_OUTPUT=1 +APPLY_OSC="auto" +THEME_REF="" +declare -A COLORS + +usage() { + cat <<'USAGE' +Usage: omarchy-dev-theme-preview [theme-name|theme-dir|colors.toml] [--no-color] [--no-osc] + +Preview a theme palette in the terminal. Without an argument, previews the +current theme from ~/.local/state/omarchy/current/theme/colors.toml. + +When stdout is a terminal and color output is enabled, the preview also applies +that theme's OSC palette to the current terminal only. Use --no-osc to suppress +that, or --osc to force it even when stdout is not detected as a terminal. +USAGE +} + +for arg in "$@"; do + case "$arg" in + --no-color | --plain) + COLOR_OUTPUT=0 + APPLY_OSC="never" + ;; + --no-osc) + APPLY_OSC="never" + ;; + --osc | --apply-osc | --terminal) + APPLY_OSC="always" + ;; + -h | --help) + usage + exit 0 + ;; + *) + if [[ -n $THEME_REF ]]; then + usage >&2 + exit 1 + fi + THEME_REF="$arg" + ;; + esac +done + +if [[ -n ${NO_COLOR:-} ]]; then + COLOR_OUTPUT=0 +fi + +normalize_theme_name() { + printf '%s' "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-' +} + +resolve_colors_file() { + local ref="$1" + local theme_name + + if [[ -z $ref ]]; then + printf '%s/colors.toml' "$CURRENT_THEME_PATH" + elif [[ -f $ref ]]; then + printf '%s' "$ref" + elif [[ -d $ref && -f $ref/colors.toml ]]; then + printf '%s/colors.toml' "$ref" + else + theme_name=$(normalize_theme_name "$ref") + if [[ -f $USER_THEMES_PATH/$theme_name/colors.toml ]]; then + printf '%s/colors.toml' "$USER_THEMES_PATH/$theme_name" + elif [[ -f $OMARCHY_THEMES_PATH/$theme_name/colors.toml ]]; then + printf '%s/colors.toml' "$OMARCHY_THEMES_PATH/$theme_name" + else + return 1 + fi + fi +} + +omarchy_tool() { + local tool="$1" + shift + + if [[ -x $OMARCHY_PATH/bin/$tool ]]; then + "$OMARCHY_PATH/bin/$tool" "$@" + else + "$tool" "$@" + fi +} + +load_colors() { + local colors_file="$1" + local key value + local -A resolved=() + + while IFS=$'\t' read -r key value; do + resolved[$key]="$value" + done < <(omarchy_tool omarchy-theme-color --file "$colors_file" --all) + + # Preview the palette the theme itself defines, at canonically resolved values. + while IFS=$'\t' read -r key value; do + COLORS[$key]="${resolved[$key]:-}" + done < <(omarchy_tool omarchy-theme-color --file "$colors_file" --raw) + + # Plus the core slots every theme resolves, so sparse and legacy themes + # still render a complete preview. + for key in mode background foreground light_foreground bright_foreground selection_background selection_foreground dark_background darker_background; do + [[ -n ${resolved[$key]:-} ]] && COLORS[$key]="${resolved[$key]}" + done + + return 0 +} + +hex_parts() { + local hex="${1#\#}" + + printf '%d %d %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}" +} + +hex_valid() { + [[ $1 =~ ^#[0-9A-Fa-f]{6}$ ]] +} + +luma_sum() { + local r g b + + read -r r g b < <(hex_parts "$1") + printf '%d' $((r * 2126 + g * 7152 + b * 722)) +} + +contrast_ratio() { + local fg="$1" + local bg="$2" + + awk -v fg="${fg#\#}" -v bg="${bg#\#}" ' + function hex_value(char) { return index("0123456789abcdef", tolower(char)) - 1 } + function hex_pair(hex, idx) { return hex_value(substr(hex, idx, 1)) * 16 + hex_value(substr(hex, idx + 1, 1)) } + function channel(hex, idx) { return hex_pair(hex, idx) / 255 } + function linear(c) { return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ^ 2.4 } + function lum(hex) { + return 0.2126 * linear(channel(hex, 1)) + 0.7152 * linear(channel(hex, 3)) + 0.0722 * linear(channel(hex, 5)) + } + BEGIN { + a = lum(fg) + b = lum(bg) + if (a < b) { tmp = a; a = b; b = tmp } + printf "%.2f", (a + 0.05) / (b + 0.05) + } + ' +} + +ansi_bg() { + local r g b + + read -r r g b < <(hex_parts "$1") + printf '\033[48;2;%d;%d;%dm' "$r" "$g" "$b" +} + +ansi_fg() { + local r g b + + read -r r g b < <(hex_parts "$1") + printf '\033[38;2;%d;%d;%dm' "$r" "$g" "$b" +} + +reset_ansi() { + printf '\033[0m' +} + +apply_terminal_osc() { + case "$APPLY_OSC" in + never) + return + ;; + auto) + [[ -t 1 ]] && (( COLOR_OUTPUT )) || return + ;; + always) + ;; + esac + + omarchy_tool omarchy-theme-osc "$colors_file" || true +} + +swatch() { + local hex="$1" + local width="${2:-18}" + local i + + if (( COLOR_OUTPUT )) && hex_valid "$hex"; then + ansi_bg "$hex" + for (( i = 0; i < width; i++ )); do + printf ' ' + done + reset_ansi + else + for (( i = 0; i < width; i++ )); do + printf '#' + done + fi +} + +print_color_row() { + local key="$1" + local hex="${COLORS[$key]}" + + [[ -n $hex ]] || return + printf ' %-22s %-9s ' "$key" "$hex" + swatch "$hex" 20 + printf '\n' +} + +paint_segment() { + local text="$1" + local fg="$2" + local bg="${3:-${COLORS[background]}}" + + if (( COLOR_OUTPUT )) && hex_valid "$fg" && hex_valid "$bg"; then + ansi_bg "$bg" + ansi_fg "$fg" + printf '%s' "$text" + reset_ansi + else + printf '%s' "$text" + fi +} + +print_group() { + local title="$1" + shift + local key + + printf '\n%s\n' "$title" + for key in "$@"; do + print_color_row "$key" + done +} + +print_selection_sample() { + local bg="${COLORS[background]}" + local fg="${COLORS[foreground]}" + local selection_bg="${COLORS[selection_background]}" + local selection_fg="${COLORS[selection_foreground]}" + + [[ -n $bg && -n $fg && -n $selection_bg && -n $selection_fg ]] || return + + printf '\nSelection sample\n ' + if (( COLOR_OUTPUT )) && hex_valid "$bg" && hex_valid "$fg" && hex_valid "$selection_bg" && hex_valid "$selection_fg"; then + ansi_bg "$bg" + ansi_fg "$fg" + printf ' This is some ' + ansi_bg "$selection_bg" + ansi_fg "$selection_fg" + printf 'selected text' + ansi_bg "$bg" + ansi_fg "$fg" + printf ' in a sentence ' + reset_ansi + printf '\n' + else + printf 'This is some [selected text] in a sentence\n' + fi +} + +print_practical_samples() { + printf '\nTerminal/UI samples\n' + printf ' ' + paint_segment ' normal text ' "${COLORS[foreground]}" + paint_segment ' muted/comment ' "${COLORS[muted]}" + paint_segment ' accent/link ' "${COLORS[accent]}" + paint_segment ' error ' "${COLORS[red]}" + paint_segment ' warning ' "${COLORS[yellow]}" + paint_segment ' success ' "${COLORS[green]}" + printf '\n ' + paint_segment ' $ omarchy theme-preview ' "${COLORS[green]}" + paint_segment ' # comment ' "${COLORS[muted]}" + paint_segment ' "string" ' "${COLORS[green]}" + paint_segment ' function() ' "${COLORS[blue]}" + paint_segment ' --flag ' "${COLORS[magenta]}" + printf '\n ' + paint_segment ' unselected menu row ' "${COLORS[foreground]}" + printf '\n ' + paint_segment ' selected menu row ' "${COLORS[selection_foreground]}" "${COLORS[selection_background]}" + printf '\n ' + paint_segment ' status/inverse ' "${COLORS[background]}" "${COLORS[foreground]}" + paint_segment ' inactive surface ' "${COLORS[foreground]}" "${COLORS[lighter_background]}" + paint_segment ' urgent ' "${COLORS[background]}" "${COLORS[red]}" + printf '\n' +} + +print_palette_strip() { + local title="$1" + shift + local key hex + + printf ' %-12s ' "$title" + for key in "$@"; do + hex="${COLORS[$key]}" + if [[ -n $hex ]]; then + swatch "$hex" 4 + printf ' ' + fi + done + printf '\n' +} + +print_ansi_palette() { + printf '\nANSI palette strips\n' + print_palette_strip normal background red green yellow blue magenta cyan foreground + print_palette_strip bright muted bright_red bright_green bright_yellow bright_blue bright_magenta bright_cyan bright_foreground +} + +mix_hex() { + local start="$1" + local end="$2" + local index="$3" + local max_index="$4" + local sr sg sb er eg eb r g b + + read -r sr sg sb < <(hex_parts "$start") + read -r er eg eb < <(hex_parts "$end") + + if (( max_index == 0 )); then + printf '%s' "$start" + return + fi + + r=$(((sr * (max_index - index) + er * index + max_index / 2) / max_index)) + g=$(((sg * (max_index - index) + eg * index + max_index / 2) / max_index)) + b=$(((sb * (max_index - index) + eb * index + max_index / 2) / max_index)) + printf '#%02x%02x%02x' "$r" "$g" "$b" +} + +print_gradient() { + local start_key="$1" + local end_key="$2" + local start="${COLORS[$start_key]}" + local end="${COLORS[$end_key]}" + local steps=24 + local i hex + + [[ -n $start && -n $end ]] || return + hex_valid "$start" && hex_valid "$end" || return + + printf '\n%s -> %s gradient\n' "$start_key" "$end_key" + printf ' %s ' "$start" + for (( i = 0; i < steps; i++ )); do + hex=$(mix_hex "$start" "$end" "$i" "$((steps - 1))") + if (( COLOR_OUTPUT )); then + ansi_bg "$hex" + printf ' ' + reset_ansi + else + printf '%s ' "$hex" + fi + done + printf ' %s\n' "$end" +} + +print_neutral_ramp() { + local mode="$1" + local direction sort_flag key hex score entry + local -a keys entries sorted + + keys=(darker_background dark_background background lighter_background selection muted dark_foreground foreground light_foreground bright_foreground) + if [[ $mode == "light" ]]; then + direction="lightest -> darkest" + sort_flag="-rn" + else + direction="darkest -> lightest" + sort_flag="-n" + fi + + for key in "${keys[@]}"; do + hex="${COLORS[$key]}" + [[ -n $hex ]] || continue + hex_valid "$hex" || continue + score=$(luma_sum "$hex") + entries+=("$score $key") + done + + (( ${#entries[@]} > 0 )) || return + + mapfile -t sorted < <(printf '%s\n' "${entries[@]}" | sort "$sort_flag") + + printf '\nNeutral ramp (%s)\n' "$direction" + for entry in "${sorted[@]}"; do + key="${entry#* }" + print_color_row "$key" + done +} + +colors_file=$(resolve_colors_file "$THEME_REF") || { + echo "Theme not found: $THEME_REF" >&2 + exit 1 +} + +if [[ ! -f $colors_file ]]; then + echo "Missing colors.toml: $colors_file" >&2 + exit 1 +fi + +load_colors "$colors_file" + +mode="${COLORS[mode]:-dark}" + +theme_label="$THEME_REF" +if [[ -z $theme_label ]]; then + if [[ -f $HOME/.local/state/omarchy/current/theme.name ]]; then + theme_label=$(<"$HOME/.local/state/omarchy/current/theme.name") + else + theme_label="current" + fi +fi + +apply_terminal_osc + +printf 'Theme: %s\n' "$theme_label" +printf 'File: %s\n' "$colors_file" +printf 'Mode: %s\n' "$mode" +if [[ ${COLORS[foreground]} =~ ^#[0-9A-Fa-f]{6}$ && ${COLORS[background]} =~ ^#[0-9A-Fa-f]{6}$ ]]; then + printf 'foreground/background contrast: %s:1\n' "$(contrast_ratio "${COLORS[foreground]}" "${COLORS[background]}")" +fi + +print_gradient background bright_foreground +print_neutral_ramp "$mode" +print_group "Foundation" background foreground accent selection +print_selection_sample +print_practical_samples +print_ansi_palette +print_group "Normal colors" red yellow orange green cyan blue magenta brown +print_group "Bright colors" bright_red bright_yellow bright_green bright_cyan bright_blue bright_magenta bright_foreground diff --git a/bin/omarchy-dev-ui-preview b/bin/omarchy-dev-ui-preview new file mode 100755 index 0000000000..17ed25792e --- /dev/null +++ b/bin/omarchy-dev-ui-preview @@ -0,0 +1,20 @@ +#!/bin/bash + +# omarchy:summary=Open the omarchy-shell dev gallery (qs.Ui kit preview) +# omarchy:args=[section] +# omarchy:examples=omarchy dev ui-preview | omarchy dev ui-preview button | omarchy dev ui-preview button-group | omarchy dev ui-preview slider +# +# Pass a section name to jump straight to that component instead of +# scrolling from the top. Section names match the cursor section ids in +# shell/plugins/dev-gallery/GalleryPanel.qml — `button`, `button-group`, +# `cursor-surface`, `slider`, `toggle`, `dropdown`, etc. Unknown names +# are ignored and the gallery opens at its normal default position. + +if (( $# > 0 )) && [[ -n $1 ]]; then + section="$1" + payload=$(printf '{"section":"%s"}' "${section//\"/}") +else + payload='{}' +fi + +omarchy-shell shell summon omarchy.dev-gallery "$payload" diff --git a/bin/omarchy-dev-unlink b/bin/omarchy-dev-unlink new file mode 100755 index 0000000000..b1892639ba --- /dev/null +++ b/bin/omarchy-dev-unlink @@ -0,0 +1,64 @@ +#!/bin/bash + +# omarchy:summary=Restore Omarchy to the package install after reboot +# omarchy:group=dev +# omarchy:args=[--no-reboot] + +set -euo pipefail + +if (( EUID == 0 )); then + echo "Error: run omarchy-dev-unlink as your user, not under sudo." >&2 + exit 1 +fi + +prompt_reboot=1 + +sudoers_file="/etc/sudoers.d/omarchy-dev-path" + +if (( $# > 1 )); then + echo "Usage: omarchy dev unlink [--no-reboot]" >&2 + exit 1 +fi + +case "${1:-}" in + "") + ;; + --no-reboot) + prompt_reboot=0 + ;; + -h|--help) + cat <&2 + exit 1 + ;; +esac + +default_target="/usr/share/omarchy" + +printf 'export OMARCHY_PATH="%s"\n' "$default_target" | sudo tee /etc/omarchy.conf >/dev/null + +# omarchy-dev-link prepended the checkout to sudo's secure_path. Drop it in the +# same step that drops the checkout, or sudo keeps running a tree nothing else +# points at — and keeps trusting a user-writable directory for root's commands. +sudo rm -f "$sudoers_file" + +echo "Pointed Omarchy at $default_target" +echo + +if (( prompt_reboot )) && gum confirm "Reboot now to activate?"; then + omarchy-system-reboot +fi diff --git a/bin/omarchy-disk-speedtest b/bin/omarchy-disk-speedtest new file mode 100755 index 0000000000..dbdc779465 --- /dev/null +++ b/bin/omarchy-disk-speedtest @@ -0,0 +1,232 @@ +#!/bin/bash + +# omarchy:summary=Measure live disk read and write speed +# omarchy:args=[target-dir] + +set -e + +if [[ -n ${1:-} && ! -d $1 ]]; then + echo "Usage: omarchy-disk-speedtest [target-dir]" >&2 + exit 2 +fi + +target_dir="${1:-${XDG_CACHE_HOME:-$HOME/.cache}/omarchy}" +phase_seconds=8 +parallel=4 +chunk_mb=4 +file_mb=256 + +mkdir -p "$target_dir" + +worker_pids=() +chunk_file="" +test_files=() + +stop_workers() { + local pid + for pid in "${worker_pids[@]}"; do + [[ -n $pid ]] || continue + pkill -TERM -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + done + for pid in "${worker_pids[@]}"; do + [[ -n $pid ]] || continue + wait "$pid" 2>/dev/null || true + done + worker_pids=() +} + +alive_workers() { + local pid count=0 + for pid in "${worker_pids[@]}"; do + kill -0 "$pid" 2>/dev/null && count=$((count + 1)) + done + echo "$count" +} + +cleanup() { + # Unlink before stopping the workers, so even a cleanup cut short by an + # impatient SIGKILL has already taken the names off the filesystem. A live + # write worker's next dd pass recreates its file by name, so sweep again + # once they are gone. + rm -f ${chunk_file:+"$chunk_file"} "${test_files[@]}" + stop_workers + rm -f ${chunk_file:+"$chunk_file"} "${test_files[@]}" +} +# Armed before any scratch file exists, so a failed preflight check below +# cannot leak them. +trap cleanup EXIT +trap 'exit 143' TERM INT + +# Exclusive per-invocation scratch files: predictable names could clobber a +# user's file, follow a planted symlink, or let overlapping runs delete each +# other's active files out from under the measurement. Each worker gets its +# own on-disk file so the phases run at a queue depth the device can actually +# stretch out on, like the network test's parallel curl workers. +# +# The files are marked NOCOW where the filesystem supports it (btrfs), which +# turns off copy-on-write, checksums, and compression for them. That is what +# makes O_DIRECT truly direct on btrfs -- with checksums on it silently falls +# back to the page cache -- and it makes every rewrite land in place instead +# of churning the extent allocator, which run-to-run reproducibility depends +# on. +chunk_file=$(mktemp /dev/shm/omarchy-disk-speedtest-XXXXXX.src) +for (( i = 0; i < parallel; i++ )); do + file=$(mktemp "$target_dir/disk-speedtest-XXXXXX.dat") + chattr +C "$file" 2>/dev/null || true + test_files+=("$file") +done + +format_rate() { + awk -v value="$1" 'BEGIN { + if (value <= 0) print "0.0" + else if (value < 10) printf "%.1f\n", value + else printf "%.0f\n", value + }' +} + +# Resolve the block device backing the target directory, so throughput can be +# sampled from its kernel I/O counters the same way the network speed test +# samples the interface counters. +source_dev=$(findmnt -no SOURCE --target "$target_dir" 2>/dev/null) +source_dev=${source_dev%%\[*} # Strip btrfs subvolume suffix: /dev/sda2[/@home] + +if [[ $source_dev != /dev/* ]]; then + echo "Cannot find a disk behind $target_dir" >&2 + exit 1 +fi + +dev=$(readlink -f "$source_dev") +dev=${dev##*/} + +if [[ ! -r /sys/class/block/$dev/stat ]]; then + echo "No I/O statistics for $dev" >&2 + exit 1 +fi + +available_mb=$(df --output=avail -m "$target_dir" | tail -1 | tr -d ' ') +if (( available_mb < parallel * file_mb * 2 )); then + echo "Need at least $((parallel * file_mb * 2))MB free on $target_dir" >&2 + exit 1 +fi + +# Name the physical disk under test, walking dm-crypt/LVM layers and the +# partition table up to the whole device that carries the hardware model. +disk=$dev +while slave=$(ls "/sys/class/block/$disk/slaves" 2>/dev/null | head -1); [[ -n $slave ]]; do + disk=$slave +done +if [[ -f /sys/class/block/$disk/partition ]]; then + parent=$(readlink -f "/sys/class/block/$disk") + parent=${parent%/*} + disk=${parent##*/} +fi +model=$(lsblk -dno MODEL "/dev/$disk" 2>/dev/null | sed 's/^ *//; s/ *$//') +echo "disk ${model:-$disk}" + +# The stress data must be incompressible so nothing between the write call +# and the flash can shrink it. Staging a urandom chunk in RAM also keeps the +# source out of the measurement -- reading tmpfs is a memcpy. +dd if=/dev/urandom of="$chunk_file" bs=${chunk_mb}M count=$((file_mb / chunk_mb)) status=none + +# Workers loop only while the main script lives: if cleanup ever loses the +# race with a kill, an orphaned worker finishes its current pass and stops +# instead of hammering the disk forever. +write_worker() { + local file=$1 + while kill -0 $$ 2>/dev/null; do + dd if="$chunk_file" of="$file" bs=${chunk_mb}M oflag=direct conv=notrunc status=none 2>/dev/null || return + done +} + +read_worker() { + local file=$1 + while kill -0 $$ 2>/dev/null; do + dd if="$file" of=/dev/null bs=${chunk_mb}M iflag=direct status=none 2>/dev/null || return + done +} + +device_sectors() { + local -a stats + read -r -a stats < "/sys/class/block/$dev/stat" + if [[ $1 == "read" ]]; then + echo "${stats[2]}" + else + echo "${stats[6]}" + fi +} + +run_phase() { + local phase=$1 + local file before after deadline rate alive samples=0 + local baseline_sectors baseline_time end_time + + for file in "${test_files[@]}"; do + "${phase}_worker" "$file" 2>/dev/null & + worker_pids+=("$!") + done + + before=$(device_sectors "$phase") + deadline=$((SECONDS + phase_seconds)) + + while (( SECONDS < deadline )) && (( $(alive_workers) > 0 )); do + sleep 1 + after=$(device_sectors "$phase") + end_time=$EPOCHREALTIME + rate=$(awk -v before="$before" -v after="$after" 'BEGIN { + if (after < before) print 0 + else print (after - before) * 512 / 1000000 + }') + echo "$phase $(format_rate "$rate")" + samples=$((samples + 1)) + # The first second is warm-up -- governor ramp, crypt workers spinning + # up -- so the steady-state average starts after it. + if (( samples == 1 )); then + baseline_sectors=$after + baseline_time=$end_time + fi + before=$after + done + + # The workers only stop on their own when dd fails (quota, I/O error, full + # disk), so any worker gone before the deadline is a failed measurement, + # not a finished one. + alive=$(alive_workers) + stop_workers + if (( alive < parallel )); then + echo "Disk $phase test failed before finishing" >&2 + exit 1 + fi + + # The figure the dial settles on is the steady-state mean over the whole + # phase, not whatever rate the final second happened to catch. + if (( samples > 1 )); then + rate=$(awk -v before="$baseline_sectors" -v after="$after" -v start="$baseline_time" -v end="$end_time" 'BEGIN { + secs = end - start + if (secs <= 0 || after < before) print 0 + else print (after - before) * 512 / 1000000 / secs + }') + echo "$phase $(format_rate "$rate")" + fi +} + +# The read phase runs first, so its data must be staged before any measuring +# starts. Direct I/O leaves nothing in the page cache to serve reads from. +for file in "${test_files[@]}"; do + dd if="$chunk_file" of="$file" bs=${chunk_mb}M oflag=direct conv=notrunc status=none 2>/dev/null & + worker_pids+=("$!") +done + +stage_failed=0 +for pid in "${worker_pids[@]}"; do + wait "$pid" || stage_failed=1 +done +worker_pids=() + +if (( stage_failed )) || [[ ! -s ${test_files[0]} ]]; then + echo "Direct disk I/O is not available on $target_dir" >&2 + exit 1 +fi + +run_phase read +run_phase write diff --git a/bin/omarchy-display-text-size b/bin/omarchy-display-text-size new file mode 100755 index 0000000000..b15b200fec --- /dev/null +++ b/bin/omarchy-display-text-size @@ -0,0 +1,226 @@ +#!/bin/bash + +# omarchy:summary=Scale text everywhere — omarchy shell, GTK apps, and terminals +# omarchy:args=[size|reset] +# omarchy:examples=omarchy display text size | omarchy display text size 16 | omarchy display text size reset + +# One knob for apparent text size across the desktop. It drives three settings +# in lockstep, all anchored to the shell default of 12px: +# • the omarchy shell's font base-size (~/.config/omarchy/shell.toml [font]) +# • GNOME/GTK's text-scaling-factor (12px -> 1.0, quantized so the GTK +# interface font lands on a whole point size, so 16 -> 15pt/11pt = 1.3636) +# • the terminal font point size (12px -> 9pt, so terminal_pt = px * 9/12) +# The shell override layers on top of the active theme (so the size survives +# theme switches) and the shell watches the file, so shell text re-flows live. +# Accepts an integer from 9 to 20 (px). + +MIN=9 +MAX=20 +GKEY_SCHEMA="org.gnome.desktop.interface" +GKEY_NAME="text-scaling-factor" + +# Anchors: 12px shell base == factor 1.0 == 9pt terminal font. +TERM_DEFAULT_PT=9 +SHELL_DEFAULT_PX=12 + +shell_config="$HOME/.config/omarchy/shell.toml" + +usage() { + echo "Usage: omarchy-display-text-size [size|reset]" + echo " (no args) print the current text size, GTK factor, and terminal size" + echo " set text size in px ($MIN–$MAX); shell + GTK + terminals together" + echo " reset return all three to their defaults (12px / 1.0 / 9pt)" +} + +# ---- shell base-size: the rem root every shell type size derives from ---- + +# Print the base-size currently set under [font], or nothing if unset. +current_base_size() { + [[ -f $shell_config ]] || return 0 + awk ' + /^[[:space:]]*\[/ { in_font = ($0 ~ /^[[:space:]]*\[font\]([[:space:]]|$)/); next } + in_font && /^[[:space:]]*base-size[[:space:]]*=/ { + v = $0 + sub(/^[^=]*=[[:space:]]*/, "", v) + sub(/[[:space:]]*(#.*)?$/, "", v) + print v + exit + } + ' "$shell_config" +} + +# Upsert base-size under [font]: replace it in place if present, insert it into +# an existing [font] section, or append a fresh [font] section otherwise. Other +# sections and keys in the user override are left untouched. +set_base_size() { + local size="$1" + mkdir -p "$(dirname "$shell_config")" + + if [[ ! -f $shell_config ]]; then + printf '[font]\nbase-size = %s\n' "$size" >"$shell_config" + return + fi + + local tmp + tmp="$(mktemp)" + awk -v val="$size" ' + function emit_base() { print "base-size = " val; done = 1 } + /^[[:space:]]*\[/ { + if (in_font && !done) emit_base() + in_font = ($0 ~ /^[[:space:]]*\[font\]([[:space:]]|$)/) + print + next + } + in_font && /^[[:space:]]*base-size[[:space:]]*=/ { + if (!done) emit_base() + next + } + { print } + END { + if (in_font && !done) emit_base() + if (!done) { + if (NR > 0) print "" + print "[font]" + emit_base() + } + } + ' "$shell_config" >"$tmp" + mv "$tmp" "$shell_config" +} + +# Drop the base-size line, returning the shell to the theme/default size. +reset_base_size() { + [[ -f $shell_config ]] || return 0 + local tmp + tmp="$(mktemp)" + awk ' + /^[[:space:]]*\[/ { in_font = ($0 ~ /^[[:space:]]*\[font\]([[:space:]]|$)/) } + in_font && /^[[:space:]]*base-size[[:space:]]*=/ { next } + { print } + ' "$shell_config" >"$tmp" + mv "$tmp" "$shell_config" +} + +# ---- GTK text-scaling-factor ---- + +# Point size of the GTK interface font (font-name), used to quantize the +# scaling factor. Falls back to the GNOME default when unreadable. +gtk_font_pt() { + local name pt + name="$(gsettings get "$GKEY_SCHEMA" font-name 2>/dev/null)" + pt="${name%\'}" + pt="${pt##* }" + if [[ $pt =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + echo "$pt" + else + echo 11 + fi +} + +set_factor() { + gsettings set "$GKEY_SCHEMA" "$GKEY_NAME" "$1" 2>/dev/null || true +} + +# ---- terminal font point size ---- + +# px base-size -> terminal point size, rounded to the nearest integer. +term_pt_for() { + awk -v s="$1" -v p="$TERM_DEFAULT_PT" -v b="$SHELL_DEFAULT_PX" \ + 'BEGIN { printf "%d", int(s * p / b + 0.5) }' +} + +# Set the font point size in every terminal config that exists. 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() { + local pt="$1" + + if [[ -f ~/.config/alacritty/alacritty.toml ]]; then + 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 + pkill -USR1 kitty 2>/dev/null || true + fi + + if [[ -f ~/.config/ghostty/config ]]; then + sed -i -E "s/^font-size = .*/font-size = $pt/" ~/.config/ghostty/config + pkill -SIGUSR2 ghostty 2>/dev/null || true + fi + + if [[ -f ~/.config/foot/foot.ini ]]; then + sed -i -E "s/(:size=)[0-9.]+/\1$pt/" ~/.config/foot/foot.ini + # Foot has no config-reload signal, so a running instance keeps its startup + # size until relaunched (new windows pick up the change). Nudge the user — + # but reuse the same notification id (freedesktop replaces_id) so dragging + # through several sizes refreshes one toast instead of stacking a pile. + if pgrep -x foot >/dev/null 2>&1; then + local id_file="${XDG_RUNTIME_DIR:-/tmp}/omarchy-display-text-size.foot-notif-id" + local prev_id="" + [[ -f $id_file ]] && read -r prev_id <"$id_file" 2>/dev/null + local replace=() + [[ $prev_id =~ ^[0-9]+$ ]] && replace=(-r "$prev_id") + local new_id + new_id="$(omarchy-notification-send \ + "Restart Foot to apply the new terminal font size" \ + "${replace[@]}" -p 2>/dev/null)" || true + [[ $new_id =~ ^[0-9]+$ ]] && printf '%s\n' "$new_id" >"$id_file" + fi + fi +} + +# Report the current terminal point size from whichever config we find first. +term_current_pt() { + if [[ -f ~/.config/ghostty/config ]]; then + grep -oP '^font-size = \K[0-9.]+' ~/.config/ghostty/config | head -1 + 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 + elif [[ -f ~/.config/foot/foot.ini ]]; then + grep -oP ':size=\K[0-9.]+' ~/.config/foot/foot.ini | head -1 + fi +} + +case "${1:-}" in + -h | --help) + usage + exit 0 + ;; + "") + cur="$(current_base_size)" + size="${cur:-12 (default)}" + factor="$(gsettings get "$GKEY_SCHEMA" "$GKEY_NAME" 2>/dev/null)" + term="$(term_current_pt)" + printf 'text size: %s px\ngtk text-scaling-factor: %s\nterminal font: %s pt\n' \ + "$size" "$factor" "${term:-n/a}" + exit 0 + ;; + reset | default) + reset_base_size + gsettings reset "$GKEY_SCHEMA" "$GKEY_NAME" 2>/dev/null || true + set_terminal_size "$TERM_DEFAULT_PT" + exit 0 + ;; +esac + +size="$1" +if [[ ! $size =~ ^[0-9]+$ ]] || ((size < MIN || size > MAX)); then + echo "Size must be an integer between $MIN and $MAX (px)." >&2 + usage >&2 + exit 1 +fi + +# Shell side: base-size in px (the omarchy shell's rem root). +set_base_size "$size" + +# GTK side: multiplier anchored so 12px == 1.0, quantized so the interface +# font renders at a whole point size. Raw ratios yield fractional point sizes, +# which GTK4 menus clip at the ascenders on scale-1 monitors. +factor="$(awk -v s="$size" -v b="$SHELL_DEFAULT_PX" -v f="$(gtk_font_pt)" \ + 'BEGIN { printf "%.4f", int(f * s / b + 0.5) / f }')" +set_factor "$factor" + +# Terminal side: point size anchored so 12px == 9pt. +set_terminal_size "$(term_pt_for "$size")" diff --git a/bin/omarchy-dns b/bin/omarchy-dns new file mode 100755 index 0000000000..a22e028c5b --- /dev/null +++ b/bin/omarchy-dns @@ -0,0 +1,301 @@ +#!/bin/bash + +# omarchy:summary=Show or configure the system DNS provider +# omarchy:args=[Cloudflare|Google|DHCP|Custom] +# omarchy:examples=omarchy dns | omarchy dns Cloudflare | omarchy dns Custom + +set -euo pipefail + +NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf + +provider_from_arg() { + case "${1:-}" in + Cloudflare | cloudflare) + echo "Cloudflare" + ;; + Google | google) + echo "Google" + ;; + DHCP | dhcp) + echo "DHCP" + ;; + Custom | custom) + echo "Custom" + ;; + *) + return 1 + ;; + esac +} + +# The path etc/sudoers.d/omarchy-dns names. The privileged half always runs from +# there rather than from whichever copy was invoked, so the rule matches even +# where $OMARCHY_PATH points at a checkout. +PACKAGED_PATH=/usr/bin/omarchy-dns + +# True when sudo would run this exact command without stopping for a password. +# `sudo -l` on its own reports whether a command is permitted, which the blanket +# %wheel rule answers yes to for everything; the long listing prints the matched +# entry's tags, so !authenticate is the grant in etc/sudoers.d/omarchy-dns and +# nothing else. Listing runs nothing and, under -n, prompts for nothing, so a +# machine whose omarchy-settings predates that file falls through to polkit +# instead of dying on a password prompt it has no terminal to show. +sudo_grants_passwordless() { + sudo -n -l -l "$PACKAGED_PATH" "$@" 2>/dev/null | grep -q '!authenticate' +} + +require_root() { + if (( EUID == 0 )); then + return + elif [[ -t 0 ]] || sudo_grants_passwordless "$@"; then + # A terminal can carry sudo's own password prompt. Without one, sudo is + # right only where the grant reaches; polkit can at least put a prompt on + # screen, and offer to authenticate as someone else. + exec sudo "$PACKAGED_PATH" "$@" + else + exec pkexec "$PACKAGED_PATH" "$@" + fi +} + +networkmanager_global_dns() { + [[ -f $NM_DNS_CONF ]] || return 0 + + awk -F= ' + /^[[:space:]]*#/ { next } + /^[[:space:]]*\[global-dns-domain-\*\][[:space:]]*$/ { in_default = 1; next } + /^[[:space:]]*\[/ { in_default = 0 } + in_default && /^[[:space:]]*servers[[:space:]]*=/ { + value = $0 + sub(/^[^=]*=/, "", value) + print value + exit + } + ' "$NM_DNS_CONF" +} + +resolved_dns() { + awk -F= ' + /^[[:space:]]*#/ { next } + /^[[:space:]]*DNS[[:space:]]*=/ { + value=$0 + sub(/^[^=]*=/, "", value) + print value + exit + } + ' /etc/systemd/resolved.conf 2>/dev/null || true +} + +current_dns_provider() { + local dns="" + local compact="" + + dns=$(networkmanager_global_dns) + if [[ -z $(printf '%s' "$dns" | tr -d '[:space:],') ]]; then + dns=$(resolved_dns) + fi + + compact=$(printf '%s' "$dns" | tr -d '[:space:],') + + if [[ -z $compact ]]; then + echo "DHCP" + elif [[ $dns == *"cloudflare-dns.com"* || $dns == *"1.1.1.1"* || $dns == *"2606:4700:4700::1111"* ]]; then + echo "Cloudflare" + elif [[ $dns == *"dns.google"* || $dns == *"8.8.8.8"* || $dns == *"2001:4860:4860::8888"* ]]; then + echo "Google" + else + echo "Custom" + fi +} + +normalize_servers() { + printf '%s\n' "$*" | tr ',\t\n' ' ' | xargs | tr ' ' ',' +} + +split_dns_servers() { + local servers="$1" + local server clean + ipv4_dns="" + ipv6_dns="" + + for server in ${servers//,/ }; do + clean=${server#dns+tls://} + clean=${clean#dns+udp://} + clean=${clean%%#*} + clean=${clean#[} + clean=${clean%]} + + [[ -n $clean ]] || continue + if [[ $clean == *:* ]]; then + ipv6_dns+="${ipv6_dns:+ }$clean" + else + ipv4_dns+="${ipv4_dns:+ }$clean" + fi + done +} + +write_networkmanager_dns() { + local servers="$1" + + install -d -m 0755 "$(dirname "$NM_DNS_CONF")" + cat >"$NM_DNS_CONF" </dev/null + done < <(nmcli -t -f UUID,TYPE connection show) +} + +clear_connection_dns() { + local uuid type + + while IFS=: read -r uuid type; do + [[ -n $uuid ]] || continue + networkmanager_dns_connection "$type" || continue + + nmcli connection modify "$uuid" \ + ipv4.ignore-auto-dns no \ + ipv4.dns "" \ + ipv6.ignore-auto-dns no \ + ipv6.dns "" \ + >/dev/null + done < <(nmcli -t -f UUID,TYPE connection show) +} + +reapply_active_dns_connections() { + local device type state + + while IFS=: read -r device type state; do + [[ -n $device && $state == connected ]] || continue + case "$type" in + wifi|ethernet) + nmcli device reapply "$device" >/dev/null 2>&1 || true + ;; + esac + done < <(nmcli -t -f DEVICE,TYPE,STATE device status) +} + +reload_dns_stack() { + if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then + # Load the updated NetworkManager config first, then reapply the active + # profiles. A single conf,dns-full reload here pushes the old active DNS + # settings, making the shell toggle appear one selection behind. + nmcli general reload conf >/dev/null 2>&1 || systemctl reload NetworkManager.service 2>/dev/null || true + reapply_active_dns_connections + fi + + systemctl reload systemd-resolved.service 2>/dev/null || systemctl restart systemd-resolved.service + + if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then + # A resolved reload/restart can leave per-link DNS stale or empty; ask + # NetworkManager to publish DNS after resolved has reread its config. + nmcli general reload dns-full >/dev/null 2>&1 || true + fi +} + +usage() { + echo "Usage: omarchy-dns [Cloudflare|Google|DHCP|Custom]" >&2 +} + +if (( $# == 0 )); then + current_dns_provider + exit 0 +fi + +if (( $# > 1 )); then + usage + exit 1 +fi + +if ! provider=$(provider_from_arg "$1"); then + usage + exit 1 +fi + +require_root "$provider" + +case "$provider" in +Cloudflare) + write_networkmanager_dns "1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001" + set_connection_dns "1.1.1.1 1.0.0.1" "2606:4700:4700::1111 2606:4700:4700::1001" + tee /etc/systemd/resolved.conf >/dev/null <<'EOF' +[Resolve] +DNS=1.1.1.1#cloudflare-dns.com 1.0.0.1#cloudflare-dns.com 2606:4700:4700::1111#cloudflare-dns.com 2606:4700:4700::1001#cloudflare-dns.com +FallbackDNS=9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net 2620:fe::fe#dns.quad9.net 2620:fe::9#dns.quad9.net +DNSOverTLS=opportunistic +EOF + ;; + +Google) + write_networkmanager_dns "8.8.8.8,8.8.4.4,2001:4860:4860::8888,2001:4860:4860::8844" + set_connection_dns "8.8.8.8 8.8.4.4" "2001:4860:4860::8888 2001:4860:4860::8844" + tee /etc/systemd/resolved.conf >/dev/null <<'EOF' +[Resolve] +DNS=8.8.8.8#dns.google 8.8.4.4#dns.google 2001:4860:4860::8888#dns.google 2001:4860:4860::8844#dns.google +FallbackDNS=9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net 2620:fe::fe#dns.quad9.net 2620:fe::9#dns.quad9.net +DNSOverTLS=opportunistic +EOF + ;; + +DHCP) + clear_networkmanager_dns + clear_connection_dns + tee /etc/systemd/resolved.conf >/dev/null <<'EOF' +[Resolve] +DNSOverTLS=no +EOF + ;; + +Custom) + echo "Enter your DNS servers (space-separated, e.g. '192.168.1.1 1.1.1.1'):" + if ! read -r dns_servers; then + dns_servers="" + fi + + dns_servers=$(normalize_servers "$dns_servers") + if [[ -z $dns_servers ]]; then + echo "Error: No DNS servers provided." >&2 + exit 1 + fi + + split_dns_servers "$dns_servers" + write_networkmanager_dns "$dns_servers" + set_connection_dns "$ipv4_dns" "$ipv6_dns" + tee /etc/systemd/resolved.conf >/dev/null < +# omarchy:hidden=true + +set -e + +if (( $# != 2 )); then + echo "Usage: omarchy-done " >&2 + exit 1 +fi + +action=$1 +name=$2 +done_dir="$HOME/.local/state/omarchy/done" +marker="$done_dir/$name" + +if [[ $name == */* || $name == "." || $name == ".." ]]; then + echo "Invalid done marker name: $name" >&2 + exit 1 +fi + +case "$action" in + check) + [[ -f $marker ]] + ;; + mark) + mkdir -p "$done_dir" + touch "$marker" + ;; + ensure) + mkdir -p "$done_dir" + (set -o noclobber; : >"$marker") 2>/dev/null + ;; + *) + echo "Usage: omarchy-done " >&2 + exit 1 + ;; +esac diff --git a/bin/omarchy-drive-password b/bin/omarchy-drive-password index 3fdf9b0828..2fbbb18753 100755 --- a/bin/omarchy-drive-password +++ b/bin/omarchy-drive-password @@ -13,8 +13,16 @@ if [[ -n $encrypted_drives ]]; then fi if [[ -n $drive_to_change ]]; then + new_password=$(gum input --password --header "New encryption password") || exit 1 + [[ -n $new_password ]] || { echo "Password cannot be empty."; exit 1; } + + confirmation=$(gum input --password --header "Confirm new encryption password") || exit 1 + [[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; exit 1; } + echo "Changing full-disk encryption password for $drive_to_change" - sudo cryptsetup luksChangeKey --pbkdf argon2id --iter-time 2000 "$drive_to_change" + # The new key travels over stdin and reaches cryptsetup as a keyfile via + # <(cat), leaving the tty free for the current-passphrase prompt. + printf "%s" "$new_password" | sudo bash -c 'exec cryptsetup luksChangeKey --pbkdf argon2id --iter-time 2000 "$1" <(cat) ] [--multiple] [--directory] [--extensions ""] +# omarchy:examples=omarchy file select --title "Send with Tailscale" --multiple | omarchy file select --title "Pick image" --extensions "png svg" | omarchy file select --title "Share folder" --directory + +# Python rather than bash, alone among the commands here, because the portal +# answers a request with a Response signal addressed to the connection that +# asked, and D-Bus delivers a directed signal only to that connection. Every +# shell-callable client — gdbus call, busctl call, dbus-send — opens its own +# connection and exits before the answer arrives, and gdbus monitor registers +# with AddMatch rather than BecomeMonitor, so it never sees one either. Holding +# a single connection across both the call and the wait is the whole job, and +# bash has no way to hold one. + +import argparse +import os +import sys + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib + +# A dialog nobody ever answers would otherwise keep this process, and whatever +# waits on its output, alive forever. +ANSWER_TIMEOUT_SEC = 600 + +# Callers act on these: nothing picked is a decision, a chooser that never ran +# is a fault, and the two want different handling. +EXIT_NOTHING_PICKED = 1 +EXIT_CHOOSER_FAILED = 2 + + +def main(): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--title", default="Select file") + parser.add_argument("--multiple", action="store_true") + parser.add_argument("--directory", action="store_true") + parser.add_argument("--extensions", default="") + args, unknown = parser.parse_known_args() + + if unknown: + print("omarchy-file-select: unknown option %s" % unknown[0], file=sys.stderr) + return EXIT_CHOOSER_FAILED + + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + loop = GLib.MainLoop() + uris = [] + + def on_response(connection, sender, path, interface, signal, params): + code, results = params.unpack() + if code == 0: + uris.extend(results.get("uris", [])) + loop.quit() + + def subscribe(path): + bus.signal_subscribe( + "org.freedesktop.portal.Desktop", + "org.freedesktop.portal.Request", + "Response", + path, + None, + Gio.DBusSignalFlags.NONE, + on_response, + ) + + # The request path is derived from our bus name and the token we pass, so it + # can be subscribed to up front. Asking first would race a dialog that gets + # answered immediately. + token = "omarchy%d" % os.getpid() + sender = bus.get_unique_name()[1:].replace(".", "_") + predicted = "/org/freedesktop/portal/desktop/request/%s/%s" % (sender, token) + subscribe(predicted) + + options = { + "handle_token": GLib.Variant("s", token), + "multiple": GLib.Variant("b", args.multiple), + } + + if args.directory: + options["directory"] = GLib.Variant("b", True) + + # Filters name file formats, which a directory chooser has no use for. + if args.extensions and not args.directory: + # Glob matching in the chooser is case-sensitive, so cover both cases. + exts = [ext.lstrip(".").lower() for ext in args.extensions.split()] + patterns = [(0, "*." + ext) for ext in exts] + [(0, "*." + ext.upper()) for ext in exts] + label = " ".join("*." + ext for ext in exts) + filters = GLib.Variant("a(sa(us))", [(label, patterns)]) + options["filters"] = filters + options["current_filter"] = GLib.Variant("(sa(us))", (label, patterns)) + + handle = bus.call_sync( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.FileChooser", + "OpenFile", + GLib.Variant("(ssa{sv})", ("", args.title, options)), + None, + Gio.DBusCallFlags.NONE, + -1, + None, + ).unpack()[0] + + # Portals predating the token convention answer on a path of their choosing. + if handle != predicted: + subscribe(handle) + + GLib.timeout_add_seconds(ANSWER_TIMEOUT_SEC, loop.quit) + loop.run() + + for uri in uris: + print(GLib.filename_from_uri(uri)[0]) + + return 0 if uris else EXIT_NOTHING_PICKED + + +if __name__ == "__main__": + try: + sys.exit(main()) + except GLib.Error as error: + print("omarchy-file-select: %s" % error.message, file=sys.stderr) + sys.exit(EXIT_CHOOSER_FAILED) diff --git a/bin/omarchy-first-run b/bin/omarchy-first-run deleted file mode 100755 index 023d1a6517..0000000000 --- a/bin/omarchy-first-run +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Finish the installation of Omarchy with items that can only be done after logging in. -# omarchy:requires-sudo=true - -set -e - -FIRST_RUN_MODE=~/.local/state/omarchy/first-run.mode - -if [[ -f $FIRST_RUN_MODE ]]; then - rm -f "$FIRST_RUN_MODE" - - bash "$OMARCHY_PATH/install/first-run/battery-monitor.sh" - bash "$OMARCHY_PATH/install/first-run/recover-internal-monitor.sh" - bash "$OMARCHY_PATH/install/first-run/cleanup-reboot-sudoers.sh" - bash "$OMARCHY_PATH/install/first-run/firewall.sh" - bash "$OMARCHY_PATH/install/first-run/dns-resolver.sh" - bash "$OMARCHY_PATH/install/first-run/gnome-theme.sh" - bash "$OMARCHY_PATH/install/first-run/swayosd.sh" - bash "$OMARCHY_PATH/install/first-run/gtk-primary-paste.sh" - bash "$OMARCHY_PATH/install/first-run/elephant.sh" - omarchy-hook-install post-update "$OMARCHY_PATH/install/first-run/install-voxtype.hook" - sudo rm -f /etc/sudoers.d/first-run - - bash "$OMARCHY_PATH/install/first-run/welcome.sh" - bash "$OMARCHY_PATH/install/first-run/wifi.sh" -fi diff --git a/bin/omarchy-font-current b/bin/omarchy-font-current index ead8c3a3da..839bd4db7b 100755 --- a/bin/omarchy-font-current +++ b/bin/omarchy-font-current @@ -3,4 +3,7 @@ # omarchy:summary=Show current monospace font # omarchy:examples=omarchy font current -grep -oP 'font-family:\s*["'\'']?\K[^;"'\'']+' ~/.config/waybar/style.css | head -n1 +# fontconfig is the source of truth. fc-match returns a comma-separated +# alias list (e.g. "JetBrainsMono Nerd Font,JetBrainsMono NF") so take +# the first entry. +fc-match monospace -f '%{family}\n' | head -n1 | cut -d, -f1 diff --git a/bin/omarchy-font-set b/bin/omarchy-font-set index 44bb98336b..7c80fbc49d 100755 --- a/bin/omarchy-font-set +++ b/bin/omarchy-font-set @@ -4,52 +4,75 @@ # omarchy:args= # omarchy:examples=omarchy font list | omarchy font set "CaskaydiaMono Nerd Font" -font_name="$1" - -if [[ -n $font_name ]]; then - if fc-list | grep -iq "$font_name"; then - if [[ -f ~/.config/alacritty/alacritty.toml ]]; then - sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml - fi - - if [[ -f ~/.config/kitty/kitty.conf ]]; then - sed -i "s/^font_family .*/font_family $font_name/g" ~/.config/kitty/kitty.conf - pkill -USR1 kitty - fi - - if [[ -f ~/.config/ghostty/config ]]; then - sed -i "s/font-family = \".*\"/font-family = \"$font_name\"/g" ~/.config/ghostty/config - pkill -SIGUSR2 ghostty - fi - - if [[ -f ~/.config/foot/foot.ini ]]; then - sed -i "s/^font=.*/font=$font_name:size=9/g" ~/.config/foot/foot.ini - fi - - sed -i "s/font_family = .*/font_family = $font_name/g" ~/.config/hypr/hyprlock.conf - sed -i "s/font-family: .*/font-family: '$font_name';/g" ~/.config/waybar/style.css - sed -i "s/font-family: .*/font-family: '$font_name';/g" ~/.config/swayosd/style.css - xmlstarlet ed -L \ - -u '//match[@target="pattern"][test/string="monospace"]/edit[@name="family"]/string' \ - -v "$font_name" \ - ~/.config/fontconfig/fonts.conf - - omarchy-restart-waybar - omarchy-restart-swayosd - - if pgrep -x ghostty; then - notify-send -u low " You must restart Ghostty to see font change" - fi - - if pgrep -x foot; then - notify-send -u low " You must restart Foot to see font change" - fi - - omarchy-hook font-set "$font_name" - else - echo "Font '$font_name' not found." - exit 1 - fi -else +usage() { echo "Usage: omarchy-font-set " +} + +font_name="${1:-}" + +case "$font_name" in + -h|--help) + usage + exit 0 + ;; + "") + usage >&2 + exit 1 + ;; +esac + +if ! fc-list | grep -Fqi -- "$font_name"; then + echo "Font '$font_name' not found." + exit 1 +fi + +if [[ -f ~/.config/alacritty/alacritty.toml ]]; then + sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml +fi + +if [[ -f ~/.config/kitty/kitty.conf ]]; then + sed -i "s/^font_family .*/font_family $font_name/g" ~/.config/kitty/kitty.conf + pkill -USR1 kitty +fi + +if [[ -f ~/.config/ghostty/config ]]; then + sed -i "s/font-family = \".*\"/font-family = \"$font_name\"/g" ~/.config/ghostty/config + pkill -SIGUSR2 ghostty +fi + +if [[ -f ~/.config/foot/foot.ini ]]; then + sed -i "s/^font=.*/font=$font_name:size=9/g" ~/.config/foot/foot.ini +fi + +# fontconfig is the canonical source of truth — the omarchy shell, Qt apps, +# and anything resolving "monospace" all read from here. This file is loaded +# after the package-owned default, and prepend_first puts the chosen family at +# the head of the list so it wins over the family that default prefers. +fontconfig_file="$HOME/.config/fontconfig/fonts.conf" +mkdir -p "$(dirname "$fontconfig_file")" +cat >"$fontconfig_file" < + + + + + monospace + + + $font_name + + + +XML + +omarchy-restart-shell + +if pgrep -x ghostty; then + omarchy-notification-send -g "You must restart Ghostty to see font change" fi + +if pgrep -x foot; then + omarchy-notification-send -g "You must restart Foot to see font change" +fi + +omarchy-hook font-set "$font_name" diff --git a/bin/omarchy-games-retro-cores b/bin/omarchy-games-retro-cores new file mode 100755 index 0000000000..a81e9bb0c0 --- /dev/null +++ b/bin/omarchy-games-retro-cores @@ -0,0 +1,39 @@ +#!/bin/bash + +# omarchy:summary=List installed RetroArch core names + +set -e + +core_dir="/usr/lib/libretro" +preferred_cores=( + "Amstrad CPC|cap32" + "Arcade FBNeo|fbneo" + "Arcade MAME|mame" + "Commodore Amiga|puae" + "Commodore C128|vice_x128" + "Commodore C64|vice_x64" + "Commodore VIC-20|vice_xvic" + "Nintendo DS|desmume" + "Nintendo Game Boy / Color|gambatte" + "Nintendo Game Boy Advance|mgba" + "Nintendo GameCube / Wii|dolphin" + "Nintendo NES / Famicom|mesen" + "Nintendo 64|parallel_n64" + "Nintendo SNES / SFC|snes9x" + "NEC PC Engine / TurboGrafx-16|mednafen_pce_fast" + "NEC PC Engine CD / TurboGrafx-CD|mednafen_pce" + "NEC PC Engine SuperGrafx|mednafen_supergrafx" + "Sega Dreamcast|flycast" + "Sega Mega Drive / Master System / Game Gear|genesis_plus_gx" + "Sega Saturn|kronos" + "Sony PlayStation|mednafen_psx_hw" + "Sony PlayStation Portable|ppsspp" +) + +[[ -d $core_dir ]] || exit 0 + +for preferred_core in "${preferred_cores[@]}"; do + label="${preferred_core%%|*}" + core="${preferred_core#*|}" + [[ -f $core_dir/${core}_libretro.so ]] && printf '%s (%s)\n' "$label" "$core" +done diff --git a/bin/omarchy-games-retro-install b/bin/omarchy-games-retro-install new file mode 100755 index 0000000000..9b30bb74c1 --- /dev/null +++ b/bin/omarchy-games-retro-install @@ -0,0 +1,72 @@ +#!/bin/bash + +# omarchy:summary=Create a desktop launcher for a RetroArch game +# omarchy:args=[core path-to-game] +# omarchy:examples=omarchy games retro install snes9x ~/Games/roms/snes/game.sfc | omarchy-games-retro-install /usr/lib/libretro/mgba_libretro.so ~/Games/roms/gba/game.gba + +set -e + +if (( $# == 0 )); then + mapfile -t cores < <(omarchy-games-retro-cores) + + if (( ${#cores[@]} == 0 )); then + omarchy-notification-send -g 󰯉 "No RetroArch cores found" "/usr/lib/libretro" + exit 1 + fi + + core=$(omarchy-menu-select "RetroArch core" "${cores[@]}") || exit 0 + [[ -n $core ]] || exit 0 + core="${core##*(}" + core="${core%)}" + + game_path=$(omarchy-menu-file "Retro game" "$HOME/Games/roms" "7z bin ccd chd cue dmg elf fds gb gba gbc iso lha m3u md n64 nds nes pbp sfc smc swc zip z64") || exit 0 + [[ -n $game_path ]] || exit 0 +elif (( $# == 2 )); then + core="$1" + game_path="$2" +else + echo "Usage: omarchy-games-retro-install [core path-to-game]" + echo "Example: omarchy-games-retro-install snes9x ~/Games/roms/snes/game.sfc" + exit 1 +fi + +if [[ ! -f $game_path ]]; then + echo "Game not found: $game_path" + exit 1 +fi + +if [[ $core == */* ]]; then + core_path="$core" +else + core_path="/usr/lib/libretro/${core}_libretro.so" +fi + +if [[ ! -f $core_path ]]; then + echo "Core not found: $core_path" + exit 1 +fi + +game_name=$(printf '%s' "${game_path##*/}" | sed 's/\.[^.]*$//; s/[[:space:]]*([^)]*)//g; s/[[:space:]]\+/ /g; s/^ //; s/ $//' | perl -Mopen=locale -pe 's/(^|[[:space:]])([^[:space:]])/$1\U$2/g') +desktop_name="$game_name" +desktop_id=$(printf '%s' "$desktop_name" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alnum:]' '-' | sed 's/^-//; s/-$//') +desktop_dir="$HOME/.local/share/applications" +desktop_file="$desktop_dir/$desktop_id.desktop" +mkdir -p "$desktop_dir" + +cat >"$desktop_file" </dev/null || true + +omarchy-notification-send -g 󰯉 "$game_name installed" "Start it with Super + Space" diff --git a/bin/omarchy-hibernation-remove b/bin/omarchy-hibernation-remove index 49bad5bd6e..f06396b55f 100755 --- a/bin/omarchy-hibernation-remove +++ b/bin/omarchy-hibernation-remove @@ -44,11 +44,6 @@ if grep -Fq "$SWAP_FILE" /etc/fstab; then sudo sed -i '/^# Btrfs swapfile for system hibernation$/d' /etc/fstab fi -# Remove suspend-then-hibernate configuration -echo "Removing suspend-then-hibernate configuration" -sudo rm -f /etc/systemd/logind.conf.d/lid.conf -sudo rm -f /etc/systemd/sleep.conf.d/hibernate.conf - # Remove mkinitcpio resume hook echo "Removing resume hook" sudo rm "$MKINITCPIO_CONF" diff --git a/bin/omarchy-hibernation-setup b/bin/omarchy-hibernation-setup index d8e32c94e2..7bc5724679 100755 --- a/bin/omarchy-hibernation-setup +++ b/bin/omarchy-hibernation-setup @@ -21,7 +21,7 @@ fi # When --no-rebuild is set, the caller is responsible for the UKI rebuild # (e.g. running before limine-mkinitcpio-hook is installed during initial # install), so we only require limine-mkinitcpio when we'd invoke it ourselves. -if ! $NO_REBUILD && ! command -v limine-mkinitcpio &>/dev/null; then +if ! $NO_REBUILD && omarchy-cmd-missing limine-mkinitcpio; then echo "Skipping hibernation setup (requires Limine bootloader)" exit 0 fi @@ -38,8 +38,9 @@ if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; if [[ -n $RESUME_OFFSET ]]; then echo "Fixing empty resume_offset ($RESUME_OFFSET)" sudo sed -i "s/resume_offset=\"$/resume_offset=$RESUME_OFFSET\"/" "$RESUME_DROP_IN" - sudo sed -i "s/resume_offset=\"$/resume_offset=$RESUME_OFFSET\"/" /etc/default/limine - $NO_REBUILD || sudo limine-mkinitcpio + if ! $NO_REBUILD; then + sudo limine-mkinitcpio + fi fi fi echo "Hibernation is already set up" @@ -100,7 +101,6 @@ if [[ ! -f $RESUME_DROP_IN ]]; then if [[ -n $RESUME_OFFSET ]]; then sudo mkdir -p /etc/limine-entry-tool.d echo "KERNEL_CMDLINE[default]+=\" resume=$RESUME_DEVICE resume_offset=$RESUME_OFFSET\"" | sudo tee "$RESUME_DROP_IN" >/dev/null - sudo tee -a /etc/default/limine < "$RESUME_DROP_IN" >/dev/null else echo "Warning: Could not determine resume offset for $SWAP_FILE" >&2 fi @@ -113,7 +113,6 @@ if grep -q "\[s2idle\]" /sys/power/mem_sleep 2>/dev/null; then echo "Enabling ACPI RTC alarm for s2idle suspend" sudo mkdir -p /etc/limine-entry-tool.d echo 'KERNEL_CMDLINE[default]+=" rtc_cmos.use_acpi_alarm=1"' | sudo tee "$LIMINE_DROP_IN" >/dev/null - sudo tee -a /etc/default/limine < "$LIMINE_DROP_IN" >/dev/null fi fi diff --git a/bin/omarchy-hw-clamshell b/bin/omarchy-hw-clamshell new file mode 100755 index 0000000000..238f66273a --- /dev/null +++ b/bin/omarchy-hw-clamshell @@ -0,0 +1,7 @@ +#!/bin/bash + +# omarchy:summary=Returns true when clamshell mode is active +# omarchy:hidden=true + +# Clamshell = lid closed while driving one or more external monitors. +omarchy-hw-laptop-closed && omarchy-hw-external-monitors diff --git a/bin/omarchy-hw-display b/bin/omarchy-hw-display new file mode 100755 index 0000000000..d3c6d3e66f --- /dev/null +++ b/bin/omarchy-hw-display @@ -0,0 +1,25 @@ +#!/bin/bash + +# omarchy:summary=Print the most likely display backlight device. +# omarchy:examples=omarchy-hw-display + +backlight_path="${OMARCHY_BACKLIGHT_PATH:-/sys/class/backlight}" + +# Start with the first possible output, then refine to the most likely given an order heuristic. +# Glob the candidates in the loop list: [[ ]] does not do pathname expansion. +# The Touch Bar on T2 Macs registers a backlight that never drives the display panel. +device="$(ls -1 "$backlight_path" 2>/dev/null | grep -vx appletb_backlight | head -n1)" +# gmux comes first: apple-gmux only registers when the kernel has already picked it, and on +# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends. +for candidate in "$backlight_path"/gmux_backlight "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do + if [[ -e $candidate ]]; then + device="${candidate##*/}" + break + fi +done + +if [[ -n $device ]]; then + printf '%s\n' "$device" +else + exit 1 +fi diff --git a/bin/omarchy-hw-external-monitors b/bin/omarchy-hw-external-monitors index 08ab52ff1f..9c75a97a9c 100755 --- a/bin/omarchy-hw-external-monitors +++ b/bin/omarchy-hw-external-monitors @@ -2,8 +2,11 @@ # omarchy:summary=Returns true when an external monitor is physically connected. -for status in /sys/class/drm/card*-*/status; do - [[ "$status" == *-eDP-*/status ]] && continue - [[ "$(<"$status")" == "connected" ]] && exit 0 +drm_path="${OMARCHY_DRM_PATH:-/sys/class/drm}" + +for status in "$drm_path"/card*-*/status; do + [[ -e $status ]] || continue + [[ $status =~ -(eDP|LVDS|DSI)-[^/]+/status$ ]] && continue + [[ $(< $status) == "connected" ]] && exit 0 done exit 1 diff --git a/bin/omarchy-hw-fingerprint b/bin/omarchy-hw-fingerprint new file mode 100755 index 0000000000..cdaf040a24 --- /dev/null +++ b/bin/omarchy-hw-fingerprint @@ -0,0 +1,56 @@ +#!/bin/bash + +# omarchy:summary=Returns true when a fingerprint reader is present +# omarchy:hidden=true + +# Detect straight from sysfs so this works before fprintd/usbutils are +# installed (the fingerprint setup pulls those in). USB vendor IDs listed here +# ship fingerprint readers; multi-purpose vendors (e.g. Elan/STMicro, which +# also make USB touchscreens) are left out to avoid nagging laptops with no +# reader — those still match on the product string below when present. +fingerprint_vendors=" 27c6 138a 06cb 08ff 1c7a 147e " +usb_devices_path="${OMARCHY_USB_DEVICES_PATH:-/sys/bus/usb/devices}" + +# libfprint drives every reader it supports from userspace over libusb, so a +# real reader sits there with no kernel driver bound to any of its interfaces. +# The other things these vendors build — Synaptics webcam bridges (usbio-bridge +# on the Dell XPS 14), touchpads and touchscreens (usbhid), cameras (uvcvideo) +# — all bind one. Only the vendor-ID guess needs this; a device that names +# itself a fingerprint reader is trusted outright. +has_kernel_driver() { + local intf driver + for intf in "$1"/*:*; do + [[ -e $intf/driver ]] || continue + # usbfs is the exception: libusb claims an interface through it, so a reader + # fprintd is enrolling or verifying against binds a driver for as long as it + # holds the claim. That is userspace driving the device — what a reader is + # supposed to look like — so it must not read as a kernel driver here. + driver=$(readlink -f "$intf/driver") + [[ ${driver##*/} == "usbfs" ]] || return 0 + done + return 1 +} + +for dev in "$usb_devices_path"/*; do + # The device's own product descriptor usually names it, e.g. "Goodix + # Fingerprint USB Device" — driver-independent and vendor-agnostic. + if [[ -r $dev/product ]]; then + product=$(<"$dev/product") + product=${product,,} + # Elan's match-on-chip readers report "ELAN:ARM-M4" and Fingerprint Cards' + # report "FPC Sensor Controller" or "FPC L:0000 FW:1425046" — the family or + # the manufacturer rather than the function. Both vendors are left out of + # the list above on purpose (Elan also makes touchscreens), so without these + # they match nothing. FPC leads the string on every reader on record, and + # three letters are little to match on, so require the prefix. + [[ $product == *fingerprint* || $product == *biometric* || $product == *elan:arm-m4* || $product == "fpc "* ]] && exit 0 + fi + + if [[ -r $dev/idVendor ]]; then + vendor=$(<"$dev/idVendor") + [[ $fingerprint_vendors == *" $vendor "* ]] && + ! has_kernel_driver "$dev" && exit 0 + fi +done + +exit 1 diff --git a/bin/omarchy-hw-hybrid-gpu b/bin/omarchy-hw-hybrid-gpu index 7a960c790d..42f3c19dfe 100755 --- a/bin/omarchy-hw-hybrid-gpu +++ b/bin/omarchy-hw-hybrid-gpu @@ -2,8 +2,23 @@ # omarchy:summary=Detect whether the system has an active hybrid GPU configuration -if command -v supergfxctl &>/dev/null; then - supergfxctl -s 2>/dev/null | grep -qw Hybrid -else +multiple_gpus() { (($(lspci | grep -cE 'VGA|3D|Display') >= 2)) +} + +if omarchy-cmd-present supergfxctl; then + # A wedged supergfxd blocks its clients forever, and this gate runs while + # the menu renders. Bound the query, and treat a daemon that cannot answer + # like a machine without supergfxctl: count GPUs instead of hiding hardware + # that is really there. + modes=$(timeout --kill-after=1s 1s supergfxctl -s 2>/dev/null) + status=$? + + if ((status == 124 || status == 137)); then + multiple_gpus + else + grep -qw Hybrid <<<"$modes" + fi +else + multiple_gpus fi diff --git a/bin/omarchy-hw-intel-sof b/bin/omarchy-hw-intel-sof new file mode 100755 index 0000000000..0055dc69d9 --- /dev/null +++ b/bin/omarchy-hw-intel-sof @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Detect an Intel SOF-capable audio DSP + +lspci | grep -qiE '(Multimedia audio controller|Audio device).*Intel' diff --git a/bin/omarchy-hw-laptop b/bin/omarchy-hw-laptop new file mode 100755 index 0000000000..0ffbeeb3a8 --- /dev/null +++ b/bin/omarchy-hw-laptop @@ -0,0 +1,17 @@ +#!/bin/bash + +# omarchy:summary=Returns true when running on a laptop (has a lid or laptop chassis). + +# A lid switch is the definitive signal for clamshell-capable hardware. +for state in /proc/acpi/button/lid/*/state; do + [[ -e $state ]] && exit 0 +done + +# Fall back to the DMI chassis type for laptops that don't expose an ACPI lid +# button. 8=Portable 9=Laptop 10=Notebook 14=Sub Notebook 30=Tablet +# 31=Convertible 32=Detachable. +case $(< /sys/class/dmi/id/chassis_type 2>/dev/null) in +8 | 9 | 10 | 14 | 30 | 31 | 32) exit 0 ;; +esac + +exit 1 diff --git a/bin/omarchy-hw-laptop-closed b/bin/omarchy-hw-laptop-closed new file mode 100755 index 0000000000..8e03f9656f --- /dev/null +++ b/bin/omarchy-hw-laptop-closed @@ -0,0 +1,11 @@ +#!/bin/bash + +# omarchy:summary=Returns true when the laptop lid is closed +# omarchy:hidden=true + +for state in /proc/acpi/button/lid/*/state; do + [[ -r $state ]] || continue + [[ $(< "$state") == *"closed"* ]] && exit 0 +done + +exit 1 diff --git a/bin/omarchy-hw-nvidia b/bin/omarchy-hw-nvidia new file mode 100755 index 0000000000..7d18059354 --- /dev/null +++ b/bin/omarchy-hw-nvidia @@ -0,0 +1,16 @@ +#!/bin/bash + +# omarchy:summary=Detect whether the computer has an NVIDIA GPU. + +# Read the cached sysfs IDs rather than lspci, which reads PCI config space and +# resumes runtime-suspended GPUs. +pci_devices_path="${OMARCHY_PCI_DEVICES_PATH:-/sys/bus/pci/devices}" + +shopt -s nullglob + +for device in "$pci_devices_path"/*; do + [[ $(< "$device/vendor") == "0x10de" ]] || continue + [[ $(< "$device/class") == 0x03* ]] && exit 0 +done + +exit 1 diff --git a/bin/omarchy-hw-nvidia-gsp b/bin/omarchy-hw-nvidia-gsp index 06af2a3e3f..d3c584f977 100755 --- a/bin/omarchy-hw-nvidia-gsp +++ b/bin/omarchy-hw-nvidia-gsp @@ -2,5 +2,19 @@ # omarchy:summary=Detect whether the computer has an NVIDIA GPU with GSP firmware (Turing or newer). -# GTX 16xx, RTX 20xx-50xx, RTX Pro, Quadro RTX, datacenter A/H/T/L series. -lspci | grep -i 'nvidia' | grep -qE "GTX 16[0-9]{2}|RTX [2-5][0-9]{3}|RTX PRO [0-9]{4}|Quadro RTX|RTX A[0-9]{4}|A[1-9][0-9]{2}|H[1-9][0-9]{2}|T4|L[0-9]+" +# Turing is the first generation with GSP firmware, and the first to use device +# IDs at 0x1e00 or above; Maxwell, Pascal, and Volta all sit below that line. +# +# Read the cached sysfs IDs rather than lspci, which reads PCI config space and +# resumes runtime-suspended GPUs. +pci_devices_path="${OMARCHY_PCI_DEVICES_PATH:-/sys/bus/pci/devices}" + +shopt -s nullglob + +for device in "$pci_devices_path"/*; do + [[ $(< "$device/vendor") == "0x10de" ]] || continue + [[ $(< "$device/class") == 0x03* ]] || continue + (( $(< "$device/device") >= 0x1e00 )) && exit 0 +done + +exit 1 diff --git a/bin/omarchy-hw-nvidia-without-gsp b/bin/omarchy-hw-nvidia-without-gsp index 177e60b618..f951ebf168 100755 --- a/bin/omarchy-hw-nvidia-without-gsp +++ b/bin/omarchy-hw-nvidia-without-gsp @@ -2,5 +2,22 @@ # omarchy:summary=Detect whether the computer has an NVIDIA GPU without GSP firmware (Maxwell/Pascal/Volta). -# GTX 9xx/10xx, GT 10xx, Quadro P/M/GV, MX series, Titan X/Xp/V, Tesla V100. -lspci | grep -i 'nvidia' | grep -qE "GTX (9[0-9]{2}|10[0-9]{2})|GT 10[0-9]{2}|Quadro [PM][0-9]{3,4}|Quadro GV100|MX *[0-9]+|Titan (X|Xp|V)|Tesla V100" +# Bounded at both ends, because the callers install the 580xx driver on a match +# and it supports exactly this span. GSP firmware arrived with Turing, which is +# also where device IDs cross 0x1e00. Maxwell opens at 0x1340, one ID after the +# last Kepler part; anything older needs a legacy driver we don't package. +# +# Read the cached sysfs IDs rather than lspci, which reads PCI config space and +# resumes runtime-suspended GPUs. +pci_devices_path="${OMARCHY_PCI_DEVICES_PATH:-/sys/bus/pci/devices}" + +shopt -s nullglob + +for device in "$pci_devices_path"/*; do + [[ $(< "$device/vendor") == "0x10de" ]] || continue + [[ $(< "$device/class") == 0x03* ]] || continue + device_id=$(< "$device/device") + (( device_id >= 0x1340 && device_id < 0x1e00 )) && exit 0 +done + +exit 1 diff --git a/bin/omarchy-hw-recover-internal-monitor b/bin/omarchy-hw-recover-internal-monitor index 2ff125e71d..182f54f3a8 100755 --- a/bin/omarchy-hw-recover-internal-monitor +++ b/bin/omarchy-hw-recover-internal-monitor @@ -2,7 +2,7 @@ # omarchy:summary=Clear the internal-monitor-disable toggle if no external display is connected. -TOGGLE="$HOME/.local/state/omarchy/toggles/hypr/internal-monitor-disable.conf" +TOGGLE="$HOME/.local/state/omarchy/toggles/hypr/internal-monitor-disable.lua" if [[ -f $TOGGLE ]] && ! omarchy-hw-external-monitors; then rm -f "$TOGGLE" diff --git a/bin/omarchy-hw-webcam b/bin/omarchy-hw-webcam new file mode 100755 index 0000000000..7b25be703c --- /dev/null +++ b/bin/omarchy-hw-webcam @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Check whether a webcam is available + +[[ -n $(omarchy-capture-webcam-list) ]] diff --git a/bin/omarchy-hyprland-focus-app b/bin/omarchy-hyprland-focus-app new file mode 100755 index 0000000000..ca7b997633 --- /dev/null +++ b/bin/omarchy-hyprland-focus-app @@ -0,0 +1,31 @@ +#!/bin/bash + +# omarchy:summary=Focus a Hyprland window by application identity +# omarchy:args= +# omarchy:examples=omarchy hyprland focus app Slack + +usage() { + echo "Usage: omarchy-hyprland-focus-app " >&2 + exit 1 +} + +app=${1:-} +[[ -n $app ]] || usage + +# Agent terminals notify as kitty/foot/etc. while their shared window class is +# org.omarchy.agent, leaving the terminal name only in initialTitle. So match +# by class first, then fall back to the launch-time title of agent windows. +address=$( + hyprctl clients -j 2>/dev/null | + jq -r --arg pattern "$app" \ + 'def matches($value): ($value // "") | test($pattern; "i"); + first( + (.[] | select(matches(.class))), + (.[] | select(.initialClass == "org.omarchy.agent" and matches(.initialTitle))) + ).address // empty' +) + +[[ -n $address ]] || exit 1 + +hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })" >/dev/null 2>&1 || \ + hyprctl dispatch focuswindow "address:$address" >/dev/null diff --git a/bin/omarchy-hyprland-monitor-clamshell b/bin/omarchy-hyprland-monitor-clamshell new file mode 100755 index 0000000000..cbd1b33fdf --- /dev/null +++ b/bin/omarchy-hyprland-monitor-clamshell @@ -0,0 +1,244 @@ +#!/bin/bash + +# omarchy:summary=Apply clamshell display state to Hyprland monitors +# omarchy:hidden=true + +TOGGLES_DIR="$HOME/.local/state/omarchy/toggles/hypr" +CLAMSHELL_FLAG="$TOGGLES_DIR/internal-monitor-clamshell.lua" +MANUAL_DISABLE_FLAG="$TOGGLES_DIR/internal-monitor-disable.lua" +SCALE_STATE="$TOGGLES_DIR/internal-monitor-scale" +MONITOR_LUA="$HOME/.config/hypr/monitors.lua" + +INTERNAL=$(omarchy-hyprland-monitor-laptop) + +valid_scale() { + [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] +} + +scales_match() { + local left="$1" + local right="$2" + + valid_scale "$left" && valid_scale "$right" || return 1 + awk -v left="$left" -v right="$right" 'BEGIN { + diff = left - right + if (diff < 0) diff = -diff + exit(diff < 0.001 ? 0 : 1) + }' +} + +lua_identifier() { + [[ $1 =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] +} + +# Value assigned by `local = ...`, without its quotes or trailing comment. +# Only a lone scalar counts: an expression like `1080 / 720` must stay unresolved +# so the caller falls back rather than applying the first half of the sum. +lua_local_value() { + local name="$1" value + lua_identifier "$name" && [[ -f $MONITOR_LUA ]] || return 0 + + value=$(sed -nE 's/^[[:space:]]*local[[:space:]]+'"$name"'[[:space:]]*=[[:space:]]*("[^"]*"|[^"[:space:]]+)[[:space:]]*(--.*)?$/\1/p' "$MONITOR_LUA" | head -1) + [[ $value == \"*\" ]] && value="${value:1:-1}" + printf '%s\n' "$value" +} + +# A quoted capture is a Lua string and stands for itself; a bare word may instead +# name a local the rule refers to, as the shipped scale = omarchy_monitor_scale +# does. A bare word naming no local is left alone to fail validation. +lua_scalar() { + local value="$1" resolved + + if [[ $value == \"*\" ]]; then + printf '%s\n' "${value:1:-1}" + return + fi + + resolved=$(lua_local_value "$value") + printf '%s\n' "${resolved:-$value}" +} + +# The config with its comments cut away, so commented-out text can pose neither +# as a rule nor as one of its keys. +monitor_rules() { + [[ -f $MONITOR_LUA ]] || return 0 + + sed -E -e 's/--\[\[[^]]*\]\]//g' -e 's/--.*$//' "$MONITOR_LUA" +} + +monitor_rule_regex() { + printf '^[[:space:]]*hl\\.monitor\\(\\{.*output[[:space:]]*=[[:space:]]*"%s"' "$1" +} + +# A key is preceded by a table separator, so a longer key cannot stand in for it, +# and its value is the whole of what sits between the `=` and the next separator. +# Anything else is an expression this cannot evaluate. +configured_monitor_value() { + local output="$1" key="$2" value + + value=$(monitor_rules | sed -nE '/'"$(monitor_rule_regex "$output")"'/s/.*[{,;[:space:]]'"$key"'[[:space:]]*=[[:space:]]*("[^"]*"|[^,;}[:space:]]+)[[:space:]]*([,;}].*)?$/\1/p' | head -1) + lua_scalar "$value" +} + +configured_internal_monitor_value() { + [[ -n $INTERNAL ]] || return 0 + + configured_monitor_value "$INTERNAL" "$1" +} + +configured_monitor_scale() { + local scale + scale=$(configured_internal_monitor_value scale) + # An internal rule that names a scale settles it, even when the name resolves + # to nothing usable. Only a rule that names none at all defers to the catch-all, + # which is the scale Omarchy has always applied for that config. + [[ -n $scale ]] || scale=$(configured_monitor_value "" scale) + # No rule carries a scale at all: fall back to Omarchy's own knob. + [[ -n $scale ]] || scale=$(lua_local_value omarchy_monitor_scale) + + printf '%s\n' "$scale" +} + +current_internal_scale() { + [[ -n $INTERNAL ]] || return 0 + hyprctl monitors all -j | jq -r --arg internal "$INTERNAL" '.[] | select(.name == $internal and .disabled != true) | .scale' | head -1 +} + +store_internal_scale() { + local scale="$1" + valid_scale "$scale" || return 0 + + mkdir -p "$TOGGLES_DIR" + printf '%s\n' "$scale" >"$SCALE_STATE" +} + +remember_internal_scale() { + local scale + scale=$(current_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 + + if (( $# )); then + scale="$1" + else + scale=$(configured_monitor_scale) + fi + + if valid_scale "$scale"; then + echo "$scale" + return + fi + + if [[ -f $SCALE_STATE ]]; then + scale=$(<"$SCALE_STATE") + if valid_scale "$scale"; then + echo "$scale" + return + fi + fi + + echo 2 +} + +read_monitor_position() { + local position + position=$(configured_internal_monitor_value position) + if [[ $position =~ ^[-[:alnum:]_.+]+$ ]]; then + echo "$position" + return + fi + + echo auto +} + +enable_internal_output() { + [[ -n $INTERNAL ]] || return 0 + local scale="${1:-}" + local position + [[ -n $scale ]] || scale=$(read_monitor_scale) + position=$(read_monitor_position) + hyprctl eval "hl.monitor({ output = \"$INTERNAL\", mode = \"preferred\", position = \"$position\", scale = $scale })" >/dev/null 2>&1 || true +} + +sync_internal_scale() { + [[ -n $INTERNAL ]] || return 0 + local configured_scale + local desired_scale + local active_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" +} + +dpms_internal() { + local action="$1" + + [[ -n $INTERNAL ]] || return 0 + hyprctl dispatch "hl.dsp.dpms({ action = \"$action\", monitor = \"$INTERNAL\" })" >/dev/null 2>&1 || true +} + +enable_internal() { + local changed=0 + + if [[ -f $CLAMSHELL_FLAG ]]; then + rm -f "$CLAMSHELL_FLAG" + changed=1 + fi + + if (( changed )); then + hyprctl reload >/dev/null 2>&1 || true + fi + + [[ -f $MANUAL_DISABLE_FLAG ]] && omarchy-hyprland-monitor-external-active && return 0 + sync_internal_scale + + if (( changed )); then + dpms_internal enable + fi +} + +disable_internal() { + [[ -n $INTERNAL ]] || exit 0 + [[ -f $MANUAL_DISABLE_FLAG ]] && return 0 + + mkdir -p "$TOGGLES_DIR" + remember_internal_scale + + local config + config=$(printf 'hl.monitor({ output = "%s", disabled = true })' "$INTERNAL") + + if [[ ! -f $CLAMSHELL_FLAG ]] || [[ $(< "$CLAMSHELL_FLAG") != $config ]]; then + printf '%s\n' "$config" >"$CLAMSHELL_FLAG" + hyprctl reload >/dev/null 2>&1 || true + fi +} + +omarchy-hyprland-monitor-internal recover >/dev/null 2>&1 || true +omarchy-hyprland-monitor-internal-mirror recover >/dev/null 2>&1 || true + +if omarchy-hw-clamshell && omarchy-hyprland-monitor-external-active; then + disable_internal +else + enable_internal +fi diff --git a/bin/omarchy-hyprland-monitor-external-active b/bin/omarchy-hyprland-monitor-external-active new file mode 100755 index 0000000000..7fde41a50d --- /dev/null +++ b/bin/omarchy-hyprland-monitor-external-active @@ -0,0 +1,6 @@ +#!/bin/bash + +# omarchy:summary=Returns true when Hyprland has an active external monitor +# omarchy:hidden=true + +hyprctl monitors all -j | jq -e '.[] | select(.name | test("^(eDP|LVDS|DSI)-") | not) | select(.disabled == false)' >/dev/null 2>&1 diff --git a/bin/omarchy-hyprland-monitor-focused-apple b/bin/omarchy-hyprland-monitor-focused-apple index 99dd55e835..670faa522d 100755 --- a/bin/omarchy-hyprland-monitor-focused-apple +++ b/bin/omarchy-hyprland-monitor-focused-apple @@ -1,5 +1,12 @@ #!/bin/bash -# omarchy:summary=Return success if the focused Hyprland monitor is an Apple display. +# omarchy:summary=Return success if the focused or named Hyprland monitor is an Apple display. +# omarchy:args=[monitor] -hyprctl monitors -j | jq -e '.[] | select(.focused == true) | select(.make == "Apple Computer Inc" and (.model | test("StudioDisplay|ProDisplayXDR|Studio XDR")))' >/dev/null +monitor="${1:-}" + +hyprctl monitors -j | jq -e --arg monitor "$monitor" ' + .[] + | select(if $monitor == "" then .focused == true else .name == $monitor end) + | select(.make == "Apple Computer Inc" and (.model | test("StudioDisplay|ProDisplayXDR|Studio XDR"))) +' >/dev/null diff --git a/bin/omarchy-hyprland-monitor-internal b/bin/omarchy-hyprland-monitor-internal index 29f101d49c..836073ee7d 100755 --- a/bin/omarchy-hyprland-monitor-internal +++ b/bin/omarchy-hyprland-monitor-internal @@ -4,40 +4,65 @@ # omarchy:args= TOGGLE="internal-monitor-disable" -TOGGLE_FLAG="$HOME/.local/state/omarchy/toggles/hypr/$TOGGLE.conf" +TOGGLE_FLAG="$HOME/.local/state/omarchy/toggles/hypr/$TOGGLE.lua" MIRROR_TOGGLE="internal-monitor-mirror" -# Get internal monitor name dynamically -INTERNAL=$(hyprctl monitors -j | jq -r '.[] | select(.name | contains("eDP")).name' | head -n 1) +INTERNAL=$(omarchy-hyprland-monitor-laptop) -enable() { - if omarchy-hyprland-toggle-enabled "$TOGGLE"; then - omarchy-hyprland-toggle --disabled-notification "󰍹 Laptop display enabled" "$TOGGLE" +wake() { + hyprctl dispatch 'hl.dsp.dpms({ action = "enable" })' >/dev/null 2>&1 || true +} + +on() { + if omarchy-hyprland-toggle-enabled $TOGGLE; then + omarchy-hyprland-toggle $TOGGLE off + omarchy-notification-send -g 󰍹 "Laptop display enabled" fi + + wake } -disable() { - if ! omarchy-hw-external-monitors; then - notify-send -u low "󰍹 Can't disable the only active display" +off() { + if [[ -z $INTERNAL ]]; then + omarchy-notification-send -g 󰍹 "No laptop display found" + exit 1 + fi + + if ! omarchy-hyprland-monitor-external-active; then + omarchy-notification-send -g 󰍹 "Can't disable the only active display" exit 1 fi - if omarchy-hyprland-toggle-disabled "$TOGGLE" && omarchy-hyprland-toggle-disabled "$MIRROR_TOGGLE"; then - echo "monitor=$INTERNAL,disable" >"$TOGGLE_FLAG" - notify-send -u low "󰍹 Laptop display disabled" + + if omarchy-hyprland-toggle-disabled $TOGGLE && omarchy-hyprland-toggle-disabled $MIRROR_TOGGLE; then + printf 'hl.monitor({ output = "%s", disabled = true })\n' "$INTERNAL" >"$TOGGLE_FLAG" + omarchy-notification-send -g 󰍹 "Laptop display disabled" hyprctl reload fi } recover() { - if ! omarchy-hw-external-monitors && omarchy-hyprland-toggle-enabled "$TOGGLE"; then - omarchy-hyprland-toggle "$TOGGLE" + # Runs from the clamshell watcher every few seconds, so it must be a no-op + # unless it actually re-enables a display: an unconditional wake here undoes + # lock-screen blanking and races the resume modeset into a visible flash. + omarchy-hyprland-monitor-external-active && return 0 + omarchy-hyprland-toggle-enabled $TOGGLE || return 0 + + omarchy-hyprland-toggle $TOGGLE off + wake +} + +toggle() { + if omarchy-hyprland-toggle-enabled $TOGGLE; then + on + else + off fi } case "$1" in - on) enable ;; - off) disable ;; - toggle) if omarchy-hyprland-toggle-enabled "$TOGGLE"; then enable; else disable; fi ;; + on) on ;; + off) off ;; + toggle) toggle ;; recover) recover ;; *) echo "Usage: $(basename "$0") {on|off|toggle|recover}" >&2 diff --git a/bin/omarchy-hyprland-monitor-internal-mirror b/bin/omarchy-hyprland-monitor-internal-mirror index 07a0ccad32..d5213d4c4b 100755 --- a/bin/omarchy-hyprland-monitor-internal-mirror +++ b/bin/omarchy-hyprland-monitor-internal-mirror @@ -4,52 +4,58 @@ # omarchy:args= TOGGLE="internal-monitor-mirror" -TOGGLE_FLAG="$HOME/.local/state/omarchy/toggles/hypr/$TOGGLE.conf" +TOGGLE_FLAG="$HOME/.local/state/omarchy/toggles/hypr/$TOGGLE.lua" DISABLE_TOGGLE="internal-monitor-disable" -# Get names dynamically -INTERNAL=$(hyprctl monitors -j | jq -r '.[] | select(.name | contains("eDP")).name' | head -n 1) -# Get the first available external monitor -EXTERNAL=$(hyprctl monitors -j | jq -r '.[] | select(.name | contains("eDP") | not).name' | head -n 1) +INTERNAL=$(omarchy-hyprland-monitor-laptop) +# The first active external monitor +EXTERNAL=$(hyprctl monitors -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)-") | not).name' | head -n 1) -enable() { - if [[ -z "$EXTERNAL" ]]; then - notify-send -u low "󰍹 No external monitors found for mirror" +on() { + if [[ -z $EXTERNAL ]]; then + omarchy-notification-send -g 󰍹 "No external monitors found for mirror" exit 1 fi - if [[ -z "$INTERNAL" ]]; then - notify-send -u low "󰍹 No laptop monitor found to mirror" + if [[ -z $INTERNAL ]]; then + omarchy-notification-send -g 󰍹 "No laptop monitor found to mirror" exit 1 fi - if omarchy-hyprland-toggle-enabled "$DISABLE_TOGGLE"; then - omarchy-hyprland-toggle "$DISABLE_TOGGLE" - fi + omarchy-hyprland-toggle $DISABLE_TOGGLE off - if omarchy-hyprland-toggle-disabled "$TOGGLE"; then - echo "monitor=$EXTERNAL, preferred, auto, 1, mirror, $INTERNAL" > "$TOGGLE_FLAG" - notify-send -u low "󰍹 Mirroring enabled ($EXTERNAL)" + if omarchy-hyprland-toggle-disabled $TOGGLE; then + printf 'hl.monitor({ output = "%s", mode = "preferred", position = "auto", scale = 1, mirror = "%s" })\n' "$EXTERNAL" "$INTERNAL" >"$TOGGLE_FLAG" + omarchy-notification-send -g 󰍹 "Mirroring enabled ($EXTERNAL)" hyprctl reload fi } -disable() { - if omarchy-hyprland-toggle-enabled "$TOGGLE"; then - omarchy-hyprland-toggle --disabled-notification "󰍹 Extended mode restored" "$TOGGLE" +off() { + if omarchy-hyprland-toggle-enabled $TOGGLE; then + omarchy-hyprland-toggle $TOGGLE off + omarchy-notification-send -g 󰍹 "Extended mode restored" + fi +} + +toggle() { + if omarchy-hyprland-toggle-enabled $TOGGLE; then + off + else + on fi } recover() { - if ! omarchy-hw-external-monitors && omarchy-hyprland-toggle-enabled "$TOGGLE"; then - omarchy-hyprland-toggle "$TOGGLE" + if ! omarchy-hyprland-monitor-external-active && omarchy-hyprland-toggle-enabled $TOGGLE; then + omarchy-hyprland-toggle $TOGGLE off fi } case "$1" in - on) enable ;; - off) disable ;; - toggle) if omarchy-hyprland-toggle-enabled "$TOGGLE"; then disable; else enable; fi ;; + on) on ;; + off) off ;; + toggle) toggle ;; recover) recover ;; *) echo "Usage: $(basename "$0") {on|off|toggle|recover}" >&2 diff --git a/bin/omarchy-hyprland-monitor-laptop b/bin/omarchy-hyprland-monitor-laptop new file mode 100755 index 0000000000..cc0a254f2c --- /dev/null +++ b/bin/omarchy-hyprland-monitor-laptop @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Print the name of the built-in laptop display, including disabled outputs. + +hyprctl monitors all -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)-")).name' | head -n 1 diff --git a/bin/omarchy-hyprland-monitor-modeless b/bin/omarchy-hyprland-monitor-modeless new file mode 100755 index 0000000000..cb55e3b8f0 --- /dev/null +++ b/bin/omarchy-hyprland-monitor-modeless @@ -0,0 +1,21 @@ +#!/bin/bash + +# omarchy:summary=Returns true when Hyprland has an enabled monitor with no mode +# omarchy:hidden=true + +# A monitor powered off at boot answers with a partial EDID carrying no video +# modes, so Hyprland brings it up at 0x0 and the screen stays black. Mirrors are +# absent from plain `monitors`, hence `all` plus an explicit disabled filter. +# +# Exits 0 modeless, 1 not, 2 when the compositor cannot say. Nothing fires an +# event for this state, so a caller that gave up on an unanswered query would +# leave the screen black for good. +monitors=$(hyprctl monitors all -j 2>/dev/null) || exit 2 + +state=$(jq 'if any(.[]; .disabled != true and (.width == 0 or .height == 0)) then 0 else 1 end' \ + <<<"$monitors" 2>/dev/null) + +case $state in + 0 | 1) exit "$state" ;; + *) exit 2 ;; +esac diff --git a/bin/omarchy-hyprland-monitor-scaling b/bin/omarchy-hyprland-monitor-scaling new file mode 100755 index 0000000000..240a1ea5af --- /dev/null +++ b/bin/omarchy-hyprland-monitor-scaling @@ -0,0 +1,195 @@ +#!/bin/bash + +# omarchy:summary=Show, set, or adjust focused Hyprland monitor scaling +# omarchy:args=[up|down|SCALE] +# omarchy:examples=omarchy hyprland monitor scaling | omarchy hyprland monitor scaling 1.6 | omarchy hyprland monitor scaling up | omarchy hyprland monitor scaling down + +SCALES=(1 1.25 1.6 2 3 4) +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy" +SCALE_LOG="$STATE_DIR/monitor-scaling.log" + +usage() { + echo "Usage: omarchy-hyprland-monitor-scaling [up|down|SCALE]" +} + +focused_monitor_scale() { + hyprctl monitors -j | jq -er '.[] | select(.focused == true) | .scale' +} + +cmdline_for_pid() { + local pid="$1" + + [[ -r /proc/$pid/cmdline ]] || return 0 + tr '\0\t\n' ' ' <"/proc/$pid/cmdline" | sed -E 's/[[:space:]]+/ /g; s/[[:space:]]+$//' +} + +audit_scale_change() { + local requested="$1" + local active_monitor="$2" + local current_scale="$3" + local new_scale="$4" + local parent_pid="$PPID" + local grandparent_pid + local parent_cmd + local grandparent_cmd + + mkdir -p "$STATE_DIR" || return 0 + + grandparent_pid=$(ps -o ppid= -p "$parent_pid" 2>/dev/null | tr -d ' ') + parent_cmd=$(cmdline_for_pid "$parent_pid") + grandparent_cmd=$(cmdline_for_pid "$grandparent_pid") + + printf 'at=%s\trequested=%s\tcurrent=%s\tnew=%s\tmonitor=%s\tpid=%s\tppid=%s\tparent=%s\tgppid=%s\tgrandparent=%s\n' \ + "$(date --iso-8601=seconds)" \ + "$requested" \ + "$current_scale" \ + "$new_scale" \ + "$active_monitor" \ + "$$" \ + "$parent_pid" \ + "$parent_cmd" \ + "$grandparent_pid" \ + "$grandparent_cmd" >>"$SCALE_LOG" +} + +# Hyprland only accepts scales where the mode divides into whole logical +# pixels (in 1/120 steps), so clean scales are divisors of gcd(w*120, h*120). +# Round the requested scale up to the nearest clean value. +clean_scale() { + awk -v scale="$1" -v width="$2" -v height="$3" ' + function gcd(a, b, t) { while (b) { t = a % b; a = b; b = t } return a } + BEGIN { + g = gcd(width * 120, height * 120) + k = int(scale * 120 + 0.5) + if (k > g) k = g + while (g % k != 0) k++ + printf "%g\n", k / 120 + }' +} + +normalize_scale() { + awk 'NR == 1 { printf "%g\n", $0 }' +} + +set_scale() { + local requested_scale="$1" + local requested="${2:-$requested_scale}" + local monitor_info="$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)')" + local active_monitor="$(echo "$monitor_info" | jq -r '.name')" + local current_scale="$(echo "$monitor_info" | jq -r '.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')" + 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. + local new_gdk_scale="$(awk -v scale="$new_scale" 'BEGIN { printf "%d", int(scale + 0.5) }')" + local monitor_lua="$HOME/.config/hypr/monitors.lua" + + hyprctl eval "hl.monitor({ output = \"$active_monitor\", mode = \"${width}x${height}@${refresh_rate}\", position = \"auto\", scale = $new_scale })" >/dev/null + audit_scale_change "$requested" "$active_monitor" "$current_scale" "$new_scale" + + # Persist to monitors.lua if the user still has Omarchy's generic catch-all + # defaults, so the scale survives reboots. + if [[ -f $monitor_lua ]] && grep -q '^local omarchy_monitor_scale = ' "$monitor_lua"; then + sed -i -E \ + -e "s|^local omarchy_monitor_scale = .*|local omarchy_monitor_scale = ${new_scale}|" \ + -e "s|^local omarchy_gdk_scale = .*|local omarchy_gdk_scale = ${new_gdk_scale}|" \ + "$monitor_lua" + elif [[ -f $monitor_lua ]] && grep -Eq '^hl\.monitor\(\{ output = "", mode = "preferred", position = "auto", scale = ("auto"|[0-9.]+) \}\)' "$monitor_lua"; then + sed -i -E \ + -e "s|^(hl\.monitor\(\{ output = \"\", mode = \"preferred\", position = \"auto\", scale = )([^ ]+)( \}\))|\\1${new_scale}\\3|" \ + -e 's|^hl\.env\("GDK_SCALE", ".*"\)|hl.env("GDK_SCALE", "'"$new_gdk_scale"'")|' \ + "$monitor_lua" + fi +} + +scale_from_current() { + local direction="${1:-}" + local width="${2:-}" + local height="${3:-}" + + awk -v direction="$direction" -v list="${SCALES[*]}" -v width="$width" -v height="$height" ' + function gcd(a, b, t) { while (b) { t = a % b; a = b; b = t } return a } + function clean(scale, g, k) { + g = gcd(width * 120, height * 120) + k = int(scale * 120 + 0.5) + if (k > g) k = g + while (g % k != 0) k++ + return k / 120 + } + NR == 1 { scale = $0; found = 1 } + END { + if (!found) exit 1 + + preset_count = split(list, presets, " ") + for (i = 1; i <= preset_count; i++) { + effective = clean(presets[i]) + key = sprintf("%.8f", effective) + distance = presets[i] - effective + if (distance < 0) distance = -distance + + # Multiple presets can collapse to the same clean scale. Keep only the + # closest label so stepping always moves to a distinct effective value. + if (!(key in effective_index)) { + effective_index[key] = ++n + effective_scales[n] = effective + scales[n] = presets[i] + distances[n] = distance + } else { + idx = effective_index[key] + if (distance < distances[idx]) { + scales[idx] = presets[i] + distances[idx] = distance + } + } + } + + # Snap to the nearest effective scale first. Hyprland reports floating + # point values, so exact comparisons can otherwise get stuck. + best = 1; best_diff = 1e9 + for (i = 1; i <= n; i++) { + diff = scale - effective_scales[i]; if (diff < 0) diff = -diff + if (diff < best_diff) { best_diff = diff; best = i } + } + + if (direction == "next") { + print scales[(best < n ? best + 1 : n)] + } else if (direction == "previous") { + print scales[(best > 1 ? best - 1 : 1)] + } else { + print scales[best] + } + }' +} + +case "${1:-}" in +"") + focused_monitor_scale | normalize_scale + ;; +-h | --help) + usage + ;; +up) + monitor_info=$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)') + set_scale "$(echo "$monitor_info" | jq -r '.scale' | scale_from_current next \ + "$(echo "$monitor_info" | jq -r '.width')" "$(echo "$monitor_info" | jq -r '.height')")" "up" + ;; +down) + monitor_info=$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)') + set_scale "$(echo "$monitor_info" | jq -r '.scale' | scale_from_current previous \ + "$(echo "$monitor_info" | jq -r '.width')" "$(echo "$monitor_info" | jq -r '.height')")" "down" + ;; +1 | 1.25 | 1.6 | 2 | 3 | 4) + set_scale "$1" "$1" + ;; +*) + if [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] && + awk -v scale="$1" 'BEGIN { exit !(scale >= 1 && scale <= 4) }'; then + set_scale "$1" "$1" + else + usage >&2 + exit 1 + fi + ;; +esac diff --git a/bin/omarchy-hyprland-monitor-scaling-cycle b/bin/omarchy-hyprland-monitor-scaling-cycle deleted file mode 100755 index 8628c17ce0..0000000000 --- a/bin/omarchy-hyprland-monitor-scaling-cycle +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Cycle focused Hyprland monitor scaling through 1x, 1.25x, 1.6x, 2x, 3x, and 4x - -MONITOR_INFO=$(hyprctl monitors -j | jq -r '.[] | select(.focused == true)') -ACTIVE_MONITOR=$(echo "$MONITOR_INFO" | jq -r '.name') -CURRENT_SCALE=$(echo "$MONITOR_INFO" | jq -r '.scale') -WIDTH=$(echo "$MONITOR_INFO" | jq -r '.width') -HEIGHT=$(echo "$MONITOR_INFO" | jq -r '.height') -REFRESH_RATE=$(echo "$MONITOR_INFO" | jq -r '.refreshRate') - -# Cycle through scales: 1 → 1.25 → 1.6 → 2 → 3 → 4 → 1 (or reverse with --reverse) -SCALES=(1 1.25 1.6 2 3 4) - -# Find the index of the scale closest to the current one (Hyprland may -# snap fractional scales to nearby values, so we can't match exactly) -CURRENT_IDX=$(awk -v s="$CURRENT_SCALE" -v list="${SCALES[*]}" 'BEGIN { - n = split(list, arr, " ") - best = 0; best_diff = 1e9 - for (i = 1; i <= n; i++) { - d = s - arr[i]; if (d < 0) d = -d - if (d < best_diff) { best_diff = d; best = i - 1 } - } - print best -}') - -if [[ "$1" == "--reverse" ]]; then - NEW_IDX=$(( (CURRENT_IDX - 1 + ${#SCALES[@]}) % ${#SCALES[@]} )) -else - NEW_IDX=$(( (CURRENT_IDX + 1) % ${#SCALES[@]} )) -fi - -NEW_SCALE=${SCALES[$NEW_IDX]} - -hyprctl keyword monitor "$ACTIVE_MONITOR,${WIDTH}x${HEIGHT}@${REFRESH_RATE},auto,$NEW_SCALE" - -# Persist to monitors.conf if the user has a single generic catch-all line -# (ignoring disabled monitors), so the scale survives reboots. -MONITOR_CONF="$HOME/.config/hypr/monitors.conf" -if [[ -f $MONITOR_CONF ]]; then - mapfile -t ACTIVE_LINES < <(grep -E '^[[:space:]]*monitor=' "$MONITOR_CONF" | grep -vE 'disable[[:space:]]*$') - if [[ ${#ACTIVE_LINES[@]} -eq 1 ]] && [[ "${ACTIVE_LINES[0]}" =~ ^monitor=,preferred,auto, ]]; then - sed -i -E "s|^(monitor=,preferred,auto,).*|\\1${NEW_SCALE}|" "$MONITOR_CONF" - fi -fi - -notify-send -u low "󰍹 Display scaling set to ${NEW_SCALE}x" diff --git a/bin/omarchy-hyprland-monitor-watch b/bin/omarchy-hyprland-monitor-watch index 7d0df49410..33e8f2c599 100755 --- a/bin/omarchy-hyprland-monitor-watch +++ b/bin/omarchy-hyprland-monitor-watch @@ -3,12 +3,112 @@ # omarchy:summary=Watch Hyprland monitor events and recover monitor toggles when a monitor is removed SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" +LOCK="${XDG_RUNTIME_DIR:-/tmp}/omarchy-monitor-clamshell.lock" +MODELESS_LOCK="${XDG_RUNTIME_DIR:-/tmp}/omarchy-monitor-modeless.lock" -socat -U - "UNIX-CONNECT:$SOCKET" | while read -r event; do +sync_clamshell() { + ( + flock -n 9 || exit 0 + omarchy-hyprland-monitor-clamshell + ) 9>"$LOCK" +} + +sync_clamshell_after_monitor_change() { + sync_clamshell + + ( + for delay in 1 3 7; do + sleep "$delay" + sync_clamshell + done + ) & +} + +# Powering the monitor on fires no DRM hotplug, and forcing a re-probe needs +# root, so only a reload re-reads the EDID and only a reload reveals whether it +# worked. Back off: the machine can sit like this all night. The lock keeps one +# loop running across the events that call this; the wait is for an event landing +# in the moment one is exiting, which would otherwise be the last one to come. +recover_modeless() { + ( + flock -w 1 9 || exit 0 + + local delay=3 state reloaded unanswered=0 + + while true; do + omarchy-hyprland-monitor-modeless + state=$? + + # A monitor reporting a mode is recovered. One the compositor cannot speak + # for is not an answer, and nothing else will ask again -- but a compositor + # that stays silent has gone, taking the session and this loop's reason + # with it. + (( state == 1 )) && break + (( state == 2 )) && (( ++unanswered > 20 )) && break + (( state == 0 )) && unanswered=0 + + reloaded=0 + # Reloading into half-replaced package config is what the guard prevents. + if (( state == 0 )) && ! omarchy-hyprland-reload-guard paused; then + hyprctl reload >/dev/null 2>&1 || true + reloaded=1 + fi + + sleep "$delay" + (( reloaded )) && (( delay = delay * 2 > 60 ? 60 : delay * 2 )) + done + ) 9>"$MODELESS_LOCK" & +} + +poll_clamshell_state() { + while true; do + sleep 2 + sync_clamshell + done +} + +# The internal panel is only ever disabled while a laptop is docked (lid shut +# with an external monitor active), so the reconciliation poll only has anything +# to reconcile in that window. Run it while docked, stop it when undocked, and +# never on a machine without a lid. Lid open/close itself is handled by the +# Hyprland "switch:*:Lid Switch" binds; this poll is the recovery backstop for +# drift those binds can miss (e.g. across suspend/resume). +poll_pid="" +sync_poll_state() { + if omarchy-hw-laptop && omarchy-hyprland-monitor-external-active; then + if [[ -z $poll_pid ]] || ! kill -0 "$poll_pid" 2>/dev/null; then + poll_clamshell_state & + poll_pid=$! + fi + elif [[ -n $poll_pid ]]; then + kill "$poll_pid" 2>/dev/null + poll_pid="" + fi +} + +sync_clamshell_after_monitor_change +sync_poll_state +recover_modeless + +# Process substitution (not a pipe) keeps this loop in the main shell, so +# sync_poll_state can start and stop the background poll as monitors come and go. +while read -r event; do case "$event" in + monitoradded\>\>*|monitoraddedv2\>\>*) + sync_clamshell_after_monitor_change + sync_poll_state + recover_modeless + ;; monitorremoved\>\>*|monitorremovedv2\>\>*) - omarchy-hyprland-monitor-internal recover - omarchy-hyprland-monitor-internal-mirror recover + sync_clamshell_after_monitor_change + sync_poll_state + recover_modeless + ;; + # A reload while the monitor is unpowered leaves it at 0x0 with no hotplug + # to notice, same as at boot. Our own recovery reload lands here too, and is + # a no-op while its loop still holds the pid. + configreloaded\>\>*) + recover_modeless ;; esac -done +done < <(socat -U - "UNIX-CONNECT:$SOCKET") diff --git a/bin/omarchy-hyprland-reload-guard b/bin/omarchy-hyprland-reload-guard new file mode 100755 index 0000000000..2dff5175da --- /dev/null +++ b/bin/omarchy-hyprland-reload-guard @@ -0,0 +1,115 @@ +#!/bin/bash + +# omarchy:summary=Pause or resume Hyprland config auto-reload around package transactions. +# omarchy:hidden=true + +set -euo pipefail + +command="${1:-}" +case "$command" in + pause | resume | paused) ;; + *) + echo "Usage: omarchy-hyprland-reload-guard pause|resume|paused" >&2 + exit 1 + ;; +esac + +run_root="${OMARCHY_HYPRLAND_RELOAD_GUARD_RUN_ROOT:-/run/user}" +state_dir="${OMARCHY_HYPRLAND_RELOAD_GUARD_STATE_DIR:-/run/omarchy/hyprland-reload-guard}" +hyprctl_bin="${HYPRCTL:-/usr/bin/hyprctl}" + +hyprctl_instance() { + local runtime_dir="$1" + local signature="$2" + shift 2 + + XDG_RUNTIME_DIR="$runtime_dir" "$hyprctl_bin" --instance "$signature" "$@" +} + +option_bool() { + local runtime_dir="$1" + local signature="$2" + local option="$3" + + # A dead instance makes hyprctl print "Couldn't connect ..." on stdout, so + # silence jq too and let the failed pipeline skip the instance. + hyprctl_instance "$runtime_dir" "$signature" -j getoption "$option" 2>/dev/null | jq -r '.bool' 2>/dev/null +} + +instances() { + local instance_dir signature hypr_dir runtime_dir uid + + [[ -d $run_root ]] || return 0 + + for instance_dir in "$run_root"/*/hypr/*; do + [[ -d $instance_dir ]] || continue + + signature="${instance_dir##*/}" + hypr_dir="${instance_dir%/*}" + runtime_dir="${hypr_dir%/*}" + uid="${runtime_dir##*/}" + + [[ $uid =~ ^[0-9]+$ ]] || continue + + printf '%s\t%s\n' "$runtime_dir" "$signature" + done +} + +pause_instance() { + local runtime_dir="$1" + local signature="$2" + local disable_autoreload suppress_errors state_file="$state_dir/$signature" + + mkdir -p "$state_dir" + + if [[ ! -f $state_file ]]; then + disable_autoreload=$(option_bool "$runtime_dir" "$signature" misc.disable_autoreload) || return 0 + suppress_errors=$(option_bool "$runtime_dir" "$signature" debug.suppress_errors) || return 0 + printf '%s\t%s\t%s\n' "$runtime_dir" "$disable_autoreload" "$suppress_errors" >"$state_file" + fi + + hyprctl_instance "$runtime_dir" "$signature" eval \ + 'hl.config({ misc = { disable_autoreload = true }, debug = { suppress_errors = true } })' \ + >/dev/null 2>&1 || true +} + +resume_instance() { + local state_file="$1" + local signature="${state_file##*/}" + local runtime_dir disable_autoreload suppress_errors + + IFS=$'\t' read -r runtime_dir disable_autoreload suppress_errors <"$state_file" || return 0 + + if [[ -d $runtime_dir ]]; then + hyprctl_instance "$runtime_dir" "$signature" eval \ + "hl.config({ debug = { suppress_errors = $suppress_errors } })" \ + >/dev/null 2>&1 || true + + hyprctl_instance "$runtime_dir" "$signature" reload >/dev/null 2>&1 || true + + hyprctl_instance "$runtime_dir" "$signature" eval \ + "hl.config({ misc = { disable_autoreload = $disable_autoreload }, debug = { suppress_errors = $suppress_errors } })" \ + >/dev/null 2>&1 || true + fi + + rm -f "$state_file" +} + +case "$command" in + paused) + compgen -G "$state_dir/*" >/dev/null + ;; + pause) + while IFS=$'\t' read -r runtime_dir signature; do + pause_instance "$runtime_dir" "$signature" + done < <(instances) + ;; + resume) + [[ -d $state_dir ]] || exit 0 + for state_file in "$state_dir"/*; do + [[ -f $state_file ]] || continue + resume_instance "$state_file" + done + rmdir "$state_dir" 2>/dev/null || true + ;; +esac diff --git a/bin/omarchy-hyprland-session-locked b/bin/omarchy-hyprland-session-locked new file mode 100755 index 0000000000..7ff5248d64 --- /dev/null +++ b/bin/omarchy-hyprland-session-locked @@ -0,0 +1,29 @@ +#!/bin/bash + +# omarchy:summary=Returns true when the compositor holds a session lock +# omarchy:hidden=true + +# Hyprland reports no lock state directly, but an active ext-session-lock is one +# of the reasons a monitor cannot go solitary: LOCK in solitaryBlockedBy. It +# stays set once the lock's client dies, which is the case worth detecting. +# +# Exits 0 locked, 1 unlocked, 2 undetermined. Hyprland stops at the first reason +# on a monitor with no workspace yet, before it ever reaches the lock, so a +# missing LOCK there means nothing was asked. Callers branching only on success +# treat 2 as unlocked. +monitors=$(hyprctl -j monitors 2>/dev/null) || exit 2 + +state=$(jq ' + def blockers: .solitaryBlockedBy // []; + def readable: blockers | index("WORKSPACE") | not; + + if any(.[]; blockers | index("LOCK")) then 0 + elif any(.[]; readable) then 1 + else 2 + end +' <<<"$monitors" 2>/dev/null) + +case $state in + 0 | 1) exit "$state" ;; + *) exit 2 ;; +esac diff --git a/bin/omarchy-hyprland-toggle b/bin/omarchy-hyprland-toggle index 363ac8554f..158e28ac7a 100755 --- a/bin/omarchy-hyprland-toggle +++ b/bin/omarchy-hyprland-toggle @@ -1,32 +1,54 @@ #!/bin/bash # omarchy:summary=Toggle permanent Hyprland flags by copying them into a directory that's sourced entirely. -# omarchy:args=[--enabled-notification ] [--disabled-notification ] +# omarchy:args= [on|off|toggle] -ENABLED_NOTIFICATION="" -DISABLED_NOTIFICATION="" +usage() { + echo "Usage: omarchy-hyprland-toggle [on|off|toggle]" >&2 +} -while [[ $# -gt 1 ]]; do - case $1 in - --enabled-notification) ENABLED_NOTIFICATION="$2"; shift 2 ;; - --disabled-notification) DISABLED_NOTIFICATION="$2"; shift 2 ;; - *) break ;; - esac -done - -FLAG_NAME="$1" -FLAG="$HOME/.local/state/omarchy/toggles/hypr/$FLAG_NAME.conf" -FLAG_SOURCE="$OMARCHY_PATH/default/hypr/toggles/$FLAG_NAME.conf" - -if [[ -f $FLAG ]]; then - rm $FLAG - [[ -n $DISABLED_NOTIFICATION ]] && notify-send -u low "$DISABLED_NOTIFICATION" -elif [[ -f $FLAG_SOURCE ]]; then - cp $FLAG_SOURCE $FLAG - [[ -n $ENABLED_NOTIFICATION ]] && notify-send -u low "$ENABLED_NOTIFICATION" -else - echo "Flag not found: $FLAG_NAME" +if (($# < 1)); then + usage exit 1 fi -hyprctl reload +FLAG_NAME="$1" +ACTION="${2:-toggle}" +FLAG_FILE="$HOME/.local/state/omarchy/toggles/hypr/$FLAG_NAME.lua" +FLAG_SOURCE="$OMARCHY_PATH/default/hypr/toggles/$FLAG_NAME.lua" + +on() { + if [[ -f $FLAG_SOURCE ]]; then + mkdir -p "$(dirname "$FLAG_FILE")" + cp "$FLAG_SOURCE" "$FLAG_FILE" + else + echo "Flag not found: $FLAG_NAME" >&2 + exit 1 + fi +} + +off() { + rm -f "$FLAG_FILE" +} + +toggle() { + if [[ -f $FLAG_FILE ]]; then + off + echo "off" + else + on + echo "on" + fi +} + +case $ACTION in + on) on ;; + off) off ;; + toggle) toggle ;; + *) + usage + exit 1 + ;; +esac + +hyprctl reload >/dev/null diff --git a/bin/omarchy-hyprland-toggle-disabled b/bin/omarchy-hyprland-toggle-disabled index 51707491f9..5414e2b51b 100755 --- a/bin/omarchy-hyprland-toggle-disabled +++ b/bin/omarchy-hyprland-toggle-disabled @@ -3,4 +3,4 @@ # omarchy:summary=Check if a Hyprland toggle is currently disabled (missing). # omarchy:args= -[[ ! -f "$HOME/.local/state/omarchy/toggles/hypr/$1.conf" ]] +[[ ! -f "$HOME/.local/state/omarchy/toggles/hypr/$1.lua" ]] diff --git a/bin/omarchy-hyprland-toggle-enabled b/bin/omarchy-hyprland-toggle-enabled index cdc4e71816..73bb7ba132 100755 --- a/bin/omarchy-hyprland-toggle-enabled +++ b/bin/omarchy-hyprland-toggle-enabled @@ -3,4 +3,4 @@ # omarchy:summary=Check if a Hyprland toggle is currently enabled. # omarchy:args= -[[ -f "$HOME/.local/state/omarchy/toggles/hypr/$1.conf" ]] +[[ -f "$HOME/.local/state/omarchy/toggles/hypr/$1.lua" ]] diff --git a/bin/omarchy-hyprland-window-close-all b/bin/omarchy-hyprland-window-close-all index dbf0d75870..db0d9ae250 100755 --- a/bin/omarchy-hyprland-window-close-all +++ b/bin/omarchy-hyprland-window-close-all @@ -4,7 +4,9 @@ hyprctl clients -j | \ jq -r ".[].address" | \ - xargs -I{} hyprctl dispatch closewindow address:{} + while read -r addr; do + hyprctl dispatch "hl.dsp.window.close({ window = \"address:$addr\" })" >/dev/null + done # Move to first workspace -hyprctl dispatch workspace 1 +hyprctl dispatch 'hl.dsp.focus({ workspace = "1" })' >/dev/null 2>&1 || hyprctl dispatch workspace 1 diff --git a/bin/omarchy-hyprland-window-pop b/bin/omarchy-hyprland-window-pop index 0545107680..c115e09170 100755 --- a/bin/omarchy-hyprland-window-pop +++ b/bin/omarchy-hyprland-window-pop @@ -11,24 +11,30 @@ y=${4:-} active=$(hyprctl activewindow -j) pinned=$(echo "$active" | jq ".pinned") addr=$(echo "$active" | jq -r ".address") +window="address:$addr" + +hypr_dispatch() { + local lua="$1" + shift + + hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null +} if [[ $pinned == "true" ]]; then - hyprctl -q --batch \ - "dispatch pin address:$addr;" \ - "dispatch togglefloating address:$addr;" \ - "dispatch tagwindow -pop address:$addr;" + hypr_dispatch "hl.dsp.window.pin({ window = \"$window\" })" pin "$window" + hypr_dispatch "hl.dsp.window.float({ window = \"$window\", action = \"toggle\" })" togglefloating "$window" + hypr_dispatch "hl.dsp.window.tag({ window = \"$window\", tag = \"-pop\" })" tagwindow -pop "$window" elif [[ -n $addr ]]; then - hyprctl dispatch togglefloating address:$addr - hyprctl dispatch resizeactive exact $width $height address:$addr + hypr_dispatch "hl.dsp.window.float({ window = \"$window\", action = \"toggle\" })" togglefloating "$window" + hypr_dispatch "hl.dsp.window.resize({ window = \"$window\", x = $width, y = $height })" resizeactive exact "$width" "$height" "$window" if [[ -n $x && -n $y ]]; then - hyprctl dispatch moveactive $x $y address:$addr + hypr_dispatch "hl.dsp.window.move({ window = \"$window\", x = $x, y = $y })" moveactive "$x" "$y" "$window" else - hyprctl dispatch centerwindow address:$addr + hypr_dispatch "hl.dsp.window.center({ window = \"$window\" })" centerwindow "$window" fi - hyprctl -q --batch \ - "dispatch pin address:$addr;" \ - "dispatch alterzorder top address:$addr;" \ - "dispatch tagwindow +pop address:$addr;" + hypr_dispatch "hl.dsp.window.pin({ window = \"$window\" })" pin "$window" + hypr_dispatch "hl.dsp.window.alter_zorder({ window = \"$window\", mode = \"top\" })" alterzorder top "$window" + hypr_dispatch "hl.dsp.window.tag({ window = \"$window\", tag = \"+pop\" })" tagwindow +pop "$window" fi diff --git a/bin/omarchy-hyprland-window-single-square-aspect-toggle b/bin/omarchy-hyprland-window-single-square-aspect-toggle index d600c4086d..dd51f28d77 100755 --- a/bin/omarchy-hyprland-window-single-square-aspect-toggle +++ b/bin/omarchy-hyprland-window-single-square-aspect-toggle @@ -2,7 +2,7 @@ # omarchy:summary=Toggle single-window square aspect ratio. -omarchy-hyprland-toggle \ - --enabled-notification " Enable single-window square aspect ratio" \ - --disabled-notification " Disable single-window square aspect ratio" \ - single-window-aspect-ratio +case $(omarchy-hyprland-toggle single-window-aspect-ratio) in + on) omarchy-notification-send -g  "Enable single-window square aspect ratio" ;; + off) omarchy-notification-send -g  "Disable single-window square aspect ratio" ;; +esac diff --git a/bin/omarchy-hyprland-window-tiled-fullscreen-toggle b/bin/omarchy-hyprland-window-tiled-fullscreen-toggle new file mode 100755 index 0000000000..daa06ee240 --- /dev/null +++ b/bin/omarchy-hyprland-window-tiled-fullscreen-toggle @@ -0,0 +1,16 @@ +#!/bin/bash + +# omarchy:summary=Toggle tiled fullscreen for the focused Hyprland window + +set -euo pipefail + +active=$(hyprctl activewindow -j) +fullscreen_client=$(jq -r '.fullscreenClient // 0' <<<"$active") + +if [[ $fullscreen_client == "2" ]]; then + hyprctl dispatch 'hl.dsp.window.fullscreen_state({ internal = 0, client = 0 })' >/dev/null 2>&1 || \ + hyprctl dispatch fullscreenstate 0 0 >/dev/null +else + hyprctl dispatch 'hl.dsp.window.fullscreen_state({ internal = 0, client = 2 })' >/dev/null 2>&1 || \ + hyprctl dispatch fullscreenstate 0 2 >/dev/null +fi diff --git a/bin/omarchy-hyprland-window-transparency-toggle b/bin/omarchy-hyprland-window-transparency-toggle index 81afc272b0..1235969d6d 100755 --- a/bin/omarchy-hyprland-window-transparency-toggle +++ b/bin/omarchy-hyprland-window-transparency-toggle @@ -2,4 +2,6 @@ # omarchy:summary=Toggles transparency for the currently focused window. -hyprctl dispatch setprop "address:$(hyprctl activewindow -j | jq -r '.address')" opaque toggle +addr=$(hyprctl activewindow -j | jq -r '.address') +hyprctl dispatch "hl.dsp.window.set_prop({ window = \"address:$addr\", prop = \"opaque\", value = \"toggle\" })" >/dev/null 2>&1 || \ + hyprctl dispatch setprop "address:$addr" opaque toggle diff --git a/bin/omarchy-hyprland-window-width b/bin/omarchy-hyprland-window-width new file mode 100755 index 0000000000..78bf5d89e5 --- /dev/null +++ b/bin/omarchy-hyprland-window-width @@ -0,0 +1,168 @@ +#!/bin/bash + +# omarchy:summary=Save or restore the focused Hyprland window width +# omarchy:args= +# omarchy:examples=omarchy hyprland window width save | omarchy-hyprland-window-width restore + +set -euo pipefail + +STATE_DIR="$HOME/.local/state/omarchy/windows" + +usage() { + echo "Usage: omarchy-hyprland-window-width save|restore" >&2 + exit 1 +} + +active_window() { + hyprctl activewindow -j 2>/dev/null +} + +window_key() { + jq -r '[.class, .initialClass, .title] | map(select(. != null and . != "")) | first // empty' <<<"$1" +} + +workspace_key() { + jq -r '.workspace.id // .workspace.name // empty' <<<"$1" +} + +state_file_for() { + local key="$1" + local workspace="$2" + local filename="workspace-${workspace}-${key}" + + filename="${filename//\//_}" + filename="${filename//$'\n'/_}" + + printf '%s/%s.width' "$STATE_DIR" "$filename" +} + +notify_missing_width() { + local key="$1" + local workspace="$2" + + omarchy-notification-send -g  "No saved width found for $key on workspace $workspace" "Use Super + Alt + Home to save one for this workspace." +} + +notify_saved_width() { + local key="$1" + local workspace="$2" + + omarchy-notification-send -g  "Saved width for $key on workspace $workspace" "Restore using Super + Home on this workspace." +} + +hypr_dispatch() { + local lua="$1" + shift + + hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null +} + +window_width() { + local address="$1" + + hyprctl clients -j | jq -er --arg address "$address" '.[] | select(.address == $address) | .size[0]' +} + +resize_width_by() { + local window="$1" + local delta="$2" + + hypr_dispatch "hl.dsp.window.resize({ window = \"$window\", x = $delta, y = 0, relative = true })" resizeactive "$delta" 0 "$window" +} + +save_width() { + local active="$1" + local key="$2" + local workspace="$3" + local state_file="$4" + local tmp="" + local width="" + + width=$(jq -er '.size[0]' <<<"$active") + + mkdir -p "$STATE_DIR" + tmp=$(mktemp "$STATE_DIR/.width.XXXXXX") + printf '%s\n' "$width" >"$tmp" + mv "$tmp" "$state_file" + + notify_saved_width "$key" "$workspace" + echo "Saved width for $key on workspace $workspace" +} + +restore_width() { + local active="$1" + local key="$2" + local workspace="$3" + local state_file="$4" + local address="" + local current_width="" + local delta="" + local direction="" + local next_width="" + local probe="" + local probe_delta="" + local width="" + local window="" + + if [[ ! -f $state_file ]]; then + notify_missing_width "$key" "$workspace" + exit 1 + fi + + width=$(<"$state_file") + [[ $width =~ ^[0-9]+$ ]] || exit 1 + + address=$(jq -r '.address // empty' <<<"$active") + [[ -n $address ]] || exit 1 + + window="address:$address" + + current_width=$(window_width "$address") + ((current_width == width)) && return + + for probe in 10 -10; do + resize_width_by "$window" "$probe" + next_width=$(window_width "$address") + + if ((next_width != current_width)); then + if (((next_width - current_width) * probe > 0)); then + direction=1 + else + direction=-1 + fi + + current_width=$next_width + break + fi + done + + [[ -n $direction ]] || exit 1 + + for _ in {1..6}; do + delta=$((width - current_width)) + ((delta == 0)) && break + + probe_delta=$((delta * direction)) + resize_width_by "$window" "$probe_delta" + next_width=$(window_width "$address") + + ((next_width == current_width)) && break + current_width=$next_width + done +} + +action=${1:-} +[[ $action == "save" || $action == "restore" ]] || usage + +active=$(active_window) +key=$(window_key "$active") +[[ -n $key ]] || exit 1 +workspace=$(workspace_key "$active") +[[ -n $workspace ]] || exit 1 + +state_file=$(state_file_for "$key" "$workspace") + +case "$action" in +save) save_width "$active" "$key" "$workspace" "$state_file" ;; +restore) restore_width "$active" "$key" "$workspace" "$state_file" ;; +esac diff --git a/bin/omarchy-hyprland-workspace-layout-toggle b/bin/omarchy-hyprland-workspace-layout-toggle index 4636e38e99..29b0c10e9f 100755 --- a/bin/omarchy-hyprland-workspace-layout-toggle +++ b/bin/omarchy-hyprland-workspace-layout-toggle @@ -3,12 +3,19 @@ # omarchy:summary=Toggle the layout on the current active workspace between dwindle and scrolling ACTIVE_WORKSPACE=$(hyprctl activeworkspace -j | jq -r '.id') +[[ $ACTIVE_WORKSPACE =~ ^-?[0-9]+$ ]] || exit 1 CURRENT_LAYOUT=$(hyprctl activeworkspace -j | jq -r '.tiledLayout') +LAYOUTS_DIR="$HOME/.local/state/omarchy/workspace-layouts" +LAYOUT_FILE="$LAYOUTS_DIR/$ACTIVE_WORKSPACE.lua" case "$CURRENT_LAYOUT" in dwindle) NEW_LAYOUT=scrolling ;; *) NEW_LAYOUT=dwindle ;; esac -hyprctl keyword workspace $ACTIVE_WORKSPACE, layout:$NEW_LAYOUT -notify-send -u low "󱂬 Workspace layout set to $NEW_LAYOUT" +mkdir -p "$LAYOUTS_DIR" +printf 'hl.workspace_rule({ workspace = "%s", layout = "%s" })\n' "$ACTIVE_WORKSPACE" "$NEW_LAYOUT" >"$LAYOUT_FILE" + +hyprctl eval "hl.workspace_rule({ workspace = \"$ACTIVE_WORKSPACE\", layout = \"$NEW_LAYOUT\" })" >/dev/null 2>&1 || \ + hyprctl keyword workspace "$ACTIVE_WORKSPACE, layout:$NEW_LAYOUT" +omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_LAYOUT" diff --git a/bin/omarchy-install-ai-chatgpt b/bin/omarchy-install-ai-chatgpt new file mode 100755 index 0000000000..8986e8ed5e --- /dev/null +++ b/bin/omarchy-install-ai-chatgpt @@ -0,0 +1,15 @@ +#!/bin/bash + +# omarchy:summary=Install the ChatGPT desktop app +# omarchy:requires-sudo=true + +set -e + +echo "Installing ChatGPT..." +omarchy-pkg-add openai-codex-desktop + +echo "Opening ChatGPT..." +setsid uwsm-app -- /usr/bin/chatgpt >/dev/null 2>&1 & + +echo "" +echo "ChatGPT has been installed." diff --git a/bin/omarchy-install-and-launch b/bin/omarchy-install-and-launch new file mode 100755 index 0000000000..53bbfbc940 --- /dev/null +++ b/bin/omarchy-install-and-launch @@ -0,0 +1,21 @@ +#!/bin/bash + +# omarchy:summary=Install a packaged app and launch it once it finishes +# omarchy:args= +# omarchy:examples=omarchy install and launch Cursor cursor-bin cursor + +name="${1-}" +packages="${2-}" +desktop_id="${3-}" + +if [[ -z $name || -z $packages || -z $desktop_id ]]; then + echo "Usage: omarchy-install-and-launch " >&2 + exit 1 +fi + +printf -v install_message '%q' "Installing ${name}..." +printf -v desktop_id_arg '%q' "$desktop_id" + +# 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 &)" diff --git a/bin/omarchy-install-app b/bin/omarchy-install-app new file mode 100755 index 0000000000..bae9089762 --- /dev/null +++ b/bin/omarchy-install-app @@ -0,0 +1,15 @@ +#!/bin/bash + +# omarchy:summary=Install a packaged app, surfacing the install in a floating terminal +# omarchy:args= +# omarchy:examples=omarchy install app 'LM Studio' lmstudio-bin + +name="${1-}" +packages="${2-}" + +if [[ -z $name || -z $packages ]]; then + echo "Usage: omarchy-install-app " >&2 + exit 1 +fi + +exec omarchy-launch-floating-terminal-with-presentation "echo 'Installing ${name}...'; omarchy-pkg-add ${packages}" diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index ed067d8ddd..f71c7c984f 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -1,8 +1,10 @@ #!/bin/bash # omarchy:summary=Install a supported browser -# omarchy:args= -# omarchy:examples=omarchy install browser firefox | omarchy install browser brave +# omarchy:args= +# omarchy:examples=omarchy install browser chromium | omarchy install browser firefox + +set -e setup_policy_directory() { sudo mkdir -p "$1" @@ -16,7 +18,9 @@ announce_browser_installed() { copy_chromium_flags() { mkdir -p ~/.config - cp -f "${OMARCHY_PATH:-$HOME/.local/share/omarchy}/config/chromium-flags.conf" "$1" + cp -f "$OMARCHY_PATH/config/chromium-flags.conf" "$1" + omarchy-install-chromium-copy-url + omarchy-install-chromium-ytdlp } setup_firefox_preferences() { @@ -32,6 +36,15 @@ setup_firefox_wayland() { } case $1 in +chromium) + echo "Installing Chromium..." + omarchy-pkg-add chromium + + setup_policy_directory /etc/chromium/policies/managed + copy_chromium_flags ~/.config/chromium-flags.conf + omarchy-theme-set-browser + announce_browser_installed "Chromium" + ;; chrome) echo "Installing Chrome..." omarchy-pkg-aur-add google-chrome || exit 1 @@ -61,12 +74,10 @@ brave) ;; brave-origin) echo "Installing Brave Origin..." - omarchy-pkg-aur-add brave-origin-beta-bin || exit 1 + omarchy-pkg-aur-add brave-origin-bin || exit 1 setup_policy_directory /etc/brave/policies/managed - mkdir -p ~/.config - # FIXME: Use normal chromium flags when Brave Origin wrapper has been fixed - echo "--load-extension=~/.local/share/omarchy/default/chromium/extensions/copy-url" > ~/.config/brave-origin-beta-flags.conf + copy_chromium_flags ~/.config/brave-origin-flags.conf omarchy-theme-set-browser announce_browser_installed "Brave Origin" ;; @@ -87,7 +98,7 @@ zen) announce_browser_installed "Zen" ;; *) - echo "Usage: omarchy-install-browser " + echo "Usage: omarchy-install-browser " exit 1 ;; esac diff --git a/bin/omarchy-install-chromium-copy-url b/bin/omarchy-install-chromium-copy-url new file mode 100755 index 0000000000..4f6dae026b --- /dev/null +++ b/bin/omarchy-install-chromium-copy-url @@ -0,0 +1,28 @@ +#!/bin/bash + +# omarchy:summary=Install the native messaging host for the Copy URL Chromium extension + +set -euo pipefail + +HOST_NAME="com.omarchy.copy_url" +HOST_PATH="$OMARCHY_PATH/bin/omarchy-chromium-copy-url-host" +TEMPLATE="$OMARCHY_PATH/default/chromium/native-messaging-hosts/$HOST_NAME.json" + +browser_dirs=( + "$HOME/.config/chromium" + "$HOME/.config/google-chrome" + "$HOME/.config/google-chrome-beta" + "$HOME/.config/google-chrome-unstable" + "$HOME/.config/BraveSoftware/Brave-Browser" + "$HOME/.config/BraveSoftware/Brave-Browser-Beta" + "$HOME/.config/BraveSoftware/Brave-Browser-Nightly" + "$HOME/.config/microsoft-edge" + "$HOME/.config/microsoft-edge-dev" +) + +manifest=$(sed "s|__HOST_PATH__|$HOST_PATH|g" "$TEMPLATE") + +for dir in "${browser_dirs[@]}"; do + mkdir -p "$dir/NativeMessagingHosts" + printf '%s\n' "$manifest" >"$dir/NativeMessagingHosts/$HOST_NAME.json" +done diff --git a/bin/omarchy-install-chromium-ytdlp b/bin/omarchy-install-chromium-ytdlp new file mode 100755 index 0000000000..c700578cf5 --- /dev/null +++ b/bin/omarchy-install-chromium-ytdlp @@ -0,0 +1,29 @@ +#!/bin/bash + +# omarchy:summary=Install the native messaging host for the yt-dlp Chromium extension + +set -euo pipefail + +HOST_NAME="com.omarchy.ytdlp" +HOST_PATH="$OMARCHY_PATH/bin/omarchy-chromium-ytdlp-host" +TEMPLATE="$OMARCHY_PATH/default/chromium/native-messaging-hosts/$HOST_NAME.json" + +# Chromium-based browser profile roots that use the NativeMessagingHosts layout. +browser_dirs=( + "$HOME/.config/chromium" + "$HOME/.config/google-chrome" + "$HOME/.config/google-chrome-beta" + "$HOME/.config/google-chrome-unstable" + "$HOME/.config/BraveSoftware/Brave-Browser" + "$HOME/.config/BraveSoftware/Brave-Browser-Beta" + "$HOME/.config/BraveSoftware/Brave-Browser-Nightly" + "$HOME/.config/microsoft-edge" + "$HOME/.config/microsoft-edge-dev" +) + +manifest=$(sed "s|__HOST_PATH__|$HOST_PATH|g" "$TEMPLATE") + +for dir in "${browser_dirs[@]}"; do + mkdir -p "$dir/NativeMessagingHosts" + printf '%s\n' "$manifest" >"$dir/NativeMessagingHosts/$HOST_NAME.json" +done diff --git a/bin/omarchy-install-dropbox b/bin/omarchy-install-dropbox deleted file mode 100755 index 4add3460cd..0000000000 --- a/bin/omarchy-install-dropbox +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Install and start the Dropbox service. Must then be authenticated via the web. - -echo "Installing all dependencies..." -omarchy-pkg-add dropbox dropbox-cli libappindicator-gtk3 python-gpgme nautilus-dropbox - -echo "Starting Dropbox..." -uwsm-app -- dropbox-cli start &>/dev/null & -echo "See Dropbox icon behind  hover tray in top right and right-click for setup." diff --git a/bin/omarchy-install-editor-emacs b/bin/omarchy-install-editor-emacs new file mode 100755 index 0000000000..743b0ce694 --- /dev/null +++ b/bin/omarchy-install-editor-emacs @@ -0,0 +1,9 @@ +#!/bin/bash + +# omarchy:summary=Install Emacs with Omarchy theme and font integration via the omarchy-emacs AUR package + +echo "Installing Emacs..." +omarchy-pkg-aur-add omarchy-emacs && omarchy-install-emacs + +# emacsclient opens a frame on the running daemon, not a second Emacs +setsid uwsm-app -- gtk-launch emacsclient >/dev/null 2>&1 & diff --git a/bin/omarchy-install-editor-helix b/bin/omarchy-install-editor-helix new file mode 100755 index 0000000000..460bfa5e10 --- /dev/null +++ b/bin/omarchy-install-editor-helix @@ -0,0 +1,28 @@ +#!/bin/bash + +# omarchy:summary=Install Helix and configure it to use the current Omarchy theme + +echo "Installing Helix..." +omarchy-pkg-add helix + +mkdir -p ~/.config/helix/themes + +# Symlink the rendered Omarchy theme so Helix tracks the active theme +ln -sf "$HOME/.local/state/omarchy/current/theme/helix.toml" ~/.config/helix/themes/omarchy.toml + +# Only seed a config.toml if the user does not already have one +if [[ ! -f ~/.config/helix/config.toml ]]; then + cat >~/.config/helix/config.toml <<'EOF' +theme = "omarchy" +EOF +fi + +# Ensure the symlink target exists for users whose current theme predates this template +if [[ ! -e $HOME/.local/state/omarchy/current/theme/helix.toml ]]; then + omarchy-theme-refresh +fi + +# Arch-based distros ship Helix as 'helix' rather than the upstream 'hx'. +if ! grep -q '^alias hx="helix"' ~/.bashrc 2>/dev/null; then + echo 'alias hx="helix"' >>~/.bashrc +fi diff --git a/bin/omarchy-install-editor-vscode b/bin/omarchy-install-editor-vscode new file mode 100755 index 0000000000..2705fc1072 --- /dev/null +++ b/bin/omarchy-install-editor-vscode @@ -0,0 +1,29 @@ +#!/bin/bash + +# omarchy:summary=Install VS Code and configure Omarchy defaults for secrets, updates, and theme + +echo "Installing VSCode..." +omarchy-pkg-add visual-studio-code-bin + +mkdir -p ~/.vscode ~/.config/Code/User + +cat > ~/.vscode/argv.json << 'EOF' +// This configuration file allows you to pass permanent command line arguments to VS Code. +// Only a subset of arguments is currently supported to reduce the likelihood of breaking +// the installation. +// +// PLEASE DO NOT CHANGE WITHOUT UNDERSTANDING THE IMPACT +// +// NOTE: Changing this file requires a restart of VS Code. +{ + "password-store":"gnome-libsecret" +} +EOF + +# Ensure VSC's own auto-update feature is turned off +printf '{\n "update.mode": "none"\n}\n' > ~/.config/Code/User/settings.json + +# Apply Omarchy theme to VSCode +omarchy-theme-set-vscode + +setsid uwsm-app -- gtk-launch code >/dev/null 2>&1 & diff --git a/bin/omarchy-install-editor-zed b/bin/omarchy-install-editor-zed new file mode 100755 index 0000000000..799f86c2a5 --- /dev/null +++ b/bin/omarchy-install-editor-zed @@ -0,0 +1,11 @@ +#!/bin/bash + +# omarchy:summary=Install Zed Editor and configure it with the current Omarchy theme + +echo "Installing Zed Editor..." +omarchy-pkg-add zed omazed + +# Apply Omarchy theme to Zed +omazed setup + +setsid uwsm-app -- gtk-launch dev.zed.Zed >/dev/null 2>&1 & diff --git a/bin/omarchy-install-font b/bin/omarchy-install-font new file mode 100755 index 0000000000..6f68892e9b --- /dev/null +++ b/bin/omarchy-install-font @@ -0,0 +1,17 @@ +#!/bin/bash + +# omarchy:summary=Install a Nerd Font package and switch the system to it +# omarchy:args= +# omarchy:examples=omarchy install font 'Cascadia Mono' ttf-cascadia-mono-nerd 'CaskaydiaMono Nerd Font' + +name="${1-}" +package="${2-}" +family="${3-}" + +if [[ -z $name || -z $package || -z $family ]]; then + echo "Usage: omarchy-install-font " >&2 + exit 1 +fi + +exec omarchy-launch-floating-terminal-with-presentation \ + "echo 'Installing ${name}...'; omarchy-pkg-add ${package} && sleep 2 && omarchy-font-set '${family}'" diff --git a/bin/omarchy-install-gaming-battlenet b/bin/omarchy-install-gaming-battlenet new file mode 100755 index 0000000000..1bb4ddcdc2 --- /dev/null +++ b/bin/omarchy-install-gaming-battlenet @@ -0,0 +1,88 @@ +#!/bin/bash + +# omarchy:summary=Install Battle.net standalone via umu-launcher + GE-Proton (no Steam, no Lutris, no Heroic). +# omarchy:requires-sudo=true + +set -e + +PREFIX="$HOME/Games/battlenet" +LAUNCHER="$PREFIX/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe" +INSTALLER_URL="https://downloader.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live" + +echo "Installing Battle.net..." + +omarchy-pkg-add umu-launcher +omarchy-install-gaming-gpu-lib32 + +# Detect a half-finished prefix from a closed/crashed previous run and offer +# to wipe it before trying again. Battle.net's installer isn't idempotent. +if [[ -d $PREFIX && ! -f $LAUNCHER ]]; then + echo + echo "Found a partial Battle.net install at $PREFIX (no Launcher.exe)." + echo "Battle.net's installer can't resume from this state." + if gum confirm "Wipe the partial prefix and start fresh?"; then + pkill -f "$PREFIX" 2>/dev/null || true + sleep 1 + rm -rf "$PREFIX" + else + echo "Aborting. Re-run when ready to wipe." + exit 1 + fi +fi + +mkdir -p "$PREFIX" + +export WINEPREFIX="$PREFIX" +export PROTONPATH=GE-Proton +export GAMEID=umu-battlenet +export PROTON_VERB=run + +if [[ -f $LAUNCHER ]]; then + echo "Battle.net is already installed at $PREFIX." + launched_installer=0 +else + cache_dir="$HOME/.cache/omarchy" + mkdir -p "$cache_dir" + installer="$cache_dir/Battle.net-Setup.exe" + + echo + echo "Downloading Battle.net installer..." + curl --fail --location --retry 3 "$INSTALLER_URL" --output "$installer" + + cat <<'EOF' + +Launching the Battle.net setup wizard. Click through it normally — the +default install path is fine. When it finishes, Battle.net will be in your +app launcher. + +EOF + + log="/tmp/omarchy-battlenet-installer.log" + setsid -f sh -c "umu-run '$installer' >'$log' 2>&1" /dev/null 2>&1 + echo "Installer log: $log" + launched_installer=1 +fi + +mkdir -p "$HOME/.local/share/applications" +install -m 644 "$OMARCHY_PATH/default/applications/battlenet.desktop" \ + "$HOME/.local/share/applications/battlenet.desktop" +update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true + +if (( launched_installer )); then + cat < 0 )) && omarchy-pkg-add "${PACKAGES[@]}" diff --git a/bin/omarchy-install-gaming-heroic b/bin/omarchy-install-gaming-heroic index df416a6c03..8ba43ce7a7 100755 --- a/bin/omarchy-install-gaming-heroic +++ b/bin/omarchy-install-gaming-heroic @@ -9,4 +9,4 @@ echo "Installing Heroic Games Launcher..." omarchy-pkg-add heroic-games-launcher-bin omarchy-install-gaming-gpu-lib32 -setsid gtk-launch heroic >/dev/null 2>&1 & +setsid uwsm-app -- gtk-launch heroic >/dev/null 2>&1 & diff --git a/bin/omarchy-install-gaming-moonlight b/bin/omarchy-install-gaming-moonlight deleted file mode 100755 index 1fe77e6850..0000000000 --- a/bin/omarchy-install-gaming-moonlight +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Install Moonlight (NVIDIA GameStream / Sunshine client) for streaming games to this PC. -# omarchy:requires-sudo=true - -set -e - -echo "Installing Moonlight..." -omarchy-pkg-add moonlight-qt - -setsid gtk-launch com.moonlight_stream.Moonlight.desktop >/dev/null 2>&1 & diff --git a/bin/omarchy-install-gaming-retroarch b/bin/omarchy-install-gaming-retroarch index 3ebfea77c3..36076c43d1 100755 --- a/bin/omarchy-install-gaming-retroarch +++ b/bin/omarchy-install-gaming-retroarch @@ -10,12 +10,12 @@ omarchy-pkg-add \ retroarch-assets-glui retroarch-assets-ozone retroarch-assets-xmb \ libretro-beetle-pce libretro-beetle-pce-fast libretro-beetle-psx libretro-beetle-psx-hw libretro-beetle-supergrafx \ libretro-blastem \ - libretro-bsnes libretro-bsnes-hd libretro-bsnes2014 \ + libretro-bsnes libretro-bsnes-hd \ libretro-core-info \ libretro-desmume libretro-dolphin libretro-flycast \ libretro-gambatte libretro-genesis-plus-gx \ libretro-kronos \ - libretro-mame libretro-mame2016 libretro-melonds libretro-mesen libretro-mesen-s libretro-mgba libretro-mupen64plus-next \ + libretro-mame libretro-melonds libretro-mesen libretro-mesen-s libretro-mgba libretro-mupen64plus-next \ libretro-nestopia \ libretro-overlays \ libretro-parallel-n64 libretro-picodrive libretro-play libretro-ppsspp \ diff --git a/bin/omarchy-install-gaming-steam b/bin/omarchy-install-gaming-steam index 20dce3b6db..cefe8ce613 100755 --- a/bin/omarchy-install-gaming-steam +++ b/bin/omarchy-install-gaming-steam @@ -12,4 +12,4 @@ omarchy-install-gaming-gpu-lib32 echo "" echo "Steam will start automatically now. This might take a while..." -setsid gtk-launch steam >/dev/null 2>&1 & +setsid uwsm-app -- gtk-launch steam >/dev/null 2>&1 & diff --git a/bin/omarchy-install-gaming-xbox-cloud b/bin/omarchy-install-gaming-xbox-cloud index 8622949d33..2367328f5f 100755 --- a/bin/omarchy-install-gaming-xbox-cloud +++ b/bin/omarchy-install-gaming-xbox-cloud @@ -1,6 +1,8 @@ #!/bin/bash # omarchy:summary=Install Xbox Cloud Gaming as a web app and launch it. +# omarchy:group=install +# omarchy:name=gaming xbox-cloud set -e diff --git a/bin/omarchy-install-gaming-xbox-controllers b/bin/omarchy-install-gaming-xbox-controllers index 539d029722..f4134f767f 100755 --- a/bin/omarchy-install-gaming-xbox-controllers +++ b/bin/omarchy-install-gaming-xbox-controllers @@ -1,6 +1,8 @@ #!/bin/bash # omarchy:summary=Install support for using Xbox controllers with Steam/RetroArch/etc. +# omarchy:group=install +# omarchy:name=gaming xbox-controllers # omarchy:requires-sudo=true set -e diff --git a/bin/omarchy-install-helix b/bin/omarchy-install-helix deleted file mode 100755 index e8952c4e96..0000000000 --- a/bin/omarchy-install-helix +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Install Helix and configure it to use the current Omarchy theme - -echo "Installing Helix..." -omarchy-pkg-add helix - -mkdir -p ~/.config/helix/themes - -# Symlink the rendered Omarchy theme so Helix tracks the active theme -ln -sf ~/.config/omarchy/current/theme/helix.toml ~/.config/helix/themes/omarchy.toml - -# Only seed a config.toml if the user does not already have one -if [[ ! -f ~/.config/helix/config.toml ]]; then - cat >~/.config/helix/config.toml <<'EOF' -theme = "omarchy" -EOF -fi - -# Ensure the symlink target exists for users whose current theme predates this template -if [[ ! -e ~/.config/omarchy/current/theme/helix.toml ]]; then - omarchy-theme-refresh -fi - -# Arch-based distros ship Helix as 'helix' rather than the upstream 'hx'. -if ! grep -q '^alias hx="helix"' ~/.bashrc 2>/dev/null; then - echo 'alias hx="helix"' >>~/.bashrc -fi diff --git a/bin/omarchy-install-nordvpn b/bin/omarchy-install-nordvpn deleted file mode 100755 index d275800dd0..0000000000 --- a/bin/omarchy-install-nordvpn +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Install the NordVPN service with optional GUI. -# omarchy:requires-sudo=true - -echo "Installing NordVPN..." -omarchy-pkg-aur-add nordvpn-bin - -echo "Enabling NordVPN daemon..." -sudo systemctl enable --now nordvpnd - -echo "Adding user to nordvpn group..." -sudo usermod -aG nordvpn "$USER" - -echo -e "\nNordVPN installed! After reboot, run 'nordvpn login' to authenticate." - -echo -gum confirm "Reboot now to make NordVPN usable?" && omarchy-system-reboot diff --git a/bin/omarchy-install-preinstalls b/bin/omarchy-install-preinstalls new file mode 100755 index 0000000000..549ce792dd --- /dev/null +++ b/bin/omarchy-install-preinstalls @@ -0,0 +1,36 @@ +#!/bin/bash + +# omarchy:summary=Restore the preinstalled Omarchy applications (web apps, TUIs, and selected packages). +# omarchy:requires-sudo=true + +if gum confirm "Are you sure you want to restore all preinstalled web apps, TUI wrappers, and desktop applications?"; then + echo -e "Restoring preinstalled Omarchy applications...\n" + + # Recreates the shipped .desktop launchers (web apps and TUIs) and the mise stubs + # that back claude, gh, opencode, and the rest of the agents + omarchy-refresh-applications + + # Mirrors the list in omarchy-remove-preinstalls; both track omarchy-base.packages + if ! omarchy-pkg-add \ + aether \ + cliamp \ + libreoffice-fresh \ + xournalpp \ + pinta \ + obsidian \ + obs-studio \ + kdenlive \ + moonlight-qt \ + lazydocker \ + omacut \ + omacalc \ + omawrite; then + echo -e "\nPreinstalls are still marked as removed. Fix the errors above and try again." + exit 1 + fi + + # Last, so a failure above leaves the opt-out intact rather than restoring + # keybindings for apps that never came back + rm -f ~/.local/state/omarchy/preinstalls-removed + hyprctl reload +fi diff --git a/bin/omarchy-install-service-1password b/bin/omarchy-install-service-1password new file mode 100755 index 0000000000..f59cab9257 --- /dev/null +++ b/bin/omarchy-install-service-1password @@ -0,0 +1,34 @@ +#!/bin/bash + +# omarchy:summary=Install 1Password and its Chromium extension. +# omarchy:requires-sudo=true + +set -e + +EXTENSION_ID="aeblfdkhhhdcdjpifhhbdiojplfjncoa" +EXTENSION_DIR="/usr/share/chromium/extensions" +EXTENSION_FILE="$EXTENSION_DIR/$EXTENSION_ID.json" +WEBSTORE_UPDATE_URL="https://clients2.google.com/service/update2/crx" + +install_chromium_extension() { + if omarchy-cmd-missing chromium; then + echo "Chromium is not installed; skipping 1Password Chromium extension." + return + fi + + sudo mkdir -p "$EXTENSION_DIR" + printf '{ "external_update_url": "%s" }\n' "$WEBSTORE_UPDATE_URL" | sudo tee "$EXTENSION_FILE" >/dev/null + sudo chmod 644 "$EXTENSION_FILE" +} + +echo "Installing 1Password..." +omarchy-pkg-add 1password 1password-cli + +echo "Installing 1Password extension for Chromium..." +install_chromium_extension + +echo "Opening 1Password..." +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 new file mode 100755 index 0000000000..0ee07bfbfc --- /dev/null +++ b/bin/omarchy-install-service-dropbox @@ -0,0 +1,13 @@ +#!/bin/bash + +# omarchy:summary=Install and start the Dropbox service. Must then be authenticated via the web. + +echo "Installing all dependencies..." +omarchy-pkg-add dropbox dropbox-cli libappindicator-gtk3 python-gpgme nautilus-dropbox + +echo "Adding Dropbox to the bar..." +omarchy-plugin-enable omarchy.dropbox + +echo "Starting Dropbox..." +uwsm-app -- dropbox-cli start &>/dev/null & +echo "See Dropbox icon behind  hover tray in top right and right-click for setup." diff --git a/bin/omarchy-install-service-nordvpn b/bin/omarchy-install-service-nordvpn new file mode 100755 index 0000000000..3c314ab250 --- /dev/null +++ b/bin/omarchy-install-service-nordvpn @@ -0,0 +1,18 @@ +#!/bin/bash + +# omarchy:summary=Install the NordVPN service with optional GUI. +# omarchy:requires-sudo=true + +echo "Installing NordVPN..." +omarchy-pkg-add nordvpn-bin + +echo "Enabling NordVPN daemon..." +sudo systemctl enable --now nordvpnd + +echo "Adding user to nordvpn group..." +sudo usermod -aG nordvpn "$USER" + +echo -e "\nNordVPN installed! After reboot, run 'nordvpn login' to authenticate." + +echo +gum confirm "Reboot now to make NordVPN usable?" && omarchy-system-reboot diff --git a/bin/omarchy-install-once b/bin/omarchy-install-service-once similarity index 100% rename from bin/omarchy-install-once rename to bin/omarchy-install-service-once diff --git a/bin/omarchy-install-service-signal b/bin/omarchy-install-service-signal new file mode 100755 index 0000000000..f767968638 --- /dev/null +++ b/bin/omarchy-install-service-signal @@ -0,0 +1,15 @@ +#!/bin/bash + +# omarchy:summary=Install Signal and launch it. +# omarchy:requires-sudo=true + +set -e + +echo "Installing Signal..." +omarchy-pkg-add signal-desktop + +echo "Opening Signal..." +setsid uwsm-app -- /usr/bin/signal-desktop >/dev/null 2>&1 & + +echo "" +echo "Signal has been installed." diff --git a/bin/omarchy-install-service-spotify b/bin/omarchy-install-service-spotify new file mode 100755 index 0000000000..beae99397a --- /dev/null +++ b/bin/omarchy-install-service-spotify @@ -0,0 +1,15 @@ +#!/bin/bash + +# omarchy:summary=Install Spotify. +# omarchy:requires-sudo=true + +set -e + +echo "Installing Spotify..." +omarchy-pkg-add spotify + +echo "Opening Spotify..." +setsid uwsm-app -- /usr/bin/spotify >/dev/null 2>&1 & + +echo "" +echo "Spotify has been installed." diff --git a/bin/omarchy-install-service-sunshine b/bin/omarchy-install-service-sunshine new file mode 100755 index 0000000000..c215584b10 --- /dev/null +++ b/bin/omarchy-install-service-sunshine @@ -0,0 +1,87 @@ +#!/bin/bash + +# omarchy:summary=Install Sunshine and open Moonlight streaming ports for LAN and Tailscale. +# omarchy:requires-sudo=true + +set -e + +TCP_PORTS=(47984 47989 48010) +UDP_PORTS=(5353 47998 47999 48000 48002 48010) +PRIVATE_CIDRS=(10.0.0.0/8 172.16.0.0/12 192.168.0.0/16) +UFW_COMMENT="omarchy-sunshine" +SUNSHINE_ADMIN_APP="Sunshine Admin" +SUNSHINE_ADMIN_URL="https://localhost:47990" +SUNSHINE_ADMIN_EXEC="omarchy-launch-webapp $SUNSHINE_ADMIN_URL --ignore-certificate-errors" +SUNSHINE_ICON_SOURCE="/usr/share/sunshine/web/images/logo-sunshine-45.png" +HYPR_AUTOSTART_FILE="$HOME/.config/hypr/autostart.lua" +HYPR_AUTOSTART_ENTRY='o.launch_on_start("sunshine")' + +open_ufw_port_for_private_lans() { + local proto="$1" + local port="$2" + local cidr + + for cidr in "${PRIVATE_CIDRS[@]}"; do + sudo ufw allow in proto "$proto" from "$cidr" to any port "$port" comment "$UFW_COMMENT" >/dev/null + done +} + +open_ufw_port_for_tailscale() { + local proto="$1" + local port="$2" + + if ip link show tailscale0 >/dev/null 2>&1; then + sudo ufw allow in on tailscale0 to any port "$port" proto "$proto" comment "$UFW_COMMENT" >/dev/null + fi +} + +open_ufw_ports() { + local port + + if omarchy-cmd-missing ufw; then + echo "UFW is not installed; skipping Sunshine firewall rules." + return + fi + + for port in "${TCP_PORTS[@]}"; do + open_ufw_port_for_private_lans tcp "$port" + open_ufw_port_for_tailscale tcp "$port" + done + + for port in "${UDP_PORTS[@]}"; do + open_ufw_port_for_private_lans udp "$port" + open_ufw_port_for_tailscale udp "$port" + done + + sudo ufw reload +} + +install_admin_webapp() { + omarchy-webapp-install "$SUNSHINE_ADMIN_APP" "$SUNSHINE_ADMIN_URL" "$SUNSHINE_ICON_SOURCE" "$SUNSHINE_ADMIN_EXEC" +} + +enable_hyprland_autostart() { + mkdir -p "$(dirname "$HYPR_AUTOSTART_FILE")" + touch "$HYPR_AUTOSTART_FILE" + + if ! grep -Fxq "$HYPR_AUTOSTART_ENTRY" "$HYPR_AUTOSTART_FILE"; then + printf '\n%s\n' "$HYPR_AUTOSTART_ENTRY" >>"$HYPR_AUTOSTART_FILE" + fi +} + +echo "Installing Sunshine..." +omarchy-pkg-add sunshine +systemctl --user enable --now sunshine + +echo "Opening Sunshine firewall ports..." +open_ufw_ports + +echo "Installing Sunshine admin web app..." +install_admin_webapp +$SUNSHINE_ADMIN_EXEC >/dev/null 2>&1 & + +echo "Enabling Sunshine autostart..." +enable_hyprland_autostart + +echo "" +echo "Sunshine has been installed and its Moonlight streaming ports are open for private LANs and Tailscale." diff --git a/bin/omarchy-install-service-tailscale b/bin/omarchy-install-service-tailscale new file mode 100755 index 0000000000..5bb304cf0c --- /dev/null +++ b/bin/omarchy-install-service-tailscale @@ -0,0 +1,22 @@ +#!/bin/bash + +# omarchy:summary=Install the Tailscale mesh VPN service and a web app for the Tailscale Admin Console. +# omarchy:requires-sudo=true + +echo -e "\nInstalling Tailscale..." +omarchy-pkg-add tailscale + +echo -e "\nStarting Tailscale..." +sudo systemctl enable --now tailscaled.service +sudo tailscale up --accept-routes + +echo -e "\nAllowing $USER to manage Tailscale..." +sudo tailscale set --operator="$USER" + +echo -e "\nReceiving Taildrop files in $HOME/Downloads..." +systemctl --user enable --now omarchy-tailscale-receive.service + +echo -e "\nAdding Tailscale to the bar..." +omarchy-plugin-enable omarchy.tailscale + +omarchy-webapp-install "Tailscale" "https://login.tailscale.com/admin/machines" https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tailscale-light.png diff --git a/bin/omarchy-install-tailscale b/bin/omarchy-install-tailscale deleted file mode 100755 index 1c37ba419a..0000000000 --- a/bin/omarchy-install-tailscale +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Install the Tailscale mesh VPN service and a web app for the Tailscale Admin Console. -# omarchy:requires-sudo=true - -echo -e "\nInstalling Tailscale..." -omarchy-pkg-add tailscale - -echo -e "\nStarting Tailscale..." -sudo systemctl enable --now tailscaled.service -sudo tailscale up --accept-routes - -omarchy-webapp-install "Tailscale" "https://login.tailscale.com/admin/machines" https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tailscale-light.png diff --git a/bin/omarchy-install-terminal b/bin/omarchy-install-terminal index 2fb040ddd2..a32a7a330c 100755 --- a/bin/omarchy-install-terminal +++ b/bin/omarchy-install-terminal @@ -30,10 +30,10 @@ if omarchy-pkg-add $package; then # Copy custom desktop entries with X-TerminalArg* keys if [[ $package == "alacritty" ]]; then mkdir -p ~/.local/share/applications - cp "$OMARCHY_PATH/applications/$desktop_id" ~/.local/share/applications/ + cp "$OMARCHY_PATH/default/alacritty/$desktop_id" ~/.local/share/applications/ elif [[ $package == "foot" ]]; then mkdir -p ~/.local/share/applications - cp "$OMARCHY_PATH/default/foot/$desktop_id" ~/.local/share/applications/ + cp "$OMARCHY_PATH/applications/$desktop_id" ~/.local/share/applications/ fi # Copy default config for optional terminals when missing diff --git a/bin/omarchy-install-vscode b/bin/omarchy-install-vscode deleted file mode 100755 index 1850e77983..0000000000 --- a/bin/omarchy-install-vscode +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Install VS Code and configure Omarchy defaults for secrets, updates, and theme - -echo "Installing VSCode..." -omarchy-pkg-add visual-studio-code-bin - -mkdir -p ~/.vscode ~/.config/Code/User - -cat > ~/.vscode/argv.json << 'EOF' -// This configuration file allows you to pass permanent command line arguments to VS Code. -// Only a subset of arguments is currently supported to reduce the likelihood of breaking -// the installation. -// -// PLEASE DO NOT CHANGE WITHOUT UNDERSTANDING THE IMPACT -// -// NOTE: Changing this file requires a restart of VS Code. -{ - "password-store":"gnome-libsecret" -} -EOF - -# Ensure VSC's own auto-update feature is turned off -printf '{\n "update.mode": "none"\n}\n' > ~/.config/Code/User/settings.json - -# Apply Omarchy theme to VSCode -omarchy-theme-set-vscode - -setsid gtk-launch code diff --git a/bin/omarchy-install-zed b/bin/omarchy-install-zed deleted file mode 100755 index 9fa9f0ae7e..0000000000 --- a/bin/omarchy-install-zed +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Install Zed Editor and configure it with the current Omarchy theme - -echo "Installing Zed Editor..." -omarchy-pkg-add zed omazed - -# Apply Omarchy theme to Zed -omazed setup - -setsid gtk-launch dev.zed.Zed diff --git a/bin/omarchy-installed-service-dropbox b/bin/omarchy-installed-service-dropbox new file mode 100755 index 0000000000..84b91a6192 --- /dev/null +++ b/bin/omarchy-installed-service-dropbox @@ -0,0 +1,12 @@ +#!/bin/bash + +# omarchy:summary=Check whether Dropbox is installed and running +# omarchy:hidden=true + +set -euo pipefail + +if omarchy-cmd-present dropbox-cli && dropbox-cli running >/dev/null 2>&1; then + exit 0 +fi + +pgrep -x dropbox >/dev/null 2>&1 diff --git a/bin/omarchy-installed-service-tailscale b/bin/omarchy-installed-service-tailscale new file mode 100755 index 0000000000..44effe90ac --- /dev/null +++ b/bin/omarchy-installed-service-tailscale @@ -0,0 +1,12 @@ +#!/bin/bash + +# omarchy:summary=Check whether Tailscale is installed and running +# omarchy:hidden=true + +set -euo pipefail + +if omarchy-cmd-present tailscale && tailscale status --json >/dev/null 2>&1; then + exit 0 +fi + +systemctl is-active --quiet tailscaled.service >/dev/null 2>&1 || pgrep -x tailscaled >/dev/null 2>&1 diff --git a/bin/omarchy-launch-1password b/bin/omarchy-launch-1password new file mode 100755 index 0000000000..d50da0b214 --- /dev/null +++ b/bin/omarchy-launch-1password @@ -0,0 +1,11 @@ +#!/bin/bash + +# omarchy:summary=Launch 1Password or start its installer when missing. + +set -e + +if omarchy-cmd-present 1password; then + exec setsid uwsm-app -- 1password +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 cf9b4516e0..2d7c9d2c41 100755 --- a/bin/omarchy-launch-about +++ b/bin/omarchy-launch-about @@ -2,4 +2,363 @@ # omarchy:summary=Launch the fastfetch TUI that gives information about the current system. -exec omarchy-launch-or-focus-tui "bash -c 'fastfetch; read -n 1 -s'" +# The size that hugs the About content depends on the terminal font and the user's +# logo, so it can only be measured from inside the terminal. We remember the size +# that fit and apply it as a window rule before launching, so the window opens at +# it instead of resizing after the first paint. Bash defers WINCH traps while read +# blocks, so poll for size changes and re-render fastfetch whenever it is resized. + +LOGO_FILE="$HOME/.config/omarchy/branding/about.txt" +FIT_FILE="$HOME/.local/state/omarchy/windows/about.fit" +OMARCHY_FASTFETCH_DIR=/etc/fastfetch + +# 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 ]] +} + +# wc -L counts display columns only in a UTF-8 locale. A session that never set +# one counts every box-drawing and Nerd Font glyph in the About layout as +# nothing, which measures the content narrower than it renders. +display_columns() { + LC_ALL=C.UTF-8 wc -L +} + +logo_dimensions() { + [[ -f $LOGO_FILE ]] || return 1 + printf '%s %s' "$(display_columns <"$LOGO_FILE")" "$(wc -l <"$LOGO_FILE")" +} + +hypr_dispatch() { + local lua="$1" + shift + hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null +} + +remember_fit() { + local directory tmp + directory=$(dirname "$FIT_FILE") + mkdir -p "$directory" + tmp=$(mktemp "$directory/.about.fit.XXXXXX") + printf '%s %s %s %s\n' "$1" "$2" "$3" "$4" >"$tmp" + mv "$tmp" "$FIT_FILE" +} + +# Replaces the rule from the last launch so a remembered size never outlives the +# fit it came from. Called without one it only clears, leaving the float rule's +# starting size — where a Hyprland without the Lua API stays too. +apply_size_rule() { + local rule="" + (( $# == 2 )) && rule="omarchy_about_size_rule = hl.window_rule({ match = { class = \"org.omarchy.about\" }, size = { $1, $2 } })" + + hyprctl eval "if omarchy_about_size_rule then omarchy_about_size_rule:set_enabled(false) end; omarchy_about_size_rule = nil; $rule" >/dev/null 2>&1 +} + +# Sized before the terminal is spawned, so the window maps at its final size. +presize_window() { + local logo_w logo_h fit_logo_w fit_logo_h fit_w fit_h + + if ! custom_fastfetch_config && [[ -r $FIT_FILE ]]; then + read -r logo_w logo_h <<<"$(logo_dimensions)" + read -r fit_logo_w fit_logo_h fit_w fit_h <"$FIT_FILE" + + # A different logo needs a different window, so leave that launch to the + # float rule and let the fit measure the new size. + if [[ -n ${logo_w:-} && $logo_w == "$fit_logo_w" && $logo_h == "$fit_logo_h" ]] && + [[ $fit_w =~ ^[0-9]+$ && $fit_h =~ ^[0-9]+$ ]]; then + apply_size_rule "$fit_w" "$fit_h" + return + fi + fi + + apply_size_rule +} + +# Hyprland animates a resize and the terminal reflows to every step of it, so +# wait for the grid to hold still before measuring it. +settle_grid() { + local current previous="" held=0 + + for _ in {1..20}; do + current=$(stty size) + if [[ $current == $previous ]]; then + (( ++held == 3 )) && break + else + held=0 + previous=$current + fi + sleep 0.05 + done +} + +fit_window() { + custom_fastfetch_config && return 0 + + local logo_w logo_h + read -r logo_w logo_h <<<"$(logo_dimensions)" + [[ -n ${logo_w:-} ]] || return 1 + + # The guard character keeps command substitution from eating the trailing + # break line, which provides the bottom padding row. + local modules module_w + 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) + + # 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. 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 + read -r rows cols <<<"$(stty size)" + read -r address width height <<<"$(hyprctl clients -j | jq -r '.[] | select(.class == "org.omarchy.about") | "\(.address) \(.size[0]) \(.size[1])"')" + [[ -n ${address:-} ]] || return 1 + (( cols > 0 && rows > 0 && width > 0 && height > 0 )) || return 1 + + # A window has to land on the terminal's cell boundaries, so take a cell of + # slack over chasing an exact grid, and remember where it came to rest. + if (( cols >= target_c && cols <= target_c + 1 && rows >= target_r && rows <= target_r + 1 )); then + remember_fit "$logo_w" "$logo_h" "$width" "$height" + return 0 + fi + + # Two nudges is the budget, and each one is measured before the next is spent. + (( ++nudges <= 2 )) || return 1 + + # Move by the cells the window is off by, rather than scaling it to the grid, + # which would multiply up the terminal's padding along with them. Dividing a + # window that carries that padding still leaves the cell a touch generous, so + # round the move away from the grid that would clip. + shift_w=$(( (target_c - cols) * width )) + shift_h=$(( (target_r - rows) * height )) + target_w=$(( width + (shift_w >= 0 ? (shift_w + cols - 1) / cols : shift_w / cols) )) + target_h=$(( height + (shift_h >= 0 ? (shift_h + rows - 1) / rows : shift_h / rows) )) + + hypr_dispatch "hl.dsp.window.resize({ window = \"address:$address\", x = $target_w, y = $target_h })" resizewindowpixel "exact $target_w $target_h,address:$address" + hypr_dispatch "hl.dsp.window.center({ window = \"address:$address\" })" centerwindow + settle_grid + done + + return 1 +} + +# 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 + 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 + # time. Beyond that, a window that will not settle would be fitted again on + # every repaint. + if [[ $fitted == false ]] && (( ++passes <= 2 )); then + fit_window && fitted=true + fi + # 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 + passes=0 + fi + done +fi + +presize_window +exec omarchy-launch-or-focus-tui --app-id=org.omarchy.about omarchy-launch-about --render diff --git a/bin/omarchy-launch-audio b/bin/omarchy-launch-audio deleted file mode 100755 index c3d2592cff..0000000000 --- a/bin/omarchy-launch-audio +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Launch the Omarchy audio controls TUI (provided by wiremix). - -omarchy-launch-or-focus-tui wiremix diff --git a/bin/omarchy-launch-battlenet b/bin/omarchy-launch-battlenet new file mode 100755 index 0000000000..abaa85e5f9 --- /dev/null +++ b/bin/omarchy-launch-battlenet @@ -0,0 +1,48 @@ +#!/bin/bash + +# omarchy:summary=Launch the installed Battle.net client via umu-launcher + GE-Proton. +# omarchy:args=[--with-mangohud] +# omarchy:examples=omarchy launch battlenet | omarchy launch battlenet --with-mangohud + +set -e + +PREFIX="$HOME/Games/battlenet" +LAUNCHER="$PREFIX/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe" + +with_mangohud=0 +for arg in "$@"; do + case "$arg" in + --with-mangohud) with_mangohud=1 ;; + -h|--help) + cat <<'EOF' +Usage: omarchy-launch-battlenet [--with-mangohud] + +Options: + --with-mangohud Enable the MangoHud FPS overlay for games launched from + Battle.net. Toggle perf logging in-game with Shift_L+F2; + CSV logs land in ~/mangohud/. +EOF + exit 0 + ;; + *) + echo "Unknown argument: $arg" >&2 + echo "Try: omarchy-launch-battlenet --help" >&2 + exit 1 + ;; + esac +done + +if [[ ! -f $LAUNCHER ]]; then + echo "Battle.net is not installed. Run omarchy-install-gaming-battlenet first." >&2 + exit 1 +fi + +env_args=( + WINEPREFIX="$PREFIX" + PROTONPATH=GE-Proton + GAMEID=umu-battlenet + PROTON_VERB=run +) +(( with_mangohud )) && env_args+=(MANGOHUD=1) + +env "${env_args[@]}" umu-run "$LAUNCHER" diff --git a/bin/omarchy-launch-bluetooth b/bin/omarchy-launch-bluetooth deleted file mode 100755 index 8c450261b9..0000000000 --- a/bin/omarchy-launch-bluetooth +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Launch the Omarchy bluetooth controls TUI (provided by bluetui). - -rfkill unblock bluetooth -exec omarchy-launch-or-focus-tui bluetui diff --git a/bin/omarchy-launch-browser b/bin/omarchy-launch-browser index fefc935e07..044b220d22 100755 --- a/bin/omarchy-launch-browser +++ b/bin/omarchy-launch-browser @@ -3,10 +3,13 @@ # omarchy:summary=Launch the default browser as determined by xdg-settings. # omarchy:args=[url] -default_browser=$(xdg-settings get default-web-browser) +default_browser=$(env -u BROWSER xdg-settings get default-web-browser) +if [[ -z $default_browser ]]; then + default_browser=$(xdg-mime query default x-scheme-handler/https) +fi browser_exec=$(sed -n 's/^Exec=\([^ ]*\).*/\1/p' {~/.local,~/.nix-profile,/usr}/share/applications/$default_browser 2>/dev/null | head -1) -if $browser_exec --help | grep -q MOZ_LOG; then +if $browser_exec --help 2>/dev/null | grep -q MOZ_LOG; then private_flag="--private-window" elif [[ $browser_exec =~ edge ]]; then private_flag="--inprivate" @@ -14,4 +17,18 @@ else private_flag="--incognito" fi -exec setsid uwsm-app -- "$browser_exec" "${@/--private/$private_flag}" +systemd-run --user --quiet --collect --unit="omarchy-browser-$(date +%s%N)" \ + --property=StandardOutput=null --property=StandardError=null \ + uwsm-app -- "$browser_exec" "${@/--private/$private_flag}" + +url="" +for argument in "$@"; do + if [[ $argument != "--private" ]]; then + url=$argument + break + fi +done + +if [[ -n $url && -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then + omarchy-hyprland-focus-app "^$(basename "$browser_exec" -stable).*$" || true +fi diff --git a/bin/omarchy-launch-config-editor b/bin/omarchy-launch-config-editor new file mode 100755 index 0000000000..ffe100e970 --- /dev/null +++ b/bin/omarchy-launch-config-editor @@ -0,0 +1,15 @@ +#!/bin/bash + +# omarchy:summary=Open a config file in the user's editor and surface a toast +# omarchy:args= +# omarchy:examples=omarchy launch config-editor ~/.config/hypr/hyprland.lua + +path="${1-}" + +if [[ -z $path ]]; then + echo "Usage: omarchy-launch-config-editor " >&2 + exit 1 +fi + +omarchy-notification-send -u low "Editing config file" "$path" +exec omarchy-launch-editor "$path" diff --git a/bin/omarchy-launch-discord-community b/bin/omarchy-launch-discord-community new file mode 100755 index 0000000000..1c2f236636 --- /dev/null +++ b/bin/omarchy-launch-discord-community @@ -0,0 +1,11 @@ +#!/bin/bash + +# omarchy:summary=Open the Omarchy Discord community in the Discord app or a browser. + +invite="https://discord.gg/tXFUdasqhY" + +if omarchy-cmd-present discord; then + exec setsid uwsm-app -- discord --url -- "discord://-/invite/${invite##*/}" +else + exec omarchy-launch-webapp "$invite" +fi diff --git a/bin/omarchy-launch-editor b/bin/omarchy-launch-editor index 9c3ec369a3..8a69dabd4b 100755 --- a/bin/omarchy-launch-editor +++ b/bin/omarchy-launch-editor @@ -1,15 +1,34 @@ #!/bin/bash -# omarchy:summary=Launch the default editor as determined by $EDITOR (set via ~/.config/uwsm/default) (or nvim if missing). -# omarchy:args= +# omarchy:summary=Launch the default editor selected via Omarchy defaults. +# omarchy:args=[--inline] -omarchy-cmd-present "$EDITOR" || EDITOR=nvim +default_editor="$HOME/.local/state/omarchy/defaults/editor" -case "$EDITOR" in +if [[ ${1:-} == "--inline" ]]; then + inline=true + shift +else + inline=false +fi + +if [[ -f $default_editor ]]; then + read -r editor <"$default_editor" +else + editor="nvim" +fi + +omarchy-cmd-present "$editor" || editor="nvim" + +case "${editor##*/}" in nvim | vim | nano | micro | hx | helix | fresh) - exec omarchy-launch-tui "$EDITOR" "$@" + if [[ $inline == "true" ]]; then + exec "$editor" "$@" + else + 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 b0eaee72b6..c9cb255abf 100755 --- a/bin/omarchy-launch-floating-terminal-with-presentation +++ b/bin/omarchy-launch-floating-terminal-with-presentation @@ -3,5 +3,11 @@ # omarchy:summary=Launch a floating terminal with the Omarchy presentation wrapper # omarchy:args= +# Export the current theme's gum styling so gum widgets match the active theme +# even after a theme switch (the inherited environment is captured at login). +source omarchy-restart-gum + cmd="$*" -exec setsid uwsm-app -- xdg-terminal-exec --app-id=org.omarchy.terminal --title=Omarchy -e bash -c "omarchy-show-logo; $cmd; if (( \$? != 130 )); then omarchy-show-done; fi" +presentation_script="omarchy-show-logo; $cmd; if (( \$? != 130 )); then omarchy-show-done; 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-nautilus b/bin/omarchy-launch-nautilus new file mode 100755 index 0000000000..0b10b7b2a8 --- /dev/null +++ b/bin/omarchy-launch-nautilus @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Launch Files + +exec setsid uwsm-app -- nautilus --new-window diff --git a/bin/omarchy-launch-nautilus-cwd b/bin/omarchy-launch-nautilus-cwd new file mode 100755 index 0000000000..ae9a0d8856 --- /dev/null +++ b/bin/omarchy-launch-nautilus-cwd @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Launch Files in the active terminal's current directory + +exec setsid uwsm-app -- nautilus --new-window "$(omarchy-cmd-terminal-cwd)" diff --git a/bin/omarchy-launch-or-focus b/bin/omarchy-launch-or-focus index 44164bb55e..11f65c5f46 100755 --- a/bin/omarchy-launch-or-focus +++ b/bin/omarchy-launch-or-focus @@ -13,7 +13,7 @@ LAUNCH_COMMAND="${2:-"uwsm-app -- $WINDOW_PATTERN"}" WINDOW_ADDRESS=$(hyprctl clients -j | jq -r --arg p "$WINDOW_PATTERN" '.[]|select((.class|test("\\b" + $p + "\\b";"i")) or (.title|test("\\b" + $p + "\\b";"i")))|.address' | head -n1) if [[ -n $WINDOW_ADDRESS ]]; then - hyprctl dispatch focuswindow "address:$WINDOW_ADDRESS" + hyprctl dispatch "hl.dsp.focus({ window = \"address:$WINDOW_ADDRESS\" })" >/dev/null 2>&1 || hyprctl dispatch focuswindow "address:$WINDOW_ADDRESS" else eval exec setsid $LAUNCH_COMMAND fi diff --git a/bin/omarchy-launch-or-focus-tui b/bin/omarchy-launch-or-focus-tui index 9b1858b38c..45f9226871 100755 --- a/bin/omarchy-launch-or-focus-tui +++ b/bin/omarchy-launch-or-focus-tui @@ -1,9 +1,14 @@ #!/bin/bash # omarchy:summary=Launch a TUI or focus an existing terminal window for it -# omarchy:args= [args...] +# omarchy:args=[--app-id=] [args...] + +if [[ ${1:-} == --app-id=* ]]; then + APP_ID="${1#--app-id=}" +else + APP_ID="org.omarchy.$(basename "$1")" +fi -APP_ID="org.omarchy.$(basename "$1")" LAUNCH_COMMAND="omarchy-launch-tui $@" exec omarchy-launch-or-focus "$APP_ID" "$LAUNCH_COMMAND" diff --git a/bin/omarchy-launch-screensaver b/bin/omarchy-launch-screensaver index 0cd64a70b7..c66c41da3e 100755 --- a/bin/omarchy-launch-screensaver +++ b/bin/omarchy-launch-screensaver @@ -2,58 +2,72 @@ # omarchy:summary=Launch the Omarchy screensaver in the default terminal on the system with the correct font configuration. -if ! command -v tte &>/dev/null; then - exit 1 -fi - -# Exit early if screensave is already running -pgrep -f org.omarchy.screensaver && exit 0 +# Exit early if screensaver is already running +pgrep -f '[o]rg.omarchy.screensaver' && exit 0 # Allow screensaver to be turned off but also force started if omarchy-toggle-enabled screensaver-off && [[ $1 != "force" ]]; then exit 1 fi -# Silently quit Walker on overlay -walker -q - focused=$(omarchy-hyprland-monitor-focused) terminal=$(xdg-terminal-exec --print-id) +case $terminal in +*Alacritty* | *ghostty* | *foot* | *kitty*) ;; +*) + omarchy-notification-send -g ✋ "Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty" + exit 1 + ;; +esac + +hypr_focus_monitor() { + hyprctl dispatch "hl.dsp.focus({ monitor = \"$1\" })" >/dev/null 2>&1 || hyprctl dispatch focusmonitor "$1" >/dev/null +} + +hypr_exec() { + local command + printf -v command '%q ' "$@" + + hyprctl dispatch "hl.dsp.exec_cmd([[$command]])" >/dev/null 2>&1 || hyprctl dispatch exec -- bash -lc "$command" >/dev/null +} + +SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" + +# Open Hyprland's event stream before spawning anything, so a terminal that maps +# quickly can't emit its openwindow event before we are listening for it. +exec {events}< <(socat -U - "UNIX-CONNECT:$SOCKET") + +# hypr_exec is async and a new window maps on whatever monitor is focused at that +# moment. Block until this monitor's screensaver actually opens before moving +# focus on -- otherwise slow-starting terminals all pile onto the last monitor. +# The deadline is a safety net in case the window never appears. +wait_for_screensaver_window() { + local line deadline=$((SECONDS + 5)) + while ((SECONDS < deadline)) && IFS= read -r -t $((deadline - SECONDS)) -u "$events" line; do + [[ $line == openwindow\>\>*,org.omarchy.screensaver,* ]] && return 0 + done +} + for m in $(hyprctl monitors -j | jq -r '.[] | .name'); do - hyprctl dispatch focusmonitor $m + hypr_focus_monitor "$m" case $terminal in *Alacritty*) - hyprctl dispatch exec -- \ - alacritty --class=org.omarchy.screensaver \ - --config-file ~/.local/share/omarchy/default/alacritty/screensaver.toml \ - -e omarchy-screensaver + hypr_exec alacritty --class=org.omarchy.screensaver --config-file "$OMARCHY_PATH/default/alacritty/screensaver.toml" -e omarchy-screensaver ;; *ghostty*) - hyprctl dispatch exec -- \ - ghostty --class=org.omarchy.screensaver \ - --config-file=~/.local/share/omarchy/default/ghostty/screensaver \ - --font-size=18 \ - -e omarchy-screensaver + hypr_exec ghostty --class=org.omarchy.screensaver --config-file="$OMARCHY_PATH/default/ghostty/screensaver" --font-size=18 -e omarchy-screensaver ;; *foot*) - hyprctl dispatch exec -- \ - foot --app-id=org.omarchy.screensaver \ - --config="$OMARCHY_PATH/default/foot/screensaver.ini" \ - -e omarchy-screensaver + hypr_exec foot --app-id=org.omarchy.screensaver --config="$OMARCHY_PATH/default/foot/screensaver.ini" -e omarchy-screensaver ;; *kitty*) - hyprctl dispatch exec -- \ - kitty --class=org.omarchy.screensaver \ - --override font_size=18 \ - --override window_padding_width=0 \ - -e omarchy-screensaver - ;; - *) - notify-send -u low "✋ Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty" + hypr_exec kitty --class=org.omarchy.screensaver --override font_size=18 --override window_padding_width=0 -e omarchy-screensaver ;; esac + + wait_for_screensaver_window done -hyprctl dispatch focusmonitor $focused +hypr_focus_monitor "$focused" diff --git a/bin/omarchy-launch-shell b/bin/omarchy-launch-shell new file mode 100755 index 0000000000..c183eb0b56 --- /dev/null +++ b/bin/omarchy-launch-shell @@ -0,0 +1,91 @@ +#!/bin/bash + +# omarchy:summary=Launch the Omarchy shell with its log kept in the journal +# omarchy:hidden=true + +# Quickshell only logs to its instance runtime dir (tmpfs), so when the shell +# dies the idle/lock event trail is gone after a reboot. The journal keeps it +# across sessions, bounded and timestamped, under the omarchy-shell tag. +# +# Backgrounded because bash defers a trap until a foreground command returns but +# interrupts wait. systemd-cat execs, so the job is Quickshell itself. +# +# Quickshell's own reloading is off; Omarchy restarts the shell deliberately. +# A package upgrade rewriting $OMARCHY_PATH/shell would otherwise reload it +# against a half-written tree, and that failed reload leaves a second engine +# generation behind that turns the next restart's IPC kill into a crash. +run_shell() { + QS_DISABLE_FILE_WATCHER=1 QS_NO_RELOAD_POPUP=1 \ + systemd-cat -t omarchy-shell -- quickshell -n -p "$OMARCHY_PATH/shell" & + shell_pid=$! + + local status + while true; do + wait "$shell_pid" + status=$? + + # An interrupted wait and a shell killed by that signal report alike. + kill -0 "$shell_pid" 2>/dev/null || break + done + + shell_pid="" + return $status +} + +# A compositor busy reconfiguring outputs can miss a query without being gone, +# and that is when the shell dies. +compositor_alive() { + local attempt + + for attempt in 1 2 3; do + hyprctl -j monitors >/dev/null 2>&1 && return 0 + (( attempt < 3 )) && sleep 0.5 + done + + return 1 +} + +# Quickshell relaunches itself from its signal handlers, but Qt leaves through +# _exit() when the Wayland connection fails, raising no signal: no crash report, +# no relaunch, no bar. Supervise those deaths. A clean exit is a deliberate stop +# (omarchy-restart-shell starts its own replacement); a signal here means the +# session is going, and has to reach the shell the launcher used to exec. +terminating=0 +shell_pid="" + +stop() { + terminating=1 + [[ -n $shell_pid ]] && kill -TERM "$shell_pid" 2>/dev/null + return 0 +} +trap stop HUP INT TERM + +attempts=0 +window_started=$SECONDS + +while true; do + # A signal during the backoff only reaches the trap once the sleep is over. + (( terminating )) && exit 0 + + run_shell + status=$? + + (( terminating )) && exit 0 + (( status == 0 )) && exit 0 + + # Relaunching into a session already tearing down burns the attempt budget. + compositor_alive || exit 0 + + if (( SECONDS - window_started > 60 )); then + attempts=0 + window_started=$SECONDS + fi + + if (( ++attempts > 5 )); then + logger -t omarchy-shell "Giving up on the Omarchy shell after $attempts relaunches in under a minute." + exit 1 + fi + + logger -t omarchy-shell "Omarchy shell exited with status $status; relaunching." + sleep 1 +done diff --git a/bin/omarchy-launch-signal b/bin/omarchy-launch-signal new file mode 100755 index 0000000000..31d94cced7 --- /dev/null +++ b/bin/omarchy-launch-signal @@ -0,0 +1,15 @@ +#!/bin/bash + +# omarchy:summary=Launch Signal or start its installer when missing. + +set -e + +WINDOW_ADDRESS=$(hyprctl clients -j | jq -r '.[]|select((.class|test("\\bsignal\\b";"i")) or (.title|test("\\bsignal\\b";"i")))|.address' | head -n1) + +if [[ -n $WINDOW_ADDRESS ]]; then + hyprctl dispatch "hl.dsp.focus({ window = \"address:$WINDOW_ADDRESS\" })" >/dev/null 2>&1 || hyprctl dispatch focuswindow "address:$WINDOW_ADDRESS" +elif [[ -x /usr/bin/signal-desktop ]]; then + exec setsid uwsm-app -- /usr/bin/signal-desktop +else + exec omarchy-launch-floating-terminal-with-presentation omarchy-install-service-signal +fi diff --git a/bin/omarchy-launch-spotify b/bin/omarchy-launch-spotify new file mode 100755 index 0000000000..d94202a330 --- /dev/null +++ b/bin/omarchy-launch-spotify @@ -0,0 +1,15 @@ +#!/bin/bash + +# omarchy:summary=Launch Spotify or start its installer when missing. + +set -e + +WINDOW_ADDRESS=$(hyprctl clients -j | jq -r '.[]|select((.class|test("\\bspotify\\b";"i")) or (.title|test("\\bspotify\\b";"i")))|.address' | head -n1) + +if [[ -n $WINDOW_ADDRESS ]]; then + hyprctl dispatch "hl.dsp.focus({ window = \"address:$WINDOW_ADDRESS\" })" >/dev/null 2>&1 || hyprctl dispatch focuswindow "address:$WINDOW_ADDRESS" +elif [[ -x /usr/bin/spotify ]]; then + exec setsid uwsm-app -- /usr/bin/spotify +else + exec omarchy-launch-floating-terminal-with-presentation omarchy-install-service-spotify +fi diff --git a/bin/omarchy-launch-terminal b/bin/omarchy-launch-terminal new file mode 100755 index 0000000000..a07a1dde4f --- /dev/null +++ b/bin/omarchy-launch-terminal @@ -0,0 +1,6 @@ +#!/bin/bash + +# omarchy:summary=Launch a terminal in the active terminal's current directory +# omarchy:args=[command...] + +exec setsid uwsm-app -- xdg-terminal-exec --dir="$(omarchy-cmd-terminal-cwd)" "$@" diff --git a/bin/omarchy-launch-terminal-herdr b/bin/omarchy-launch-terminal-herdr new file mode 100755 index 0000000000..48e898e2c5 --- /dev/null +++ b/bin/omarchy-launch-terminal-herdr @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Launch or attach to the persistent herdr session in a terminal + +exec omarchy-launch-terminal herdr diff --git a/bin/omarchy-launch-terminal-tmux b/bin/omarchy-launch-terminal-tmux new file mode 100755 index 0000000000..2b70179c69 --- /dev/null +++ b/bin/omarchy-launch-terminal-tmux @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Launch or attach to the Work tmux session in a terminal + +exec omarchy-launch-terminal bash -c "tmux attach || tmux new -s Work" diff --git a/bin/omarchy-launch-tui b/bin/omarchy-launch-tui index ba64ad931d..bd67c64ca9 100755 --- a/bin/omarchy-launch-tui +++ b/bin/omarchy-launch-tui @@ -1,6 +1,13 @@ #!/bin/bash # omarchy:summary=Launch a TUI command in the default terminal with Omarchy styling -# omarchy:args= [args...] +# omarchy:args=[--app-id=] [args...] -exec setsid uwsm-app -- xdg-terminal-exec --app-id=org.omarchy.$(basename $1) -e "$1" "${@:2}" +if [[ ${1:-} == --app-id=* ]]; then + APP_ID="${1#--app-id=}" + shift +else + APP_ID="org.omarchy.$(basename $1)" +fi + +exec setsid uwsm-app -- xdg-terminal-exec --app-id=$APP_ID -e "$1" "${@:2}" diff --git a/bin/omarchy-launch-walker b/bin/omarchy-launch-walker deleted file mode 100755 index d6af04e19e..0000000000 --- a/bin/omarchy-launch-walker +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Launch Walker and ensure its Elephant data provider is running - -if ! pgrep -x elephant > /dev/null; then - setsid uwsm-app -- elephant & -fi - -# Ensure walker service is running -if ! pgrep -f "walker --gapplication-service" > /dev/null; then - setsid uwsm-app -- env GSK_RENDERER=cairo walker --gapplication-service & -fi - -exec walker --width 644 --maxheight 300 --minheight 300 "$@" diff --git a/bin/omarchy-launch-wifi b/bin/omarchy-launch-wifi deleted file mode 100755 index e22b708308..0000000000 --- a/bin/omarchy-launch-wifi +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Launch the Omarchy wifi controls (provided by the Impala TUI). - -rfkill unblock wifi -omarchy-launch-or-focus-tui impala diff --git a/bin/omarchy-menu b/bin/omarchy-menu index 9f098c9201..ecd2c2b86e 100755 --- a/bin/omarchy-menu +++ b/bin/omarchy-menu @@ -1,891 +1,52 @@ #!/bin/bash -# omarchy:summary=Launch the Omarchy Menu or takes a parameter to jump straight to a submenu. +# omarchy:summary=Control the Omarchy menu (toggle / summon / close / refresh) +# omarchy:args=[toggle|summon|close|refresh|ping] [route] +# omarchy:examples=omarchy menu | omarchy menu toggle system | omarchy menu summon style.theme | omarchy menu refresh -# Set to true when going directly to a submenu, so we can exit directly -BACK_TO_EXIT=false +# Thin wrapper around the standard plugin IPC surface. The menu is the +# first-party `omarchy.menu` plugin; routes are passed as JSON payload. -back_to() { - local parent_menu="$1" +set -euo pipefail - if [[ $BACK_TO_EXIT == "true" ]]; then - exit 0 - elif [[ -n $parent_menu ]]; then - "$parent_menu" - else - show_main_menu - fi -} - -toggle_existing_menu() { - if pgrep -f "walker.*--dmenu" >/dev/null; then - walker --close >/dev/null 2>&1 - exit 0 - fi -} - -menu() { - local prompt="$1" - local options="$2" - local extra="$3" - local preselect="$4" - - read -r -a args <<<"$extra" - - if [[ -n $preselect ]]; then - local index - index=$(echo -e "$options" | grep -nxF "$preselect" | cut -d: -f1) - if [[ -n $index ]]; then - args+=("-c" "$index") - fi - fi - - echo -e "$options" | omarchy-launch-walker --dmenu --width 295 --minheight 1 --maxheight 630 -p "$prompt…" "${args[@]}" 2>/dev/null -} - -terminal() { - xdg-terminal-exec --app-id=org.omarchy.terminal "$@" -} +verb="${1-toggle}" +route="${2-root}" -present_terminal() { - omarchy-launch-floating-terminal-with-presentation $1 +menu_payload() { + jq -nc --arg menu "$1" '{ menu: $menu }' } -open_in_editor() { - notify-send -u low "Editing config file" "$1" - omarchy-launch-editor "$1" -} - -install() { - present_terminal "echo 'Installing $1...'; omarchy-pkg-add $2" -} - -install_and_launch() { - present_terminal "echo 'Installing $1...'; omarchy-pkg-add $2 && setsid gtk-launch $3" -} - -install_font() { - present_terminal "echo 'Installing $1...'; omarchy-pkg-add $2 && sleep 2 && omarchy-font-set '$3'" -} - -install_terminal() { - present_terminal "omarchy-install-terminal $1" -} - -aur_install() { - present_terminal "echo 'Installing $1 from AUR...'; omarchy-pkg-aur-add $2" -} - -aur_install_and_launch() { - present_terminal "echo 'Installing $1 from AUR...'; omarchy-pkg-aur-add $2 && setsid gtk-launch $3" -} - -show_learn_menu() { - case $(menu "Learn" " Keybindings\n Omarchy\n Hyprland\n󰣇 Arch\n Neovim\n󱆃 Bash") in - *Keybindings*) omarchy-menu-keybindings ;; - *Omarchy*) omarchy-launch-webapp "https://learn.omacom.io/2/the-omarchy-manual" ;; - *Hyprland*) omarchy-launch-webapp "https://wiki.hypr.land/" ;; - *Arch*) omarchy-launch-webapp "https://wiki.archlinux.org/title/Main_page" ;; - *Bash*) omarchy-launch-webapp "https://devhints.io/bash" ;; - *Neovim*) omarchy-launch-webapp "https://www.lazyvim.org/keymaps" ;; - *) show_main_menu ;; - esac -} - -show_trigger_menu() { - case $(menu "Trigger" "󰔛 Reminder\n Capture\n󰧸 Transcode\n Share\n󰔎 Toggle\n Hardware") in - *Reminder*) show_reminder_menu ;; - *Capture*) show_capture_menu ;; - *Transcode*) omarchy-transcode || back_to show_trigger_menu ;; - *Share*) show_share_menu ;; - *Toggle*) show_toggle_menu ;; - *Hardware*) show_hardware_menu ;; - *) show_main_menu ;; - esac -} - -show_reminder_menu() { - case $(menu "Reminder" "󰔛 Set one\n󰔛 Show all\n󰔛 Clear all") in - *Set*) show_custom_reminder_input ;; - *"Show all"*) omarchy-reminder show ;; - *"Clear all"*) omarchy-reminder clear ;; - *) back_to show_trigger_menu ;; - esac -} - -show_custom_reminder_input() { - local minutes - minutes=$(omarchy-menu-input "Remind in minutes") - - if [[ $minutes =~ ^[0-9]+$ ]] && ((minutes > 0)); then - show_reminder_message_input "$minutes" - elif [[ -n $minutes ]]; then - omarchy-notification-send "󰔛" "Invalid reminder" "Enter the number of minutes" -u critical - show_custom_reminder_input - else - back_to show_reminder_menu - fi -} - -show_reminder_message_input() { - local minutes="$1" - local message - message=$(omarchy-menu-input "Reminder message") - - if [[ -n $message ]]; then - omarchy-reminder "$minutes" "$message" - else - omarchy-reminder "$minutes" - fi -} - -show_capture_menu() { - case $(menu "Capture" " Screenshot\n Screenrecord\n󰴑 Text Extraction\n󰃉 Color") in - *Screenshot*) omarchy-capture-screenshot ;; - *Screenrecord*) show_screenrecord_menu ;; - *Text*) omarchy-capture-text-extraction ;; - *Color*) pkill hyprpicker || hyprpicker -a ;; - *) back_to show_trigger_menu ;; - esac -} - -get_webcam_list() { - v4l2-ctl --list-devices 2>/dev/null | while IFS= read -r line; do - if [[ $line != $'\t'* && -n $line ]]; then - local name="$line" - IFS= read -r device || break - device=$(echo "$device" | tr -d '\t' | head -1) - [[ -n $device ]] && echo "$device $name" - fi - done -} - -show_webcam_select_menu() { - local devices=$(get_webcam_list) - local count=$(echo "$devices" | grep -c . 2>/dev/null || echo 0) - - if [[ -z $devices ]] || ((count == 0)); then - notify-send "No webcam devices found" -u critical -t 3000 - return 1 - fi - - if ((count == 1)); then - echo "$devices" | awk '{print $1}' - else - menu "Select Webcam" "$devices" | awk '{print $1}' - fi -} - -show_screenrecord_menu() { - omarchy-capture-screenrecording --stop-recording && exit 0 - - case $(menu "Screenrecord" " With no audio\n With desktop audio\n With desktop + microphone audio\n With desktop + microphone audio + webcam") in - *"With no audio") omarchy-capture-screenrecording ;; - *"With desktop audio") omarchy-capture-screenrecording --with-desktop-audio ;; - *"With desktop + microphone audio") omarchy-capture-screenrecording --with-desktop-audio --with-microphone-audio ;; - *"With desktop + microphone audio + webcam") - local device=$(show_webcam_select_menu) || { - back_to show_capture_menu - return - } - omarchy-capture-screenrecording --with-desktop-audio --with-microphone-audio --with-webcam --webcam-device="$device" +case "$verb" in + toggle) + exec omarchy-shell shell toggle omarchy.menu "$(menu_payload "$route")" ;; - *) back_to show_capture_menu ;; - esac -} - -show_share_menu() { - case $(menu "Share" " Clipboard\n File \n Folder") in - *Clipboard*) omarchy-menu-share clipboard ;; - *File*) terminal bash -c "omarchy-menu-share file" ;; - *Folder*) terminal bash -c "omarchy-menu-share folder" ;; - *) back_to show_trigger_menu ;; - esac -} - -show_toggle_menu() { - local options="󱄄 Screensaver\n󰔎 Nightlight\n󱫖 Idle Lock\n󰂛 Notifications\n󰍜 Top Bar\n󱂬 Workspace Layout\n Window Gaps\n 1-Window Ratio\n󰍹 Monitor Scaling\n Direct Boot\n󰟵 Passwordless Sudo" - - case $(menu "Toggle" "$options") in - *Screensaver*) omarchy-toggle-screensaver ;; - *Nightlight*) omarchy-toggle-nightlight ;; - *Idle*) omarchy-toggle-idle ;; - *Notifications*) omarchy-toggle-notification-silencing ;; - *Bar*) omarchy-toggle-waybar ;; - *Layout*) omarchy-hyprland-workspace-layout-toggle ;; - *Ratio*) omarchy-hyprland-window-single-square-aspect-toggle ;; - *Gaps*) omarchy-hyprland-window-gaps-toggle ;; - *Scaling*) omarchy-hyprland-monitor-scaling-cycle ;; - *"Direct Boot"*) present_terminal omarchy-config-direct-boot ;; - *"Passwordless Sudo"*) present_terminal omarchy-sudo-passwordless ;; - *) back_to show_trigger_menu ;; - esac -} - -show_hardware_menu() { - local options="󰛧 Laptop Display\n 󰍹 Mirror Display" - - if omarchy-hw-hybrid-gpu; then - options="$options\n Hybrid GPU" - fi - - if omarchy-hw-touchpad; then - options="$options\n󰟸 Touchpad" - fi - - if omarchy-hw-dell-xps-haptic-touchpad && omarchy-cmd-present dell-xps-touchpad-haptics; then - options="$options\n󰌌 Touchpad Haptics" - fi - - if omarchy-hw-touchscreen; then - options="$options\n󰆽 Touchscreen" - fi - - case $(menu "Toggle" "$options") in - *Laptop*) omarchy-hyprland-monitor-internal toggle ;; - *Mirror*) omarchy-hyprland-monitor-internal-mirror toggle ;; - *Haptics*) show_hardware_touchpad_haptics_menu ;; - *Touchpad*) omarchy-toggle-touchpad ;; - *Touchscreen*) omarchy-toggle-touchscreen ;; - *"Hybrid GPU"*) present_terminal omarchy-toggle-hybrid-gpu ;; - *) back_to show_trigger_menu ;; - esac -} - -show_hardware_touchpad_haptics_menu() { - local current=$(dell-xps-touchpad-haptics get) - local selected=$(menu "Touchpad Haptics" "low\nmid\nhigh" "" "$current") - - if [[ -n $selected ]]; then - dell-xps-touchpad-haptics set "$selected" - else - back_to show_hardware_menu - fi -} - -show_style_menu() { - case $(menu "Style" "󰸌 Theme\n󰟵 Unlock\n Font\n Background\n Hyprland\n󱄄 Screensaver\n About") in - *Theme*) show_theme_menu ;; - *Unlock*) omarchy-launch-walker -m menus:omarchyunlocks --width 800 --minheight 400 ;; - *Font*) show_font_menu ;; - *Background*) show_background_menu ;; - *Hyprland*) open_in_editor ~/.config/hypr/looknfeel.conf ;; - *Screensaver*) show_screensaver_menu ;; - *About*) show_about_menu ;; - *) show_main_menu ;; - esac -} - -show_about_menu() { - case $(menu "About" " Edit Text\n Set From Image\n Restore Default") in - *Text*) omarchy-branding-about text ;; - *Image*) omarchy-branding-about image ;; - *Default*) omarchy-branding-about reset ;; - *) show_style_menu ;; - esac -} - -show_screensaver_menu() { - case $(menu "Screensaver" " Edit Text\n Set From Image\n Restore Default") in - *Text*) omarchy-branding-screensaver text ;; - *Image*) omarchy-branding-screensaver image ;; - *Default*) omarchy-branding-screensaver reset ;; - *) show_style_menu ;; - esac -} - -show_theme_menu() { - omarchy-launch-walker -m menus:omarchythemes --width 800 --minheight 400 -} - -show_background_menu() { - omarchy-launch-walker -m menus:omarchyBackgroundSelector --width 800 --minheight 400 -} - -show_font_menu() { - theme=$(menu "Font" "$(omarchy-font-list)" "--width 350" "$(omarchy-font-current)") - if [[ $theme == "CNCLD" || -z $theme ]]; then - back_to show_style_menu - else - omarchy-font-set "$theme" - fi -} - -show_setup_menu() { - local options=" Audio\n Wifi\n󰂯 Bluetooth\n󱐋 Power Profile\n System Sleep\n󰍹 Monitors" - [[ -f ~/.config/hypr/bindings.conf ]] && options="$options\n Keybindings" - [[ -f ~/.config/hypr/input.conf ]] && options="$options\n Input" - options="$options\n Defaults\n󰱔 DNS\n Security\n Config" - - case $(menu "Setup" "$options") in - *Audio*) omarchy-launch-audio ;; - *Wifi*) omarchy-launch-wifi ;; - *Bluetooth*) omarchy-launch-bluetooth ;; - *Power*) show_setup_power_menu ;; - *System*) show_setup_system_menu ;; - *Monitors*) open_in_editor ~/.config/hypr/monitors.conf ;; - *Keybindings*) open_in_editor ~/.config/hypr/bindings.conf ;; - *Input*) open_in_editor ~/.config/hypr/input.conf ;; - *Defaults*) show_setup_default_menu ;; - *DNS*) present_terminal omarchy-setup-dns ;; - *Security*) show_setup_security_menu ;; - *Config*) show_setup_config_menu ;; - *) show_main_menu ;; - esac -} - -show_setup_power_menu() { - profile=$(menu "Power Profile" "$(omarchy-powerprofiles-list)" "" "$(powerprofilesctl get)") - - if [[ $profile == "CNCLD" || -z $profile ]]; then - back_to show_setup_menu - else - powerprofilesctl set "$profile" - fi -} - -show_setup_security_menu() { - case $(menu "Setup" "󰈷 Fingerprint\n Fido2") in - *Fingerprint*) present_terminal omarchy-setup-security-fingerprint ;; - *Fido2*) present_terminal omarchy-setup-security-fido2 ;; - *) show_setup_menu ;; - esac -} - -show_setup_default_menu() { - case $(menu "Default" " Browser\n Terminal\n Editor") in - *Browser*) show_setup_default_browser_menu ;; - *Terminal*) show_setup_default_terminal_menu ;; - *Editor*) show_setup_default_editor_menu ;; - *) show_setup_menu ;; - esac -} - -browser_desktop_exists() { - [[ -f ~/.local/share/applications/$1 || -f ~/.nix-profile/share/applications/$1 || -f /usr/share/applications/$1 ]] -} - -show_setup_default_browser_menu() { - local options="" - browser_desktop_exists chromium.desktop && options="$options Chromium" - browser_desktop_exists google-chrome.desktop && options="${options:+$options\n}󰊯 Chrome" - browser_desktop_exists brave-browser.desktop && options="${options:+$options\n}󰖟 Brave" - browser_desktop_exists brave-origin-beta.desktop && options="${options:+$options\n}󰖟 Brave Origin" - browser_desktop_exists microsoft-edge.desktop && options="${options:+$options\n}󰇩 Edge" - browser_desktop_exists firefox.desktop && options="${options:+$options\n}󰈹 Firefox" - browser_desktop_exists zen.desktop && options="${options:+$options\n}󰖟 Zen" - - local current="" - case "$(omarchy-default-browser)" in - chromium) current=" Chromium" ;; - chrome) current="󰊯 Chrome" ;; - brave) current="󰖟 Brave" ;; - brave-origin) current="󰖟 Brave Origin" ;; - edge) current="󰇩 Edge" ;; - firefox) current="󰈹 Firefox" ;; - zen) current="󰖟 Zen" ;; - esac - - case $(menu "Default Browser" "$options" "" "$current") in - *Chromium*) omarchy-default-browser chromium ;; - *Chrome*) omarchy-default-browser chrome ;; - *"Brave Origin"*) omarchy-default-browser brave-origin ;; - *Brave*) omarchy-default-browser brave ;; - *Edge*) omarchy-default-browser edge ;; - *Firefox*) omarchy-default-browser firefox ;; - *Zen*) omarchy-default-browser zen ;; - *) show_setup_default_menu ;; - esac -} - -show_setup_default_terminal_menu() { - local options="" - omarchy-cmd-present alacritty && options="$options Alacritty" - omarchy-cmd-present foot && options="${options:+$options\n} Foot" - omarchy-cmd-present ghostty && options="${options:+$options\n} Ghostty" - omarchy-cmd-present kitty && options="${options:+$options\n} Kitty" - - local current="" - case "$(omarchy-default-terminal)" in - alacritty) current=" Alacritty" ;; - foot) current=" Foot" ;; - ghostty) current=" Ghostty" ;; - kitty) current=" Kitty" ;; - esac - - case $(menu "Default Terminal" "$options" "" "$current") in - *Alacritty*) omarchy-default-terminal alacritty ;; - *Foot*) omarchy-default-terminal foot ;; - *Ghostty*) omarchy-default-terminal ghostty ;; - *Kitty*) omarchy-default-terminal kitty ;; - *) show_setup_default_menu ;; - esac -} - -show_setup_default_editor_menu() { - local options="" - omarchy-cmd-present nvim && options="$options Neovim" - omarchy-cmd-present code && options="${options:+$options\n} VSCode" - omarchy-cmd-present cursor && options="${options:+$options\n} Cursor" - omarchy-cmd-present zeditor && options="${options:+$options\n} Zed" - omarchy-cmd-present sublime_text && options="${options:+$options\n} Sublime Text" - omarchy-cmd-present helix && options="${options:+$options\n} Helix" - omarchy-cmd-present vim && options="${options:+$options\n} Vim" - omarchy-cmd-present emacs && options="${options:+$options\n} Emacs" - - local current="" - case "$(omarchy-default-editor)" in - nvim) current=" Neovim" ;; - code) current=" VSCode" ;; - cursor) current=" Cursor" ;; - zed | zeditor) current=" Zed" ;; - sublime_text) current=" Sublime Text" ;; - helix) current=" Helix" ;; - vim) current=" Vim" ;; - emacs) current=" Emacs" ;; - esac - - case $(menu "Default Editor" "$options" "" "$current") in - *Neovim*) omarchy-default-editor nvim ;; - *VSCode*) omarchy-default-editor code ;; - *Cursor*) omarchy-default-editor cursor ;; - *Zed*) omarchy-default-editor zed ;; - *Sublime*) omarchy-default-editor sublime_text ;; - *Helix*) omarchy-default-editor helix ;; - *Vim*) omarchy-default-editor vim ;; - *Emacs*) omarchy-default-editor emacs ;; - *) show_setup_default_menu ;; - esac -} - -show_setup_config_menu() { - case $(menu "Setup" " Hyprland\n Hypridle\n Hyprlock\n Hyprsunset\n Swayosd\n󰌧 Walker\n󰍜 Waybar\n󰞅 XCompose") in - *Hyprland*) open_in_editor ~/.config/hypr/hyprland.conf ;; - *Hypridle*) open_in_editor ~/.config/hypr/hypridle.conf && omarchy-restart-hypridle ;; - *Hyprlock*) open_in_editor ~/.config/hypr/hyprlock.conf ;; - *Hyprsunset*) open_in_editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset ;; - *Swayosd*) open_in_editor ~/.config/swayosd/config.toml && omarchy-restart-swayosd ;; - *Walker*) open_in_editor ~/.config/walker/config.toml && omarchy-restart-walker ;; - *Waybar*) open_in_editor ~/.config/waybar/config.jsonc && omarchy-restart-waybar ;; - *XCompose*) open_in_editor ~/.XCompose && omarchy-restart-xcompose ;; - *) show_setup_menu ;; - esac -} - -show_setup_system_menu() { - local options="" - - if omarchy-toggle-enabled suspend-off; then - options="$options󰒲 Enable Suspend" - else - options="$options󰒲 Disable Suspend" - fi - - if omarchy-hibernation-available; then - options="$options\n󰤁 Disable Hibernate" - else - options="$options\n󰤁 Enable Hibernate" - fi - - case $(menu "System" "$options") in - *Suspend*) omarchy-toggle-suspend ;; - *"Enable Hibernate"*) present_terminal omarchy-hibernation-setup ;; - *"Disable Hibernate"*) present_terminal omarchy-hibernation-remove ;; - *) show_setup_menu ;; - esac -} - -show_install_menu() { - case $(menu "Install" "󰣇 Package\n󰣇 AUR\n Web App\n TUI\n Service\n Style\n󰵮 Development\n Editor\n Terminal\n Browser\n󱚤 AI\n Gaming\n󰍲 Windows") in - *Package*) terminal omarchy-pkg-install ;; - *AUR*) terminal omarchy-pkg-aur-install ;; - *Web*) present_terminal omarchy-webapp-install ;; - *TUI*) present_terminal omarchy-tui-install ;; - *Service*) show_install_service_menu ;; - *Style*) show_install_style_menu ;; - *Development*) show_install_development_menu ;; - *Editor*) show_install_editor_menu ;; - *Terminal*) show_install_terminal_menu ;; - *Browser*) show_install_browser_menu ;; - *Gaming*) show_install_gaming_menu ;; - *AI*) show_install_ai_menu ;; - *Windows*) present_terminal "omarchy-windows-vm install" ;; - *) show_main_menu ;; - esac -} - -show_install_browser_menu() { - case $(menu "Install" " Chrome\n Edge\n Brave\n Brave Origin\n Firefox\n󰖟 Zen") in - *Chrome*) present_terminal "omarchy-install-browser chrome" ;; - *Edge*) present_terminal "omarchy-install-browser edge" ;; - *"Brave Origin"*) present_terminal "omarchy-install-browser brave-origin" ;; - *Brave*) present_terminal "omarchy-install-browser brave" ;; - *Firefox*) present_terminal "omarchy-install-browser firefox" ;; - *Zen*) present_terminal "omarchy-install-browser zen" ;; - *) show_install_menu ;; - esac -} - -show_install_service_menu() { - case $(menu "Install" " Dropbox\n Tailscale\n󱇱 NordVPN [AUR]\n󰏖 ONCE\n󰟵 Bitwarden\n Chromium Account") in - *Dropbox*) present_terminal omarchy-install-dropbox ;; - *Tailscale*) present_terminal omarchy-install-tailscale ;; - *NordVPN*) present_terminal omarchy-install-nordvpn ;; - *ONCE*) present_terminal omarchy-install-once ;; - *Bitwarden*) install_and_launch "Bitwarden" "bitwarden bitwarden-cli" "bitwarden" ;; - *Chromium*) present_terminal omarchy-install-chromium-google-account ;; - *) show_install_menu ;; - esac -} - -show_install_editor_menu() { - case $(menu "Install" " VSCode\n Cursor\n Zed\n Sublime Text\n Helix\n Vim\n Emacs") in - *VSCode*) present_terminal omarchy-install-vscode ;; - *Cursor*) install_and_launch "Cursor" "cursor-bin" "cursor" ;; - *Zed*) present_terminal omarchy-install-zed ;; - *Sublime*) install_and_launch "Sublime Text" "sublime-text-4" "sublime_text" ;; - *Helix*) present_terminal omarchy-install-helix ;; - *Vim*) install "Vim" "vim" ;; - *Emacs*) install "Emacs" "emacs-wayland" && systemctl --user enable --now emacs.service ;; - *) show_install_menu ;; - esac -} - -show_install_terminal_menu() { - case $(menu "Install" " Alacritty\n Foot\n Ghostty\n Kitty") in - *Alacritty*) install_terminal "alacritty" ;; - *Foot*) install_terminal "foot" ;; - *Ghostty*) install_terminal "ghostty" ;; - *Kitty*) install_terminal "kitty" ;; - *) show_install_menu ;; - esac -} - -show_install_ai_menu() { - ollama_pkg=$( - (omarchy-cmd-present nvidia-smi && echo ollama-cuda) || - (omarchy-cmd-present rocminfo && echo ollama-rocm) || - echo ollama - ) - - case $(menu "Install" " Dictation\n󱚤 LM Studio\n󱚤 Ollama\n󱚤 Crush") in - *Dictation*) present_terminal omarchy-voxtype-install ;; - *Studio*) install "LM Studio" "lmstudio-bin" ;; - *Ollama*) install "Ollama" $ollama_pkg ;; - *Crush*) install "Crush" "crush-bin" ;; - *) show_install_menu ;; - esac -} - -show_install_gaming_menu() { - case $(menu "Install" " Steam\n RetroArch\n󰍳 Minecraft\n󰢹 NVIDIA GeForce NOW\n Xbox Cloud Gaming\n󰂯 Xbox Controller\n󰍹 Moonlight (GameStream)\n Lutris (Battle.net)\n󱓟 Heroic (Epic Games)") in - *Steam*) present_terminal omarchy-install-gaming-steam ;; - *RetroArch*) present_terminal omarchy-install-gaming-retroarch ;; - *Minecraft*) install_and_launch "Minecraft" "minecraft-launcher" "minecraft-launcher" ;; - *GeForce*) present_terminal omarchy-install-gaming-geforce-now ;; - *"Xbox Cloud"*) present_terminal omarchy-install-gaming-xbox-cloud ;; - *Xbox*) present_terminal omarchy-install-gaming-xbox-controllers ;; - *Lutris*) present_terminal omarchy-install-gaming-lutris ;; - *Heroic*) present_terminal omarchy-install-gaming-heroic ;; - *Moonlight*) present_terminal omarchy-install-gaming-moonlight ;; - *) show_install_menu ;; - esac -} - -show_install_style_menu() { - case $(menu "Install" "󰸌 Theme\n Background\n Font") in - *Theme*) present_terminal omarchy-theme-install ;; - *Background*) omarchy-theme-bg-install ;; - *Font*) show_install_font_menu ;; - *) show_install_menu ;; - esac -} - -show_install_font_menu() { - case $(menu "Install" " Cascadia Mono\n Meslo LG Mono\n Fira Code\n Victor Code\n Bitstream Vera Mono\n Iosevka" "--width 350") in - *Cascadia*) install_font "Cascadia Mono" "ttf-cascadia-mono-nerd" "CaskaydiaMono Nerd Font" ;; - *Meslo*) install_font "Meslo LG Mono" "ttf-meslo-nerd" "MesloLGL Nerd Font" ;; - *Fira*) install_font "Fira Code" "ttf-firacode-nerd" "FiraCode Nerd Font" ;; - *Victor*) install_font "Victor Code" "ttf-victor-mono-nerd" "VictorMono Nerd Font" ;; - *Bitstream*) install_font "Bitstream Vera Code" "ttf-bitstream-vera-mono-nerd" "BitstromWera Nerd Font" ;; - *Iosevka*) install_font "Iosevka" "ttf-iosevka-nerd" "Iosevka Nerd Font Mono" ;; - *) show_install_menu ;; - esac -} - -show_install_development_menu() { - case $(menu "Install" "󰫏 Ruby on Rails\n Docker DB\n JavaScript\n Go\n PHP\n Python\n Elixir\n Zig\n Rust\n Java\n .NET\n OCaml\n Clojure\n Scala") in - *Rails*) present_terminal "omarchy-install-dev-env ruby" ;; - *Docker*) present_terminal omarchy-install-docker-dbs ;; - *JavaScript*) show_install_javascript_menu ;; - *Go*) present_terminal "omarchy-install-dev-env go" ;; - *PHP*) show_install_php_menu ;; - *Python*) present_terminal "omarchy-install-dev-env python" ;; - *Elixir*) show_install_elixir_menu ;; - *Zig*) present_terminal "omarchy-install-dev-env zig" ;; - *Rust*) present_terminal "omarchy-install-dev-env rust" ;; - *Java*) present_terminal "omarchy-install-dev-env java" ;; - *NET*) present_terminal "omarchy-install-dev-env dotnet" ;; - *OCaml*) present_terminal "omarchy-install-dev-env ocaml" ;; - *Clojure*) present_terminal "omarchy-install-dev-env clojure" ;; - *Scala*) present_terminal "omarchy-install-dev-env scala" ;; - *) show_install_menu ;; - esac -} - -show_install_javascript_menu() { - case $(menu "Install" " Node.js\n Bun\n Deno") in - *Node*) present_terminal "omarchy-install-dev-env node" ;; - *Bun*) present_terminal "omarchy-install-dev-env bun" ;; - *Deno*) present_terminal "omarchy-install-dev-env deno" ;; - *) show_install_development_menu ;; - esac -} - -show_install_php_menu() { - case $(menu "Install" " PHP\n Laravel\n Symfony") in - *PHP*) present_terminal "omarchy-install-dev-env php" ;; - *Laravel*) present_terminal "omarchy-install-dev-env laravel" ;; - *Symfony*) present_terminal "omarchy-install-dev-env symfony" ;; - *) show_install_development_menu ;; - esac -} - -show_install_elixir_menu() { - case $(menu "Install" " Elixir\n Phoenix") in - *Elixir*) present_terminal "omarchy-install-dev-env elixir" ;; - *Phoenix*) present_terminal "omarchy-install-dev-env phoenix" ;; - *) show_install_development_menu ;; - esac -} - -show_remove_menu() { - case $(menu "Remove" "󰣇 Package\n Web App\n TUI\n󰵮 Development\n󰸌 Theme\n Browser\n Dictation\n Gaming\n󰍲 Windows\n󰏓 Preinstalls\n Security") in - *Package*) terminal omarchy-pkg-remove ;; - *Web*) present_terminal omarchy-webapp-remove ;; - *TUI*) present_terminal omarchy-tui-remove ;; - *Development*) show_remove_development_menu ;; - *Theme*) present_terminal omarchy-theme-remove ;; - *Browser*) show_remove_browser_menu ;; - *Dictation*) present_terminal omarchy-voxtype-remove ;; - *Gaming*) show_remove_gaming_menu ;; - *Windows*) present_terminal "omarchy-windows-vm remove" ;; - *Preinstalls*) present_terminal omarchy-remove-preinstalls ;; - *Security*) show_remove_security_menu ;; - *) show_main_menu ;; - esac -} - -show_remove_security_menu() { - case $(menu "Remove" "󰈷 Fingerprint\n Fido2") in - *Fingerprint*) present_terminal omarchy-remove-security-fingerprint ;; - *Fido2*) present_terminal omarchy-remove-security-fido2 ;; - *) show_remove_menu ;; - esac -} - -show_remove_browser_menu() { - case $(menu "Remove" " Chrome\n Edge\n Brave\n Brave Origin\n Firefox\n Zen") in - *Chrome*) present_terminal "omarchy-remove-browser chrome" ;; - *Edge*) present_terminal "omarchy-remove-browser edge" ;; - *"Brave Origin"*) present_terminal "omarchy-remove-browser brave-origin" ;; - *Brave*) present_terminal "omarchy-remove-browser brave" ;; - *Firefox*) present_terminal "omarchy-remove-browser firefox" ;; - *Zen*) present_terminal "omarchy-remove-browser zen" ;; - *) show_remove_menu ;; - esac -} - -show_remove_gaming_menu() { - case $(menu "Remove" " Steam\n RetroArch\n󰍳 Minecraft\n󰢹 NVIDIA GeForce NOW\n Xbox Cloud Gaming\n󰖺 Xbox Controller (󰂯)\n󰍹 Moonlight (GameStream)\n Lutris (Battle.net)\n󱓟 Heroic (Epic Games)") in - *Steam*) present_terminal omarchy-remove-gaming-steam ;; - *RetroArch*) present_terminal omarchy-remove-gaming-retroarch ;; - *Minecraft*) present_terminal omarchy-remove-gaming-minecraft ;; - *GeForce*) present_terminal omarchy-remove-gaming-geforce-now ;; - *"Xbox Cloud"*) present_terminal omarchy-remove-gaming-xbox-cloud ;; - *Xbox*) present_terminal omarchy-remove-gaming-xbox-controllers ;; - *Moonlight*) present_terminal omarchy-remove-gaming-moonlight ;; - *Lutris*) present_terminal omarchy-remove-gaming-lutris ;; - *Heroic*) present_terminal omarchy-remove-gaming-heroic ;; - *) show_remove_menu ;; - esac -} - -show_remove_development_menu() { - case $(menu "Remove" "󰫏 Ruby on Rails\n JavaScript\n Go\n PHP\n Python\n Elixir\n Zig\n Rust\n Java\n .NET\n OCaml\n Clojure\n Scala") in - *Rails*) present_terminal "omarchy-remove-dev-env ruby" ;; - *JavaScript*) show_remove_javascript_menu ;; - *Go*) present_terminal "omarchy-remove-dev-env go" ;; - *PHP*) show_remove_php_menu ;; - *Python*) present_terminal "omarchy-remove-dev-env python" ;; - *Elixir*) show_remove_elixir_menu ;; - *Zig*) present_terminal "omarchy-remove-dev-env zig" ;; - *Rust*) present_terminal "omarchy-remove-dev-env rust" ;; - *Java*) present_terminal "omarchy-remove-dev-env java" ;; - *NET*) present_terminal "omarchy-remove-dev-env dotnet" ;; - *OCaml*) present_terminal "omarchy-remove-dev-env ocaml" ;; - *Clojure*) present_terminal "omarchy-remove-dev-env clojure" ;; - *Scala*) present_terminal "omarchy-remove-dev-env scala" ;; - *) show_remove_menu ;; - esac -} - -show_remove_javascript_menu() { - case $(menu "Remove" " Node.js\n Bun\n Deno") in - *Node*) present_terminal "omarchy-remove-dev-env node" ;; - *Bun*) present_terminal "omarchy-remove-dev-env bun" ;; - *Deno*) present_terminal "omarchy-remove-dev-env deno" ;; - *) show_remove_development_menu ;; - esac -} - -show_remove_php_menu() { - case $(menu "Remove" " PHP\n Laravel\n Symfony") in - *PHP*) present_terminal "omarchy-remove-dev-env php" ;; - *Laravel*) present_terminal "omarchy-remove-dev-env laravel" ;; - *Symfony*) present_terminal "omarchy-remove-dev-env symfony" ;; - *) show_remove_development_menu ;; - esac -} - -show_remove_elixir_menu() { - case $(menu "Remove" " Elixir\n Phoenix") in - *Elixir*) present_terminal "omarchy-remove-dev-env elixir" ;; - *Phoenix*) present_terminal "omarchy-remove-dev-env phoenix" ;; - *) show_remove_development_menu ;; - esac -} - -show_update_menu() { - case $(menu "Update" "  Omarchy\n󰔫 Channel\n Config\n󰸌 Extra Themes\n Process\n󰇅 Hardware\n Firmware\n Password\n Timezone\n Time") in - *Omarchy*) present_terminal omarchy-update ;; - *Channel*) show_update_channel_menu ;; - *Config*) show_update_config_menu ;; - *Themes*) present_terminal omarchy-theme-update ;; - *Process*) show_update_process_menu ;; - *Hardware*) show_update_hardware_menu ;; - *Firmware*) present_terminal omarchy-update-firmware ;; - *Timezone*) present_terminal omarchy-tz-select ;; - *Time*) present_terminal omarchy-update-time ;; - *Password*) show_update_password_menu ;; - *) show_main_menu ;; - esac -} - -show_update_channel_menu() { - case $(menu "Update channel" "🟢 Stable\n🟡 RC\n🟠 Edge\n🔴 Dev") in - *Stable*) present_terminal "omarchy-channel-set stable" ;; - *RC*) present_terminal "omarchy-channel-set rc" ;; - *Edge*) present_terminal "omarchy-channel-set edge" ;; - *Dev*) present_terminal "omarchy-channel-set dev" ;; - *) show_update_menu ;; - esac -} -show_update_process_menu() { - case $(menu "Restart" " Hypridle\n Hyprsunset\n󰎟 Mako\n Swayosd\n󰌧 Walker\n󰍜 Waybar") in - *Hypridle*) omarchy-restart-hypridle ;; - *Hyprsunset*) omarchy-restart-hyprsunset ;; - *Mako*) omarchy-restart-mako ;; - *Swayosd*) omarchy-restart-swayosd ;; - *Walker*) omarchy-restart-walker ;; - *Waybar*) omarchy-restart-waybar ;; - *) show_update_menu ;; - esac -} - -show_update_config_menu() { - case $(menu "Use default config" " Hyprland\n Hypridle\n Hyprlock\n Hyprsunset\n󱣴 Plymouth\n Swayosd\n Tmux\n󰌧 Walker\n󰍜 Waybar") in - *Hyprland*) present_terminal omarchy-refresh-hyprland ;; - *Hypridle*) present_terminal omarchy-refresh-hypridle ;; - *Hyprlock*) present_terminal omarchy-refresh-hyprlock ;; - *Hyprsunset*) present_terminal omarchy-refresh-hyprsunset ;; - *Plymouth*) present_terminal omarchy-refresh-plymouth ;; - *Swayosd*) present_terminal omarchy-refresh-swayosd ;; - *Tmux*) present_terminal omarchy-refresh-tmux ;; - *Walker*) present_terminal omarchy-refresh-walker ;; - *Waybar*) present_terminal omarchy-refresh-waybar ;; - *) show_update_menu ;; - esac -} - -show_update_hardware_menu() { - case $(menu "Restart" " Audio\n󱚾 Wi-Fi\n󰂯 Bluetooth\n󰟸 Trackpad") in - *Audio*) present_terminal omarchy-restart-pipewire ;; - *Wi-Fi*) present_terminal omarchy-restart-wifi ;; - *Bluetooth*) present_terminal omarchy-restart-bluetooth ;; - *Trackpad*) present_terminal omarchy-restart-trackpad ;; - *) show_update_menu ;; - esac -} - -show_update_password_menu() { - case $(menu "Update Password" " Drive Encryption\n User") in - *Drive*) present_terminal omarchy-drive-password ;; - *User*) present_terminal passwd ;; - *) show_update_menu ;; - esac -} - -show_about() { - omarchy-launch-about -} - -show_system_menu() { - local options="󱄄 Screensaver\n Lock" - ! omarchy-toggle-enabled suspend-off && options="$options\n󰒲 Suspend" - omarchy-hibernation-available && options="$options\n󰤁 Hibernate" - options="$options\n󰍃 Logout\n󰜉 Restart\n󰐥 Shutdown" - - case $(menu "System" "$options") in - *Screensaver*) omarchy-launch-screensaver force ;; - *Lock*) omarchy-system-lock ;; - *Suspend*) systemctl suspend ;; - *Hibernate*) systemctl hibernate ;; - *Logout*) omarchy-system-logout ;; - *Restart*) omarchy-system-reboot ;; - *Shutdown*) omarchy-system-shutdown ;; - *) back_to show_main_menu ;; - esac -} - -show_main_menu() { - go_to_menu "$(menu "Go" "󰀻 Apps\n󰧑 Learn\n󱓞 Trigger\n Style\n Setup\n󰉉 Install\n󰭌 Remove\n Update\n About\n System")" -} - -go_to_menu() { - case "${1,,}" in - *apps*) walker -p "Launch…" ;; - *learn*) show_learn_menu ;; - *trigger*) show_trigger_menu ;; - *toggle*) show_toggle_menu ;; - *hardware*) show_hardware_menu ;; - *share*) show_share_menu ;; - *reminder-set*) show_custom_reminder_input ;; - *reminder*) show_reminder_menu ;; - *background*) show_background_menu ;; - *capture*) show_capture_menu ;; - *style*) show_style_menu ;; - *theme*) show_theme_menu ;; - *screenrecord*) show_screenrecord_menu ;; - *setup*) show_setup_menu ;; - *power*) show_setup_power_menu ;; - *install*) show_install_menu ;; - *remove*) show_remove_menu ;; - *update*) show_update_menu ;; - *about*) show_about ;; - *system*) show_system_menu ;; - esac -} - -# Allow user extensions and overrides -USER_EXTENSIONS="$HOME/.config/omarchy/extensions/menu.sh" -[[ -f $USER_EXTENSIONS ]] && source "$USER_EXTENSIONS" - -toggle_existing_menu - -if [[ -n $1 ]]; then - BACK_TO_EXIT=true - go_to_menu "$1" -else - show_main_menu -fi + summon) + exec omarchy-shell shell summon omarchy.menu "$(menu_payload "$route")" + ;; + close) + exec omarchy-shell shell hide omarchy.menu + ;; + refresh | ping) + exec omarchy-shell shell call omarchy.menu "$verb" "{}" + ;; + -h | --help | help) + cat <, or close it if already open. Default verb. + summon [route] Always open the menu (no close-if-visible toggle). + close Close the menu if it is visible. + refresh Re-parse the menu JSONC files. + ping Health check. + +Route is an item id (e.g. setup.power) or alias (e.g. power). Defaults to +"root", which opens the top-level menu. +USAGE + exit 0 + ;; + *) + echo "omarchy-menu: unknown verb '$verb'. Try 'omarchy menu --help'." >&2 + exit 2 + ;; +esac diff --git a/bin/omarchy-menu-clipboard b/bin/omarchy-menu-clipboard new file mode 100755 index 0000000000..83a6b557b2 --- /dev/null +++ b/bin/omarchy-menu-clipboard @@ -0,0 +1,6 @@ +#!/bin/bash +# omarchy:summary=Launch the clipboard manager +# omarchy:group=menu +# omarchy:examples=omarchy menu clipboard + +omarchy-shell shell toggle omarchy.clipboard diff --git a/bin/omarchy-menu-emoji b/bin/omarchy-menu-emoji new file mode 100755 index 0000000000..edc1427b79 --- /dev/null +++ b/bin/omarchy-menu-emoji @@ -0,0 +1,6 @@ +#!/bin/bash +# omarchy:summary=Launch emojis +# omarchy:group=menu +# omarchy:examples=omarchy menu emoji + +omarchy-shell shell toggle omarchy.emojis diff --git a/bin/omarchy-menu-emoji-insert b/bin/omarchy-menu-emoji-insert new file mode 100755 index 0000000000..583702e095 --- /dev/null +++ b/bin/omarchy-menu-emoji-insert @@ -0,0 +1,20 @@ +#!/bin/bash + +# omarchy:summary=Insert an emoji into the focused application +# omarchy:group=menu +# omarchy:args= +# omarchy:hidden=true + +emoji="${1:-}" +copy_pid="" + +[[ -n $emoji ]] || exit + +printf '%s' "$emoji" | wl-copy --type text/plain --sensitive --foreground & +copy_pid=$! + +sleep 0.15 +wtype -M shift -k Insert -m shift 2>/dev/null || true +sleep 0.2 + +kill "$copy_pid" 2>/dev/null || true diff --git a/bin/omarchy-menu-file b/bin/omarchy-menu-file index 8cdf4bb0a5..c26620607a 100755 --- a/bin/omarchy-menu-file +++ b/bin/omarchy-menu-file @@ -1,15 +1,15 @@ #!/bin/bash -# omarchy:summary=Pick a file with Walker +# omarchy:summary=Pick a file from a menu # omarchy:group=menu # omarchy:name=file -# omarchy:args=label paths formats [walker args...] +# omarchy:args=label paths formats [menu args...] # omarchy:examples=omarchy menu file "Select image" "$HOME/Pictures" "jpg png webp"|omarchy-menu-file "Select media" "$HOME/Pictures:$HOME/Videos" "jpg png mp4 mov" --width 800 set -euo pipefail if (( $# < 3 )); then - echo "Usage: omarchy-menu-file