diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 11d44b3..d6ef1a7 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -80,7 +80,7 @@ const QamPanel: FC = () => { {/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's picker (fullscreen page → host row → games button). */} {pins.pins.length > 0 && ( - + {pins.pins.map((pin) => { const { online } = resolvePinHost(pin, hosts); return ( diff --git a/clients/decky/src/library.tsx b/clients/decky/src/library.tsx index aa13fe1..3c4c930 100644 --- a/clients/decky/src/library.tsx +++ b/clients/decky/src/library.tsx @@ -3,13 +3,14 @@ // 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 // library (`--browse`). -import { DialogButton, Field, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui"; -import { CSSProperties, FC, useEffect, useState } from "react"; +import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui"; +import { FC, useEffect, useState } from "react"; import { FaThLarge, FaTv } from "react-icons/fa"; import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend"; import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks"; import { isSafeLaunchId } from "./steam"; import { PairModal } from "./pair"; +import { RowActions, actionButton } from "./ui"; /** Human store tag (mirrors the GTK client's `store_label`). */ 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); } -const pickButton: CSSProperties = { - width: "fit-content", - minWidth: "5em", - flexShrink: 0, -}; - // Copy per backend error code (LibraryResult.error); `detail` covers the generic case. function errorCopy(res: LibraryResult): string { switch (res.error) { @@ -143,16 +138,18 @@ export const GamePickerModal: FC<{ description="Browse this host's games with the controller, full screen" childrenContainerWidth="max" > - { - closeModal?.(); - void startBrowse(host); - }} - > - - Open - + + { + closeModal?.(); + void startBrowse(host); + }} + > + + Open + + {clientUpdatePending && ( @@ -177,10 +174,10 @@ export const GamePickerModal: FC<{ {result !== null && !result.ok && ( - + {result.error === "not-paired" && ( showModal( setAttempt((n) => n + 1)} />) } @@ -188,10 +185,10 @@ export const GamePickerModal: FC<{ Pair )} - setAttempt((n) => n + 1)}> + setAttempt((n) => n + 1)}> Retry - + )} @@ -217,10 +214,12 @@ export const GamePickerModal: FC<{ } childrenContainerWidth="max" > - togglePin(g)}> - - {pinned ? "Unpin" : "Pin"} - + + togglePin(g)}> + + {pinned ? "Unpin" : "Pin"} + + ); })} diff --git a/clients/decky/src/page.tsx b/clients/decky/src/page.tsx index e7ff14b..28ec67f 100644 --- a/clients/decky/src/page.tsx +++ b/clients/decky/src/page.tsx @@ -10,6 +10,7 @@ import { showModal, staticClasses, } from "@decky/ui"; +import { RowActions, actionButton, iconButton } from "./ui"; import { toaster } from "@decky/api"; import { CSSProperties, FC, useState } from "react"; import { @@ -58,27 +59,6 @@ const tabScroll: CSSProperties = { 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 // 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" > - + showModal()} @@ -153,13 +133,13 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ( {/* 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. */} - + Games {needsPair && ( showModal()} > Pair @@ -178,7 +158,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ( Stream - + ); }; @@ -201,14 +181,16 @@ const HostsTab: FC<{ childrenContainerWidth="max" bottomSeparator={hosts.length ? "standard" : "none"} > - - {scanning ? ( - - ) : ( - - )} - {scanning ? "Scanning…" : "Refresh"} - + + + {scanning ? ( + + ) : ( + + )} + {scanning ? "Scanning…" : "Refresh"} + + {hosts.length === 0 && !scanning && ( @@ -251,18 +233,18 @@ const HostsTab: FC<{ }${pin.paired ? "" : " · pairing required"}`} childrenContainerWidth="max" > - + streamPin(pin, hosts, pins)}> Play pins.removePin(pin.host_fp, pin.game_id)} > Remove - + ); })} @@ -306,13 +288,15 @@ const AboutTab: FC<{ } childrenContainerWidth="max" > - void checkForUpdatesNow(check)} - > - {checking ? : "Check for updates"} - + + void checkForUpdatesNow(check)} + > + {checking ? : "Check for updates"} + + {hasUpdate(update) && ( - applyUpdate(update!, check)} - > - - Update - + + applyUpdate(update!, check)}> + + Update + + )} - Navigation.NavigateToExternalWeb(DOCS_URL)} - > - - Open - + + Navigation.NavigateToExternalWeb(DOCS_URL)} + > + + Open + + - void forceStopStream()}> - Force-stop - + + void forceStopStream()}> + Force-stop + + ); @@ -399,16 +386,21 @@ const PunktfunkPage: FC = () => { - {/* overflow:hidden is load-bearing: Valve's Tabs slides the incoming panel in from the - right on L1/R1, and with autoFocusContents it scrollIntoViews a control inside that - still-offscreen panel. Without a clip here the scroll pans #GamepadUI itself — the whole - Steam UI (top bar included) slides left until you click a tab. Valve's own Tabs always - live in a clipped flex box; match that. */} + {/* Two things fight each other on an L1/R1 tab switch: + 1. Valve's Tabs slides the incoming panel in from the right with a CSS transform. + 2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which + fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll + 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. */}
setTab(id)} - autoFocusContents tabs={[ { id: "hosts", diff --git a/clients/decky/src/settings.tsx b/clients/decky/src/settings.tsx index 80a3b1b..de39aef 100644 --- a/clients/decky/src/settings.tsx +++ b/clients/decky/src/settings.tsx @@ -2,8 +2,20 @@ // 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`. 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 { 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][] = [ [0, 0, "Native display"], @@ -61,21 +73,29 @@ export const SettingsSection: FC = () => { description="The host creates a virtual output at exactly this size" childrenContainerWidth="max" > - ({ data: i, label }))} - selectedOption={resIdx} - onChange={(o) => { - const [w, h] = RESOLUTIONS[o.data as number]; - patch({ width: w, height: h }); - }} - /> + +
+ ({ data: i, label }))} + selectedOption={resIdx} + onChange={(o) => { + const [w, h] = RESOLUTIONS[o.data as number]; + patch({ width: w, height: h }); + }} + /> +
+
- ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))} - selectedOption={s.refresh_hz} - onChange={(o) => patch({ refresh_hz: o.data as number })} - /> + +
+ ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))} + selectedOption={s.refresh_hz} + onChange={(o) => patch({ refresh_hz: o.data as number })} + /> +
+
{ description="Which virtual controller the host creates for your inputs" childrenContainerWidth="max" > - ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} - selectedOption={s.gamepad} - onChange={(o) => patch({ gamepad: o.data as string })} - /> + +
+ ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} + selectedOption={s.gamepad} + onChange={(o) => patch({ gamepad: o.data as string })} + /> +
+
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && ( { description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host" childrenContainerWidth="max" > - ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))} - selectedOption={s.compositor} - onChange={(o) => patch({ compositor: o.data as string })} - /> + +
+ ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))} + selectedOption={s.compositor} + onChange={(o) => patch({ compositor: o.data as string })} + /> +
+
= ({ children }) => ( + + {children} + +); + +// 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", +}; diff --git a/docs-site/content/docs/arch.md b/docs-site/content/docs/arch.md new file mode 100644 index 0000000..4ad4287 --- /dev/null +++ b/docs-site/content/docs/arch.md @@ -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://: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). diff --git a/docs-site/content/docs/clients.md b/docs-site/content/docs/clients.md index 89fd9a7..ecea127 100644 --- a/docs-site/content/docs/clients.md +++ b/docs-site/content/docs/clients.md @@ -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. - **Ubuntu / Debian** — `apt install punktfunk-client` from the punktfunk apt 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 connect straight away: diff --git a/docs-site/content/docs/install-client.md b/docs-site/content/docs/install-client.md index b0709ae..eb167d3 100644 --- a/docs-site/content/docs/install-client.md +++ b/docs-site/content/docs/install-client.md @@ -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) | | **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: diff --git a/docs-site/content/docs/install.md b/docs-site/content/docs/install.md index d038e35..8c464c3 100644 --- a/docs-site/content/docs/install.md +++ b/docs-site/content/docs/install.md @@ -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) | | **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) | -| **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) | Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a diff --git a/docs-site/content/docs/meta.json b/docs-site/content/docs/meta.json index 221852f..e8374aa 100644 --- a/docs-site/content/docs/meta.json +++ b/docs-site/content/docs/meta.json @@ -11,6 +11,7 @@ "ubuntu-gnome", "ubuntu-kde", "fedora-kde", + "arch", "bazzite", "steamos-host", "windows-host",