1 Commits

Author SHA1 Message Date
Levi Woodard
7bc6d2789b Adding functions 2026-05-21 19:18:05 -06:00
6 changed files with 208 additions and 514 deletions

View File

@@ -1,237 +0,0 @@
#!/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

View File

@@ -1,13 +1,81 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Sunshine systemd ExecStartPre hook. # 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.
# #
# As of 2026-07-27 this is a thin wrapper around the HEADLESS-PRIMARY manager, # Non-fatal at every step: a stale state can't worsen things by aborting here.
# which is the single source of truth for the monitor/headless topology:
# - ensures exactly one persistent HEADLESS output exists (Sunshine's capture set -uo pipefail
# target) BEFORE Sunshine's startup encoder probe runs,
# - sizes it and mirrors DP-1 onto it when the physical monitor is present, log() { printf '[sunshine-prestart] %s\n' "$*" >&2; }
# - keeps sunshine.conf's output_name in sync with the live headless name.
# CONF="$HOME/.config/sunshine/sunshine.conf"
# 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. # Recover Hyprland's instance signature when the unit's env didn't propagate it.
exec "$(dirname "$0")/sunshine-headless-primary.sh" apply 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

View File

@@ -1,24 +1,41 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Invoked by Sunshine as a stream-start hook (global_prep_cmd `do`). # Invoked by Sunshine as a stream-start hook (global_prep_cmd `do`).
# Creates/resizes a Hyprland headless output to match the connecting # Resizes the vkms-backed `Virtual-1` connector to match the connecting
# Moonlight client's resolution, and moves the active workspace onto it # Moonlight client's resolution, positions it adjacent to the existing real
# so the user's existing windows are visible on the stream. # monitor(s), and (optionally) moves a content-bearing workspace onto it so
# the user sees something instead of an empty desktop.
#
# This script no longer disables eDP-* — vkms gives us a real DRM connector
# whose dmabuf is hardware-encoder-friendly, so Sunshine's `capture = kms`
# matches it unambiguously by name and we don't need to touch other outputs.
# #
# Sunshine env vars set on connect: # Sunshine env vars set on connect:
# SUNSHINE_CLIENT_WIDTH, SUNSHINE_CLIENT_HEIGHT, SUNSHINE_CLIENT_FPS # SUNSHINE_CLIENT_WIDTH, SUNSHINE_CLIENT_HEIGHT, SUNSHINE_CLIENT_FPS
set -euo pipefail set -euo pipefail
log() { printf '[sunshine-do] %s\n' "$*" >&2; }
WIDTH="${SUNSHINE_CLIENT_WIDTH:-1920}" WIDTH="${SUNSHINE_CLIENT_WIDTH:-1920}"
HEIGHT="${SUNSHINE_CLIENT_HEIGHT:-1080}" HEIGHT="${SUNSHINE_CLIENT_HEIGHT:-1080}"
FPS="${SUNSHINE_CLIENT_FPS:-60}" FPS="${SUNSHINE_CLIENT_FPS:-60}"
VIRT_MON="${OMARCHY_VIRTUAL_OUTPUT:-Virtual-1}"
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless" STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless"
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
# Sunshine doesn't forward prep-cmd stderr to its journal, so also tee every
# log line to a runtime file. Truncates on each stream so the file is scoped
# to one connect/disconnect cycle.
HOOK_LOG="$STATE_DIR/hook.log"
: > "$HOOK_LOG"
log() {
local msg
msg="$(date +%H:%M:%S.%3N) [sunshine-do] $*"
printf '%s\n' "$msg" >&2
printf '%s\n' "$msg" >> "$HOOK_LOG"
}
log "do-hook start: client=${WIDTH}x${HEIGHT}@${FPS} target=${VIRT_MON}"
if ! command -v hyprctl >/dev/null 2>&1; then if ! command -v hyprctl >/dev/null 2>&1; then
log "hyprctl not found; cannot configure headless. Stream will use whatever output Sunshine selects." log "hyprctl not found; cannot configure virtual display. Stream may show whatever Sunshine selects."
exit 0 exit 0
fi fi
@@ -37,39 +54,78 @@ if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
fi fi
fi fi
# Snapshot prior state so undo can restore. # Verify the virtual output exists. If the vkms module isn't loaded, this will
hyprctl monitors -j > "$STATE_DIR/prev-monitors.json" 2>/dev/null || true # be empty and we bail cleanly — Sunshine's KMS capture will just see no
PREV_WS="$(hyprctl activeworkspace -j 2>/dev/null | jq -r '.id // 1' || echo 1)" # matching connector and the user gets nothing useful, but at least nothing
echo "$PREV_WS" > "$STATE_DIR/prev-workspace-id" # else gets disturbed.
if ! hyprctl monitors -j 2>/dev/null \
# Discover whatever headless output already exists. sunshine-prestart.sh is | jq -e --arg m "$VIRT_MON" '.[] | select(.name == $m)' >/dev/null; then
# responsible for ensuring one exists and aligning sunshine.conf's output_name log "Virtual monitor '$VIRT_MON' not present in Hyprland. Is vkms loaded? (lsmod | grep vkms)"
# to its actual name (Hyprland's HEADLESS-N counter drifts across restarts).
MON="$(hyprctl monitors -j 2>/dev/null \
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1)"
if [[ -z "$MON" ]]; then
log "No headless output found; creating one"
hyprctl output create headless >/dev/null
for _ in 1 2 3 4 5; do
MON="$(hyprctl monitors -j 2>/dev/null \
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1)"
[[ -n "$MON" ]] && break
sleep 0.1
done
fi
if [[ -z "$MON" ]]; then
log "Failed to obtain a headless output; bailing."
exit 0 exit 0
fi fi
echo "$MON" > "$STATE_DIR/headless-name"
# Resize headless to the client's resolution / framerate. # Snapshot prior state so the undo hook can restore focus to where the user
log "Sizing $MON${WIDTH}x${HEIGHT}@${FPS}" # actually was at connect time, even if we promote a different workspace
hyprctl keyword monitor "$MON,${WIDTH}x${HEIGHT}@${FPS},auto,1" >/dev/null # onto Virtual-1 below.
hyprctl monitors -j > "$STATE_DIR/prev-monitors.json" 2>/dev/null || true
ACTIVE_WS_ID="$(hyprctl activeworkspace -j 2>/dev/null | jq -r '.id // 1' || echo 1)"
ACTIVE_WS_WINDOWS="$(hyprctl activeworkspace -j 2>/dev/null | jq -r '.windows // 0' || echo 0)"
echo "$ACTIVE_WS_ID" > "$STATE_DIR/orig-active-workspace-id"
# Move the active workspace onto the headless so existing windows appear in the stream. # Choose a workspace whose content goes to the stream:
log "Moving workspace $PREV_WS$MON, focusing it" # 1. active workspace, if it has windows
hyprctl dispatch moveworkspacetomonitor "$PREV_WS $MON" >/dev/null || true # 2. otherwise the lowest-id workspace currently bound to Virtual-1 (sticky)
hyprctl dispatch focusmonitor "$MON" >/dev/null || true # 3. otherwise the lowest-id workspace with any windows
# 4. otherwise the active workspace id (stream will show wallpaper only)
if [[ "${ACTIVE_WS_WINDOWS:-0}" -gt 0 ]]; then
PREV_WS="$ACTIVE_WS_ID"
log "Active workspace $PREV_WS has $ACTIVE_WS_WINDOWS window(s); promoting it to $VIRT_MON"
else
# Workspace already on Virtual-1 (a sticky one from a previous stream).
PREV_WS="$(hyprctl workspaces -j 2>/dev/null \
| jq -r --arg m "$VIRT_MON" '[.[] | select(.monitor == $m)] | sort_by(.id) | first | .id // empty' \
|| true)"
if [[ -z "$PREV_WS" ]]; then
PREV_WS="$(hyprctl workspaces -j 2>/dev/null \
| jq -r '[.[] | select(.windows > 0)] | sort_by(.id) | first | .id // empty' \
|| true)"
fi
if [[ -z "$PREV_WS" ]]; then
PREV_WS="$ACTIVE_WS_ID"
log "No populated workspace found; using empty active WS $PREV_WS (stream may show wallpaper only)"
else
log "Active WS $ACTIVE_WS_ID is empty; promoting WS $PREV_WS to $VIRT_MON"
fi
fi
echo "$PREV_WS" > "$STATE_DIR/prev-workspace-id"
log "Stream ready: ${WIDTH}x${HEIGHT}@${FPS} on $MON" # Compute a non-overlapping position for Virtual-1: just to the right of the
# rightmost real monitor's logical edge. Real monitors keep their position;
# Virtual-1 ends up as a new "right of laptop" workspace the user can drift to.
MAX_RIGHT="$(hyprctl monitors -j 2>/dev/null \
| jq -r --arg m "$VIRT_MON" '
[.[] | select(.name != $m)
| (.x + ((.width / .scale) | floor))]
| max // 0' \
|| echo 0)"
# Resize Virtual-1 to the client's requested mode at that x-offset. vkms
# supports arbitrary modes via DRM mode-set; if a refresh-rate variant of the
# exact mode isn't in the reported list, Hyprland still negotiates.
log "Sizing $VIRT_MON${WIDTH}x${HEIGHT}@${FPS} at ${MAX_RIGHT}x0 (scale=1)"
hyprctl keyword monitor "$VIRT_MON,${WIDTH}x${HEIGHT}@${FPS},${MAX_RIGHT}x0,1" >/dev/null
# Move the chosen workspace onto Virtual-1 and focus it.
log "Moving workspace $PREV_WS$VIRT_MON, focusing it"
hyprctl dispatch moveworkspacetomonitor "$PREV_WS $VIRT_MON" >/dev/null || true
hyprctl dispatch focusmonitor "$VIRT_MON" >/dev/null || true
# Dump post-state so we can verify everything ended up where intended.
post_mons="$(hyprctl monitors -j 2>/dev/null \
| jq -r '.[] | "\(.name) \(.width)x\(.height)@\(.refreshRate) at \(.x)x\(.y) activeWS=\(.activeWorkspace.id)"' \
| tr '\n' ';' || true)"
post_ws="$(hyprctl workspaces -j 2>/dev/null \
| jq -r '.[] | "ws\(.id)=\(.windows)win on \(.monitor)"' \
| tr '\n' ';' || true)"
log "post-state monitors: $post_mons"
log "post-state workspaces: $post_ws"
log "Stream ready: ${WIDTH}x${HEIGHT}@${FPS} on $VIRT_MON (eDP-* untouched)"

