Add headless-primary streaming mode + off-screen window self-heal

Desktop lives on a persistent HEADLESS output that Sunshine captures; DP-1
mirrors it when present, so streaming works whether or not the physical monitor
is on. Fixes workspace/window orphaning when the monitor powers off (the old
'HEADLESS mirrors DP-1' design collapsed when DP-1 disappeared).

- bin/sunshine-headless-primary.sh: apply (idempotent topology + rescue),
  rescue (recenter off-screen floating windows), watch (self-heal on monitor
  add/remove and on windows that open off-screen).
- bin/sunshine-prestart.sh: delegates to the manager's apply.
- docs/HEADLESS-PRIMARY.md: full rationale + the ufw stale-subnet gotcha.

Not yet wired into install.sh; configured directly on JARVIS via monitors.conf
and autostart.conf (tracked in the dotfiles repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Woodard
2026-07-29 16:47:22 -06:00
parent 836d775d3d
commit 0d6da8c24a
3 changed files with 372 additions and 79 deletions

View File

@@ -1,81 +1,13 @@
#!/usr/bin/env bash
# Runs as a systemd ExecStartPre for the Sunshine service. Two jobs:
# 1. Make sure exactly one Hyprland headless output exists.
# 2. Sync sunshine.conf's `output_name` to whatever the headless output is
# currently named — Hyprland's HEADLESS-N counter doesn't reset across
# session restarts, so pinning to HEADLESS-1 drifts after the first
# remove/create cycle.
# Sunshine systemd ExecStartPre hook.
#
# Non-fatal at every step: a stale state can't worsen things by aborting here.
set -uo pipefail
log() { printf '[sunshine-prestart] %s\n' "$*" >&2; }
CONF="$HOME/.config/sunshine/sunshine.conf"
# Recover Hyprland's instance signature when the unit's env didn't propagate it.
if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
for sig in "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"/hypr/*/; do
[[ -d "$sig" ]] || continue
export HYPRLAND_INSTANCE_SIGNATURE="$(basename "$sig")"
break
done
fi
if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
log "Hyprland not running; nothing to prepare."
exit 0
fi
if ! command -v hyprctl >/dev/null || ! command -v jq >/dev/null; then
log "hyprctl/jq missing; skipping prestart."
exit 0
fi
# Reduce to exactly one headless output. Hyprland's HEADLESS-N counter
# increments on every create and never decrements, so previous failed runs
# leave extras laying around. Remove all but the lowest-numbered one (most
# likely to be the one with workspaces bound to it).
mapfile -t headless_outputs < <(hyprctl monitors -j 2>/dev/null \
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' \
| sort -V)
existing="${headless_outputs[0]:-}"
if [[ -z "$existing" ]]; then
log "No headless output present; creating one"
hyprctl output create headless >/dev/null
for _ in 1 2 3 4 5; do
existing="$(hyprctl monitors -j 2>/dev/null \
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' \
| sort -V | head -1)"
[[ -n "$existing" ]] && break
sleep 0.1
done
elif [[ ${#headless_outputs[@]} -gt 1 ]]; then
log "Found ${#headless_outputs[@]} headless outputs; keeping $existing, removing the rest"
for extra in "${headless_outputs[@]:1}"; do
hyprctl output remove "$extra" >/dev/null 2>&1 || true
done
fi
if [[ -z "$existing" ]]; then
log "Failed to obtain a headless output; Sunshine will start without one."
exit 0
fi
log "Headless output present: $existing"
# Sync sunshine.conf's output_name. Only touch the file if it's our managed
# variant (has the management marker) AND the line has actually drifted.
if [[ -f "$CONF" ]] && grep -qF '# managed-by: omarchy-moonlight' "$CONF"; then
current="$(awk '/^output_name = / {print $3; exit}' "$CONF" 2>/dev/null || true)"
if [[ "$current" != "$existing" ]]; then
log "Updating sunshine.conf output_name: ${current:-(unset)} -> $existing"
if grep -q '^output_name = ' "$CONF"; then
sed -i "s|^output_name = .*|output_name = $existing|" "$CONF"
else
printf '\noutput_name = %s\n' "$existing" >> "$CONF"
fi
fi
fi
exit 0
# As of 2026-07-27 this is a thin wrapper around the HEADLESS-PRIMARY manager,
# which is the single source of truth for the monitor/headless topology:
# - ensures exactly one persistent HEADLESS output exists (Sunshine's capture
# target) BEFORE Sunshine's startup encoder probe runs,
# - sizes it and mirrors DP-1 onto it when the physical monitor is present,
# - keeps sunshine.conf's output_name in sync with the live headless name.
#
# Non-fatal by design (the drop-in prefixes this with '-'): if Hyprland isn't
# reachable yet, the manager logs and returns 0 so Sunshine still starts.
exec "$(dirname "$0")/sunshine-headless-primary.sh" apply