Files
Omarchy-Stream/bin/sunshine-headless-primary.sh
Levi Woodard 2bd57a8395 headless-primary: make the watcher always-converge
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>
2026-07-30 16:33:10 -06:00

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