Files
Omarchy-Stream/bin/sunshine-headless-primary.sh
Levi Woodard 0fe38e9797 headless-primary: relocate workspaces stranded on a mirrored output
On swap-back Hyprland brings DP-1 up normal, binds a workspace to it, then apply
mirrors DP-1 — trapping that workspace on a layout-excluded mirror (unreachable;
symptom: 'can't make workspace 1 show'). apply now moves any workspace off a
mirrored output onto the headless primary. Verified the heal operation
(move-off-mirror) manually.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 16:10:29 -06:00

189 lines
7.7 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
# watch long-running: re-apply on monitor hotplug AND rescue windows that
# open off-screen (this is what makes stray windows self-heal)
set -uo pipefail
log() { printf '[headless-primary] %s\n' "$*" >&2; }
WIDTH=5120
HEIGHT=1440
RATE=60
POS="0x0"
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
}
# Recenter any FLOATING, mapped window whose rectangle does not intersect the
# monitor its workspace lives on (i.e. it's stranded off-screen after a monitor
# topology change, or it opened off-screen). Pass a single window address to
# check just that window (used on the openwindow event); no arg = sweep all.
rescue_offscreen_windows() {
local only="${1:-}" mons clients addrs a
have_tools || return 0
mons="$(hyprctl monitors all -j 2>/dev/null)" || return 0
clients="$(hyprctl clients -j 2>/dev/null)" || return 0
addrs="$(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)"
for a in $addrs; 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 — it 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 that lives on a MIRRORED output onto the headless
# primary. A mirror is excluded from the layout, so a workspace bound to it
# is trapped and unreachable — exactly what happens on swap-back when
# Hyprland brings DP-1 up normal, binds a workspace to it, then we mirror
# it. (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"
}
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"
# monitoradded/removed -> full re-apply (re-impose mirror + rescue everything)
# openwindow -> targeted rescue if the NEW window opened off-screen
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 # debounce event bursts (v1+v2)
last=$now
log "monitor event (${event%%>*}) -> apply"
apply
;;
openwindow*)
addr="0x${event#openwindow>>}"; addr="${addr%%,*}"
sleep 0.3 # let the new window's geometry settle
rescue_offscreen_windows "$addr"
;;
esac
done
}
}
case "${1:-apply}" in
apply) apply ;;
rescue) rescue_offscreen_windows "${2:-}" ;;
watch) watch ;;
*) echo "Usage: $(basename "$0") {apply|rescue|watch}" >&2; exit 1 ;;
esac