#!/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