Motion-Wallpaper-Omarchy/motion-wallpaper-toggle
28allday 154bcc85d4 Also recover when HYPRLAND_INSTANCE_SIGNATURE is stale
Walker's env can carry a stale HIS across Hyprland restarts — the variable
points at a signature whose socket dir no longer exists, so hyprctl dumps
a plain-text "not running" error to stdout and the TUI silently bails.

Validate HIS against `$XDG_RUNTIME_DIR/hypr/$HIS/` and fall through to the
`hyprctl instances` recovery path when the directory is missing, not only
when HIS is unset. Same detection added to the watcher.
2026-04-23 22:50:14 +01:00

584 lines
18 KiB
Bash

#!/usr/bin/env bash
# ==============================================================================
# Motion Wallpaper — gum-powered TUI for mpvpaper on Omarchy / Hyprland.
#
# Actions:
# toggle (default) Interactive TUI. If running, offers Stop / Change.
# start Non-interactive start from saved state (for systemd).
# stop Stop mpvpaper and restore the normal wallpaper.
# change Pick a new video while already running.
# status Print current state (TUI header via gum).
#
# Files:
# ~/.config/motion-wallpaper/state last video + target monitor
# ~/Videos/Wallpapers/ optional quick-pick library
# ~/.cache/motion-wallpaper.log runtime log
# ==============================================================================
set -euo pipefail
APP_NAME="Motion Wallpaper"
STATE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/motion-wallpaper"
STATE_FILE="$STATE_DIR/state"
LOG_DIR="${XDG_CACHE_HOME:-$HOME/.cache}"
LOG_FILE="$LOG_DIR/motion-wallpaper.log"
LIBRARY_DIR="$HOME/Videos/Wallpapers"
# mpv options forwarded via mpvpaper -o. An input-ipc-server socket is opened
# so our companion watcher can send pause/resume commands when Hyprland
# reports a fullscreen window (mpvpaper's own -p is unreliable on 0.54.x).
RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
MPV_IPC_SOCK="$RUNTIME_DIR/motion-wallpaper-mpv.sock"
MPV_OPTS="--loop --no-audio --mute=yes --vo=gpu --profile=high-quality --input-ipc-server=$MPV_IPC_SOCK"
# Catppuccin Mocha-ish accents
COLOR_ACCENT="#cba6f7"
COLOR_ERROR="#f38ba8"
COLOR_OK="#a6e3a1"
COLOR_MUTED="#6c7086"
BROWSE_SENTINEL="── Browse filesystem… ──"
mkdir -p "$STATE_DIR" "$LOG_DIR"
# ===== helpers ================================================================
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE"
}
require_gum() {
if ! command -v gum >/dev/null 2>&1; then
echo "ERROR: gum is not installed. Run: sudo pacman -S gum" >&2
exit 1
fi
}
require_tty() {
if [ ! -t 0 ] || [ ! -t 1 ]; then
cat >&2 <<MSG
$APP_NAME: interactive mode requires a terminal.
Run from a terminal, or use a non-interactive action:
$0 start | stop | status
MSG
exit 1
fi
}
tui_err() {
gum style --foreground="$COLOR_ERROR" --bold "ERROR: $1"
log "ERROR: $1"
# Hold the terminal open so launcher-spawned windows don't flash away.
if [ -t 0 ]; then
gum input --placeholder="Press enter to close…" >/dev/null 2>&1 || true
fi
}
tui_ok() {
gum style --foreground="$COLOR_OK" "$1"
log "$1"
}
notify() {
# Fire-and-forget system notification so the user sees results after the
# TUI terminal closes.
if command -v notify-send >/dev/null 2>&1; then
notify-send "$APP_NAME" "$1" || true
fi
log "$1"
}
load_state() {
LAST_VIDEO=""
LAST_TARGET=""
LAST_DIR=""
[ -f "$STATE_FILE" ] || return 0
# Parse KEY="VALUE" lines directly instead of `source`, so a maliciously
# crafted video path (e.g. containing `";rm -rf …`) can't execute.
local key val
while IFS='=' read -r key val; do
val="${val#\"}"
val="${val%\"}"
case "$key" in
LAST_VIDEO) LAST_VIDEO="$val" ;;
LAST_TARGET) LAST_TARGET="$val" ;;
LAST_DIR) LAST_DIR="$val" ;;
esac
done < "$STATE_FILE"
}
save_state() {
# $1 video, $2 target, $3 (optional) last dir — defaults to dirname of $1
# so the filesystem browser re-opens where the user last landed.
local dir="${3:-$(dirname "$1")}"
umask 077
# Atomic write: avoids leaving a truncated file if the process dies mid-cat.
local tmp="$STATE_FILE.tmp"
cat > "$tmp" <<STATE
LAST_VIDEO="$1"
LAST_TARGET="$2"
LAST_DIR="$dir"
STATE
mv -f "$tmp" "$STATE_FILE"
}
is_running() {
pgrep -x mpvpaper >/dev/null 2>&1
}
# ===== autostart (systemd user unit) ==========================================
autostart_installed() {
systemctl --user list-unit-files motion-wallpaper.service >/dev/null 2>&1
}
autostart_enabled() {
systemctl --user is-enabled motion-wallpaper.service >/dev/null 2>&1
}
autostart_enable() {
if ! autostart_installed; then
tui_err "motion-wallpaper.service is not installed. Re-run wallpaper.sh."
return 1
fi
if ! systemctl --user enable motion-wallpaper.service >/dev/null 2>&1; then
tui_err "Failed to enable autostart (systemctl error)."
return 1
fi
log "autostart enabled"
return 0
}
autostart_disable() {
systemctl --user disable motion-wallpaper.service >/dev/null 2>&1 || true
log "autostart disabled"
}
show_header() {
load_state
local status_line target_line video_line autostart_line
if is_running; then
status_line="status: $(gum style --foreground="$COLOR_OK" running)"
else
status_line="status: $(gum style --foreground="$COLOR_MUTED" stopped)"
fi
target_line="target: ${LAST_TARGET:-$(gum style --foreground="$COLOR_MUTED" --italic '(none)')}"
video_line="video: ${LAST_VIDEO:-$(gum style --foreground="$COLOR_MUTED" --italic '(none)')}"
if autostart_enabled; then
autostart_line="autostart: $(gum style --foreground="$COLOR_OK" enabled)"
else
autostart_line="autostart: $(gum style --foreground="$COLOR_MUTED" disabled)"
fi
gum style --border=rounded --border-foreground="$COLOR_ACCENT" \
--padding="1 2" --margin="1 0" \
"$(gum style --bold --foreground="$COLOR_ACCENT" "$APP_NAME")" \
"" \
"$status_line" \
"$target_line" \
"$video_line" \
"$autostart_line"
}
# ===== selection ==============================================================
ensure_hyprland_env() {
# The TUI can be launched from contexts whose env doesn't carry a valid
# HYPRLAND_INSTANCE_SIGNATURE. Two failure modes:
# 1) HIS unset (fresh login shell from a non-graphical context)
# 2) HIS set but STALE — points to a previous Hyprland session whose
# socket no longer exists (seen when Walker's env carries the
# signature from an earlier login). The second case is silent but
# equally broken: hyprctl dumps a plain-text error on stdout.
# `hyprctl instances` always reports the *live* signatures, so recover
# from that. Leave HIS unchanged if we can't find a live instance.
local his="${HYPRLAND_INSTANCE_SIGNATURE:-}"
if [ -n "$his" ] && [ -d "$RUNTIME_DIR/hypr/$his" ]; then
return 0
fi
local sig
sig="$(hyprctl instances 2>/dev/null | awk '/^instance /{sub(/:$/,"",$2); print $2; exit}')"
if [ -n "$sig" ] && [ "$sig" != "$his" ]; then
export HYPRLAND_INSTANCE_SIGNATURE="$sig"
if [ -n "$his" ]; then
log "replaced stale HYPRLAND_INSTANCE_SIGNATURE ($his$sig)"
else
log "recovered HYPRLAND_INSTANCE_SIGNATURE=$sig from hyprctl instances"
fi
fi
}
get_monitors() {
# hyprctl's failure modes are noisy: when HYPRLAND_INSTANCE_SIGNATURE isn't
# set (can happen when the TUI is spawned from a launcher whose env doesn't
# inherit the Hyprland session), hyprctl prints a plain-text error to stdout
# and exits 0 — which then makes jq complain loudly. Validate the JSON and
# fall back to parsing the human-readable form.
ensure_hyprland_env
local mon_json mon_text
mon_json="$(hyprctl monitors -j 2>/dev/null || true)"
if [ -n "$mon_json" ] && printf '%s' "$mon_json" | jq -e . >/dev/null 2>&1; then
printf '%s' "$mon_json" | jq -r '.[].name'
return 0
fi
mon_text="$(hyprctl monitors 2>/dev/null || true)"
if [ -n "$mon_text" ]; then
printf '%s\n' "$mon_text" \
| awk '/^Monitor /{print $2; ok=1} END{exit !ok}' \
&& return 0
fi
log "get_monitors: hyprctl produced no usable output (HIS=${HYPRLAND_INSTANCE_SIGNATURE:-unset})"
return 1
}
pick_target() {
command -v hyprctl >/dev/null || { tui_err "hyprctl not found. Are you in Hyprland?"; return 1; }
command -v jq >/dev/null || { tui_err "jq is not installed."; return 1; }
local monitors
if ! monitors="$(get_monitors)" || [ -z "$monitors" ]; then
if [ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]; then
tui_err "HYPRLAND_INSTANCE_SIGNATURE is not set — the launcher didn't inherit the Hyprland session. Try running motion-wallpaper-toggle from a regular terminal."
else
tui_err "Could not read monitors from hyprctl. See $LOG_FILE."
fi
return 1
fi
local count
count="$(printf '%s\n' "$monitors" | wc -l)"
if [ "$count" -eq 1 ]; then
printf '%s' "$monitors"
return 0
fi
local selected
selected=$( { echo "All monitors"; printf '%s\n' "$monitors"; } \
| gum choose --header="Select a monitor") || return 1
if [ "$selected" = "All monitors" ]; then
printf '%s' '*'
else
printf '%s' "$selected"
fi
}
browse_filesystem() {
# Start gum file at the last directory we successfully picked from, so
# changing videos returns the user to where they were. Falls back to the
# last video's dirname (for state files pre-dating LAST_DIR), then $HOME.
load_state
local start_dir="${LAST_DIR:-}"
if [ -z "$start_dir" ] && [ -n "${LAST_VIDEO:-}" ]; then
start_dir="$(dirname "$LAST_VIDEO")"
fi
if [ -z "$start_dir" ] || [ ! -d "$start_dir" ]; then
start_dir="$HOME"
fi
gum file --height=20 "$start_dir"
}
pick_video() {
local library=() basenames=() v
if [ -d "$LIBRARY_DIR" ]; then
while IFS= read -r -d '' f; do
library+=("$f")
done < <(
find "$LIBRARY_DIR" -maxdepth 1 -type f \
\( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' -o -iname '*.mov' -o -iname '*.avi' \) \
-print0 2>/dev/null | sort -z
)
fi
if [ ${#library[@]} -eq 0 ]; then
browse_filesystem || return 1
return 0
fi
for v in "${library[@]}"; do
basenames+=("$(basename "$v")")
done
local choice
choice=$( { printf '%s\n' "${basenames[@]}"; echo "$BROWSE_SENTINEL"; } \
| gum choose --header="Choose a video from $LIBRARY_DIR") || return 1
if [ "$choice" = "$BROWSE_SENTINEL" ]; then
browse_filesystem
else
printf '%s' "$LIBRARY_DIR/$choice"
fi
}
# ===== mpvpaper control =======================================================
kill_static_wallpapers() {
pkill -x hyprpaper 2>/dev/null || true
pkill -x swaybg 2>/dev/null || true
}
kill_watcher() {
pkill -f motion-wallpaper-watcher 2>/dev/null || true
}
start_watcher() {
local watcher
# PATH in launcher-spawned terminals is unreliable (minimal systemd env etc),
# so fall back to the script's own directory where the installer puts both.
watcher="$(command -v motion-wallpaper-watcher 2>/dev/null || true)"
if [ -z "$watcher" ]; then
local self_dir
self_dir="$(dirname "$(readlink -f "$0")")"
if [ -x "$self_dir/motion-wallpaper-watcher" ]; then
watcher="$self_dir/motion-wallpaper-watcher"
fi
fi
if [ -z "$watcher" ]; then
log "watcher binary not found — auto-pause disabled"
return 0
fi
kill_watcher
setsid "$watcher" < /dev/null >> "$LOG_FILE" 2>&1 &
disown 2>/dev/null || true
log "watcher spawned via $watcher"
}
restore_static_wallpaper() {
local omarchy_bg="$HOME/.config/omarchy/current/background"
if [ -e "$omarchy_bg" ]; then
# Stop may be called twice in quick succession when the TUI stops the
# wallpaper and systemd's ExecStop fires right after. Skip if someone
# else already put swaybg back so we don't kill-and-respawn (causes a
# visible flicker).
if pgrep -x swaybg >/dev/null 2>&1 && ! pgrep -x mpvpaper >/dev/null 2>&1; then
log "swaybg already running, skipping restore"
return 0
fi
pkill -x hyprpaper 2>/dev/null || true
pkill -x swaybg 2>/dev/null || true
if command -v uwsm-app >/dev/null 2>&1; then
setsid uwsm-app -- swaybg -i "$omarchy_bg" -m fill >/dev/null 2>&1 &
else
setsid swaybg -i "$omarchy_bg" -m fill >/dev/null 2>&1 &
fi
disown 2>/dev/null || true
log "restored swaybg -> $omarchy_bg"
elif command -v hyprpaper >/dev/null 2>&1; then
hyprctl dispatch exec hyprpaper >/dev/null 2>&1 || true
log "restored hyprpaper"
else
log "no known static wallpaper daemon to restore"
fi
}
start_mpvpaper_fg() {
local target="$1" video="$2"
kill_static_wallpapers
pkill -x mpvpaper 2>/dev/null || true
sleep 0.3
log "systemd start target=$target video=$video"
start_watcher
# shellcheck disable=SC2086 # intentional word-splitting on MPV_OPTS
exec mpvpaper -o "$MPV_OPTS" "$target" "$video"
}
start_mpvpaper_bg() {
local target="$1" video="$2"
kill_static_wallpapers
pkill -x mpvpaper 2>/dev/null || true
sleep 0.3
log "start target=$target video=$video"
# setsid detaches from the controlling terminal so mpvpaper survives the
# TUI terminal closing; uwsm-app parents it to the user systemd scope
# (matches how Omarchy autostarts swaybg). Without both, mpvpaper was
# getting SIGHUP'd when the Walker-spawned terminal exited.
# shellcheck disable=SC2086 # intentional word-splitting on MPV_OPTS
if command -v uwsm-app >/dev/null 2>&1; then
setsid uwsm-app -- mpvpaper -o "$MPV_OPTS" "$target" "$video" \
< /dev/null >> "$LOG_FILE" 2>&1 &
else
# shellcheck disable=SC2086
setsid mpvpaper -o "$MPV_OPTS" "$target" "$video" \
< /dev/null >> "$LOG_FILE" 2>&1 &
fi
disown 2>/dev/null || true
start_watcher
sleep 0.8
if ! is_running; then
tui_err "mpvpaper failed to start. See $LOG_FILE for details."
return 1
fi
}
stop_mpvpaper() {
# Serialize stops so the TUI-initiated path and systemd's ExecStop (which
# fires when the TUI kills mpvpaper) can't both be mid-restore at once.
# `flock -n` → second caller skips cleanly if the first still holds it.
(
flock -n 9 || { log "stop already in progress, skipping"; exit 0; }
kill_watcher
pkill -x mpvpaper 2>/dev/null || true
# mpv cleans up its IPC socket on exit; a hard kill can leave it dangling.
rm -f "$MPV_IPC_SOCK" 2>/dev/null || true
restore_static_wallpaper
log "stopped"
) 9>"$STATE_DIR/.stop.lock"
}
# ===== actions ================================================================
action_toggle() {
require_gum
require_tty
if is_running; then
show_header
local autostart_label
if autostart_enabled; then
autostart_label="Turn autostart OFF"
else
autostart_label="Turn autostart ON"
fi
local choice
choice=$(gum choose --header="What would you like to do?" \
"Stop motion wallpaper" "Change video" "$autostart_label" "Cancel") || exit 0
case "$choice" in
"Stop motion wallpaper")
stop_mpvpaper
tui_ok "Stopped. Normal wallpaper restored."
notify "Motion wallpaper stopped."
# If autostart is on, a plain stop will let the wallpaper return on
# next reboot. Offer to turn autostart off so "stop" means "stop".
if autostart_enabled; then
if gum confirm "Autostart is still enabled — also disable it so the wallpaper doesn't resume after reboot?"; then
autostart_disable
tui_ok "Autostart disabled."
fi
fi
;;
"Change video")
load_state
local video
video=$(pick_video) || exit 0
[ -z "$video" ] && exit 0
[ -f "$video" ] || { tui_err "File not found: $video"; exit 1; }
save_state "$video" "$LAST_TARGET"
start_mpvpaper_bg "$LAST_TARGET" "$video" || exit 1
tui_ok "Swapped to $(basename "$video")."
notify "Motion wallpaper updated."
;;
"Turn autostart ON")
autostart_enable && tui_ok "Autostart enabled — wallpaper will resume after reboot."
;;
"Turn autostart OFF")
autostart_disable
tui_ok "Autostart disabled."
# Motion wallpaper is still running at this point. Offer to also stop
# it now so the label "turn off" matches visible behaviour.
if is_running; then
if gum confirm "Motion wallpaper is still running — stop it now too?"; then
stop_mpvpaper
tui_ok "Stopped. Normal wallpaper restored."
notify "Motion wallpaper stopped."
fi
fi
;;
Cancel) exit 0 ;;
esac
return 0
fi
show_header
local target video
target=$(pick_target) || exit 0
[ -z "$target" ] && exit 0
video=$(pick_video) || exit 0
[ -z "$video" ] && exit 0
[ -f "$video" ] || { tui_err "File not found: $video"; exit 1; }
save_state "$video" "$target"
start_mpvpaper_bg "$target" "$video" || exit 1
tui_ok "Started $(basename "$video") on $target."
notify "Motion wallpaper started on $target."
# Offer autostart on first fresh start (skipped silently if already on or
# if the systemd unit isn't installed).
if autostart_installed && ! autostart_enabled; then
if gum confirm "Start motion wallpaper automatically after login / reboot?"; then
autostart_enable && tui_ok "Autostart enabled."
fi
fi
}
action_change() {
require_gum
require_tty
if ! is_running; then
tui_err "Motion wallpaper is not running."
exit 1
fi
load_state
local video
video=$(pick_video) || exit 0
[ -z "$video" ] && exit 0
[ -f "$video" ] || { tui_err "File not found: $video"; exit 1; }
save_state "$video" "$LAST_TARGET"
start_mpvpaper_bg "$LAST_TARGET" "$video" || exit 1
tui_ok "Swapped to $(basename "$video")."
notify "Motion wallpaper updated."
}
action_start() {
load_state
if [ -z "$LAST_VIDEO" ] || [ -z "$LAST_TARGET" ]; then
log "autostart: no saved state, exiting cleanly"
exit 0
fi
if [ ! -f "$LAST_VIDEO" ]; then
log "autostart: saved video missing ($LAST_VIDEO)"
exit 1
fi
start_mpvpaper_fg "$LAST_TARGET" "$LAST_VIDEO"
}
action_stop() {
stop_mpvpaper
}
action_status() {
if command -v gum >/dev/null 2>&1 && [ -t 1 ]; then
show_header
else
load_state
if is_running; then echo "status: running"; else echo "status: stopped"; fi
if [ -n "${LAST_TARGET:-}" ]; then echo "target: $LAST_TARGET"; fi
if [ -n "${LAST_VIDEO:-}" ]; then echo "video: $LAST_VIDEO"; fi
fi
}
# ===== main ===================================================================
case "${1:-toggle}" in
toggle) action_toggle ;;
start) action_start ;;
stop) action_stop ;;
change) action_change ;;
status) action_status ;;
-h|--help)
cat <<USAGE
$APP_NAME
Usage: ${0##*/} [toggle|start|stop|change|status]
toggle Interactive gum TUI (default). If running, offers Stop / Change.
start Start from saved state without prompting (for systemd).
stop Stop mpvpaper and restore the normal wallpaper.
change Pick a new video while already running.
status Print current state.
USAGE
;;
*)
echo "Unknown action: $1" >&2
echo "Try: ${0##*/} --help" >&2
exit 1
;;
esac