diff --git a/bin/sunshine-headless-primary.sh b/bin/sunshine-headless-primary.sh new file mode 100755 index 0000000..06efd7d --- /dev/null +++ b/bin/sunshine-headless-primary.sh @@ -0,0 +1,174 @@ +#!/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. Rescue orphaned workspaces (monitorID == -1) onto the headless primary. + # Narrow by design — a healthy second monitor (DP-2) is left alone. + local ws + 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 + + # 5. Rescue any floating windows stranded off-screen by the topology change. + rescue_offscreen_windows + + # 6. 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 diff --git a/bin/sunshine-prestart.sh b/bin/sunshine-prestart.sh index 23872ad..66b7922 100755 --- a/bin/sunshine-prestart.sh +++ b/bin/sunshine-prestart.sh @@ -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 diff --git a/docs/HEADLESS-PRIMARY.md b/docs/HEADLESS-PRIMARY.md new file mode 100644 index 0000000..854be3d --- /dev/null +++ b/docs/HEADLESS-PRIMARY.md @@ -0,0 +1,187 @@ +# 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; `watch` re-applies on monitor hotplug AND auto-rescues windows that open off-screen. | +| `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. 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` subscribes to Hyprland's event socket and reacts: + +- `monitoradded` / `monitorremoved` → full `apply` (re-impose the mirror, + re-consolidate, rescue) — debounced 2s to coalesce the v1+v2 event pair. +- `openwindow` → targeted `rescue` of just that window if it opened off-screen + (0.3s settle delay first). + +This is what makes stray windows self-heal: a monitor swap or an app that opens +off-screen (OBS, `bluetui`, kdenlive have all done this) gets pulled back into +view automatically, no manual hunting. + +### 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`.