Manage plugins from Setup > Plugins (#6420)

* Give built-in plugins an honest on/off state

Every built-in reported itself enabled no matter what. A bar widget said
"enabled" while sitting nowhere near the bar, and disabling a built-in service
silently did nothing, because enabled meant "listed in plugins[]" and a
built-in never is. Nothing surfaced that, since the only caller listing plugins
was the CLI.

For a widget, on and off is its place in the bar, so listPlugins reports layout
membership -- what enable/disable actually toggles. For everything else built
in, loading by default is the right behaviour to keep, so switching one off is
recorded the other way round, in disabledPlugins[]. shell.json still carries
only the deviation from the defaults: the key is dropped the moment nothing is
switched off, leaving a config that never disabled anything byte-identical.

isEnabled still answers a separate question -- whether the component loads at
all -- and deliberately does not follow a widget out of the bar. omarchy.menu
is both a widget and the menu itself, so tying the two together would let
taking its button off the bar lock the menu out of the shell, with no way back
that isn't the CLI.

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

* Manage plugins from Setup > Plugins

Plugins were CLI-only. Setup > Plugins now offers Enable, Disable, Add, and
Remove, each list living in the menu itself so picking a row acts on it.

Enable and Disable cover the built-ins as well as anything installed -- the bar
widgets you can put in the bar, the services and overlays you can switch off.
Remove is limited to plugins the user installed, since a built-in has no
checkout to delete, and stays hidden until there is one. Whole-bar
replacements are left out; those are chosen under Style.

Enabling a bar widget asks for a section first, because enabling alone drops it
on the right and the only way to move it was a follow-up bar plugin move. The
CLI asks the same question after its own add, so both paths place a widget the
same way. Add and Remove run in a terminal: one needs a git URL and shows the
trust warning before cloning, the other deletes a checkout and prints where it
backed it up.

Providers grew two hooks for this. placementFor turns a row into a submenu
instead of an action, and volatile re-runs the enumeration when its submenu is
entered -- picking from these lists is what changes them, and rows a provider
no longer returns now drop out instead of lingering forever.

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

* Trim the plugin menu after review

Menu.qml carried its own shellQuote while already importing Util and calling
Util.shellQuote a few lines up; two copies of the same escaping is one place
for a future fix to miss. isDisabled walked the array by hand to compare values
it writes itself, and dropDisabled was an eight-line helper with one caller.

Two bugs came out of the same pass. A whole-bar replacement belongs under Style
rather than these lists, but the exclusion sat in the shared row builder, so a
third-party bar could be installed and never removed -- Remove would show an
empty list under a guard that said something was there. The exclusion now sits
on the two lists that mean it.

Rows are keyed by id, and distinct plugin ids can slugify alike: acme.foo,
acme_foo and acme-foo all give acme-foo. The merge keeps the first row per id,
so the rest simply vanished from the list with nothing to say why. Row ids are
now made distinct before merging.

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

* Pick a plugin the way we pick a theme

Setup > Plugins listed plugins as menu rows, which needed three providers, a
placement submenu, a volatile-refresh hook and a row-swap in the merge. Only
Font and Apps are built that way. Theme, Background, Unlock, Timezone and
Keybindings all pipe a list into omarchy-menu-select instead, which is one
action string and a small script -- so that is what these use now.

The trade is search: a plugin name is no longer findable from the root prompt.
Neither is a theme name or a timezone, and Enable Plugin still is, so the loss
sits where the rest of the menu already puts it.

Two pieces of the row machinery stay, because they are worth having for the
lists that remain. A volatile provider re-runs when its submenu is entered, so
a font installed since the shell started now shows up without restarting it,
and rows a provider stops returning drop out. Row ids are still made distinct
before merging: Fira Code and Fira-Code both slug to fira-code, and a repeated
id was silently dropped.

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

* Let a picked option carry an icon

Moving the plugin lists onto omarchy-menu-select cost them their glyphs: the
select mode has always hardcoded an empty icon, which is why Timezone and
Keybindings have none either. An option may now lead with one, as
"<glyph><TAB><label>". The menu shows the glyph, filters on the label, and
hands the label back, so a caller never strips a glyph off its own selection
and a list of plain strings behaves exactly as before.

The plugin picker uses it for the puzzle glyph on each plugin and the align
glyphs on the sections, which also regain the capitals they lost when the
section names were passed through raw.

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

* Switch bars by enabling one

A bar option was kept out of Enable and Disable on the grounds that picking
which bar to run belongs under Style -- but nothing under Style ever offered
it, so an installed bar could be added and removed and never actually put to
use. The menu was guarding a door to a room that was never built.

Enabling one is the switch. setEnabled already assigns bar.id for a bar
option, so a bar has always replaced the one before it; only the picker's
filter stood in the way. Dropping it costs nothing else, because enabled for a
bar option means active: the bar in use is the one row absent from Enable,
every other installed bar is one pick away, and the built-in is just another
entry, so going back to it is enabling Bar.

Disable keeps the exclusion. That is the one verb a bar cannot answer -- there
is no off, only a successor -- and offering it would have listed the built-in
bar on a stock system, where turning it off deletes a bar.id that was never
set and nothing happens.

A bar carries the bar glyph rather than the puzzle one, so a row that replaces
the whole bar does not read like one more widget to switch on, and enable now
says "Now using X as the bar" instead of "Enabled X", which understated a
whole-bar swap in both the enable and the freshly-added path.

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

* Refuse a plugin that declares a kind it cannot load

A kind is a promise to supply something to load, and the shell reads that
something from a fixed key: entryPoints.bar to draw a bar, entryPoints.menu to
open a menu. Nothing checked the promise. A manifest could claim kinds ["bar"]
with no bar entry point, pass validation, install, and enable -- and then the
bar would fall back to the built-in and the widget would be skipped, leaving a
plugin that does nothing, explained only by a console.warn nobody reads.

Our own plugins have been held to this table by plugins-test.sh all along.
This holds third-party ones to the same table, at add and update time, where
there is still someone to tell.

A kind outside the table is left alone rather than guessed at, so a shell that
learns a new kind does not need this list updated first. The cost is that a
misspelled kind still installs quietly.

omarchy-plugin-validate had no tests; it has some now.

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

* Act on the plugin whose row was picked

The picker showed a name and then looked that name up again across every
plugin, filtered set or not, taking the first match. Two plugins can share a
name: cloning one keeps the name it was cloned from, so the documented
`omarchy plugin clone omarchy.clock local.clock` leaves two plugins called
Clock. Enable listed the clone -- the built-in was already enabled, so only the
clone was eligible -- and then enabled omarchy.clock, moving the built-in
widget instead. Remove listed the clone and tried to delete a built-in that has
no checkout to delete.

A row now carries its id alongside its label, and the id is read back off the
row that was picked instead of being derived from the name a second time. Where
a name is not unique among the rows on offer, the label carries the id too, so
two rows that would both say Clock can be told apart at all -- which they could
not before, whichever one the pick resolved to.

The verb prompt only ever sees the first two fields, so the menu shows what it
always did.

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

* Never ask a bar where to sit in the bar

A manifest may declare both bar and bar-widget, and validation accepts it. The
picker saw bar-widget, asked for a section, and passed it to enable. setEnabled
takes bar as the dominant kind: it writes bar.id and returns, adding nothing to
any layout, so the move that followed had no widget to find and failed -- after
the bar had already been switched. A partial success with an error on the way
out.

Bar wins ahead of bar-widget now, in the picker and in the placement prompt
`plugin add --enable` asks, so a bar is enabled without a placement it cannot
use. The CLI refuses a placement on a bar outright, before the bar is switched
rather than after, since `omarchy plugin enable <bar> --section left` could
reach the same half-applied state without going through either.

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

* Only replacement, no off

* Add default placement for bar widgets

* Simplify plugin menu actions

* Document plugin placement behavior

* Allow dropping widgets in empty bar space

* Treat plugin dependencies as runtime invariants

* Reject duplicate plugin ids on add

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-07-29 18:11:32 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 6593403345
commit 09b955dc75
27 changed files with 1007 additions and 154 deletions
+8 -1
View File
@@ -121,6 +121,13 @@ widget_is_known() {
' >/dev/null ' >/dev/null
} }
widget_default_section() {
omarchy-plugin-catalog | jq -r --arg id "$1" '
(map(select(.id == $id))[0].barWidget.defaultSection // "center") as $section
| if ["left", "center", "right"] | index($section) then $section else "center" end
'
}
# --------------------------------------------------------------------- placement # --------------------------------------------------------------------- placement
# Parse placement flags shared by add/move/remove/set. Sets the globals below. # Parse placement flags shared by add/move/remove/set. Sets the globals below.
@@ -217,7 +224,7 @@ cmd_add() {
widget_is_known "$id" || fail "$id is not a known bar widget; run 'omarchy plugin list' to see valid ids" widget_is_known "$id" || fail "$id is not a known bar widget; run 'omarchy plugin list' to see valid ids"
local default_section="${PLACEMENT_SECTION:-right}" local default_section="${PLACEMENT_SECTION:-$(widget_default_section "$id")}"
local explicit="false" local explicit="false"
if [[ -n $PLACEMENT_INDEX || -n $PLACEMENT_BEFORE || -n $PLACEMENT_AFTER || $PLACEMENT_DUPLICATE == true ]]; then if [[ -n $PLACEMENT_INDEX || -n $PLACEMENT_BEFORE || -n $PLACEMENT_AFTER || $PLACEMENT_DUPLICATE == true ]]; then
explicit="true" explicit="true"
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# omarchy:summary=Pick a shell plugin to enable, disable, or remove
# omarchy:group=menu
# omarchy:name=plugin
# omarchy:args=<enable|disable|remove>
# omarchy:examples=omarchy menu plugin enable | omarchy menu plugin disable
set -euo pipefail
PLUGIN_ICON=$'\U000f0431'
case "${1:-}" in
enable) filter='(.enabled | not)' ;;
disable) filter='.canDisable and .enabled' ;;
remove) filter='(.firstParty | not)' ;;
*)
echo "Usage: omarchy-menu-plugin <enable|disable|remove>" >&2
exit 1
;;
esac
plugins=$(omarchy-plugin list --json) || exit 1
# Two plugins may share a name, so keep the id with the row and show it when the
# name alone cannot identify the pick.
rows=$(jq -r --arg icon "$PLUGIN_ICON" \
"([.[] | select($filter)]) as \$rows
| \$rows[]
| .name as \$name
| (if ([\$rows[] | select(.name == \$name)] | length) > 1 then \$name + \" (\" + .id + \")\" else \$name end) as \$label
| \$icon + \"\\t\" + \$label + \"\\t\" + .id" <<<"$plugins")
[[ -n $rows ]] || { omarchy-notification-send "No plugin to ${1}"; exit 0; }
name=$(omarchy-menu-select "${1^} plugin" < <(cut -f1,2 <<<"$rows")) || exit 0
[[ -n $name ]] || exit 0
id=$(awk -F'\t' -v label="$name" '$2 == label { print $3; exit }' <<<"$rows")
[[ -n $id ]] || exit 1
if [[ $1 == "remove" ]]; then
omarchy-launch-floating-terminal-with-presentation "omarchy-plugin remove $(printf '%q' "$id")"
else
omarchy-plugin "$1" "$id"
fi
+3
View File
@@ -6,6 +6,9 @@
# omarchy:args=prompt [option...] [-- menu args...] # omarchy:args=prompt [option...] [-- menu args...]
# omarchy:examples=omarchy menu select Format jpg png|omarchy-menu-select Resolution 4k 1080p 720p -- --width 400 # omarchy:examples=omarchy menu select Format jpg png|omarchy-menu-select Resolution 4k 1080p 720p -- --width 400
# An option may lead with an icon, as "<glyph><TAB><label>". The menu shows the
# glyph and returns the label alone, so callers never strip it back off.
set -euo pipefail set -euo pipefail
if (( $# < 1 )); then if (( $# < 1 )); then
+61 -15
View File
@@ -21,7 +21,7 @@ Usage: omarchy-plugin <command> [args...]
Manage plugins: Manage plugins:
list [--json] List discovered shell plugins list [--json] List discovered shell plugins
rescan Rescan ~/.config/omarchy/plugins rescan Rescan ~/.config/omarchy/plugins
enable <id> [placement] Enable a plugin enable <id> [placement] Enable a plugin (a bar replaces the one in use)
disable <id> Disable a plugin disable <id> Disable a plugin
Install from git (a plugin is a git repo): Install from git (a plugin is a git repo):
@@ -51,10 +51,6 @@ fail() {
exit 1 exit 1
} }
require_command() {
omarchy-cmd-present "$1" || fail "$1 is required"
}
interactive() { interactive() {
[[ -t 0 && -t 1 ]] [[ -t 0 && -t 1 ]]
} }
@@ -78,10 +74,50 @@ valid_plugin_id() {
[[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ && $1 != *..* ]] [[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ && $1 != *..* ]]
} }
is_bar_option() {
omarchy-plugin-catalog | jq -e --arg id "$1" 'any(.[]; .id == $id and (.kinds | index("bar")))' >/dev/null
}
# Only one bar runs at a time, so enabling one replaces the one before it. Say
# that rather than "Enabled", which reads like one more thing switched on next
# to everything already there.
enabled_message() {
local id="$1"
if is_bar_option "$id"; then
echo "Now using $id as the bar"
else
echo "Enabled $id"
fi
}
# A bar widget lands on the right when it is enabled, which is rarely where it
# belongs. Ask once, here, rather than leaving a follow-up 'omarchy bar plugin
# move' as the only way to place it.
place_bar_widget() {
local id="$1" section default_section
interactive || return 0
(( ASSUME_YES )) && return 0
# A plugin that is also a bar has no place in the layout to ask about.
jq -e '(.kinds // []) | (index("bar") | not) and (index("bar-widget") != null)' \
"$PLUGINS_DIR/$id/manifest.json" >/dev/null 2>&1 || return 0
default_section=$(jq -r '.barWidget.defaultSection // "center"' "$PLUGINS_DIR/$id/manifest.json")
section=$(printf '%s\n' left center right |
gum choose --header="Place $id in which bar section?" --selected "$default_section") || return 0
[[ -n $section ]] || return 0
omarchy-bar-plugin move "$id" --section "$section" >/dev/null && echo "Placed $id in the $section section"
}
installed_plugin_ids() { installed_plugin_ids() {
find "$PLUGINS_DIR" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) ! -name '.*' -printf '%f\n' 2>/dev/null | sort find "$PLUGINS_DIR" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) ! -name '.*' -printf '%f\n' 2>/dev/null | sort
} }
plugin_id_manifest() {
omarchy-plugin-catalog | jq -r --arg id "$1" '
map(select(.id == $id))[0].manifestPath // empty
'
}
plugin_discovered() { plugin_discovered() {
local id="$1" plugins local id="$1" plugins
plugins=$(omarchy-shell shell listPlugins 2>/dev/null) || return 1 plugins=$(omarchy-shell shell listPlugins 2>/dev/null) || return 1
@@ -124,7 +160,6 @@ print_plugins() {
return return
fi fi
require_command jq
jq -r ' jq -r '
sort_by(.id)[] | sort_by(.id)[] |
[ .id, [ .id,
@@ -155,9 +190,15 @@ plugin_enabled() {
fail "disable does not take placement options" fail "disable does not take placement options"
fi fi
# Enabling a bar writes bar.id and touches no layout, so a placement could
# only be applied to a widget that is not there. Refuse before the bar is
# switched, rather than switching it and failing on the move afterwards.
if [[ $enabled == "true" ]] && (( $# > 0 )) && is_bar_option "$id"; then
fail "'$id' is a bar; it replaces the bar in use rather than taking a place in one"
fi
omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
if [[ $enabled == "true" ]]; then if [[ $enabled == "true" ]]; then
require_command jq
wait_for_plugin_discovery "$id" || fail "plugin '$id' was not discovered; is omarchy-shell running?" wait_for_plugin_discovery "$id" || fail "plugin '$id' was not discovered; is omarchy-shell running?"
fi fi
@@ -169,7 +210,7 @@ plugin_enabled() {
omarchy-bar-plugin move "$id" "$@" omarchy-bar-plugin move "$id" "$@"
echo "Enabled and moved $id" echo "Enabled and moved $id"
elif [[ $enabled == "true" ]]; then elif [[ $enabled == "true" ]]; then
echo "Enabled $id" enabled_message "$id"
else else
echo "Disabled $id" echo "Disabled $id"
fi fi
@@ -178,9 +219,6 @@ plugin_enabled() {
# ---------------------------------------------------------------- add # ---------------------------------------------------------------- add
plugin_add() { plugin_add() {
require_command git
require_command jq
local url="" enable_after="" local url="" enable_after=""
while (( $# > 0 )); do while (( $# > 0 )); do
case "$1" in case "$1" in
@@ -230,8 +268,17 @@ WARN
fail "refusing to add: validation failed" fail "refusing to add: validation failed"
fi fi
local id local id existing_manifest
id=$(jq -r '.id' "$stage/manifest.json") id=$(jq -r '.id' "$stage/manifest.json")
existing_manifest=$(plugin_id_manifest "$id") || {
rm -rf "$stage"
fail "could not inspect installed plugin ids"
}
if [[ -n $existing_manifest ]]; then
rm -rf "$stage"
fail "plugin id '$id' is already used by $existing_manifest"
fi
local target="$PLUGINS_DIR/$id" local target="$PLUGINS_DIR/$id"
if [[ -e $target || -L $target ]]; then if [[ -e $target || -L $target ]]; then
rm -rf "$stage" rm -rf "$stage"
@@ -254,7 +301,8 @@ WARN
if [[ $enable_after == true ]]; then if [[ $enable_after == true ]]; then
if wait_for_plugin_discovery "$id" && [[ $(omarchy-shell shell setPluginEnabled "$id" true) == "ok" ]]; then if wait_for_plugin_discovery "$id" && [[ $(omarchy-shell shell setPluginEnabled "$id" true) == "ok" ]]; then
echo "Enabled $id" enabled_message "$id"
place_bar_widget "$id"
else else
echo "Could not enable $id (is omarchy-shell running?). Enable later with: omarchy plugin enable $id" >&2 echo "Could not enable $id (is omarchy-shell running?). Enable later with: omarchy plugin enable $id" >&2
fi fi
@@ -310,8 +358,6 @@ update_one() {
} }
plugin_update() { plugin_update() {
require_command git
local id="" all=0 local id="" all=0
while (( $# > 0 )); do while (( $# > 0 )); do
case "$1" in case "$1" in
+2 -2
View File
@@ -15,7 +15,6 @@
set -o pipefail set -o pipefail
OMARCHY_PATH="${OMARCHY_PATH:-}" OMARCHY_PATH="${OMARCHY_PATH:-}"
omarchy-cmd-present jq || { echo "omarchy-plugin-catalog: jq is required" >&2; exit 1; }
paths=() paths=()
@@ -29,7 +28,8 @@ user_dir="$HOME/.config/omarchy/plugins"
if [[ -d $user_dir ]]; then if [[ -d $user_dir ]]; then
while IFS= read -r manifest; do while IFS= read -r manifest; do
paths+=("$manifest") paths+=("$manifest")
done < <(find -L "$user_dir" -mindepth 2 -maxdepth 2 -type f -name manifest.json 2>/dev/null | sort) done < <(find -L "$user_dir" -mindepth 2 -maxdepth 2 -type f -name manifest.json \
! -path "$user_dir/.*/*" 2>/dev/null | sort)
fi fi
if (( ${#paths[@]} == 0 )); then if (( ${#paths[@]} == 0 )); then
-7
View File
@@ -14,10 +14,6 @@ fail() {
exit 1 exit 1
} }
require_command() {
omarchy-cmd-present "$1" || fail "$1 is required"
}
require_omarchy_path() { require_omarchy_path() {
[[ -n ${OMARCHY_PATH:-} ]] || fail "OMARCHY_PATH is not set" [[ -n ${OMARCHY_PATH:-} ]] || fail "OMARCHY_PATH is not set"
} }
@@ -39,7 +35,6 @@ validate_plugin_id() {
catalog_json() { catalog_json() {
require_omarchy_path require_omarchy_path
require_command jq
omarchy-plugin-catalog omarchy-plugin-catalog
} }
@@ -224,8 +219,6 @@ USAGE
} }
clone_command() { clone_command() {
require_command jq
require_command python3
require_omarchy_path require_omarchy_path
local source_id="" local source_id=""
+38 -5
View File
@@ -21,15 +21,13 @@ if [[ ${1:-} == -h || ${1:-} == --help ]]; then
Usage: omarchy plugin validate <plugin-folder> Usage: omarchy plugin validate <plugin-folder>
Checks a plugin folder's manifest.json against the schema the shell enforces: Checks a plugin folder's manifest.json against the schema the shell enforces:
schemaVersion, required fields, safe relative entry points that exist, no schemaVersion, required fields, safe relative entry points that exist, an entry
symlinks, and an id that is not reserved. Exits 0 if valid — handy for plugin point for each kind that needs one, no symlinks, and an id that is not reserved.
authors before publishing. Exits 0 if valid — handy for plugin authors before publishing.
USAGE USAGE
exit 0 exit 0
fi fi
omarchy-cmd-present jq || fail "jq is required"
PLUGIN_DIR="${1:-}" PLUGIN_DIR="${1:-}"
[[ -n $PLUGIN_DIR && -d $PLUGIN_DIR ]] || fail "plugin folder not found: ${PLUGIN_DIR:-<none>}" [[ -n $PLUGIN_DIR && -d $PLUGIN_DIR ]] || fail "plugin folder not found: ${PLUGIN_DIR:-<none>}"
@@ -62,6 +60,19 @@ jq -e '(.kinds | type) == "array" and (.kinds | length) > 0' "$MANIFEST" >/dev/n
jq -e '(.entryPoints | type) == "object"' "$MANIFEST" >/dev/null 2>&1 \ jq -e '(.entryPoints | type) == "object"' "$MANIFEST" >/dev/null 2>&1 \
|| fail "'entryPoints' must be an object" || fail "'entryPoints' must be an object"
# A bar widget may declare where it should land when enabled without an
# explicit placement.
jq -e '
if ((.barWidget? | type) == "object" and (.barWidget | has("defaultSection"))) then
.barWidget.defaultSection as $section
| ($section | type) == "string"
and (["left", "center", "right"] | index($section)) != null
else
true
end
' "$MANIFEST" >/dev/null 2>&1 \
|| fail "'barWidget.defaultSection' must be left, center, or right"
# Read each entry point as a JSON-encoded string (one per line), then decode it, # Read each entry point as a JSON-encoded string (one per line), then decode it,
# so a value that itself contains a newline stays one literal path instead of # so a value that itself contains a newline stays one literal path instead of
# being split into fragments that each pass the checks. # being split into fragments that each pass the checks.
@@ -75,6 +86,28 @@ while IFS= read -r ep_json; do
[[ -f "$PLUGIN_DIR/$ep" ]] || fail "entry point file not found: '$ep'" [[ -f "$PLUGIN_DIR/$ep" ]] || fail "entry point file not found: '$ep'"
done < <(jq -c '.entryPoints | to_entries[] | .value' "$MANIFEST") done < <(jq -c '.entryPoints | to_entries[] | .value' "$MANIFEST")
# A kind is a promise to supply something to load, and the shell looks for that
# something under a fixed key: entryPoints.bar to draw a bar, entryPoints.menu
# to open a menu, and so on. Claiming a kind without its entry point is accepted
# everywhere else -- the bar falls back to the built-in, the widget is skipped --
# leaving a plugin that installs, enables, and does nothing, explained only by a
# line on the shell's console. Refuse it here, while there is still someone to
# tell. First-party plugins are held to the same table in plugins-test.sh; a kind
# not listed is left alone rather than guessed at.
for kind_entry_point in \
"bar:bar" \
"bar-widget:barWidget" \
"menu:menu" \
"overlay:overlay" \
"panel:panel" \
"service:service"; do
kind="${kind_entry_point%%:*}"
entry_point="${kind_entry_point##*:}"
jq -e --arg kind "$kind" '(.kinds | index($kind)) != null' "$MANIFEST" >/dev/null 2>&1 || continue
jq -e --arg ep "$entry_point" '.entryPoints | has($ep)' "$MANIFEST" >/dev/null 2>&1 \
|| fail "kind '$kind' requires an 'entryPoints.$entry_point' to load"
done
# Refuse any symlink anywhere inside the plugin folder. Symlinks could point a # Refuse any symlink anywhere inside the plugin folder. Symlinks could point a
# copied plugin back at arbitrary files on disk after it lands in the trusted # copied plugin back at arbitrary files on disk after it lands in the trusted
# plugins directory. The .git dir is skipped: installed plugins are git # plugins directory. The .git dir is skipped: installed plugins are git
+7
View File
@@ -154,6 +154,13 @@
"setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""}, "setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""},
"setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"}, "setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"},
"setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"}, "setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"},
"setup.plugin": {"icon":"󰐱","label":"Plugins","aliases":["plugin","plugins"]},
"setup.plugin.enable": {"icon":"󰄬","label":"Enable Plugin","action":"omarchy-menu-plugin enable"},
"setup.plugin.disable": {"icon":"󰅖","label":"Disable Plugin","action":"omarchy-menu-plugin disable"},
"setup.plugin.add": {"icon":"󰖟","label":"Add Plugin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-plugin add'"},
// Only a plugin you installed yourself can be deleted, so Remove stays
// hidden until there is one.
"setup.plugin.remove": {"icon":"󰭌","label":"Remove Plugin","when":"compgen -G \"$HOME/.config/omarchy/plugins/*/manifest.json\"","action":"omarchy-menu-plugin remove"},
// Install // Install
"install.package": {"icon":"󰣇","label":"Package","action":"xdg-terminal-exec --app-id=org.omarchy.terminal omarchy-pkg-install"}, "install.package": {"icon":"󰣇","label":"Package","action":"xdg-terminal-exec --app-id=org.omarchy.terminal omarchy-pkg-install"},
+16 -2
View File
@@ -52,6 +52,19 @@ omarchy plugin update --all # fetches, shows a diff, fast-forwards
omarchy plugin remove acme.weather omarchy plugin remove acme.weather
``` ```
**Setup Plugins** offers Enable, Disable, Add, and Remove. Enable and Disable
include built-ins as well as installed plugins. Remove is limited to installed
plugins, since a built-in has no checkout to delete. Add and Remove open a
terminal so their warning, confirmation, and output stay visible.
For a bar widget, on and off means its place in the bar. Everything else is
loaded by default when it is built in, so `shell.json` records only the
deviation: a third-party plugin you added under `plugins[]`, a built-in you
switched off under `disabledPlugins[]`. A full bar has no off state: enabling
one replaces the active bar, and it is therefore never offered under Disable.
Bar widgets may set `barWidget.defaultSection` to `left`, `center`, or `right`;
widgets that omit it default to `center`.
Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you
before cloning, plugins land disabled so you can review the code before before cloning, plugins land disabled so you can review the code before
`omarchy plugin enable`, and updates show a diff before touching anything. `omarchy plugin enable`, and updates show a diff before touching anything.
@@ -60,8 +73,9 @@ arguments — add `--yes` to skip every prompt (the path for scripts and agents)
You can still install by hand: drop a plugin into You can still install by hand: drop a plugin into
`~/.config/omarchy/plugins/<id>/`, run `omarchy plugin rescan`, then `~/.config/omarchy/plugins/<id>/`, run `omarchy plugin rescan`, then
`omarchy plugin enable <id>` (bar widgets also need `omarchy bar plugin add <id>`; `omarchy plugin enable <id>`. A bar widget starts in its declared default
full bar replacements are selected with `omarchy bar use <id>`). section and can be moved with `omarchy bar plugin move`; enabling a full bar
replaces the one in use.
The lower-level IPC methods remain available through `omarchy-shell shell ...`. The lower-level IPC methods remain available through `omarchy-shell shell ...`.
## IPC ## IPC
+9 -6
View File
@@ -62,6 +62,7 @@ shell should load it. Minimal example:
"displayName": "Cool clock", "displayName": "Cool clock",
"category": "Time", "category": "Time",
"allowMultiple": false, "allowMultiple": false,
"defaultSection": "left",
"defaults": { "format": "HH:mm" }, "defaults": { "format": "HH:mm" },
"schema": [ "schema": [
{ "key": "format", "type": "string", "label": "Format" } { "key": "format", "type": "string", "label": "Format" }
@@ -130,7 +131,9 @@ You can still drop a plugin in without git:
1. Put it in `~/.config/omarchy/plugins/<plugin-id>/` with a `manifest.json` 1. Put it in `~/.config/omarchy/plugins/<plugin-id>/` with a `manifest.json`
plus the QML referenced from its `entryPoints`. plus the QML referenced from its `entryPoints`.
2. `omarchy plugin rescan`. 2. `omarchy plugin rescan`.
3. `omarchy plugin enable <id>` (bar widgets also need `omarchy bar plugin add <id>`; full bar replacements are selected with `omarchy bar use <id>`). 3. `omarchy plugin enable <id>`. Bar widgets start in
`barWidget.defaultSection`, or in the center when it is omitted, and can be
moved with `omarchy bar plugin move`; a full bar replaces the one in use.
The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`, The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`,
`omarchy-shell shell setPluginEnabled <id> true`, and `omarchy-shell shell listPlugins`. `omarchy-shell shell setPluginEnabled <id> true`, and `omarchy-shell shell listPlugins`.
@@ -147,10 +150,10 @@ omarchy plugin clone # interactive source/name picker
omarchy plugin edit local.clock # cd into the plugin directory omarchy plugin edit local.clock # cd into the plugin directory
``` ```
First-party plugins under `shell/plugins/` First-party plugins under `shell/plugins/` are discovered the same way and load
are discovered the same way and cannot be disabled, except that the built-in by default. Disabling a non-widget records it in `disabledPlugins[]`; disabling
bar option can become inactive while a third-party `kind: "bar"` plugin is the a widget removes it from the bar layout while leaving its component available
selected bar. to add again. A full bar has no off state and is replaced by enabling another.
## IPC contract ## IPC contract
@@ -170,7 +173,7 @@ running a separate Quickshell instance.
| `rescanPlugins` | — | re-walk plugin dirs and hot-reload plugin code | | `rescanPlugins` | — | re-walk plugin dirs and hot-reload plugin code |
| `reloadConfig` | `ok` | reload `~/.config/omarchy/shell.json` | | `reloadConfig` | `ok` | reload `~/.config/omarchy/shell.json` |
| `setPluginEnabled <id> <enabled>` | `ok` / `unknown` | flip the persisted enabled bit (see note) | | `setPluginEnabled <id> <enabled>` | `ok` / `unknown` | flip the persisted enabled bit (see note) |
| `listPlugins` | JSON | every discovered plugin (id, name, kinds, enabled) | | `listPlugins` | JSON | every discovered plugin, sorted by name |
Direct invocation: Direct invocation:
+57 -55
View File
@@ -75,6 +75,7 @@ Item {
property var activePopout: null property var activePopout: null
property var barDragSource: null property var barDragSource: null
property var barDragTarget: null property var barDragTarget: null
property var barDragTargetGeometry: null
property bool barDragAfter: false property bool barDragAfter: false
property var barDragWindow: null property var barDragWindow: null
property var barDragScreen: null property var barDragScreen: null
@@ -180,6 +181,7 @@ Item {
barDragScreen = null barDragScreen = null
barDragImageUrl = "" barDragImageUrl = ""
barDragTarget = null barDragTarget = null
barDragTargetGeometry = null
barDragAfter = false barDragAfter = false
barDragSceneX = 0 barDragSceneX = 0
barDragSceneY = 0 barDragSceneY = 0
@@ -206,6 +208,33 @@ Item {
return windowScreenPoint(scenePoint, barDragWindow) return windowScreenPoint(scenePoint, barDragWindow)
} }
function dropMarkerRect(slot, after) {
if (!slot) return null
try {
var slotPoint = slot.mapToItem(null, 0, 0)
var screenPoint = barDragScreenPoint(slotPoint)
var thickness = Style.spacing.xs
if (vertical) {
return {
x: screenPoint.x,
y: screenPoint.y + (after ? slot.height : 0) - thickness / 2,
width: slot.width,
height: thickness
}
}
return {
x: screenPoint.x + (after ? slot.width : 0) - thickness / 2,
y: screenPoint.y,
width: thickness,
height: slot.height
}
} catch (e) {
return null
}
}
// Split the screen along its diagonals (in normalized space, so widescreens // Split the screen along its diagonals (in normalized space, so widescreens
// don't bias toward left/right): whichever triangle holds the cursor names // don't bias toward left/right): whichever triangle holds the cursor names
// the candidate edge. // the candidate edge.
@@ -577,6 +606,14 @@ Item {
function moduleDropAtScene(scenePoint, sourceSlot) { function moduleDropAtScene(scenePoint, sourceSlot) {
var sourceWindow = root.slotWindow(sourceSlot) || root.barDragWindow var sourceWindow = root.slotWindow(sourceSlot) || root.barDragWindow
if (sourceWindow && sourceWindow.contentItem) {
var barPoint = sourceWindow.contentItem.mapFromItem(null, scenePoint.x, scenePoint.y)
if (barPoint.x < 0 || barPoint.x > sourceWindow.contentItem.width ||
barPoint.y < 0 || barPoint.y > sourceWindow.contentItem.height)
return null
}
var candidates = []
for (var i = 0; i < moduleSlots.length; i++) { for (var i = 0; i < moduleSlots.length; i++) {
var slot = moduleSlots[i] var slot = moduleSlots[i]
if (!slot || slot === sourceSlot || !slot.visible || slot.width <= 0 || slot.height <= 0) continue if (!slot || slot === sourceSlot || !slot.visible || slot.width <= 0 || slot.height <= 0) continue
@@ -588,16 +625,16 @@ Item {
} catch (e) { } catch (e) {
} }
if (scenePoint.x >= slotPoint.x && scenePoint.x <= slotPoint.x + slot.width && candidates.push({
scenePoint.y >= slotPoint.y && scenePoint.y <= slotPoint.y + slot.height) {
return {
slot: slot, slot: slot,
after: root.vertical ? scenePoint.y > slotPoint.y + slot.height / 2 : scenePoint.x > slotPoint.x + slot.width / 2 x: slotPoint.x,
} y: slotPoint.y,
} width: slot.width,
height: slot.height
})
} }
return null return BarModel.nearestDropTarget(candidates, scenePoint, root.vertical)
} }
function visibleModuleSlot(region, name, sourceSlot) { function visibleModuleSlot(region, name, sourceSlot) {
@@ -1061,6 +1098,18 @@ Item {
opacity: 0.84 opacity: 0.84
} }
} }
Rectangle {
readonly property var targetRect: root.barDragTargetGeometry
visible: ghostWindow.active && targetRect !== null
x: targetRect ? Math.round(targetRect.x) : 0
y: targetRect ? Math.round(targetRect.y) : 0
width: targetRect ? targetRect.width : 0
height: targetRect ? targetRect.height : 0
color: Color.accent
radius: Math.min(width, height) / 2
}
} }
component BarMoveGhostPanel: PanelWindow { component BarMoveGhostPanel: PanelWindow {
@@ -1494,54 +1543,6 @@ Item {
} }
} }
Rectangle {
visible: !root.vertical && root.barDragTarget === slot && !root.barDragAfter
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
}
width: 2
color: root.barForeground
opacity: 0.9
}
Rectangle {
visible: !root.vertical && root.barDragTarget === slot && root.barDragAfter
anchors {
right: parent.right
top: parent.top
bottom: parent.bottom
}
width: 2
color: root.barForeground
opacity: 0.9
}
Rectangle {
visible: root.vertical && root.barDragTarget === slot && !root.barDragAfter
anchors {
left: parent.left
right: parent.right
top: parent.top
}
height: 2
color: root.barForeground
opacity: 0.9
}
Rectangle {
visible: root.vertical && root.barDragTarget === slot && root.barDragAfter
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
}
height: 2
color: root.barForeground
opacity: 0.9
}
MouseArea { MouseArea {
id: modulePointer id: modulePointer
@@ -1597,6 +1598,7 @@ Item {
var drop = root.moduleDropAtScene(scenePoint, slot) var drop = root.moduleDropAtScene(scenePoint, slot)
root.barDragTarget = drop ? drop.slot : null root.barDragTarget = drop ? drop.slot : null
root.barDragAfter = drop ? drop.after : false root.barDragAfter = drop ? drop.after : false
root.barDragTargetGeometry = drop ? root.dropMarkerRect(drop.slot, drop.after) : null
} }
} }
+32
View File
@@ -118,10 +118,42 @@ function pickDrawnSlot(slots) {
return placeholder return placeholder
} }
// Resolve a pointer anywhere along the bar to the closest insertion edge.
// Requiring the pointer to sit inside another widget makes the empty space
// around a centered group a dead zone, even though it visually reads as the
// most natural place to drop.
function nearestDropTarget(candidates, point, vertical) {
var rows = Array.isArray(candidates) ? candidates : []
var axis = vertical ? Number(point && point.y) : Number(point && point.x)
if (!isFinite(axis)) return null
var best = null
var bestDistance = Infinity
for (var i = 0; i < rows.length; i++) {
var row = rows[i]
if (!row || !row.slot) continue
var start = Number(vertical ? row.y : row.x)
var size = Number(vertical ? row.height : row.width)
if (!isFinite(start) || !isFinite(size) || size <= 0) continue
var beforeDistance = Math.abs(axis - start)
var afterDistance = Math.abs(axis - (start + size))
var after = afterDistance < beforeDistance
var distance = after ? afterDistance : beforeDistance
if (distance < bestDistance) {
best = { slot: row.slot, after: after }
bestDistance = distance
}
}
return best
}
if (typeof module !== "undefined") { if (typeof module !== "undefined") {
module.exports = { module.exports = {
isDrawnSlot: isDrawnSlot, isDrawnSlot: isDrawnSlot,
pickDrawnSlot: pickDrawnSlot, pickDrawnSlot: pickDrawnSlot,
nearestDropTarget: nearestDropTarget,
normalizePosition: normalizePosition, normalizePosition: normalizePosition,
entrySettings: entrySettings, entrySettings: entrySettings,
entryId: entryId, entryId: entryId,
@@ -15,6 +15,7 @@
"displayName": "Active window", "displayName": "Active window",
"description": "Title of the focused window", "description": "Title of the focused window",
"category": "Compositor", "category": "Compositor",
"allowMultiple": false "allowMultiple": false,
"defaultSection": "left"
} }
} }
+35 -9
View File
@@ -222,17 +222,20 @@ Item {
// Each known provider is a tiny bash one-liner that enumerates a list and // Each known provider is a tiny bash one-liner that enumerates a list and
// emits one tab-delimited row per item: `label\tvalue\tcurrent`. The shell // emits one tab-delimited row per item: `label\tvalue\tcurrent`. The shell
// turns those into menu items children of `menuId`. // turns those into menu items children of `menuId`. A `volatile` provider
// re-runs every time its submenu is entered, so a font installed since the
// shell started shows up without restarting it.
readonly property var providers: ({ readonly property var providers: ({
"fonts": { "fonts": {
script: "current=$(omarchy-font-current 2>/dev/null); omarchy-font-list 2>/dev/null | while read -r f; do [[ -z $f ]] && continue; printf '%s\\t%s\\t%s\\n' \"$f\" \"$f\" \"$current\"; done", script: "current=$(omarchy-font-current 2>/dev/null); omarchy-font-list 2>/dev/null | while read -r f; do [[ -z $f ]] && continue; printf '%s\\t%s\\t%s\\n' \"$f\" \"$f\" \"$current\"; done",
icon: "", icon: "",
actionFor: function(value) { return "omarchy-font-set '" + value.replace(/'/g, "'\\''") + "'" } volatile: true,
actionFor: function(value) { return "omarchy-font-set " + Util.shellQuote(value) }
}, },
"power-profiles": { "power-profiles": {
script: "current=$(powerprofilesctl get 2>/dev/null); omarchy-powerprofiles-list 2>/dev/null | while read -r p; do [[ -z $p ]] && continue; printf '%s\\t%s\\t%s\\n' \"$p\" \"$p\" \"$current\"; done", script: "current=$(powerprofilesctl get 2>/dev/null); omarchy-powerprofiles-list 2>/dev/null | while read -r p; do [[ -z $p ]] && continue; printf '%s\\t%s\\t%s\\n' \"$p\" \"$p\" \"$current\"; done",
icon: "\udb81\udc0b", icon: "\udb81\udc0b",
actionFor: function(value) { return "omarchy-powerprofiles-set autodetect '" + value.replace(/'/g, "'\\''") + "'" } actionFor: function(value) { return "omarchy-powerprofiles-set autodetect " + Util.shellQuote(value) }
} }
}) })
@@ -308,6 +311,7 @@ Item {
if (!spec) return if (!spec) return
var lines = String(rows || "").split("\n") var lines = String(rows || "").split("\n")
var providerRows = [] var providerRows = []
var takenIds = ({})
for (var i = 0; i < lines.length; i++) { for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim() var line = lines[i].trim()
if (!line) continue if (!line) continue
@@ -316,8 +320,15 @@ Item {
var value = parts[1] || parts[0] || "" var value = parts[1] || parts[0] || ""
var current = parts[2] || "" var current = parts[2] || ""
if (!label) continue if (!label) continue
// Distinct values can slugify alike — Fira Code and Fira-Code both give
// fira-code — and a repeated id is dropped, which would silently lose a
// row from the list. Nudge it until it is the row's own.
var rowId = menuId + "." + root.slugify(value)
while (takenIds[rowId]) rowId += "-"
takenIds[rowId] = true
providerRows.push({ providerRows.push({
id: menuId + "." + root.slugify(value), id: rowId,
parent: menuId, parent: menuId,
kind: "action", kind: "action",
icon: (value === current) ? "✓" : (spec.icon || ""), icon: (value === current) ? "✓" : (spec.icon || ""),
@@ -333,11 +344,10 @@ Item {
order: 0 order: 0
}) })
} }
var changed = providerRows.length > 0 var merged = MenuModel.swapProviderRows(root.items, root.itemOrder, menuId, providerRows)
var merged = MenuModel.mergeRowsById(root.items, root.itemOrder, providerRows)
root.items = merged.items root.items = merged.items
root.itemOrder = merged.itemOrder root.itemOrder = merged.itemOrder
if (changed && root.opened) root.rebuildDisplay() if (root.opened) root.rebuildDisplay()
} }
function startNextProvider() { function startNextProvider() {
@@ -353,6 +363,15 @@ Item {
} }
} }
// Entering a submenu is the one moment a volatile list is worth paying for
// again: it may have been reshaped by the last pick from it. Search doesn't
// invalidate, or every keystroke would restart the same enumeration.
function invalidateVolatileProvider(id) {
var entry = root.item(id)
var spec = entry && entry.provider ? root.providers[entry.provider] : null
if (spec && spec.volatile) root.providersLoaded[id] = false
}
function loadProviderForMenu(id) { function loadProviderForMenu(id) {
var entry = root.item(id) var entry = root.item(id)
if (!entry || !entry.provider || root.providersLoaded[id]) return if (!entry || !entry.provider || root.providersLoaded[id]) return
@@ -458,12 +477,17 @@ Item {
var query = root.filterText.trim().toLowerCase() var query = root.filterText.trim().toLowerCase()
for (var i = 0; i < root.dmenuOptions.length; i++) { for (var i = 0; i < root.dmenuOptions.length; i++) {
var label = String(root.dmenuOptions[i] || "") // An option may lead with an icon, as "<glyph>\t<label>". Only the label
// is filtered against and handed back, so the caller never sees a glyph
// it has to strip off the selection.
var parts = String(root.dmenuOptions[i] || "").split("\t")
var icon = parts.length > 1 ? parts.shift() : ""
var label = parts.join("\t")
if (query && label.toLowerCase().indexOf(query) < 0) continue if (query && label.toLowerCase().indexOf(query) < 0) continue
displayModel.append({ displayModel.append({
itemId: "dmenu." + i, itemId: "dmenu." + i,
kind: "dmenu", kind: "dmenu",
icon: "", icon: icon,
iconFont: "", iconFont: "",
appIcon: "", appIcon: "",
appId: "", appId: "",
@@ -605,6 +629,7 @@ Item {
if (fromPointer) pointerGate.allowInitialSample() if (fromPointer) pointerGate.allowInitialSample()
else root.disarmPointer() else root.disarmPointer()
root.rebuildDisplay() root.rebuildDisplay()
root.invalidateVolatileProvider(id)
root.loadProviderForMenu(id) root.loadProviderForMenu(id)
} }
@@ -715,6 +740,7 @@ Item {
root.evaluateGuards() root.evaluateGuards()
opened = true opened = true
rebuildDisplay() rebuildDisplay()
invalidateVolatileProvider(activeMenu)
loadProviderForMenu(activeMenu) loadProviderForMenu(activeMenu)
// The shell may start before first-install packages have finished placing // The shell may start before first-install packages have finished placing
// their icons. Refresh here even when the desktop entry list did not change. // their icons. Refresh here even when the desktop entry list did not change.
+21 -11
View File
@@ -132,22 +132,32 @@ function mergeAppRows(items, itemOrder, appRows) {
return { items: nextItems, itemOrder: nextOrder } return { items: nextItems, itemOrder: nextOrder }
} }
// Adds or replaces rows by id, leaving every other item untouched. Used by the // Swaps the rows one provider contributed, leaving every other item untouched.
// bash-backed providers, which contribute rows to one submenu at a time. // Rows carry the id of the submenu that produced them, so a provider that runs
function mergeRowsById(items, itemOrder, rows) { // again drops its previous batch — a plugin that was just enabled disappears
// from the Enable list — without disturbing static children declared in JSONC.
function swapProviderRows(items, itemOrder, menuId, rows) {
var source = items || ({}) var source = items || ({})
var order = Array.isArray(itemOrder) ? itemOrder : []
var incoming = Array.isArray(rows) ? rows : [] var incoming = Array.isArray(rows) ? rows : []
var nextItems = ({}) var nextItems = ({})
var nextOrder = (Array.isArray(itemOrder) ? itemOrder : []).slice() var nextOrder = []
for (var k in source) nextItems[k] = source[k] for (var i = 0; i < order.length; i++) {
var id = order[i]
var existing = source[id]
if (!existing || existing.providerMenu === menuId) continue
nextItems[id] = existing
nextOrder.push(id)
}
for (var i = 0; i < incoming.length; i++) { for (var j = 0; j < incoming.length; j++) {
var row = incoming[i] var row = incoming[j]
if (!row || !row.id) continue if (!row || !row.id || nextItems[row.id]) continue
if (!nextItems[row.id]) nextOrder.push(row.id) row.providerMenu = menuId
row.order = nextOrder.length
nextItems[row.id] = row nextItems[row.id] = row
row.order = nextOrder.indexOf(row.id) nextOrder.push(row.id)
} }
return { items: nextItems, itemOrder: nextOrder } return { items: nextItems, itemOrder: nextOrder }
@@ -345,7 +355,7 @@ if (typeof module !== "undefined") {
parseMenuJsonc: parseMenuJsonc, parseMenuJsonc: parseMenuJsonc,
mergeMenuSources: mergeMenuSources, mergeMenuSources: mergeMenuSources,
mergeAppRows: mergeAppRows, mergeAppRows: mergeAppRows,
mergeRowsById: mergeRowsById, swapProviderRows: swapProviderRows,
item: item, item: item,
slugify: slugify, slugify: slugify,
depthFor: depthFor, depthFor: depthFor,
@@ -17,6 +17,7 @@
"description": "Log in to Dropbox, view storage usage, and open recent synced files.", "description": "Log in to Dropbox, view storage usage, and open recent synced files.",
"category": "Files", "category": "Files",
"allowMultiple": false, "allowMultiple": false,
"defaultSection": "right",
"defaults": { "defaults": {
"refreshIntervalSec": 60 "refreshIntervalSec": 60
}, },
+60 -13
View File
@@ -67,6 +67,14 @@ QtObject {
console.warn("PluginRegistry: entryPoints must be an object at " + sourcePath) console.warn("PluginRegistry: entryPoints must be an object at " + sourcePath)
return null return null
} }
if (manifest.barWidget !== undefined && Util.isPlainObject(manifest.barWidget)
&& manifest.barWidget.defaultSection !== undefined) {
var defaultSection = String(manifest.barWidget.defaultSection)
if (["left", "center", "right"].indexOf(defaultSection) === -1) {
console.warn("PluginRegistry: invalid barWidget.defaultSection at " + sourcePath)
return null
}
}
// Every entry point must be a relative path inside the plugin's source // Every entry point must be a relative path inside the plugin's source
// directory. Reject the whole manifest if anything looks like an attempt // directory. Reject the whole manifest if anything looks like an attempt
// to escape the plugin's sandbox. // to escape the plugin's sandbox.
@@ -108,7 +116,8 @@ QtObject {
// - first-party non-bar plugins are shell infrastructure (settings, // - first-party non-bar plugins are shell infrastructure (settings,
// image-picker, ...). Requiring users to add them to plugins[] just to // image-picker, ...). Requiring users to add them to plugins[] just to
// summon them was a footgun: a stock shell.json with `plugins: []` would // summon them was a footgun: a stock shell.json with `plugins: []` would
// silently make `omarchy launch bar-settings` a no-op. // silently make `omarchy launch bar-settings` a no-op. Turning one off
// is therefore recorded the other way round, in `disabledPlugins[]`.
function isEnabled(id) { function isEnabled(id) {
var key = String(id) var key = String(id)
var manifest = installedPlugins[key] var manifest = installedPlugins[key]
@@ -121,11 +130,33 @@ QtObject {
if (!selectedBar) selectedBar = "omarchy.bar" if (!selectedBar) selectedBar = "omarchy.bar"
return selectedBar === key return selectedBar === key
} }
if (isDisabled(config, key)) return false
if (manifest.__isFirstParty) return true if (manifest.__isFirstParty) return true
} }
return findEntryLocation(config, key).found return findEntryLocation(config, key).found
} }
function isDisabled(config, id) {
return Util.isPlainObject(config) && Array.isArray(config.disabledPlugins)
&& config.disabledPlugins.indexOf(Util.canonicalWidgetId(String(id))) !== -1
}
// A bar widget is on when it sits in the bar, whoever shipped it. That is a
// different question from isEnabled(), which decides whether the widget's
// component is loaded at all — a built-in stays loadable so it can be put
// back, and so a plugin that is both a widget and a menu (omarchy.menu)
// cannot be locked out of the shell by taking its button off the bar.
function inBar(id) {
var config = shellConfigProvider ? shellConfigProvider() : null
return findEntryLocation(config, id).kind === "bar"
}
function defaultBarWidgetSection(manifest) {
var metadata = manifest && Util.isPlainObject(manifest.barWidget) ? manifest.barWidget : null
var section = metadata ? String(metadata.defaultSection || "") : ""
return ["left", "center", "right"].indexOf(section) !== -1 ? section : "center"
}
function findEntryLocation(config, id) { function findEntryLocation(config, id) {
if (!Util.isPlainObject(config)) return { found: false } if (!Util.isPlainObject(config)) return { found: false }
var key = Util.canonicalWidgetId(String(id)) var key = Util.canonicalWidgetId(String(id))
@@ -151,9 +182,11 @@ QtObject {
return { found: false } return { found: false }
} }
// Adding a plugin places it in the right section based on its declared // Bar widgets use the default section declared in their manifest, falling
// kinds. Bar widgets default to the right section; panels/overlays/menus/ // back to center. Panels/overlays/menus/services go into the plugins[] array.
// services go into the plugins[] array. // Built-ins are already loaded, so shell.json only ever records the
// deviation: an added third-party plugin in plugins[], a switched-off
// built-in in disabledPlugins[].
function setEnabled(id, value) { function setEnabled(id, value) {
var key = Util.canonicalWidgetId(String(id)) var key = Util.canonicalWidgetId(String(id))
if (!shellConfigMutator) { if (!shellConfigMutator) {
@@ -182,21 +215,35 @@ QtObject {
return return
} }
var isFirstParty = manifest && manifest.__isFirstParty
var location = findEntryLocation(config, key) var location = findEntryLocation(config, key)
if (value && !location.found) {
if (value) {
// Leave shell.json without the key once nothing is switched off, so a
// config that never disabled anything reads as it always did.
if (Array.isArray(config.disabledPlugins)) {
config.disabledPlugins = config.disabledPlugins.filter(function(entry) { return entry !== key })
if (config.disabledPlugins.length === 0) delete config.disabledPlugins
}
if (location.found) return
var entry = { id: key } var entry = { id: key }
if (isBarWidget) { if (isBarWidget) {
if (!Array.isArray(config.bar.layout.right)) config.bar.layout.right = [] var section = defaultBarWidgetSection(manifest)
config.bar.layout.right.push(entry) if (!Array.isArray(config.bar.layout[section])) config.bar.layout[section] = []
} else { config.bar.layout[section].push(entry)
} else if (!isFirstParty) {
config.plugins.push(entry) config.plugins.push(entry)
} }
} else if (!value && location.found) { return
if (location.kind === "bar") {
config.bar.layout[location.section].splice(location.index, 1)
} else if (location.kind === "plugin") {
config.plugins.splice(location.index, 1)
} }
if (location.kind === "bar") config.bar.layout[location.section].splice(location.index, 1)
else if (location.kind === "plugin") config.plugins.splice(location.index, 1)
// Dropping the layout entry is the whole story for a widget. Anything
// else built-in loads by default, so switching it off has to be stated.
if (isFirstParty && !isBarWidget && !isDisabled(config, key)) {
if (!Array.isArray(config.disabledPlugins)) config.disabledPlugins = []
config.disabledPlugins.push(key)
} }
}) })
registryRevision++ registryRevision++
+18 -1
View File
@@ -903,16 +903,33 @@ ShellRoot {
for (var id in plugins) { for (var id in plugins) {
var kinds = plugins[id].kinds || [] var kinds = plugins[id].kinds || []
var isBarOption = Array.isArray(kinds) && kinds.indexOf("bar") !== -1 var isBarOption = Array.isArray(kinds) && kinds.indexOf("bar") !== -1
var isBarWidget = Array.isArray(kinds) && kinds.indexOf("bar-widget") !== -1
var active = isBarOption && shell.isActiveBarOption(id) var active = isBarOption && shell.isActiveBarOption(id)
out.push({ out.push({
id: id, id: id,
name: plugins[id].name, name: plugins[id].name,
kinds: kinds, kinds: kinds,
enabled: isBarOption ? active : shell.pluginRegistry.isEnabled(id), // What `omarchy plugin enable/disable` toggles: for a widget that is
// its place in the bar, not whether its component is loadable.
enabled: isBarOption ? active
: (isBarWidget ? shell.pluginRegistry.inBar(id) : shell.pluginRegistry.isEnabled(id)),
active: active, active: active,
// A bar has no off, only a successor: you leave one by enabling
// another, so there is nothing for disable to do to it. Said here so
// that a caller offering the verbs does not have to read kinds and
// work it out again.
canDisable: !isBarOption,
firstParty: !!plugins[id].__isFirstParty firstParty: !!plugins[id].__isFirstParty
}) })
} }
// Consumers should not each invent their own presentation order.
out.sort(function(left, right) {
var leftName = String(left.name || left.id)
var rightName = String(right.name || right.id)
if (leftName < rightName) return -1
if (leftName > rightName) return 1
return String(left.id).localeCompare(String(right.id))
})
return JSON.stringify(out) return JSON.stringify(out)
} }
+47
View File
@@ -44,6 +44,53 @@ assert(
'bar routes panels through the drawn-slot picker' 'bar routes panels through the drawn-slot picker'
) )
const clockSlot = { id: 'clock' }
const traySlot = { id: 'tray' }
const horizontalTargets = [
{ slot: clockSlot, x: 100, y: 0, width: 100, height: 26 },
{ slot: traySlot, x: 500, y: 0, width: 50, height: 26 }
]
assertDeepEqual(
bar.nearestDropTarget(horizontalTargets, { x: 240, y: 13 }, false),
{ slot: clockSlot, after: true },
'bar resolves free space beside a widget to its nearest insertion edge'
)
assertDeepEqual(
bar.nearestDropTarget(horizontalTargets, { x: 460, y: 13 }, false),
{ slot: traySlot, after: false },
'bar resolves free space before a widget to its nearest insertion edge'
)
assertDeepEqual(
bar.nearestDropTarget(horizontalTargets, { x: 125, y: 13 }, false),
{ slot: clockSlot, after: false },
'bar resolves the first half of a widget before it'
)
assertDeepEqual(
bar.nearestDropTarget(horizontalTargets, { x: 175, y: 13 }, false),
{ slot: clockSlot, after: true },
'bar resolves the second half of a widget after it'
)
assertDeepEqual(
bar.nearestDropTarget([
{ slot: clockSlot, x: 0, y: 100, width: 26, height: 80 }
], { x: 13, y: 220 }, true),
{ slot: clockSlot, after: true },
'vertical bars resolve free space along their vertical axis'
)
assertEqual(bar.nearestDropTarget([], { x: 10, y: 10 }, false), null, 'bar reports no insertion edge without targets')
assert(
/contentItem\.mapFromItem\(null, scenePoint\.x, scenePoint\.y\)[\s\S]*?return null/.test(barSource),
'bar rejects free-space drops after the pointer leaves the bar'
)
assert(
/BarModel\.nearestDropTarget\(candidates, scenePoint, root\.vertical\)/.test(barSource),
'bar uses nearest insertion targeting for widget and free-space drops'
)
assert(
/component DragGhostPanel:[\s\S]*?readonly property var targetRect: root\.barDragTargetGeometry[\s\S]*?color: Color\.accent/.test(barSource),
'bar draws the insertion marker above the bar in the drag overlay'
)
// The open-panel mark sits on the module's desktop-facing edge at every // The open-panel mark sits on the module's desktop-facing edge at every
// position: under a top bar, over a bottom one, inward from left and right. // position: under a top bar, over a bottom one, inward from left and right.
const indicator = barSource.slice(barSource.indexOf('id: openPanelIndicator'), barSource.indexOf('id: openPanelIndicator') + 1600) const indicator = barSource.slice(barSource.indexOf('id: openPanelIndicator'), barSource.indexOf('id: openPanelIndicator') + 1600)
+30 -14
View File
@@ -253,28 +253,44 @@ pass "shell config resets to built-in bar option"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.tailscale HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.tailscale
jq -e ' jq -e '
def ids: map(.id // .); def ids: map(.id // .);
.bar.layout.right | ids == ["omarchy.tray", "omarchy.tailscale", "omarchy.bluetooth"] .bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "omarchy.tailscale"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends widgets to right by default" pass "shell config defaults widgets without a section to center"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.active-window left HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.active-window
jq -e ' jq -e '
def ids: map(.id // .); def ids: map(.id // .);
.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "omarchy.active-window"] .bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "omarchy.active-window"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends left widgets after workspaces" pass "shell config uses a widget's default section"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.media
jq -e '
def ids: map(.id // .);
.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "omarchy.media", "omarchy.tailscale"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin remove omarchy.media >/dev/null
pass "shell config honors a center default section"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.dropbox
jq -e '
def ids: map(.id // .);
.bar.layout.right | ids == ["omarchy.tray", "omarchy.dropbox", "omarchy.bluetooth"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin remove omarchy.dropbox >/dev/null
pass "shell config honors a right default section"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.system-update center HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.system-update center
jq -e ' jq -e '
def ids: map(.id // .); def ids: map(.id // .);
.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "omarchy.system-update"] .bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "omarchy.system-update", "omarchy.tailscale"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends center widgets after weather" pass "shell config appends center widgets after weather"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.microphone right HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.microphone right
jq -e ' jq -e '
def ids: map(.id // .); def ids: map(.id // .);
.bar.layout.right | ids == ["omarchy.tray", "omarchy.microphone", "omarchy.tailscale", "omarchy.bluetooth"] .bar.layout.right | ids == ["omarchy.tray", "omarchy.microphone", "omarchy.bluetooth"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config moves existing widgets without duplicates" pass "shell config moves existing widgets without duplicates"
@@ -282,7 +298,7 @@ HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin move omarchy.active-
jq -e ' jq -e '
def ids: map(.id // .); def ids: map(.id // .);
(.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces"]) and (.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces"]) and
(.bar.layout.right | ids == ["omarchy.tray", "omarchy.active-window", "omarchy.microphone", "omarchy.tailscale", "omarchy.bluetooth"]) (.bar.layout.right | ids == ["omarchy.tray", "omarchy.active-window", "omarchy.microphone", "omarchy.bluetooth"])
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "bar plugin move accepts a positional target section" pass "bar plugin move accepts a positional target section"
@@ -290,7 +306,7 @@ HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin move omarchy.active-
jq -e ' jq -e '
def ids: map(.id // .); def ids: map(.id // .);
(.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "omarchy.active-window"]) and (.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "omarchy.active-window"]) and
(.bar.layout.right | ids == ["omarchy.tray", "omarchy.microphone", "omarchy.tailscale", "omarchy.bluetooth"]) (.bar.layout.right | ids == ["omarchy.tray", "omarchy.microphone", "omarchy.bluetooth"])
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "bar plugin move can restore a widget with positional syntax" pass "bar plugin move can restore a widget with positional syntax"
@@ -327,15 +343,15 @@ HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin drop omarchy.active-
jq -e ' jq -e '
def ids: map(.id // .); def ids: map(.id // .);
(.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces"]) and (.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces"]) and
(.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "omarchy.system-update"]) and (.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "omarchy.system-update", "omarchy.tailscale"]) and
(.bar.layout.right | ids == ["omarchy.tray", "omarchy.microphone", "omarchy.tailscale", "omarchy.bluetooth"]) (.bar.layout.right | ids == ["omarchy.tray", "omarchy.microphone", "omarchy.bluetooth"])
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config drops widgets from any section" pass "shell config drops widgets from any section"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin remove omarchy.system-update HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin remove omarchy.system-update
jq -e ' jq -e '
def ids: map(.id // .); def ids: map(.id // .);
.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather"] .bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "omarchy.tailscale"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config removes widgets with remove alias" pass "shell config removes widgets with remove alias"
@@ -415,7 +431,7 @@ HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$mock_path" OMARCHY_TEST_DROPBOX=
jq -e ' jq -e '
def ids: map(.id // .); def ids: map(.id // .);
(.bar.layout.right | ids | index("omarchy.dropbox") != null) and (.bar.layout.right | ids | index("omarchy.dropbox") != null) and
(.bar.layout.right | ids | index("omarchy.tailscale") != null) (.bar.layout.center | ids | index("omarchy.tailscale") != null)
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "bar defaults adds widgets for running optional services" pass "bar defaults adds widgets for running optional services"
@@ -423,7 +439,7 @@ HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$mock_path" OMARCHY_TEST_DROPBOX=
jq -e ' jq -e '
def ids: map(.id // .); def ids: map(.id // .);
(.bar.layout.right | ids | index("omarchy.dropbox") == null) and (.bar.layout.right | ids | index("omarchy.dropbox") == null) and
(.bar.layout.right | ids | index("omarchy.tailscale") == null) (.bar.layout.center | ids | index("omarchy.tailscale") == null)
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell refresh keeps optional service widgets absent when services are unavailable" pass "shell refresh keeps optional service widgets absent when services are unavailable"
@@ -431,7 +447,7 @@ HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$mock_path" OMARCHY_TEST_DROPBOX=
jq -e ' jq -e '
def ids: map(.id // .); def ids: map(.id // .);
(.bar.layout.right | ids | index("omarchy.dropbox") != null) and (.bar.layout.right | ids | index("omarchy.dropbox") != null) and
(.bar.layout.right | ids | index("omarchy.tailscale") != null) (.bar.layout.center | ids | index("omarchy.tailscale") != null)
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
[[ -f $TMPDIR/home/.local/state/omarchy/restart-shell-called ]] || fail "shell refresh restarts shell" [[ -f $TMPDIR/home/.local/state/omarchy/restart-shell-called ]] || fail "shell refresh restarts shell"
pass "shell refresh adds optional service widgets when services are available" pass "shell refresh adds optional service widgets when services are available"
@@ -50,8 +50,8 @@ ShellRoot {
} }
} }
function manifest(id, kinds, entryPoints) { function manifest(id, kinds, entryPoints, barWidget) {
return { var value = {
schemaVersion: 1, schemaVersion: 1,
id: id, id: id,
name: id, name: id,
@@ -59,6 +59,8 @@ ShellRoot {
kinds: kinds, kinds: kinds,
entryPoints: entryPoints entryPoints: entryPoints
} }
if (barWidget) value.barWidget = barWidget
return value
} }
function block(kind, source, payload) { function block(kind, source, payload) {
@@ -81,12 +83,13 @@ ShellRoot {
scan += block("firstparty", "/first/bar", manifest("omarchy.bar", ["bar"], { bar: "Bar.qml" })) scan += block("firstparty", "/first/bar", manifest("omarchy.bar", ["bar"], { bar: "Bar.qml" }))
scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" })) scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" }))
scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" }))
scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" })) scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" }, { defaultSection: "left" }))
scan += block("thirdparty", "/third/bar", manifest("third.bar", ["bar"], { bar: "Bar.qml" })) scan += block("thirdparty", "/third/bar", manifest("third.bar", ["bar"], { bar: "Bar.qml" }))
scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" }))
scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" }))
scan += block("thirdparty", "/third/unsafe", manifest("third.unsafe", ["panel"], { panel: "../Panel.qml" })) scan += block("thirdparty", "/third/unsafe", manifest("third.unsafe", ["panel"], { panel: "../Panel.qml" }))
scan += block("thirdparty", "/third/missing", { schemaVersion: 1, id: "third.missing", name: "missing", version: "1.0.0", kinds: ["panel"] }) scan += block("thirdparty", "/third/missing", { schemaVersion: 1, id: "third.missing", name: "missing", version: "1.0.0", kinds: ["panel"] })
scan += block("thirdparty", "/third/bad-section", manifest("third.bad-section", ["bar-widget"], { barWidget: "Widget.qml" }, { defaultSection: "bottom" }))
scan += block("thirdparty", "/third/schema", { schemaVersion: 2, id: "third.schema", name: "schema", version: "1.0.0", kinds: ["panel"], entryPoints: { panel: "Panel.qml" } }) scan += block("thirdparty", "/third/schema", { schemaVersion: 2, id: "third.schema", name: "schema", version: "1.0.0", kinds: ["panel"], entryPoints: { panel: "Panel.qml" } })
scan += block("thirdparty", "/third/bad-json", "{") scan += block("thirdparty", "/third/bad-json", "{")
@@ -110,6 +113,7 @@ ShellRoot {
root.assertTrue(!has("omarchy.reserved"), "third-party omarchy namespace ids are rejected") root.assertTrue(!has("omarchy.reserved"), "third-party omarchy namespace ids are rejected")
root.assertTrue(!has("third.unsafe"), "unsafe entry points are rejected") root.assertTrue(!has("third.unsafe"), "unsafe entry points are rejected")
root.assertTrue(!has("third.missing"), "incomplete manifests are rejected") root.assertTrue(!has("third.missing"), "incomplete manifests are rejected")
root.assertTrue(!has("third.bad-section"), "invalid default bar widget sections are rejected")
root.assertTrue(!has("third.schema"), "unsupported schema versions are rejected") root.assertTrue(!has("third.schema"), "unsupported schema versions are rejected")
root.assertTrue(registry.isEnabled("omarchy.first-widget"), "first-party plugins are implicitly enabled") root.assertTrue(registry.isEnabled("omarchy.first-widget"), "first-party plugins are implicitly enabled")
@@ -132,10 +136,10 @@ ShellRoot {
root.assertDeepEqual(root.config.plugins, [], "disabling third-party panels removes plugins array entry") root.assertDeepEqual(root.config.plugins, [], "disabling third-party panels removes plugins array entry")
registry.setEnabled("third.widget", true) registry.setEnabled("third.widget", true)
root.assertDeepEqual(root.config.bar.layout.right, [{ id: "third.widget" }], "enabling bar widgets appends to right layout") root.assertDeepEqual(root.config.bar.layout.left, [{ id: "third.widget" }], "enabling bar widgets uses their default section")
root.assertTrue(registry.isEnabled("third.widget"), "enabled bar widgets are found") root.assertTrue(registry.isEnabled("third.widget"), "enabled bar widgets are found")
registry.setEnabled("third.widget", false) registry.setEnabled("third.widget", false)
root.assertDeepEqual(root.config.bar.layout.right, [], "disabling bar widgets removes layout entry") root.assertDeepEqual(root.config.bar.layout.left, [], "disabling bar widgets removes layout entry")
root.config = { root.config = {
version: 1, version: 1,
@@ -150,6 +154,35 @@ ShellRoot {
registry.setEnabled("third.panel", true) registry.setEnabled("third.panel", true)
root.assertDeepEqual(root.config.plugins, [{ id: "third.panel" }], "setEnabled repairs missing plugin config shape") root.assertDeepEqual(root.config.plugins, [{ id: "third.panel" }], "setEnabled repairs missing plugin config shape")
// A built-in loads by default, so switching one off is recorded the other
// way round and has to survive round-tripping back on.
root.config = { version: 1, bar: { layout: { left: [], center: [], right: [] } }, plugins: [] }
registry.setEnabled("omarchy.grouped-panel", false)
root.assertDeepEqual(root.config.disabledPlugins, ["omarchy.grouped-panel"], "disabling a first-party plugin records it")
root.assertTrue(!registry.isEnabled("omarchy.grouped-panel"), "a recorded first-party plugin is disabled")
root.assertDeepEqual(root.config.plugins, [], "disabling a first-party plugin leaves the plugins array alone")
registry.setEnabled("omarchy.grouped-panel", true)
root.assertTrue(root.config.disabledPlugins === undefined, "re-enabling drops the disabled record entirely")
root.assertTrue(registry.isEnabled("omarchy.grouped-panel"), "a first-party plugin returns to enabled")
root.assertDeepEqual(root.config.plugins, [], "re-enabling a first-party plugin adds no redundant entry")
// A widget's place in the bar is its on/off switch. Loadability must not
// follow it down, or a plugin that is both widget and menu (omarchy.menu)
// would be locked out of the shell by taking its button off the bar.
root.config = {
version: 1,
bar: { layout: { left: [], center: [], right: [{ id: "omarchy.first-widget" }] } },
plugins: []
}
root.assertTrue(registry.inBar("omarchy.first-widget"), "inBar sees a widget in the layout")
registry.setEnabled("omarchy.first-widget", false)
root.assertDeepEqual(root.config.bar.layout.right, [], "disabling a first-party widget removes its layout entry")
root.assertTrue(root.config.disabledPlugins === undefined, "disabling a first-party widget records nothing else")
root.assertTrue(!registry.inBar("omarchy.first-widget"), "inBar follows the widget out of the layout")
root.assertTrue(registry.isEnabled("omarchy.first-widget"), "a first-party widget stays loadable off the bar")
registry.setEnabled("omarchy.first-widget", true)
root.assertDeepEqual(root.config.bar.layout.center, [{ id: "omarchy.first-widget" }], "a widget without a default section falls back to center")
root.assertTrue(changeCount > 0, "registry emits change notifications") root.assertTrue(changeCount > 0, "registry emits change notifications")
writeResult() writeResult()
} }
+168
View File
@@ -0,0 +1,168 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command jq
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
STUB_DIR="$TMPDIR/stub"
mkdir -p "$STUB_DIR"
# The picker reads the plugin list from omarchy-plugin and hands what it decided
# back to it, so stubbing both ends shows which plugin a pick actually resolved
# to -- the thing a source-level check cannot see.
cat >"$STUB_DIR/omarchy-plugin" <<'STUB'
#!/bin/bash
[[ $1 == list ]] && { cat "$FAKE_PLUGINS"; exit 0; }
printf 'omarchy-plugin %s\n' "$*" >>"$FAKE_CALLS"
STUB
# Records the rows it was offered, then answers with the pick under test.
cat >"$STUB_DIR/omarchy-menu-select" <<'STUB'
#!/bin/bash
cat >"$FAKE_ROWS"
printf '%s\n' "$FAKE_PICK"
STUB
cat >"$STUB_DIR/omarchy-notification-send" <<'STUB'
#!/bin/bash
printf 'notification: %s\n' "$*" >>"$FAKE_CALLS"
STUB
cat >"$STUB_DIR/omarchy-launch-floating-terminal-with-presentation" <<'STUB'
#!/bin/bash
printf 'terminal: %s\n' "$*" >>"$FAKE_CALLS"
STUB
chmod +x "$STUB_DIR"/*
# Runs the picker against a plugin list, answering its prompt with $2. Leaves
# the rows it offered in $ROWS and what it called in $CALLS.
pick() {
local verb="$1" choice="$2"
: >"$TMPDIR/calls"
: >"$TMPDIR/rows"
PATH="$STUB_DIR:$PATH" \
FAKE_PLUGINS="$TMPDIR/plugins.json" \
FAKE_CALLS="$TMPDIR/calls" \
FAKE_ROWS="$TMPDIR/rows" \
FAKE_PICK="$choice" \
"$ROOT/bin/omarchy-menu-plugin" "$verb" >/dev/null 2>&1
ROWS=$(cat "$TMPDIR/rows")
CALLS=$(cat "$TMPDIR/calls")
}
# Cloning a plugin keeps the name it was cloned from, so two plugins really can
# arrive at the picker calling themselves Clock. Both eligible for the same
# verb: neither row can stand on the name alone.
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": true},
{"id": "local.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false}
]
JSON
pick enable "Clock (local.clock)"
[[ $ROWS == *"Clock (omarchy.clock)"* && $ROWS == *"Clock (local.clock)"* ]] \
|| fail "picker tells two plugins of the same name apart" "$ROWS"
pass "picker tells two plugins of the same name apart"
[[ $CALLS == *"omarchy-plugin enable local.clock"* ]] \
|| fail "picker acts on the row that was picked, not the one that shares its name" "$CALLS"
pass "picker acts on the row that was picked, not the one that shares its name"
# Only one of them is eligible, so the row needs no id -- but resolving it by
# name alone would still find the wrong plugin, since the other one exists.
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": true, "active": false, "canDisable": true, "firstParty": true},
{"id": "local.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false}
]
JSON
pick enable "Clock"
[[ $ROWS != *"("* ]] || fail "picker adorns a row only when its name is taken twice over" "$ROWS"
pass "picker adorns a row only when its name is taken twice over"
[[ $CALLS == *"omarchy-plugin enable local.clock"* ]] \
|| fail "picker resolves a lone row to the plugin the verb offered, not a namesake it filtered out" "$CALLS"
pass "picker resolves a lone row to the plugin the verb offered, not a namesake it filtered out"
pick remove "Clock"
[[ $CALLS == *"omarchy-plugin remove local.clock"* ]] \
|| fail "picker removes the plugin whose row was picked" "$CALLS"
pass "picker removes the plugin whose row was picked"
# A name that stands alone is left alone: no id trailing a row that needs none.
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "acme.weather", "name": "Weather", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false}
]
JSON
pick enable "Weather"
[[ $ROWS == *"Weather"* && $ROWS != *"acme.weather)"* ]] \
|| fail "picker leaves an unambiguous name unadorned" "$ROWS"
pass "picker leaves an unambiguous name unadorned"
[[ $CALLS == *"omarchy-plugin enable acme.weather"* ]] \
|| fail "picker delegates plugin enablement to the plugin command" "$CALLS"
pass "picker delegates plugin enablement to the plugin command"
# The picker treats every plugin alike and leaves kind-specific behavior to the
# plugin command.
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "acme.fancy", "name": "Fancy", "kinds": ["bar", "bar-widget"], "enabled": false, "active": false, "canDisable": false, "firstParty": false}
]
JSON
pick enable "Fancy"
[[ $CALLS == *"omarchy-plugin enable acme.fancy"* && $CALLS != *"--section"* ]] \
|| fail "picker delegates kind-specific enablement" "$CALLS"
pass "picker delegates kind-specific enablement"
# A bar has no off, so it is never offered under disable -- including this one,
# which is a widget too.
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "acme.fancy", "name": "Fancy", "kinds": ["bar", "bar-widget"], "enabled": true, "active": true, "canDisable": false, "firstParty": false},
{"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": true, "active": false, "canDisable": true, "firstParty": true}
]
JSON
pick disable "Clock"
[[ $ROWS == *"Clock"* && $ROWS != *"Fancy"* ]] \
|| fail "picker keeps a bar out of disable" "$ROWS"
pass "picker keeps a bar out of disable"
# The bar in use is the row absent from enable; every other bar is one pick away.
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "omarchy.bar", "name": "Bar", "kinds": ["bar"], "enabled": false, "active": false, "canDisable": false, "firstParty": true},
{"id": "local.neon-bar", "name": "Neon Bar", "kinds": ["bar"], "enabled": true, "active": true, "canDisable": false, "firstParty": false}
]
JSON
pick enable "Bar"
[[ $ROWS == *"Bar"* && $ROWS != *"Neon Bar"* ]] \
|| fail "picker offers every bar except the one already running" "$ROWS"
pass "picker offers every bar except the one already running"
[[ $CALLS == *"omarchy-plugin enable omarchy.bar"* ]] \
|| fail "picker returns to the built-in bar by enabling it" "$CALLS"
pass "picker returns to the built-in bar by enabling it"
# Nothing the verb can act on is said out loud, not opened as an empty list.
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "omarchy.bar", "name": "Bar", "kinds": ["bar"], "enabled": true, "active": true, "canDisable": false, "firstParty": true}
]
JSON
pick enable ""
[[ $CALLS == *"notification: No plugin to enable"* ]] \
|| fail "picker says when a verb has nothing to act on" "$CALLS"
pass "picker says when a verb has nothing to act on"
+112 -3
View File
@@ -181,6 +181,91 @@ assertEqual(
'omarchy-bar transparent toggle', 'omarchy-bar transparent toggle',
'menu exposes Menu Bar transparency as a toggle' 'menu exposes Menu Bar transparency as a toggle'
) )
assertDeepEqual(
defaultItems.filter(item => item.parent === 'setup.plugin').map(item => item.label),
['Enable Plugin', 'Disable Plugin', 'Add Plugin', 'Remove Plugin'],
'menu manages plugins from Setup > Plugins'
)
assert(
['enable', 'disable', 'remove'].every(
verb => defaultById[`setup.plugin.${verb}`].action === `omarchy-menu-plugin ${verb}`
),
'menu picks a plugin the way it already picks a theme or a timezone'
)
assert(
!defaultById['setup.plugin.enable'].when && !defaultById['setup.plugin.disable'].when,
'menu always offers Enable and Disable, which cover the built-in plugins too'
)
assert(
defaultById['setup.plugin.remove'].when.includes('.config/omarchy/plugins'),
'menu hides Remove until a plugin the user installed exists to delete'
)
assert(
defaultById['setup.plugin.add'].action.includes('omarchy-plugin add'),
'menu adds a plugin through the CLI, where the trust warning and clone output are visible'
)
const pluginPicker = fs.readFileSync(path.join(root, 'bin/omarchy-menu-plugin'), 'utf8')
assert(
/enable\).*\(\.enabled \| not\)/.test(pluginPicker) && /disable\).*\.canDisable and \.enabled/.test(pluginPicker),
'plugin picker offers what each verb can act on'
)
assert(
/remove\).*\(\.firstParty \| not\)/.test(pluginPicker)
&& !/kinds|bar-widget|A_BAR_OPTION|NOT_A_BAR_OPTION|BAR_ICON/.test(pluginPicker),
'plugin picker leaves plugin-kind decisions to its data and the plugin command'
)
const pluginCli = fs.readFileSync(path.join(root, 'bin/omarchy-plugin'), 'utf8')
assert(
/Now using \$id as the bar/.test(pluginCli)
&& /enabled_message "\$id"[\s\S]*?place_bar_widget/.test(pluginCli),
'plugin enable reports a bar as replacing the one in use, whether enabled or freshly added'
)
assert(
/\.barWidget\.defaultSection \/\/ "center"/.test(pluginCli)
&& /gum choose[\s\S]*?--selected "\$default_section"/.test(pluginCli),
'interactive plugin add selects the manifest placement or center fallback by default'
)
assert(
/omarchy-plugin "\$1" "\$id"/.test(pluginPicker),
'plugin picker delegates enable and disable without interpreting plugin kinds'
)
// Icons ride along as "<glyph>\tlabel"; the menu shows the glyph and hands
// back the label, so nothing downstream has to strip one off. The id rides in
// a third field, cut off before the menu is ever shown it. What the picker
// then does with the row it gets back is checked in menu-plugin-test.sh.
assert(
/\$label \+ \\"\\\\t\\" \+ \.id/.test(pluginPicker)
&& /omarchy-menu-select "\$\{1\^\} plugin" < <\(cut -f1,2 <<<"\$rows"\)/.test(pluginPicker),
'plugin picker labels its rows with glyphs and keeps the id out of the label'
)
assert(
/var icon = parts\.length > 1 \? parts\.shift\(\) : ""\s*\n\s*var label = parts\.join\("\\t"\)/.test(menuQml),
'menu select mode reads a leading icon off an option and filters on the label alone'
)
assert(
/omarchy-launch-floating-terminal-with-presentation "omarchy-plugin remove/.test(pluginPicker),
'plugin picker removes where the confirmation and backup path are visible'
)
// A font installed since the shell started should show up without a restart.
const providerBlock = menuQml.match(/readonly property var providers: \(\{[\s\S]*?\n \}\)/)[0]
assert(
/"fonts": \{[\s\S]*?volatile: true/.test(providerBlock),
'menu re-enumerates the font list every time it is opened'
)
assert(
/function setActiveMenu\([\s\S]*?root\.invalidateVolatileProvider\(id\)\s*\n\s*root\.loadProviderForMenu\(id\)/.test(menuQml)
&& /function openExistingMenu\([\s\S]*?invalidateVolatileProvider\(activeMenu\)\s*\n\s*loadProviderForMenu\(activeMenu\)/.test(menuQml),
'menu invalidates volatile providers when entering a menu, not on every keystroke'
)
assert(
['loadProviderForMenu', 'loadProvidersForSearch'].every(
name => !menuQml.match(new RegExp(`function ${name}\\([^)]*\\) \\{([\\s\\S]*?)\\n \\}`))[1].includes('invalidateVolatileProvider')
),
'menu search never restarts a volatile provider'
)
assertEqual( assertEqual(
defaultById['trigger.hardware.laptop-display'].when, defaultById['trigger.hardware.laptop-display'].when,
'omarchy-hw-laptop', 'omarchy-hw-laptop',
@@ -280,16 +365,40 @@ assert(
) )
const providerRowsFor = values => values.map(value => ({ id: `style.font.${value}`, kind: 'action', parent: 'style.font', label: value })) const providerRowsFor = values => values.map(value => ({ id: `style.font.${value}`, kind: 'action', parent: 'style.font', label: value }))
const firstProviderMerge = menu.mergeRowsById(nonAppItems, nonAppOrder, providerRowsFor(['mono', 'serif'])) const firstProviderMerge = menu.swapProviderRows(nonAppItems, nonAppOrder, 'style.font', providerRowsFor(['mono', 'serif']))
assert( assert(
firstProviderMerge.itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif', firstProviderMerge.itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif',
'provider merge appends its rows' 'provider merge appends its rows'
) )
assert( assert(
menu.mergeRowsById(firstProviderMerge.items, firstProviderMerge.itemOrder, providerRowsFor(['mono', 'serif'])) menu.swapProviderRows(firstProviderMerge.items, firstProviderMerge.itemOrder, 'style.font', providerRowsFor(['mono', 'serif']))
.itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif', .itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif',
'repeating a provider merge does not duplicate rows' 'repeating a provider merge does not duplicate rows'
) )
// A plugin drops out of the Enable list the moment it is enabled, so a
// provider that runs again has to lose the rows it contributed last time.
const rerunProviderMerge = menu.swapProviderRows(firstProviderMerge.items, firstProviderMerge.itemOrder, 'style.font', providerRowsFor(['serif']))
assert(
rerunProviderMerge.itemOrder.join(',') === 'root,apps,style.font.serif',
'provider merge drops rows the provider no longer lists'
)
assert(
menu.swapProviderRows(firstProviderMerge.items, firstProviderMerge.itemOrder, 'style.other', providerRowsFor([]))
.itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif',
'provider merge leaves rows belonging to another provider alone'
)
// Rows are keyed by id, so a provider handing over two rows with the same id
// would lose one. Distinct plugin ids can slugify alike, which is why the
// menu makes each row id its own before merging.
assertEqual(
['acme.foo', 'acme_foo', 'acme-foo'].map(menu.slugify).join(','),
'acme-foo,acme-foo,acme-foo',
'menu slugs collide across plugin ids that differ only in separator'
)
assert(
/var rowId = menuId \+ "\." \+ root\.slugify\(value\)\s*\n\s*while \(takenIds\[rowId\]\) rowId \+= "-"/.test(menuQml),
'menu keeps colliding provider rows apart so none is dropped'
)
// The maps live in QML `var` properties, where an in-place write is // The maps live in QML `var` properties, where an in-place write is
// occasionally dropped by the engine, so both merges must hand back fresh // occasionally dropped by the engine, so both merges must hand back fresh
@@ -299,7 +408,7 @@ assert(
'menu assigns the rebuilt app item map instead of mutating it in place' 'menu assigns the rebuilt app item map instead of mutating it in place'
) )
assert( assert(
/var merged = MenuModel\.mergeRowsById\(root\.items, root\.itemOrder, providerRows\)\s*\n\s*root\.items = merged\.items\s*\n\s*root\.itemOrder = merged\.itemOrder/.test(menuQml), /var merged = MenuModel\.swapProviderRows\(root\.items, root\.itemOrder, menuId, providerRows\)\s*\n[\s\S]*?root\.items = merged\.items\s*\n\s*root\.itemOrder = merged\.itemOrder/.test(menuQml),
'menu assigns the rebuilt provider item map instead of mutating it in place' 'menu assigns the rebuilt provider item map instead of mutating it in place'
) )
assert( assert(
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
write_plugin() {
local dir="$1"
local id="$2"
local name="$3"
mkdir -p "$dir"
cat >"$dir/manifest.json" <<JSON
{
"schemaVersion": 1,
"id": "$id",
"name": "$name",
"version": "1.0.0",
"kinds": ["bar-widget"],
"entryPoints": { "barWidget": "Widget.qml" },
"barWidget": {
"displayName": "$name",
"category": "Test",
"allowMultiple": false
}
}
JSON
printf 'import QtQuick\nItem {}\n' >"$dir/Widget.qml"
}
stub_dir="$TMPDIR/stubs"
mkdir -p "$stub_dir"
cat >"$stub_dir/omarchy-shell" <<'STUB'
#!/bin/bash
exit 0
STUB
chmod +x "$stub_dir/omarchy-shell"
test_home="$TMPDIR/home"
write_plugin "$test_home/.config/omarchy/plugins/different-folder" "acme.same" "Installed"
incoming="$TMPDIR/incoming"
write_plugin "$incoming" "acme.same" "Incoming"
git -C "$incoming" init -q
git -C "$incoming" add .
git -C "$incoming" -c user.name=Test -c user.email=test@example.com commit -qm "Initial"
output=$(HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$stub_dir:$ROOT/bin:$PATH" \
omarchy-plugin add "$incoming" --yes 2>&1) &&
fail "plugin add accepts an id already installed under another directory" "$output"
grep -qF "plugin id 'acme.same' is already used by" <<<"$output" ||
fail "plugin add explains the installed id collision" "$output"
[[ ! -e $test_home/.config/omarchy/plugins/acme.same ]] ||
fail "plugin add leaves a target behind after refusing a duplicate id"
pass "plugin add refuses an installed manifest id regardless of directory name"
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command jq
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# A plugin folder with whatever kinds and entry points the case needs. Every
# entry point named gets a file, so a rejection is about the manifest and not a
# missing QML file.
write_plugin() {
local name="$1" kinds="$2" entry_points="$3" bar_widget="${4:-null}"
local dir="$TMPDIR/$name"
local entry
mkdir -p "$dir"
cat >"$dir/manifest.json" <<JSON
{
"schemaVersion": 1,
"id": "acme.$name",
"name": "Acme $name",
"version": "1.0.0",
"kinds": $kinds,
"entryPoints": $entry_points,
"barWidget": $bar_widget
}
JSON
while IFS= read -r entry; do
[[ -n $entry ]] || continue
touch "$dir/$entry"
done < <(jq -r '.[]' <<<"$entry_points")
printf '%s\n' "$dir"
}
validate() {
OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-plugin-validate" "$1" 2>&1
}
# Every kind the shell knows how to load names the entry point it loads from.
# Declaring the kind without it installs a plugin that does nothing at all.
while IFS=: read -r kind entry_point; do
dir=$(write_plugin "wants-$kind" "[\"$kind\"]" "{\"$entry_point\": \"Entry.qml\"}")
validate "$dir" >/dev/null || fail "validate accepts $kind with its $entry_point entry point"
pass "validate accepts $kind with its $entry_point entry point"
# Swap in an entry point the kind does not read, so the manifest is otherwise
# complete and only the promised one is missing.
other_key="service"
[[ $entry_point == "service" ]] && other_key="panel"
dir=$(write_plugin "missing-$kind" "[\"$kind\"]" "{\"$other_key\": \"Entry.qml\"}")
output=$(validate "$dir") && fail "validate refuses $kind without its $entry_point entry point" "$output"
grep -qF "kind '$kind' requires an 'entryPoints.$entry_point' to load" <<<"$output" \
|| fail "validate names the entry point $kind is missing" "$output"
pass "validate refuses $kind without its $entry_point entry point"
done <<'KINDS'
bar:bar
bar-widget:barWidget
menu:menu
overlay:overlay
panel:panel
service:service
KINDS
# A plugin that is both a bar and a widget owes an entry point for each.
dir=$(write_plugin "both" '["bar","bar-widget"]' '{"bar": "Bar.qml", "barWidget": "Widget.qml"}')
validate "$dir" >/dev/null || fail "validate accepts a plugin that satisfies every kind it declares"
pass "validate accepts a plugin that satisfies every kind it declares"
dir=$(write_plugin "half" '["bar","bar-widget"]' '{"bar": "Bar.qml"}')
output=$(validate "$dir") && fail "validate refuses a plugin that satisfies only one of its kinds" "$output"
grep -qF "kind 'bar-widget' requires" <<<"$output" \
|| fail "validate names the unsatisfied kind" "$output"
pass "validate refuses a plugin that satisfies only one of its kinds"
# A widget can choose its default bar section, but no other section name.
for section in left center right; do
dir=$(write_plugin "defaults-$section" '["bar-widget"]' '{"barWidget": "Widget.qml"}' "{\"defaultSection\": \"$section\"}")
validate "$dir" >/dev/null || fail "validate accepts $section as a default bar widget section"
pass "validate accepts $section as a default bar widget section"
done
dir=$(write_plugin "defaults-bottom" '["bar-widget"]' '{"barWidget": "Widget.qml"}' '{"defaultSection": "bottom"}')
output=$(validate "$dir") && fail "validate refuses an invalid default bar widget section" "$output"
grep -qF "'barWidget.defaultSection' must be left, center, or right" <<<"$output" \
|| fail "validate explains the default bar widget section contract" "$output"
pass "validate refuses an invalid default bar widget section"
# A kind the table does not cover is left alone rather than guessed at, so an
# unknown kind is not turned into a demand for an entry point nobody reads.
dir=$(write_plugin "unknown" '["future-thing"]' '{"service": "Entry.qml"}')
validate "$dir" >/dev/null || fail "validate leaves a kind it does not know alone"
pass "validate leaves a kind it does not know alone"
# The check reports the manifest, so a path that does not resolve still gets the
# more specific complaint it had before.
dir="$TMPDIR/ghost"
mkdir -p "$dir"
cat >"$dir/manifest.json" <<'JSON'
{
"schemaVersion": 1,
"id": "acme.ghost",
"name": "Acme Ghost",
"version": "1.0.0",
"kinds": ["bar"],
"entryPoints": { "bar": "Missing.qml" }
}
JSON
output=$(validate "$dir") && fail "validate refuses an entry point file that is not there" "$output"
grep -qF "entry point file not found" <<<"$output" \
|| fail "validate reports a missing file as a missing file" "$output"
pass "validate refuses an entry point file that is not there"
+18
View File
@@ -145,6 +145,12 @@ for (const manifestPath of manifests) {
) )
} }
check(manifest.barWidget && typeof manifest.barWidget.allowMultiple === 'boolean', `${manifest.id} barWidget allowMultiple must be boolean`) check(manifest.barWidget && typeof manifest.barWidget.allowMultiple === 'boolean', `${manifest.id} barWidget allowMultiple must be boolean`)
if (manifest.barWidget && manifest.barWidget.defaultSection !== undefined) {
check(
['left', 'center', 'right'].includes(manifest.barWidget.defaultSection),
`${manifest.id} barWidget defaultSection must be left, center, or right`
)
}
} }
if (relativePath.endsWith('.manifest.json')) { if (relativePath.endsWith('.manifest.json')) {
@@ -152,5 +158,17 @@ for (const manifestPath of manifests) {
} }
} }
const byId = Object.fromEntries(manifests.map(manifestPath => {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
return [manifest.id, manifest]
}))
for (const [id, section] of Object.entries({
'omarchy.active-window': 'left',
'omarchy.dropbox': 'right'
})) {
check(byId[id]?.barWidget?.defaultSection === section, `${id} must default to the ${section} bar section`)
}
check(byId['omarchy.media']?.barWidget?.defaultSection === undefined, 'omarchy.media must use the center fallback')
assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n')) assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n'))
JS JS
+2 -2
View File
@@ -108,7 +108,8 @@ done
jq -e ' jq -e '
map(.id) as $ids | map(.id) as $ids |
all(["omarchy.menu", "omarchy.notifications", "omarchy.clock", "omarchy.osd"][]; $ids | index(.)) and all(["omarchy.menu", "omarchy.notifications", "omarchy.clock", "omarchy.osd"][]; $ids | index(.)) and
all(.[]; (.kinds | type == "array") and (.enabled | type == "boolean") and (.firstParty | type == "boolean")) all(.[]; (.kinds | type == "array") and (.enabled | type == "boolean") and (.canDisable | type == "boolean") and (.firstParty | type == "boolean")) and
([.[].name] == ([.[].name] | sort))
' <<<"$plugins" >/dev/null || { ' <<<"$plugins" >/dev/null || {
printf 'Plugins:\n%s\n' "$plugins" | jq . >&2 printf 'Plugins:\n%s\n' "$plugins" | jq . >&2
fail_with_log "shell IPC lists plugin metadata" fail_with_log "shell IPC lists plugin metadata"
@@ -275,4 +276,3 @@ jq -e 'all(.[]; .id != "omarchy.audio")' <<<"$geometry" >/dev/null || {
} }
pass "bar remove reloads shell config and updates bar layout" pass "bar remove reloads shell config and updates bar layout"