Compare commits

..
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 59ef285f08 process: the PR template asks whether a user-facing fact changed, and the release flow gets a docs-freshness step
ci / bun-nix (pull_request) Successful in 25s
ci / docs-drift (pull_request) Successful in 59s
ci / docs-site (pull_request) Successful in 1m32s
ci / web (pull_request) Successful in 1m39s
apple / swift (pull_request) Successful in 2m9s
installer-smoke / smoke (arch) (pull_request) Successful in 32s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m26s
installer-smoke / smoke (debian-13) (pull_request) Successful in 2m47s
installer-smoke / smoke (fedora-44) (pull_request) Successful in 2m8s
android / android (pull_request) Successful in 8m1s
ci / rust (pull_request) Successful in 8m18s
WP5 of the docs-and-onboarding overhaul, the two riders the RFC attaches to WP2–WP4:

- .gitea/PULL_REQUEST_TEMPLATE.md — one question: did a user-facing fact change, and is the
  docs-site page that owns it updated in this PR (install/repo/port facts in data/platforms.json).
  CI's docs-drift only catches the mechanical half; this is the reminder for the rest.
- docs/releases/README.md step 1 — while the release diff is in front of you, check docs freshness,
  and if platforms.json changed, run `bun run sync-platforms` in punktfunk-website and commit,
  because the download page vendors that file and only refreshes when someone does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:01:00 +02:00
enricobuehler 5bec450402 install.sh: probe /dev/tty by opening it — a container has the node but no controlling terminal, so -r/-w said yes and the redirect failed (first installer-smoke run); name the matrix jobs by family
ci / bun-nix (pull_request) Successful in 41s
ci / docs-drift (pull_request) Successful in 46s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m49s
ci / rust-arm64 (pull_request) Successful in 1m54s
installer-smoke / smoke (arch) (pull_request) Successful in 49s
apple / swift (pull_request) Successful in 2m17s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
installer-smoke / smoke (fedora-44) (pull_request) Successful in 1m22s
installer-smoke / smoke (debian-13) (pull_request) Successful in 1m45s
android / android (pull_request) Canceled after 3m55s
ci / rust (pull_request) Canceled after 3m56s
2026-08-19 20:57:02 +02:00
enricobuehlerandClaude Fable 5 cd6ce34892 install.sh: a guided Linux installer (preview) that runs exactly the commands platforms.json states, with a CI smoke test per package family
ci / bun-nix (pull_request) Successful in 35s
ci / docs-drift (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 1m21s
installer-smoke / smoke (arch, archlinux:base, pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)) (pull_request) Failing after 23s
installer-smoke / smoke (fedora-44, fedora:44, dnf install -y -q curl git nodejs) (pull_request) Failing after 0s
ci / web (pull_request) Successful in 1m27s
ci / rust-arm64 (pull_request) Successful in 1m31s
installer-smoke / smoke (debian-13, debian:trixie, apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs) (pull_request) Failing after 21s
apple / swift (pull_request) Successful in 2m19s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Canceled after 2m37s
ci / rust (pull_request) Canceled after 2m38s
WP4 of the docs-and-onboarding overhaul (punktfunk-planning design/docs-and-onboarding-overhaul.md).

