3 Commits

Author SHA1 Message Date
Levi Woodard
2bd57a8395 headless-primary: make the watcher always-converge
Add a self-healing backstop so recovery no longer depends on an event firing:
- periodic reconcile (every 8s): read-only drift check that runs apply ONLY when
  broken (no headless / DP-1 not mirroring / workspace trapped on a mirror /
  orphan / off-screen window) — no steady-state flicker.
- delayed reconcile 1.5s after a monitor event to catch late workspace
  reassignment races.
- socat auto-reconnect; exit cleanly when the session socket vanishes.
New 'reconcile' subcommand. Verified: reconcile heals a deliberately broken
mirror and no-ops on a healthy state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 16:33:10 -06:00
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
Levi Woodard
0d6da8c24a 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>
2026-07-29 16:47:22 -06:00
30 changed files with 648 additions and 1933 deletions

View File

@@ -200,22 +200,13 @@ The host-side installer handles Linux clients via `moonlight-qt`. For everything
## Diagnostics ## Diagnostics
```bash ```bash
./status.sh # runtime health check — is it g2g right now? ./install.sh --doctor # run all checks
./install.sh --doctor # install-time correctness checks
systemctl --user status sunshine systemctl --user status sunshine
journalctl --user -u sunshine -f journalctl --user -u sunshine -f
getcap "$(readlink -f "$(command -v sunshine)")" # should include cap_sys_admin getcap "$(readlink -f "$(command -v sunshine)")" # should include cap_sys_admin
id -nG | tr ' ' '\n' | grep -x input # confirm group membership id -nG | tr ' ' '\n' | grep -x input # confirm group membership
``` ```
`status.sh` walks what's actually *running* — service state, boot wiring,
display backend (auto-detects x11 / wlr / kms from your `sunshine.conf`),
encoder, ports, web UI, `/dev/uinput`, and pairing — then prints either
**g2g** or a concrete TODO list of what needs work (exit 1 if anything's
broken). Run it as the Sunshine user, or as root (`sudo ./status.sh`, which
auto-detects the user). `--doctor` is the install-time complement: it checks
the install is *correct*; `status.sh` checks the host is *up*.
Useful Sunshine ports (auto-opened if a firewall is active): Useful Sunshine ports (auto-opened if a firewall is active):
- TCP: `47984 47989 47990 48010` - TCP: `47984 47989 47990 48010`

237
bin/sunshine-headless-primary.sh Executable file
View File

