Add Debian/Ubuntu support via a thin distro dispatch layer

Adds a parallel install path for Debian/Ubuntu hosts alongside the existing
Arch/Omarchy/Hyprland one. The Arch path is untouched at runtime; everything
new is gated on $DISTRO and (for headless) $COMPOSITOR.

Highlights:
- lib/distro.sh: detect_distro + pkg_install/pkg_remove/ca_anchor_path/
  ca_update_trust dispatch helpers
- lib/packages.sh: Ubuntu sunshine install pulls LizardByte's official .deb
  from GitHub releases (override via SUNSHINE_DEB_URL/SUNSHINE_DEB_VERSION);
  GPU encoder packages branch per $DISTRO:$GPU_VENDOR
- bin/sunshine-stream-{do,undo,prestart}-sway.sh + files/sway-headless.*:
  swaymsg-based headless capture path for hosts without Hyprland. sway runs
  under a systemd-user unit that sunshine.service depends on via drop-in.
- lib/preflight.sh: clearer NVIDIA driver guidance on Ubuntu (we don't install
  the driver - too many branch/kernel/Secure-Boot variants); sway-aware
  headless preflight
- lib/certs.sh + lib/verify.sh + uninstall.sh: distro-aware CA trust anchor
  (Arch: /etc/ca-certificates/trust-source/anchors + update-ca-trust;
   Debian: /usr/local/share/ca-certificates + update-ca-certificates)

Verified on Ubuntu 24.04: ./install.sh --doctor --headless loads cleanly,
distro/GPU/compositor detection report the right values, all pre-install
failures correspond to the actual missing pieces.
This commit is contained in:
2026-05-23 01:17:42 +00:00
parent 7bc6d2789b
commit ee1379d5be
16 changed files with 903 additions and 114 deletions

76
bin/sunshine-prestart-sway.sh Executable file
View File

@@ -0,0 +1,76 @@
#!/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

112
bin/sunshine-stream-do-sway.sh Executable file
View File

@@ -0,0 +1,112 @@
#!/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

@@ -0,0 +1,50 @@
#!/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"