From edf4effb1ea38d2a0cb87d324f1312172d53fda2 Mon Sep 17 00:00:00 2001
From: luxus <7449+luxus@users.noreply.github.com>
Date: Sat, 29 Aug 2026 00:25:40 +0200
Subject: [PATCH 1/4] feat(install): pick per-distro defaults from user intent
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The guided installer defaulted every option to no, so Bazzite and
Nobara users who held Enter got a degraded pad and no linger —
the opposite of those distro pages. Prompts now name the intent,
defaults follow the box, and a summary prints before the first sudo.
Fixes #431
---
.gitea/workflows/installer-smoke.yml | 11 ++
docs-site/content/docs/bazzite.mdx | 6 +-
docs-site/content/docs/gamescope.md | 7 +-
docs-site/content/docs/install.mdx | 20 ++--
scripts/ci/check-docs-drift.sh | 2 +
scripts/ci/check-install-defaults.sh | 120 ++++++++++++++++++++
scripts/install.sh | 158 ++++++++++++++++++++++-----
7 files changed, 284 insertions(+), 40 deletions(-)
create mode 100755 scripts/ci/check-install-defaults.sh
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..b766f36f 100644
--- a/docs-site/content/docs/gamescope.md
+++ b/docs-site/content/docs/gamescope.md
@@ -54,9 +54,10 @@ 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, Nobara, and other `ujust` / Fedora Atomic boxes. 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..5422bc94
--- /dev/null
+++ b/scripts/ci/check-install-defaults.sh
@@ -0,0 +1,120 @@
+#!/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'
+
+# Desktop seat: only the couch/HTPC distros flip group + linger on.
+defaults_case debian-desk "$DEB" desktop '' 'Full controller: 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: no'
+defaults_case fedora-desk2 "$FED" desktop '' 'Start at boot with nobody logged in: no'
+defaults_case arch-desk "$ARCH" desktop '' 'Full controller: 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: 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: 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: 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: 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: 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: no'
+
+# ujust is how Bazzite-shaped boxes that aren't ID=bazzite still identify.
+printf '#!/bin/sh\nexit 0\n' > "$ujbin/ujust"
+chmod +x "$ujbin/ujust"
+defaults_case debian-ujust "$DEB" desktop "$ujbin" 'Full controller: yes (Game Mode / HTPC box — virtual Steam Deck pad)'
+defaults_case debian-ujust2 "$DEB" desktop "$ujbin" 'Start at boot with nobody logged in: yes (Game Mode / HTPC box)'
+
+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..2831649a 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,7 +121,13 @@ run() {
fi
printf ' + %s\n' "$cmd"
[ "$DRY" = 1 ] && return 0
- if [ -n "$TTY" ]; then sh -ec "$cmd" < "$TTY"; else sh -ec "$cmd" < /dev/null; fi \
+ # --yes means unattended: never block on a sudo password (sudo reads the TTY, not stdin).
+ # Printed command stays without -n so the platforms.json verbatim gate still matches.
+ exec_cmd=$cmd
+ if [ "$YES" = 1 ]; then
+ exec_cmd=$(printf '%s' "$cmd" | sed 's/sudo /sudo -n /g')
+ fi
+ if [ -n "$TTY" ] && [ "$YES" != 1 ]; then sh -ec "$exec_cmd" < "$TTY"; else sh -ec "$exec_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,115 @@ 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
+}
+couch_box() {
+ [ "$FAMILY" = sysext ] && return 0
+ [ "$ID" = bazzite ] && return 0
+ [ "$ID" = nobara ] && return 0
+ command -v ujust >/dev/null 2>&1 && return 0
+ return 1
+}
+# 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 && return 1 || return 0
+ 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 — joining the punktfunk group so the virtual Steam Deck pad works" ;;
+ nobara) q="Nobara detected — joining the punktfunk group so the virtual Steam Deck pad works" ;;
+ *) q="Game Mode / HTPC box detected — joining the punktfunk group so the virtual Steam Deck pad works" ;;
+ esac
+ ask "$q" y && PF_GROUP=1 || PF_GROUP=0
+ else
+ ask "Do you want the full controller — paddles, trackpads, gyro? (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
+}
+say "Choices (nothing below has run yet)"
+choice_line "Full controller" "$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 +663,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 +670,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
@@ -591,6 +698,9 @@ fi
# ---------------------------------------------------------------------------- 6. start
if [ "$START" = 1 ]; then
say "Starting the host and the web console"
+ # Linger is what creates a user manager on a seatless box; apply it before probing
+ # `systemctl --user`, otherwise SSH/headless installs print the enable command and stop.
+ [ "$LINGER" = 1 ] && run 'sudo loginctl enable-linger "$USER"'
if ! systemctl --user show-environment >/dev/null 2>&1; then
warn "no user systemd session here (ssh without a login session?) — run this from a terminal in your desktop session:"
echo " systemctl --user enable --now punktfunk-host punktfunk-web"
@@ -606,10 +716,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
--
2.54.0
From 9bc6c606fb04ea3a48d3b7e52103c4dfc083d4d2 Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Sat, 29 Aug 2026 18:12:27 +0200
Subject: [PATCH 2/4] fix(install): sudo -n, --no-start linger, and couch-box
scope
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review of the intent-based defaults. Five fixes, three of them defects
the new matrix could not see, so each one gains a case that fails on the
code as it stood.
run(): the `sudo -n` rewrite keyed off --yes, but no terminal already
implies --yes and never the reverse, so `sh install.sh --yes` typed at a
prompt sent `sudo -n` and died on the first step without a cached ticket.
sudo reads /dev/tty, not stdin, so a terminal under --yes prompts fine.
Gated on `[ -z "$TTY" ]` instead, which is the case that actually has
nobody to type a password.
linger: the summary promised "start at boot" unconditionally, but the
`loginctl enable-linger` sat inside `if [ "$START" = 1 ]`, so
`--no-start` printed the promise and dropped it. Every case in the matrix
passes --no-start, so the gate was certifying exactly that. Linger is
configuration, not starting — moved out of the START branch, still ahead
of the `systemctl --user` probe it has to precede.
couch_box(): keying off FAMILY=sysext and a `ujust` on PATH covered
Silverblue, Kinoite, Bluefin and Aurora — desktop workstations that ship
rpm-ostree and ujust and got the group plus linger under --yes without
being asked. Narrowed to `like bazzite || like nobara`, which is what
install.mdx already claimed; gamescope.md corrected to match.
The group prompts name the grant again (usbip attach, only on a machine
you trust, with the docs link). Asking about intent is right, but under
--yes the summary is the only place it is stated, so that line names the
group too.
sunshine_active(): any non-zero exit from `detect-conflicts` read as
"Sunshine is running", so a crashed or too-old host binary would flip
GameStream on and open the plain-HTTP pairing port unasked. Only exit 1
is an answer now; anything else falls through to the unit probe.
check-install-defaults.sh also lifts run() out of the script and drives
it against a stub sudo — --dry-run returns before run() executes
anything, so no dry-run case can reach it.
---
docs-site/content/docs/gamescope.md | 5 +-
scripts/ci/check-install-defaults.sh | 71 +++++++++++++++++++++++-----
scripts/install.sh | 56 ++++++++++++++--------
3 files changed, 99 insertions(+), 33 deletions(-)
diff --git a/docs-site/content/docs/gamescope.md b/docs-site/content/docs/gamescope.md
index b766f36f..5cd5dace 100644
--- a/docs-site/content/docs/gamescope.md
+++ b/docs-site/content/docs/gamescope.md
@@ -55,7 +55,10 @@ root helper, the `punktfunk` group, and lingering, and left the box with nothing
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 guided installer
-> defaults to yes on Bazzite, Nobara, and other `ujust` / Fedora Atomic boxes. The takeover
+> 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:
>
diff --git a/scripts/ci/check-install-defaults.sh b/scripts/ci/check-install-defaults.sh
index 5422bc94..ea64d6c5 100755
--- a/scripts/ci/check-install-defaults.sh
+++ b/scripts/ci/check-install-defaults.sh
@@ -64,34 +64,35 @@ 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: no'
+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: 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: 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: no'
+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: yes (Bazzite — virtual Steam Deck pad)'
+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: yes (Nobara — virtual Steam Deck pad)'
+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: no' --no-punktfunk-group
+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: no'
+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.
@@ -105,13 +106,59 @@ 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: no'
+defaults_case debian-gs2 "$DEB" desktop "$gsbin" 'Full controller (joins the punktfunk group): no'
-# ujust is how Bazzite-shaped boxes that aren't ID=bazzite still identify.
+# `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"
-defaults_case debian-ujust "$DEB" desktop "$ujbin" 'Full controller: yes (Game Mode / HTPC box — virtual Steam Deck pad)'
-defaults_case debian-ujust2 "$DEB" desktop "$ujbin" 'Start at boot with nobody logged in: yes (Game Mode / HTPC box)'
+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(): who gets `sudo -n`
+# --dry-run returns before run() executes anything, so the matrix above is structurally blind to
+# this. Lift the real function out of the script and drive it against a stub sudo instead. A
+# terminal keeps sudo's own password prompt even under --yes — sudo reads /dev/tty, not stdin —
+# and only a session with no terminal at all gets -n, where nothing can type a password anyway.
+# 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\n[ "${1:-}" = -n ] && { echo SUDO-N; exit 0; }\necho SUDO-PLAIN\n' > "$sudobin/sudo"
+chmod +x "$sudobin/sudo"
+
+# name TTY-value YES expected-substring
+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 true'\n" "$2" "$3"
+ } > "$sudobin/harness.sh"
+ got=$(PATH="$sudobin:$PATH" sh "$sudobin/harness.sh" 2>&1)
+ case "$got" in
+ *"$4"*) ;;
+ *)
+ echo "::error::run() with $1: expected '$4', got:"
+ printf '%s\n' "$got" | sed 's/^/ /'
+ fail=1 ;;
+ esac
+}
+run_case 'a terminal and --yes' /dev/null 1 SUDO-PLAIN
+run_case 'a terminal, prompting' /dev/null 0 SUDO-PLAIN
+run_case 'no terminal at all' '' 1 SUDO-N
if [ "$fail" -ne 0 ]; then
echo "installer default matrix failed"
diff --git a/scripts/install.sh b/scripts/install.sh
index 2831649a..41a47ba2 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -121,13 +121,17 @@ run() {
fi
printf ' + %s\n' "$cmd"
[ "$DRY" = 1 ] && return 0
- # --yes means unattended: never block on a sudo password (sudo reads the TTY, not stdin).
+ # No terminal at all (a systemd unit, CI, `ssh box 'curl … | sh'`) — nothing can type a
+ # password, so make sudo say that and exit instead of waiting on one. A terminal is still a
+ # terminal under --yes: sudo reads /dev/tty, not stdin, so its prompt works and -n must NOT
+ # apply, or `sh install.sh --yes` typed at a prompt dies on the first step without a cached
+ # ticket. No TTY already forces YES=1 above, so this is the narrower of the two conditions.
# Printed command stays without -n so the platforms.json verbatim gate still matches.
exec_cmd=$cmd
- if [ "$YES" = 1 ]; then
+ if [ -z "$TTY" ]; then
exec_cmd=$(printf '%s' "$cmd" | sed 's/sudo /sudo -n /g')
fi
- if [ -n "$TTY" ] && [ "$YES" != 1 ]; then sh -ec "$exec_cmd" < "$TTY"; else sh -ec "$exec_cmd" < /dev/null; fi \
+ if [ -n "$TTY" ]; then sh -ec "$exec_cmd" < "$TTY"; else sh -ec "$exec_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"
}
@@ -313,18 +317,23 @@ has_graphical_seat() {
case "${XDG_SESSION_TYPE:-}" in x11|wayland) return 0 ;; esac
return 1
}
-couch_box() {
- [ "$FAMILY" = sysext ] && return 0
- [ "$ID" = bazzite ] && return 0
- [ "$ID" = nobara ] && return 0
- command -v ujust >/dev/null 2>&1 && return 0
- 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 && return 1 || return 0
+ 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
@@ -365,13 +374,14 @@ fi
if [ -z "$PF_GROUP" ]; then
if [ "$DEF_GROUP" = y ]; then
case "$ID" in
- bazzite) q="Bazzite detected — joining the punktfunk group so the virtual Steam Deck pad works" ;;
- nobara) q="Nobara detected — joining the punktfunk group so the virtual Steam Deck pad works" ;;
- *) q="Game Mode / HTPC box detected — joining the punktfunk group so the virtual Steam Deck pad works" ;;
+ bazzite) q="Bazzite detected" ;;
+ nobara) q="Nobara detected" ;;
+ *) q="Game Mode / HTPC box detected" ;;
esac
- ask "$q" y && PF_GROUP=1 || PF_GROUP=0
+ # 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? (skip it and the pad arrives as a plain Xbox 360 controller)" n && PF_GROUP=1 || PF_GROUP=0
+ 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=
@@ -406,8 +416,9 @@ choice_line() {
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" "$PF_GROUP" "$GROUP_WHY"
+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"
@@ -696,11 +707,16 @@ 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"
+ run 'sudo loginctl enable-linger "$USER"'
+fi
if [ "$START" = 1 ]; then
say "Starting the host and the web console"
- # Linger is what creates a user manager on a seatless box; apply it before probing
- # `systemctl --user`, otherwise SSH/headless installs print the enable command and stop.
- [ "$LINGER" = 1 ] && run 'sudo loginctl enable-linger "$USER"'
if ! systemctl --user show-environment >/dev/null 2>&1; then
warn "no user systemd session here (ssh without a login session?) — run this from a terminal in your desktop session:"
echo " systemctl --user enable --now punktfunk-host punktfunk-web"
--
2.54.0
From 581e25dd4a1a35efad8584a7700a0799e8ae736f Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Sat, 29 Aug 2026 18:48:58 +0200
Subject: [PATCH 3/4] fix(install): drop the sudo -n rewrite that broke the
shim
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
installer-smoke went red on the root-container leg:
/tmp/tmp.9gBiJE8x4x/sudo: 2: exec: -n: not found
That shim is install.sh's own — the one it writes when running as root
with no sudo, so the verbatim `sudo …` lines from platforms.json still
work — and it is `exec "$@"`. Rewriting `sudo install -d …` to
`sudo -n install -d …` makes it `exec -n install -d …`, which is not a
command. The smoke job covers that path on purpose.
Not introduced by the previous commit: driving the run() from edf4effb
with no TTY and --yes against the same shim fails identically. Narrowing
the condition moved which sessions hit it, not whether they do.
So the rewrite goes. It buys nothing it claimed: with no terminal, sudo
already exits at once with "no tty present and no askpass program
specified" rather than hanging, and where an askpass helper IS set up,
-n breaks the one unattended path that works. Deleting it also drops a
global sed over an arbitrary command string.
The gate grows the case that would have caught this. Its stub sudo is
now that shim byte for byte, and it asserts the command actually runs.
First attempt at that check was vacuous — run() echoes each command
before running it, so a marker word matched the echo and passed on known
-bad code. The probe is `sudo expr 40 + 2` and the assertion is on 42,
which appears nowhere in the command text.
---
scripts/ci/check-install-defaults.sh | 28 ++++++++++++++++------------
scripts/install.sh | 18 +++++++-----------
2 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/scripts/ci/check-install-defaults.sh b/scripts/ci/check-install-defaults.sh
index ea64d6c5..48e6f7c8 100755
--- a/scripts/ci/check-install-defaults.sh
+++ b/scripts/ci/check-install-defaults.sh
@@ -128,37 +128,41 @@ defaults_case bazzite-sysext "$BAZ" desktop "$ujbin" 'Full controller (joins the
# 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(): who gets `sudo -n`
+# ------------------------------------------------ run(): the command must survive to the shell
# --dry-run returns before run() executes anything, so the matrix above is structurally blind to
-# this. Lift the real function out of the script and drive it against a stub sudo instead. A
-# terminal keeps sudo's own password prompt even under --yes — sudo reads /dev/tty, not stdin —
-# and only a session with no terminal at all gets -n, where nothing can type a password anyway.
+# 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\n[ "${1:-}" = -n ] && { echo SUDO-N; exit 0; }\necho SUDO-PLAIN\n' > "$sudobin/sudo"
+printf '#!/bin/sh\nexec "$@"\n' > "$sudobin/sudo"
chmod +x "$sudobin/sudo"
-# name TTY-value YES expected-substring
+# 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 true'\n" "$2" "$3"
+ 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
- *"$4"*) ;;
+ *42*) ;;
*)
- echo "::error::run() with $1: expected '$4', got:"
+ 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 SUDO-PLAIN
-run_case 'a terminal, prompting' /dev/null 0 SUDO-PLAIN
-run_case 'no terminal at all' '' 1 SUDO-N
+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"
diff --git a/scripts/install.sh b/scripts/install.sh
index 41a47ba2..5998a77b 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -121,17 +121,13 @@ run() {
fi
printf ' + %s\n' "$cmd"
[ "$DRY" = 1 ] && return 0
- # No terminal at all (a systemd unit, CI, `ssh box 'curl … | sh'`) — nothing can type a
- # password, so make sudo say that and exit instead of waiting on one. A terminal is still a
- # terminal under --yes: sudo reads /dev/tty, not stdin, so its prompt works and -n must NOT
- # apply, or `sh install.sh --yes` typed at a prompt dies on the first step without a cached
- # ticket. No TTY already forces YES=1 above, so this is the narrower of the two conditions.
- # Printed command stays without -n so the platforms.json verbatim gate still matches.
- exec_cmd=$cmd
- if [ -z "$TTY" ]; then
- exec_cmd=$(printf '%s' "$cmd" | sed 's/sudo /sudo -n /g')
- fi
- if [ -n "$TTY" ]; then sh -ec "$exec_cmd" < "$TTY"; else sh -ec "$exec_cmd" < /dev/null; fi \
+ # 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"
}
--
2.54.0
From 39a42cf35e969fa37e61f805302dd02461244be7 Mon Sep 17 00:00:00 2001
From: enricobuehler
Date: Sat, 29 Aug 2026 18:55:12 +0200
Subject: [PATCH 4/4] fix(install): skip linger where systemd is not PID 1
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
smoke (arch) got past the sudo fix and died one step later, on the
linger step this branch moved out of --no-start:
==> Starting at boot with nobody logged in
+ sudo loginctl enable-linger "$USER"
System has not been booted with systemd as init system (PID 1).
The smoke containers have no logind, so the call cannot work and linger
would mean nothing there. Reverting the move would put back the summary
that promises start-at-boot and silently drops it under --no-start, so
guard the call instead: run it when systemd is PID 1, warn and carry on
when it is not. An install that otherwise succeeded should not fail on
its last step for a setting the box cannot hold.
--dry-run still prints the command — it reports what a real box would do,
and the defaults matrix asserts on it.
The container path itself is checked by installer-smoke, which is where
it surfaced; no dry-run case can reach it.
---
scripts/install.sh | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/scripts/install.sh b/scripts/install.sh
index 5998a77b..dd4255f1 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -709,7 +709,15 @@ fi
# `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"
- run 'sudo loginctl enable-linger "$USER"'
+ # 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"
--
2.54.0