diff --git a/config/omarchy/shell.json b/config/omarchy/shell.json index e6202da8..64cc4075 100644 --- a/config/omarchy/shell.json +++ b/config/omarchy/shell.json @@ -38,6 +38,9 @@ } ], "right": [ + { + "id": "omacom.elsewhen" + }, { "id": "omarchy.tray" }, diff --git a/default/agents/skills/omarchy/plugins.md b/default/agents/skills/omarchy/plugins.md index 6e3ab069..bf1033b3 100644 --- a/default/agents/skills/omarchy/plugins.md +++ b/default/agents/skills/omarchy/plugins.md @@ -9,9 +9,14 @@ inside a single long-running Quickshell process (`omarchy-shell`). ``` ~/.config/omarchy/shell.json # User overrides: bar, plugins, idle ~/.config/omarchy/plugins// # User-owned shell plugins +/usr/share/omarchy/plugins// # Packaged plugins (pacman), e.g. omacom.elsewhen $OMARCHY_PATH/config/omarchy/shell.json # Canonical defaults ``` +Packaged plugins such as Elsewhen (`elsewhen` package) update with `omarchy update` and are removed with pacman; +`omarchy plugin update` and `omarchy plugin remove` refuse them and name the +package. + The shell hot-reloads `shell.json` on save — no restart needed for layout changes. `idle.screensaver` and `idle.lock` are seconds since user idle began. diff --git a/docs/file-layout.md b/docs/file-layout.md index f261085f..8bbcd5b2 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -78,6 +78,9 @@ install/** ──► omarchy /usr/share/omarchy migrations/** ──► omarchy /usr/share/omarchy/migrations/ themes/** ──► omarchy /usr/share/omarchy/themes/ shell/** ──► omarchy /usr/share/omarchy/shell/ + (packaged plugins from their own + packages, e.g. elsewhen, land beside it + in /usr/share/omarchy/plugins//) version ──► omarchy /usr/share/omarchy/version + /etc/skel/.local/state/omarchy/migrations/* diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index d62ff743..18a61896 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -88,6 +88,19 @@ bar from the CLI — `use | reset | defaults | position | transparent | put | move | set`, with placement flags such as `--section` and `--index`. The lower-level IPC methods remain available through `omarchy-shell shell ...`. +## Packaged plugins + +A default plugin can ship as its own Arch package instead of in the Omarchy +checkout: Elsewhen (`omacom.elsewhen`) is the `elsewhen` package. pacman installs them at +`/usr/share/omarchy/plugins//`, a root the shell scans between +`$OMARCHY_PATH/shell/plugins` and `~/.config/omarchy/plugins`. A packaged +`omarchy.*` id is trusted like a bundled plugin and loads by default; any other +packaged id behaves like an installed plugin. Precedence is bundled, then +packaged, then user, so a checkout under `omarchy dev link` overrides the +package. Packaged plugins update through `omarchy update` and leave through +pacman; `omarchy plugin update` and `omarchy plugin remove` refuse them and +name the package, and Setup › Plugins › Remove does not list them. + ## IPC The shell exposes a `shell` target (the host also registers diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 76e74110..0a8e27cc 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -26,6 +26,7 @@ docker-compose dosfstools dotnet-runtime dua-cli +elsewhen evince exfatprogs expac diff --git a/migrations/1789581661.sh b/migrations/1789581661.sh new file mode 100644 index 00000000..fd5d30be --- /dev/null +++ b/migrations/1789581661.sh @@ -0,0 +1,85 @@ +echo "Install Elsewhen, the world clock plugin" + +# Elsewhen ships as the elsewhen package rather than in this checkout; pacman +# puts it under /usr/share/omarchy/plugins, where the shell looks next to its +# bundled plugins. A package the mirror does not carry yet fails the migration +# on purpose: nothing before this line has changed anything, and the runner +# retries it on the next update or login rather than quietly leaving the +# machine without the plugin. +omarchy-pkg-add elsewhen + +# A shell that already knows the packaged root (dev-linked, or restarted +# since) picks the plugin up here; the one an update runs under predates it +# and is replaced by omarchy-update-restart right after the migrations. +omarchy-shell -q shell rescanPlugins || true + +# Before the package, the README had users run +# `omarchy plugin add https://github.com/omacom/elsewhen.git`, which left a +# git clone under ~/.config/omarchy/plugins. The packaged copy now shadows +# it: the shell logs a rejection on every scan, and `omarchy plugin update` +# keeps pulling a checkout nothing loads. A pristine clone of the upstream +# repo is retired the way `omarchy plugin remove` retires one (the repo +# remains upstream). Anything else -- a symlink, a plain directory, local +# changes, a fork, a checkout git cannot read -- is the user's and stays. This +# runs only once the package is in place, so the plugin never leaves a +# machine; its id stays in shell.json either way, which is what keeps it +# enabled. The shell an update runs under predates the packaged root, and it +# watches this directory: dropping the checkout empties the widget's slot in +# that shell until omarchy-update-restart replaces it a few steps later with +# one that finds the package. A one-time gap of a few minutes, during the +# update itself. +upstream_checkout() { + local dir="$1" origin status + [[ -d $dir && ! -L $dir && -d $dir/.git ]] || return 1 + origin=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1 + origin=${origin,,} + origin=${origin%/} + origin=${origin%.git} + [[ $origin =~ ^([a-z+]+://)?([^/@]+@)?github\.com[/:]omacom/elsewhen$ ]] || return 1 + status=$(git -C "$dir" status --porcelain 2>/dev/null) || return 1 + [[ -z $status ]] +} + +checkout="$HOME/.config/omarchy/plugins/omacom.elsewhen" +if [[ -e $checkout || -L $checkout ]]; then + if upstream_checkout "$checkout"; then + rm -rf "$checkout" + echo "Retired the omacom.elsewhen checkout at $checkout in favour of the elsewhen package." + else + echo "The elsewhen package takes precedence over $checkout, which is left as it is." + fi +fi + +# The widget opens the right section of the default bar, just before the +# tray, which a machine without a shell.json takes on as soon as the shell +# restarts. A customized bar gets the entry written into its file rather than +# placed over IPC: the shell this runs under cannot see a widget its scan +# never reached and would refuse it, whereas the file it hot-reloads carries +# the entry through to the restart. A bar that already carries the widget +# keeps it where the user put it. +source omarchy-shell-config + +[[ -s $CONFIG_FILE ]] || exit 0 +# A file the shell cannot read is left for its owner to repair. +jq empty "$CONFIG_FILE" 2>/dev/null || exit 0 + +entry_id='def entry_id: if type == "object" then (.id // "" | tostring) else tostring end;' + +if jq -e "$entry_id"' + any((.bar.layout // {} | .left, .center, .right | arrays)[]; entry_id == "omacom.elsewhen") +' "$CONFIG_FILE" >/dev/null; then + exit 0 +fi + +commit "$NORMALIZE | $entry_id"' + def ids: map(entry_id); + def insert_at($section; $index): + .bar.layout[$section] = .bar.layout[$section][:$index] + [{id: "omacom.elsewhen"}] + .bar.layout[$section][$index:]; + . as $config + | (["left", "center", "right"] | map(select($config.bar.layout[.] | ids | index("omarchy.tray") != null)) | first) as $section + | if $section != null then + insert_at($section; .bar.layout[$section] | ids | index("omarchy.tray")) + else + insert_at("right"; 0) + end +' diff --git a/shell/README.md b/shell/README.md index ca07b520..23a25ca6 100644 --- a/shell/README.md +++ b/shell/README.md @@ -140,6 +140,19 @@ You can still drop a plugin in without git: `barWidget.defaultSection`, or in the center when it is omitted, and can be moved with `omarchy bar move`; a full bar replaces the one in use. +### Packaged plugins + +Some default plugins ship as their own Arch package rather than in this +checkout: Elsewhen (`omacom.elsewhen`) is the `elsewhen` package. pacman installs such a plugin, +root-owned, at `/usr/share/omarchy/plugins//`, and +the shell scans that root between its bundled plugins and the user's. A +packaged `omarchy.*` id is trusted exactly like a bundled one; a packaged +plugin under any other id is treated like a user plugin. They update with +`omarchy update` and are removed with pacman, so `omarchy plugin update` and +`omarchy plugin remove` refuse them and name the package instead. A bundled +copy in `$OMARCHY_PATH/shell/plugins` wins over the packaged one, which is how +a packaged plugin is developed under `omarchy dev link`. + The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`, `omarchy-shell shell enablePlugin '{}'`, and `omarchy-shell shell listPlugins`. The `omarchy plugin` commands wrap those calls. `omarchy bar move` and @@ -224,6 +237,7 @@ customization from the shipped defaults lives in it. |-----------------------------------|----------------|--------------------------------------------------------| | `~/.config/omarchy/shell.json` | the shell | full layout + per-entry settings + enabled plugin list | | `~/.config/omarchy/plugins//` | user | drop-in third-party plugin source files | +| `/usr/share/omarchy/plugins//` | pacman | packaged plugins (e.g. `elsewhen`), updated by `omarchy update` | The `config/omarchy/shell.json` default config describes the fresh-install state. When the user has no `shell.json`, the shell uses diff --git a/shell/plugins/README.md b/shell/plugins/README.md index 66cb74e1..1a2d26fb 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -9,7 +9,10 @@ First-party non-bar plugins are enabled unless listed in `disabledPlugins[]`; at startup; other panels, overlays, and menus are loaded on demand. User-installed plugins live alongside these conceptually but on disk under -`~/.config/omarchy/plugins//` rather than in this directory. +`~/.config/omarchy/plugins//` rather than in this directory. A few +default plugins arrive as their own package instead: pacman installs them at +`/usr/share/omarchy/plugins//`, where an `omarchy.*` id is trusted +like the plugins here, and a copy in this directory shadows the packaged one. | Plugin | id | kinds | entry point | |---------------|---------------------------|-------------------------|---------------------------------------| @@ -28,6 +31,7 @@ User-installed plugins live alongside these conceptually but on disk under | Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` | | Tailscale | `omarchy.tailscale` | `bar-widget` | `panels/tailscale/Panel.qml` | | Agents | `omarchy.agents` | `bar-widget` | `agents/Panel.qml` | +| Elsewhen | `omacom.elsewhen` | `bar-widget` | packaged: `elsewhen` installs `/usr/share/omarchy/plugins/omacom.elsewhen/` | | Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` | | Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` | | Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` | diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 355ca26b..d9086d44 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -41,11 +41,12 @@ pass "default clock date format has no leading zero" jq -e ' def ids: map(.id // .); (.bar.layout.right | ids) as $ids | + ($ids | index("omacom.elsewhen")) as $elsewhen | ($ids | index("omarchy.tray")) as $tray | ($ids | index("omarchy.agents")) as $agents | - $tray != null and $agents == $tray + 1 + $elsewhen == 0 and $tray == 1 and $agents == $tray + 1 ' "$ROOT/config/omarchy/shell.json" >/dev/null -pass "default right layout keeps agents next to the tray" +pass "default right layout opens with elsewhen and the tray, then agents" ROOT="$ROOT" python3 <<'PY' import json diff --git a/test/shell.d/elsewhen-default-migration-test.sh b/test/shell.d/elsewhen-default-migration-test.sh new file mode 100644 index 00000000..438ba1d3 --- /dev/null +++ b/test/shell.d/elsewhen-default-migration-test.sh @@ -0,0 +1,285 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq +require_command git + +migration="$ROOT/migrations/1789581661.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +mkdir -p "$test_dir/bin" +export INSTALLED_PACKAGES="$test_dir/installed" CALL_LOG="$test_dir/calls" SHELL_CALLS="$test_dir/shell-calls" +# The checkout cases use the real git; keep the developer's config (signing, +# hooks, safe.directory) out of what it sees. +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com + +# Keep the real package helpers, but contain every pacman transaction here. +cat >"$test_dir/bin/pacman" <<'SH' +#!/bin/bash +case "$1" in + -Q) grep -Fxq -- "$2" "$INSTALLED_PACKAGES" ;; + -S) + [[ ${FAIL_INSTALL:-0} == 0 ]] || exit 1 + shift 3 # -S --noconfirm --needed + printf '%s\n' "$@" >>"$INSTALLED_PACKAGES" + printf '%s\n' "$@" >>"$CALL_LOG" + ;; + *) exit 1 ;; +esac +SH +cat >"$test_dir/bin/sudo" <<'SH' +#!/bin/bash +[[ $1 == "pacman" ]] || exit 1 +"$@" +SH +# The migration runs under a shell that predates the packaged root, or under +# none at all, so it must only ever ask the shell for best-effort refreshes. +cat >"$test_dir/bin/omarchy-shell" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$SHELL_CALLS" +[[ $1 != "-q" ]] || exit 0 +exit "${SHELL_STATUS:-0}" +SH +chmod +x "$test_dir/bin/"* + +home="$test_dir/home" +config="$home/.config/omarchy/shell.json" +checkout="$home/.config/omarchy/plugins/omacom.elsewhen" +output="$test_dir/output" +mkdir -p "$home/.config/omarchy" + +run_migration() { + : >"$CALL_LOG" + : >"$SHELL_CALLS" + HOME="$home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \ + bash -euo pipefail "$migration" >"$output" +} + +ids() { + jq -c ".bar.layout.$1 | map(if type == \"object\" then .id else . end)" "$config" +} + +# ------------------------------------------------------------ package install +: >"$INSTALLED_PACKAGES" +cat >"$config" <<'JSON' +{ + "version": 1, + "bar": { + "layout": { + "left": [{ "id": "omarchy.menu" }], + "center": [{ "id": "omarchy.clock", "format": "HH:mm" }], + "right": [ + { "id": "omarchy.tray" }, + { "id": "omarchy.agents", "syncMode": "On" }, + { "id": "omarchy.power" } + ] + } + }, + "plugins": [] +} +JSON + +run_migration +grep -Fxq elsewhen "$CALL_LOG" || fail "migration installs the elsewhen package" "$(cat "$CALL_LOG")" +pass "migration installs the elsewhen package" + +[[ $(ids right) == '["omacom.elsewhen","omarchy.tray","omarchy.agents","omarchy.power"]' ]] || + fail "migration puts the widget just before the tray" "$(cat "$config")" +pass "migration puts the widget just before the tray" + +[[ $(jq -c '.bar.layout.right[2]' "$config") == '{"id":"omarchy.agents","syncMode":"On"}' ]] || + fail "migration keeps the settings of its neighbours" "$(cat "$config")" +[[ $(jq -c '.bar.layout.center[0]' "$config") == '{"format":"HH:mm","id":"omarchy.clock"}' ]] || + fail "migration leaves other sections alone" "$(cat "$config")" +pass "migration leaves the rest of the layout alone" + +grep -q 'shell rescanPlugins' "$SHELL_CALLS" && grep -q 'shell reloadConfig' "$SHELL_CALLS" || + fail "migration asks the running shell to rescan and reload" "$(cat "$SHELL_CALLS")" +pass "migration asks the running shell to rescan and reload" + +before=$(sha256sum "$config") +run_migration +[[ $before == $(sha256sum "$config") ]] || fail "migration is idempotent" "$(cat "$config")" +[[ ! -s $CALL_LOG ]] || fail "migration does not reinstall a present package" "$(cat "$CALL_LOG")" +pass "migration is idempotent" + +# ------------------------------------------------------------- placements +cat >"$config" <<'JSON' +{ + "version": 1, + "bar": { "layout": { "left": [], "center": ["omarchy.clock", "omarchy.tray"], "right": ["omarchy.power"] } } +} +JSON +run_migration +[[ $(ids center) == '["omarchy.clock","omacom.elsewhen","omarchy.tray"]' && $(ids right) == '["omarchy.power"]' ]] || + fail "migration follows the tray into another section and reads string entries" "$(cat "$config")" +pass "migration follows the tray into another section and reads string entries" + +cat >"$config" <<'JSON' +{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.agents" }, { "id": "omarchy.power" }] } } } +JSON +run_migration +[[ $(ids right) == '["omacom.elsewhen","omarchy.agents","omarchy.power"]' ]] || + fail "migration prepends to the right section when the tray is off the bar" "$(cat "$config")" +pass "migration prepends to the right section when the tray is off the bar" + +cat >"$config" <<'JSON' +{ + "version": 1, + "bar": { "layout": { "left": [{ "id": "omacom.elsewhen" }], "center": [], "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.agents" }] } } +} +JSON +before=$(sha256sum "$config") +run_migration +[[ $before == $(sha256sum "$config") ]] || + fail "migration leaves a widget the user already placed where it is" "$(cat "$config")" +pass "migration leaves a widget the user already placed where it is" + +# ------------------------------------------------------------- edge cases +rm -f "$config" +run_migration +[[ ! -e $config ]] || fail "migration writes no shell.json where the defaults apply" "$(cat "$config")" +pass "migration writes no shell.json where the defaults apply" + +printf '{ not json' >"$config" +run_migration +[[ $(cat "$config") == '{ not json' ]] || fail "migration leaves an unparsable config untouched" "$(cat "$config")" +pass "migration leaves an unparsable config untouched" + +cat >"$config" <<'JSON' +{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.agents" }] } } } +JSON +SHELL_STATUS=1 run_migration +[[ $(ids right) == '["omacom.elsewhen","omarchy.tray","omarchy.agents"]' ]] || + fail "migration places the widget with no shell running" "$(cat "$config")" +pass "migration places the widget with no shell running" + +# A package the mirror does not carry yet leaves the migration pending rather +# than half-done: the layout must not name a widget nothing can install. +: >"$INSTALLED_PACKAGES" +cat >"$config" <<'JSON' +{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.agents" }] } } } +JSON +before=$(sha256sum "$config") +if FAIL_INSTALL=1 run_migration 2>/dev/null; then + fail "a failed package installation must leave the migration pending" +fi +[[ $before == $(sha256sum "$config") ]] || + fail "a failed package installation leaves the layout untouched" "$(cat "$config")" +pass "a failed package installation is propagated and changes nothing" + +# ------------------------------------------------- pre-package checkouts +# Users who followed the README before the package existed hold a git clone +# under ~/.config/omarchy/plugins, which the packaged copy now shadows. Only a +# clone of the upstream repo with nothing local in it is retired; everything +# else is the user's and stays, and shell.json gets the widget either way. + +# A clone made by `omarchy plugin add`: one commit, the given origin, nothing +# local. Built with init rather than clone so the origin can be any URL form. +make_checkout() { + local origin="$1" + rm -rf "$checkout" + mkdir -p "$checkout" + git -C "$checkout" init -q + git -C "$checkout" remote add origin "$origin" + printf '{ "id": "omacom.elsewhen" }\n' >"$checkout/manifest.json" + printf 'import QtQuick\nItem {}\n' >"$checkout/Panel.qml" + git -C "$checkout" add -A + git -C "$checkout" commit -q -m "Elsewhen" +} + +reset_layout() { + cat >"$config" <<'JSON' +{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.power" }] } } } +JSON +} + +assert_widget_placed() { + [[ $(ids right) == '["omacom.elsewhen","omarchy.tray","omarchy.power"]' ]] || + fail "$1 still gets the widget" "$(cat "$config")" +} + +assert_retired() { + local description="$1" + [[ ! -e $checkout && ! -L $checkout ]] || fail "$description is retired" "$(ls -la "$checkout")" + grep -q 'Retired the omacom.elsewhen checkout' "$output" || fail "$description is reported as retired" "$(cat "$output")" + assert_widget_placed "$description" + pass "$description is retired" +} + +assert_kept() { + local description="$1" + [[ -e $checkout || -L $checkout ]] || fail "$description is kept" + grep -q 'takes precedence over' "$output" || fail "$description is reported as shadowed" "$(cat "$output")" + assert_widget_placed "$description" + pass "$description is kept" +} + +for origin in \ + https://github.com/omacom/elsewhen.git \ + https://github.com/omacom/elsewhen \ + git@github.com:omacom/elsewhen.git \ + ssh://git@github.com/omacom/elsewhen.git \ + https://GitHub.com/Omacom/Elsewhen/; do + make_checkout "$origin" + reset_layout + run_migration + assert_retired "a pristine clone with origin $origin" +done + +make_checkout https://github.com/omacom/elsewhen.git +printf 'import QtQuick\nItem { id: mine }\n' >"$checkout/Panel.qml" +reset_layout +run_migration +assert_kept "a clone with a modified tracked file" +[[ $(cat "$checkout/Panel.qml") == $'import QtQuick\nItem { id: mine }' ]] || + fail "a kept clone keeps its local change" "$(cat "$checkout/Panel.qml")" +pass "a kept clone keeps its local change" + +make_checkout https://github.com/omacom/elsewhen.git +printf 'notes\n' >"$checkout/NOTES.md" +reset_layout +run_migration +assert_kept "a clone with an untracked file" + +make_checkout https://github.com/someone/elsewhen-fork.git +reset_layout +run_migration +assert_kept "a clone of another repository" + +make_checkout https://github.com/omacom/elsewhen.git +git -C "$checkout" remote remove origin +reset_layout +run_migration +assert_kept "a clone without an origin remote" + +rm -rf "$checkout" +mkdir -p "$checkout" +printf '{ "id": "omacom.elsewhen" }\n' >"$checkout/manifest.json" +reset_layout +run_migration +assert_kept "a plain directory without .git" + +rm -rf "$checkout" +make_checkout https://github.com/omacom/elsewhen.git +mv "$checkout" "$test_dir/elsewhen-src" +ln -s "$test_dir/elsewhen-src" "$checkout" +reset_layout +run_migration +assert_kept "a symlinked checkout" +[[ -L $checkout && -f $test_dir/elsewhen-src/manifest.json ]] || + fail "a symlinked checkout stays a symlink with its target intact" "$(ls -la "$checkout" "$test_dir/elsewhen-src")" +pass "a symlinked checkout stays a symlink with its target intact" + +rm -f "$checkout" +reset_layout +run_migration +grep -q 'omacom.elsewhen checkout\|takes precedence' "$output" && fail "no checkout means no checkout report" "$(cat "$output")" +assert_widget_placed "a machine without a checkout" +pass "a machine without a checkout is left alone"