diff --git a/.gitea/workflows/installer-smoke.yml b/.gitea/workflows/installer-smoke.yml
index 354cf212..451da8b4 100644
--- a/.gitea/workflows/installer-smoke.yml
+++ b/.gitea/workflows/installer-smoke.yml
@@ -18,14 +18,25 @@ on:
paths:
- scripts/install.sh
- data/platforms.json
+ - scripts/ci/check-install-defaults.sh
- .gitea/workflows/installer-smoke.yml
pull_request:
paths:
- scripts/install.sh
- data/platforms.json
+ - scripts/ci/check-install-defaults.sh
- .gitea/workflows/installer-smoke.yml
jobs:
+ defaults:
+ name: derived defaults
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Assert per-family installer defaults
+ run: sh scripts/ci/check-install-defaults.sh
+
smoke:
name: smoke (${{ matrix.family }})
runs-on: ubuntu-24.04
diff --git a/docs-site/content/docs/bazzite.mdx b/docs-site/content/docs/bazzite.mdx
index 74652c20..ffa36b0b 100644
--- a/docs-site/content/docs/bazzite.mdx
+++ b/docs-site/content/docs/bazzite.mdx
@@ -9,8 +9,10 @@ Steam **Gaming Mode** and the **KDE desktop**, and follows the box when you swit
Nothing in the config picks a mode.
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
-everything on this page — it detects the distro, installs the host, joins the groups, opens the
-firewall and starts it:
+everything on this page — it detects the distro, installs the host, joins the `input` and
+`punktfunk` groups, enables linger, opens the firewall and starts it. Those last two match the
+hand steps below (Bazzite hosts are usually headless, and the virtual Steam Deck pad needs the
+group):
diff --git a/docs-site/content/docs/gamescope.md b/docs-site/content/docs/gamescope.md
index a624f48a..5cd5dace 100644
--- a/docs-site/content/docs/gamescope.md
+++ b/docs-site/content/docs/gamescope.md
@@ -54,9 +54,13 @@ Versions before this one stopped the display manager for the stream's duration
root helper, the `punktfunk` group, and lingering, and left the box with nothing able to start a
desktop session, so Steam's own "Switch to Desktop" hung until a reboot.
-> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover itself no
-> longer needs it — the group now gates the usbip nodes the virtual Steam Deck pad attaches
-> through, so without it the pad arrives as an ordinary Xbox 360 controller:
+> **Join the `punktfunk` group on any box you stream Game Mode from.** The guided installer
+> defaults to yes on Bazzite and Nobara; on any other distro it asks, and `--punktfunk-group`
+> answers it up front. (Silverblue, Kinoite, Bluefin and Aurora are desktop images, so they get
+> the ordinary "ask, default no" — being Fedora Atomic is not on its own a Game Mode box.) The
+> takeover
+> itself no longer needs the group — it now gates the usbip nodes the virtual Steam Deck pad
+> attaches through, so without it the pad arrives as an ordinary Xbox 360 controller:
>
> ```sh
> sudo usermod -aG punktfunk "$USER" # then log out and back in
diff --git a/docs-site/content/docs/install.mdx b/docs-site/content/docs/install.mdx
index 763c79eb..3040f598 100644
--- a/docs-site/content/docs/install.mdx
+++ b/docs-site/content/docs/install.mdx
@@ -35,15 +35,17 @@ Prefer to read what runs first (it's plain `sh`, ~450 lines):
-It asks before anything optional (Moonlight compat, the shared clipboard, the `punktfunk` group,
-starting at boot) and every answer has a default, so `sh install.sh --yes` — or piping it with no
-terminal — runs unattended; `--channel stable|canary`, `--mgmt-port`, `--no-start`, `--uninstall` and the environment
-twins (`PUNKTFUNK_INSTALL_YES`, `PUNKTFUNK_INSTALL_CHANNEL`, `PUNKTFUNK_INSTALL_GAMESTREAM`,
-`PUNKTFUNK_INSTALL_CLIPBOARD`, `PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP`, `PUNKTFUNK_INSTALL_LINGER`,
-`PUNKTFUNK_INSTALL_MGMT_PORT`) are listed by `--help`. It covers Ubuntu/Debian, Fedora, Arch-family
-and Bazzite/Fedora Atomic; NixOS, SteamOS and Windows it points at their pages. **Preview:** the
-per-system pages above remain the documented path, and the script runs exactly the install commands
-they show (CI fails if the two drift apart). Re-running is safe.
+It asks about intent, not internals (full controller, third-party Moonlight clients, clipboard,
+start at boot) and every answer has a default, so `sh install.sh --yes` — or piping it with no
+terminal — runs unattended. The default follows the box: Bazzite and Nobara join the `punktfunk`
+group and linger; an active Sunshine/Apollo host opts into Moonlight compat; a seatless SSH
+session lingers. Clipboard stays off. `--channel stable|canary`, `--mgmt-port`, `--no-start`,
+`--uninstall` and the environment twins (`PUNKTFUNK_INSTALL_YES`, `PUNKTFUNK_INSTALL_CHANNEL`,
+`PUNKTFUNK_INSTALL_GAMESTREAM`, `PUNKTFUNK_INSTALL_CLIPBOARD`, `PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP`,
+`PUNKTFUNK_INSTALL_LINGER`, `PUNKTFUNK_INSTALL_MGMT_PORT`) are listed by `--help`. It covers
+Ubuntu/Debian, Fedora, Arch-family and Bazzite/Fedora Atomic; NixOS, SteamOS and Windows it points
+at their pages. **Preview:** the per-system pages above remain the documented path, and the script
+runs exactly the install commands they show (CI fails if the two drift apart). Re-running is safe.
`--channel` also **switches** a host that is already installed — see
[Release Channels](/docs/channels#switch-an-installed-box-between-channels). Without the flag the
diff --git a/scripts/ci/check-docs-drift.sh b/scripts/ci/check-docs-drift.sh
index 0852a373..0c305d6b 100755
--- a/scripts/ci/check-docs-drift.sh
+++ b/scripts/ci/check-docs-drift.sh
@@ -31,6 +31,7 @@
# 7. The installer under --dry-run against faked os-release files detects every family it claims
# to (and --uninstall prints each family's removal) — the committed half of the manual
# 16-file matrix PR #345 was verified with. Needs curl on PATH (the script's own prerequisite).
+# Per-family *defaults* (group / linger / GameStream) live in check-install-defaults.sh.
# 8. scripts/web-init.sh — the web console's host-readiness gate — actually waits for the host's
# first-run files and actually stops waiting when they land, against a faked config dir. Not
# docs drift, but the same shape: a packaging script no build exercises, whose failure mode
@@ -165,6 +166,7 @@ installer_case fedora-rm 'ID=fedora\nVERSION_ID=44\n' 'yum.rep
installer_case arch-rm 'ID=arch\n' '/etc/pacman.conf' --uninstall
installer_case omarchy-rm 'ID=omarchy\nID_LIKE=arch\n' 'punktfunk-omarchy remove' --uninstall
installer_case bazzite-rm 'ID=bazzite\nID_LIKE="fedora"\nVERSION_ID=43\n' 'punktfunk-sysext remove' --uninstall
+sh scripts/ci/check-install-defaults.sh || fail=1
# ---------------------------------------------------------------- gate 8: channel switching
# A box that already has all three binaries, on a repo config naming one channel, told --channel
diff --git a/scripts/ci/check-install-defaults.sh b/scripts/ci/check-install-defaults.sh
new file mode 100755
index 00000000..48e6f7c8
--- /dev/null
+++ b/scripts/ci/check-install-defaults.sh
@@ -0,0 +1,171 @@
+#!/bin/sh
+# Derived defaults for the guided installer (scripts/install.sh, issue #431).
+# Faked os-release + --dry-run --yes --no-start: each family must print the summary that
+# --yes would actually take, and that summary must appear before the first sudo.
+# DISPLAY / WAYLAND_DISPLAY / XDG_SESSION_TYPE are pinned per case so a graphical seat on
+# the machine running the gate cannot leak into a "headless" assertion.
+set -u
+cd "$(dirname "$0")/../.." || exit 2
+
+fail=0
+osr=$(mktemp -d)
+gsbin=$(mktemp -d)
+ujbin=$(mktemp -d)
+trap 'rm -rf "$osr" "$gsbin" "$ujbin"' EXIT
+
+# name os-release-body seat(desktop|headless) extra-PATH-dir expected-substring [install.sh args...]
+defaults_case() {
+ name=$1
+ printf '%b' "$2" > "$osr/$name"
+ seat=$3
+ extra=$4
+ want=$5
+ shift 5
+ case "$seat" in
+ desktop) _env="DISPLAY=:0 WAYLAND_DISPLAY= XDG_SESSION_TYPE=x11" ;;
+ headless) _env="DISPLAY= WAYLAND_DISPLAY= XDG_SESSION_TYPE=" ;;
+ *) echo "::error::defaults_case $name: seat must be desktop or headless, got '$seat'"; fail=1; return ;;
+ esac
+ # $_env is expanded on purpose: the seat pins must override a graphical session on the
+ # machine running the gate. Quoted "$@" are extra install.sh flags (e.g. --no-punktfunk-group).
+ out=$(env $_env PATH="${extra:+$extra:}$PATH" \
+ PUNKTFUNK_INSTALL_OS_RELEASE="$osr/$name" \
+ sh scripts/install.sh --dry-run --yes --no-start "$@" 2>&1) || {
+ echo "::error::scripts/install.sh --dry-run --yes --no-start on fake $name ($seat) exited $?"
+ printf '%s\n' "$out" | sed 's/^/ /'
+ fail=1
+ return
+ }
+ case "$out" in
+ *"$want"*) ;;
+ *)
+ echo "::error::scripts/install.sh --dry-run on fake $name ($seat) did not print '$want':"
+ printf '%s\n' "$out" | sed 's/^/ /'
+ fail=1
+ return ;;
+ esac
+ # Summary is the audit trail for --yes; it has to land before any privileged command.
+ # A re-run on a box that already has the host may print no sudo at all — Choices must
+ # still appear; when sudo does print, it must not come first.
+ if ! printf '%s\n' "$out" | awk '
+ /Choices \(nothing below has run yet\)/ { c=NR }
+ index($0, " + sudo") && !s { s=NR }
+ END { if (!c) exit 1; if (s && c > s) exit 1 }
+ '; then
+ echo "::error::scripts/install.sh --dry-run on fake $name ($seat): Choices must print before the first '+ sudo':"
+ printf '%s\n' "$out" | sed 's/^/ /'
+ fail=1
+ fi
+}
+
+DEB='ID=debian\nVERSION_ID=13\nPRETTY_NAME="Debian GNU/Linux 13"\n'
+FED='ID=fedora\nVERSION_ID=44\nPRETTY_NAME="Fedora Linux 44"\n'
+ARCH='ID=arch\nPRETTY_NAME="Arch Linux"\n'
+OMA='ID=omarchy\nID_LIKE=arch\nVERSION_ID=4.0.1\nPRETTY_NAME="Omarchy"\n'
+BAZ='ID=bazzite\nID_LIKE="fedora"\nVERSION_ID=43\nPRETTY_NAME="Bazzite"\n'
+NOB='ID=nobara\nID_LIKE="fedora"\nVERSION_ID=43\nPRETTY_NAME="Nobara Linux"\n'
+SIL='ID=fedora\nVARIANT_ID=silverblue\nVERSION_ID=44\nPRETTY_NAME="Fedora Linux 44 (Silverblue)"\n'
+
+# Desktop seat: only the couch/HTPC distros flip group + linger on.
+defaults_case debian-desk "$DEB" desktop '' 'Full controller (joins the punktfunk group): no'
+defaults_case debian-desk2 "$DEB" desktop '' 'Third-party clients (Moonlight, Artemis): no'
+defaults_case debian-desk3 "$DEB" desktop '' 'Shared clipboard: no'
+defaults_case debian-desk4 "$DEB" desktop '' 'Start at boot with nobody logged in: no'
+defaults_case fedora-desk "$FED" desktop '' 'Full controller (joins the punktfunk group): no'
+defaults_case fedora-desk2 "$FED" desktop '' 'Start at boot with nobody logged in: no'
+defaults_case arch-desk "$ARCH" desktop '' 'Full controller (joins the punktfunk group): no'
+defaults_case arch-desk2 "$ARCH" desktop '' 'Start at boot with nobody logged in: no'
+# Omarchy is a sit-at Arch flavour, not a couch/HTPC default — linger only if the session is seatless.
+defaults_case omarchy-desk "$OMA" desktop '' 'Full controller (joins the punktfunk group): no'
+defaults_case omarchy-desk2 "$OMA" desktop '' 'Start at boot with nobody logged in: no'
+defaults_case omarchy-ssh "$OMA" headless '' 'Start at boot with nobody logged in: yes (no graphical session)'
+
+defaults_case bazzite-desk "$BAZ" desktop '' 'Full controller (joins the punktfunk group): yes (Bazzite — virtual Steam Deck pad)'
+defaults_case bazzite-desk2 "$BAZ" desktop '' 'Start at boot with nobody logged in: yes (Bazzite hosts are usually headless)'
+defaults_case bazzite-desk3 "$BAZ" desktop '' 'Third-party clients (Moonlight, Artemis): no'
+defaults_case bazzite-desk4 "$BAZ" desktop '' 'Shared clipboard: no'
+
+defaults_case nobara-desk "$NOB" desktop '' 'Full controller (joins the punktfunk group): yes (Nobara — virtual Steam Deck pad)'
+defaults_case nobara-desk2 "$NOB" desktop '' 'Start at boot with nobody logged in: yes (Nobara hosts are usually headless)'
+# Flags/env still win over the distro default.
+defaults_case bazzite-flag "$BAZ" desktop '' 'Full controller (joins the punktfunk group): no' --no-punktfunk-group
+
+# No graphical seat (SSH, CI, a pipe) → linger even on a generic distro.
+defaults_case debian-ssh "$DEB" headless '' 'Start at boot with nobody logged in: yes (no graphical session)'
+defaults_case debian-ssh2 "$DEB" headless '' 'Full controller (joins the punktfunk group): no'
+
+# Active Sunshine-family host (detect-conflicts exit 1) → Moonlight compat defaults on.
+# A dormant leftover is not enough — that is the same split detect-conflicts uses.
+cat > "$gsbin/punktfunk-host" <<'EOF'
+#!/bin/sh
+if [ "${1:-}" = detect-conflicts ]; then
+ echo "Sunshine is running"
+ exit 1
+fi
+echo 0.0.0-test
+EOF
+chmod +x "$gsbin/punktfunk-host"
+defaults_case debian-gs "$DEB" desktop "$gsbin" 'Third-party clients (Moonlight, Artemis): yes (Sunshine/Apollo already on this box)'
+defaults_case debian-gs2 "$DEB" desktop "$gsbin" 'Full controller (joins the punktfunk group): no'
+
+# `ujust` and `rpm-ostree` are NOT couch-box tells. Bluefin and Aurora ship ujust; Silverblue
+# and Kinoite are rpm-ostree (so the installer calls them FAMILY=sysext, same as Bazzite). All
+# four are desktop workstations, and joining their users to the punktfunk group or enabling
+# linger under --yes without asking is not what they installed. Only ID/ID_LIKE decides.
+printf '#!/bin/sh\nexit 0\n' > "$ujbin/ujust"
+chmod +x "$ujbin/ujust"
+printf '#!/bin/sh\nexit 0\n' > "$ujbin/rpm-ostree"
+chmod +x "$ujbin/rpm-ostree"
+defaults_case debian-ujust "$DEB" desktop "$ujbin" 'Full controller (joins the punktfunk group): no'
+defaults_case debian-ujust2 "$DEB" desktop "$ujbin" 'Start at boot with nobody logged in: no'
+defaults_case silverblue "$SIL" desktop "$ujbin" 'Full controller (joins the punktfunk group): no'
+defaults_case silverblue2 "$SIL" desktop "$ujbin" 'Start at boot with nobody logged in: no'
+# …and the narrowing must not cost Bazzite its own defaults, sysext family and all.
+defaults_case bazzite-sysext "$BAZ" desktop "$ujbin" 'Full controller (joins the punktfunk group): yes (Bazzite — virtual Steam Deck pad)'
+
+# --no-start is "don't enable the services", not "don't configure": linger was promised in the
+# summary, so it has to actually run. Every other case here passes --no-start, so without this
+# one the matrix would happily certify a summary the run drops on the floor.
+defaults_case bazzite-linger "$BAZ" desktop '' '+ sudo loginctl enable-linger'
+
+# ------------------------------------------------ run(): the command must survive to the shell
+# --dry-run returns before run() executes anything, so the matrix above is structurally blind to
+# what run() does to a command. That blindness cost a red smoke job: a `sudo` → `sudo -n` rewrite
+# under --yes met install.sh's own root-container shim, which is `exec "$@"`, and `exec -n …` is
+# not a command. So the stub here IS that shim, byte for byte, and the assertion is simply that
+# the command still runs. Any flag injected into a sudo line fails this in every mode.
+# TTY=/dev/null stands in for a terminal: run() only tests whether the variable is set, and a CI
+# runner has no controlling terminal to open.
+sudobin=$(mktemp -d)
+trap 'rm -rf "$osr" "$gsbin" "$ujbin" "$sudobin"' EXIT
+printf '#!/bin/sh\nexec "$@"\n' > "$sudobin/sudo"
+chmod +x "$sudobin/sudo"
+
+# name TTY-value YES
+# The probe is `sudo expr 40 + 2`, and the assertion is on its OUTPUT (42), which appears in the
+# command's *text* nowhere. run() echoes every command before running it, so a marker word would
+# match that echo and pass even when nothing executed.
+run_case() {
+ {
+ echo 'die() { echo DIED; exit 1; }'
+ awk '/^run\(\) \{/,/^\}/' scripts/install.sh
+ printf "DRY=0; TTY=%s; YES=%s; DOCS_PAGE=x\nrun 'sudo expr 40 + 2'\n" "$2" "$3"
+ } > "$sudobin/harness.sh"
+ got=$(PATH="$sudobin:$PATH" sh "$sudobin/harness.sh" 2>&1)
+ case "$got" in
+ *42*) ;;
+ *)
+ echo "::error::run() with $1: the sudo line never reached the shell:"
+ printf '%s\n' "$got" | sed 's/^/ /'
+ fail=1 ;;
+ esac
+}
+run_case 'a terminal and --yes' /dev/null 1
+run_case 'a terminal, prompting' /dev/null 0
+run_case 'no terminal (the smoke job, root + shim)' '' 1
+
+if [ "$fail" -ne 0 ]; then
+ echo "installer default matrix failed"
+ exit 1
+fi
+echo "installer default matrix ok"
diff --git a/scripts/install.sh b/scripts/install.sh
index e65244c3..dd4255f1 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -4,10 +4,11 @@
# curl -fsSL https://punktfunk.unom.io/install.sh | sh
# curl -fsSLO https://punktfunk.unom.io/install.sh && sh install.sh --help # read it first
#
-# Zero-to-streaming for a Linux host: detect the distro → add the package repo → install → deal
-# with a Sunshine/Apollo/Vibeshine already on the box (move one port) → groups → options →
-# firewall → start the services → verify → print how to pair. Plain POSIX sh, `read` prompts,
-# no TUI. Every prompt has a default so `--yes` (or no terminal) runs unattended.
+# Zero-to-streaming for a Linux host: detect the distro → ask intent (not internals) → print
+# the choices → add the package repo → install → deal with a Sunshine/Apollo/Vibeshine already
+# on the box (move one port) → groups → options → firewall → start the services → verify →
+# print how to pair. Plain POSIX sh, `read` prompts, no TUI. Every prompt has a default so
+# `--yes` (or no terminal) runs unattended; the default follows the box, not a global no.
#
# This is WP4 of the docs-and-onboarding overhaul and is labelled PREVIEW on purpose: the per-distro
# docs pages (https://docs.punktfunk.unom.io/docs/install) remain the documented default until it
@@ -25,10 +26,10 @@ USER=${USER:-$(id -un)}; export USER
YES=${PUNKTFUNK_INSTALL_YES:-0}
CHANNEL=${PUNKTFUNK_INSTALL_CHANNEL:-stable}
CHANNEL_SET=0; [ -n "${PUNKTFUNK_INSTALL_CHANNEL:-}" ] && CHANNEL_SET=1 # asked for, vs. defaulted
-GAMESTREAM=${PUNKTFUNK_INSTALL_GAMESTREAM:-} # 1/0, empty = ask (default no)
-CLIPBOARD=${PUNKTFUNK_INSTALL_CLIPBOARD:-} # 1/0, empty = ask (default no)
-PF_GROUP=${PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP:-} # 1/0, empty = ask (default no)
-LINGER=${PUNKTFUNK_INSTALL_LINGER:-} # 1/0, empty = ask (default no)
+GAMESTREAM=${PUNKTFUNK_INSTALL_GAMESTREAM:-} # 1/0, empty = ask
+CLIPBOARD=${PUNKTFUNK_INSTALL_CLIPBOARD:-} # 1/0, empty = ask
+PF_GROUP=${PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP:-} # 1/0, empty = ask
+LINGER=${PUNKTFUNK_INSTALL_LINGER:-} # 1/0, empty = ask
MGMT_PORT=${PUNKTFUNK_INSTALL_MGMT_PORT:-47991} # where the management API moves to on a conflict
START=1
DRY=${PUNKTFUNK_INSTALL_DRY_RUN:-0}
@@ -42,10 +43,10 @@ usage: sh install.sh [options]
-y, --yes no prompts: take every default (also the behaviour without a terminal)
--channel stable|canary package channel (default stable; canary = latest main build). On a box
that already has the host this SWITCHES channel, either direction.
- --gamestream | --no-gamestream also serve stock Moonlight clients (default no — trusted LANs only)
- --clipboard | --no-clipboard allow the shared clipboard on this host (default no)
- --punktfunk-group | --no-punktfunk-group join the punktfunk group (virtual Steam Deck pad; default no)
- --linger | --no-linger start the host at boot with nobody logged in (default no)
+ --gamestream | --no-gamestream Moonlight/Artemis/third-party clients (default depends on the box)
+ --clipboard | --no-clipboard shared clipboard (default no)
+ --punktfunk-group | --no-punktfunk-group full controller / virtual Steam Deck pad (default depends on the box)
+ --linger | --no-linger start at boot with nobody logged in (default depends on the box)
--mgmt-port N port to move the management API to if Sunshine/Apollo holds 47990 (default $MGMT_PORT)
--no-start install and configure, but don't enable the services
--uninstall stop the services and remove the packages + repo (config stays: $DOCS/uninstall)
@@ -120,6 +121,12 @@ run() {
fi
printf ' + %s\n' "$cmd"
[ "$DRY" = 1 ] && return 0
+ # Deliberately no `sudo -n` rewrite. It breaks the root-container shim below — that shim is
+ # `exec "$@"`, and `exec -n install …` is not a command — which is a path installer-smoke
+ # covers. It also buys nothing it claims to: with no terminal, sudo already exits at once
+ # with "no tty present and no askpass program specified" instead of hanging, and on a box
+ # where an askpass helper IS configured, -n would break the one unattended path that works.
+ # Stdin is the terminal when there is one so a package manager's own prompt still reaches it.
if [ -n "$TTY" ]; then sh -ec "$cmd" < "$TTY"; else sh -ec "$cmd" < /dev/null; fi \
|| die "that step failed — fix it and re-run (the script is safe to repeat), or follow the page by hand: $DOCS_PAGE"
}
@@ -296,6 +303,122 @@ if [ "$FAMILY" = dnf ]; then
esac
fi
+# ---------------------------------------------------------------------------- choices
+# Empty flag/env still means "ask". The default behind Enter (and --yes) follows the box:
+# couch/HTPC distros want the Deck pad and linger; an active Sunshine-family host wants
+# Moonlight compat; a seatless session wants linger. Clipboard stays off. Flags/env win.
+has_graphical_seat() {
+ [ -n "${DISPLAY:-}" ] && return 0
+ [ -n "${WAYLAND_DISPLAY:-}" ] && return 0
+ case "${XDG_SESSION_TYPE:-}" in x11|wayland) return 0 ;; esac
+ return 1
+}
+# Only the Game Mode / HTPC images, which is exactly what the docs promise. `rpm-ostree`,
+# `bootc` and `ujust` are NOT tells: Silverblue, Kinoite, Bluefin and Aurora ship all three and
+# are desktop workstations, so keying off FAMILY=sysext or a `ujust` on PATH would join their
+# users to the punktfunk group and enable linger under --yes without ever asking.
+couch_box() { like bazzite || like nobara; }
+# Same split as `punktfunk-host detect-conflicts`: exit 1 only when a Sunshine-family host
+# runs or autostarts. A dormant leftover is not a reason to open Moonlight ports.
+sunshine_active() {
+ if command -v punktfunk-host >/dev/null 2>&1; then
+ punktfunk-host detect-conflicts >/dev/null 2>&1
+ # Only 1 is an answer. Any other code is a host too old to know the subcommand, a
+ # half-installed one, or a crash — fall through to the unit probe rather than opening
+ # the plain-HTTP GameStream surface on a guess.
+ case $? in
+ 0) return 1 ;;
+ 1) return 0 ;;
+ esac
+ fi
+ for u in sunshine.service apollo.service vibeshine.service; do
+ systemctl is-active --quiet "$u" 2>/dev/null && return 0
+ systemctl is-enabled --quiet "$u" 2>/dev/null && return 0
+ systemctl --user is-active --quiet "$u" 2>/dev/null && return 0
+ systemctl --user is-enabled --quiet "$u" 2>/dev/null && return 0
+ done
+ return 1
+}
+
+GROUP_WHY=; GS_WHY=; LINGER_WHY=
+DEF_GROUP=n; DEF_GS=n; DEF_LINGER=n
+
+if couch_box; then
+ DEF_GROUP=y
+ DEF_LINGER=y
+ case "$ID" in
+ bazzite)
+ GROUP_WHY="Bazzite — virtual Steam Deck pad"
+ LINGER_WHY="Bazzite hosts are usually headless" ;;
+ nobara)
+ GROUP_WHY="Nobara — virtual Steam Deck pad"
+ LINGER_WHY="Nobara hosts are usually headless" ;;
+ *)
+ GROUP_WHY="Game Mode / HTPC box — virtual Steam Deck pad"
+ LINGER_WHY="Game Mode / HTPC box" ;;
+ esac
+fi
+if ! has_graphical_seat; then
+ DEF_LINGER=y
+ [ -z "$LINGER_WHY" ] && LINGER_WHY="no graphical session"
+fi
+if sunshine_active; then
+ DEF_GS=y
+ GS_WHY="Sunshine/Apollo already on this box"
+fi
+
+if [ -z "$PF_GROUP" ]; then
+ if [ "$DEF_GROUP" = y ]; then
+ case "$ID" in
+ bazzite) q="Bazzite detected" ;;
+ nobara) q="Nobara detected" ;;
+ *) q="Game Mode / HTPC box detected" ;;
+ esac
+ # Ask about intent, but never hide what the answer grants: the group gates usbip attach.
+ ask "$q — join the punktfunk group for the full controller (paddles, trackpads, gyro)? It grants usbip attach, so only on a machine you trust ($DOCS/gamescope#nobara-and-other-autologin-display-managers)" y && PF_GROUP=1 || PF_GROUP=0
+ else
+ ask "Do you want the full controller — paddles, trackpads, gyro? It joins the punktfunk group, which grants usbip attach — only on a machine you trust ($DOCS/gamescope#nobara-and-other-autologin-display-managers). Skip it and the pad arrives as a plain Xbox 360 controller" n && PF_GROUP=1 || PF_GROUP=0
+ GROUP_WHY=
+ fi
+ [ "$PF_GROUP" = 0 ] && GROUP_WHY=
+fi
+if [ -z "$GAMESTREAM" ]; then
+ if [ "$DEF_GS" = y ]; then
+ ask "Sunshine or Apollo is already on this box — also serve Moonlight, Artemis, or another third-party client? (Punktfunk's own apps don't need this)" y && GAMESTREAM=1 || GAMESTREAM=0
+ else
+ ask "Will you connect with Moonlight, Artemis, or another third-party client? (Punktfunk's own apps don't need this)" n && GAMESTREAM=1 || GAMESTREAM=0
+ GS_WHY=
+ fi
+ [ "$GAMESTREAM" = 0 ] && GS_WHY=
+fi
+if [ -z "$CLIPBOARD" ]; then
+ ask "Share the clipboard between this host and your clients? (each client still opts in per host)" n && CLIPBOARD=1 || CLIPBOARD=0
+fi
+if [ -z "$LINGER" ]; then
+ if [ "$DEF_LINGER" = y ]; then
+ ask "$LINGER_WHY — start the host at boot with nobody logged in?" y && LINGER=1 || LINGER=0
+ else
+ ask "Is this a box you stream from and rarely log into? (starts the host at boot with nobody logged in)" n && LINGER=1 || LINGER=0
+ LINGER_WHY=
+ fi
+ [ "$LINGER" = 0 ] && LINGER_WHY=
+fi
+
+yn() { [ "$1" = 1 ] && printf yes || printf no; }
+choice_line() {
+ if [ -n "$3" ] && [ "$2" = 1 ]; then
+ printf ' %s: %s (%s)\n' "$1" "$(yn "$2")" "$3"
+ else
+ printf ' %s: %s\n' "$1" "$(yn "$2")"
+ fi
+}
+# Under --yes this summary is the ONLY place the group grant is stated, so it names the group.
+say "Choices (nothing below has run yet)"
+choice_line "Full controller (joins the punktfunk group)" "$PF_GROUP" "$GROUP_WHY"
+choice_line "Third-party clients (Moonlight, Artemis)" "$GAMESTREAM" "$GS_WHY"
+choice_line "Shared clipboard" "$CLIPBOARD" ""
+choice_line "Start at boot with nobody logged in" "$LINGER" "$LINGER_WHY"
+
# ---------------------------------------------------------------------------- 1. install
# The snippets below are data/platforms.json's install lines, verbatim (stable channel); canary
# and the Fedora group are edited in. check-docs-drift.sh gate 6 keeps them identical.
@@ -547,9 +670,6 @@ elif ! getent group input >/dev/null 2>&1; then
else
run 'sudo usermod -aG input "$USER"'; RELOGIN=1
fi
-if [ -z "$PF_GROUP" ]; then
- ask "Also join the punktfunk group? It enables the virtual Steam Deck controller (paddles, trackpads, gyro) by granting usbip attach — only on a machine you trust ($DOCS/gamescope#nobara-and-other-autologin-display-managers)" n && PF_GROUP=1 || PF_GROUP=0
-fi
if [ "$PF_GROUP" = 1 ]; then
if id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx punktfunk; then ok "already in the punktfunk group"
else run 'sudo usermod -aG punktfunk "$USER"'; RELOGIN=1; fi
@@ -557,16 +677,10 @@ fi
# ---------------------------------------------------------------------------- 4. options
say "Options (host.env — everything here is off by default and reversible)"
-if [ -z "$GAMESTREAM" ]; then
- ask "Also serve stock Moonlight clients (GameStream compat)? Its pairing runs over plain HTTP — trusted LANs only; Punktfunk's own apps don't need it" n && GAMESTREAM=1 || GAMESTREAM=0
-fi
if [ "$GAMESTREAM" = 1 ]; then
[ "$CONFLICT" = 1 ] && warn "with another GameStream host running, only one can bind the Moonlight ports — stop the other first or skip this"
set_env PUNKTFUNK_GAMESTREAM 1
fi
-if [ -z "$CLIPBOARD" ]; then
- ask "Allow the shared clipboard on this host (each client still opts in per host)?" n && CLIPBOARD=1 || CLIPBOARD=0
-fi
[ "$CLIPBOARD" = 1 ] && set_env PUNKTFUNK_CLIPBOARD on
# ---------------------------------------------------------------------------- 5. firewall
@@ -589,6 +703,22 @@ else
fi
# ---------------------------------------------------------------------------- 6. start
+# Linger is configuration, not starting, so --no-start still honours it — the summary above
+# promised it either way, and a promise the run silently drops is worse than not offering it.
+# It is also what creates the user manager on a seatless box, so it has to land before the
+# `systemctl --user` probe below, or SSH/headless installs print the enable command and stop.
+if [ "$LINGER" = 1 ]; then
+ say "Starting at boot with nobody logged in"
+ # A container (installer-smoke, a chroot, docker) has no logind: enable-linger there fails
+ # with "System has not been booted with systemd as init system", and linger would mean
+ # nothing anyway. Say so and carry on — the rest of the install worked. --dry-run still
+ # prints the command, because it reports what a real box would do.
+ if [ "$DRY" = 1 ] || [ -d /run/systemd/system ]; then
+ run 'sudo loginctl enable-linger "$USER"'
+ else
+ warn "no systemd as PID 1 here (a container?) — skipping linger, nothing would honour it"
+ fi
+fi
if [ "$START" = 1 ]; then
say "Starting the host and the web console"
if ! systemctl --user show-environment >/dev/null 2>&1; then
@@ -606,10 +736,6 @@ if [ "$START" = 1 ]; then
# The plugin runner fills the game library; apt/dnf/sysext start it themselves, Arch doesn't.
if systemctl --user list-unit-files punktfunk-scripting.service 2>/dev/null | grep -q disabled; then units="$units punktfunk-scripting"; fi
run "systemctl --user enable --now $units"
- if [ -z "$LINGER" ]; then
- ask "Start the host at boot even with nobody logged in (headless box)?" n && LINGER=1 || LINGER=0
- fi
- [ "$LINGER" = 1 ] && run 'sudo loginctl enable-linger "$USER"'
fi
fi