Files
Omarchy-Stream/lib/headless.sh
Levi Woodard 6fc34a2bd2 Add reproducible headless desktop for the X11/NVENC capture path
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>
2026-06-02 23:43:00 +00:00

136 lines
5.3 KiB
Bash

#!/usr/bin/env bash
# Set up headless streaming mode: install prep-cmd hook scripts into a stable
# location and ensure they're executable. The actual sunshine.conf entries
# (capture=wlr, output_name=HEADLESS-1, global_prep_cmd=[...]) are written
# 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"
DO_SCRIPT="$HEADLESS_BIN_DIR/sunshine-stream-do.sh"
UNDO_SCRIPT="$HEADLESS_BIN_DIR/sunshine-stream-undo.sh"
PRESTART_SCRIPT="$HEADLESS_BIN_DIR/sunshine-prestart.sh"
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"
# Pull the lightweight WM (Openbox) we drive on :0, plus xsetroot for the
# solid-background touch. Idempotent — pkg_install skips what's present.
ensure_headless_desktop_packages() {
command -v openbox-session >/dev/null 2>&1 || pkg_install openbox
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
}
# Install + enable the Openbox session that renders onto the headless Xorg
# display (:0), so the X11/NVENC capture path has something to show instead of
# a black root window. 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.service"
if [[ ! -f "$src" ]]; then
err "Desktop unit source missing: $src"
return 1
fi
ensure_headless_desktop_packages
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 (Openbox 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() {
mkdir -p "$HEADLESS_BIN_DIR"
mapfile -t srcs < <(_headless_hook_sources)
local do_src="${srcs[0]}" undo_src="${srcs[1]}" pre_src="${srcs[2]}"
install -m 0755 "$do_src" "$DO_SCRIPT"
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
# starts. 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,
# sunshine-bin, or the .deb on Ubuntu); 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"
}