View File

@@ -1,15 +1,25 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Invoked by Sunshine as a stream-stop hook (global_prep_cmd `undo`). # Invoked by Sunshine as a stream-stop hook (global_prep_cmd `undo`).
# Moves the previously-active workspace back to a real monitor (if any # Returns the promoted workspace to the original real monitor and restores
# exist) and tears down the headless output created by sunshine-stream-do.sh. # focus to whichever workspace the user had active at connect time.
#
# With the vkms-based design, no monitors get disabled, so undo is just a
# workspace-and-focus restore. Virtual-1 stays alive; the next stream resizes
# it as needed.
set -euo pipefail set -euo pipefail
log() { printf '[sunshine-undo] %s\n' "$*" >&2; } VIRT_MON="${OMARCHY_VIRTUAL_OUTPUT:-Virtual-1}"
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless" STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless"
# Headless name was captured by sunshine-stream-do.sh; fall back to discovery. HOOK_LOG="$STATE_DIR/hook.log"
MON="$(cat "$STATE_DIR/headless-name" 2>/dev/null || true)" log() {
local msg
msg="$(date +%H:%M:%S.%3N) [sunshine-undo] $*"
printf '%s\n' "$msg" >&2
# Append (not truncate) so the undo log lands alongside the do log.
printf '%s\n' "$msg" >> "$HOOK_LOG" 2>/dev/null || true
}
log "undo-hook start"
if ! command -v hyprctl >/dev/null 2>&1; then if ! command -v hyprctl >/dev/null 2>&1; then
log "hyprctl not found; nothing to undo." log "hyprctl not found; nothing to undo."
@@ -29,29 +39,27 @@ if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
fi fi
PREV_WS="$(cat "$STATE_DIR/prev-workspace-id" 2>/dev/null || echo 1)" PREV_WS="$(cat "$STATE_DIR/prev-workspace-id" 2>/dev/null || echo 1)"
ORIG_ACTIVE_WS="$(cat "$STATE_DIR/orig-active-workspace-id" 2>/dev/null || echo "$PREV_WS")"
if [[ -z "$MON" ]]; then # Find a non-virtual monitor to move the promoted workspace back to.
MON="$(hyprctl monitors -j 2>/dev/null \ REAL_MON="$(hyprctl monitors -j 2>/dev/null \
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1)" | jq -r --arg m "$VIRT_MON" '.[] | select(.name != $m) | .name' \
fi | head -n1)"
# Find a non-headless monitor to move the workspace back to. If there isn't one
# (truly headless host with KVM detached), the workspace just lives on whatever
# Hyprland reassigns it to when we remove the output.
REAL_MON="$(hyprctl monitors -j 2>/dev/null | jq -r '.[] | select(.name | test("^HEADLESS") | not) | .name' | head -n1)"
if [[ -n "$REAL_MON" ]]; then if [[ -n "$REAL_MON" ]]; then
log "Returning workspace $PREV_WS$REAL_MON" log "Returning workspace $PREV_WS$REAL_MON"
hyprctl dispatch moveworkspacetomonitor "$PREV_WS $REAL_MON" >/dev/null || true hyprctl dispatch moveworkspacetomonitor "$PREV_WS $REAL_MON" >/dev/null || true
# If the do-hook promoted a non-active workspace because the active one was
# empty, ORIG_ACTIVE_WS differs from PREV_WS — restore focus to where the
# user actually was at connect time.
if [[ "$ORIG_ACTIVE_WS" != "$PREV_WS" ]]; then
log "Restoring focus to original active workspace $ORIG_ACTIVE_WS"
hyprctl dispatch workspace "$ORIG_ACTIVE_WS" >/dev/null || true
fi
hyprctl dispatch focusmonitor "$REAL_MON" >/dev/null || true hyprctl dispatch focusmonitor "$REAL_MON" >/dev/null || true
else else
log "No real monitor connected; leaving workspace assignment to Hyprland defaults." log "No real monitor connected; leaving workspace assignment to Hyprland defaults."
fi fi
# Leave HEADLESS-1 in place. It needs to exist persistently for Sunshine's # Clean state files but keep the directory + hook.log for the next run.
# encoder probe to succeed at startup; removing-and-recreating per session rm -f "$STATE_DIR/prev-monitors.json" "$STATE_DIR/prev-workspace-id" "$STATE_DIR/orig-active-workspace-id"
# raced with the probe and caused fatal startup errors. Resizing on each log "Stream teardown complete ($VIRT_MON kept alive for next connect)"
# new client (in sunshine-stream-do.sh) is enough — the output itself stays.
# Clean state files but keep the directory for the next run.
rm -f "$STATE_DIR/prev-monitors.json" "$STATE_DIR/prev-workspace-id"
log "Stream teardown complete (HEADLESS-1 kept for next connect)"

