diff --git a/bin/sunshine-stream-undo.sh b/bin/sunshine-stream-undo.sh index 3a41522..8f404f4 100644 --- a/bin/sunshine-stream-undo.sh +++ b/bin/sunshine-stream-undo.sh @@ -41,11 +41,11 @@ else log "No real monitor connected; leaving workspace assignment to Hyprland defaults." fi -if hyprctl monitors all -j 2>/dev/null | jq -e --arg m "$MON" '.[] | select(.name == $m)' >/dev/null; then - log "Removing $MON" - hyprctl output remove "$MON" >/dev/null || true -fi +# Leave HEADLESS-1 in place. It needs to exist persistently for Sunshine's +# encoder probe to succeed at startup; removing-and-recreating per session +# 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" +log "Stream teardown complete (HEADLESS-1 kept for next connect)" diff --git a/files/headless-prestart.conf b/files/headless-prestart.conf new file mode 100644 index 0000000..4df1e63 --- /dev/null +++ b/files/headless-prestart.conf @@ -0,0 +1,8 @@ +# systemd-user drop-in installed by omarchy-moonlight in headless mode. +# Ensures Hyprland's HEADLESS-1 output exists before Sunshine starts so the +# encoder probe at startup finds a valid surface (otherwise: "Fatal: Unable +# to find display or encoder during startup"). The '-' prefix makes failure +# non-fatal so Sunshine still starts if Hyprland isn't reachable. + +[Service] +ExecStartPre=-/usr/bin/hyprctl output create headless diff --git a/files/sunshine.service b/files/sunshine.service new file mode 100644 index 0000000..a161a2b --- /dev/null +++ b/files/sunshine.service @@ -0,0 +1,18 @@ +[Unit] +Description=Self-hosted game stream host for Moonlight +StartLimitIntervalSec=500 +StartLimitBurst=5 +PartOf=graphical-session.target +After=graphical-session.target + +[Service] +Type=simple +# Ensure the Hyprland headless output exists before Sunshine probes encoders. +# Non-fatal if Hyprland isn't reachable (e.g., service starts before login). +ExecStartPre=-/usr/bin/hyprctl output create headless +ExecStart=/usr/bin/sunshine +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=graphical-session.target diff --git a/install.sh b/install.sh index 7bc7f36..07c28c6 100755 --- a/install.sh +++ b/install.sh @@ -134,6 +134,8 @@ main() { step "Installing headless prep-cmd hooks" install_headless_hooks fi + # NOTE: the headless prestart drop-in needs the sunshine unit to already + # exist; install it after service-unit detection in enable_sunshine_service. if [[ $WRITE_CONFIG -eq 1 ]]; then step "Writing tuned sunshine.conf" diff --git a/lib/certs.sh b/lib/certs.sh index 4ca39cc..b60a033 100644 --- a/lib/certs.sh +++ b/lib/certs.sh @@ -102,7 +102,9 @@ subjectAltName = @alt_names [alt_names] DNS.1 = ${host_lc}.lan +DNS.2 = localhost IP.1 = ${lan_ip} +IP.2 = 127.0.0.1 EOF openssl genrsa -out "$tmpdir/host-key.pem" 2048 2>/dev/null @@ -176,7 +178,9 @@ fetch_and_install_certs() { && cert_is_current \ && cert_signed_by_ca "$tmpdir/ca-cert.pem" \ && cert_has_san_for "${host_lc}.lan" \ - && cert_has_san_for "${lan_ip}"; then + && cert_has_san_for "${lan_ip}" \ + && cert_has_san_for "localhost" \ + && cert_has_san_for "127.0.0.1"; then ok "Sunshine cert is current, signed by CA, and matches expected SANs — skipping mint" return 0 fi diff --git a/lib/config.sh b/lib/config.sh index 0f3e902..651bc9d 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -74,6 +74,13 @@ audio_sink = pulse # Keyboard / mouse / gamepad pass-through via /dev/uinput. # (Requires user to be in the 'input' group; install.sh handles this.) + +# Lock the web UI to localhost-only regardless of what the socket binds to. +# Reach the admin panel from another machine via an SSH tunnel: +# ssh -L 47990:127.0.0.1:47990 @ +# then open https://localhost:47990 in a browser. The streaming/pairing port +# (47989) stays LAN-accessible — only the admin UI is locked down. +origin_web_ui_allowed = pc EOF ok "Wrote sunshine.conf" } diff --git a/lib/headless.sh b/lib/headless.sh index c2b48ce..d4a3317 100644 --- a/lib/headless.sh +++ b/lib/headless.sh @@ -18,3 +18,36 @@ install_headless_hooks() { install -m 0755 "$SCRIPT_DIR/bin/sunshine-stream-undo.sh" "$UNDO_SCRIPT" ok "Installed prep-cmd hooks to $HEADLESS_BIN_DIR" } + +# Install a systemd-user drop-in that pre-creates HEADLESS-1 before Sunshine +# starts, so the encoder probe at startup sees a valid Wayland output. Without +# this, Sunshine reports a fatal "Unable to find display or encoder during +# startup" on every restart, even though streaming works once a client connects. +install_headless_prestart_dropin() { + local dropin_src="$SCRIPT_DIR/files/headless-prestart.conf" + if [[ ! -f "$dropin_src" ]]; then + err "Drop-in source missing: $dropin_src" + return 1 + fi + + # Resolve the actual unit name. Prefer sunshine.service when present (alias + # or sunshine-bin); fall back to the AUR source pkg's reverse-DNS name. + local unit="" + for u in sunshine.service app-dev.lizardbyte.app.Sunshine.service; do + if systemctl --user list-unit-files "$u" >/dev/null 2>&1 \ + && systemctl --user cat "$u" >/dev/null 2>&1; then + unit="$u" + break + fi + done + if [[ -z "$unit" ]]; then + warn "Could not resolve a sunshine unit to attach the drop-in to; skipping." + return 0 + fi + + local dropin_dir="$HOME/.config/systemd/user/${unit}.d" + mkdir -p "$dropin_dir" + install -m 0644 "$dropin_src" "$dropin_dir/headless-prestart.conf" + systemctl --user daemon-reload + ok "Installed headless prestart drop-in at $dropin_dir/headless-prestart.conf" +} diff --git a/lib/service.sh b/lib/service.sh index fda6175..5adafe5 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -2,9 +2,64 @@ # Enable Sunshine as a systemd --user service and turn on lingering so it # runs at boot without a graphical login. +ensure_sunshine_unit_present() { + # Case 1: a sunshine.service unit already exists in any path systemd-user + # scans. sunshine-bin ships /usr/lib/systemd/user/sunshine.service directly. + for p in \ + /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 + 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. + local fqdn_unit="" + for p in \ + /usr/lib/systemd/user/app-dev.lizardbyte.app.Sunshine.service \ + /etc/systemd/user/app-dev.lizardbyte.app.Sunshine.service + do + [[ -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" + return 0 + fi + + # Case 3: no unit shipped at all — drop our own. + local fallback="$SCRIPT_DIR/files/sunshine.service" + if [[ ! -f "$fallback" ]]; then + err "No sunshine.service shipped by the package, and no fallback found at $fallback" + return 1 + fi + info "No packaged sunshine.service found; installing repo fallback unit" + mkdir -p "$HOME/.config/systemd/user" + install -m 0644 "$fallback" "$HOME/.config/systemd/user/sunshine.service" + ok "Installed $HOME/.config/systemd/user/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 + systemctl --user daemon-reload + + # 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 + # which unit ensure_sunshine_unit_present resolved. + if [[ "${STREAM_MODE:-}" == "headless" ]] && declare -F install_headless_prestart_dropin >/dev/null; then + install_headless_prestart_dropin + fi + if ! systemctl --user list-unit-files sunshine.service >/dev/null 2>&1; then - err "sunshine.service not found in user systemd units. Did the package install correctly?" + err "sunshine.service still not found after fallback. Inspect: find /usr ~/.config -name sunshine.service" return 1 fi