@@ -0,0 +1,237 @@
#!/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,76 +0,0 @@
#!/usr/bin/env bash
# Sway analog of sunshine-prestart.sh. Runs as ExecStartPre for sunshine.service
# on Ubuntu/Debian installs that use the Sway-headless path.
#
# Two jobs:
# 1. Confirm sway is reachable; create HEADLESS-1 if it doesn't exist yet.
# 2. Sync sunshine.conf's `output_name` to whatever the headless output is
# currently named (`create_output` may auto-assign HEADLESS-2, -3, etc.
# after a session restart).
set -uo pipefail
log() { printf '[sunshine-prestart-sway] %s\n' "$*" >&2; }
CONF="$HOME/.config/sunshine/sunshine.conf"
if [[ -z "${SWAYSOCK:-}" ]]; then
for sock in "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"/sway-ipc.*.sock; do
[[ -S "$sock" ]] || continue
export SWAYSOCK="$sock"
break
done
fi
if [[ -z "${SWAYSOCK:-}" ]]; then
log "sway not running; nothing to prepare."
exit 0
fi
if ! command -v swaymsg >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
log "swaymsg/jq missing; skipping prestart."
exit 0
fi
# Reduce to exactly one HEADLESS-* output.
mapfile -t headless_outputs < <(swaymsg -t get_outputs -r 2>/dev/null \
| jq -r '.[].name | select(startswith("HEADLESS-"))' \
| sort -V)
existing="${headless_outputs[0]:-}"
if [[ -z "$existing" ]]; then
log "No headless output present; creating one"
swaymsg create_output HEADLESS-1 >/dev/null 2>&1 || swaymsg create_output >/dev/null 2>&1 || true
for _ in 1 2 3 4 5; do
existing="$(swaymsg -t get_outputs -r 2>/dev/null \
| jq -r '.[].name | select(startswith("HEADLESS-"))' \
| sort -V | head -1)"
[[ -n "$existing" ]] && break
sleep 0.1
done
elif [[ ${#headless_outputs[@]} -gt 1 ]]; then
# Keep the first, log the rest. Removing outputs in sway during prestart can
# cascade workspace re-assignment, so we err on the side of leaving them.
log "Found ${#headless_outputs[@]} headless outputs; using $existing (extras left in place)"
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,81 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Runs as a systemd ExecStartPre for the Sunshine service. Two jobs: # Sunshine systemd ExecStartPre hook.
# 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.
# #
# Non-fatal at every step: a stale state can't worsen things by aborting here. # 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:
set -uo pipefail # - ensures exactly one persistent HEADLESS output exists (Sunshine's capture
# target) BEFORE Sunshine's startup encoder probe runs,
log() { printf '[sunshine-prestart] %s\n' "$*" >&2; } # - 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.
CONF="$HOME/.config/sunshine/sunshine.conf" #
# Non-fatal by design (the drop-in prefixes this with '-'): if Hyprland isn't
# Recover Hyprland's instance signature when the unit's env didn't propagate it. # reachable yet, the manager logs and returns 0 so Sunshine still starts.
if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then exec "$(dirname "$0")/sunshine-headless-primary.sh" apply
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,112 +0,0 @@
#!/usr/bin/env bash
# Sunshine global_prep_cmd `do` hook for the Sway-based headless capture path
# (Debian/Ubuntu installs where Hyprland isn't available).
#
# On client connect:
# - Ensures a HEADLESS-1 output exists on the running sway session
# - Resizes it to the client's negotiated mode (WxH@FPS)
# - Snapshots state for the undo hook
#
# Sunshine env vars set on connect:
# SUNSHINE_CLIENT_WIDTH, SUNSHINE_CLIENT_HEIGHT, SUNSHINE_CLIENT_FPS
#
# This script intentionally mirrors the Hyprland do-hook's shape so debugging
# transfers across the two paths.
set -uo pipefail
WIDTH="${SUNSHINE_CLIENT_WIDTH:-1920}"
HEIGHT="${SUNSHINE_CLIENT_HEIGHT:-1080}"
FPS="${SUNSHINE_CLIENT_FPS:-60}"
HEADLESS_NAME="${OMARCHY_VIRTUAL_OUTPUT:-HEADLESS-1}"
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless"
mkdir -p "$STATE_DIR"
HOOK_LOG="$STATE_DIR/hook.log"
: > "$HOOK_LOG"
log() {
local msg
msg="$(date +%H:%M:%S.%3N) [sunshine-do-sway] $*"
printf '%s\n' "$msg" >&2
printf '%s\n' "$msg" >> "$HOOK_LOG"
}
log "do-hook start: client=${WIDTH}x${HEIGHT}@${FPS} target=${HEADLESS_NAME}"
if ! command -v swaymsg >/dev/null 2>&1; then
log "swaymsg not found; nothing to configure."
exit 0
fi
# Recover SWAYSOCK if the unit env didn't propagate it. sway writes the socket
# path into a predictable /run/user/$UID location, but the env var is the
# clean handle.
if [[ -z "${SWAYSOCK:-}" ]]; then
for sock in "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"/sway-ipc.*.sock; do
[[ -S "$sock" ]] || continue
export SWAYSOCK="$sock"
log "Discovered SWAYSOCK=$SWAYSOCK"
break
done
if [[ -z "${SWAYSOCK:-}" ]]; then
log "sway not running (no IPC socket found); nothing to configure."
exit 0
fi
fi
# True if an output named $HEADLESS_NAME currently exists on the session.
_headless_present() {
swaymsg -t get_outputs -r 2>/dev/null \
| jq -e --arg n "$HEADLESS_NAME" '.[] | select(.name == $n)' >/dev/null
}
# Create the headless output if missing. Sway's `create_output` accepts an
# optional name; without one it auto-assigns HEADLESS-N like Hyprland does.
if ! _headless_present; then
log "Creating headless output $HEADLESS_NAME"
if ! swaymsg create_output "$HEADLESS_NAME" >/dev/null 2>&1; then
# Older sway versions ignore the name argument; fall back and rename via
# output detection after the fact.
swaymsg create_output >/dev/null 2>&1 || true
fi
# Poll briefly for the output to appear.
for _ in 1 2 3 4 5; do
_headless_present && break
sleep 0.1
done
if ! _headless_present; then
# Last-ditch: take the highest-numbered HEADLESS-* that exists and treat
# it as ours. Update HEADLESS_NAME in-memory so the resize below targets it.
found="$(swaymsg -t get_outputs -r 2>/dev/null \
| jq -r '[.[].name | select(startswith("HEADLESS-"))] | sort_by(.) | last // empty')"
if [[ -n "$found" ]]; then
HEADLESS_NAME="$found"
log "Adopted existing headless output: $HEADLESS_NAME"
else
log "Failed to create a headless output; stream will rely on whatever Sunshine selects."
exit 0
fi
fi
fi
# Snapshot state so undo can put things back. We don't move workspaces around
# on a headless-only box (there is no other monitor), but we still record what
# was active in case the user runs sway with a real display attached.
swaymsg -t get_outputs -r > "$STATE_DIR/prev-outputs.json" 2>/dev/null || true
echo "$HEADLESS_NAME" > "$STATE_DIR/headless-name"
# Resize the headless output. Sway accepts mode strings as "WIDTHxHEIGHT@FPSHz".
log "Sizing $HEADLESS_NAME${WIDTH}x${HEIGHT}@${FPS}Hz"
if ! swaymsg output "$HEADLESS_NAME" mode "${WIDTH}x${HEIGHT}@${FPS}Hz" >/dev/null 2>&1; then
log "Mode set with refresh rate failed; retrying without refresh"
swaymsg output "$HEADLESS_NAME" mode "${WIDTH}x${HEIGHT}" >/dev/null 2>&1 || \
log "Mode set failed; sway will keep the previous mode."
fi
# Focus the headless output so window placement lands there.
swaymsg focus output "$HEADLESS_NAME" >/dev/null 2>&1 || true
post="$(swaymsg -t get_outputs -r 2>/dev/null \
| jq -r '.[] | "\(.name) \(.current_mode.width)x\(.current_mode.height)@\(.current_mode.refresh) focused=\(.focused)"' \
| tr '\n' ';' || true)"
log "post-state outputs: $post"
log "Stream ready: ${WIDTH}x${HEIGHT}@${FPS} on $HEADLESS_NAME"

View File

@@ -1,41 +1,24 @@
#!/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`).
# Resizes the vkms-backed `Virtual-1` connector to match the connecting # Creates/resizes a Hyprland headless output to match the connecting
# Moonlight client's resolution, positions it adjacent to the existing real # Moonlight client's resolution, and moves the active workspace onto it
# monitor(s), and (optionally) moves a content-bearing workspace onto it so # so the user's existing windows are visible on the stream.
# 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 virtual display. Stream may show whatever Sunshine selects." log "hyprctl not found; cannot configure headless. Stream will use whatever output Sunshine selects."
exit 0 exit 0
fi fi
@@ -54,78 +37,39 @@ if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
fi fi
fi fi
# Verify the virtual output exists. If the vkms module isn't loaded, this will # Snapshot prior state so undo can restore.
# be empty and we bail cleanly — Sunshine's KMS capture will just see no
# matching connector and the user gets nothing useful, but at least nothing
# else gets disturbed.
if ! hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$VIRT_MON" '.[] | select(.name == $m)' >/dev/null; then
log "Virtual monitor '$VIRT_MON' not present in Hyprland. Is vkms loaded? (lsmod | grep vkms)"
exit 0
fi
# Snapshot prior state so the undo hook can restore focus to where the user
# actually was at connect time, even if we promote a different workspace
# onto Virtual-1 below.
hyprctl monitors -j > "$STATE_DIR/prev-monitors.json" 2>/dev/null || true 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)" PREV_WS="$(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"
# Choose a workspace whose content goes to the stream:
# 1. active workspace, if it has windows
# 2. otherwise the lowest-id workspace currently bound to Virtual-1 (sticky)
# 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" echo "$PREV_WS" > "$STATE_DIR/prev-workspace-id"
# Compute a non-overlapping position for Virtual-1: just to the right of the # Discover whatever headless output already exists. sunshine-prestart.sh is
# rightmost real monitor's logical edge. Real monitors keep their position; # responsible for ensuring one exists and aligning sunshine.conf's output_name
# Virtual-1 ends up as a new "right of laptop" workspace the user can drift to. # to its actual name (Hyprland's HEADLESS-N counter drifts across restarts).
MAX_RIGHT="$(hyprctl monitors -j 2>/dev/null \ MON="$(hyprctl monitors -j 2>/dev/null \
| jq -r --arg m "$VIRT_MON" ' | jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1)"
[.[] | select(.name != $m) if [[ -z "$MON" ]]; then
| (.x + ((.width / .scale) | floor))] log "No headless output found; creating one"
| max // 0' \ hyprctl output create headless >/dev/null
|| echo 0)" for _ in 1 2 3 4 5; do
# Resize Virtual-1 to the client's requested mode at that x-offset. vkms MON="$(hyprctl monitors -j 2>/dev/null \
# supports arbitrary modes via DRM mode-set; if a refresh-rate variant of the | jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1)"
# exact mode isn't in the reported list, Hyprland still negotiates. [[ -n "$MON" ]] && break
log "Sizing $VIRT_MON${WIDTH}x${HEIGHT}@${FPS} at ${MAX_RIGHT}x0 (scale=1)" sleep 0.1
hyprctl keyword monitor "$VIRT_MON,${WIDTH}x${HEIGHT}@${FPS},${MAX_RIGHT}x0,1" >/dev/null done
fi
if [[ -z "$MON" ]]; then
log "Failed to obtain a headless output; bailing."
exit 0
fi
echo "$MON" > "$STATE_DIR/headless-name"
# Move the chosen workspace onto Virtual-1 and focus it. # Resize headless to the client's resolution / framerate.
log "Moving workspace $PREV_WS$VIRT_MON, focusing it" log "Sizing $MON${WIDTH}x${HEIGHT}@${FPS}"
hyprctl dispatch moveworkspacetomonitor "$PREV_WS $VIRT_MON" >/dev/null || true hyprctl keyword monitor "$MON,${WIDTH}x${HEIGHT}@${FPS},auto,1" >/dev/null
hyprctl dispatch focusmonitor "$VIRT_MON" >/dev/null || true
# Dump post-state so we can verify everything ended up where intended. # Move the active workspace onto the headless so existing windows appear in the stream.
post_mons="$(hyprctl monitors -j 2>/dev/null \ log "Moving workspace $PREV_WS$MON, focusing it"
| jq -r '.[] | "\(.name) \(.width)x\(.height)@\(.refreshRate) at \(.x)x\(.y) activeWS=\(.activeWorkspace.id)"' \ hyprctl dispatch moveworkspacetomonitor "$PREV_WS $MON" >/dev/null || true
| tr '\n' ';' || true)" hyprctl dispatch focusmonitor "$MON" >/dev/null || true
post_ws="$(hyprctl workspaces -j 2>/dev/null \
| jq -r '.[] | "ws\(.id)=\(.windows)win on \(.monitor)"' \ log "Stream ready: ${WIDTH}x${HEIGHT}@${FPS} on $MON"
| 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,50 +0,0 @@
#!/usr/bin/env bash
# Sunshine global_prep_cmd `undo` hook for the Sway-based headless path.
# Cheaper than the Hyprland undo: there's no workspace shuffling to reverse on
# a true headless box. We just keep the headless output alive for the next
# connect (creating one is ~free, removing it forces sway to renegotiate
# focused output every cycle).
set -uo pipefail
HEADLESS_NAME="${OMARCHY_VIRTUAL_OUTPUT:-HEADLESS-1}"
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless"
HOOK_LOG="$STATE_DIR/hook.log"
log() {
local msg
msg="$(date +%H:%M:%S.%3N) [sunshine-undo-sway] $*"
printf '%s\n' "$msg" >&2
printf '%s\n' "$msg" >> "$HOOK_LOG" 2>/dev/null || true
}
log "undo-hook start"
if ! command -v swaymsg >/dev/null 2>&1; then
log "swaymsg not found; nothing to undo."
exit 0
fi
if [[ -z "${SWAYSOCK:-}" ]]; then
for sock in "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"/sway-ipc.*.sock; do
[[ -S "$sock" ]] || continue
export SWAYSOCK="$sock"
break
done
if [[ -z "${SWAYSOCK:-}" ]]; then
log "sway not running; nothing to undo."
exit 0
fi
fi
# Remember the headless name across the connect cycle if we adopted a
# different output name in the do-hook.
if [[ -f "$STATE_DIR/headless-name" ]]; then
HEADLESS_NAME="$(cat "$STATE_DIR/headless-name" 2>/dev/null || echo "$HEADLESS_NAME")"
fi
# On a server with no real outputs, removing HEADLESS-1 leaves sway with zero
# outputs and any future create_output starts numbering at -2, -3, etc.
# Cheaper to keep it alive.
log "Keeping $HEADLESS_NAME alive for the next stream"
rm -f "$STATE_DIR/prev-outputs.json" "$STATE_DIR/headless-name"
log "Stream teardown complete"

View File

@@ -1,25 +1,15 @@
#!/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`).
# Returns the promoted workspace to the original real monitor and restores # Moves the previously-active workspace back to a real monitor (if any
# focus to whichever workspace the user had active at connect time. # exist) and tears down the headless output created by sunshine-stream-do.sh.
#
# 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
VIRT_MON="${OMARCHY_VIRTUAL_OUTPUT:-Virtual-1}" log() { printf '[sunshine-undo] %s\n' "$*" >&2; }
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless" STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless"
HOOK_LOG="$STATE_DIR/hook.log" # Headless name was captured by sunshine-stream-do.sh; fall back to discovery.
log() { MON="$(cat "$STATE_DIR/headless-name" 2>/dev/null || true)"
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."
@@ -39,27 +29,29 @@ 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")"
# Find a non-virtual monitor to move the promoted workspace back to. if [[ -z "$MON" ]]; then
REAL_MON="$(hyprctl monitors -j 2>/dev/null \ MON="$(hyprctl monitors -j 2>/dev/null \
| jq -r --arg m "$VIRT_MON" '.[] | select(.name != $m) | .name' \ | jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1)"
| head -n1)" fi
# 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
# Clean state files but keep the directory + hook.log for the next run. # Leave HEADLESS-1 in place. It needs to exist persistently for Sunshine's
rm -f "$STATE_DIR/prev-monitors.json" "$STATE_DIR/prev-workspace-id" "$STATE_DIR/orig-active-workspace-id" # encoder probe to succeed at startup; removing-and-recreating per session
log "Stream teardown complete ($VIRT_MON kept alive for next connect)" # raced with the probe and caused fatal startup errors. Resizing on each
# 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

@@ -162,35 +162,6 @@ Moonlight tears down the stream
--- ---
## Headless capture backends: wlr vs X11/NVENC
The installer's headless mode assumes **wlr** (`capture = wlr`) on Hyprland —
that's the runtime flow diagrammed above. There is a second headless backend,
used on hosts that don't run Hyprland (e.g. Ubuntu) or that want a guaranteed
NVIDIA GL context for NVENC: **X11 capture of a headless Xorg**.
| | wlr path (installer default) | X11/NVENC path (manual) |
|---|---|---|
| Compositor | Hyprland (or sway-headless on a server) | headless Xorg on `:0` |
| `sunshine.conf` | `capture = wlr`, `output_name = HEADLESS-N` | `capture = x11`, `output_name = 0` |
| Display unit | the Hyprland/sway session | `xorg-headless.service` (user unit) |
| Per-client resize | yes — `global_prep_cmd` do/undo hooks | no — Xorg has a fixed `MetaModes` resolution |
| How the display is faked | wlroots headless output | NVIDIA `ConnectedMonitor` + `ModeValidation` (TwinView) |
| Service env drop-in | inherits Wayland env | pins `DISPLAY=:0`, `XDG_SESSION_TYPE=x11` |
The X11/NVENC path is the systemd-service form of the upstream "Remote SSH
Headless Setup" guide. It trades per-client resolution adaptation (the wlr
path's main feature) for a simpler, compositor-free capture that the NVIDIA
driver accelerates directly. Input injection is identical for both — `input`
group + `60-sunshine.rules` on `/dev/uinput`.
Both backends share the same boot caveat: on a headless host the Sunshine unit
must be wired into `default.target`, not `graphical-session.target`, or it
never auto-starts. See TROUBLESHOOTING.md §1213 for the drop-ins and the
alias-merge gotcha that lets a stale wlr drop-in poison the X11 environment.
---
## Cert pipeline ## Cert pipeline
A separate one-time bootstrap creates the CA in 1Password. Every host then A separate one-time bootstrap creates the CA in 1Password. Every host then
@@ -294,7 +265,6 @@ verify.
omarchy-moonlight/ omarchy-moonlight/
├── install.sh Orchestrator ├── install.sh Orchestrator
├── uninstall.sh Reverse install (preserves user data by default) ├── uninstall.sh Reverse install (preserves user data by default)
├── status.sh Runtime health check (what's running + g2g verdict)
├── README.md User-facing install + usage ├── README.md User-facing install + usage
├── scripts/ ├── scripts/
│ └── cert-bootstrap.sh One-time CA generation + 1P upload │ └── cert-bootstrap.sh One-time CA generation + 1P upload

View File

@@ -220,49 +220,6 @@ that prefix. Substantial work; not justified without a real second user.
--- ---
## P3 — Installer support for the X11/NVENC (non-Hyprland) headless path
**Symptom**: `install.sh` only knows the Arch + Hyprland + wlr world. At least
one real deployment is an **Ubuntu host running the X11/NVENC path** (headless
Xorg on `:0`, `capture = x11`, NVIDIA TwinView virtual display) — set up
entirely by hand. None of it is reproducible from the repo: not the
`xorg-headless.service` unit, not the X11 `sunshine.conf`, not the
`DISPLAY=:0` service drop-in, not the `default.target` boot wiring (see
TROUBLESHOOTING.md §1213).
**Current workaround**: configure those hosts manually using the recipes now
documented in ARCHITECTURE.md ("Headless capture backends") and
TROUBLESHOOTING.md §13.
**Fix sketch**:
- A `--backend x11|wlr` flag (or auto-detect: Hyprland reachable → wlr,
NVIDIA + no Wayland compositor → x11).
- `lib/config.sh`: emit the X11 conf variant when backend is x11.
- Ship an `xorg-headless.service` + `xorg-headless.conf` template (the
`ConnectedMonitor`/`ModeValidation` block is GPU-output-specific — needs
`xrandr` detection or a prompt).
- `lib/service.sh`: install the `default.target` boot drop-in for headless
hosts regardless of backend, and the `DISPLAY=:0` env drop-in for x11.
- Debian/Ubuntu package install path (`apt` + the `.deb`), since `yay`/AUR
don't exist there. This is a larger lift than the flag itself.
**Done so far**: the *desktop* piece of the x11 backend is now reproducible.
`install.sh` detects `capture = x11` (via `capture_backend_is_x11`) and installs
+ enables `headless-desktop.service` so the headless Xorg has a desktop to
render — without it, capture is a black screen. Desktop is selectable via
`HEADLESS_DESKTOP`: `gnome` (default — full Ubuntu GNOME session, X11 path) or
`openbox` (lightweight bare WM); each ships as its own unit template
(`files/headless-desktop-{gnome,openbox}.service`). `status.sh` gained a matching
check (FAIL if no desktop on `:0`, and reports which one). Still outstanding:
the `--backend x11|wlr` flag/auto-detect, the `config.sh` x11 conf variant, and
shipping the `xorg-headless.service` + `xorg-headless.conf` templates.
**Complexity**: medium-high. The capture-backend split is moderate; full
Debian packaging support is the bulk of the work.
---
## Not on the list (intentionally) ## Not on the list (intentionally)
- **TLS for the stream itself.** Sunshine and Moonlight handle this with - **TLS for the stream itself.** Sunshine and Moonlight handle this with

204
docs/HEADLESS-PRIMARY.md Normal file
View File

@@ -0,0 +1,204 @@
# 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

@@ -364,150 +364,6 @@ IP.2 = 127.0.0.1
And the idempotency check in `fetch_and_install_certs` requires those SANs And the idempotency check in `fetch_and_install_certs` requires those SANs
— existing hosts re-mint on next install. — existing hosts re-mint on next install.
### 12. Headless host: service never auto-starts after boot (`graphical-session.target` never activates)
**Symptom**
Sunshine works right after install, and works again if you `systemctl --user
start` it by hand, but never comes up on its own after a reboot. `systemctl
--user status` shows the unit `enabled` yet `inactive (dead)`*not* failed,
just never started. Ports 47984/47989/47990 aren't listening and nothing is
in the journal because the unit was never invoked.
**Cause**
The packaged unit (`sunshine.service`, or `app-dev.lizardbyte.app.Sunshine.service`)
is wired `WantedBy=graphical-session.target` and `After=graphical-session.target`.
On a desktop, logging in activates `graphical-session.target`, which pulls
Sunshine up. A **headless host has no graphical login session**, so
`graphical-session.target` never activates — even with `loginctl enable-linger`
on and a headless display server (Xorg/sway) already running. The installer's
one-time `systemctl --user start` is why it appears to work at install time;
the wiring just never fires again at boot.
This bites any headless deployment (KVM box, server with a dummy/virtual
display, the X11/NVENC path in §13). It does *not* bite a laptop/desktop that
actually logs into Hyprland.
**Fix**
Add a drop-in that wires the unit into a target the lingering user-manager
actually reaches (`default.target`) and orders it after whatever provides the
display:
```bash
# adjust the unit name to whichever one is installed (see issue #3)
UNIT=app-dev.lizardbyte.app.Sunshine.service
mkdir -p ~/.config/systemd/user/$UNIT.d
cat > ~/.config/systemd/user/$UNIT.d/headless-boot.conf <<'EOF'
[Unit]
# Order after the headless display unit (xorg-headless.service for the X11
# path, sway-headless.service for the wlr path).
Requires=xorg-headless.service
After=xorg-headless.service
[Install]
# graphical-session.target never activates on a headless host; default.target
# is reached by the lingering user manager, so this is what makes boot work.
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user reenable $UNIT # recreates default.target.wants symlink
systemctl --user restart $UNIT
```
Confirm the load-bearing symlink exists:
`ls ~/.config/systemd/user/default.target.wants/ | grep -i sunshine`. The real
proof is a reboot — `enabled` alone isn't enough on a headless box.
> Note: dependency keys (`Requires=`, `After=`) belong in the `[Unit]` section.
> A drop-in that puts them in `[Service]` is silently ignored — systemd logs
> `Unknown key name 'Requires' in section 'Service', ignoring` in
> `systemctl status`, and the ordering never takes effect.
### 13. X11/NVENC headless path (Ubuntu / non-Hyprland hosts)
**Symptom / context**
This repo's headless mode assumes Hyprland + `capture = wlr`. On a host that
isn't running Hyprland (e.g. an Ubuntu box, or one where you want NVIDIA NVENC
with a guaranteed GL context), the wlr path has nothing to capture and every
encoder probe fails at startup (same red banner as issue #4).
**The X11/NVENC alternative (as deployed on at least one host)**
Instead of a wlroots compositor, run a **headless Xorg on `:0`** and have
Sunshine grab the X root window. This is the systemd-service equivalent of the
upstream "Remote SSH Headless Setup" guide's TwinView trick — NVIDIA Xorg is
told a monitor is connected so it produces a hardware-accelerated virtual
display.
- A `xorg-headless.service` (user unit) runs
`Xorg :0 -config xorg-headless.conf …` with an NVIDIA `ConnectedMonitor` +
`ModeValidation` block (see the upstream guide). It's the headless display
the Sunshine unit orders against in issue #12.
- `~/.config/sunshine/sunshine.conf` uses `capture = x11`, `output_name = 0`,
`encoder = nvenc`. (Hand-edited — delete the `# managed-by:` marker line so
the Arch installer leaves it alone.)
- A service drop-in pins the X11 environment so Sunshine doesn't auto-probe
Wayland:
```ini
[Service]
Environment=
Environment=DISPLAY=:0
Environment=XDG_SESSION_TYPE=x11
UnsetEnvironment=WAYLAND_DISPLAY XDG_SESSION_TYPE
```
- Input injection works the normal way — user in the `input` group +
`60-sunshine.rules` udev rule on `/dev/uinput`. The upstream guide's
`chown`-via-passwordless-sudo `/dev/uinput` workaround is **not needed**;
it only applies when you SSH in without group membership.
**Gotcha: leftover wlr drop-ins poison the X11 env.** If a host was first set
up for the wlr path and later migrated to X11, a stale
`sunshine.service.d/sway-headless.conf` drop-in can linger. Because
`sunshine.service` is an *alias* of `app-dev.lizardbyte.app.Sunshine.service`,
systemd merges drop-ins from **both** name directories, so that leftover keeps
injecting `XDG_SESSION_TYPE=wayland`, `WAYLAND_DISPLAY=wayland-1`, and a hard
`Requires=sway-headless.service` (a dead unit). It "works" only because
`capture = x11` is explicit in the conf, but at boot it tries to pull in the
dead sway unit. Delete the stale drop-in and verify the effective env:
```bash
systemctl --user show $UNIT -p Environment -p Requires -p After
# should show DISPLAY=:0 + XDG_SESSION_TYPE=x11, xorg-headless.service, no wayland/sway
```
**Gotcha: black screen in Moonlight even though everything "works".** Pairing
succeeds, NVENC loads, mouse/keyboard input reaches the host — but the client
sees only black. Cause: the headless Xorg on `:0` is up, but **nothing is
rendering on it**. Unlike the wlr path (where the compositor *is* the desktop),
a bare Xorg server draws nothing on its own, so `capture = x11` grabs an empty
black root window. Confirm with:
```bash
DISPLAY=:0 xlsclients # only 'sunshine' = no WM/desktop
DISPLAY=:0 xprop -root _NET_SUPPORTING_WM_CHECK # 'not found' = no window manager
```
Fix: run a desktop on `:0`. The installer ships a `headless-desktop.service`
for exactly this and enables it whenever it detects `capture = x11`. It defaults
to a full **GNOME** session (`gnome-session --session=ubuntu`, forced to the X11
path); set `HEADLESS_DESKTOP=openbox` for a lightweight bare WM instead (lower
overhead, but no panel/launcher — right-click menu only). The unit forces
`XDG_SESSION_TYPE=x11` and does **not** wrap GNOME in `dbus-run-session` — GNOME
must share the systemd *user* bus the service already inherits.
```bash
systemctl --user enable --now headless-desktop.service
DISPLAY=:0 xprop -root _NET_SUPPORTING_WM_CHECK # now reports a 'window id'
# read the running desktop's name off that window:
DISPLAY=:0 xprop -id <id> _NET_WM_NAME # e.g. "GNOME Shell"
```
`status.sh` checks for this directly: with `capture = x11` it now FAILs if no
window manager is present on `:0`, instead of only verifying the X server answers.
--- ---
## Custom keybinding to escape Moonlight (Mac) ## Custom keybinding to escape Moonlight (Mac)

View File

@@ -1,30 +0,0 @@
[Unit]
Description=GNOME session on the headless Xorg (gives Sunshine something to capture)
# Without a desktop running on :0, Sunshine's x11 capture grabs an empty black
# X root window — pairing, NVENC, and input all work but the client sees only
# black. This runs a full GNOME session on :0 so the stream shows a real
# desktop. Only relevant to the X11/NVENC capture backend (capture = x11); the
# wlr backend's compositor renders for itself.
Requires=xorg-headless.service
After=xorg-headless.service
PartOf=graphical-session.target
[Service]
Type=simple
# Force the X11 session path (not Wayland) onto the existing headless Xorg.
# No dbus-run-session wrapper: GNOME integrates with the systemd *user* bus,
# which this service already inherits — a fresh bus would break that.
Environment=DISPLAY=:0
Environment=XDG_SESSION_TYPE=x11
Environment=XDG_CURRENT_DESKTOP=ubuntu:GNOME
Environment=GNOME_SHELL_SESSION_MODE=ubuntu
# Wait for the X server to accept connections before launching the session.
ExecStartPre=/bin/sh -c 'for i in $(seq 1 20); do xset -display :0 -q >/dev/null 2>&1 && exit 0; sleep 0.5; done; exit 1'
ExecStart=/usr/bin/gnome-session --session=ubuntu
Restart=on-failure
RestartSec=2s
[Install]
# Lingering user manager reaches default.target without a graphical login,
# matching the headless-boot drop-in pattern used for Sunshine.
WantedBy=default.target

View File

@@ -1,27 +0,0 @@
[Unit]
Description=Openbox session on the headless Xorg (gives Sunshine something to capture)
# Without a window manager/desktop running on :0, Sunshine's x11 capture grabs
# an empty black X root window — pairing, NVENC, and input all work but the
# client sees only black. This unit renders a session onto :0 so the stream
# shows an actual desktop. Only relevant to the X11/NVENC capture backend
# (capture = x11); the wlr backend's compositor renders for itself.
Requires=xorg-headless.service
After=xorg-headless.service
PartOf=graphical-session.target
[Service]
Type=simple
Environment=DISPLAY=:0
# Wait for the X server to accept connections before launching the WM.
ExecStartPre=/bin/sh -c 'for i in $(seq 1 20); do xset -display :0 -q >/dev/null 2>&1 && exit 0; sleep 0.5; done; exit 1'
ExecStart=/usr/bin/openbox-session
# Paint a solid root so a connecting client sees an obvious (non-black) desktop.
# Leading '-' = best-effort; a missing xsetroot must not fail the unit.
ExecStartPost=-/usr/bin/xsetroot -display :0 -solid "#2e3440"
Restart=on-failure
RestartSec=2s
[Install]
# Lingering user manager reaches default.target without a graphical login,
# matching the headless-boot drop-in pattern used for Sunshine.
WantedBy=default.target

View File

@@ -1,30 +0,0 @@
# Minimal sway config for a headless Sunshine host. Installed by
# omarchy-moonlight as ~/.config/sway/config-headless. Loaded by the
# sway-headless.service systemd-user unit on Debian/Ubuntu installs.
#
# Goals:
# - Boot sway with a single headless output named HEADLESS-1 so Sunshine's
# wlr capture has a stable target.
# - No keybindings, no bars, no animations. There's no human at the console.
# - The sunshine-stream-do-sway.sh hook adjusts HEADLESS-1's mode per client
# connect; this config is just the boot-time baseline.
# Create the headless output at startup. Sway accepts `output HEADLESS-1
# enable` only after the output exists, so we issue create_output here.
exec swaymsg create_output HEADLESS-1
# Default mode — overridden per-connect by the do-hook.
output HEADLESS-1 mode 1920x1080@60Hz
output HEADLESS-1 background #1a1a1a solid_color
# No idle locking on a headless box; no XWayland (would just waste DRM resources).
xwayland disable
# Don't enable animations / focus-follow / etc — there's no user input here.
focus_follows_mouse no
default_border none
default_floating_border none
# A minimal placeholder so sway has something to display. The actual stream
# content lives in whatever app the user launches via sunshine commands.
exec --no-startup-id true

View File

@@ -1,37 +0,0 @@
[Unit]
Description=Headless Sway compositor for Sunshine wlr capture
# Don't auto-restart on `systemctl --user stop` — but bring sway back if it
# actually crashes mid-stream.
After=graphical-session-pre.target
PartOf=graphical-session.target
[Service]
Type=simple
# Tell wlroots to use the headless backend (no DRM master needed) and skip
# libinput device probing — there are no input devices on a real headless box.
#
# GLES2 renderer: the Vulkan backend strictly requires
# VK_EXT_external_memory_dma_buf, which the proprietary NVIDIA driver
# doesn't expose on every build (esp. data-center / -server branches). GLES2
# is universally available and Sunshine's capture path uses its own dma-buf
# flow, so we don't lose hardware acceleration.
Environment=WLR_BACKENDS=headless
Environment=WLR_LIBINPUT_NO_DEVICES=1
# Pixman (software) renderer. The GLES2 path needs a GBM-allocated framebuffer,
# which fails on the proprietary NVIDIA driver: its GBM bridge doesn't permit
# unprivileged CREATE_DUMB even on render nodes (Mesa drivers do). The trade-
# off: Sunshine's wlr-screencopy then takes the shm path and uses libx264
# software encoding. For NVENC on NVIDIA cloud GPUs, sunshine should be
# pointed at a virtual KMS connector (vkms or Xorg+Dummy) instead of wlroots.
Environment=WLR_RENDERER=pixman
Environment=XDG_SESSION_TYPE=wayland
ExecStart=/usr/bin/sway --config %h/.config/sway/config-headless --unsupported-gpu
Restart=on-failure
RestartSec=2s
TimeoutStopSec=5s
[Install]
WantedBy=default.target

View File

@@ -8,8 +8,6 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh # shellcheck source=lib/common.sh
source "$SCRIPT_DIR/lib/common.sh" source "$SCRIPT_DIR/lib/common.sh"
# shellcheck source=lib/distro.sh
source "$SCRIPT_DIR/lib/distro.sh"
# shellcheck source=lib/detect.sh # shellcheck source=lib/detect.sh
source "$SCRIPT_DIR/lib/detect.sh" source "$SCRIPT_DIR/lib/detect.sh"
# shellcheck source=lib/preflight.sh # shellcheck source=lib/preflight.sh
@@ -59,9 +57,6 @@ Environment overrides:
HEADLESS_HOSTS Comma-separated hostnames that default to headless mode. HEADLESS_HOSTS Comma-separated hostnames that default to headless mode.
Unset by default; anything not listed defaults to mirror. Unset by default; anything not listed defaults to mirror.
Override per-invocation with --headless or --mirror. Override per-invocation with --headless or --mirror.
HEADLESS_DESKTOP Desktop to render on the headless Xorg :0 for the X11/NVENC
capture path (capture = x11): 'gnome' (default) or 'openbox'
(lightweight, for minimal hosts). Ignored on wlr/kms.
EOF EOF
} }
@@ -147,14 +142,14 @@ setup_install_log() {
main() { main() {
setup_install_log setup_install_log
require_not_root require_not_root
require_supported_distro require_arch
require_yay
step "Detecting system" step "Detecting system"
detect_all detect_all
compute_stream_mode compute_stream_mode
export STREAM_MODE export STREAM_MODE
info "Distro: $DISTRO ($DISTRO_ID ${DISTRO_VERSION:-}) Host: $HOSTNAME_SHORT" info "Host: $HOSTNAME_SHORT GPU: $GPU_VENDOR Session: $SESSION_TYPE"
info "GPU: $GPU_VENDOR Session: $SESSION_TYPE Compositor: $COMPOSITOR"
info "Mode: $STREAM_MODE" info "Mode: $STREAM_MODE"
if [[ $DOCTOR_ONLY -eq 1 ]]; then if [[ $DOCTOR_ONLY -eq 1 ]]; then
@@ -165,17 +160,6 @@ main() {
step "Preflight checks" step "Preflight checks"
preflight_all preflight_all
# On headless mode, make sure we have a compositor we can drive. On Arch
# this is typically Hyprland (already on Omarchy); on Ubuntu we install Sway
# and switch COMPOSITOR before installing hooks.
if [[ "$STREAM_MODE" == "headless" && "$COMPOSITOR" == "none" ]]; then
step "Installing headless compositor"
install_headless_compositor
COMPOSITOR="$(detect_compositor)"
export COMPOSITOR
info "Compositor (after install): $COMPOSITOR"
fi
if [[ $INSTALL_SUNSHINE -eq 1 ]]; then if [[ $INSTALL_SUNSHINE -eq 1 ]]; then
step "Installing Sunshine and GPU encoder support" step "Installing Sunshine and GPU encoder support"
install_sunshine install_sunshine
@@ -190,16 +174,6 @@ main() {
step "Installing headless prep-cmd hooks" step "Installing headless prep-cmd hooks"
install_headless_hooks install_headless_hooks
fi fi
# X11/NVENC headless backend (capture = x11): the headless Xorg on :0 has
# no compositor of its own, so it needs a window manager rendering on it or
# Sunshine captures a black screen. Detected from the existing sunshine.conf
# (this backend is hand-configured; see FOLLOWUPS.md P3). Harmless no-op on
# the wlr/kms backends, whose capture source renders for itself.
if capture_backend_is_x11; then
step "Installing headless desktop (${HEADLESS_DESKTOP:-gnome} on :0 for the X11 capture path)"
install_headless_desktop
fi
# NOTE: the headless prestart drop-in needs the sunshine unit to already # NOTE: the headless prestart drop-in needs the sunshine unit to already
# exist; install it after service-unit detection in enable_sunshine_service. # exist; install it after service-unit detection in enable_sunshine_service.

View File

@@ -21,21 +21,14 @@
SUNSHINE_CRED_DIR="$HOME/.config/sunshine/credentials" SUNSHINE_CRED_DIR="$HOME/.config/sunshine/credentials"
SUNSHINE_CERT="$SUNSHINE_CRED_DIR/cacert.pem" SUNSHINE_CERT="$SUNSHINE_CRED_DIR/cacert.pem"
SUNSHINE_KEY="$SUNSHINE_CRED_DIR/cakey.pem" SUNSHINE_KEY="$SUNSHINE_CRED_DIR/cakey.pem"
# Resolved per-distro via lib/distro.sh: SYSTEM_TRUST_ANCHOR="/etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem"
# Arch: /etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem
# Debian: /usr/local/share/ca-certificates/omarchy-stream-ca.crt
# Read it via ca_anchor_path; do not hard-code here.
# --- 1Password helpers ---------------------------------------------------- # --- 1Password helpers ----------------------------------------------------
op_require_signin() { op_require_signin() {
if ! command -v op >/dev/null 2>&1; then if ! command -v op >/dev/null 2>&1; then
err "1Password CLI ('op') not found on PATH." err "1Password CLI ('op') not found on PATH."
case "$DISTRO" in err "Install it: yay -S 1password-cli"
arch) err "Install it: yay -S 1password-cli" ;;
debian) err "Install it: https://developer.1password.com/docs/cli/get-started/ (apt repo or .deb)" ;;
*) err "Install the 1Password CLI from https://developer.1password.com/docs/cli/get-started/" ;;
esac
return 1 return 1
fi fi
if ! op whoami >/dev/null 2>&1; then if ! op whoami >/dev/null 2>&1; then
@@ -131,25 +124,16 @@ EOF
install_ca_to_system_trust() { install_ca_to_system_trust() {
local ca_pem="$1" local ca_pem="$1"
local anchor # Idempotent: compare sha256 first to avoid pointless update-ca-trust runs.
anchor="$(ca_anchor_path)" if [[ -f "$SYSTEM_TRUST_ANCHOR" ]] \
if [[ -z "$anchor" ]]; then && cmp -s "$ca_pem" "$SYSTEM_TRUST_ANCHOR"; then
warn "Don't know how to install CA on distro '$DISTRO' — skipping system trust step."
return 0
fi
# Idempotent: compare sha256 first to avoid pointless update-ca-* runs.
if [[ -f "$anchor" ]] && cmp -s "$ca_pem" "$anchor"; then
ok "CA already in system trust store" ok "CA already in system trust store"
return 0 return 0
fi fi
# Debian's update-ca-certificates only picks up files under info "Installing CA into $SYSTEM_TRUST_ANCHOR"
# /usr/local/share/ca-certificates/ that end in .crt. The path returned by as_root install -m 0644 "$ca_pem" "$SYSTEM_TRUST_ANCHOR"
# ca_anchor_path already accounts for that. as_root update-ca-trust extract >/dev/null
info "Installing CA into $anchor" ok "System trust store refreshed (update-ca-trust)"
as_root mkdir -p "$(dirname "$anchor")"
as_root install -m 0644 "$ca_pem" "$anchor"
ca_update_trust
ok "System trust store refreshed"
} }
# --- Top-level orchestration --------------------------------------------- # --- Top-level orchestration ---------------------------------------------

View File

@@ -29,14 +29,25 @@ require_not_root() {
fi fi
} }
# Package management (pkg_installed, pkg_install, etc.) and distro detection require_arch() {
# live in lib/distro.sh. Source it after this file. if [[ ! -f /etc/arch-release ]] && ! grep -q '^ID=arch' /etc/os-release 2>/dev/null; then
# err "This script targets Arch Linux (Omarchy). /etc/arch-release not found."
# yay_install is kept as an alias for code paths that explicitly want AUR exit 1
# packages even on a distro-agnostic call site (rare). On non-Arch distros it fi
# falls back to pkg_install. }
require_yay() {
if ! command -v yay >/dev/null 2>&1; then
err "yay is required to install AUR packages. Install yay first."
exit 1
fi
}
# True if package is installed (pacman -Qi).
pkg_installed() { pacman -Qi "$1" >/dev/null 2>&1; }
# Install one or more packages via yay if any are missing.
yay_install() { yay_install() {
if [[ "${DISTRO:-}" == "arch" ]] && command -v yay >/dev/null 2>&1; then
local missing=() local missing=()
local p local p
for p in "$@"; do for p in "$@"; do
@@ -46,9 +57,6 @@ yay_install() {
ok "Already installed: $*" ok "Already installed: $*"
return 0 return 0
fi fi
info "Installing (AUR): ${missing[*]}" info "Installing: ${missing[*]}"
yay -S --needed --noconfirm "${missing[@]}" yay -S --needed --noconfirm "${missing[@]}"
else
pkg_install "$@"
fi
} }

View File

@@ -69,11 +69,8 @@ $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
# Audio sink is intentionally left unset so Sunshine auto-detects the default # Use the PipeWire pulse compatibility layer for audio.
# PulseAudio/PipeWire sink and creates its virtual 'sink-sunshine-stereo'. audio_sink = pulse
# 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.)

View File

@@ -1,47 +1,25 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Detect GPU vendor, session type, hostname, compositor. # Detect GPU vendor, session type, hostname.
detect_gpu_vendor() { detect_gpu_vendor() {
local vga local vga
vga="$(lspci -nn 2>/dev/null | grep -iE 'vga|3d|display' || true)" vga="$(lspci -nn 2>/dev/null | grep -iE 'vga|3d|display' || true)"
# Prefer the first discrete/dedicated entry — VM hosts often expose a if grep -qi 'nvidia' <<<"$vga"; then
# placeholder BOCHS/QEMU VGA device before the real GPU.
local nvidia_line amd_line intel_line
nvidia_line="$(grep -i 'nvidia' <<<"$vga" | head -n1)"
amd_line="$(grep -iE 'amd|advanced micro devices|ati' <<<"$vga" | head -n1)"
intel_line="$(grep -i 'intel' <<<"$vga" | head -n1)"
if [[ -n "$nvidia_line" ]]; then
echo "nvidia" echo "nvidia"
elif [[ -n "$amd_line" ]]; then elif grep -qiE 'amd|advanced micro devices|ati' <<<"$vga"; then
echo "amd" echo "amd"
elif [[ -n "$intel_line" ]]; then elif grep -qi 'intel' <<<"$vga"; then
echo "intel" echo "intel"
else else
echo "unknown" echo "unknown"
fi fi
} }
# Decide which wlroots-based compositor to drive for headless capture.
# Returns one of:
# hyprland - hyprctl available (preferred when present, matches existing hooks)
# sway - sway/swaymsg available
# none - neither installed yet (install_headless_compositor will fix this)
detect_compositor() {
if command -v hyprctl >/dev/null 2>&1; then
echo "hyprland"
elif command -v swaymsg >/dev/null 2>&1 || command -v sway >/dev/null 2>&1; then
echo "sway"
else
echo "none"
fi
}
detect_all() { detect_all() {
HOSTNAME_SHORT="$(hostname -s 2>/dev/null || hostname)" HOSTNAME_SHORT="$(hostname -s 2>/dev/null || hostname)"
GPU_VENDOR="$(detect_gpu_vendor)" GPU_VENDOR="$(detect_gpu_vendor)"
SESSION_TYPE="${XDG_SESSION_TYPE:-unknown}" SESSION_TYPE="${XDG_SESSION_TYPE:-unknown}"
COMPOSITOR="$(detect_compositor)" export HOSTNAME_SHORT GPU_VENDOR SESSION_TYPE
export HOSTNAME_SHORT GPU_VENDOR SESSION_TYPE COMPOSITOR
if [[ "$SESSION_TYPE" != "wayland" ]]; then if [[ "$SESSION_TYPE" != "wayland" ]]; then
warn "Session type is '$SESSION_TYPE' (not wayland). KMS capture still works at the TTY/DRM level, but Hyprland-specific paths assume Wayland." warn "Session type is '$SESSION_TYPE' (not wayland). KMS capture still works at the TTY/DRM level, but Hyprland-specific paths assume Wayland."

View File

@@ -1,174 +0,0 @@
#!/usr/bin/env bash
# Distro detection + small dispatch layer so the rest of the installer can
# stay distro-agnostic. Two backends supported today: Arch (Omarchy) and
# Debian/Ubuntu.
#
# Sourced once at the top of install.sh / uninstall.sh. detect_distro must
# run before any of the dispatch helpers; require_supported_distro calls it
# for you.
# Populated by detect_distro:
# DISTRO - "arch" | "debian" (ubuntu folds into debian)
# DISTRO_ID - raw ID from /etc/os-release (e.g. "ubuntu", "arch")
# DISTRO_VERSION - VERSION_ID from /etc/os-release (e.g. "24.04"), empty on Arch
detect_distro() {
if [[ -n "${DISTRO:-}" ]]; then
return 0
fi
local id="" id_like="" version_id=""
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
id="${ID:-}"
id_like="${ID_LIKE:-}"
version_id="${VERSION_ID:-}"
fi
DISTRO_ID="$id"
DISTRO_VERSION="$version_id"
case "$id" in
arch|manjaro|endeavouros|omarchy)
DISTRO="arch"
;;
ubuntu|debian|pop|linuxmint)
DISTRO="debian"
;;
*)
# Fall back to ID_LIKE.
if [[ " $id_like " == *" arch "* ]]; then
DISTRO="arch"
elif [[ " $id_like " == *" debian "* || " $id_like " == *" ubuntu "* ]]; then
DISTRO="debian"
elif [[ -f /etc/arch-release ]]; then
DISTRO="arch"
elif command -v apt-get >/dev/null 2>&1; then
DISTRO="debian"
elif command -v pacman >/dev/null 2>&1; then
DISTRO="arch"
else
DISTRO="unknown"
fi
;;
esac
export DISTRO DISTRO_ID DISTRO_VERSION
}
require_supported_distro() {
detect_distro
case "$DISTRO" in
arch)
if ! command -v yay >/dev/null 2>&1; then
err "yay is required to install AUR packages on Arch. Install yay first."
exit 1
fi
;;
debian)
if ! command -v apt-get >/dev/null 2>&1; then
err "apt-get not found — Debian/Ubuntu install path requires it."
exit 1
fi
;;
*)
err "Unsupported distro (ID='${DISTRO_ID:-unknown}'). Supported: Arch family, Debian/Ubuntu family."
exit 1
;;
esac
}
# ---------------------------------------------------------------------------
# Package query / install dispatch.
# True if package is installed.
pkg_installed() {
case "$DISTRO" in
arch) pacman -Qi "$1" >/dev/null 2>&1 ;;
debian) dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q '^install ok installed$' ;;
*) return 1 ;;
esac
}
# Install one or more packages. Idempotent: only installs missing ones.
# On Arch, uses yay; on Debian/Ubuntu, uses apt-get. The Arch-only yay_install
# function below is kept as an alias for code that explicitly wants AUR.
pkg_install() {
local missing=()
local p
for p in "$@"; do
pkg_installed "$p" || missing+=("$p")
done
if [[ ${#missing[@]} -eq 0 ]]; then
ok "Already installed: $*"
return 0
fi
info "Installing: ${missing[*]}"
case "$DISTRO" in
arch)
yay -S --needed --noconfirm "${missing[@]}"
;;
debian)
_apt_ensure_updated
as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${missing[@]}"
;;
*)
err "Don't know how to install packages on distro '$DISTRO'"
return 1
;;
esac
}
# Cache `apt-get update` for this script run; running it on every pkg_install
# call would be slow and noisy.
_APT_UPDATED=0
_apt_ensure_updated() {
[[ "$_APT_UPDATED" -eq 1 ]] && return 0
info "Refreshing apt package lists"
as_root env DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null
_APT_UPDATED=1
}
# Install a local .deb file. Resolves its dependencies via apt.
deb_install_local() {
local deb_path="$1"
[[ -f "$deb_path" ]] || { err "deb not found: $deb_path"; return 1; }
_apt_ensure_updated
info "Installing $(basename "$deb_path") via apt-get"
as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$deb_path"
}
# Remove a package if installed. Quiet no-op if absent.
pkg_remove() {
local p
for p in "$@"; do
pkg_installed "$p" || continue
case "$DISTRO" in
arch) as_root pacman -Rns --noconfirm "$p" ;;
debian) as_root env DEBIAN_FRONTEND=noninteractive apt-get purge -y "$p" ;;
esac
done
}
# ---------------------------------------------------------------------------
# CA trust-store dispatch.
#
# Arch: /etc/ca-certificates/trust-source/anchors/<name>.pem + update-ca-trust
# Debian: /usr/local/share/ca-certificates/<name>.crt + update-ca-certificates
# Path where we'll drop our CA anchor for this distro. Stable across runs.
ca_anchor_path() {
case "$DISTRO" in
arch) echo "/etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem" ;;
debian) echo "/usr/local/share/ca-certificates/omarchy-stream-ca.crt" ;;
*) echo "" ;;
esac
}
# Refresh the system trust store after writing a new anchor.
ca_update_trust() {
case "$DISTRO" in
arch) as_root update-ca-trust extract >/dev/null ;;
debian) as_root update-ca-certificates >/dev/null ;;
*) warn "Unknown distro — CA trust update skipped"; return 1 ;;
esac
}