View File

@@ -1,204 +0,0 @@
# Headless-primary streaming (JARVIS as-built)
How **JARVIS** streams its desktop to Moonlight so that it works **whether or not
the physical monitor is on** — the machine's normal remote-use case.
> **Companion note — keep in sync.** This document is mirrored in the Obsidian
> vault at `Documents/Sync Vault/Network/Network — Sunshine + Moonlight (JARVIS).md`.
> They are **not** auto-linked. **If you edit one, update the other.**
---
## TL;DR
- The desktop permanently lives on a **persistent virtual `HEADLESS-1` output**.
Sunshine captures it. It exists independent of the physical monitor.
- The physical **`DP-1` mirrors `HEADLESS-1`** when present, so the at-desk view
equals the stream.
- When `DP-1` is off (remote), nothing is orphaned and the stream keeps working.
- Managed by `~/.local/share/omarchy-moonlight/bin/sunshine-headless-primary.sh`.
---
## Why this design (the three iterations)
1. **Headless-move (stock omarchy-moonlight headless mode)** — created a
client-sized `HEADLESS-1` and *moved the active workspace onto it* via the
`global_prep_cmd` hooks. Not a clone; and because a permanent off-screen
output existed, apps that remember their monitor (OBS) reopened invisibly on
it. Rejected.
2. **Mirror-clone (`HEADLESS-1` mirrors `DP-1`)** — a true clone while the
monitor is on, and mirrored outputs are excluded from the layout so nothing
strands on them. **But** when the physical monitor fully powers off, `DP-1`
disappears from Hyprland, the mirror collapses to a standalone empty output,
and every workspace bound to `DP-1` is orphaned off-screen. Fatal for a
remote-first host whose monitor is usually off.
3. **Headless-primary (current)** — invert the mirror. The **headless output is
the source of truth** (always present); `DP-1` mirrors *it*. The capture
target is never absent, so nothing is ever orphaned. This is the only model
that satisfies "the stream is a clone AND survives the monitor being off."
**Tradeoff:** the stream is the full `5120x1440` ultrawide, letterboxed on 16:9
clients. Inherent to cloning an ultrawide.
---
## Components
| Path | Role |
|---|---|
| `bin/sunshine-headless-primary.sh` | The manager. `apply` establishes/repairs state (idempotent); `rescue` pulls off-screen floating windows back on-screen; `reconcile` heals drift only if broken; `watch` the self-healing daemon (events + periodic reconcile backstop + auto-reconnect). |
| `bin/sunshine-prestart.sh` | Sunshine `ExecStartPre` — thin wrapper that runs `sunshine-headless-primary.sh apply` before the encoder probe. |
| `~/.config/hypr/monitors.conf` | `DP-1` kept **normal** (safety fallback); `HEADLESS-1..4` default to `0x0`. The manager applies the `DP-1 → mirror HEADLESS-1` on top. |
| `~/.config/hypr/autostart.conf` | `exec-once` runs `apply` at login and starts the `watch` daemon. |
| `~/.config/sunshine/sunshine.conf` | `capture = wlr`, `output_name = HEADLESS-1`, `encoder = nvenc`, `global_prep_cmd = []` (no workspace moving). |
| systemd drop-in `…Sunshine.service.d/headless-prestart.conf` | wires `ExecStartPre` to prestart. |
### What `apply` does (idempotent)
1. Ensures exactly one `HEADLESS` output (creates if none; **disables** extras —
`hyprctl output remove` is unreliable for these, returns "output not found").
2. Sizes it `5120x1440@60` at `0x0`.
3. If `DP-1` is present → `hyprctl keyword monitor "DP-1,…,mirror,HEADLESS-1"`.
4. **Relocates any workspace stranded on a mirrored output** onto the primary.
On swap-back Hyprland brings `DP-1` up *normal*, binds a workspace to it, and
then we mirror it — leaving that workspace trapped on a layout-excluded
mirror (unreachable). This moves it off. (You can move a workspace *off* a
mirror but not *onto* one, which is why this only bites via hotplug.)
5. Rescues any **orphaned** workspace (`monitorID == -1`) onto the headless
primary — deliberately narrow, so a healthy second monitor (DP-2) is left
alone.
5. **Rescues off-screen floating windows** — recenters any floating, mapped
window whose rectangle doesn't intersect the monitor its workspace lives on
(stranded by a topology change, or opened off-screen).
6. Syncs `sunshine.conf`'s `output_name` to the live headless name.
### Self-healing (the watcher)
`watch` is designed so the setup **always converges**, not just when an event
fires. Three layers:
1. **Events** (fast path) — subscribes to Hyprland's socket:
- `monitoradded` / `monitorremoved``apply` immediately (debounced 2s to
coalesce the v1+v2 burst), then a `reconcile` 1.5s later to catch a
workspace Hyprland reassigns to `DP-1` *after* apply already ran.
- `openwindow` → targeted `rescue` if that window opened off-screen.
2. **Periodic `reconcile` backstop** (every `RECONCILE_SECS`, default 8s) — a
read-only drift check that runs `apply` **only when something is actually
wrong** (no headless / DP-1 not mirroring / workspace trapped on a mirror /
orphaned workspace / off-screen window). This is the guarantee: even if an
event is missed entirely, the state self-heals within a few seconds. It does
nothing when healthy, so there's no steady-state flicker.
3. **Auto-reconnect** — if `socat` drops, the loop reconnects; if the session
socket disappears (Hyprland gone) it exits cleanly so the next login's
`exec-once` starts a fresh watcher (no cross-session duplicates).
So a monitor swap, a trapped workspace ("can't show workspace 1"), or an app
that opens off-screen (OBS, `bluetui`, kdenlive have all done this) gets fixed
automatically — near-instant via the event, or within ~8s via the backstop.
Force a check manually anytime with `sunshine-headless-primary.sh reconcile`.
### Critical implementation notes
- **Always `hyprctl monitors all`**, never plain `hyprctl monitors` — a mirrored
output is excluded from the plain list, i.e. invisible to exactly the outputs
we manage. Plain form → the dedup loop spawns a new headless every run.
- **`DP-1` must stay `normal` in `monitors.conf`.** It is the safety fallback: if
the manager never runs, the physical screen still displays. Never hard-code
`DP-1 … mirror,HEADLESS-1` statically — at cold boot `HEADLESS-1` doesn't exist
yet and the screen could blank. The mirror is only ever added at runtime.
- **Hotplug:** Omarchy's own `omarchy-hyprland-monitor-watch` only handles
`monitorremoved`; our `watch` handles `monitoradded` to re-impose the mirror
when the monitor is plugged back in.
---
## The network side (this bit is what actually blocks connections)
Streaming failing is usually **not** Sunshine. Two real blockers hit on JARVIS:
### 1. ufw rules pinned to the OLD subnet (the real outage)
The omarchy-moonlight installer opens the Sunshine ports in **ufw scoped to the
LAN subnet**. After the LAN was renumbered `192.168.1.0/24 → 10.0.0.0/24`, the
rules still said `192.168.1.0/24`, so every client on `10.0.0.x` was silently
rejected (default-deny input) while `ssh` — rule `Anywhere` — kept working.
```bash
# symptom: from another LAN host, tcp/22 OPEN but 47984/47989/48010 BLOCKED,
# yet the ports listen on 0.0.0.0 and answer locally.
sudo ufw status verbose
# fix — re-scope to the current subnet:
sudo ufw allow from 10.0.0.0/24 to any port 47984,47989,47990,48010 proto tcp
sudo ufw allow from 10.0.0.0/24 to any port 47998,47999,48000,48010 proto udp
sudo ufw reload
# then delete the stale 192.168.1.0/24 rules (ufw status numbered; ufw delete N)
```
> **Rule of thumb: re-scope the omarchy-moonlight ufw rules after any network
> renumber.** The ports are TCP `47984/47989/47990/48010` + UDP
> `47998/47999/48000/48010`. `47990` (web UI) can stay closed — it's
> localhost-locked anyway.
### 2. mDNS resolves to the Docker bridge
With Docker running, avahi advertises on the docker interfaces and
`JARVIS.local` resolves to `172.17.0.1` (docker0) or an IPv6 link-local address —
not `10.0.0.13`. Moonlight auto-discovery then targets an unreachable address.
**Workaround: add the host in Moonlight by IP `10.0.0.13`.** Proper fix: restrict
avahi to the real NIC(s):
```bash
# /etc/avahi/avahi-daemon.conf, under [server]:
# allow-interfaces=enp7s0,wlan0
# deny-interfaces=docker0,br-8482440f28f0
sudo systemctl restart avahi-daemon
avahi-resolve -4 -n JARVIS.local # should print 10.0.0.13
```
---
## Verify
```bash
# headless primary at 0x0, workspaces on it, DP-1 (if present) mirroring it
hyprctl monitors all -j | jq -r '.[] | "\(.name) pos=\(.x)x\(.y) mirrorOf=\(.mirrorOf // "-")"'
hyprctl workspaces -j | jq -r '.[] | "ws \(.id) -> \(.monitor)"'
# manager is idempotent (run twice; stays one headless)
~/.local/share/omarchy-moonlight/bin/sunshine-headless-primary.sh apply
# hotplug watcher alive
pgrep -af 'sunshine-headless-primary.sh watch'
# from another LAN host: all Sunshine ports reachable
for p in 47984 47989 48010; do nc -vz 10.0.0.13 $p; done
```
Then connect Moonlight (add host by IP `10.0.0.13`) → you should see your desktop
whether or not the physical monitor is on.
---
## Gotchas & recovery
- **Stuck on the wrong/empty workspace after a monitor state change** — re-run
the manager, or nudge manually:
```bash
~/.local/share/omarchy-moonlight/bin/sunshine-headless-primary.sh apply
# or, targeted:
hyprctl dispatch moveworkspacetomonitor "1 HEADLESS-1"; hyprctl dispatch workspace 1
```
- **`hyprctl output remove HEADLESS-N`** returns "output not found" and exits 0
for these — use `hyprctl keyword monitor "HEADLESS-N,disable"` or reboot.
- **Re-running the omarchy-moonlight `install.sh`** regenerates `sunshine.conf`
(re-enabling `global_prep_cmd`) and re-adds subnet-scoped ufw rules. Re-apply
the headless-primary `sunshine.conf` settings and re-check ufw after any
reinstall.
- **Admin UI** is localhost-only (`origin_web_ui_allowed = pc`). Reach it via
`ssh -L 47990:localhost:47990 lwoodard@10.0.0.13` then `https://localhost:47990`
(note: `-L` local forward, not `-R`).
See also `docs/ARCHITECTURE.md`, `docs/TROUBLESHOOTING.md`, `docs/FOLLOWUPS.md`.

View File

@@ -69,8 +69,11 @@ $encoder_block
# Threading — more threads helps high-bitrate H.265/AV1. # Threading — more threads helps high-bitrate H.265/AV1.
min_threads = 4 min_threads = 4
# Use the PipeWire pulse compatibility layer for audio. # Audio sink is intentionally left unset so Sunshine auto-detects the default
audio_sink = pulse # PulseAudio/PipeWire sink and creates its virtual `sink-sunshine-stereo`.
# Hard-coding audio_sink = pulse here breaks capture: Sunshine treats it as a
# literal sink name, can't resolve its monitor source, and pa_simple_new()
# fails with "Invalid argument" → no audio in the stream.
# Keyboard / mouse / gamepad pass-through via /dev/uinput. # Keyboard / mouse / gamepad pass-through via /dev/uinput.
# (Requires user to be in the 'input' group; install.sh handles this.) # (Requires user to be in the 'input' group; install.sh handles this.)