diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes index 55a3a271..fb1411c0 100755 --- a/bin/omarchy-install-ai-hermes +++ b/bin/omarchy-install-ai-hermes @@ -21,13 +21,8 @@ omarchy-install-hermes-cli || true echo "Opening Hermes Desktop..." setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 & -# The app builds its runtime on first launch, which takes minutes, and only a -# running Hermes can be told which skin to show. So the theme is handed over -# after the launch above, by a helper that waits for that runtime and then -# names the Omarchy skin through Hermes itself; the running app repaints. Only -# Hermes' own default is replaced, so a skin chosen in Hermes is never undone. -# It runs as its own unit, so this terminal closing cannot take it along and -# what it did is in the journal: journalctl --user -u omarchy-hermes-theme +# Only a running Hermes can be told which skin to show, and the first launch +# takes minutes; a unit outlives this terminal and reports to the journal. echo "Matching Hermes to the current theme once it is set up..." systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index 2465e46c..f6b9f292 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -8,6 +8,9 @@ set -euo pipefail omarchy-pkg-drop hermes-desktop +# The installer leaves a unit waiting to hand the app the Omarchy theme. +systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true + # The mise CLI is the app's predecessor, not the app itself: Hermes Desktop takes # it over on install and runs its own runtime instead, so a copy still here is one # the app never superseded -- an interrupted install, or the terminal CLI from diff --git a/bin/omarchy-theme-set-hermes b/bin/omarchy-theme-set-hermes index a4663738..c73b24c7 100755 --- a/bin/omarchy-theme-set-hermes +++ b/bin/omarchy-theme-set-hermes @@ -4,21 +4,8 @@ # omarchy:args=[--activate] [--wait] # omarchy:hidden=true -# Hermes has one theme unit for all of its surfaces, the skin: a YAML file in -# ~/.hermes/skins/ that the desktop app, the TUI and the CLI each turn into -# their own palette. The active one is named by display.skin in Hermes' config, -# and the gateway watches that file's mtime, so a rewrite of the active skin -# repaints every connected surface within a second -- no Omarchy code in -# Hermes, and nothing here that Hermes did not already offer any other system. -# -# Every run publishes the skin file, and for the desktop app Omarchy installed -# names it in the config through Hermes' own config writer if Hermes is still -# on its default skin, so a theme switch also picks up an install whose -# hand-over was missed. --activate is the deliberate form, for the installer -# and the migration: it renders a skin the current theme does not have yet and -# says why nothing happened when it cannot finish. --wait holds the whole thing -# until the desktop app has finished building the runtime its first launch -# provisions. +# A skin is Hermes' one theme unit for the desktop app, the TUI and the CLI; +# its gateway watches the active skin file and repaints every surface on change. set -euo pipefail @@ -26,11 +13,10 @@ HERMES_SOURCE_PATH="$HOME/.local/state/omarchy/current/theme/hermes.yaml" HERMES_THEME_NAME_PATH="$HOME/.local/state/omarchy/current/theme.name" HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" HERMES_CONFIG_PATH="$HERMES_HOME/config.yaml" -# The filename is the skin name Hermes shows and display.skin refers to, and it -# stays put while the colors change underneath on every theme switch. HERMES_SKIN_NAME="omarchy" -# The app writes this when the runtime it provisions has landed; the CLI -# installer's readiness probe passes only once it has. +# The command the readiness probe vets, rather than whichever hermes is on PATH. +HERMES_COMMAND="$HOME/.local/bin/hermes" +# Written by the desktop app once the runtime its first launch provisions is in. HERMES_BOOTSTRAP_MARKER="$HERMES_HOME/hermes-agent/.hermes-bootstrap-complete" HERMES_ACTIVATE=0 HERMES_WAIT=0 @@ -60,17 +46,14 @@ for arg in "$@"; do esac done -# Only the deliberate form explains itself: the theme switch runs this beside -# a dozen other hooks, where a note about Hermes on every switch is noise. +# A theme switch runs this beside a dozen other hooks; only --activate explains. note() { if (( HERMES_ACTIVATE == 1 )); then echo "$*" >&2 fi } -# The skin is generated with the rest of the theme, so a theme applied before -# the template existed has none yet. Asked to activate, re-stage the current -# theme to render it; a theme switch simply has nothing to publish. +# A theme applied before the template existed has no skin rendered yet. if [[ ! -f $HERMES_SOURCE_PATH ]]; then (( HERMES_ACTIVATE == 1 )) || exit 0 @@ -88,9 +71,8 @@ if [[ ! -f $HERMES_SOURCE_PATH ]]; then fi fi -# The desktop app builds its runtime on first launch, which takes minutes and -# may be abandoned. Poll for the marker rather than the readiness probe, which -# starts Hermes to answer, and give up quietly on a launch that never came. +# The first launch takes minutes and may be abandoned; the readiness probe +# would start Hermes to answer, so poll for the marker instead. if (( HERMES_WAIT == 1 )); then waited=0 until [[ -f $HERMES_BOOTSTRAP_MARKER && -f $HERMES_CONFIG_PATH ]]; do @@ -104,96 +86,148 @@ if (( HERMES_WAIT == 1 )); then done fi -# Only follow a Hermes that has been set up. Its config is what Hermes writes -# when it has; ~/.hermes itself proves nothing, because provisioning creates it -# on every machine to hold the Omarchy skill. +# Provisioning creates ~/.hermes on every machine for the Omarchy skill; the +# config is what Hermes writes once it has actually run. if [[ ! -f $HERMES_CONFIG_PATH ]]; then note "Hermes is not set up yet; launch it once, then run omarchy-theme-set-hermes --activate." exit 0 fi -# Hermes parses the skin as YAML and hands the strings to every surface, so the -# generated file is held to the shape the template promises before it is -# published: the name Hermes loads it by, a plain description, and colors that -# are all #rrggbb. A theme missing a palette key leaves its placeholder -# unresolved, and a theme cloned from a git repo may ship its own hermes.yaml; -# neither gets to write anything else into a file another program reads. The -# previous skin stays in place instead. +# Hermes parses the skin as YAML and hands its strings to every surface, so only +# the name, a plain description and #rrggbb colours may reach it, in the order +# YAML needs them. YAML breaks lines on bytes grep does not, so the bytes are +# counted first, NUL included. skin_is_well_formed() { local skin="$1" - grep -qx "name: $HERMES_SKIN_NAME" "$skin" && - grep -qx 'colors:' "$skin" && - ! grep -qvE "^(#.*|[[:space:]]*|name: $HERMES_SKIN_NAME|description: [A-Za-z0-9 ,.()-]*|colors:| [a-z_]+: \"#[0-9a-fA-F]{6}\")$" "$skin" + [[ -f $skin ]] && + (( $(LC_ALL=C tr -d ' -~\n' <"$skin" | wc -c) == 0 )) && + awk -v name="name: $HERMES_SKIN_NAME" ' + bad { next } + /^#/ || /^[[:space:]]*$/ { next } + !seen_name { if ($0 == name) seen_name = 1; else bad = 1; next } + !seen_colors { + if ($0 == "colors:") seen_colors = 1 + else if (!seen_description && $0 ~ /^description: [A-Za-z0-9 ,.()-]{0,200}$/) seen_description = 1 + else bad = 1 + next + } + /^ [a-z_]{1,64}: "#[0-9a-fA-F]{6}"$/ { colors++; next } + { bad = 1 } + END { exit (bad || !seen_colors || colors == 0) } + ' "$skin" } -if ! skin_is_well_formed "$HERMES_SOURCE_PATH"; then +# The check has to cover the bytes that get published, and the theme can change +# underneath between the two, so a private copy is taken and that is checked. +snapshot_dir=$(mktemp -d) +trap 'rm -rf "$snapshot_dir"' EXIT +HERMES_SNAPSHOT="$snapshot_dir/$HERMES_SKIN_NAME.yaml" + +take_snapshot() { + cp "$HERMES_SOURCE_PATH" "$HERMES_SNAPSHOT" 2>/dev/null && skin_is_well_formed "$HERMES_SNAPSHOT" +} + +if ! take_snapshot; then echo "Skipping Hermes skin: $(basename "$HERMES_SOURCE_PATH") is not a plain color palette." >&2 exit 0 fi -# The gateway watches the skin file's mtime and reads it whole on change, so -# the write has to be atomic: a half-written file would read as no skin at all. +# The gateway reads the file whole on an mtime change, so the write is atomic; +# -T so a directory at the skin's path is an error rather than a destination. publish_skin() { local skins_dir="$1" local tmp - mkdir -p "$skins_dir" - tmp=$(mktemp "$skins_dir/$HERMES_SKIN_NAME.yaml.XXXXXX") - cp "$HERMES_SOURCE_PATH" "$tmp" - mv "$tmp" "$skins_dir/$HERMES_SKIN_NAME.yaml" + mkdir -p "$skins_dir" 2>/dev/null || return 1 + tmp=$(mktemp "$skins_dir/$HERMES_SKIN_NAME.yaml.XXXXXX" 2>/dev/null) || return 1 + if ! cp "$HERMES_SNAPSHOT" "$tmp" 2>/dev/null || ! mv -T "$tmp" "$skins_dir/$HERMES_SKIN_NAME.yaml" 2>/dev/null; then + rm -f "$tmp" + return 1 + fi } -publish_skin "$HERMES_HOME/skins" +# A profile is a Hermes home of its own; existing ones get the skin, none are +# made, and one that cannot take it does not cost the others. +publish_skin_everywhere() { + local profile -# A Hermes profile is a home of its own, with its own skins and config, and the -# gateway watches whichever profile is active. Existing profiles get the skin -# too; none are created here. -for profile in "$HERMES_HOME"/profiles/*/; do - [[ -d $profile ]] || continue - publish_skin "${profile%/}/skins" -done + publish_skin "$HERMES_HOME/skins" || { + echo "Could not publish the Hermes skin to $HERMES_HOME/skins." >&2 + return 1 + } -# A theme switch finishes the hand-over only for the app Omarchy installed -# under Install > AI; a Hermes set up some other way keeps whatever skin it -# has unless --activate asks for it by name. Hermes writes the active skin -# under display: at that indent, and once it names ours a theme switch is done -# here without starting Hermes; anything else is for Hermes itself to answer -# below, so the grep is only ever a shortcut. + for profile in "$HERMES_HOME"/profiles/*/; do + [[ -d $profile ]] || continue + publish_skin "${profile%/}/skins" || note "Could not publish the skin to the Hermes profile $(basename "$profile")." + done +} + +publish_skin_everywhere + +# Hermes reads the config of the profile named in active_profile; the profile +# exists once its directory does, with or without a config of its own. +active_config_path() { + local profile + + profile=$(cat "$HERMES_HOME/active_profile" 2>/dev/null || true) + profile=${profile,,} + + if [[ -n $profile && $profile != "default" && -d $HERMES_HOME/profiles/$profile ]]; then + echo "$HERMES_HOME/profiles/$profile/config.yaml" + else + echo "$HERMES_CONFIG_PATH" + fi +} + +# A theme switch finishes the hand-over only for the app Omarchy installed, and +# only Hermes' default is ever replaced, so a config plainly naming another skin +# ends it here without starting Hermes. Anything less plain is for Hermes to read. if (( HERMES_ACTIVATE == 0 )); then omarchy-pkg-present hermes-desktop || exit 0 - grep -qx " skin: $HERMES_SKIN_NAME" "$HERMES_CONFIG_PATH" && exit 0 + + skin_line=$(grep -m1 -x ' skin: .*' "$(active_config_path)" 2>/dev/null || true) + case "${skin_line# skin: }" in + "" | default | null | true | false | *[!A-Za-z0-9_-]*) ;; + *) exit 0 ;; + esac fi -# Naming the skin in the config is Hermes' own job: `hermes config set` is what -# `hermes skin use` runs, it writes the active profile's config, and it touches -# the skin file so a running gateway broadcasts the change and every surface -# repaints at once. Asked through the installer's readiness probe rather than by -# running whatever `hermes` is on PATH, because Omarchy's cold stub installs -# Hermes when run, and that takes minutes. +# Omarchy's cold stub installs Hermes when run, so ask the probe before running it. if ! omarchy-install-hermes-cli --check 2>/dev/null; then note "Hermes is not ready, so the Omarchy skin is published but not active." note "Once Hermes runs, activate it with: hermes config set display.skin $HERMES_SKIN_NAME" exit 0 fi -# A skin the user picked in Hermes stays theirs. Only Hermes' own default is -# replaced, so a reinstall, a migration or a theme switch never undoes a choice. -current_skin=$(hermes config get display.skin 2>/dev/null || true) +# Only Hermes' own default is replaced, so a skin chosen in Hermes stays; an +# answer that did not come is not a default. +if ! current_skin=$(timeout 15 "$HERMES_COMMAND" config get display.skin 2>/dev/null); then + note "Hermes did not say which skin it is on, so the Omarchy skin is published but not active." + exit 0 +fi + if [[ -n $current_skin && $current_skin != "default" && $current_skin != "$HERMES_SKIN_NAME" ]]; then note "Hermes is set to the '$current_skin' skin; leaving it. Switch with: hermes config set display.skin $HERMES_SKIN_NAME" exit 0 fi -hermes config set display.skin "$HERMES_SKIN_NAME" >/dev/null +# Hermes' own writer: it updates the active profile's config and touches the +# skin file so a running gateway broadcasts the change. A refusal is cosmetic. +if ! timeout 30 "$HERMES_COMMAND" config set display.skin "$HERMES_SKIN_NAME" >/dev/null 2>&1; then + note "Hermes refused to switch skins, so the Omarchy skin is published but not active." + exit 0 +fi + note "Hermes is on the Omarchy skin." -# The desktop app adopts a skin from a change broadcast, not from the config it -# finds at connect time, and the gateway seeds its change watcher when it starts. -# A first launch brings the gateway up moments after the marker, so a config -# written in that gap goes unannounced; republishing once the gateway is surely -# up moves the file's mtime again, and that broadcast the desktop does apply. +# The desktop applies a skin only from a broadcast, and a config written before +# the gateway seeded its watcher goes unannounced; a later write is announced. +# The theme may have changed underneath in the meantime, so it is checked again. if (( HERMES_WAIT == 1 )); then sleep 60 - publish_skin "$HERMES_HOME/skins" + + if take_snapshot; then + publish_skin_everywhere + fi fi diff --git a/migrations/1788619462.sh b/migrations/1788619462.sh index 56f6c39a..0b491679 100644 --- a/migrations/1788619462.sh +++ b/migrations/1788619462.sh @@ -4,9 +4,6 @@ echo "Hand Hermes Desktop the Omarchy theme as a skin" # A Hermes the user set up some other way keeps whatever skin they chose. omarchy-pkg-present hermes-desktop || exit 0 -# Renders the skin for a theme applied before the template existed, publishes -# it, and names it in Hermes' config the same way a fresh install does: through -# Hermes itself, and only when Hermes is still on its default skin, so a skin -# the user chose stays. A Hermes that is not ready is told and skipped; a Hermes -# that refuses the write is cosmetic and must not hold up later migrations. -omarchy-theme-set-hermes --activate || true +# The same hand-over a fresh install does. A Hermes that is not ready or refuses +# the write is reported and done with there; only Omarchy's own failures return. +omarchy-theme-set-hermes --activate diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index b3ef6862..0a06f03a 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -34,6 +34,11 @@ cat >"$mock_bin/gum" <<'SH' printf '%s\0' "$@" >>"$OMARCHY_TEST_GUM_LOG" exit "${OMARCHY_TEST_GUM_STATUS:-1}" SH +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +echo "systemctl $*" >>"$OMARCHY_TEST_SYSTEMCTL_LOG" +SH + chmod +x "$mock_bin"/* seed_install() { @@ -58,9 +63,11 @@ seed_install() { remove() { : >"$test_tmp/installer-log" : >"$test_tmp/gum-log" + : >"$test_tmp/systemctl-log" OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \ OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \ OMARCHY_TEST_INSTALLER_STATUS="${OMARCHY_TEST_INSTALLER_STATUS:-0}" \ + OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \ OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ HOME="$test_home" PATH="$mock_bin:$PATH" \ bash "$ROOT/bin/omarchy-remove-ai-hermes" /dev/null 2>&1 @@ -71,8 +78,10 @@ remove() { remove_tty() { : >"$test_tmp/installer-log" : >"$test_tmp/gum-log" + : >"$test_tmp/systemctl-log" OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \ OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \ + OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \ OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \ HOME="$test_home" PATH="$mock_bin:$PATH" \ @@ -89,6 +98,10 @@ remove || fail "remove succeeds" [[ ! -d $test_home/.hermes/node ]] || fail "the node the app installed is removed" pass "removal takes the whole runtime the app installed" +grep -Fxq 'systemctl --user stop omarchy-hermes-theme.service' "$test_tmp/systemctl-log" || + fail "the unit the installer left waiting to hand over the theme is stopped" "$(cat "$test_tmp/systemctl-log")" +pass "removal stops the installer's theme hand-over" + [[ -d $test_home/.config/Hermes ]] || fail "gateway connections, tokens and settings survive removal" pass "removal keeps the app's connections and settings" diff --git a/test/shell.d/hermes-skin-migration-test.sh b/test/shell.d/hermes-skin-migration-test.sh index 9e27bf96..3fef5303 100644 --- a/test/shell.d/hermes-skin-migration-test.sh +++ b/test/shell.d/hermes-skin-migration-test.sh @@ -4,8 +4,8 @@ set -euo pipefail # The migration hands an existing Hermes Desktop install the Omarchy skin. It # is exercised here with the package probe and the skin hook stubbed, so a -# migration that reached a Hermes Omarchy did not install, or that let a -# Hermes refusing the write hold up later migrations, shows up in what it ran. +# migration that reached a Hermes Omarchy did not install, or that marked a +# failed hand-over done, shows up in what it ran. source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" @@ -22,7 +22,7 @@ mkdir -p "$mock_bin" cat >"$mock_bin/omarchy-pkg-present" <<'SH' #!/bin/bash -[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]] +[[ $1 == "hermes-desktop" && ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]] SH cat >"$mock_bin/omarchy-theme-set-hermes" <<'SH' @@ -53,5 +53,7 @@ run_migration || fail "migration exits clean with Hermes Desktop installed" fail "the skin is rendered, published and activated through the hook's deliberate form" "$(cat "$calls")" pass "migration hands the skin over through the hook" -OMARCHY_TEST_HOOK_FAILS=1 run_migration || fail "a skin the hook cannot hand over does not hold up later migrations" -pass "migration tolerates a skin the hook could not activate" +if OMARCHY_TEST_HOOK_FAILS=1 run_migration; then + fail "a hand-over that failed on Omarchy's side stays pending" +fi +pass "migration stays pending when the hand-over fails" diff --git a/test/shell.d/hermes-theme-test.sh b/test/shell.d/hermes-theme-test.sh index 6a68cb70..631ffe70 100644 --- a/test/shell.d/hermes-theme-test.sh +++ b/test/shell.d/hermes-theme-test.sh @@ -30,11 +30,23 @@ cat >"$mock_bin/omarchy-pkg-present" <<'SH' [[ $1 == "hermes-desktop" && ${OMARCHY_TEST_DESKTOP_INSTALLED:-1} == "1" ]] SH +# The hook runs the hermes the probe vets, ~/.local/bin/hermes, not one on PATH; +# reset_home installs this stub there and a decoy on PATH that must never run. cat >"$mock_bin/hermes" <<'SH' #!/bin/bash +echo "PATH hermes ran: $*" >>"$OMARCHY_TEST_HERMES_CALLS" +exit 1 +SH + +cat >"$mock_bin/hermes-stub" <<'SH' +#!/bin/bash printf '%s\n' "$*" >>"$OMARCHY_TEST_HERMES_CALLS" if [[ $1 == "config" && $2 == "get" ]]; then - printf '%s\n' "${OMARCHY_TEST_HERMES_SKIN:-default}" + [[ ${OMARCHY_TEST_HERMES_GET_FAILS:-0} == 0 ]] || exit 1 + printf '%s\n' "${OMARCHY_TEST_HERMES_SKIN-default}" +fi +if [[ $1 == "config" && $2 == "set" ]]; then + [[ ${OMARCHY_TEST_HERMES_SET_FAILS:-0} == 0 ]] || exit 1 fi SH @@ -51,6 +63,12 @@ SH cat >"$mock_bin/sleep" <<'SH' #!/bin/bash printf 'sleep %s\n' "$1" >>"$OMARCHY_TEST_HERMES_CALLS" +if [[ $1 == 60 && -n ${OMARCHY_TEST_SWAP_SOURCE:-} ]]; then + printf '%s\n' "$OMARCHY_TEST_SWAP_SOURCE" >"$HOME/.local/state/omarchy/current/theme/hermes.yaml" +fi +if [[ $1 == 60 && ${OMARCHY_TEST_DROP_SKINS:-0} == 1 ]]; then + rm -f "$HOME/.hermes/skins/omarchy.yaml" "$HOME"/.hermes/profiles/*/skins/omarchy.yaml +fi SH chmod +x "$mock_bin"/* @@ -75,7 +93,8 @@ reset_home() { local source="$good_skin" rm -rf "$test_home" - mkdir -p "$test_home/.local/state/omarchy/current/theme" "$hermes_home/skills" + mkdir -p "$test_home/.local/state/omarchy/current/theme" "$test_home/.local/bin" "$hermes_home/skills" + cp "$mock_bin/hermes-stub" "$test_home/.local/bin/hermes" : >"$hermes_calls" while (( $# > 0 )); do @@ -92,9 +111,13 @@ reset_home() { run_hook() { OMARCHY_TEST_HERMES_READY="${OMARCHY_TEST_HERMES_READY:-0}" \ - OMARCHY_TEST_HERMES_SKIN="${OMARCHY_TEST_HERMES_SKIN:-default}" \ + OMARCHY_TEST_HERMES_SKIN="${OMARCHY_TEST_HERMES_SKIN-default}" \ + OMARCHY_TEST_HERMES_GET_FAILS="${OMARCHY_TEST_HERMES_GET_FAILS:-0}" \ + OMARCHY_TEST_HERMES_SET_FAILS="${OMARCHY_TEST_HERMES_SET_FAILS:-0}" \ OMARCHY_TEST_HERMES_CALLS="$hermes_calls" \ OMARCHY_TEST_DESKTOP_INSTALLED="${OMARCHY_TEST_DESKTOP_INSTALLED:-1}" \ + OMARCHY_TEST_SWAP_SOURCE="${OMARCHY_TEST_SWAP_SOURCE:-}" \ + OMARCHY_TEST_DROP_SKINS="${OMARCHY_TEST_DROP_SKINS:-0}" \ PATH="$mock_bin:$PATH" \ HOME="$test_home" \ HERMES_HOME='' \ @@ -132,17 +155,48 @@ grep -q '#000000' "$skin" || fail "an unresolved placeholder keeps the previous grep -q 'not a plain color palette' "$test_tmp/stderr" || fail "an unresolved placeholder is reported" pass "a skin with unresolved colors is not published" +# mv would otherwise move the temp file inside a directory at the skin's path, +# leaving Hermes a directory to read and the temp file behind. +reset_home --set-up +mkdir -p "$skin" +if run_hook 2>/dev/null; then + fail "a directory at the skin's path is an error, not a place to put the skin" +fi +[[ -z $(ls -A "$skin") && $(ls "$hermes_home/skins") == "omarchy.yaml" ]] || + fail "nothing is left inside or beside a directory at the skin's path" "$(ls -R "$hermes_home/skins")" +pass "a directory at the skin's path is refused cleanly" + for bad in \ $'name: omarchy\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26"\nbanner_logo: "[link=file:///etc/passwd]x[/link]"' \ $'name: omarchy\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26\\"\\n ui_text: \\"#ffffff"' \ $'name: nord\ndescription: Nord\ncolors:\n background: "#2e3440"' \ $'description: Omarchy system theme\ncolors:\n background: "#1a1b26"' \ - $'name: omarchy\ndescription: Nord: arctic palette\ncolors:\n background: "#2e3440"'; do + $'name: omarchy\ndescription: Nord: arctic palette\ncolors:\n background: "#2e3440"' \ + $'name: omarchy\ncolors:\n background: "#1a1b26"\n#\rbanner_logo: "[link=file:///etc/passwd]x[/link]"' \ + $'name: omarchy\ncolors:\n background: "#1a1b26"\n#\xe2\x80\xa8banner_logo: "evil"' \ + $'name: omarchy\ncolors:\n background: "#1a1b26"\n#\xc2\x85banner_logo: "evil"' \ + $'name: omarchy\n background: "#1a1b26"\ncolors:' \ + $'name: omarchy\ncolors:\n background: "#1a1b26"\ncolors:' \ + $'colors:\n background: "#1a1b26"\nname: omarchy' \ + $'name: omarchy\ncolors:'; do reset_home --set-up "$bad" run_hook 2>/dev/null [[ ! -e $skin ]] || fail "a skin that is not exactly a named palette of hex colors is not published" "$bad" done -pass "a skin is held to the shape Hermes loads" +pass "a skin is held to the shape Hermes loads, on the lines Hermes' YAML reader sees" + +# A NUL cannot travel through a shell string, so it is written straight to the +# source; grep reads past one where YAML stops. +reset_home --set-up +printf 'name: omarchy\ncolors:\n background: "#1a1b26"\0\n' >"$test_home/.local/state/omarchy/current/theme/hermes.yaml" +run_hook 2>/dev/null +[[ ! -e $skin ]] || fail "a NUL byte in the skin is rejected" +pass "a skin carrying a NUL byte is not published" + +reset_home --set-up $'# rendered by Omarchy\nname: omarchy\n\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26"\n' +run_hook 2>/dev/null +[[ -f $skin ]] || fail "comments and blank lines are allowed around the palette" +pass "a well-formed skin with comments and blank lines is published" # -- a theme switch finishes a missed hand-over --------------------------------- @@ -173,10 +227,61 @@ pass "--activate does not ask which Hermes it is" reset_home --on ares OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN=ares run_hook 2>"$test_tmp/stderr" -! grep -q 'config set' "$hermes_calls" || fail "a theme switch never replaces a chosen skin" "$(cat "$hermes_calls")" +[[ -f $skin ]] || fail "a chosen skin still gets the Omarchy skin published beside it" +[[ ! -s $hermes_calls ]] || fail "a theme switch does not start a Hermes whose config names a chosen skin" "$(cat "$hermes_calls")" [[ ! -s $test_tmp/stderr ]] || fail "a chosen skin is left without comment on a theme switch" "$(cat "$test_tmp/stderr")" pass "a theme switch leaves a skin the user chose in Hermes" +# Hermes reads the config of the profile named in active_profile, so that is +# the config that says whether there is anything left to do. +reset_home --on omarchy +mkdir -p "$hermes_home/profiles/work" +printf 'display:\n skin: default\n' >"$hermes_home/profiles/work/config.yaml" +echo work >"$hermes_home/active_profile" +OMARCHY_TEST_HERMES_READY=1 run_hook +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "an active profile still on its default is switched even when the root config names the skin" "$(cat "$hermes_calls")" +pass "a theme switch follows the active Hermes profile" + +reset_home --set-up +mkdir -p "$hermes_home/profiles/work" +printf 'display:\n skin: ares\n' >"$hermes_home/profiles/work/config.yaml" +echo work >"$hermes_home/active_profile" +OMARCHY_TEST_HERMES_READY=1 run_hook +[[ ! -s $hermes_calls ]] || fail "a skin chosen in the active profile is not replaced" "$(cat "$hermes_calls")" +pass "a theme switch leaves a skin chosen in the active Hermes profile" + +# Hermes selects a profile once its directory exists; without a config of its +# own it is on the default skin whatever the root config says. +reset_home --on omarchy +mkdir -p "$hermes_home/profiles/work" +echo Work >"$hermes_home/active_profile" +OMARCHY_TEST_HERMES_READY=1 run_hook +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "an active profile without a config of its own is on the default and gets switched" "$(cat "$hermes_calls")" +pass "a theme switch follows an active profile that has no config yet" + +# Only a plainly named skin ends a switch early; anything Hermes might read as +# its default is left for Hermes to answer. +for line in 'skin: "default"' 'skin: default # chosen long ago' 'skin: null' 'skin: false'; do + reset_home --set-up + printf 'display:\n %s\n' "$line" >"$hermes_home/config.yaml" + OMARCHY_TEST_HERMES_READY=1 run_hook + grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "a config line Hermes reads as the default still gets the skin activated" "$line: $(cat "$hermes_calls")" +done +pass "a theme switch asks Hermes about any skin line that is not a plain name" + +reset_home --set-up +mkdir -p "$hermes_home/profiles/broken" +: >"$hermes_home/profiles/broken/skins" +OMARCHY_TEST_HERMES_READY=1 run_hook 2>"$test_tmp/stderr" || fail "a profile that cannot take the skin does not fail the hook" +[[ ! -s $test_tmp/stderr ]] || fail "a theme switch stays quiet about a profile it could not reach" "$(cat "$test_tmp/stderr")" +[[ -f $skin ]] || fail "the Hermes home still gets the skin beside a broken profile" +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "activation still happens beside a broken profile" "$(cat "$hermes_calls")" +pass "a profile that cannot take the skin costs nobody else" + # -- activation --------------------------------------------------------------- reset_home @@ -221,6 +326,20 @@ OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN=ares run_hook --activate 2> grep -q "'ares' skin" "$test_tmp/stderr" || fail "leaving a chosen skin is reported" pass "--activate leaves a skin the user chose in Hermes" +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_GET_FAILS=1 run_hook --activate 2>"$test_tmp/stderr" || fail "a Hermes that does not answer is not an error" +! grep -q 'config set' "$hermes_calls" || fail "no answer from Hermes is not taken for the default" "$(cat "$hermes_calls")" +grep -q 'did not say' "$test_tmp/stderr" || fail "an unanswered question is reported" +pass "--activate does not switch a Hermes that did not say which skin it is on" + +reset_home --set-up +mkdir -p "$hermes_home/hermes-agent" +touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete" +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SET_FAILS=1 run_hook --wait 2>"$test_tmp/stderr" || fail "a Hermes that refuses the write is not an error" +grep -q 'refused' "$test_tmp/stderr" || fail "a refused write is reported" +! grep -q 'sleep 60' "$hermes_calls" || fail "nothing is announced for a write that did not happen" "$(cat "$hermes_calls")" +pass "--activate reports a Hermes that refused the skin and moves on" + # -- a skin the current theme has not rendered yet ------------------------------- reset_home --set-up @@ -258,6 +377,21 @@ OMARCHY_TEST_HERMES_READY=1 run_hook --wait 2>/dev/null [[ -f $skin ]] || fail "--wait publishes the skin" pass "--wait activates once the desktop app has built its runtime" +reset_home --set-up +mkdir -p "$hermes_home/hermes-agent" "$hermes_home/profiles/work" +touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete" +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_SWAP_SOURCE=$'name: omarchy\ncolors:\n background: "#1a1b26"\nbanner_logo: "evil"' run_hook --wait 2>/dev/null +! grep -q 'banner_logo' "$skin" || fail "the republish after the gateway is up does not publish a theme that changed underneath into something rejected" +pass "--wait checks the skin again before republishing it" + +reset_home --set-up +mkdir -p "$hermes_home/hermes-agent" "$hermes_home/profiles/work" +touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete" +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_DROP_SKINS=1 run_hook --wait 2>/dev/null +[[ -f $skin && -f $hermes_home/profiles/work/skins/omarchy.yaml ]] || + fail "the republish after the gateway is up writes the skin to the Hermes home and every profile again" "$(ls -R "$hermes_home")" +pass "--wait republishes the skin everywhere once the gateway is up" + reset_home OMARCHY_TEST_HERMES_READY=1 run_hook --wait 2>"$test_tmp/stderr" [[ $(head -1 "$hermes_calls") == "sleep 10" && ! -e $hermes_home/skins ]] ||