View File

@@ -3,129 +3,27 @@
# location and ensure they're executable. The actual sunshine.conf entries # location and ensure they're executable. The actual sunshine.conf entries
# (capture=wlr, output_name=HEADLESS-1, global_prep_cmd=[...]) are written # (capture=wlr, output_name=HEADLESS-1, global_prep_cmd=[...]) are written
# by lib/config.sh. # by lib/config.sh.
#
# Two compositor backends supported:
# - hyprland (default on Omarchy/Arch): bin/sunshine-stream-{do,undo}.sh
# - sway (default on Debian/Ubuntu): bin/sunshine-stream-{do,undo}-sway.sh
# detect_compositor (lib/detect.sh) decides which to install. The script names
# at the install target are *always* sunshine-stream-do.sh / -undo.sh, so the
# rest of the installer (config.sh, verify.sh) doesn't have to branch.
HEADLESS_BIN_DIR="$HOME/.local/share/omarchy-moonlight/bin" HEADLESS_BIN_DIR="$HOME/.local/share/omarchy-moonlight/bin"
DO_SCRIPT="$HEADLESS_BIN_DIR/sunshine-stream-do.sh" DO_SCRIPT="$HEADLESS_BIN_DIR/sunshine-stream-do.sh"
UNDO_SCRIPT="$HEADLESS_BIN_DIR/sunshine-stream-undo.sh" UNDO_SCRIPT="$HEADLESS_BIN_DIR/sunshine-stream-undo.sh"
PRESTART_SCRIPT="$HEADLESS_BIN_DIR/sunshine-prestart.sh" export DO_SCRIPT UNDO_SCRIPT
export DO_SCRIPT UNDO_SCRIPT PRESTART_SCRIPT
# Resolve which hook source files to install based on the detected compositor.
_headless_hook_sources() {
case "${COMPOSITOR:-hyprland}" in
sway)
echo "$SCRIPT_DIR/bin/sunshine-stream-do-sway.sh"
echo "$SCRIPT_DIR/bin/sunshine-stream-undo-sway.sh"
echo "$SCRIPT_DIR/bin/sunshine-prestart-sway.sh"
;;
hyprland|*)
echo "$SCRIPT_DIR/bin/sunshine-stream-do.sh"
echo "$SCRIPT_DIR/bin/sunshine-stream-undo.sh"
echo "$SCRIPT_DIR/bin/sunshine-prestart.sh"
;;
esac
}
# The X11/NVENC headless backend (capture = x11, headless Xorg on :0) isn't
# selected by --headless/--mirror — those pick the *Wayland* capture method.
# It's identified by an existing `capture = x11` in sunshine.conf, the
# hand-built path documented in FOLLOWUPS.md P3. When that backend is in play,
# the headless Xorg needs a window manager rendering on :0 or Sunshine
# captures a black screen.
capture_backend_is_x11() {
local conf="$HOME/.config/sunshine/sunshine.conf"
[[ -f "$conf" ]] || return 1
grep -qE '^[[:space:]]*capture[[:space:]]*=[[:space:]]*x11' "$conf"
}
HEADLESS_DESKTOP_UNIT="headless-desktop.service"
# Which desktop to render on the headless Xorg :0. Default is a full GNOME
# session (the familiar Ubuntu desktop). Set HEADLESS_DESKTOP=openbox for a
# lightweight bare WM instead — lower overhead, better for a truly minimal or
# low-power host, but no panel/launcher out of the box (right-click menu only).
: "${HEADLESS_DESKTOP:=gnome}"
# Install the packages the chosen desktop needs. Idempotent — pkg_install
# skips what's already present.
ensure_headless_desktop_packages() {
case "$HEADLESS_DESKTOP" in
gnome)
case "$DISTRO" in
debian) pkg_install gnome-session gnome-shell ubuntu-session ;;
arch) pkg_install gnome-shell gnome-session ;;
esac
;;
openbox)
command -v openbox-session >/dev/null 2>&1 || pkg_install openbox
# xsetroot paints a solid root so the bare desktop is visibly non-black.
if ! command -v xsetroot >/dev/null 2>&1; then
case "$DISTRO" in
debian) pkg_install x11-xserver-utils ;;
arch) pkg_install xorg-xsetroot ;;
esac
fi
;;
*)
err "Unknown HEADLESS_DESKTOP='$HEADLESS_DESKTOP' (expected: gnome | openbox)"
return 1
;;
esac
}
# Install + enable a desktop session that renders onto the headless Xorg
# display (:0), so the X11/NVENC capture path shows a real desktop instead of a
# black root window. Picks the GNOME or Openbox unit template per
# $HEADLESS_DESKTOP. Idempotent: safe to re-run. Bound to xorg-headless.service
# and to default.target (the lingering-user manager reaches it without a login).
install_headless_desktop() {
local src="$SCRIPT_DIR/files/headless-desktop-${HEADLESS_DESKTOP}.service"
if [[ ! -f "$src" ]]; then
err "Desktop unit source missing: $src (HEADLESS_DESKTOP=$HEADLESS_DESKTOP)"
return 1
fi
ensure_headless_desktop_packages || return 1
local unit_dir="$HOME/.config/systemd/user"
mkdir -p "$unit_dir"
install -m 0644 "$src" "$unit_dir/$HEADLESS_DESKTOP_UNIT"
systemctl --user daemon-reload
# enable --now starts it immediately when a user manager is live; on a fresh
# headless box with no session yet, fall back to enable-only so it comes up
# on next boot via default.target.
if systemctl --user enable --now "$HEADLESS_DESKTOP_UNIT" >/dev/null 2>&1; then
ok "Enabled $HEADLESS_DESKTOP_UNIT ($HEADLESS_DESKTOP session on :0)"
else
systemctl --user enable "$HEADLESS_DESKTOP_UNIT" >/dev/null 2>&1 || true
warn "Installed $HEADLESS_DESKTOP_UNIT but couldn't start it now — it will start on next login/boot."
fi
}
install_headless_hooks() { install_headless_hooks() {
# Install hook scripts to ~/.local/share so they don't disappear if the
# repo gets moved or deleted. Sunshine's config will reference these stable paths.
mkdir -p "$HEADLESS_BIN_DIR" mkdir -p "$HEADLESS_BIN_DIR"
mapfile -t srcs < <(_headless_hook_sources) install -m 0755 "$SCRIPT_DIR/bin/sunshine-stream-do.sh" "$DO_SCRIPT"
local do_src="${srcs[0]}" undo_src="${srcs[1]}" pre_src="${srcs[2]}" install -m 0755 "$SCRIPT_DIR/bin/sunshine-stream-undo.sh" "$UNDO_SCRIPT"
install -m 0755 "$SCRIPT_DIR/bin/sunshine-prestart.sh" "$HEADLESS_BIN_DIR/sunshine-prestart.sh"
install -m 0755 "$do_src" "$DO_SCRIPT" ok "Installed prep-cmd + prestart hooks to $HEADLESS_BIN_DIR"
install -m 0755 "$undo_src" "$UNDO_SCRIPT"
install -m 0755 "$pre_src" "$PRESTART_SCRIPT"
ok "Installed prep-cmd + prestart hooks ($COMPOSITOR) to $HEADLESS_BIN_DIR"
} }
# Install a systemd-user drop-in that pre-creates HEADLESS-1 before Sunshine # Install a systemd-user drop-in that pre-creates HEADLESS-1 before Sunshine
# starts. Without this, Sunshine reports a fatal "Unable to find display or # starts, so the encoder probe at startup sees a valid Wayland output. Without
# encoder during startup" on every restart, even though streaming works once # this, Sunshine reports a fatal "Unable to find display or encoder during
# a client connects. # startup" on every restart, even though streaming works once a client connects.
install_headless_prestart_dropin() { install_headless_prestart_dropin() {
local dropin_src="$SCRIPT_DIR/files/headless-prestart.conf" local dropin_src="$SCRIPT_DIR/files/headless-prestart.conf"
if [[ ! -f "$dropin_src" ]]; then if [[ ! -f "$dropin_src" ]]; then
@@ -133,9 +31,8 @@ install_headless_prestart_dropin() {
return 1 return 1
fi fi
# Resolve the actual unit name. Prefer sunshine.service when present (alias, # Resolve the actual unit name. Prefer sunshine.service when present (alias
# sunshine-bin, or the .deb on Ubuntu); fall back to the AUR source pkg's # or sunshine-bin); fall back to the AUR source pkg's reverse-DNS name.
# reverse-DNS name.
local unit="" local unit=""
for u in sunshine.service app-dev.lizardbyte.app.Sunshine.service; do for u in sunshine.service app-dev.lizardbyte.app.Sunshine.service; do
if systemctl --user list-unit-files "$u" >/dev/null 2>&1 \ if systemctl --user list-unit-files "$u" >/dev/null 2>&1 \

View File

@@ -1,8 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Install Sunshine, Moonlight, and GPU-specific hardware-encode dependencies. # Install Sunshine, Moonlight, and GPU-specific hardware-encode dependencies.
# Branches by $DISTRO (set by lib/distro.sh).
# --- Arch defaults --------------------------------------------------------
# Default to the precompiled AUR build for a fast install (~seconds instead of # Default to the precompiled AUR build for a fast install (~seconds instead of
# the ~10 minute source compile). Override with SUNSHINE_PKG=sunshine to build # the ~10 minute source compile). Override with SUNSHINE_PKG=sunshine to build
@@ -10,24 +7,7 @@
: "${SUNSHINE_PKG:=sunshine-bin}" : "${SUNSHINE_PKG:=sunshine-bin}"
: "${MOONLIGHT_PKG:=moonlight-qt}" : "${MOONLIGHT_PKG:=moonlight-qt}"
# --- Debian/Ubuntu defaults ----------------------------------------------
# LizardByte ships official .deb builds per Ubuntu release on GitHub.
# Resolved at runtime by _ubuntu_sunshine_deb_url to match this host.
: "${SUNSHINE_DEB_URL:=}"
: "${SUNSHINE_DEB_VERSION:=latest}"
install_sunshine() { install_sunshine() {
case "$DISTRO" in
arch) _install_sunshine_arch ;;
debian) _install_sunshine_debian ;;
*) err "install_sunshine: unsupported distro '$DISTRO'"; return 1 ;;
esac
}
# --- Arch implementation -------------------------------------------------
_install_sunshine_arch() {
# Ensure runtime deps useful for capture/diagnostics across vendors. # Ensure runtime deps useful for capture/diagnostics across vendors.
yay_install pipewire-pulse vulkan-tools libva-utils jq yay_install pipewire-pulse vulkan-tools libva-utils jq
@@ -79,71 +59,6 @@ _install_sunshine_arch() {
fi fi
} }
# --- Debian/Ubuntu implementation ----------------------------------------
# LizardByte's sunshine .deb assets are named per Ubuntu codename / version,
# e.g. sunshine-ubuntu-24.04-amd64.deb. Resolve the right one for this host.
_ubuntu_sunshine_deb_filename() {
local arch
arch="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
local v="${DISTRO_VERSION:-24.04}"
echo "sunshine-ubuntu-${v}-${arch}.deb"
}
# Resolve the download URL. If SUNSHINE_DEB_URL is set, honor it (escape hatch
# for offline mirrors / version pinning). Otherwise build a GitHub Releases
# URL — 'latest' uses the redirecting /latest/download/ alias.
_ubuntu_sunshine_deb_url() {
if [[ -n "$SUNSHINE_DEB_URL" ]]; then
echo "$SUNSHINE_DEB_URL"
return 0
fi
local file
file="$(_ubuntu_sunshine_deb_filename)"
if [[ "$SUNSHINE_DEB_VERSION" == "latest" ]]; then
echo "https://github.com/LizardByte/Sunshine/releases/latest/download/${file}"
else
echo "https://github.com/LizardByte/Sunshine/releases/download/${SUNSHINE_DEB_VERSION}/${file}"
fi
}
_install_sunshine_debian() {
# Universal runtime deps. vainfo is what Ubuntu calls libva-utils on Arch.
# pipewire-pulse is the Ubuntu 24.04+ default audio path; on older releases
# `pulseaudio-utils` works too — we don't force the codename split since
# sunshine just needs *a* PulseAudio API endpoint.
pkg_install jq vulkan-tools vainfo curl ca-certificates
if pkg_installed sunshine; then
ok "sunshine already installed (dpkg)"
return 0
fi
local url
url="$(_ubuntu_sunshine_deb_url)"
local tmpdir deb_path
tmpdir="$(mktemp -d /tmp/omarchy-sunshine.XXXXXX)"
# shellcheck disable=SC2064
trap "rm -rf '$tmpdir'" RETURN
deb_path="$tmpdir/$(_ubuntu_sunshine_deb_filename)"
info "Downloading Sunshine .deb: $url"
if ! curl -fL --retry 3 -o "$deb_path" "$url"; then
err "Failed to download $url"
err "If your Ubuntu version doesn't have a prebuilt .deb, set SUNSHINE_DEB_URL"
err "or SUNSHINE_DEB_VERSION (e.g. SUNSHINE_DEB_VERSION=v2025.118.84544 ./install.sh)."
return 1
fi
deb_install_local "$deb_path"
if ! command -v sunshine >/dev/null 2>&1; then
err "sunshine command not on PATH after install — package layout unexpected."
return 1
fi
ok "Installed sunshine from $(basename "$deb_path")"
}
# True if every shared library sunshine links against resolves on this system. # True if every shared library sunshine links against resolves on this system.
sunshine_runtime_deps_ok() { sunshine_runtime_deps_ok() {
local bin local bin
@@ -153,40 +68,17 @@ sunshine_runtime_deps_ok() {
} }
install_moonlight() { install_moonlight() {
case "$DISTRO" in
arch)
yay_install "$MOONLIGHT_PKG" yay_install "$MOONLIGHT_PKG"
;;
debian)
# moonlight-qt is published as a PPA + flatpak. On a typical Ubuntu host
# the flatpak is the lowest-friction install path; falling back to apt
# requires adding the cloudsmith PPA. For a headless server (the primary
# Ubuntu target here) the client side is almost never wanted — so this
# is best-effort.
if pkg_installed moonlight-qt; then
ok "moonlight-qt already installed"
return 0
fi
if command -v flatpak >/dev/null 2>&1; then
info "Installing moonlight-qt via flatpak"
as_root flatpak install -y flathub com.moonlight_stream.Moonlight || {
warn "flatpak install of Moonlight failed — install it manually if needed."
}
else
warn "moonlight-qt: no apt package in Ubuntu's default repos and no flatpak available."
warn " Install flatpak first, or grab the .deb from https://github.com/moonlight-stream/moonlight-qt/releases"
warn " Skipping — headless hosts rarely need the client anyway."
fi
;;
esac
} }
install_gpu_encoder_packages() { install_gpu_encoder_packages() {
case "$DISTRO:$GPU_VENDOR" in case "$GPU_VENDOR" in
arch:nvidia) nvidia)
# NVENC works through the proprietary driver. libva-nvidia-driver lets some
# apps use VAAPI on NVIDIA; not strictly required for Sunshine NVENC but useful.
yay_install nvidia-utils libva-nvidia-driver yay_install nvidia-utils libva-nvidia-driver
;; ;;
arch:amd) amd)
# VAAPI (mesa) + Vulkan for AMD hardware encode paths. # VAAPI (mesa) + Vulkan for AMD hardware encode paths.
# libva-mesa-driver is now provided by mesa (merged upstream); mesa-vdpau # libva-mesa-driver is now provided by mesa (merged upstream); mesa-vdpau
# was removed from official repos. Naming them here makes yay fall back to # was removed from official repos. Naming them here makes yay fall back to
@@ -194,68 +86,11 @@ install_gpu_encoder_packages() {
# `provides=(libva-mesa-driver mesa-vdpau)`. # `provides=(libva-mesa-driver mesa-vdpau)`.
yay_install mesa vulkan-radeon yay_install mesa vulkan-radeon
;; ;;
arch:intel) intel)
yay_install intel-media-driver vulkan-intel yay_install intel-media-driver vulkan-intel
;; ;;
debian:nvidia)
# Ubuntu's nvidia-driver-NNN(-server) metapackage pulls in the matching
# libnvidia-encode-NNN(-server) as a dependency, so NVENC is normally
# already present. Only intervene if it's missing — and derive the right
# package name from the loaded driver's major version instead of
# guessing.
if dpkg-query -W -f='${Status}\n' 'libnvidia-encode-*' 2>/dev/null \
| grep -q '^install ok installed$'; then
ok "NVENC runtime library already installed via the driver metapackage"
else
local drv_major drv_full
drv_full="$(nvidia-smi --query-gpu=driver_version --format=csv,noheader,nounits 2>/dev/null | head -n1)"
drv_major="${drv_full%%.*}"
if [[ -z "$drv_major" ]]; then
warn "Could not detect NVIDIA driver version; NVENC may be missing."
else
# Try -server first (cloud GPUs usually run server branches), then the
# consumer branch. Stop at the first one apt knows about.
local picked=""
for cand in "libnvidia-encode-${drv_major}-server" "libnvidia-encode-${drv_major}"; do
if apt-cache show "$cand" >/dev/null 2>&1; then
picked="$cand"; break
fi
done
if [[ -n "$picked" ]]; then
pkg_install "$picked"
else
warn "No libnvidia-encode-${drv_major}* package in apt — install it manually if NVENC fails."
fi
fi
fi
;;
debian:amd)
pkg_install mesa-va-drivers mesa-vulkan-drivers vainfo
;;
debian:intel)
pkg_install intel-media-va-driver-non-free mesa-vulkan-drivers
;;
*) *)
info "Unknown distro/GPU combination ($DISTRO:$GPU_VENDOR); skipping vendor-specific encoder packages." info "Unknown GPU vendor; skipping vendor-specific encoder packages."
;;
esac
}
# Install a wlroots-based compositor for headless capture on systems without
# Hyprland. Currently means: Sway on Debian/Ubuntu. On Arch the existing
# Hyprland flow is the canonical path; we only fall back to Sway if Hyprland
# isn't installed (rare on Omarchy).
install_headless_compositor() {
case "$DISTRO" in
debian)
pkg_install sway wlr-randr
;;
arch)
# Hyprland is presumed installed on Omarchy. Only act if it's missing.
if ! command -v hyprctl >/dev/null 2>&1; then
warn "hyprctl not found on Arch — falling back to Sway for headless capture."
yay_install sway wlr-randr
fi
;; ;;
esac esac
} }

