diff --git a/README.md b/README.md index f8de363..982989d 100644 --- a/README.md +++ b/README.md @@ -200,13 +200,22 @@ The host-side installer handles Linux clients via `moonlight-qt`. For everything ## Diagnostics ```bash -./install.sh --doctor # run all checks +./status.sh # runtime health check — is it g2g right now? +./install.sh --doctor # install-time correctness checks systemctl --user status sunshine journalctl --user -u sunshine -f getcap "$(readlink -f "$(command -v sunshine)")" # should include cap_sys_admin 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): - TCP: `47984 47989 47990 48010` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 307e1ef..58cf28f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -162,6 +162,35 @@ 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 §12–13 for the drop-ins and the +alias-merge gotcha that lets a stale wlr drop-in poison the X11 environment. + +--- + ## Cert pipeline A separate one-time bootstrap creates the CA in 1Password. Every host then @@ -265,6 +294,7 @@ verify. omarchy-moonlight/ ├── install.sh Orchestrator ├── 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 ├── scripts/ │ └── cert-bootstrap.sh One-time CA generation + 1P upload diff --git a/docs/FOLLOWUPS.md b/docs/FOLLOWUPS.md index 02ad54c..70fb80a 100644 --- a/docs/FOLLOWUPS.md +++ b/docs/FOLLOWUPS.md @@ -220,6 +220,38 @@ 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 §12–13). + +**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. + +**Complexity**: medium-high. The capture-backend split is moderate; full +Debian packaging support is the bulk of the work. + +--- + ## Not on the list (intentionally) - **TLS for the stream itself.** Sunshine and Moonlight handle this with diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index c108026..605adb3 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -364,6 +364,120 @@ IP.2 = 127.0.0.1 And the idempotency check in `fetch_and_install_certs` requires those SANs — 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 +``` + --- ## Custom keybinding to escape Moonlight (Mac) diff --git a/files/sway-headless.service b/files/sway-headless.service index 0dbfb8b..c4e4292 100644 --- a/files/sway-headless.service +++ b/files/sway-headless.service @@ -10,11 +10,21 @@ 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. -# The Vulkan renderer is preferred on NVIDIA + handles NVENC capture cleanly; -# wlroots falls back to GLES2 automatically if Vulkan isn't usable. +# +# 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 -Environment=WLR_RENDERER=vulkan +# 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 diff --git a/lib/config.sh b/lib/config.sh index 2e6ffaf..c3bb1f9 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -70,10 +70,10 @@ $encoder_block min_threads = 4 # Audio sink is intentionally left unset so Sunshine auto-detects the default -# PulseAudio/PipeWire sink and creates its virtual `sink-sunshine-stereo`. +# PulseAudio/PipeWire sink and creates its virtual 'sink-sunshine-stereo'. # Hard-coding audio_sink = pulse here breaks capture: Sunshine treats it as a # literal sink name, can't resolve its monitor source, and pa_simple_new() -# fails with "Invalid argument" → no audio in the stream. +# fails with "Invalid argument" -> no audio in the stream. # Keyboard / mouse / gamepad pass-through via /dev/uinput. # (Requires user to be in the 'input' group; install.sh handles this.) diff --git a/lib/packages.sh b/lib/packages.sh index 59ccbf4..4061b87 100644 --- a/lib/packages.sh +++ b/lib/packages.sh @@ -108,11 +108,11 @@ _ubuntu_sunshine_deb_url() { } _install_sunshine_debian() { - # Universal runtime deps. libva-utils gives `vainfo`; jq is used by hooks. + # 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 libva-utils curl ca-certificates + pkg_install jq vulkan-tools vainfo curl ca-certificates if pkg_installed sunshine; then ok "sunshine already installed (dpkg)" @@ -198,15 +198,36 @@ install_gpu_encoder_packages() { yay_install intel-media-driver vulkan-intel ;; debian:nvidia) - # On Ubuntu, the proprietary driver is usually already installed via - # `ubuntu-drivers autoinstall` or the Server install path. Don't force a - # specific nvidia-* version — they vary by release / driver branch. - # Pull only the userspace VAAPI bridge if available; harmless if missing. - pkg_install libnvidia-encode-no-dkms 2>/dev/null \ - || pkg_install libnvidia-encode-575 2>/dev/null \ - || pkg_install libnvidia-encode-565 2>/dev/null \ - || pkg_install libnvidia-encode-560 2>/dev/null \ - || info "NVENC userspace library not found via a known package name — relying on the existing driver install." + # 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 diff --git a/lib/permissions.sh b/lib/permissions.sh index 61614fb..e554d04 100644 --- a/lib/permissions.sh +++ b/lib/permissions.sh @@ -8,13 +8,33 @@ 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"' ensure_input_group() { - if id -nG "$USER" | tr ' ' '\n' | grep -qx input; then - ok "User '$USER' already in 'input' group" + _ensure_user_in_group input + # On Debian/Ubuntu, /dev/dri/renderD* nodes are mode 0660 owned by + # 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 fi - info "Adding '$USER' to 'input' group" - as_root usermod -aG input "$USER" - warn "You must log out and back in (or run 'newgrp input') for this to take effect." + if id -nG "$USER" | tr ' ' '\n' | grep -qx "$g"; then + ok "User '$USER' already in '$g' group" + return 0 + 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() { diff --git a/lib/service.sh b/lib/service.sh index bd14c9f..761d0de 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -4,23 +4,46 @@ # 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() { - # Case 1: a sunshine.service unit already exists in any path systemd-user - # scans. The LizardByte .deb on Ubuntu drops it at /lib/systemd/user/. - # sunshine-bin on Arch drops it at /usr/lib/systemd/user/. + SUNSHINE_SERVICE="sunshine.service" + + # 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 \ /lib/systemd/user/sunshine.service \ /usr/lib/systemd/user/sunshine.service \ /etc/systemd/user/sunshine.service \ - "$HOME/.config/systemd/user/sunshine.service" \ "$HOME/.local/share/systemd/user/sunshine.service" do - [[ -e "$p" ]] && return 0 + if [[ -f "$p" && ! -L "$p" ]]; then + SUNSHINE_ENABLE_NAME="sunshine.service" + export SUNSHINE_ENABLE_NAME SUNSHINE_SERVICE + return 0 + fi done - # Case 2: the AUR source 'sunshine' package ships the unit under a - # Flatpak-style reverse-DNS name. Symlink it as sunshine.service so the rest - # of our tooling can keep using the short name. + # Case 2: the LizardByte .deb (Ubuntu) and the AUR source package ship the + # unit under a reverse-DNS FQDN with Alias=sunshine.service in [Install]. + # Do NOT symlink — `systemctl --user enable` on the FQDN name creates the + # alias symlink itself in ~/.config/systemd/user/. local fqdn_unit="" for p in \ /usr/lib/systemd/user/app-dev.lizardbyte.app.Sunshine.service \ @@ -30,10 +53,9 @@ ensure_sunshine_unit_present() { [[ -f "$p" ]] && { fqdn_unit="$p"; break; } done if [[ -n "$fqdn_unit" ]]; then - info "Found packaged unit at $fqdn_unit" - info "Aliasing it as sunshine.service in $HOME/.config/systemd/user/" - mkdir -p "$HOME/.config/systemd/user" - ln -sf "$fqdn_unit" "$HOME/.config/systemd/user/sunshine.service" + info "Found packaged unit at $fqdn_unit (enables via alias)" + SUNSHINE_ENABLE_NAME="app-dev.lizardbyte.app.Sunshine.service" + export SUNSHINE_ENABLE_NAME SUNSHINE_SERVICE return 0 fi @@ -47,6 +69,8 @@ ensure_sunshine_unit_present() { mkdir -p "$HOME/.config/systemd/user" install -m 0644 "$fallback" "$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 @@ -114,8 +138,8 @@ enable_sunshine_service() { install_headless_prestart_dropin fi - if ! systemctl --user list-unit-files sunshine.service >/dev/null 2>&1; then - err "sunshine.service still not found after fallback. Inspect: find /usr /lib ~/.config -name sunshine.service" + if ! systemctl --user list-unit-files "$SUNSHINE_ENABLE_NAME" >/dev/null 2>&1; then + err "$SUNSHINE_ENABLE_NAME not found after fallback. Inspect: find /usr /lib ~/.config -name '*[Ss]unshine*'" return 1 fi @@ -126,8 +150,8 @@ enable_sunshine_service() { ok "User lingering already enabled" fi - info "Enabling sunshine.service (user)" - systemctl --user enable sunshine.service >/dev/null + info "Enabling ${SUNSHINE_ENABLE_NAME} (user)" + systemctl --user enable "$SUNSHINE_ENABLE_NAME" >/dev/null # Clear any prior start-limit state from a failed run so this attempt isn't # immediately rejected with "Start request repeated too quickly." diff --git a/status.sh b/status.sh new file mode 100755 index 0000000..4b2bce2 --- /dev/null +++ b/status.sh @@ -0,0 +1,325 @@ +#!/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= 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:-} encoder=${ENC:-} output_name=${OUT:-}" + +# ---- 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 + # 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 ))