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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user