View File

@@ -8,33 +8,13 @@ UINPUT_RULE_PATH="/etc/udev/rules.d/60-uinput.rules"
UINPUT_RULE_CONTENT='KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess", OWNER="root", GROUP="input", MODE="0660"' UINPUT_RULE_CONTENT='KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess", OWNER="root", GROUP="input", MODE="0660"'
ensure_input_group() { ensure_input_group() {
_ensure_user_in_group input if id -nG "$USER" | tr ' ' '\n' | grep -qx input; then
# On Debian/Ubuntu, /dev/dri/renderD* nodes are mode 0660 owned by ok "User '$USER' already in 'input' group"
# root:render, and /dev/dri/card* are root:video. Sway's wlroots renderer
# needs the render node (Vulkan/GLES FD); KMS capture needs the card node.
# Arch typically grants both via udev tag=uaccess for the logged-in seat,
# so we only add explicit memberships on Debian.
if [[ "${DISTRO:-}" == "debian" ]]; then
_ensure_user_in_group render
_ensure_user_in_group video
fi
}
# Internal: add $USER to a group if it exists and they're not already in it.
_ensure_user_in_group() {
local g="$1"
if ! getent group "$g" >/dev/null 2>&1; then
info "Group '$g' does not exist on this system — skipping."
return 0 return 0
fi fi
if id -nG "$USER" | tr ' ' '\n' | grep -qx "$g"; then info "Adding '$USER' to 'input' group"
ok "User '$USER' already in '$g' group" as_root usermod -aG input "$USER"
return 0 warn "You must log out and back in (or run 'newgrp input') for this to take effect."
fi
info "Adding '$USER' to '$g' group"
as_root usermod -aG "$g" "$USER"
warn "Group '$g' change takes effect on next login (or 'newgrp $g'). For systemd-user"
warn "services, you must fully log out and back in so the user manager restarts."
} }
ensure_uinput_udev_rule() { ensure_uinput_udev_rule() {

View File

@@ -35,22 +35,7 @@ preflight_gpu() {
case "$GPU_VENDOR" in case "$GPU_VENDOR" in
nvidia) nvidia)
if ! command -v nvidia-smi >/dev/null 2>&1; then if ! command -v nvidia-smi >/dev/null 2>&1; then
case "$DISTRO" in
arch)
warn "nvidia-smi not found yet — nvidia-utils will be installed shortly." warn "nvidia-smi not found yet — nvidia-utils will be installed shortly."
;;
debian)
# On Ubuntu the NVIDIA driver install isn't our job; we don't pull
# in nvidia-driver-* because the right version depends on the
# kernel / Secure Boot / cloud-vendor combo. Tell the user.
err "nvidia-smi not found and no NVIDIA kernel module loaded."
err "Install the driver before re-running this installer. Common paths on Ubuntu:"
err " sudo ubuntu-drivers install # picks the recommended branch"
err " sudo apt install nvidia-driver-550-server # explicit pin"
err "Then reboot (or modprobe nvidia) so 'nvidia-smi -L' returns the GPU."
exit 1
;;
esac
return 0 return 0
fi fi
if ! nvidia-smi -L >/dev/null 2>&1; then if ! nvidia-smi -L >/dev/null 2>&1; then
@@ -68,7 +53,7 @@ preflight_gpu() {
fi fi
;; ;;
intel) intel)
ok "Intel GPU — encoder packages will be installed in the packages step." ok "Intel GPU — will install intel-media-driver"
;; ;;
esac esac
} }
@@ -115,13 +100,22 @@ preflight_audio() {
preflight_headless() { preflight_headless() {
# Only relevant in headless mode. Checks are non-fatal: install can proceed # Only relevant in headless mode. Checks are non-fatal: install can proceed
# even if the compositor isn't reachable right now (hooks just won't # even if Hyprland isn't reachable right now (hooks just won't function until
# function until it is). # the user logs into Hyprland on the host).
[[ "${STREAM_MODE:-}" == "headless" ]] || return 0 [[ "${STREAM_MODE:-}" == "headless" ]] || return 0
case "${COMPOSITOR:-none}" in if command -v hyprctl >/dev/null 2>&1; then
hyprland)
ok "hyprctl on PATH" ok "hyprctl on PATH"
else
warn "hyprctl not found. Headless prep-cmd hooks will fail until Hyprland is installed and reachable."
fi
if pkg_installed jq; then
ok "jq installed (prep-cmd hooks have their parser)"
else
info "jq not installed yet — will be installed in the packages step."
fi
if [[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then if [[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
ok "Hyprland instance signature present in environment" ok "Hyprland instance signature present in environment"
else else
@@ -129,27 +123,7 @@ preflight_headless() {
if compgen -G "$rt/hypr/*/" >/dev/null 2>&1; then if compgen -G "$rt/hypr/*/" >/dev/null 2>&1; then
ok "Hyprland runtime directory found under $rt/hypr/" ok "Hyprland runtime directory found under $rt/hypr/"
else else
warn "Hyprland not currently running. Install will proceed; hooks engage on next Hyprland login." warn "Hyprland does not appear to be running. Install will proceed; hooks will only work once you log into Hyprland on the host."
fi fi
fi fi
;;
sway)
ok "swaymsg/sway on PATH"
local rt="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
if compgen -G "$rt/sway-ipc.*.sock" >/dev/null 2>&1; then
ok "Sway IPC socket present under $rt"
else
info "Sway not running yet — install will start sway-headless.service before sunshine."
fi
;;
none|*)
warn "No wlroots compositor detected. Install will attempt to install one (Sway on Debian/Ubuntu)."
;;
esac
if pkg_installed jq; then
ok "jq installed (prep-cmd hooks have their parser)"
else
info "jq not installed yet — will be installed in the packages step."
fi
} }

View File

@@ -1,61 +1,34 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Enable Sunshine as a systemd --user service and turn on lingering so it # Enable Sunshine as a systemd --user service and turn on lingering so it
# runs at boot without a graphical login. On Ubuntu installs that use the # runs at boot without a graphical login.
# Sway-headless capture path, also installs + enables sway-headless.service
# and wires sunshine.service to depend on it.
# Resolves the actual unit file to operate on. Exports:
# SUNSHINE_ENABLE_NAME - the name to pass to `systemctl --user enable`. May
# be sunshine.service or app-dev.lizardbyte....service
# depending on how the package shipped the unit.
# SUNSHINE_SERVICE - the short name we use everywhere else (start, restart,
# status, drop-ins). Always sunshine.service — systemd
# resolves it via Alias= or our installed unit.
ensure_sunshine_unit_present() { ensure_sunshine_unit_present() {
SUNSHINE_SERVICE="sunshine.service" # Case 1: a sunshine.service unit already exists in any path systemd-user
# scans. sunshine-bin ships /usr/lib/systemd/user/sunshine.service directly.
# Clean up a broken symlink from older runs that pointed sunshine.service
# at the FQDN unit. The upstream .deb already declares Alias=sunshine.service
# in [Install], so the symlink we used to create conflicts with systemd's
# enable path ("Refusing to operate on alias name").
local user_unit="$HOME/.config/systemd/user/sunshine.service"
if [[ -L "$user_unit" ]] && readlink "$user_unit" 2>/dev/null \
| grep -q 'app-dev.lizardbyte.app.Sunshine.service$'; then
info "Removing stale alias symlink at $user_unit (upstream unit declares Alias=)"
rm -f "$user_unit"
fi
# Case 1: a real sunshine.service unit ships in a system path (sunshine-bin
# on Arch drops it at /usr/lib/systemd/user/).
for p in \ for p in \
/lib/systemd/user/sunshine.service \
/usr/lib/systemd/user/sunshine.service \ /usr/lib/systemd/user/sunshine.service \
/etc/systemd/user/sunshine.service \ /etc/systemd/user/sunshine.service \
"$HOME/.config/systemd/user/sunshine.service" \
"$HOME/.local/share/systemd/user/sunshine.service" "$HOME/.local/share/systemd/user/sunshine.service"
do do
if [[ -f "$p" && ! -L "$p" ]]; then [[ -e "$p" ]] && return 0
SUNSHINE_ENABLE_NAME="sunshine.service"
export SUNSHINE_ENABLE_NAME SUNSHINE_SERVICE
return 0
fi
done done
# Case 2: the LizardByte .deb (Ubuntu) and the AUR source package ship the # Case 2: the AUR source 'sunshine' package ships the unit under a
# unit under a reverse-DNS FQDN with Alias=sunshine.service in [Install]. # Flatpak-style reverse-DNS name. Symlink it as sunshine.service so the rest
# Do NOT symlink — `systemctl --user enable` on the FQDN name creates the # of our tooling can keep using the short name.
# alias symlink itself in ~/.config/systemd/user/.
local fqdn_unit="" local fqdn_unit=""
for p in \ for p in \
/usr/lib/systemd/user/app-dev.lizardbyte.app.Sunshine.service \ /usr/lib/systemd/user/app-dev.lizardbyte.app.Sunshine.service \
/lib/systemd/user/app-dev.lizardbyte.app.Sunshine.service \
/etc/systemd/user/app-dev.lizardbyte.app.Sunshine.service /etc/systemd/user/app-dev.lizardbyte.app.Sunshine.service
do do
[[ -f "$p" ]] && { fqdn_unit="$p"; break; } [[ -f "$p" ]] && { fqdn_unit="$p"; break; }
done done
if [[ -n "$fqdn_unit" ]]; then if [[ -n "$fqdn_unit" ]]; then
info "Found packaged unit at $fqdn_unit (enables via alias)" info "Found packaged unit at $fqdn_unit"
SUNSHINE_ENABLE_NAME="app-dev.lizardbyte.app.Sunshine.service" info "Aliasing it as sunshine.service in $HOME/.config/systemd/user/"
export SUNSHINE_ENABLE_NAME SUNSHINE_SERVICE mkdir -p "$HOME/.config/systemd/user"
ln -sf "$fqdn_unit" "$HOME/.config/systemd/user/sunshine.service"
return 0 return 0
fi fi
@@ -69,68 +42,15 @@ ensure_sunshine_unit_present() {
mkdir -p "$HOME/.config/systemd/user" mkdir -p "$HOME/.config/systemd/user"
install -m 0644 "$fallback" "$HOME/.config/systemd/user/sunshine.service" install -m 0644 "$fallback" "$HOME/.config/systemd/user/sunshine.service"
ok "Installed $HOME/.config/systemd/user/sunshine.service" ok "Installed $HOME/.config/systemd/user/sunshine.service"
SUNSHINE_ENABLE_NAME="sunshine.service"
export SUNSHINE_ENABLE_NAME SUNSHINE_SERVICE
}
# Install and enable the headless sway compositor unit + config (Debian/Ubuntu
# headless path only). sunshine.service gets a drop-in making it depend on
# sway-headless.service so the wlr capture has something to talk to.
ensure_sway_headless_unit() {
[[ "$DISTRO" == "debian" ]] || return 0
[[ "${STREAM_MODE:-}" == "headless" ]] || return 0
[[ "${COMPOSITOR:-}" == "sway" ]] || return 0
local cfg_src="$SCRIPT_DIR/files/sway-headless.config"
local svc_src="$SCRIPT_DIR/files/sway-headless.service"
if [[ ! -f "$cfg_src" || ! -f "$svc_src" ]]; then
err "Missing sway-headless source files in $SCRIPT_DIR/files/"
return 1
fi
mkdir -p "$HOME/.config/sway" "$HOME/.config/systemd/user"
install -m 0644 "$cfg_src" "$HOME/.config/sway/config-headless"
install -m 0644 "$svc_src" "$HOME/.config/systemd/user/sway-headless.service"
# Wire sunshine.service to wait for sway-headless.service. Done via a
# drop-in so we don't overwrite the upstream unit shipped by the .deb.
local sun_dropin_dir="$HOME/.config/systemd/user/sunshine.service.d"
mkdir -p "$sun_dropin_dir"
cat >"$sun_dropin_dir/sway-headless.conf" <<'EOF'
# Installed by omarchy-moonlight. Sunshine's wlr capture needs a running
# wlroots compositor; sway-headless provides one on headless servers.
[Unit]
After=sway-headless.service
Requires=sway-headless.service
[Service]
# Inherit the sway IPC socket location so hooks can talk to swaymsg.
Environment=XDG_SESSION_TYPE=wayland
Environment=WAYLAND_DISPLAY=wayland-1
EOF
systemctl --user daemon-reload
systemctl --user enable sway-headless.service >/dev/null
if ! systemctl --user is-active --quiet sway-headless.service; then
info "Starting sway-headless.service"
systemctl --user restart sway-headless.service || {
err "sway-headless.service failed to start. Inspect: journalctl --user -u sway-headless"
return 1
}
# Give sway a beat to create its IPC socket.
sleep 1
fi
ok "sway-headless.service active"
} }
enable_sunshine_service() { enable_sunshine_service() {
# The AUR 'sunshine' (source) package doesn't always ship a systemd user unit
# at the standard /usr/lib/systemd/user/sunshine.service path. If systemd
# can't find one, drop our own copy into ~/.config/systemd/user/.
ensure_sunshine_unit_present ensure_sunshine_unit_present
systemctl --user daemon-reload systemctl --user daemon-reload
# If we're on the Debian+Sway headless path, install the sway-headless unit
# before sunshine so the dependency chain is satisfied when we start it.
ensure_sway_headless_unit
# In headless mode, install a drop-in that pre-creates HEADLESS-1 before # In headless mode, install a drop-in that pre-creates HEADLESS-1 before
# Sunshine starts. Done here because the drop-in target name depends on # Sunshine starts. Done here because the drop-in target name depends on
# which unit ensure_sunshine_unit_present resolved. # which unit ensure_sunshine_unit_present resolved.
@@ -138,8 +58,8 @@ enable_sunshine_service() {
install_headless_prestart_dropin install_headless_prestart_dropin
fi fi
if ! systemctl --user list-unit-files "$SUNSHINE_ENABLE_NAME" >/dev/null 2>&1; then if ! systemctl --user list-unit-files sunshine.service >/dev/null 2>&1; then
err "$SUNSHINE_ENABLE_NAME not found after fallback. Inspect: find /usr /lib ~/.config -name '*[Ss]unshine*'" err "sunshine.service still not found after fallback. Inspect: find /usr ~/.config -name sunshine.service"
return 1 return 1
fi fi
@@ -150,14 +70,15 @@ enable_sunshine_service() {
ok "User lingering already enabled" ok "User lingering already enabled"
fi fi
info "Enabling ${SUNSHINE_ENABLE_NAME} (user)" info "Enabling sunshine.service (user)"
systemctl --user enable "$SUNSHINE_ENABLE_NAME" >/dev/null systemctl --user enable sunshine.service >/dev/null
# Clear any prior start-limit state from a failed run so this attempt isn't # Clear any prior start-limit state from a failed run so this attempt isn't
# immediately rejected with "Start request repeated too quickly." # immediately rejected with "Start request repeated too quickly."
systemctl --user reset-failed sunshine.service 2>/dev/null || true systemctl --user reset-failed sunshine.service 2>/dev/null || true
info "Starting sunshine.service (user)" info "Starting sunshine.service (user)"
# Restart so a re-run picks up new config / new caps. Tolerate first-launch races.
systemctl --user restart sunshine.service || systemctl --user start sunshine.service || { systemctl --user restart sunshine.service || systemctl --user start sunshine.service || {
err "Failed to start sunshine.service. Check: journalctl --user -u sunshine" err "Failed to start sunshine.service. Check: journalctl --user -u sunshine"
return 1 return 1

View File

@@ -119,10 +119,8 @@ verify_install() {
info "Sunshine cert not present yet (will be generated on first start)" info "Sunshine cert not present yet (will be generated on first start)"
fi fi
local _anchor if [[ -f /etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem ]]; then
_anchor="$(ca_anchor_path 2>/dev/null || true)" ok "omarchy-stream CA installed in system trust store"
if [[ -n "$_anchor" && -f "$_anchor" ]]; then
ok "omarchy-stream CA installed in system trust store ($_anchor)"
else else
info "omarchy-stream CA not in system trust store (only matters if --no-certs was used)" info "omarchy-stream CA not in system trust store (only matters if --no-certs was used)"
fi fi

354
status.sh
View File

@@ -1,354 +0,0 @@
#!/usr/bin/env bash
# status.sh — runtime health check for an omarchy-moonlight Sunshine host.
#
# Walks the live system (service, display backend, ports, encoder, input,
# pairing) and prints PASS / WARN / FAIL for each, then a verdict: either
# "good to go" or a concrete TODO list of what needs work.
#
# Runtime-focused on purpose — complements `install.sh --doctor` (which checks
# install-time correctness). Safe to run repeatedly; reads only, changes nothing.
#
# Usage:
# ./status.sh # check the invoking user's Sunshine
# sudo ./status.sh # root: auto-detects the Sunshine user
# SUNSHINE_USER=alice ./status.sh
#
# Exit code: 0 if no FAILs, 1 if any FAIL.
set -uo pipefail
# ---- presentation -----------------------------------------------------------
if [[ -t 1 ]]; then
R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; B=$'\e[1m'; D=$'\e[2m'; N=$'\e[0m'
else
R=''; G=''; Y=''; B=''; D=''; N=''
fi
oks=0; warns=0; fails=0
declare -a TODO=()
pass(){ printf " ${G}${N} %s\n" "$1"; oks=$((oks+1)); }
warn(){ printf " ${Y}!${N} %s\n" "$1"; warns=$((warns+1)); [[ -n ${2:-} ]] && TODO+=("${Y}warn${N} $2"); }
fail(){ printf " ${R}${N} %s\n" "$1"; fails=$((fails+1)); [[ -n ${2:-} ]] && TODO+=("${R}FAIL${N} $2"); }
note(){ printf " ${D}·${N} %s\n" "$1"; }
section(){ printf "\n${B}▸ %s${N}\n" "$1"; }
# ---- resolve the Sunshine user / runtime context ----------------------------
if [[ -n ${SUNSHINE_USER:-} ]]; then
SUSER=$SUNSHINE_USER
elif [[ $EUID -ne 0 ]]; then
SUSER=$(id -un)
else
SUSER=$(ls -1 /var/lib/systemd/linger/ 2>/dev/null | head -1)
if [[ -z $SUSER ]]; then
for d in /home/*; do
[[ -e "$d/.config/sunshine/sunshine.conf" ]] && { SUSER=$(basename "$d"); break; }
done
fi
fi
if [[ -z ${SUSER:-} ]] || ! id "$SUSER" >/dev/null 2>&1; then
echo "Could not determine the Sunshine user. Set SUNSHINE_USER=<name> and re-run." >&2
exit 1
fi
UID_N=$(id -u "$SUSER")
RUNTIME="/run/user/$UID_N"
HOME_DIR=$(getent passwd "$SUSER" | cut -d: -f6)
CONF_DIR="$HOME_DIR/.config/sunshine"
CONF="$CONF_DIR/sunshine.conf"
LOG="$CONF_DIR/sunshine.log"
SYSD_USER="$HOME_DIR/.config/systemd/user"
# systemctl --user, transparently as the Sunshine user when run as root
uctl(){
if [[ $EUID -eq 0 && "$SUSER" != "$(id -un)" ]]; then
sudo -u "$SUSER" XDG_RUNTIME_DIR="$RUNTIME" systemctl --user "$@"
else
XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$RUNTIME}" systemctl --user "$@"
fi
}
# read a bare value from sunshine.conf (last wins; '=' separated; trimmed)
conf_val(){ grep -E "^[[:space:]]*$1[[:space:]]*=" "$CONF" 2>/dev/null | tail -1 | cut -d= -f2- | xargs; }
printf "${B}omarchy-moonlight status${N} — user=${SUSER} host=$(hostname)\n"
# ---- 1. binary --------------------------------------------------------------
section "Sunshine binary"
if command -v sunshine >/dev/null 2>&1; then
ver=$(sunshine --version 2>/dev/null | grep -i 'version' | head -1 | sed 's/.*version:/version/I' | xargs)
pass "sunshine present ($(command -v sunshine))${ver:+ — $ver}"
else
fail "sunshine binary not found" "Install Sunshine (./install.sh, or apt/yay per distro)."
fi
if command -v flatpak >/dev/null 2>&1 && flatpak list 2>/dev/null | grep -qi 'lizardbyte.app.Sunshine' \
&& command -v sunshine >/dev/null 2>&1; then
warn "both a native sunshine AND the Sunshine flatpak are installed (redundant, confusing app-id)" \
"Remove whichever you don't use: 'flatpak uninstall dev.lizardbyte.app.Sunshine'."
fi
# ---- 2. service unit + state ------------------------------------------------
section "Service"
# Can we even reach the user's systemd manager? Every uctl call below depends on
# it. If the bus is unreachable, a healthy install looks identical to a missing
# one (`cat`/`is-active`/`is-enabled` all just fail) — the classic false alarm
# from running status.sh in the first seconds after reboot (before /run/user/$UID
# exists) or as the wrong user / without a session bus. Distinguish the two.
if ! uctl list-units --type=service >/dev/null 2>&1; then
fail "can't reach ${SUSER}'s user systemd manager (bus unavailable) — unit checks below are blind, NOT proof the service is missing" \
"Run as $SUSER in a normal session (or 'sudo ./status.sh' once boot finishes). Right after reboot, /run/user/$UID_N may not exist yet — retry in a few seconds. Verify Sunshine independently: 'ss -tulnp | grep 47990'."
UNIT=""
else
UNIT=""
for u in sunshine.service app-dev.lizardbyte.app.Sunshine.service; do
if uctl cat "$u" >/dev/null 2>&1; then UNIT=$u; break; fi
done
if [[ -z $UNIT ]]; then
fail "no sunshine user unit found (sunshine.service / app-dev.lizardbyte.app.Sunshine.service)" \
"Install/enable a unit — see lib/service.sh or files/sunshine.service."
else
# Resolve the canonical unit name. 'sunshine.service' is often an alias of
# app-dev.lizardbyte.app.Sunshine.service; the .wants/ symlinks use whichever
# name is canonical, so check both.
CANON=$(uctl show "$UNIT" -p Id --value 2>/dev/null); [[ -z $CANON ]] && CANON=$UNIT
if [[ $CANON != "$UNIT" ]]; then note "unit: $UNIT$CANON"; else note "unit: $UNIT"; fi
if uctl is-active --quiet "$UNIT"; then
pass "service is active (running)"
else
state=$(uctl is-active "$UNIT" 2>/dev/null)
fail "service is $state, not running" \
"Start it: systemctl --user start $UNIT ; inspect: journalctl --user -u $UNIT -n 50"
fi
# boot wiring — the classic headless trap (TROUBLESHOOTING §12)
enabled=$(uctl is-enabled "$UNIT" 2>/dev/null)
gs=$(uctl is-active graphical-session.target 2>/dev/null)
want_default=no; want_graphical=no
for nm in "$UNIT" "$CANON"; do
[[ -e "$SYSD_USER/default.target.wants/$nm" ]] && want_default=yes
[[ -e "$SYSD_USER/graphical-session.target.wants/$nm" ]] && want_graphical=yes
# system-level packaged wants count too
[[ -e "/usr/lib/systemd/user/default.target.wants/$nm" ]] && want_default=yes
done
if [[ $enabled != enabled && $enabled != alias && $enabled != static ]]; then
fail "service not enabled (enabled=$enabled) — won't start on boot" \
"systemctl --user enable $UNIT"
elif [[ $want_default == yes ]]; then
pass "wired into default.target — auto-starts on a headless/lingering host"
elif [[ $want_graphical == yes && $gs == active ]]; then
pass "wired into graphical-session.target (active) — desktop session keeps it up"
elif [[ $want_graphical == yes && $gs != active ]]; then
fail "only wired into graphical-session.target, which is INACTIVE on this headless host — service won't auto-start on boot" \
"Add a drop-in with [Install] WantedBy=default.target, then 'systemctl --user reenable $UNIT'. See TROUBLESHOOTING.md §12."
else
warn "enabled but no target.wants symlink found — boot behavior unclear" \
"Verify: ls $SYSD_USER/*.target.wants/ | grep -i sunshine"
fi
# lingering
linger=$(loginctl show-user "$SUSER" -p Linger --value 2>/dev/null)
if [[ $linger == yes ]]; then
pass "user lingering enabled (survives logout)"
else
warn "user lingering is off — user services stop at logout / won't run before login" \
"sudo loginctl enable-linger $SUSER"
fi
# misplaced drop-in keys (Requires/After in [Service]) — systemd warns about these
if uctl status "$UNIT" 2>&1 | grep -q 'Unknown key name'; then
warn "a drop-in has keys in the wrong section (systemd is ignoring them)" \
"Run 'systemctl --user status $UNIT' — move Requires=/After= into [Unit]. See TROUBLESHOOTING.md §12."
fi
fi
fi # end user-manager-reachable guard
# ---- 3. config + capture backend --------------------------------------------
section "Config & capture backend"
if [[ -s $CONF ]]; then
pass "sunshine.conf present and non-empty"
elif [[ -f $CONF ]]; then
fail "sunshine.conf exists but is EMPTY — Sunshine will run with defaults (no encoder/capture tuning)" \
"Regenerate it (./install.sh) or restore your hand-edited config."
else
fail "sunshine.conf missing ($CONF)" "Run ./install.sh to generate it."
fi
CAP=$(conf_val capture); ENC=$(conf_val encoder); OUT=$(conf_val output_name)
note "capture=${CAP:-<unset>} encoder=${ENC:-<auto>} output_name=${OUT:-<unset>}"
# ---- 4. display backend (depends on capture) --------------------------------
section "Display backend"
case "${CAP:-}" in
x11)
if uctl is-active --quiet xorg-headless.service 2>/dev/null; then
pass "xorg-headless.service active"
elif pgrep -af 'Xorg.*:0' >/dev/null 2>&1; then
warn "an Xorg :0 is running but not via xorg-headless.service" \
"Fine if intentional; otherwise enable xorg-headless.service so it starts on boot."
else
fail "capture=x11 but no Xorg :0 / xorg-headless.service running — nothing to capture" \
"Start the headless X server (systemctl --user start xorg-headless.service). See TROUBLESHOOTING.md §13."
fi
# is DISPLAY :0 actually answering?
if command -v xset >/dev/null 2>&1; then
if (if [[ $EUID -eq 0 && "$SUSER" != "$(id -un)" ]]; then sudo -u "$SUSER" DISPLAY=:0 xset -q; else DISPLAY=:0 xset -q; fi) >/dev/null 2>&1; then
pass "X display :0 reachable"
else
fail "DISPLAY=:0 not reachable (X server not answering)" "Check xorg-headless.service logs."
fi
fi
# Is a window manager actually rendering on :0? X can be up and reachable
# yet have no WM/desktop drawing anything — Sunshine then captures an empty
# black root window (pairing/NVENC/input all work; client sees only black).
if command -v xprop >/dev/null 2>&1; then
_xp(){ if [[ $EUID -eq 0 && "$SUSER" != "$(id -un)" ]]; then sudo -u "$SUSER" DISPLAY=:0 xprop "$@"; else DISPLAY=:0 xprop "$@"; fi; }
wm_win=$(_xp -root _NET_SUPPORTING_WM_CHECK 2>/dev/null | grep -o '0x[0-9a-f]*' | head -1)
if [[ -n $wm_win ]]; then
# Read the WM's advertised name (e.g. "GNOME Shell", "Openbox") so the
# report says which desktop is actually rendering, not just that one is.
wm_name=$(_xp -id "$wm_win" _NET_WM_NAME 2>/dev/null | sed -n 's/.*= "\(.*\)"/\1/p')
pass "a desktop is running on :0${wm_name:+ (${wm_name})} — something to capture"
else
fail "no window manager on :0 — capture will be a black screen" \
"Start a desktop on the headless Xorg: 'systemctl --user enable --now headless-desktop.service' (install.sh installs it for the x11 backend; HEADLESS_DESKTOP=gnome|openbox). See TROUBLESHOOTING.md §13."
fi
else
note "xprop not installed — skipping desktop-on-:0 check (install x11-utils to enable it)"
fi
# wlr env leaking into an x11 unit (stale sway drop-in — TROUBLESHOOTING §13)
if [[ -n $UNIT ]]; then
env_dump=$(uctl show "$UNIT" -p Environment 2>/dev/null)
if grep -qiE 'WAYLAND_DISPLAY|XDG_SESSION_TYPE=wayland' <<<"$env_dump"; then
warn "Wayland env is leaking into the x11 unit (likely a stale sway-headless.conf drop-in)" \
"Remove the leftover wlr drop-in; confirm with 'systemctl --user show $UNIT -p Environment'. See TROUBLESHOOTING.md §13."
fi
fi
;;
wlr)
if pgrep -x Hyprland >/dev/null 2>&1 || pgrep -x sway >/dev/null 2>&1 \
|| uctl is-active --quiet sway-headless.service 2>/dev/null; then
pass "a wlroots compositor (Hyprland/sway) is running"
else
fail "capture=wlr but no Hyprland/sway compositor running — encoder probe will fail" \
"Start the compositor (or sway-headless.service on a server). See TROUBLESHOOTING.md §4."
fi
if command -v hyprctl >/dev/null 2>&1; then
mons=$( (if [[ $EUID -eq 0 && "$SUSER" != "$(id -un)" ]]; then sudo -u "$SUSER" XDG_RUNTIME_DIR="$RUNTIME" hyprctl monitors all -j; else hyprctl monitors all -j; fi) 2>/dev/null)
if grep -q 'HEADLESS' <<<"$mons"; then
pass "a HEADLESS output exists"
else
warn "no HEADLESS output present yet (created per-connection by the prep-cmd hook)" \
"Normal between streams; sunshine-prestart.sh creates one before the encoder probe."
fi
fi
;;
kms)
if command -v ls >/dev/null && ls /sys/class/drm/card*/card*-*/status >/dev/null 2>&1 \
&& grep -ql '^connected' /sys/class/drm/card*/card*-*/status 2>/dev/null; then
pass "a connected DRM output is present (KMS capture has something to grab)"
else
warn "capture=kms but no connected display detected — needs a real monitor or dummy plug" \
"Attach a display/dummy plug, or switch to a headless backend (--headless)."
fi
;;
"")
warn "no capture method set in sunshine.conf — Sunshine will auto-detect" \
"Pin one explicitly (capture=x11|wlr|kms) for predictable headless behavior."
;;
*)
note "capture=$CAP (unrecognized by this checker — skipping backend-specific checks)"
;;
esac
# ---- 5. encoder -------------------------------------------------------------
section "Encoder"
case "${ENC:-}" in
*nvenc*|"")
if command -v nvidia-smi >/dev/null 2>&1; then
if nvidia-smi -L >/dev/null 2>&1; then pass "NVIDIA GPU reachable ($(nvidia-smi -L | head -1 | sed 's/(UUID.*//'))"
else fail "nvidia-smi present but no GPU responding" "Check the NVIDIA driver / 'nvidia-smi'."; fi
elif [[ "${ENC:-}" == *nvenc* ]]; then
warn "encoder=nvenc but nvidia-smi not found" "Install nvidia-utils, or switch encoder to vaapi/software."
fi
;;
esac
if [[ -f $LOG ]]; then
recent=$(tail -n 4000 "$LOG" 2>/dev/null)
if grep -q 'Unable to find display or encoder' <<<"$recent"; then
fail "log shows 'Unable to find display or encoder during startup'" \
"Display backend wasn't ready at probe time — see Display backend section above & TROUBLESHOOTING.md §4."
elif grep -qE 'Found (H.264|HEVC|AV1) encoder' <<<"$recent"; then
enc_found=$(grep -oE 'Found (H.264|HEVC|AV1) encoder: [a-z0-9_]+' <<<"$recent" | tail -3 | sed 's/Found //' | paste -sd', ')
pass "encoders detected in recent log: ${enc_found:-yes}"
else
note "no recent encoder-probe lines in log (service may not have probed since last start)"
fi
fi
# ---- 6. network: ports + web UI ---------------------------------------------
section "Network"
if command -v ss >/dev/null 2>&1; then
for p in 47984 47989 47990; do
if ss -tln 2>/dev/null | grep -q ":$p "; then pass "TCP $p listening"
else fail "TCP $p NOT listening" "Service likely down or failed to bind — check the Service section."; fi
done
else
note "ss not available — skipping port checks"
fi
code=$(curl -sk -o /dev/null -m 5 -w '%{http_code}' https://localhost:47990 2>/dev/null)
case "$code" in
401|200) pass "web UI responding on :47990 (HTTP $code)";;
000|"") fail "web UI not responding on :47990" "Service down, or not bound. Check Service section.";;
*) warn "web UI returned HTTP $code on :47990" "Unexpected — inspect manually.";;
esac
# ---- 7. input injection (/dev/uinput) ---------------------------------------
section "Input (/dev/uinput)"
if [[ -e /dev/uinput ]]; then
if id -nG "$SUSER" 2>/dev/null | grep -qw input; then
pass "$SUSER is in the 'input' group"
else
fail "$SUSER is NOT in the 'input' group — keyboard/mouse injection will fail" \
"sudo usermod -aG input $SUSER (then re-login)"
fi
perms=$(stat -c '%U:%G %a' /dev/uinput 2>/dev/null)
mode=$(stat -c '%a' /dev/uinput 2>/dev/null)
grp_digit=${mode: -2:1} # group permission digit; write bit set in 2,3,6,7
if [[ $grp_digit =~ [2367] ]]; then
pass "/dev/uinput group-writable ($perms)"
else
warn "/dev/uinput not group-writable ($perms) — udev rule may not have applied" \
"Ensure 60-sunshine.rules is installed and 'udevadm control --reload && udevadm trigger'."
fi
else
fail "/dev/uinput does not exist — no virtual input devices" "Load the uinput module: 'sudo modprobe uinput'."
fi
# ---- 8. certs + pairing -----------------------------------------------------
section "Certificates & pairing"
if [[ -f "$CONF_DIR/credentials/cacert.pem" ]]; then
pass "host cert present (credentials/cacert.pem)"
else
warn "no host cert in credentials/ — clients see Sunshine's self-signed default" \
"Run the cert step (./install.sh with op signed in) if you use the shared CA."
fi
STATE="$CONF_DIR/sunshine_state.json"
if [[ -f $STATE ]]; then
if command -v jq >/dev/null 2>&1; then
n=$(jq -r '[.. | objects | select(has("uniqueid") or has("uuid")) | (.name // empty)] | length' "$STATE" 2>/dev/null)
fi
[[ -z ${n:-} || $n == 0 ]] && n=$(grep -oc '"uniqueid"' "$STATE" 2>/dev/null)
if [[ -n ${n:-} && $n -gt 0 ]]; then note "$n paired client(s) on record"
else note "no paired clients yet (pair from Moonlight, then enter the PIN)"; fi
fi
# ---- verdict ----------------------------------------------------------------
printf "\n${B}── verdict ──${N}\n"
printf " ${G}%d passed${N} ${Y}%d warnings${N} ${R}%d failures${N}\n" "$oks" "$warns" "$fails"
if (( fails == 0 && warns == 0 )); then
printf "\n ${G}${B}g2g${N} — everything checks out. Stream away.\n"
elif (( fails == 0 )); then
printf "\n ${G}${B}good to go${N} (with %d non-blocking warning(s)):\n\n" "$warns"
for t in "${TODO[@]}"; do printf " • %s\n" "$t"; done
else
printf "\n ${R}${B}NOT ready${N} — fix these:\n\n"
for t in "${TODO[@]}"; do printf " • %s\n" "$t"; done
fi
echo
exit $(( fails > 0 ? 1 : 0 ))

View File

@@ -6,10 +6,6 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh # shellcheck source=lib/common.sh
source "$SCRIPT_DIR/lib/common.sh" source "$SCRIPT_DIR/lib/common.sh"
# shellcheck source=lib/distro.sh
source "$SCRIPT_DIR/lib/distro.sh"
detect_distro
PURGE=0 PURGE=0
KEEP_MOONLIGHT=0 KEEP_MOONLIGHT=0
@@ -25,7 +21,7 @@ Usage: $(basename "$0") [--purge] [--keep-moonlight] [--remove-ca-trust]
--purge Also delete ~/.config/sunshine and ~/.local/share/sunshine --purge Also delete ~/.config/sunshine and ~/.local/share/sunshine
--keep-moonlight Do not uninstall moonlight-qt --keep-moonlight Do not uninstall moonlight-qt
--remove-ca-trust Remove the omarchy-stream CA from the system trust store --remove-ca-trust Remove the omarchy-stream CA from /etc/ca-certificates
(default: leave it — other hosts/services may rely on it) (default: leave it — other hosts/services may rely on it)
EOF EOF
exit 0 ;; exit 0 ;;
@@ -36,9 +32,8 @@ done
require_not_root require_not_root
step "Stopping services" step "Stopping Sunshine service"
systemctl --user disable --now sunshine.service 2>/dev/null || true systemctl --user disable --now sunshine.service 2>/dev/null || true
systemctl --user disable --now sway-headless.service 2>/dev/null || true
step "Removing user lingering (if enabled by us)" step "Removing user lingering (if enabled by us)"
if loginctl show-user "$USER" -p Linger --value 2>/dev/null | grep -qx yes; then if loginctl show-user "$USER" -p Linger --value 2>/dev/null | grep -qx yes; then
@@ -47,37 +42,19 @@ if loginctl show-user "$USER" -p Linger --value 2>/dev/null | grep -qx yes; then
fi fi
step "Removing packages" step "Removing packages"
case "$DISTRO" in
arch)
# Remove -debug siblings first so they don't collide with re-installation later. # Remove -debug siblings first so they don't collide with re-installation later.
pkg_remove sunshine-debug sunshine-bin-debug sunshine sunshine-bin for pkg in sunshine-debug sunshine-bin-debug sunshine sunshine-bin; do
if [[ $KEEP_MOONLIGHT -eq 0 ]]; then if pacman -Qi "$pkg" >/dev/null 2>&1; then
pkg_remove moonlight-qt moonlight-qt-bin as_root pacman -Rns --noconfirm "$pkg"
fi fi
;; done
debian)
pkg_remove sunshine
if [[ $KEEP_MOONLIGHT -eq 0 ]]; then if [[ $KEEP_MOONLIGHT -eq 0 ]]; then
pkg_remove moonlight-qt for pkg in moonlight-qt moonlight-qt-bin; do
if pacman -Qi "$pkg" >/dev/null 2>&1; then
as_root pacman -Rns --noconfirm "$pkg"
fi
done
fi fi
;;
*)
warn "Unknown distro; skipping package removal."
;;
esac
step "Removing user-installed systemd units + drop-ins"
rm -f \
"$HOME/.config/systemd/user/sway-headless.service" \
"$HOME/.config/systemd/user/sunshine.service.d/sway-headless.conf" \
"$HOME/.config/systemd/user/sunshine.service.d/headless-prestart.conf" \
"$HOME/.config/systemd/user/app-dev.lizardbyte.app.Sunshine.service.d/headless-prestart.conf"
# Clean empty .d directories
rmdir --ignore-fail-on-non-empty \
"$HOME/.config/systemd/user/sunshine.service.d" \
"$HOME/.config/systemd/user/app-dev.lizardbyte.app.Sunshine.service.d" \
2>/dev/null || true
systemctl --user daemon-reload 2>/dev/null || true
step "Removing udev rule (if we wrote one)" step "Removing udev rule (if we wrote one)"
if [[ -f /etc/udev/rules.d/60-uinput.rules ]]; then if [[ -f /etc/udev/rules.d/60-uinput.rules ]]; then
@@ -87,10 +64,10 @@ fi
if [[ $REMOVE_CA_TRUST -eq 1 ]]; then if [[ $REMOVE_CA_TRUST -eq 1 ]]; then
step "Removing omarchy-stream CA from system trust store" step "Removing omarchy-stream CA from system trust store"
anchor="$(ca_anchor_path)" anchor="/etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem"
if [[ -n "$anchor" && -f "$anchor" ]]; then if [[ -f "$anchor" ]]; then
as_root rm -f "$anchor" as_root rm -f "$anchor"
ca_update_trust as_root update-ca-trust extract >/dev/null
ok "Removed $anchor and refreshed trust store" ok "Removed $anchor and refreshed trust store"
else else
info "CA anchor not present; nothing to remove" info "CA anchor not present; nothing to remove"
@@ -98,12 +75,11 @@ if [[ $REMOVE_CA_TRUST -eq 1 ]]; then
fi fi
if [[ $PURGE -eq 1 ]]; then if [[ $PURGE -eq 1 ]]; then
step "Purging Sunshine + sway-headless user data" step "Purging Sunshine user data"
rm -rf "$HOME/.config/sunshine" "$HOME/.local/share/sunshine" "$HOME/.local/share/omarchy-moonlight" rm -rf "$HOME/.config/sunshine" "$HOME/.local/share/sunshine"
rm -f "$HOME/.config/sway/config-headless"
fi fi
ok "Uninstall complete. Firewall rules and 'input' group membership were left in place." ok "Uninstall complete. Firewall rules and 'input' group membership were left in place."
if [[ $REMOVE_CA_TRUST -eq 0 ]]; then if [[ $REMOVE_CA_TRUST -eq 0 ]]; then
info "The omarchy-stream CA was left in the system trust store (--remove-ca-trust to drop it)." info "The omarchy-stream CA was left in /etc/ca-certificates (--remove-ca-trust to drop it)."
fi fi