A Moonlight client connecting to the x11-backend host got a black screen even though pairing, NVENC, and input injection all worked: the headless Xorg on :0 had no window manager rendering on it, so capture=x11 grabbed an empty black root window. (The wlr/kms backends don't hit this — their capture source renders for itself.) This was a hand-built path with nothing in the repo to reproduce the desktop piece. Now: - files/headless-desktop.service: Openbox session on :0, bound to xorg-headless.service, enabled via default.target for lingering boots, with a best-effort xsetroot so the desktop is visibly non-black. - lib/headless.sh: capture_backend_is_x11 + install_headless_desktop (idempotent; pulls openbox/xsetroot via the distro dispatch). - install.sh: installs the desktop unit when capture=x11 is detected. - status.sh: x11 branch now FAILs if no window manager is on :0 instead of only checking the X server answers — the gap that hid this failure. - docs: TROUBLESHOOTING §13 black-screen lesson; FOLLOWUPS P3 updated. Part of the P3 x11-backend work; --backend flag, config.sh x11 variant, and xorg-headless templates remain outstanding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
340 lines
16 KiB
Bash
Executable File
340 lines
16 KiB
Bash
Executable File
#!/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"
|
|
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
|
|
|
|
# ---- 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
|
|
wm_check=$( (if [[ $EUID -eq 0 && "$SUSER" != "$(id -un)" ]]; then sudo -u "$SUSER" DISPLAY=:0 xprop -root _NET_SUPPORTING_WM_CHECK; else DISPLAY=:0 xprop -root _NET_SUPPORTING_WM_CHECK; fi) 2>/dev/null)
|
|
if grep -q 'window id' <<<"$wm_check"; then
|
|
pass "a window manager is running on :0 (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). See TROUBLESHOOTING.md §13."
|
|
fi
|
|
else
|
|
note "xprop not installed — skipping window-manager-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 ))
|