Add a self-healing backstop so recovery no longer depends on an event firing: - periodic reconcile (every 8s): read-only drift check that runs apply ONLY when broken (no headless / DP-1 not mirroring / workspace trapped on a mirror / orphan / off-screen window) — no steady-state flicker. - delayed reconcile 1.5s after a monitor event to catch late workspace reassignment races. - socat auto-reconnect; exit cleanly when the session socket vanishes. New 'reconcile' subcommand. Verified: reconcile heals a deliberately broken mirror and no-ops on a healthy state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
238 lines
9.8 KiB
Bash
Executable File
238 lines
9.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# omarchy-moonlight — HEADLESS-PRIMARY manager.
|
|
#
|
|
# Model: the desktop permanently lives on a virtual HEADLESS output that always
|
|
# exists, independent of the physical monitor. Sunshine captures it. The
|
|
# physical DP-1 MIRRORS the headless primary when it's present, so the at-desk
|
|
# view == the stream. When DP-1 is off (remote, monitor asleep/unplugged),
|
|
# nothing is orphaned — the headless output keeps the whole desktop and the
|
|
# stream keeps working.
|
|
#
|
|
# Why this replaces the old "HEADLESS mirrors DP-1" design: when the physical
|
|
# monitor fully powers off, DP-1 disappears from Hyprland, the mirror collapses,
|
|
# and every workspace bound to DP-1 is stranded off-screen. Inverting the mirror
|
|
# (headless is the source of truth) removes that failure mode entirely.
|
|
#
|
|
# SAFETY: DP-1 is a NORMAL monitor in monitors.conf. This script only ever ADDS
|
|
# a mirror on top; if it never runs, DP-1 still displays normally. The physical
|
|
# screen is never left blank by this machinery.
|
|
#
|
|
# Subcommands:
|
|
# apply (default) establish/repair state — idempotent (topology + rescues)
|
|
# rescue just pull off-screen floating windows back on-screen
|
|
# reconcile read-only drift check; runs apply ONLY if something is wrong
|
|
# watch long-running self-healing daemon (events + periodic backstop)
|
|
|
|
set -uo pipefail
|
|
|
|
log() { printf '[headless-primary] %s\n' "$*" >&2; }
|
|
|
|
WIDTH=5120
|
|
HEIGHT=1440
|
|
RATE=60
|
|
POS="0x0"
|
|
RECONCILE_SECS=8 # periodic backstop cadence (self-heal if an event is missed)
|
|
CONF="$HOME/.config/sunshine/sunshine.conf"
|
|
|
|
ensure_hypr_sig() {
|
|
[[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]] && return 0
|
|
for sig in "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"/hypr/*/; do
|
|
[[ -d "$sig" ]] || continue
|
|
export HYPRLAND_INSTANCE_SIGNATURE="$(basename "$sig")"
|
|
return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
have_tools() { command -v hyprctl >/dev/null && command -v jq >/dev/null; }
|
|
|
|
# NOTE: always `hyprctl monitors all` — a MIRRORED output is excluded from plain
|
|
# `hyprctl monitors`, so the plain form is blind to exactly the outputs we manage.
|
|
headless_names() {
|
|
hyprctl monitors all -j 2>/dev/null \
|
|
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | sort -V
|
|
}
|
|
|
|
# Addresses of FLOATING, mapped windows whose rectangle does not intersect the
|
|
# monitor their workspace lives on (stranded off-screen). Optional single-addr arg.
|
|
offscreen_addrs() {
|
|
local only="${1:-}" mons clients
|
|
mons="$(hyprctl monitors all -j 2>/dev/null)" || return 0
|
|
clients="$(hyprctl clients -j 2>/dev/null)" || return 0
|
|
printf '%s' "$clients" | jq -r --argjson mons "$mons" --arg only "$only" '
|
|
($mons | map({key:(.id|tostring), value:{x:.x,y:.y,w:.width,h:.height}}) | from_entries) as $M
|
|
| .[]
|
|
| select(.mapped == true and .floating == true)
|
|
| select($only == "" or .address == $only)
|
|
| . as $c | ($M[$c.monitor|tostring]) as $m
|
|
| select($m != null)
|
|
| select( ($c.at[0]+$c.size[0]) <= $m.x or $c.at[0] >= ($m.x+$m.w)
|
|
or ($c.at[1]+$c.size[1]) <= $m.y or $c.at[1] >= ($m.y+$m.h) )
|
|
| .address' 2>/dev/null
|
|
}
|
|
|
|
rescue_offscreen_windows() {
|
|
local a
|
|
for a in $(offscreen_addrs "${1:-}"); do
|
|
hyprctl dispatch focuswindow "address:$a" >/dev/null 2>&1
|
|
hyprctl dispatch centerwindow >/dev/null 2>&1
|
|
log "rescued off-screen window $a"
|
|
done
|
|
}
|
|
|
|
apply() {
|
|
ensure_hypr_sig || { log "Hyprland not running; skip."; return 0; }
|
|
have_tools || { log "hyprctl/jq missing."; return 0; }
|
|
|
|
# 1. Exactly one headless output. Create if none; disable extras (remove is
|
|
# unreliable for mirrored/persistent headless — returns "output not found"
|
|
# and exits 0). Keep the lowest-numbered.
|
|
mapfile -t hs < <(headless_names)
|
|
local head="${hs[0]:-}"
|
|
if [[ -z "$head" ]]; then
|
|
log "no headless output; creating one"
|
|
hyprctl output create headless >/dev/null
|
|
for _ in 1 2 3 4 5; do
|
|
head="$(headless_names | head -1)"
|
|
[[ -n "$head" ]] && break
|
|
sleep 0.2
|
|
done
|
|
else
|
|
for extra in "${hs[@]:1}"; do
|
|
hyprctl keyword monitor "$extra,disable" >/dev/null 2>&1 || true
|
|
done
|
|
fi
|
|
[[ -z "$head" ]] && { log "failed to obtain a headless output"; return 0; }
|
|
|
|
# 2. Size/position the headless primary (top-left origin, full res, scale 1).
|
|
hyprctl keyword monitor "$head,${WIDTH}x${HEIGHT}@${RATE},${POS},1" >/dev/null
|
|
|
|
# 3. If DP-1 is present, mirror the headless primary onto it (at-desk == stream).
|
|
if hyprctl monitors all -j | jq -e '.[] | select(.name=="DP-1")' >/dev/null 2>&1; then
|
|
hyprctl keyword monitor "DP-1,${WIDTH}x${HEIGHT}@${RATE},${POS},1,mirror,$head" >/dev/null
|
|
log "DP-1 mirroring $head"
|
|
fi
|
|
|
|
# 4. Relocate any workspace stranded on a MIRRORED output onto the primary.
|
|
# On swap-back Hyprland brings DP-1 up normal, binds a workspace to it, then
|
|
# we mirror it — trapping that workspace on a layout-excluded mirror. (You
|
|
# can move a workspace OFF a mirror but not ONTO one; tostring guards
|
|
# mirrorOf being null | "None" | a numeric id.)
|
|
local mon ws
|
|
for mon in $(hyprctl monitors all -j \
|
|
| jq -r '.[] | select(((.mirrorOf // "None") | tostring | ascii_downcase) != "none") | .name'); do
|
|
[[ "$mon" == "$head" ]] && continue
|
|
for ws in $(hyprctl workspaces -j | jq -r --arg m "$mon" '.[] | select(.monitor==$m) | .id'); do
|
|
log "relocating workspace $ws off mirror $mon -> $head"
|
|
hyprctl dispatch moveworkspacetomonitor "$ws $head" >/dev/null 2>&1 || true
|
|
done
|
|
done
|
|
|
|
# 5. Rescue orphaned workspaces (monitorID == -1) onto the headless primary.
|
|
# Narrow by design — a healthy second monitor (DP-2) is left alone.
|
|
for ws in $(hyprctl workspaces -j | jq -r '.[] | select(.monitorID == -1) | .id'); do
|
|
log "rescuing orphaned workspace $ws -> $head"
|
|
hyprctl dispatch moveworkspacetomonitor "$ws $head" >/dev/null 2>&1 || true
|
|
done
|
|
|
|
# 6. Rescue any floating windows stranded off-screen by the topology change.
|
|
rescue_offscreen_windows
|
|
|
|
# 7. Keep Sunshine's output_name pointed at the live headless name.
|
|
if [[ -f "$CONF" ]] && grep -qF '# managed-by: omarchy-moonlight' "$CONF"; then
|
|
local cur; cur="$(awk '/^output_name = / {print $3; exit}' "$CONF" 2>/dev/null || true)"
|
|
if [[ "$cur" != "$head" ]]; then
|
|
log "sunshine.conf output_name: ${cur:-(unset)} -> $head"
|
|
sed -i "s|^output_name = .*|output_name = $head|" "$CONF" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
|
|
log "headless-primary established on $head"
|
|
}
|
|
|
|
# Read-only drift check. Runs apply ONLY when something is actually wrong, so it
|
|
# never causes steady-state flicker. This is the backstop that makes the setup
|
|
# self-heal even if a Hyprland event is missed entirely.
|
|
reconcile() {
|
|
ensure_hypr_sig || return 0
|
|
have_tools || return 0
|
|
local mons broken=0 m
|
|
mons="$(hyprctl monitors all -j 2>/dev/null)" || return 0
|
|
|
|
# a) exactly one enabled headless output
|
|
[[ "$(printf '%s' "$mons" | jq -r '[.[]|select((.name|startswith("HEADLESS")) and (.disabled|not))]|length')" == "1" ]] || broken=1
|
|
|
|
# b) if DP-1 is present it must be mirroring (never a stray normal output)
|
|
if printf '%s' "$mons" | jq -e '.[]|select(.name=="DP-1")' >/dev/null 2>&1; then
|
|
printf '%s' "$mons" | jq -e '.[]|select(.name=="DP-1" and (((.mirrorOf//"None")|tostring|ascii_downcase)=="none"))' >/dev/null 2>&1 && broken=1
|
|
fi
|
|
|
|
# c) a workspace stranded on a mirrored output
|
|
for m in $(printf '%s' "$mons" | jq -r '.[]|select(((.mirrorOf//"None")|tostring|ascii_downcase)!="none")|.name'); do
|
|
hyprctl workspaces -j | jq -e --arg m "$m" '.[]|select(.monitor==$m)' >/dev/null 2>&1 && broken=1
|
|
done
|
|
|
|
# d) orphaned workspace
|
|
hyprctl workspaces -j | jq -e '.[]|select(.monitorID==-1)' >/dev/null 2>&1 && broken=1
|
|
|
|
# e) off-screen floating window
|
|
[[ -n "$(offscreen_addrs)" ]] && broken=1
|
|
|
|
if [[ "$broken" == "1" ]]; then
|
|
log "reconcile: state drift detected -> apply"
|
|
apply
|
|
fi
|
|
}
|
|
|
|
watch() {
|
|
ensure_hypr_sig || { log "Hyprland not running; watcher exiting."; return 0; }
|
|
have_tools || { log "hyprctl/jq missing; watcher exiting."; return 0; }
|
|
command -v socat >/dev/null || { log "socat missing; no watcher."; return 0; }
|
|
local sock="${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.socket2.sock"
|
|
log "watching Hyprland events on $sock (reconcile every ${RECONCILE_SECS}s)"
|
|
|
|
# Backstop: periodic reconcile self-heals even if an event never arrives.
|
|
# Exits when the session socket disappears (Hyprland gone) so we don't linger.
|
|
( while sleep "$RECONCILE_SECS"; do [[ -S "$sock" ]] || exit 0; reconcile; done ) &
|
|
local bg=$!
|
|
trap 'kill "$bg" 2>/dev/null' EXIT INT TERM
|
|
|
|
# Event loop with auto-reconnect (survives transient socat drops). Pinned to
|
|
# this session's socket; if it vanishes the session ended → exit cleanly and
|
|
# let the next login's exec-once start a fresh watcher.
|
|
while [[ -S "$sock" ]]; do
|
|
socat -U - "UNIX-CONNECT:$sock" 2>/dev/null | {
|
|
local last=0 now addr
|
|
while read -r event; do
|
|
case "$event" in
|
|
monitoradded*|monitorremoved*)
|
|
now=$(date +%s)
|
|
(( now - last < 2 )) && continue # coalesce the v1+v2 burst
|
|
last=$now
|
|
log "monitor event (${event%%>*}) -> apply (+delayed reconcile)"
|
|
apply
|
|
( sleep 1.5; reconcile ) & # catch late workspace reassignment
|
|
;;
|
|
openwindow*)
|
|
addr="0x${event#openwindow>>}"; addr="${addr%%,*}"
|
|
sleep 0.3 # let the new window settle
|
|
rescue_offscreen_windows "$addr"
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
[[ -S "$sock" ]] || break
|
|
log "event socket dropped; reconnecting in 1s"
|
|
sleep 1
|
|
done
|
|
log "watcher: session socket gone; exiting"
|
|
}
|
|
|
|
case "${1:-apply}" in
|
|
apply) apply ;;
|
|
rescue) rescue_offscreen_windows "${2:-}" ;;
|
|
reconcile) reconcile ;;
|
|
watch) watch ;;
|
|
*) echo "Usage: $(basename "$0") {apply|rescue|reconcile|watch}" >&2; exit 1 ;;
|
|
esac
|