docs: dedicated Arch Linux host+client guide
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled

Every other distro has a full Host Setup page; Arch only had table rows. Add
docs/arch.md (signed pacman binary repo: key import + repo + install, GPU
prereqs, service/linger, web console, client, PKGBUILD appendix), slot it into
the nav after fedora-kde, and point the install/client tables at it. Update the
client-install rows from 'from the PKGBUILD' to the binary repo now that it exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 17:37:01 +00:00
parent 2e6b822fd6
commit 8f90563ffd
10 changed files with 320 additions and 120 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ const QamPanel: FC = () => {
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's {/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
picker (fullscreen page → host row → games button). */} picker (fullscreen page → host row → games button). */}
{pins.pins.length > 0 && ( {pins.pins.length > 0 && (
<PanelSection title="Games"> <PanelSection title="Pinned Games">
{pins.pins.map((pin) => { {pins.pins.map((pin) => {
const { online } = resolvePinHost(pin, hosts); const { online } = resolvePinHost(pin, hosts);
return ( return (
+25 -26
View File
@@ -3,13 +3,14 @@
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in // can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad // the QAM's Games section; its header also launches the GTK client's on-screen gamepad
// library (`--browse`). // library (`--browse`).
import { DialogButton, Field, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui"; import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
import { CSSProperties, FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from "react";
import { FaThLarge, FaTv } from "react-icons/fa"; import { FaThLarge, FaTv } from "react-icons/fa";
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend"; import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks"; import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
import { isSafeLaunchId } from "./steam"; import { isSafeLaunchId } from "./steam";
import { PairModal } from "./pair"; import { PairModal } from "./pair";
import { RowActions, actionButton } from "./ui";
/** Human store tag (mirrors the GTK client's `store_label`). */ /** Human store tag (mirrors the GTK client's `store_label`). */
export function storeLabel(store: string): string { export function storeLabel(store: string): string {
@@ -58,12 +59,6 @@ export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
void startStream(host, { launchId: pin.game_id }, pin.title); void startStream(host, { launchId: pin.game_id }, pin.title);
} }
const pickButton: CSSProperties = {
width: "fit-content",
minWidth: "5em",
flexShrink: 0,
};
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case. // Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
function errorCopy(res: LibraryResult): string { function errorCopy(res: LibraryResult): string {
switch (res.error) { switch (res.error) {
@@ -143,16 +138,18 @@ export const GamePickerModal: FC<{
description="Browse this host's games with the controller, full screen" description="Browse this host's games with the controller, full screen"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton <RowActions>
style={pickButton} <DialogButton
onClick={() => { style={actionButton}
closeModal?.(); onClick={() => {
void startBrowse(host); closeModal?.();
}} void startBrowse(host);
> }}
<FaTv style={{ marginRight: "0.4em" }} /> >
Open <FaTv style={{ marginRight: "0.4em" }} />
</DialogButton> Open
</DialogButton>
</RowActions>
</Field> </Field>
{clientUpdatePending && ( {clientUpdatePending && (
@@ -177,10 +174,10 @@ export const GamePickerModal: FC<{
{result !== null && !result.ok && ( {result !== null && !result.ok && (
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max"> <Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}> <RowActions>
{result.error === "not-paired" && ( {result.error === "not-paired" && (
<DialogButton <DialogButton
style={pickButton} style={actionButton}
onClick={() => onClick={() =>
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />) showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
} }
@@ -188,10 +185,10 @@ export const GamePickerModal: FC<{
Pair Pair
</DialogButton> </DialogButton>
)} )}
<DialogButton style={pickButton} onClick={() => setAttempt((n) => n + 1)}> <DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
Retry Retry
</DialogButton> </DialogButton>
</Focusable> </RowActions>
</Field> </Field>
)} )}
@@ -217,10 +214,12 @@ export const GamePickerModal: FC<{
} }
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}> <RowActions>
<FaThLarge style={{ marginRight: "0.4em" }} /> <DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
{pinned ? "Unpin" : "Pin"} <FaThLarge style={{ marginRight: "0.4em" }} />
</DialogButton> {pinned ? "Unpin" : "Pin"}
</DialogButton>
</RowActions>
</Field> </Field>
); );
})} })}
+58 -66
View File
@@ -10,6 +10,7 @@ import {
showModal, showModal,
staticClasses, staticClasses,
} from "@decky/ui"; } from "@decky/ui";
import { RowActions, actionButton, iconButton } from "./ui";
import { toaster } from "@decky/api"; import { toaster } from "@decky/api";
import { CSSProperties, FC, useState } from "react"; import { CSSProperties, FC, useState } from "react";
import { import {
@@ -58,27 +59,6 @@ const tabScroll: CSSProperties = {
boxSizing: "border-box", boxSizing: "border-box",
}; };
// DialogButton stretches to 100% width in the gamepad UI — on a fullscreen row that means a
// screen-wide button. Size action buttons to their content instead (right-aligned by the
// Field's children container).
const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "6em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow) — needs an explicit height too, or
// the zero padding collapses it to the icon's line height.
const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check // Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
// against the host's own log / web console before trusting it. // against the host's own log / web console before trusting it.
@@ -144,7 +124,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
}`} }`}
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}> <RowActions>
<DialogButton <DialogButton
style={iconButton} style={iconButton}
onClick={() => showModal(<HostDetailsModal host={host} />)} onClick={() => showModal(<HostDetailsModal host={host} />)}
@@ -153,13 +133,13 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
</DialogButton> </DialogButton>
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen {/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
library browser, and controller nav has no hover tooltip to explain a bare icon. */} library browser, and controller nav has no hover tooltip to explain a bare icon. */}
<DialogButton style={{ ...actionButton, minWidth: "6em" }} onClick={onGames}> <DialogButton style={actionButton} onClick={onGames}>
<FaThLarge style={{ marginRight: "0.4em" }} /> <FaThLarge style={{ marginRight: "0.4em" }} />
Games Games
</DialogButton> </DialogButton>
{needsPair && ( {needsPair && (
<DialogButton <DialogButton
style={{ ...actionButton, minWidth: "5em" }} style={actionButton}
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)} onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
> >
Pair Pair
@@ -178,7 +158,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
<FaPlay style={{ marginRight: "0.4em" }} /> <FaPlay style={{ marginRight: "0.4em" }} />
Stream Stream
</DialogButton> </DialogButton>
</Focusable> </RowActions>
</Field> </Field>
); );
}; };
@@ -201,14 +181,16 @@ const HostsTab: FC<{
childrenContainerWidth="max" childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"} bottomSeparator={hosts.length ? "standard" : "none"}
> >
<DialogButton style={{ ...actionButton, minWidth: "8em" }} disabled={scanning} onClick={refresh}> <RowActions>
{scanning ? ( <DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
<Spinner style={{ height: "1em", marginRight: "0.5em" }} /> {scanning ? (
) : ( <Spinner style={{ height: "1em", marginRight: "0.5em" }} />
<FaSyncAlt style={{ marginRight: "0.5em" }} /> ) : (
)} <FaSyncAlt style={{ marginRight: "0.5em" }} />
{scanning ? "Scanning…" : "Refresh"} )}
</DialogButton> {scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</RowActions>
</Field> </Field>
{hosts.length === 0 && !scanning && ( {hosts.length === 0 && !scanning && (
@@ -251,18 +233,18 @@ const HostsTab: FC<{
}${pin.paired ? "" : " · pairing required"}`} }${pin.paired ? "" : " · pairing required"}`}
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}> <RowActions>
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}> <DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}>
<FaPlay style={{ marginRight: "0.4em" }} /> <FaPlay style={{ marginRight: "0.4em" }} />
Play Play
</DialogButton> </DialogButton>
<DialogButton <DialogButton
style={{ ...actionButton, minWidth: "5em" }} style={actionButton}
onClick={() => pins.removePin(pin.host_fp, pin.game_id)} onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
> >
Remove Remove
</DialogButton> </DialogButton>
</Focusable> </RowActions>
</Field> </Field>
); );
})} })}
@@ -306,13 +288,15 @@ const AboutTab: FC<{
} }
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton <RowActions>
style={{ ...actionButton, minWidth: "11em" }} <DialogButton
disabled={checking} style={actionButton}
onClick={() => void checkForUpdatesNow(check)} disabled={checking}
> onClick={() => void checkForUpdatesNow(check)}
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"} >
</DialogButton> {checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
</DialogButton>
</RowActions>
</Field> </Field>
{hasUpdate(update) && ( {hasUpdate(update) && (
<Field <Field
@@ -326,13 +310,12 @@ const AboutTab: FC<{
description="Installing can take a couple of minutes; Decky reloads the plugin when done" description="Installing can take a couple of minutes; Decky reloads the plugin when done"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton <RowActions>
style={{ ...actionButton, minWidth: "9em" }} <DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
onClick={() => applyUpdate(update!, check)} <FaDownload style={{ marginRight: "0.4em" }} />
> Update
<FaDownload style={{ marginRight: "0.4em" }} /> </DialogButton>
Update </RowActions>
</DialogButton>
</Field> </Field>
)} )}
<Field <Field
@@ -340,13 +323,15 @@ const AboutTab: FC<{
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io" description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton <RowActions>
style={{ ...actionButton, minWidth: "8em" }} <DialogButton
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)} style={actionButton}
> onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} /> >
Open <FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
</DialogButton> Open
</DialogButton>
</RowActions>
</Field> </Field>
<Field <Field
focusable={false} focusable={false}
@@ -358,9 +343,11 @@ const AboutTab: FC<{
description="Force-stop the stream client if a session wedges" description="Force-stop the stream client if a session wedges"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton style={{ ...actionButton, minWidth: "8em" }} onClick={() => void forceStopStream()}> <RowActions>
Force-stop <DialogButton style={actionButton} onClick={() => void forceStopStream()}>
</DialogButton> Force-stop
</DialogButton>
</RowActions>
</Field> </Field>
</div> </div>
); );
@@ -399,16 +386,21 @@ const PunktfunkPage: FC = () => {
</div> </div>
</Focusable> </Focusable>
{/* overflow:hidden is load-bearing: Valve's Tabs slides the incoming panel in from the {/* Two things fight each other on an L1/R1 tab switch:
right on L1/R1, and with autoFocusContents it scrollIntoViews a control inside that 1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
still-offscreen panel. Without a clip here the scroll pans #GamepadUI itself — the whole 2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
Steam UI (top bar included) slides left until you click a tab. Valve's own Tabs always fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
live in a clipped flex box; match that. */} position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
up and pans the whole page — the "screen jumps right, then animates back" glitch.
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
focus is anywhere inside — including the tab strip); after a switch, focus stays on the
strip and Down enters the content, which is how Steam's own tabbed pages behave.
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}> <div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<Tabs <Tabs
activeTab={tab} activeTab={tab}
onShowTab={(id: string) => setTab(id)} onShowTab={(id: string) => setTab(id)}
autoFocusContents
tabs={[ tabs={[
{ {
id: "hosts", id: "hosts",
+52 -24
View File
@@ -2,8 +2,20 @@
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The // the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`. // accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui"; import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
import { FC, useEffect, useState } from "react"; import { CSSProperties, FC, useEffect, useState } from "react";
import { getSettings, setSettings, StreamSettings } from "./backend"; import { getSettings, setSettings, StreamSettings } from "./backend";
import { RowActions } from "./ui";
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
// edge. Matches the right-aligned, content-sized buttons everywhere else.
const selectShell: CSSProperties = {
width: "fit-content",
minWidth: "10em",
maxWidth: "24em",
};
const RESOLUTIONS: [number, number, string][] = [ const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"], [0, 0, "Native display"],
@@ -61,21 +73,29 @@ export const SettingsSection: FC = () => {
description="The host creates a virtual output at exactly this size" description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Dropdown <RowActions>
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))} <div style={selectShell}>
selectedOption={resIdx} <Dropdown
onChange={(o) => { rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
const [w, h] = RESOLUTIONS[o.data as number]; selectedOption={resIdx}
patch({ width: w, height: h }); onChange={(o) => {
}} const [w, h] = RESOLUTIONS[o.data as number];
/> patch({ width: w, height: h });
}}
/>
</div>
</RowActions>
</Field> </Field>
<Field label="Refresh rate" childrenContainerWidth="max"> <Field label="Refresh rate" childrenContainerWidth="max">
<Dropdown <RowActions>
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))} <div style={selectShell}>
selectedOption={s.refresh_hz} <Dropdown
onChange={(o) => patch({ refresh_hz: o.data as number })} rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
/> selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</div>
</RowActions>
</Field> </Field>
<SliderField <SliderField
label="Bitrate" label="Bitrate"
@@ -93,11 +113,15 @@ export const SettingsSection: FC = () => {
description="Which virtual controller the host creates for your inputs" description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Dropdown <RowActions>
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} <div style={selectShell}>
selectedOption={s.gamepad} <Dropdown
onChange={(o) => patch({ gamepad: o.data as string })} rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
/> selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</div>
</RowActions>
</Field> </Field>
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && ( {(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field <Field
@@ -110,11 +134,15 @@ export const SettingsSection: FC = () => {
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host" description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Dropdown <RowActions>
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))} <div style={selectShell}>
selectedOption={s.compositor} <Dropdown
onChange={(o) => patch({ compositor: o.data as string })} rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
/> selectedOption={s.compositor}
onChange={(o) => patch({ compositor: o.data as string })}
/>
</div>
</RowActions>
</Field> </Field>
<ToggleField <ToggleField
label="Stream microphone" label="Stream microphone"
+46
View File
@@ -0,0 +1,46 @@
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
// them and the label text — never hugging it.
//
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
import { Focusable } from "@decky/ui";
import { CSSProperties, FC, ReactNode } from "react";
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
<Focusable
style={{
display: "flex",
gap: "0.5em",
justifyContent: "flex-end",
alignItems: "center",
}}
>
{children}
</Focusable>
);
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
// reads at the same weight.
export const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "7em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
// padding collapses it to the icon's line height.
export const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
+134
View File
@@ -0,0 +1,134 @@
---
title: Arch Linux
description: Install a punktfunk host on Arch (and Arch-derived distros) from the signed pacman binary repo.
---
Set up a punktfunk host on **Arch Linux** (or an Arch-derived distro like CachyOS/EndeavourOS). The
host installs from a **signed pacman binary repo**, so it updates with `pacman -Syu` like the rest
of your system — no building required. Host encode is **NVENC on NVIDIA** and **VAAPI on
AMD/Intel** (`PUNKTFUNK_ENCODER=auto` picks per GPU).
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
> Prefer to build it yourself? A split `PKGBUILD` (host + client + optional web console) is in the
> repo at `packaging/arch/` — see the [appendix](#appendix--build-from-source-pkgbuild). The binary
> repo below is the supported path.
## 1. GPU prerequisites
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils` (provides NVENC + the EGL/CUDA zero-copy path).
Arch's stock `ffmpeg` already has NVENC built in — no RPM-Fusion-style swap like Fedora needs.
- **AMD / Intel:** the Mesa stack (`mesa`, `libva-mesa-driver` for AMD, `intel-media-driver` for
Intel) provides the VAAPI encoder — usually already installed on a desktop.
## 2. Add the signed repo
The registry **signs its database and every package**, so first trust its key once (after this,
packages install signature-verified):
```sh
# Trust the registry signing key.
curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
| sudo pacman-key --add -
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
# Add the repo (append to /etc/pacman.conf). No SigLevel line needed — pacman's default
# verifies signed packages against the key you just trusted.
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
[punktfunk]
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
EOF
```
> **Stable vs canary.** `[punktfunk]` is the **stable** channel — it moves only when a `vX.Y.Z`
> release is cut. For the latest `main` build, use `[punktfunk-canary]` instead (same `Server` line,
> just the repo name). Enable exactly one. See [Release Channels](/docs/channels).
## 3. Install the host
```sh
sudo pacman -Sy punktfunk-host # the streaming host
sudo pacman -S punktfunk-web # optional: the browser management console (pairing + status)
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
```
`punktfunk-client` (the GTK4 couch/Deck client) is in the same repo if this box is also a client.
The host package ships the systemd **user** units, the udev rule, the UDP socket-buffer sysctl
tuning, and example configs. Updates later are just `sudo pacman -Syu`.
## 4. Configure and run
The host runs as a systemd **`--user`** service — it needs your session's PipeWire and D-Bus.
Copy a starting config, enable the service, and enable linger so it starts at boot without a login:
```sh
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env # then edit
systemctl --user daemon-reload
systemctl --user enable --now punktfunk-host
sudo loginctl enable-linger "$USER"
```
Which compositor the host captures depends on your desktop — it drives a per-client virtual output
via KWin (Plasma), Mutter (GNOME), or wlroots (Sway), or spawns a headless **gamescope** session
per connect. For a headless appliance, the package also ships `punktfunk-kde-session.service`
(a dedicated `kwin --virtual` session, same as the [Fedora KDE](/docs/fedora-kde#3-kwin-streaming-session)
guide — `cp /usr/share/punktfunk/host.env.kde ~/.config/punktfunk/host.env` and enable it alongside
the host). See [Configuration](/docs/configuration) for every knob and
[Running as a Service](/docs/running-as-a-service) for the service model.
Check it came up:
```sh
systemctl --user status punktfunk-host # active
journalctl --user -u punktfunk-host -f # watch a client connect
```
### Web console
The console (status, paired devices, arm pairing) ships as `punktfunk-web` — enable it, then open
`http://<host-ip>:47992`:
```sh
systemctl --user enable --now punktfunk-web
```
#### Console login password
On first start `punktfunk-web-init` generates a random login password and saves it to
`~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it back at any time:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own, edit that file and `systemctl --user restart punktfunk-web`. Forgot it? See
[Forgot your Password?](/docs/forgot-password).
## 5. Connect a client
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
the **PIN pairing** — arm it from the host's web console, which displays a 4-digit PIN to type into
the client. (Pairing is required by default; pass `serve --open` only if you deliberately want to
disable it.) See [Clients](/docs/clients) and [Pairing](/docs/pairing).
## Appendix — build from source (PKGBUILD)
To build instead of using the binary repo, use the split `PKGBUILD` in `packaging/arch/` (produces
`punktfunk-host` + `punktfunk-client`; set `PF_WITH_WEB=1` to also build `punktfunk-web`, which needs
`bun`):
```sh
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk/packaging/arch
# Build the working tree (no git fetch):
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
sudo pacman -U punktfunk-host-*.pkg.tar.zst
```
NVENC/EGL come from the NVIDIA driver (`nvidia-utils`); on a GPU-less builder, symlink the CUDA
stub into the link path first (the `PKGBUILD` header documents this). Full details, the
Fedora→Arch dependency map, and the SteamOS systemd-sysext path are in
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md).
+1 -1
View File
@@ -47,7 +47,7 @@ It ships as a real package, not just a source build — full steps in
`flatpak update`; this is also what the [Decky plugin](/docs/steam-deck) launches. `flatpak update`; this is also what the [Decky plugin](/docs/steam-deck) launches.
- **Ubuntu / Debian** — `apt install punktfunk-client` from the punktfunk apt registry. - **Ubuntu / Debian** — `apt install punktfunk-client` from the punktfunk apt registry.
- **Fedora / Bazzite** — `rpm-ostree install punktfunk-client` from the Gitea RPM registry. - **Fedora / Bazzite** — `rpm-ostree install punktfunk-client` from the Gitea RPM registry.
- **Arch / SteamOS** — the `punktfunk-client` split package from the `PKGBUILD`. - **Arch** — `sudo pacman -Sy punktfunk-client` from the signed binary repo (see [Arch Linux](/docs/arch)).
Launch it, pick your host from the list, and stream. For scripting you can skip the host list and Launch it, pick your host from the list, and stream. For scripting you can skip the host list and
connect straight away: connect straight away:
+1 -1
View File
@@ -48,7 +48,7 @@ see the linked guide — then it tracks updates with your normal `apt upgrade` /
|--------|---------|-------| |--------|---------|-------|
| **Ubuntu / Debian** | `sudo apt install punktfunk-client` | [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) | | **Ubuntu / Debian** | `sudo apt install punktfunk-client` | [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Fedora / Bazzite** | `rpm-ostree install punktfunk-client` | [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) | | **Fedora / Bazzite** | `rpm-ostree install punktfunk-client` | [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
| **Arch / SteamOS** | `punktfunk-client` from the `PKGBUILD` | [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) | | **Arch** | `sudo pacman -Sy punktfunk-client` (signed binary repo) | [Arch Linux](/docs/arch) |
Then launch it, pick your host from the list, and stream. For scripting, skip the picker: Then launch it, pick your host from the list, and stream. For scripting, skip the picker:
+1 -1
View File
@@ -19,7 +19,7 @@ On **Windows**, the host ships as a signed installer instead — see [Windows](#
| **Ubuntu / Debian** | apt | `sudo apt install punktfunk-host` | [Ubuntu — GNOME](/docs/ubuntu-gnome) · [Ubuntu — KDE](/docs/ubuntu-kde) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) | | **Ubuntu / Debian** | apt | `sudo apt install punktfunk-host` | [Ubuntu — GNOME](/docs/ubuntu-gnome) · [Ubuntu — KDE](/docs/ubuntu-kde) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** | systemd-sysext | `sudo bash punktfunk-sysext.sh install` (no layering, no reboot) | [Bazzite](/docs/bazzite) · [packaging/bazzite](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/bazzite/README.md) | | **Bazzite / Fedora Atomic** | systemd-sysext | `sudo bash punktfunk-sysext.sh install` (no layering, no reboot) | [Bazzite](/docs/bazzite) · [packaging/bazzite](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/bazzite/README.md) |
| **Fedora (dnf)** | dnf / rpm-ostree | `dnf install punktfunk punktfunk-web` | [Fedora — KDE](/docs/fedora-kde) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) | | **Fedora (dnf)** | dnf / rpm-ostree | `dnf install punktfunk punktfunk-web` | [Fedora — KDE](/docs/fedora-kde) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
| **Arch** | pacman | `pacman -Sy punktfunk-host` (binary repo) | [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) | | **Arch** | pacman | `pacman -Sy punktfunk-host` (binary repo) | [Arch Linux](/docs/arch) · [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
| **SteamOS (host)** | on-device script | `bash scripts/steamdeck/install.sh` | [SteamOS (Host)](/docs/steamos-host) | | **SteamOS (host)** | on-device script | `bash scripts/steamdeck/install.sh` | [SteamOS (Host)](/docs/steamos-host) |
Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a
+1
View File
@@ -11,6 +11,7 @@
"ubuntu-gnome", "ubuntu-gnome",
"ubuntu-kde", "ubuntu-kde",
"fedora-kde", "fedora-kde",
"arch",
"bazzite", "bazzite",
"steamos-host", "steamos-host",
"windows-host", "windows-host",