From 616feda1032445fd29cf72e199728d19a197b5b4 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 27 Sep 2026 20:31:02 +0200 Subject: [PATCH] Add opt-in theme sync to herdr machines (#13430) * Add a theme-set sample hook that mirrors themes to herdr machines Co-Authored-By: Claude Opus 5.5 * Mirror theme changes to herdr machines by default Replaces the sample hook with omarchy-theme-sync, which omarchy-theme-set starts detached after every theme change, and a theme-sync-off toggle that stops a machine from both sending and receiving. Co-Authored-By: Claude Opus 5.5 * Rename omarchy-theme-sync to omarchy-theme-set-herdr-machines Co-Authored-By: Claude Opus 5.5 * Label the toggle Herdr Theme Sync and extract its menu guard Co-Authored-By: Claude Opus 5.5 * Answer the Herdr Theme Sync menu guard from omarchy-theme-set-herdr-machines Co-Authored-By: Claude Opus 5.5 * Sync herdr machines one at a time and drop the dry run Co-Authored-By: Claude Opus 5.5 * Check the theme sync toggle under the lock and stop special-casing this machine Co-Authored-By: Claude Opus 5.5 * Make herdr theme sync opt-in Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- bin/omarchy-theme-set | 3 + bin/omarchy-theme-set-herdr-machines | 75 ++++++++ bin/omarchy-toggle-theme-sync | 11 ++ default/omarchy/omarchy-menu.jsonc | 1 + docs/theming.md | 2 + manual/13-toggles-idle-screensaver.md | 1 + test/shell.d/theme-set-herdr-machines-test.sh | 168 ++++++++++++++++++ 7 files changed, 261 insertions(+) create mode 100755 bin/omarchy-theme-set-herdr-machines create mode 100755 bin/omarchy-toggle-theme-sync create mode 100644 test/shell.d/theme-set-herdr-machines-test.sh diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index f2cd0410..dff1501c 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -441,6 +441,9 @@ if [[ $THEME_HEADLESS != "1" ]]; then # Call hook on theme set omarchy-hook theme-set "$THEME_NAME" >/dev/null + # Mirror the theme to herdr machines. SSH can take a while, so this never holds up the theme change. + setsid omarchy-theme-set-herdr-machines >>"$HOME/.local/state/omarchy/theme-set-herdr-machines.log" 2>&1 /dev/null 2>&1 & diff --git a/bin/omarchy-theme-set-herdr-machines b/bin/omarchy-theme-set-herdr-machines new file mode 100755 index 00000000..95d184a1 --- /dev/null +++ b/bin/omarchy-theme-set-herdr-machines @@ -0,0 +1,75 @@ +#!/bin/bash + +# omarchy:summary=Mirror the current theme to your herdr machines that run Omarchy +# omarchy:args=[--available] +# omarchy:hidden=true + +# omarchy-theme-set runs this in the background after every theme change. Sync is off until +# the herdr-theme-sync toggle is turned on, and a machine only accepts themes with it on too. + +# Columns: id, label, SSH target, session, state. +# This machine may be listed too, and it is already on the theme, so it needs no special case. +targets=$(herdr machine list 2>/dev/null | awk -F '\t' '$5 == "enabled" { print $3 }') + +# The menu offers the toggle whenever there is a machine to sync to, whether or not sync is on. +if [[ $1 == "--available" ]]; then + [[ -n $targets ]] + exit +fi + +# Serialize runs and send whichever theme is current once the lock is held, +# so a slow earlier run can never overwrite a later theme change. +exec 9>"${XDG_RUNTIME_DIR:-/tmp}/omarchy-theme-set-herdr-machines.lock" +flock 9 + +# A theme that arrived from another machine stops here, so machines never echo it back. +# The toggle is checked once the lock is held, so turning sync off also stops a queued run. +if [[ -n $OMARCHY_THEME_SYNC_FROM ]] || ! omarchy-toggle-enabled herdr-theme-sync; then + exit 0 +fi + +theme=$(cat "$HOME/.local/state/omarchy/current/theme.name" 2>/dev/null) + +# Runs on the remote machine under a login shell, so OMARCHY_PATH and PATH are set there. +# SSH carries no desktop session, so the environment comes from the live Hyprland instance. +# The flag is read directly, so an Omarchy that predates theme sync simply counts as off. +# Braces make bash read the whole script before running it, so nothing it runs can consume the rest. +read -r -d '' REMOTE_SCRIPT <<'EOF' +{ + if [[ ! -f ~/.local/state/omarchy/toggles/herdr-theme-sync ]]; then + echo "theme sync is off" + exit + fi + + if [[ $(cat ~/.local/state/omarchy/current/theme.name 2>/dev/null) == "$theme" ]]; then + echo "already on $theme" + exit + fi + + export XDG_RUNTIME_DIR="/run/user/$(id -u)" + instance=$(hyprctl instances -j 2>/dev/null | jq -r 'max_by(.time) // empty | "\(.instance) \(.wl_socket)"') + if [[ -z $instance ]]; then + echo "no Hyprland session" + exit + fi + + read -r HYPRLAND_INSTANCE_SIGNATURE WAYLAND_DISPLAY <<<"$instance" + export HYPRLAND_INSTANCE_SIGNATURE WAYLAND_DISPLAY + export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" + export OMARCHY_THEME_SYNC_FROM="$from" + + if omarchy theme set "$theme" >/dev/null 2>&1; then + echo "set $theme" + else + echo "failed to set $theme" + fi +} &1 | tail -n 1) + echo "$(date '+%F %T') $target: ${result:-unreachable}" +done diff --git a/bin/omarchy-toggle-theme-sync b/bin/omarchy-toggle-theme-sync new file mode 100755 index 00000000..c65a7d52 --- /dev/null +++ b/bin/omarchy-toggle-theme-sync @@ -0,0 +1,11 @@ +#!/bin/bash + +# omarchy:summary=Toggle mirroring theme changes to and from your herdr machines + +omarchy-toggle herdr-theme-sync + +if omarchy-toggle-enabled herdr-theme-sync; then + omarchy-notification-send -g 󰸌 "Herdr theme sync enabled" +else + omarchy-notification-send -g 󰸌 "Herdr theme sync disabled" +fi diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 37251a7d..450bc5ac 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -91,6 +91,7 @@ "trigger.toggle.notifications": {"icon":"󰂛","label":"Notifications","action":"omarchy-toggle-notification-silencing"}, "trigger.toggle.crash-capture": {"icon":"󱚡","label":"Crash Capture","action":"omarchy-toggle-crash-capture"}, "trigger.toggle.screensaver": {"icon":"󱄄","label":"Screensaver","action":"omarchy-toggle-screensaver"}, + "trigger.toggle.theme-sync": {"icon":"󰸌","label":"Herdr Theme Sync","when":"omarchy-theme-set-herdr-machines --available","action":"omarchy-toggle-theme-sync"}, "trigger.toggle.nightlight": {"icon":"󰔎","label":"Nightlight","action":"omarchy-toggle-nightlight"}, "trigger.toggle.top-bar": {"icon":"󰍜","label":"Menu Bar","action":"omarchy-toggle-bar"}, "trigger.toggle.battery-percentage": {"icon":"󰁹","label":"Battery Percentage","when":"omarchy-hw-laptop","action":"omarchy-shell omarchy.power togglePercentage"}, diff --git a/docs/theming.md b/docs/theming.md index 2399a7b0..29986291 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -45,6 +45,8 @@ Making a new app follow theme changes means adding its restart/retint command to that list. Runs serialize on a `flock`, so scripted theme changes queue instead of racing. +Last, it starts `omarchy-theme-set-herdr-machines` detached. That command sets the same theme on every enabled `herdr machine list` target that runs Omarchy, over SSH inside the remote's live Hyprland session, and logs each machine's result to `~/.local/state/omarchy/theme-set-herdr-machines.log`. Sync is off by default. The `herdr-theme-sync` toggle turns it on, and a machine only sends and accepts themes while it is on. A mirrored change carries `OMARCHY_THEME_SYNC_FROM`, so the receiving machine never sends it on. + ## What an installed theme may not ship `themes//` in this repo is Omarchy's own code and is trusted. So is a theme the user wrote by hand in `~/.config/omarchy/themes//`: it is their machine and their file, and both stage in full. diff --git a/manual/13-toggles-idle-screensaver.md b/manual/13-toggles-idle-screensaver.md index 2effdac1..37770f37 100644 --- a/manual/13-toggles-idle-screensaver.md +++ b/manual/13-toggles-idle-screensaver.md @@ -15,6 +15,7 @@ From the terminal, the same switches are `omarchy toggle `. Run `omarchy | Stay awake (no idle lock) | `Super + Ctrl + I` | `omarchy toggle idle` | | Crash capture | — | `omarchy toggle crash-capture` | | Screensaver | — | `omarchy toggle screensaver` | +| [Herdr](https://herdr.dev) theme sync | — | `omarchy toggle theme sync` | | Menu bar | `Super + Shift + Space` | `omarchy toggle bar` | | Touchpad | `XF86TouchpadToggle` | `omarchy toggle touchpad` | | Touchscreen | — | `omarchy toggle touchscreen` | diff --git a/test/shell.d/theme-set-herdr-machines-test.sh b/test/shell.d/theme-set-herdr-machines-test.sh new file mode 100644 index 00000000..1774a10f --- /dev/null +++ b/test/shell.d/theme-set-herdr-machines-test.sh @@ -0,0 +1,168 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq + +SYNC_TEST=$(mktemp -d) +trap 'rm -rf "$SYNC_TEST"' EXIT +export SYNC_TEST + +local_home="$SYNC_TEST/home" +stub_bin="$SYNC_TEST/bin" +remote_bin="$SYNC_TEST/remote-bin" +mkdir -p "$local_home/.local/state/omarchy/current" "$stub_bin" "$remote_bin" "$SYNC_TEST/run" + +# herdr lists one unreachable machine first, this machine, and a disabled one. +cat >"$stub_bin/herdr" <<'EOF' +#!/bin/bash +printf '%s\t%s\t%s\t%s\t%s\n' \ + 1 down down default enabled \ + 2 alpha alpha default enabled \ + 3 beta beta default enabled \ + 4 gamma gamma default enabled \ + 5 local-box local-box default enabled \ + 6 retired retired default disabled +EOF + +cat >"$stub_bin/hostname" <<'EOF' +#!/bin/bash +echo local-box +EOF + +# ssh runs the script it receives against a fake home for that machine, as the remote would. +cat >"$stub_bin/ssh" <<'EOF' +#!/bin/bash +while [[ $1 == "-o" ]]; do + shift 2 +done +target=$1 +echo "$target" >>"$SYNC_TEST/ssh-calls" +remote="$SYNC_TEST/remotes/$target" +if [[ ! -d $remote ]]; then + echo "ssh: connect to host $target port 22: Connection refused" >&2 + exit 255 +fi +HOME="$remote" PATH="$SYNC_TEST/remote-bin:/usr/bin:/bin" bash -s +EOF + +cat >"$remote_bin/omarchy" <<'EOF' +#!/bin/bash +echo "$* from=$OMARCHY_THEME_SYNC_FROM session=$WAYLAND_DISPLAY" >>"$HOME/set.log" +EOF + +cat >"$remote_bin/hyprctl" <<'EOF' +#!/bin/bash +echo '[{"instance":"old","time":1,"wl_socket":"wayland-0"},{"instance":"live","time":2,"wl_socket":"wayland-1"}]' +EOF + +chmod +x "$stub_bin"/* "$remote_bin"/* + +reset_remotes() { + rm -rf "$SYNC_TEST/remotes" "$SYNC_TEST/ssh-calls" + local machine + for machine in alpha beta gamma local-box; do + mkdir -p "$SYNC_TEST/remotes/$machine/.local/state/omarchy/current" + echo "tokyo-night" >"$SYNC_TEST/remotes/$machine/.local/state/omarchy/current/theme.name" + mkdir -p "$SYNC_TEST/remotes/$machine/.local/state/omarchy/toggles" + touch "$SYNC_TEST/remotes/$machine/.local/state/omarchy/toggles/herdr-theme-sync" + done +} + +local_toggle="$local_home/.local/state/omarchy/toggles/herdr-theme-sync" + +set_local_theme() { + echo "$1" >"$local_home/.local/state/omarchy/current/theme.name" +} + +run_sync() { + HOME="$local_home" XDG_RUNTIME_DIR="$SYNC_TEST/run" PATH="$stub_bin:$ROOT/bin:$PATH" omarchy-theme-set-herdr-machines "$@" +} + +set_log() { + cat "$SYNC_TEST/remotes/$1/set.log" 2>/dev/null || true +} + +# Sync is off until it is turned on. +reset_remotes +set_local_theme lumon +run_sync >/dev/null +[[ ! -e $SYNC_TEST/ssh-calls ]] || fail "theme sync is off by default" +pass "theme sync is off by default" +mkdir -p "${local_toggle%/*}" +touch "$local_toggle" + +# Syncs every enabled machine except this one, skipping machines already on the theme. +reset_remotes +set_local_theme lumon +echo "lumon" >"$SYNC_TEST/remotes/beta/.local/state/omarchy/current/theme.name" +echo "lumon" >"$SYNC_TEST/remotes/local-box/.local/state/omarchy/current/theme.name" +output=$(run_sync) +[[ $(set_log alpha) == "theme set lumon from=local-box session=wayland-1" ]] || fail "sets the theme inside the newest Hyprland session" +pass "sets the theme inside the newest Hyprland session" +[[ -z $(set_log beta) && $output == *"beta: already on lumon"* ]] || fail "skips a machine already on the theme" +pass "skips a machine already on the theme" +! grep -qx retired "$SYNC_TEST/ssh-calls" || fail "skips disabled machines" +pass "skips disabled machines" +[[ -z $(set_log local-box) && $output == *"local-box: already on lumon"* ]] || fail "leaves this machine alone when it is listed" +pass "leaves this machine alone when it is listed" +[[ $output == *"down: ssh: connect to host down"* ]] || fail "logs an unreachable machine" +pass "logs an unreachable machine" + +# Connects one machine at a time, so an SSH agent asks for approval at most once. +[[ $(paste -sd ' ' "$SYNC_TEST/ssh-calls") == "down alpha beta gamma local-box" ]] || fail "connects to machines one at a time in order" +pass "connects to machines one at a time in order" + +# A machine with the toggle off refuses themes from other machines. +reset_remotes +rm "$SYNC_TEST/remotes/gamma/.local/state/omarchy/toggles/herdr-theme-sync" +output=$(run_sync) +[[ -z $(set_log gamma) && $output == *"gamma: theme sync is off"* ]] || fail "a remote with theme sync off keeps its theme" +pass "a remote with theme sync off keeps its theme" + +# A machine with the toggle off sends nothing. +reset_remotes +rm "$local_toggle" +run_sync >/dev/null +[[ ! -e $SYNC_TEST/ssh-calls ]] || fail "theme sync off stops sending" +pass "theme sync off stops sending" +touch "$local_toggle" + +# Turning sync off also stops a run that is still waiting for an earlier one to finish. +reset_remotes +exec 8>"$SYNC_TEST/run/omarchy-theme-set-herdr-machines.lock" +flock 8 +run_sync >/dev/null & +queued=$! +sleep 0.5 +rm "$local_toggle" +flock -u 8 +wait $queued +[[ ! -e $SYNC_TEST/ssh-calls ]] || fail "turning sync off stops a queued run" +pass "turning sync off stops a queued run" +touch "$local_toggle" + +# A theme that arrived from another machine is never sent on. +reset_remotes +OMARCHY_THEME_SYNC_FROM=elsewhere run_sync >/dev/null +[[ ! -e $SYNC_TEST/ssh-calls ]] || fail "a mirrored theme change is not mirrored again" +pass "a mirrored theme change is not mirrored again" + +# The theme name reaches the remote as data, never as shell code. +reset_remotes +set_local_theme 'demo$(touch "$HOME/injected")' +run_sync >/dev/null +[[ ! -e $SYNC_TEST/remotes/alpha/injected ]] || fail "a theme name cannot run commands on the remote" +[[ $(set_log alpha) == 'theme set demo$(touch "$HOME/injected") from=local-box session=wayland-1' ]] || fail "a theme name reaches the remote verbatim" +pass "a theme name reaches the remote verbatim without running commands" + +# The menu shows Herdr Theme Sync whenever herdr has an enabled machine, with sync on or off, +# so the toggle is there to turn it on. +rm "$local_toggle" +run_sync --available || fail "an enabled herdr machine makes theme sync available" +pass "an enabled herdr machine makes theme sync available while it is off" +printf '#!/bin/bash\nprintf "1\\tretired\\tretired\\tdefault\\tdisabled\\n"\n' >"$stub_bin/herdr" +! run_sync --available || fail "only disabled herdr machines make theme sync unavailable" +pass "only disabled herdr machines make theme sync unavailable"