scripts/install.sh — plain POSIX sh (dash-clean), ~380 lines, `curl -fsSL https://punktfunk.unom.io/install.sh | sh`:
detect the distro from os-release (apt / dnf / pacman / rpm-ostree→sysext; NixOS, SteamOS, Windows
and unknown distros get a one-line pointer and stop; Debian 12 / Ubuntu 24.04 / Mint 22 / Fedora
45 hit the documented floors with the right docs link) → install with the platforms.json lines
VERBATIM (channel and the Fedora group are edited into the string at run time; `--yes` rewrites
them non-interactive, a tty hands the package manager its own prompt; stdin is never read, because
under `curl | sh` stdin is the script) → `punktfunk-host detect-conflicts` (exit 1 = active
Sunshine-family host) → offer to keep both by moving the management API port (PUNKTFUNK_MGMT_BIND,
default 47991, the firewall step opens it) → input group (ujust on Bazzite; no-op if already in) →
optional punktfunk group, GameStream compat, shared clipboard (all default no) → firewalld/ufw
profiles → enable host + console (+ the plugin runner where it isn't) → optional linger → verify
(unit active, UDP 9777 bound) and print the console URL, the password command and the pairing
steps. `--dry-run` prints every command and changes nothing; every prompt has a PUNKTFUNK_INSTALL_*
environment twin; re-running is safe (install skipped when the binary exists). Running under sudo
is refused (host.env and the units belong to the user); root without sudo gets a shim so the
verbatim lines still work.

Decisions: the canonical URL is punktfunk.unom.io/install.sh, a 302 on the website to the script at
raw/branch/main (versioned with the code it installs; precedent: the Bazzite sysext bootstrap) —
the website half is punktfunk-website PR #4. GPU drivers stay the docs pages' job; the one silent
failure (Fedora + NVIDIA without RPM Fusion's ffmpeg-libs → no NVENC) is called out at the end.

Gates: check-docs-drift.sh gate 6 — every apt/pacman/dnf/sysext install line in data/platforms.json
must appear verbatim in the script, and the script must parse (shown to fail on a planted drift).
New path-filtered workflow installer-smoke.yml runs the script unattended in debian:trixie,
fedora:44 and archlinux:base against the real registry, then `punktfunk-host --version`,
`detect-conflicts`, and a re-run that must say "already installed".

Docs: install hub gains "Guided install (preview)" rendered from platforms.json's new `installer`
block via an <Installer/> component (one-liner + inspect-first form + flags); CONTRIBUTING names
the new gate. Verified locally: sh/dash -n, both docs gates, docs-site build + lint, and a
--dry-run matrix over 16 faked os-release files (all four families, every floor, canary, every
option, piped stdin). The container run itself is the CI job's to report — Docker on this machine
was wedged under another session's emulated build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:54:13 +02:00
enricobuehler 2a60f94f74 Merge pull request 'Land the WP2 docs rewrite on main (#340 merged into the already-merged #337 branch) and make docs-drift green again' (#343) from docs-wp2-to-main into main
apple / swift (push) Successful in 2m8s
ci / rust-arm64 (push) Successful in 2m29s
ci / docs-site (push) Successful in 1m24s
ci / web (push) Successful in 1m33s
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 26s
deb / build-publish-gamescope (push) Successful in 30s
deb / build-publish-client-arm64 (push) Successful in 1m42s
decky / build-publish (push) Successful in 38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
arch / build-publish (push) Successful in 10m27s
deb / build-publish (push) Successful in 5m7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
android / android (push) Successful in 11m33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m4s
deb / build-publish-host (push) Successful in 5m46s
apple / distribute (push) Successful in 11m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / deploy-docs (push) Successful in 55s
docker / builders-arm64cross (push) Successful in 13s
ci / rust (push) Successful in 22m5s
apple / screenshots (push) Successful in 9m53s
deb / smoke-install (push) Successful in 7m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m7s
2026-08-19 18:23:48 +00:00
11 changed files with 547 additions and 2 deletions
+6
View File
@@ -0,0 +1,6 @@
<!-- What and why — the diff says how. -->
**User-facing fact changed?** (an install step, a knob, a port, what a feature does, a limit)
→ the docs-site page that owns it is updated in this PR, or this is n/a. Install/repo/port facts
live in `data/platforms.json`. (CONTRIBUTING.md "Where facts live"; `docs-drift` in CI only
catches the mechanical half.)
+63
View File
@@ -0,0 +1,63 @@
# Smoke test for the guided installer (scripts/install.sh, docs-and-onboarding overhaul WP4).
# Runs the script unattended inside a clean container per package family against the REAL
# package registry — the one path a textual gate can't cover: does the repo line, the key import
# and the install actually work today on a fresh box. `--no-start` because a container has no
# user systemd; the script degrades to printing the enable command, which is also under test.
#
# Path-filtered on purpose: it pulls ~100 MB of packages per family, so it runs when the script
# or its fact source changes, not on every push (check-docs-drift.sh gate 6 covers the cheap
# half — the install lines in the script must match data/platforms.json verbatim — on every push).
name: installer-smoke
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- scripts/install.sh
- data/platforms.json
- .gitea/workflows/installer-smoke.yml
pull_request:
paths:
- scripts/install.sh
- data/platforms.json
- .gitea/workflows/installer-smoke.yml
jobs:
smoke:
name: smoke (${{ matrix.family }})
runs-on: ubuntu-24.04
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
# actions/checkout needs git + node + CA certs in the container; curl is the
# script's own prerequisite (it says so and stops without it).
- family: debian-13
image: debian:trixie
prep: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs
- family: fedora-44
image: fedora:44
prep: dnf install -y -q curl git nodejs
- family: arch
image: archlinux:base
prep: pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)
container:
image: ${{ matrix.image }}
steps:
- name: Prepare the container (${{ matrix.family }})
run: ${{ matrix.prep }}
- uses: actions/checkout@v4
# No tty → the script runs as --yes; --no-start because there is no user systemd here.
# Root without sudo → the script's sudo shim, another path under test.
- name: Run the installer unattended
run: sh scripts/install.sh --yes --no-start
- name: The host is installed and conflict-free
run: |
punktfunk-host --version
punktfunk-host detect-conflicts
- name: Re-running is a no-op install
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
+1 -1
View File
@@ -113,7 +113,7 @@ PR.
CI enforces the cheap half of this (`scripts/ci/check-docs-drift.sh` and `check-docs-links.sh`):
the OpenAPI snapshot must match `api/openapi.json`, the docs-site copy of `data/platforms.json` must
match the canonical one, every `PUNKTFUNK_*` variable the docs mention
match the canonical one, `scripts/install.sh` must carry the file's install lines verbatim, every `PUNKTFUNK_*` variable the docs mention
must still exist in the tree, the counts of undocumented `PUNKTFUNK_*` variables and undocumented
`punktfunk-host` subcommands may never grow (document the new knob, or consciously raise the
baseline in the script), and internal docs links must resolve.
+14
View File
@@ -47,6 +47,20 @@
"web": "punktfunk-web"
},
"installer": {
"$comment": "The guided Linux installer (WP4, preview). Canonical URL is the website's /install.sh, a redirect to the raw script on main so it's versioned with the code it installs; the docs hub and the download page quote these lines.",
"status": "preview",
"url": "https://punktfunk.unom.io/install.sh",
"source": "https://git.unom.io/unom/punktfunk/raw/branch/main/scripts/install.sh",
"oneLiner": "curl -fsSL https://punktfunk.unom.io/install.sh | sh",
"inspectFirst": [
"curl -fsSLO https://punktfunk.unom.io/install.sh",
"less install.sh",
"sh install.sh"
],
"docs": "/docs/install#guided-install-preview"
},
"conflicts": {
"hosts": ["Sunshine", "Apollo", "Vibeshine"],
"detect": "punktfunk-host detect-conflicts",
+23
View File
@@ -21,6 +21,29 @@ repositories on Linux and from a signed installer on Windows — pick your syste
Streaming **to** a device instead? That's a client — see [Install a Client](/docs/install-client).
## Guided install (preview)
On Linux, one command does what the pages above walk you through — detects your distro, adds the
repo, installs the host, notices a Sunshine/Apollo/Vibeshine already on the box and moves one port
so both can run, joins the `input` group, opens the firewall if you run one, starts the host and the
console, and tells you how to pair:
<Installer />
Prefer to read what runs first (it's plain `sh`, ~350 lines):
<Installer inspect />
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 canary`, `--mgmt-port`, `--no-start` 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.
## Good to know
- **Read [Security & Safe Use](/docs/security) once.** A streaming host is remote control of the
+2 -1
View File
@@ -2,7 +2,7 @@ import defaultMdxComponents from 'fumadocs-ui/mdx'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import type { MDXComponents } from 'mdx/types'
import BitrateCalculator from '@/components/BitrateCalculator'
import { Install, Ports } from '@/components/platforms'
import { Install, Installer, Ports } from '@/components/platforms'
export function getMDXComponents(components?: MDXComponents) {
return {
@@ -11,6 +11,7 @@ export function getMDXComponents(components?: MDXComponents) {
BitrateCalculator,
// Install commands / port table quoted from data/platforms.json: <Install platform="debian" />, <Ports />
Install,
Installer,
Ports,
// Per-platform instructions: <Tabs items={['Linux', 'Windows']}><Tab value="Linux">…
Tabs,
+18
View File
@@ -35,6 +35,24 @@ export function Install({ platform, title }: { platform: string; title?: string
)
}
/** `<Installer />` — the guided installer's one-liner and its inspect-first form, from platforms.json. */
export function Installer({ inspect }: { inspect?: boolean }) {
const lines = inspect ? platforms.installer.inspectFirst : [platforms.installer.oneLiner]
return (
<CodeBlock>
<Pre>
<code>
{lines.map((line, i) => (
<span key={i} className="line">
{line}
</span>
))}
</code>
</Pre>
</CodeBlock>
)
}
/** `<Ports />` — every port the host and console use, with the firewall profile that opens it. */
export function Ports() {
const { ports, firewall } = platforms
+14
View File
@@ -47,6 +47,20 @@
"web": "punktfunk-web"
},
"installer": {
"$comment": "The guided Linux installer (WP4, preview). Canonical URL is the website's /install.sh, a redirect to the raw script on main so it's versioned with the code it installs; the docs hub and the download page quote these lines.",
"status": "preview",
"url": "https://punktfunk.unom.io/install.sh",
"source": "https://git.unom.io/unom/punktfunk/raw/branch/main/scripts/install.sh",
"oneLiner": "curl -fsSL https://punktfunk.unom.io/install.sh | sh",
"inspectFirst": [
"curl -fsSLO https://punktfunk.unom.io/install.sh",
"less install.sh",
"sh install.sh"
],
"docs": "/docs/install#guided-install-preview"
},
"conflicts": {
"hosts": ["Sunshine", "Apollo", "Vibeshine"],
"detect": "punktfunk-host detect-conflicts",
+6
View File
@@ -14,6 +14,12 @@ release is born complete and the announcement always has something to say.
1. **Write the notes.** Add `docs/releases/vX.Y.Z.md` in the same commit (or PR) as the version
bump. Copy `TEMPLATE.md` and fill it in. This file is the single source of truth for the body.
**Docs freshness, while you have the diff in front of you:** every user-facing fact the release
changes has its docs-site page updated (CONTRIBUTING.md "Where facts live" — `docs-drift` in CI
catches renamed knobs and dead links, not a stale sentence). If an install command, repo URL or
port changed, `data/platforms.json` changed with it — then run `bun run sync-platforms` in
punktfunk-website and commit, because its download page vendors that file and only refreshes
when someone does.
2. **Tag & push.** `git tag -a vX.Y.Z … && git push origin vX.Y.Z` fans out to the build
workflows. Whichever one wins the create race seeds the release body from this file
(`scripts/ci/gitea-release.sh``ensure_release`, and its PowerShell twin). The release page
+16
View File
@@ -24,6 +24,10 @@
# platforms.json — the snapshot the <Install/> and <Ports/> MDX components render from
# (the docs Docker build context is docs-site/ alone) — must be a byte copy of it, same
# rule as gate 1.
# 6. scripts/install.sh (the guided installer, WP4) runs the install lines platforms.json
# states — every `install` line of an apt/pacman/dnf/sysext host platform must appear in
# the script verbatim (it edits channel/group into the string at run time, never the
# literal), and the script must parse under sh.
#
# Textual gates, so textual limits: gate 2/3 match token spelling, not env reads — a var name in
# a code comment counts as "exists", and a quoted constant that isn't an env var counts toward
@@ -107,4 +111,16 @@ if [ "$(cksum < data/platforms.json)" != "$(cksum < docs-site/src/data/platforms
fail=1
fi
# ---------------------------------------------------------------- gate 6: installer quotes platforms.json
if ! sh -n scripts/install.sh; then
echo "::error::scripts/install.sh does not parse under sh"
fail=1
fi
installer_check='const fs=require("fs");const p=JSON.parse(fs.readFileSync("data/platforms.json","utf8"));const sh=fs.readFileSync("scripts/install.sh","utf8");let bad=0;for(const x of p.platforms){if(x.installs!=="host"||!["apt","pacman","dnf","sysext"].includes(x.packageManager))continue;for(const line of x.install||[]){if(!sh.includes(line)){console.error(`::error::scripts/install.sh no longer carries platforms.json\x27s ${x.id} install line verbatim: ${line}`);bad=1}}}process.exit(bad)'
if command -v bun >/dev/null 2>&1; then
bun -e "$installer_check" || fail=1
elif command -v node >/dev/null 2>&1; then
node -e "$installer_check" || fail=1
fi
exit "$fail"
+384
View File
@@ -0,0 +1,384 @@
#!/bin/sh
# punktfunk guided host installer — PREVIEW.
#
# 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.
#
# 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
# has mileage. The install commands it runs are the ones data/platforms.json states — verbatim, and
# CI (scripts/ci/check-docs-drift.sh) fails if they drift apart. Windows (winget/installer), the
# clients, NixOS and SteamOS have their own paths; this script points at them and stops.
#
# Exit codes: 0 done · 1 unsupported system / a step failed · 2 bad usage.
set -u
DOCS=https://docs.punktfunk.unom.io/docs
USER=${USER:-$(id -un)}; export USER
# ---------------------------------------------------------------------------- options
YES=${PUNKTFUNK_INSTALL_YES:-0}
CHANNEL=${PUNKTFUNK_INSTALL_CHANNEL:-stable}
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)
MGMT_PORT=${PUNKTFUNK_INSTALL_MGMT_PORT:-47991} # where the management API moves to on a conflict
START=1
DRY=${PUNKTFUNK_INSTALL_DRY_RUN:-0}
usage() {
cat <<EOF
punktfunk guided host installer (preview)
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)
--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)
--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
--dry-run print every command it would run, change nothing
-h, --help this text
Every option has an environment twin for scripted installs: PUNKTFUNK_INSTALL_YES=1,
PUNKTFUNK_INSTALL_CHANNEL, PUNKTFUNK_INSTALL_GAMESTREAM, PUNKTFUNK_INSTALL_CLIPBOARD,
PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP, PUNKTFUNK_INSTALL_LINGER, PUNKTFUNK_INSTALL_MGMT_PORT (1/0 for the flags).
Docs: $DOCS/install
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
-y|--yes) YES=1 ;;
--channel) shift; CHANNEL=${1:-} ;;
--channel=*) CHANNEL=${1#*=} ;;
--gamestream) GAMESTREAM=1 ;; --no-gamestream) GAMESTREAM=0 ;;
--clipboard) CLIPBOARD=1 ;; --no-clipboard) CLIPBOARD=0 ;;
--punktfunk-group) PF_GROUP=1 ;; --no-punktfunk-group) PF_GROUP=0 ;;
--linger) LINGER=1 ;; --no-linger) LINGER=0 ;;
--mgmt-port) shift; MGMT_PORT=${1:-} ;;
--mgmt-port=*) MGMT_PORT=${1#*=} ;;
--no-start) START=0 ;;
--dry-run) DRY=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
case "$CHANNEL" in stable|canary) ;; *) echo "--channel must be stable or canary" >&2; exit 2 ;; esac
case "$MGMT_PORT" in ''|*[!0-9]*) echo "--mgmt-port must be a number" >&2; exit 2 ;; esac
# ---------------------------------------------------------------------------- plumbing
say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m ok\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m !!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m xx\033[0m %s\n' "$*" >&2; exit 1; }
# Prompts read from the terminal, not stdin — stdin is the script itself under `curl | sh`.
# No terminal (CI, cron, a pipe) behaves like --yes.
# Probe by opening it: in a container or under a service /dev/tty is a node that exists but
# can't be opened (ENXIO — no controlling terminal), so -r/-w would say yes and the redirect fail.
TTY=/dev/tty
(exec 3<>/dev/tty) 2>/dev/null || { TTY=; YES=1; }
# ask "question" default(y|n) → 0 = yes, 1 = no
ask() {
if [ "$YES" = 1 ]; then [ "$2" = y ]; return; fi
if [ "$2" = y ]; then hint="[Y/n]"; else hint="[y/N]"; fi
printf '\033[1m?\033[0m %s %s ' "$1" "$hint" > "$TTY"
read -r ans < "$TTY" || ans=
case "${ans:-$2}" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
}
# run "shell snippet": print it, run it (-e), and give the package manager the terminal so its own
# confirmation prompt works; under --yes the snippet is made non-interactive first.
run() {
cmd=$1
if [ "$YES" = 1 ]; then
cmd=$(printf '%s' "$cmd" | sed \
-e 's/^sudo apt install /sudo apt install -y /' \
-e 's/^sudo dnf install /sudo dnf install -y /' \
-e 's/^sudo pacman -Syu /sudo pacman -Syu --noconfirm /')
fi
printf ' + %s\n' "$cmd"
[ "$DRY" = 1 ] && return 0
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"
}
# Running as root without sudo (a minimal Debian container): a shim so the verbatim
# `sudo …` lines from platforms.json still work.
if [ "$(id -u)" = 0 ] && ! command -v sudo >/dev/null 2>&1; then
SHIM=$(mktemp -d) && printf '#!/bin/sh\nexec "$@"\n' > "$SHIM/sudo" && chmod +x "$SHIM/sudo" && PATH="$SHIM:$PATH"
fi
HOST_ENV=${XDG_CONFIG_HOME:-$HOME/.config}/punktfunk/host.env
# set_env KEY VALUE — replace or append one line in host.env (created on first use).
set_env() {
if [ "$DRY" = 1 ]; then ok "would set $1=$2 in $HOST_ENV"; return 0; fi
mkdir -p "$(dirname "$HOST_ENV")"
touch "$HOST_ENV"
if grep -q "^$1=" "$HOST_ENV"; then
sed -i "s|^$1=.*|$1=$2|" "$HOST_ENV"
else
printf '%s=%s\n' "$1" "$2" >> "$HOST_ENV"
fi
ok "$1=$2$HOST_ENV"
}
# ---------------------------------------------------------------------------- 0. preflight
cat <<EOF
punktfunk guided host installer — PREVIEW
The per-distro pages stay the documented path; this automates them. Docs: $DOCS/install
Re-running is safe. Ctrl-C stops between steps.
EOF
[ "$(uname -s)" = Linux ] || [ -n "${PUNKTFUNK_INSTALL_OS_RELEASE:-}" ] || die "this installer is for Linux hosts — Windows: $DOCS/windows-host"
[ -n "${SUDO_USER:-}" ] && [ "$(id -u)" = 0 ] && die "run this as your normal user, not under sudo — it calls sudo itself where needed, and the host runs as you (host.env, the services)"
command -v curl >/dev/null 2>&1 || die "curl is required (install it with your package manager first)"
OS_RELEASE=${PUNKTFUNK_INSTALL_OS_RELEASE:-/etc/os-release} # override for testing the detection
[ -r "$OS_RELEASE" ] || die "no /etc/os-release — can't tell which distro this is: $DOCS/install"
. "$OS_RELEASE"
ID=${ID:-}; ID_LIKE=${ID_LIKE:-}; VERSION_ID=${VERSION_ID:-}; PRETTY=${PRETTY_NAME:-$ID}
like() { case " $ID $ID_LIKE " in *" $1 "*) return 0 ;; esac; return 1; }
FAMILY=
DOCS_PAGE=$DOCS/install
if [ "$ID" = nixos ]; then
die "NixOS: add the flake input and enable the module instead — $DOCS/nixos"
elif [ "$ID" = steamos ]; then
die "SteamOS host: the on-device installer builds against the running OS — $DOCS/steamos-host"
elif command -v rpm-ostree >/dev/null 2>&1 || command -v bootc >/dev/null 2>&1 || [ "$ID" = bazzite ]; then
FAMILY=sysext; DOCS_PAGE=$DOCS/bazzite
elif like debian || like ubuntu; then
FAMILY=apt; DOCS_PAGE=$DOCS/debian
[ "$ID" = ubuntu ] && DOCS_PAGE=$DOCS/ubuntu
elif like fedora; then
FAMILY=dnf; DOCS_PAGE=$DOCS/fedora
elif like arch; then
FAMILY=pacman; DOCS_PAGE=$DOCS/arch
else
die "no package repo for '$PRETTY' yet — $DOCS/build-from-source"
fi
say "Detected $PRETTY$FAMILY (guide: $DOCS_PAGE)"
# Version floors the package can't express: below these the install succeeds and nothing can stream.
major=${VERSION_ID%%.*}
case "$ID" in
debian) [ "${major:-0}" -ge 13 ] 2>/dev/null || die "Debian $VERSION_ID is below the glibc floor — Debian 13+ or build from source: $DOCS/build-from-source" ;;
ubuntu) case "$VERSION_ID" in 2[0-5].*) warn "Ubuntu $VERSION_ID installs the package but cannot host — its desktop is too old to create a virtual display ($DOCS/requirements#the-floor-for-a-working-host). Use 26.04+."
ask "Continue anyway?" n || exit 1 ;; esac ;;
linuxmint) case "$VERSION_ID" in 2[0-2]*) warn "Linux Mint $VERSION_ID (Ubuntu 24.04 base) installs the package but cannot host — $DOCS/requirements#cinnamon-linux-mint-and-lmde. LMDE 7 and Mint 23 can."
ask "Continue anyway?" n || exit 1 ;; esac ;;
esac
RPM_GROUP=
if [ "$FAMILY" = dnf ]; then
case "$major" in
44) RPM_GROUP=fedora-44 ;;
43) RPM_GROUP=bazzite ;; # a plain Fedora 43 build of the same package
*) die "no RPM group for Fedora $VERSION_ID yet — $DOCS/build-from-source" ;;
esac
fi
# ---------------------------------------------------------------------------- 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.
if command -v punktfunk-host >/dev/null 2>&1; then
say "punktfunk-host is already installed ($(punktfunk-host --version 2>/dev/null | head -1)) — skipping the install, continuing with setup"
else
say "Installing the host ($CHANNEL channel)"
case "$FAMILY" in
apt)
repo_line='echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" | sudo tee /etc/apt/sources.list.d/punktfunk.list'
[ "$CHANNEL" = canary ] && repo_line=$(printf '%s' "$repo_line" | sed 's/ stable main/ canary main/')
run 'sudo install -d -m 0755 /etc/apt/keyrings'
run 'curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key | sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null'
run "$repo_line"
run 'sudo apt update'
run 'sudo apt install punktfunk-host'
;;
pacman)
repo_line=$(cat <<'LINE'
grep -q '^\[punktfunk\]' /etc/pacman.conf || printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' | sudo tee -a /etc/pacman.conf >/dev/null
LINE
)
# canary: both the grep guard (escaped brackets) and the printf body name the repo
[ "$CHANNEL" = canary ] && repo_line=$(printf '%s' "$repo_line" | sed -e 's/punktfunk\\\]/punktfunk-canary\\]/' -e 's/\[punktfunk\]/[punktfunk-canary]/')
run 'curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key | sudo pacman-key --add -'
run 'sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69'
run "$repo_line"
run 'sudo pacman -Syu punktfunk-host'
if ask "Install the web console and the plugin runner too (punktfunk-web, punktfunk-scripting — optional on Arch, recommended)?" y; then
run 'sudo pacman -Syu punktfunk-web punktfunk-scripting'
fi
;;
dnf)
group=$RPM_GROUP
[ "$CHANNEL" = canary ] && group="$group-canary"
run "$(cat <<'CMD'
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
[punktfunk]
name=punktfunk
# fedora-44 on Fedora 44; bazzite on Fedora 43 (a plain Fedora 43 build of the same package)
baseurl=https://git.unom.io/api/packages/unom/rpm/fedora-44
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk
REPO
CMD
)"
[ "$group" = fedora-44 ] || run "sudo sed -i 's|/rpm/fedora-44|/rpm/$group|' /etc/yum.repos.d/punktfunk.repo"
run 'sudo dnf install punktfunk'
;;
sysext)
install_line='sudo bash punktfunk-sysext.sh install'
[ "$CHANNEL" = canary ] && install_line="$install_line --channel canary"
run 'curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh'
run "$install_line"
;;
esac
hash -r 2>/dev/null || true
if [ "$DRY" != 1 ]; then
command -v punktfunk-host >/dev/null 2>&1 || die "the install finished but punktfunk-host isn't on PATH — open a new terminal and re-run, or see $DOCS_PAGE"
ok "punktfunk-host $(punktfunk-host --version 2>/dev/null | head -1) installed"
fi
fi
# ---------------------------------------------------------------------------- 2. another host?
# detect-conflicts exits 1 only for a Sunshine-family host that runs or autostarts; dormant
# leftovers print and exit 0. Native-only, the single port both want is the management API's.
say "Checking for Sunshine / Apollo / Vibeshine"
CONFLICT=0
if [ "$DRY" = 1 ] && ! command -v punktfunk-host >/dev/null 2>&1; then
printf ' + %s\n' 'punktfunk-host detect-conflicts'
elif report=$(punktfunk-host detect-conflicts 2>&1); then
ok "${report:-No conflicting game-streaming host detected.}"
else
CONFLICT=1
printf '%s\n' "$report" | sed 's/^/ /'
warn "another streaming host is active on this box — both want TCP 47990 (its web UI, punktfunk's management API)"
if ask "Keep both for now and move punktfunk's management API to port $MGMT_PORT? (No = you stop the other host yourself)" y; then
set_env PUNKTFUNK_MGMT_BIND "0.0.0.0:$MGMT_PORT"
echo " Clients learn the port from discovery; the console and plugins read it from mgmt-endpoint. Details: $DOCS/switching-from-sunshine"
else
MGMT_PORT=
echo " Stop it before you start punktfunk (e.g. sudo systemctl disable --now sunshine) — $DOCS/switching-from-sunshine"
fi
fi
[ "$CONFLICT" = 0 ] && MGMT_PORT=
# ---------------------------------------------------------------------------- 3. groups
say "Controller access"
if id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx input; then
ok "already in the input group"
elif command -v ujust >/dev/null 2>&1; then # Bazzite: the input group is recipe-managed, usermod is the wrong tool
run 'ujust add-user-to-input-group'; RELOGIN=1
elif ! getent group input >/dev/null 2>&1; then
warn "no 'input' group on this system — virtual gamepads need /dev/uinput access; see $DOCS_PAGE"
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
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
# Packages never open ports; they install firewalld services / ufw profiles by these names.
say "Firewall"
if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then
svcs="--add-service=punktfunk-native --add-service=punktfunk-web"
[ "$GAMESTREAM" = 1 ] && svcs="$svcs --add-service=punktfunk-gamestream"
[ -n "$MGMT_PORT" ] && svcs="$svcs --add-port=$MGMT_PORT/tcp"
run 'sudo firewall-cmd --reload'
run "sudo firewall-cmd --permanent $svcs"
run 'sudo firewall-cmd --reload'
elif command -v ufw >/dev/null 2>&1 && grep -qs '^ENABLED=yes' /etc/ufw/ufw.conf; then
run 'sudo ufw allow punktfunk-native'
run 'sudo ufw allow punktfunk-web'
[ "$GAMESTREAM" = 1 ] && run 'sudo ufw allow punktfunk-gamestream'
[ -n "$MGMT_PORT" ] && run "sudo ufw allow $MGMT_PORT/tcp"
else
ok "no active firewall found — nothing to open ($DOCS/ports if you add one later)"
fi
# ---------------------------------------------------------------------------- 6. start
if [ "$START" = 1 ]; then
say "Starting the host and the web console"
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"
START=0
else
systemctl --user daemon-reload 2>/dev/null
units="punktfunk-host"
systemctl --user list-unit-files punktfunk-web.service 2>/dev/null | grep -q '^punktfunk-web.service' && units="$units punktfunk-web"
# 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
# ---------------------------------------------------------------------------- 7. verify + next
say "Checking"
if [ "$START" = 1 ] && [ "$DRY" != 1 ]; then
sleep 2
if systemctl --user is-active --quiet punktfunk-host; then ok "punktfunk-host is running"
else warn "punktfunk-host is not active — journalctl --user -u punktfunk-host -e ($DOCS/troubleshooting#the-linux-host-service-wont-start)"; fi
if command -v ss >/dev/null 2>&1 && ss -lun 2>/dev/null | grep -q ':9777 '; then ok "listening on UDP 9777 (punktfunk/1)"
else warn "nothing on UDP 9777 yet — give it a second, then: journalctl --user -u punktfunk-host -e"; fi
fi
# GPU drivers are the docs pages' job (one step, per distro) — but the one silent failure worth
# calling out: Fedora + NVIDIA with Fedora's own ffmpeg has no NVENC, and the RPM only Recommends
# RPM Fusion's build, so the install succeeded and encoding won't.
if [ "$FAMILY" = dnf ] && grep -qs 0x10de /sys/bus/pci/devices/*/vendor 2>/dev/null && ! rpm -q ffmpeg-libs >/dev/null 2>&1; then
warn "NVIDIA GPU, but RPM Fusion's ffmpeg-libs isn't installed — NVENC won't work until it is: step 1 of $DOCS_PAGE"
fi
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
[ -n "$ip" ] || ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1); exit}')
cat <<EOF
Done. Next:
1. Open the web console: https://${ip:-<host-ip>}:47992 (the certificate is the host's own — continue past the warning)
password: sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
2. Install a client on the device you stream to ($DOCS/install-client), connect, and click
Approve in the console — or Pair a device for a PIN ($DOCS/pairing).
3. Stream. Ctrl+Alt+Shift+Q hands mouse and keyboard back on desktop clients.
EOF
[ "${RELOGIN:-0}" = 1 ] && echo " Group changes apply after you log out and back in (controllers won't work until then)."
[ "$CONFLICT" = 1 ] && echo " Running next to Sunshine/Apollo: $DOCS/switching-from-sunshine"
echo " Stuck? $DOCS/troubleshooting · this installer is a preview — the full guide is $DOCS_PAGE"
echo