Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 202f40fd4e | |||
| 8f90563ffd | |||
| 2e6b822fd6 | |||
| f7c5314b5e | |||
| d6669fc3fb | |||
| e292084225 | |||
| c758b0393a | |||
| d6a659a1ee | |||
| 2190dad2ad | |||
| 5b5ec15ead | |||
| c9ff144492 | |||
| 7930d2f0f4 |
@@ -0,0 +1,142 @@
|
||||
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
|
||||
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
|
||||
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
|
||||
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
|
||||
# resolves today — the same sonames an up-to-date Arch box runs.
|
||||
#
|
||||
# Registry (public, unom org) — box setup (once), see packaging/arch/README.md. The registry
|
||||
# SIGNS the DB + packages, so the box imports the registry key first (pacman-key --add +
|
||||
# --lsign-key), then no SigLevel line is needed (pacman's default Required verifies):
|
||||
# [punktfunk] # or [punktfunk-canary] for main-push builds
|
||||
# Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
|
||||
# NOTE: this token + the registry-held private key are the trust root — a token holder can
|
||||
# publish a validly-signed package (the signature attests "via the registry", not "built by CI").
|
||||
name: arch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the
|
||||
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
|
||||
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: docker.io/library/archlinux:base-devel
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_HOME: /usr/local/cargo
|
||||
steps:
|
||||
# git + nodejs must exist before actions/checkout — base-devel ships neither, and
|
||||
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
|
||||
- name: Install build + runtime-dev deps
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed \
|
||||
git nodejs rust clang cmake nasm pkgconf python \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive
|
||||
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
||||
# it's AUR-only on Arch, so bootstrap the official binary.
|
||||
command -v bun >/dev/null || {
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||
}
|
||||
bun --version
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Cache cargo's git dir too, not just the registry: the workspace includes
|
||||
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
|
||||
# them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows
|
||||
# is ever compiled here. Cached, that cost is paid once per runner.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-arch-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-arch-
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> <next-minor>-0.<run#> in
|
||||
# `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the
|
||||
# monotonic ordering; the commit sha is stamped into the binary via the workflow log).
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.${GITHUB_RUN_NUMBER}"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
|
||||
echo "REPO=$REPO" >> "$GITHUB_ENV"
|
||||
echo "pacman $V-$R -> repo '$REPO'"
|
||||
|
||||
- name: Build packages (makepkg)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# libcuda link stub — same trick as packaging/rpm/build-rpm.sh: the zerocopy FFI
|
||||
# links -lcuda but the builder has no GPU; synthesize every cu* symbol the source
|
||||
# references so a newly-added call can't silently break the link.
|
||||
CU_SYMS="$(grep -rhoE '\bcu[A-Z][A-Za-z0-9_]*' crates/punktfunk-host/src/ | sort -u || true)"
|
||||
if [ -n "$CU_SYMS" ] && [ ! -e /usr/lib/libcuda.so ]; then
|
||||
STUB_C="$(mktemp --suffix=.c)"
|
||||
for s in $CU_SYMS; do printf 'int %s(void){return 0;}\n' "$s" >> "$STUB_C"; done
|
||||
gcc -shared -fPIC -Wl,-soname,libcuda.so.1 -o /usr/lib/libcuda.so.1 "$STUB_C"
|
||||
ln -sf libcuda.so.1 /usr/lib/libcuda.so
|
||||
rm -f "$STUB_C"; ldconfig
|
||||
echo "== libcuda stub: $(printf '%s\n' "$CU_SYMS" | wc -l) symbols =="
|
||||
fi
|
||||
# makepkg refuses to run as root; deps are already installed above (-d skips the
|
||||
# RPM-level check that can't see the script-installed bun anyway).
|
||||
useradd -m builder
|
||||
mkdir -p "$CARGO_HOME" # actions/cache doesn't create it on a cache miss
|
||||
chown -R builder: "$PWD" "$CARGO_HOME"
|
||||
sudo -u builder git config --global --add safe.directory "$PWD"
|
||||
mkdir -p dist && chown builder: dist
|
||||
cd packaging/arch
|
||||
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
|
||||
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
||||
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
|
||||
- name: Publish to the Gitea Arch registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
echo "uploading $pkg"
|
||||
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
|
||||
VER=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p')
|
||||
ARCH=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^arch = //p')
|
||||
# A re-tagged release re-fires this workflow and the registry 409s on duplicate
|
||||
# package versions — delete any prior copy first (404 on the first publish is fine).
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO/$NAME/$VER/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$pkg" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO"
|
||||
done
|
||||
echo "published to $OWNER/arch/$REPO"
|
||||
|
||||
# On a real release, also attach the packages to the unified Gitea Release.
|
||||
- name: Attach packages to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
upsert_asset "$RID" "$pkg"
|
||||
done
|
||||
@@ -35,8 +35,10 @@ jobs:
|
||||
include:
|
||||
- image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base
|
||||
group: bazzite
|
||||
fedver: 43
|
||||
- image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin
|
||||
group: fedora-44
|
||||
fedver: 44
|
||||
container:
|
||||
image: git.unom.io/unom/${{ matrix.image }}:latest
|
||||
timeout-minutes: 90
|
||||
@@ -53,6 +55,8 @@ jobs:
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
||||
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
|
||||
command -v bun >/dev/null || {
|
||||
@@ -117,6 +121,27 @@ jobs:
|
||||
done
|
||||
echo "published to $OWNER/rpm/$GROUP"
|
||||
|
||||
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
||||
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
||||
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
||||
- name: Build the sysext image
|
||||
run: |
|
||||
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
||||
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
||||
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
|
||||
- name: Publish the sysext feed
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
case "$GROUP" in
|
||||
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up
|
||||
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release
|
||||
esac
|
||||
KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \
|
||||
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
|
||||
|
||||
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
|
||||
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
|
||||
# both on the release; canary builds live in the `*-canary` rpm groups (no release page).
|
||||
@@ -132,3 +157,6 @@ jobs:
|
||||
base="$(basename "$rpm" .rpm)"
|
||||
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
|
||||
done
|
||||
for raw in dist-sysext/*.raw; do
|
||||
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
|
||||
done
|
||||
|
||||
Generated
+9
-9
@@ -2129,7 +2129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2908,7 +2908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2922,7 +2922,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2945,7 +2945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2968,7 +2968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2999,7 +2999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3071,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3085,7 +3085,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ members = [
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -83,8 +83,9 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
| Platform | Install | Guide |
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
||||
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
||||
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
|
||||
@@ -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 && (
|
||||
<PanelSection title="Games">
|
||||
<PanelSection title="Pinned Games">
|
||||
{pins.pins.map((pin) => {
|
||||
const { online } = resolvePinHost(pin, hosts);
|
||||
return (
|
||||
|
||||
@@ -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,8 +138,9 @@ export const GamePickerModal: FC<{
|
||||
description="Browse this host's games with the controller, full screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={pickButton}
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
void startBrowse(host);
|
||||
@@ -153,6 +149,7 @@ export const GamePickerModal: FC<{
|
||||
<FaTv style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{clientUpdatePending && (
|
||||
@@ -177,10 +174,10 @@ export const GamePickerModal: FC<{
|
||||
|
||||
{result !== null && !result.ok && (
|
||||
<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" && (
|
||||
<DialogButton
|
||||
style={pickButton}
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
|
||||
}
|
||||
@@ -188,10 +185,10 @@ export const GamePickerModal: FC<{
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={pickButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||
<DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||
Retry
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
@@ -217,10 +214,12 @@ export const GamePickerModal: FC<{
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
{pinned ? "Unpin" : "Pin"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
|
||||
+34
-42
@@ -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"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={iconButton}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} />)}
|
||||
@@ -153,13 +133,13 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
||||
</DialogButton>
|
||||
{/* 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. */}
|
||||
<DialogButton style={{ ...actionButton, minWidth: "6em" }} onClick={onGames}>
|
||||
<DialogButton style={actionButton} onClick={onGames}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
Games
|
||||
</DialogButton>
|
||||
{needsPair && (
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "5em" }}
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
|
||||
>
|
||||
Pair
|
||||
@@ -178,7 +158,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
@@ -201,7 +181,8 @@ const HostsTab: FC<{
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<DialogButton style={{ ...actionButton, minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
@@ -209,6 +190,7 @@ const HostsTab: FC<{
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
@@ -251,18 +233,18 @@ const HostsTab: FC<{
|
||||
}${pin.paired ? "" : " · pairing required"}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Play
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "5em" }}
|
||||
style={actionButton}
|
||||
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
|
||||
>
|
||||
Remove
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
@@ -306,13 +288,15 @@ const AboutTab: FC<{
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "11em" }}
|
||||
style={actionButton}
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{hasUpdate(update) && (
|
||||
<Field
|
||||
@@ -326,13 +310,12 @@ const AboutTab: FC<{
|
||||
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "9em" }}
|
||||
onClick={() => applyUpdate(update!, check)}
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
@@ -340,13 +323,15 @@ const AboutTab: FC<{
|
||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "8em" }}
|
||||
style={actionButton}
|
||||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||||
>
|
||||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
@@ -358,9 +343,11 @@ const AboutTab: FC<{
|
||||
description="Force-stop the stream client if a session wedges"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton style={{ ...actionButton, minWidth: "8em" }} onClick={() => void forceStopStream()}>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => void forceStopStream()}>
|
||||
Force-stop
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
@@ -399,16 +386,21 @@ const PunktfunkPage: FC = () => {
|
||||
</div>
|
||||
</Focusable>
|
||||
|
||||
{/* 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. */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
autoFocusContents
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
|
||||
@@ -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,6 +73,8 @@ export const SettingsSection: FC = () => {
|
||||
description="The host creates a virtual output at exactly this size"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
|
||||
selectedOption={resIdx}
|
||||
@@ -69,13 +83,19 @@ export const SettingsSection: FC = () => {
|
||||
patch({ width: w, height: h });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
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>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
@@ -93,11 +113,15 @@ export const SettingsSection: FC = () => {
|
||||
description="Which virtual controller the host creates for your inputs"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
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>
|
||||
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<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"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
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>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -30,15 +30,17 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
// --- EGL_EXT_image_dma_buf_import(+_modifiers) constants (khronos-egl exposes none) ------
|
||||
const EGL_LINUX_DMA_BUF_EXT: egl::Enum = 0x3270;
|
||||
const EGL_LINUX_DRM_FOURCC_EXT: usize = 0x3271;
|
||||
const EGL_DMA_BUF_PLANE0_FD_EXT: usize = 0x3272;
|
||||
const EGL_DMA_BUF_PLANE0_OFFSET_EXT: usize = 0x3273;
|
||||
const EGL_DMA_BUF_PLANE0_PITCH_EXT: usize = 0x3274;
|
||||
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: usize = 0x3443;
|
||||
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: usize = 0x3444;
|
||||
const EGL_WIDTH: usize = 0x3057;
|
||||
const EGL_HEIGHT: usize = 0x3056;
|
||||
const EGL_NONE: usize = 0x3038;
|
||||
// eglCreateImageKHR takes 32-bit EGLint attribs (the core-1.5 eglCreateImage variant is the
|
||||
// one with pointer-sized EGLAttrib) — using the wrong width feeds the driver garbage pairs.
|
||||
const EGL_LINUX_DRM_FOURCC_EXT: i32 = 0x3271;
|
||||
const EGL_DMA_BUF_PLANE0_FD_EXT: i32 = 0x3272;
|
||||
const EGL_DMA_BUF_PLANE0_OFFSET_EXT: i32 = 0x3273;
|
||||
const EGL_DMA_BUF_PLANE0_PITCH_EXT: i32 = 0x3274;
|
||||
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: i32 = 0x3443;
|
||||
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: i32 = 0x3444;
|
||||
const EGL_WIDTH: i32 = 0x3057;
|
||||
const EGL_HEIGHT: i32 = 0x3056;
|
||||
const EGL_NONE: i32 = 0x3038;
|
||||
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
|
||||
|
||||
/// `fourcc('N','V','1','2')` — the only decoder output today (8-bit 4:2:0). P010 joins when
|
||||
@@ -140,7 +142,7 @@ type EglCreateImageKhr = unsafe extern "C" fn(
|
||||
*mut c_void, // EGLContext (EGL_NO_CONTEXT for dmabuf)
|
||||
egl::Enum,
|
||||
*mut c_void, // EGLClientBuffer (null for dmabuf)
|
||||
*const usize,
|
||||
*const i32, // EGLint attrib list (KHR variant — NOT pointer-sized EGLAttrib)
|
||||
) -> *const c_void;
|
||||
type EglDestroyImageKhr = unsafe extern "C" fn(*mut c_void, *const c_void) -> egl::Boolean;
|
||||
|
||||
@@ -464,24 +466,24 @@ impl GlConverter {
|
||||
) -> Result<*const c_void> {
|
||||
let mut attribs = vec![
|
||||
EGL_WIDTH,
|
||||
width as usize,
|
||||
width as i32,
|
||||
EGL_HEIGHT,
|
||||
height as usize,
|
||||
height as i32,
|
||||
EGL_LINUX_DRM_FOURCC_EXT,
|
||||
fourcc as usize,
|
||||
fourcc as i32,
|
||||
EGL_DMA_BUF_PLANE0_FD_EXT,
|
||||
plane.fd as usize,
|
||||
plane.fd,
|
||||
EGL_DMA_BUF_PLANE0_OFFSET_EXT,
|
||||
plane.offset as usize,
|
||||
plane.offset as i32,
|
||||
EGL_DMA_BUF_PLANE0_PITCH_EXT,
|
||||
plane.stride as usize,
|
||||
plane.stride as i32,
|
||||
];
|
||||
if modifier != DRM_FORMAT_MOD_INVALID && modifier != 0 {
|
||||
attribs.extend_from_slice(&[
|
||||
EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT,
|
||||
(modifier & 0xffff_ffff) as usize,
|
||||
(modifier & 0xffff_ffff) as u32 as i32,
|
||||
EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT,
|
||||
(modifier >> 32) as usize,
|
||||
(modifier >> 32) as u32 as i32,
|
||||
]);
|
||||
}
|
||||
attribs.push(EGL_NONE);
|
||||
@@ -497,12 +499,12 @@ impl GlConverter {
|
||||
};
|
||||
if img.is_null() {
|
||||
bail!(
|
||||
"eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:#x}",
|
||||
"eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:?}",
|
||||
width,
|
||||
height,
|
||||
fourcc,
|
||||
modifier,
|
||||
self.egl.get_error().map(|e| e as u32).unwrap_or(0)
|
||||
self.egl.get_error()
|
||||
);
|
||||
}
|
||||
Ok(img)
|
||||
|
||||
@@ -412,7 +412,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&Hello {
|
||||
abi_version: punktfunk_core::ABI_VERSION,
|
||||
abi_version: punktfunk_core::WIRE_VERSION,
|
||||
mode: args.mode,
|
||||
compositor: args.compositor,
|
||||
gamepad: args.gamepad,
|
||||
|
||||
@@ -876,7 +876,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&Hello {
|
||||
abi_version: crate::ABI_VERSION,
|
||||
abi_version: crate::WIRE_VERSION,
|
||||
mode,
|
||||
compositor,
|
||||
gamepad,
|
||||
|
||||
@@ -54,3 +54,11 @@ pub use stats::Stats;
|
||||
/// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach
|
||||
/// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
|
||||
pub const ABI_VERSION: u32 = 3;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
/// (functions a client links), which can grow without changing a single wire byte — v3's
|
||||
/// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
|
||||
/// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
|
||||
/// live). Bump this ONLY when the handshake/planes actually change incompatibly.
|
||||
pub const WIRE_VERSION: u32 = 2;
|
||||
|
||||
@@ -585,10 +585,10 @@ async fn serve_session(
|
||||
// the `handshake` future re-decodes for the real session — a few dozen bytes, negligible.
|
||||
let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||
anyhow::ensure!(
|
||||
gate_hello.abi_version == punktfunk_core::ABI_VERSION,
|
||||
"ABI mismatch: client {} host {}",
|
||||
gate_hello.abi_version == punktfunk_core::WIRE_VERSION,
|
||||
"wire version mismatch: client {} host {}",
|
||||
gate_hello.abi_version,
|
||||
punktfunk_core::ABI_VERSION
|
||||
punktfunk_core::WIRE_VERSION
|
||||
);
|
||||
let fp = endpoint::peer_fingerprint(&conn);
|
||||
let known = fp
|
||||
@@ -654,10 +654,10 @@ async fn serve_session(
|
||||
let handshake = async {
|
||||
let hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||
anyhow::ensure!(
|
||||
hello.abi_version == punktfunk_core::ABI_VERSION,
|
||||
"ABI mismatch: client {} host {}",
|
||||
hello.abi_version == punktfunk_core::WIRE_VERSION,
|
||||
"wire version mismatch: client {} host {}",
|
||||
hello.abi_version,
|
||||
punktfunk_core::ABI_VERSION
|
||||
punktfunk_core::WIRE_VERSION
|
||||
);
|
||||
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
|
||||
// before this future, so a client reaching here is paired (or the host is `--open`).
|
||||
@@ -805,7 +805,7 @@ async fn serve_session(
|
||||
let mut key = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
let welcome = Welcome {
|
||||
abi_version: punktfunk_core::ABI_VERSION,
|
||||
abi_version: punktfunk_core::WIRE_VERSION,
|
||||
udp_port,
|
||||
mode: hello.mode,
|
||||
// The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption.
|
||||
|
||||
@@ -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).
|
||||
@@ -24,36 +24,43 @@ mid-stream. You flip between Gaming Mode and Desktop with Bazzite's normal Steam
|
||||
|
||||
## Install
|
||||
|
||||
The host ships as an RPM in punktfunk's **Gitea RPM registry** (public), so a Bazzite / Fedora
|
||||
Atomic box layers and updates it with `rpm-ostree`. Add the repo, then layer the host plus the web
|
||||
console and reboot:
|
||||
The host installs as a **systemd system extension (sysext)** — no `rpm-ostree` layering. The
|
||||
Bazzite docs treat layering as a last resort (layered packages slow every OS update and can block
|
||||
upgrades until removed); a sysext never enters an rpm-ostree transaction: it overlays `/usr`
|
||||
read-only from `/var/lib/extensions/`, survives OS updates, installs and updates **without a
|
||||
reboot**, and is removable in one command. This is the same mechanism the Fedora Atomic
|
||||
maintainers ship via the [fedora-sysexts](https://fedora-sysexts.github.io/) project.
|
||||
|
||||
```sh
|
||||
# Add the repo. Packages are GPG-signed (gpgcheck=1, the packages@unom.io key) AND the repo
|
||||
# metadata is Gitea-signed (repo_gpgcheck=1); gpgkey lists both keys so dnf imports each.
|
||||
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
|
||||
[gitea-unom-bazzite]
|
||||
name=punktfunk (unom, Bazzite)
|
||||
baseurl=https://git.unom.io/api/packages/unom/rpm/bazzite
|
||||
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
|
||||
|
||||
# Layer the host + the web console, then reboot into the new deployment.
|
||||
# (punktfunk Recommends punktfunk-web; list it explicitly so it's pulled regardless of weak-dep
|
||||
# settings — the Gitea registry carries punktfunk-web, which COPR can't build.)
|
||||
rpm-ostree install punktfunk punktfunk-web
|
||||
systemctl reboot
|
||||
# One-time bootstrap (afterwards the updater is on PATH as `punktfunk-sysext`):
|
||||
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
|
||||
sudo bash punktfunk-sysext.sh install # add `--channel canary` for rolling builds
|
||||
```
|
||||
|
||||
`rpm-ostree upgrade` then tracks new builds automatically (Bazzite's auto-update timer does this
|
||||
for you). For a fully baked appliance image there's also a **bootc** Containerfile that installs
|
||||
the same RPMs from this registry — see `packaging/bootc/` and `packaging/rpm/README.md` in the repo.
|
||||
Building from source works too (Bazzite is Fedora Atomic underneath, and its FFmpeg builds the host
|
||||
fine — same steps as [Fedora KDE](/docs/fedora-kde)), but the registry is the supported path.
|
||||
That downloads the newest image (host + tray + web console, SHA-256-verified over HTTPS from
|
||||
punktfunk's package registry), merges it, and applies the udev/sysctl setup on the spot — the
|
||||
host is usable immediately, no reboot. From then on:
|
||||
|
||||
```sh
|
||||
sudo punktfunk-sysext update # fetch + merge the newest build
|
||||
sudo punktfunk-sysext status # channel, installed vs latest version
|
||||
sudo punktfunk-sysext remove # unmerge and delete — the box is back to stock
|
||||
```
|
||||
|
||||
Two things to know:
|
||||
|
||||
- **After a Bazzite major rebase** (Fedora 43 → 44) the old image **refuses to load** rather than
|
||||
run against mismatched system libraries — run `sudo punktfunk-sysext update` once and it fetches
|
||||
the image built for the new base.
|
||||
- **Already layering punktfunk?** Install the sysext (it shadows the layered copy immediately),
|
||||
then drop the layer so it stops slowing your updates:
|
||||
`sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
|
||||
|
||||
For a fully baked appliance image there's also a **bootc** Containerfile that installs the RPMs
|
||||
from the registry at image-build time — see `packaging/bootc/` in the repo. Plain `rpm-ostree`
|
||||
layering from the [RPM registry](https://git.unom.io/unom/-/packages) keeps working too (see
|
||||
`packaging/bazzite/README.md`), but the sysext is the supported default. Building from source
|
||||
also works (Bazzite is Fedora Atomic underneath — same steps as [Fedora KDE](/docs/fedora-kde)).
|
||||
|
||||
## Allow controller input
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ track per machine; switching is a one-line change.
|
||||
|---|---|---|
|
||||
| **apt** (host/client) | `deb [signed-by=…] https://git.unom.io/api/packages/unom/debian canary main` | `… debian stable main` |
|
||||
| **rpm** (host) | baseurl `…/rpm/bazzite-canary` (or `fedora-44-canary`) | `…/rpm/bazzite` (or `fedora-44`) |
|
||||
| **sysext** (Bazzite host) | `sudo punktfunk-sysext install --channel canary` | `… install` / default (feeds `…/punktfunk-sysext/f43[-canary]`) |
|
||||
| **pacman** (Arch host/client) | `[punktfunk-canary]` repo section | `[punktfunk]` (`Server = …/api/packages/unom/arch/$repo/$arch`) |
|
||||
| **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` |
|
||||
| **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` |
|
||||
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -17,13 +17,14 @@ On **Windows**, the host ships as a signed installer instead — see [Windows](#
|
||||
| Distro | Package manager | One-command happy path | Guide |
|
||||
|--------|-----------------|------------------------|-------|
|
||||
| **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) |
|
||||
| **Fedora / Bazzite** | rpm-ostree | `rpm-ostree install punktfunk punktfunk-web` | [Fedora — KDE](/docs/fedora-kde) · [Bazzite](/docs/bazzite) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
|
||||
| **Arch** | PKGBUILD | `makepkg -si` | [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/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) | [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
|
||||
one-time step covered in the linked guide; after that, normal `apt upgrade` / `rpm-ostree upgrade`
|
||||
tracks new builds automatically.
|
||||
one-time step covered in the linked guide; after that, normal `apt upgrade` / `dnf upgrade` /
|
||||
`pacman -Syu` (or `sudo punktfunk-sysext update` on Bazzite) tracks new builds.
|
||||
|
||||
> **Stable vs canary.** The repos in the per-distro guides are the **stable** channel — it only
|
||||
> moves when a `vX.Y.Z` release is cut. For the latest `main` build (fast, possibly broken), point
|
||||
@@ -59,7 +60,8 @@ fallback without one. More detail — including the CLI `punktfunk-host service
|
||||
|
||||
- **`punktfunk-host`** — the streaming host. Install this on your Linux gaming machine.
|
||||
- **`punktfunk-web`** — the browser management console (pairing + status). Recommended alongside the
|
||||
host; on RPM list it explicitly (`rpm-ostree install punktfunk punktfunk-web`).
|
||||
host; on RPM list it explicitly (`dnf install punktfunk punktfunk-web`) — the Bazzite sysext
|
||||
image already includes it.
|
||||
- **`punktfunk-client`** — the GTK4 desktop client, for streaming *to* a Linux box (also shipped via
|
||||
apt / RPM / Arch / Flatpak). On a Steam Deck, this is the package you want.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"ubuntu-gnome",
|
||||
"ubuntu-kde",
|
||||
"fedora-kde",
|
||||
"arch",
|
||||
"bazzite",
|
||||
"steamos-host",
|
||||
"windows-host",
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
|
||||
#define ABI_VERSION 3
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
// (functions a client links), which can grow without changing a single wire byte — v3's
|
||||
// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
|
||||
// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
|
||||
// live). Bump this ONLY when the handshake/planes actually change incompatibly.
|
||||
#define WIRE_VERSION 2
|
||||
|
||||
// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
||||
#define PUNKTFUNK_HIDOUT_LED 1
|
||||
|
||||
|
||||
+25
-8
@@ -17,13 +17,15 @@ packaging/
|
||||
rpm/punktfunk.spec # the RPM (builds punktfunk-host from source with cargo)
|
||||
bazzite/host.env # gamescope-default config for a Bazzite appliance
|
||||
bazzite/README.md # step-by-step Bazzite setup guide
|
||||
bazzite/*sysext*.sh # the no-layering path: build/install/publish the systemd-sysext
|
||||
bootc/Containerfile # bake punktfunk into a Bazzite-based atomic image
|
||||
copr/ # COPR build-from-SCM settings
|
||||
```
|
||||
|
||||
The other packaging targets have their own READMEs: [`debian/`](debian/README.md) (apt),
|
||||
[`arch/`](arch/README.md) (PKGBUILD + sysext), [`flatpak/`](flatpak/README.md) (the client),
|
||||
[`windows/`](windows/README.md) (host installer + drivers), plus `kde/` and `linux/` helpers.
|
||||
[`arch/`](arch/README.md) (pacman binary repo + PKGBUILD + SteamOS sysext),
|
||||
[`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer +
|
||||
drivers), plus `kde/` and `linux/` helpers.
|
||||
|
||||
## What's needed beyond base Fedora
|
||||
|
||||
@@ -38,7 +40,22 @@ On **Bazzite** the only genuinely new runtime bits are `ffmpeg-libs` (RPM Fusion
|
||||
`libei` — the rest of the stack is already there. The default backend is **gamescope**
|
||||
(`packaging/bazzite/host.env`), which the host spawns headless per session — no desktop login.
|
||||
|
||||
## Option A — Gitea RPM registry (recommended; per-host, `rpm-ostree`)
|
||||
## Option A — systemd-sysext (recommended; no layering, no reboot)
|
||||
|
||||
On Bazzite / Fedora Atomic the recommended install is the **systemd-sysext** image — rpm-ostree
|
||||
layering is a last resort per the Bazzite docs (it slows every OS update and can block upgrades),
|
||||
while a sysext overlays `/usr` at runtime, survives OS updates, and updates in one command with
|
||||
no reboot. CI wraps the same RPMs below into the image, so content and channels are identical.
|
||||
|
||||
```sh
|
||||
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
|
||||
sudo bash punktfunk-sysext.sh install # then: sudo punktfunk-sysext update | status | remove
|
||||
```
|
||||
|
||||
Full walkthrough (incl. the F43→F44 rebase behavior and migration off layering):
|
||||
[`bazzite/README.md`](bazzite/README.md).
|
||||
|
||||
## Option B — Gitea RPM registry (per-host, `rpm-ostree` layering)
|
||||
|
||||
The host's RPM is published to **unom's self-hosted Gitea RPM registry** (CI builds it on every
|
||||
push), mirroring the [Debian/apt](debian/README.md) setup. Add one repo file, install, and track
|
||||
@@ -60,7 +77,7 @@ rpm-ostree install punktfunk && systemctl reboot
|
||||
# updates: rpm-ostree upgrade && systemctl reboot
|
||||
```
|
||||
|
||||
## Option B — COPR (per-host, `rpm-ostree install`)
|
||||
## Option C — COPR (per-host, `rpm-ostree install`)
|
||||
|
||||
1. Create a COPR project, enable **build-from-SCM** pointing at this repo, spec path
|
||||
`packaging/rpm/punktfunk.spec` (see `copr/README.md`). Under *External Repositories* add
|
||||
@@ -78,7 +95,7 @@ rpm-ostree install punktfunk && systemctl reboot
|
||||
systemctl reboot
|
||||
```
|
||||
|
||||
## Option C — bootc (image-based, atomic)
|
||||
## Option D — bootc (image-based, atomic)
|
||||
|
||||
Layer punktfunk into a Bazzite image once, then rebase any number of hosts onto it — no
|
||||
per-host drift. See `bootc/Containerfile`:
|
||||
@@ -89,7 +106,7 @@ podman push ghcr.io/<you>/bazzite-punktfunk
|
||||
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
|
||||
```
|
||||
|
||||
## First-run setup (either option)
|
||||
## First-run setup (all options)
|
||||
|
||||
```sh
|
||||
ujust add-user-to-input-group # virtual gamepads need /dev/uinput (then re-login).
|
||||
@@ -109,8 +126,8 @@ web console at `https://<host-ip>:47992` or directly.
|
||||
|
||||
> ⚠️ **COPR caveat:** COPR's mock chroot has no `bun`, so a COPR build produces only
|
||||
> `punktfunk` + `punktfunk-client` — **not** `punktfunk-web`. For the console on a COPR/bootc host,
|
||||
> install from the **Gitea RPM registry** (Option A — it carries `punktfunk-web`), which is also why
|
||||
> `bootc/Containerfile` installs from there rather than COPR.
|
||||
> install from the **Gitea RPM registry** (Option B — it carries `punktfunk-web`; the sysext image
|
||||
> includes it too), which is also why `bootc/Containerfile` installs from there rather than COPR.
|
||||
|
||||
## Why not Flatpak (for the HOST)?
|
||||
|
||||
|
||||
+21
-7
@@ -10,20 +10,28 @@
|
||||
# - In-tree / CI: PF_SRCDIR=$(git rev-parse --show-toplevel) makepkg --holdver
|
||||
# (builds the working tree instead of the tagged source — see build()).
|
||||
#
|
||||
# IMPORTANT: host encode is NVENC-only (crates/punktfunk-host/src/encode/linux.rs) — functional on
|
||||
# NVIDIA hosts; an AMD Deck-as-HOST needs a VAAPI backend first. The CLIENT decodes via VAAPI
|
||||
# (AMD/Intel, incl. the Deck) with a software fallback, so it works everywhere. See README.md.
|
||||
# Host encode: NVENC on NVIDIA (nvidia-utils), VAAPI on AMD/Intel (mesa) — PUNKTFUNK_ENCODER=auto
|
||||
# picks per GPU. The CLIENT decodes via VAAPI (AMD/Intel, incl. the Deck) with a software
|
||||
# fallback, so it works everywhere. See README.md.
|
||||
pkgbase=punktfunk
|
||||
# punktfunk-web (the browser console) is OPT-IN: building it needs `bun` (AUR-only as bun-bin on
|
||||
# stock Arch/SteamOS), so a default makepkg builds only host+client with no JS tooling — mirroring
|
||||
# the RPM spec's `%bcond_with web` (off by default). Set PF_WITH_WEB=1 to also build punktfunk-web
|
||||
# (appended to pkgname + bun to makedepends below).
|
||||
pkgname=('punktfunk-host' 'punktfunk-client')
|
||||
pkgver=0.2.0
|
||||
pkgrel=1
|
||||
# CI (.gitea/workflows/arch.yml) drives the version: stable tags -> X.Y.Z-1, main pushes ->
|
||||
# X.Y.Z-0.<run#> in the separate punktfunk-canary repo (mirrors the RPM's 0.ciN release; pkgrel
|
||||
# allows only digits+dots, so the run number carries the monotonic ordering).
|
||||
pkgver="${PF_PKGVER:-0.7.0}"
|
||||
pkgrel="${PF_PKGREL:-1}"
|
||||
arch=('x86_64')
|
||||
url="https://git.unom.io/unom/punktfunk"
|
||||
license=('MIT OR Apache-2.0')
|
||||
# !lto: makepkg's `lto` option injects -flto=auto into CFLAGS; aws-lc-sys (rustls' crypto)
|
||||
# compiles its C with those flags and GCC LTO bitcode objects are unreadable by rust's lld
|
||||
# linker -> "undefined symbol: aws_lc_*" at link (reproduced 2026-07-04, Arch + rust 1.90).
|
||||
# !debug: skip the -debug split package (debuginfo bloat, not shipped).
|
||||
options=('!lto' '!debug')
|
||||
|
||||
# All build deps for both crates (Arch runtime packages ship their own headers, so these cover
|
||||
# build + link). aws-lc/ring need clang+cmake; nasm is for asm.
|
||||
@@ -36,10 +44,16 @@ if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||
makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos
|
||||
fi
|
||||
|
||||
# AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root and
|
||||
# build() uses it instead; see the README.
|
||||
# AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root —
|
||||
# build() uses it instead AND the fetch is skipped entirely (a canary pkgver has no tag to
|
||||
# clone, and CI already has the checkout).
|
||||
if [ -z "${PF_SRCDIR:-}" ]; then
|
||||
source=("git+https://git.unom.io/unom/punktfunk.git#tag=v${pkgver}")
|
||||
sha256sums=('SKIP')
|
||||
else
|
||||
source=()
|
||||
sha256sums=()
|
||||
fi
|
||||
|
||||
_repo() { printf '%s' "${PF_SRCDIR:-$srcdir/punktfunk}"; }
|
||||
|
||||
|
||||
@@ -23,7 +23,45 @@ default `makepkg` builds only host+client with no JS tooling — mirroring the R
|
||||
> Arch + NVIDIA **and** AMD/Intel (incl. the Steam Deck — see the on-device path above). The client
|
||||
> decodes via VAAPI on AMD/Intel with a software fallback.
|
||||
|
||||
## Arch Linux (mutable)
|
||||
## Install from the binary repo (recommended)
|
||||
|
||||
CI (`.gitea/workflows/arch.yml`) builds this PKGBUILD in an `archlinux:base-devel` container on
|
||||
every push and publishes the packages to the **Gitea Arch package registry** — a plain pacman
|
||||
repo, so an Arch box installs and updates punktfunk with `pacman -Syu` like everything else.
|
||||
Two repos mirror the deb/rpm channels: `punktfunk` (release tags) and `punktfunk-canary`
|
||||
(rolling main-branch builds, versioned `X.Y.Z-0.<run#>` so a later release always outranks
|
||||
them). Enable exactly one.
|
||||
|
||||
The registry **signs the repo database and every package**, so first import its key into
|
||||
pacman's keyring (a one-time step — after this, packages install signature-verified):
|
||||
|
||||
```sh
|
||||
# 1. 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
|
||||
|
||||
# 2. Add the repo (pick ONE channel — punktfunk for releases, punktfunk-canary for main builds).
|
||||
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
|
||||
|
||||
[punktfunk]
|
||||
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
|
||||
EOF
|
||||
|
||||
# 3. Sync + install.
|
||||
sudo pacman -Sy punktfunk-host # gaming rig
|
||||
sudo pacman -Sy punktfunk-client # couch/Deck side
|
||||
sudo pacman -Sy punktfunk-web # optional browser management console
|
||||
```
|
||||
|
||||
(No `SigLevel` line needed — pacman's default `Required DatabaseOptional` verifies the signed
|
||||
packages against the key you just trusted. Arch is rolling, so the packages are built against
|
||||
current Arch sonames — keep the box itself updated too.)
|
||||
|
||||
Then the same first-run steps as a source build (printed by the install scriptlet): `input`
|
||||
group, `host.env`, `systemctl --user enable --now punktfunk-host` — see the next section.
|
||||
|
||||
## Build from source — Arch Linux (mutable)
|
||||
|
||||
```sh
|
||||
cd packaging/arch
|
||||
|
||||
+75
-41
@@ -12,34 +12,91 @@ flagged explicitly. For the higher-level packaging rationale ("why not Flatpak",
|
||||
> NVENC, from RPM Fusion **nonfree**), `opus`, and `libei`.
|
||||
> Source: `packaging/README.md`, `packaging/rpm/punktfunk.spec`.
|
||||
|
||||
> ⚠️ **Read this first — the COPR is operator-run, not yet published.**
|
||||
> Both install paths below pull the punktfunk RPM from a COPR project named
|
||||
> `enricobuehler/punktfunk`. That COPR is a configuration the maintainer has to **create and
|
||||
> build** (see `packaging/copr/README.md` — it documents how to set it up, not a live repo URL you
|
||||
> can assume exists). If `rpm-ostree install punktfunk` 404s, the COPR hasn't been published yet,
|
||||
> and your only path is to **build the RPM yourself** (see the appendix). The guide flags every
|
||||
> command that depends on the COPR being live.
|
||||
> ⚠️ **COPR note (Path C only).** The legacy layering path's commands reference a COPR project
|
||||
> named `enricobuehler/punktfunk` that is operator-run and may not be published (see
|
||||
> `packaging/copr/README.md`); layer from the **Gitea RPM registry** instead (`../rpm/README.md`,
|
||||
> the repo file `https://git.unom.io/api/packages/unom/rpm/bazzite.repo`) — it's what CI
|
||||
> actually publishes to. Paths A (sysext) and B (bootc) don't involve the COPR at all.
|
||||
|
||||
---
|
||||
|
||||
## 1. Choose an install path
|
||||
|
||||
There are two supported paths on Bazzite, driven by different files in `packaging/`:
|
||||
There are three paths on Bazzite, driven by different files in `packaging/`:
|
||||
|
||||
| Path | Driven by | What it does | Best for |
|
||||
|---|---|---|---|
|
||||
| **A — rpm-ostree layering** | `packaging/copr/README.md` + `packaging/rpm/punktfunk.spec` | Layers the `punktfunk` RPM onto your existing Bazzite deployment with `rpm-ostree install` | One host, quick iteration |
|
||||
| **A — systemd-sysext** ✅ recommended | `packaging/bazzite/punktfunk-sysext.sh` + `build-sysext.sh` (published by `.gitea/workflows/rpm.yml`) | Overlays the host onto `/usr` as a system extension — no layering, no reboot, one-command updates | Everyone; the default |
|
||||
| **B — bootc / OCI image** | `packaging/bootc/Containerfile` | Bakes punktfunk into a `FROM bazzite-nvidia` image once; you `bootc switch` any number of hosts onto it | Fleets, reproducible appliances, no per-host drift |
|
||||
| **C — rpm-ostree layering** (legacy) | `packaging/rpm/` + the Gitea RPM registry | Layers the `punktfunk` RPM onto your deployment with `rpm-ostree install` | Only if you specifically want the RPM database to own the files |
|
||||
|
||||
**Trade-off:** Path A is a per-host package layer — simple, but each host accumulates its own
|
||||
layered-package state. Path B builds one image (RPM Fusion + the Gitea RPM repo + the host and
|
||||
**web console** + udev rule pre-installed) that you push to a registry and rebase hosts onto
|
||||
atomically — no per-host `rpm-ostree install` drift, at the cost of running a `podman build`/`push`
|
||||
pipeline. Both require the **same first-run setup** (sections 3–6); note Path B installs from the
|
||||
**Gitea RPM registry** (which carries `punktfunk-web`), whereas Path A's COPR builds host+client
|
||||
only — for the web console on Path A, layer from the Gitea registry instead (`../rpm/README.md`).
|
||||
**Why A over C:** the Bazzite docs treat layering as a last resort — every layered package makes
|
||||
every OS update slower and can **block upgrades entirely** until removed. A sysext never enters an
|
||||
rpm-ostree transaction: it merges/unmerges at runtime, survives OS updates, and updating punktfunk
|
||||
is one command with **no reboot** (layering needs one per update). It's the mechanism the Fedora
|
||||
Atomic maintainers ship via [fedora-sysexts](https://fedora-sysexts.github.io/). All paths require
|
||||
the **same first-run setup** (sections 3–6).
|
||||
|
||||
### Path A — rpm-ostree layering from the COPR
|
||||
### Path A — systemd-sysext (recommended)
|
||||
|
||||
Run on the Bazzite host:
|
||||
|
||||
```sh
|
||||
# One-time bootstrap; afterwards the tool is on PATH as `punktfunk-sysext` (it ships inside
|
||||
# the image). `--channel canary` for rolling main-branch builds instead of releases.
|
||||
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
|
||||
sudo bash punktfunk-sysext.sh install
|
||||
```
|
||||
|
||||
This downloads the newest image for your Fedora base (host + tray + **web console**,
|
||||
SHA-256-verified from the feed `…/packages/unom/generic/punktfunk-sysext/f<ver>[-canary]/`),
|
||||
installs it as `/var/lib/extensions/punktfunk.raw`, merges it, and immediately applies what the
|
||||
RPM scriptlets would have (udev reload, sysctl) plus the two `/etc` files a sysext can't carry
|
||||
(the gamescope-session drop-in and the tray autostart entry, staged under
|
||||
`/usr/share/punktfunk/etc/`). No reboot at any point. Day-2:
|
||||
|
||||
```sh
|
||||
sudo punktfunk-sysext update # fetch + merge the newest build (then restart the user service)
|
||||
sudo punktfunk-sysext status # merged?, installed vs latest, channel/feed
|
||||
sudo punktfunk-sysext remove # unmerge + delete; ~/.config/punktfunk is left alone
|
||||
```
|
||||
|
||||
Details worth knowing:
|
||||
|
||||
- The image embeds `ID=fedora` + `VERSION_ID` (matched through Bazzite's `ID_LIKE`), so after a
|
||||
**major Bazzite rebase** (F43 → F44) the old image is **refused** instead of merging
|
||||
soname-broken binaries — `punktfunk-sysext update` then fetches the image built for the new
|
||||
base (feeds exist per Fedora major, from the same CI matrix as the RPM groups).
|
||||
- SELinux labels are baked into the image at build time (squashfs pseudo-xattrs computed from
|
||||
the targeted policy) — without them udev couldn't read the gamepad rule under enforcing.
|
||||
Validated live on Bazzite 43.
|
||||
- **Migrating from layering (path C):** install the sysext (it shadows the layered copy at
|
||||
once), then `sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
|
||||
|
||||
### Path B — bootc image (`FROM bazzite-nvidia`)
|
||||
|
||||
The image is built **off-host** (on any machine with `podman`) from
|
||||
`packaging/bootc/Containerfile`, which bases on `ghcr.io/ublue-os/bazzite-nvidia:stable`
|
||||
(override with `--build-arg BASE_IMAGE=…`), enables RPM Fusion free + nonfree, adds the Gitea RPM
|
||||
repo (`--build-arg PUNKTFUNK_RPM_GROUP=…`, default `bazzite`), and installs the host **and the web
|
||||
console** (`punktfunk punktfunk-web`). It uses the Gitea registry rather than the COPR specifically
|
||||
because the registry carries `punktfunk-web` (COPR's mock chroot can't build it — no `bun`).
|
||||
|
||||
```sh
|
||||
# Build + push (run from the repo root, on your builder machine):
|
||||
podman build -t ghcr.io/<you>/bazzite-punktfunk -f packaging/bootc/Containerfile .
|
||||
podman push ghcr.io/<you>/bazzite-punktfunk
|
||||
|
||||
# On each target Bazzite host:
|
||||
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
|
||||
```
|
||||
|
||||
> ⚠️ The image installs from the **Gitea RPM registry** (group `bazzite`), so **Path B depends on
|
||||
> that registry being populated** — CI (`.gitea/workflows/rpm.yml`) publishes `punktfunk` +
|
||||
> `punktfunk-web` on every push to `main`. Packages are unsigned with GPG-signed metadata
|
||||
> (`repo_gpgcheck=1`), matching `packaging/rpm/README.md`.
|
||||
|
||||
### Path C — rpm-ostree layering (legacy)
|
||||
|
||||
Run on the Bazzite host. (Commands verbatim from `packaging/README.md`.)
|
||||
|
||||
@@ -62,7 +119,7 @@ systemctl reboot
|
||||
> The **reboot is mandatory** — `rpm-ostree install` stages a new deployment that only takes
|
||||
> effect on the next boot. This is normal atomic-distro behavior, not a punktfunk quirk.
|
||||
|
||||
#### Updating a Path-A host — `rpm-ostree upgrade` is NOT enough
|
||||
#### Updating a Path-C host — `rpm-ostree upgrade` is NOT enough
|
||||
|
||||
> ⚠️ **`rpm-ostree upgrade` will not update punktfunk on its own.** `upgrade` bumps the **base
|
||||
> image** and only re-resolves *layered* packages **when the base changes**. A Bazzite base can
|
||||
@@ -94,29 +151,6 @@ sudo bash packaging/bazzite/update-punktfunk.sh --reboot # stage + reboot now
|
||||
> `punktfunk.repo`, canary's `<next-minor>.0-0.ciN` **outranks** the stable `X.Y.Z-1` and the box
|
||||
> silently tracks canary. Enable exactly one channel — set `enabled=0` in the other repo file.
|
||||
|
||||
### Path B — bootc image (`FROM bazzite-nvidia`)
|
||||
|
||||
The image is built **off-host** (on any machine with `podman`) from
|
||||
`packaging/bootc/Containerfile`, which bases on `ghcr.io/ublue-os/bazzite-nvidia:stable`
|
||||
(override with `--build-arg BASE_IMAGE=…`), enables RPM Fusion free + nonfree, adds the Gitea RPM
|
||||
repo (`--build-arg PUNKTFUNK_RPM_GROUP=…`, default `bazzite`), and installs the host **and the web
|
||||
console** (`punktfunk punktfunk-web`). It uses the Gitea registry rather than the COPR specifically
|
||||
because the registry carries `punktfunk-web` (COPR's mock chroot can't build it — no `bun`).
|
||||
|
||||
```sh
|
||||
# Build + push (run from the repo root, on your builder machine):
|
||||
podman build -t ghcr.io/<you>/bazzite-punktfunk -f packaging/bootc/Containerfile .
|
||||
podman push ghcr.io/<you>/bazzite-punktfunk
|
||||
|
||||
# On each target Bazzite host:
|
||||
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
|
||||
```
|
||||
|
||||
> ⚠️ The image installs from the **Gitea RPM registry** (group `bazzite`), so **Path B depends on
|
||||
> that registry being populated** — CI (`.gitea/workflows/rpm.yml`) publishes `punktfunk` +
|
||||
> `punktfunk-web` on every push to `main`. Packages are unsigned with GPG-signed metadata
|
||||
> (`repo_gpgcheck=1`), matching `packaging/rpm/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prerequisites — what Bazzite gives you vs. what you must still do
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the punktfunk systemd-sysext image for Bazzite / Fedora Atomic from the built RPMs —
|
||||
# the no-layering install path (rpm-ostree layering slows every update and can block upgrades;
|
||||
# a sysext never enters an rpm-ostree transaction). The .raw overlays /usr read-only from
|
||||
# /var/lib/extensions/, survives OS updates, and is toggled/updated without a reboot.
|
||||
#
|
||||
# Counterpart to ../arch/build-sysext.sh (which wraps a pacman package for SteamOS). This one
|
||||
# wraps the Fedora RPMs (punktfunk + punktfunk-web) and additionally:
|
||||
# * relocates the RPMs' /etc payload to /usr/share/punktfunk/etc/ (a sysext carries ONLY /usr;
|
||||
# punktfunk-sysext(8) copies these into the real /etc on install),
|
||||
# * bakes SELinux labels in as squashfs pseudo-xattrs, computed with matchpathcon from the
|
||||
# build container's targeted policy. Without them every file is unlabeled_t at runtime:
|
||||
# fine for the user session + systemd --user units (unconfined), but system daemons are
|
||||
# DENIED — udev couldn't read 60-punktfunk.rules and systemd-sysctl couldn't read the
|
||||
# sysctl drop-in (validated live on Bazzite 43, SELinux enforcing, 2026-07-04),
|
||||
# * pins compatibility via ID=fedora + VERSION_ID: merges on Bazzite/Silverblue/Aurora of the
|
||||
# SAME Fedora major (ID_LIKE matching, systemd >= 256) and is REFUSED after a major rebase
|
||||
# instead of running soname-broken binaries (`punktfunk-sysext update` then re-resolves),
|
||||
# * embeds the punktfunk-sysext helper so an installed box can update itself.
|
||||
#
|
||||
# Build in the matching Fedora container (ci/fedora*-rpm.Dockerfile) — matchpathcon needs the
|
||||
# Fedora targeted policy (libselinux-utils + selinux-policy-targeted), and the RPMs are
|
||||
# soname-coupled to their base anyway. Needs: rpm2cpio, cpio, mksquashfs (>= 4.6), matchpathcon.
|
||||
#
|
||||
# Usage:
|
||||
# bash build-sysext.sh --version-id 43 --out dist/punktfunk-0.7.1-1-x86-64.raw \
|
||||
# dist/punktfunk-0.7.1-1.fc43.x86_64.rpm dist/punktfunk-web-0.7.1-1.fc43.noarch.rpm
|
||||
#
|
||||
# The installed image MUST be named punktfunk.raw (the embedded extension-release marker is
|
||||
# extension-release.punktfunk; systemd-sysext requires marker == image name) — the feed carries
|
||||
# versioned filenames and punktfunk-sysext installs to the fixed name.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION_ID="" OUT="" RPMS=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version-id) VERSION_ID="${2:?}"; shift 2 ;;
|
||||
--out) OUT="${2:?}"; shift 2 ;;
|
||||
*) RPMS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$VERSION_ID" ] || { echo "missing --version-id <fedora major, e.g. 43>" >&2; exit 1; }
|
||||
[ -n "$OUT" ] || { echo "missing --out <image.raw>" >&2; exit 1; }
|
||||
[ "${#RPMS[@]}" -gt 0 ] || { echo "no RPMs given" >&2; exit 1; }
|
||||
for tool in rpm2cpio cpio mksquashfs matchpathcon; do
|
||||
command -v "$tool" >/dev/null || { echo "missing tool: $tool" >&2; exit 1; }
|
||||
done
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
|
||||
# SYSEXT_VERSION_ID from the punktfunk RPM (V-R without the dist tag): what
|
||||
# `punktfunk-sysext status` reports as the installed version.
|
||||
PF_VR=""
|
||||
SEEN_NAMES=" "
|
||||
for rpm in "${RPMS[@]}"; do
|
||||
[ -f "$rpm" ] || { echo "no such RPM: $rpm" >&2; exit 1; }
|
||||
name="$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)"
|
||||
# Two RPMs of the same NAME (e.g. a stale noarch next to the current x86_64 from a sloppy
|
||||
# download glob) silently shadow each other's files — refuse instead of building a chimera.
|
||||
case "$SEEN_NAMES" in *" $name "*) echo "duplicate RPM name '$name' in inputs — pass exactly one RPM per package" >&2; exit 1 ;; esac
|
||||
SEEN_NAMES="$SEEN_NAMES$name "
|
||||
if [ "$name" = punktfunk ]; then
|
||||
PF_VR="$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)"
|
||||
PF_VR="${PF_VR%.fc*}"
|
||||
fi
|
||||
rpm2cpio "$rpm" | ( cd "$STAGE" && cpio -idmu --quiet )
|
||||
done
|
||||
[ -n "$PF_VR" ] || { echo "the punktfunk (host) RPM must be among the inputs" >&2; exit 1; }
|
||||
|
||||
# A sysext carries only /usr. Relocate the RPMs' /etc payload (gamescope-session drop-in, tray
|
||||
# autostart entry) under /usr/share/punktfunk/etc/ — punktfunk-sysext copies it into /etc.
|
||||
if [ -d "$STAGE/etc" ]; then
|
||||
mkdir -p "$STAGE/usr/share/punktfunk/etc"
|
||||
cp -a "$STAGE/etc/." "$STAGE/usr/share/punktfunk/etc/"
|
||||
rm -rf "${STAGE:?}/etc"
|
||||
fi
|
||||
rm -rf "${STAGE:?}/var" # rpm ghosts etc. — nothing outside /usr may remain
|
||||
|
||||
# Self-update: the helper rides inside the image.
|
||||
install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext"
|
||||
|
||||
# Compatibility marker. ID=fedora matches Bazzite & friends through os-release ID_LIKE;
|
||||
# VERSION_ID makes a major-rebased host refuse the old ABI instead of merging it.
|
||||
install -d "$STAGE/usr/lib/extension-release.d"
|
||||
cat > "$STAGE/usr/lib/extension-release.d/extension-release.punktfunk" <<EOF
|
||||
ID=fedora
|
||||
VERSION_ID=$VERSION_ID
|
||||
ARCHITECTURE=x86-64
|
||||
SYSEXT_ID=punktfunk
|
||||
SYSEXT_VERSION_ID=$PF_VR
|
||||
EXTENSION_RELOAD_MANAGER=1
|
||||
EOF
|
||||
|
||||
# SELinux labels as pseudo-xattrs (see header). matchpathcon resolves each target path against
|
||||
# the targeted policy's file_contexts; <<none>> means "no specific entry" — skip those (the
|
||||
# handful of matches all resolve to real contexts for our payload).
|
||||
PSEUDO="$STAGE.pseudo"
|
||||
( cd "$STAGE" && find . -mindepth 1 \( -type f -o -type d \) -printf '/%P\n' ) | sort \
|
||||
| while IFS= read -r path; do
|
||||
ctx="$(matchpathcon -n "$path" 2>/dev/null || true)"
|
||||
case "$ctx" in ''|'<<none>>') continue ;; esac
|
||||
printf '%s x security.selinux=%s\n' "$path" "$ctx"
|
||||
done > "$PSEUDO"
|
||||
[ -s "$PSEUDO" ] || { echo "matchpathcon produced no labels — refusing to build an unlabeled image" >&2; exit 1; }
|
||||
|
||||
rm -f "$OUT"; mkdir -p "$(dirname "$OUT")"
|
||||
# -xattrs-exclude drops any security.selinux the staging fs already had (would collide with the
|
||||
# pseudo defs when building on an SELinux host); -all-root because cpio extracted as the CI uid.
|
||||
mksquashfs "$STAGE" "$OUT" -all-root -noappend -quiet \
|
||||
-xattrs-exclude '^security.selinux' -pf "$PSEUDO"
|
||||
rm -f "$PSEUDO"
|
||||
echo "built $OUT (punktfunk $PF_VR, fedora $VERSION_ID, $(du -h "$OUT" | cut -f1))"
|
||||
echo " install on the box: punktfunk-sysext install (or --from-file $OUT)"
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish a punktfunk sysext image into its feed on the Gitea generic package registry —
|
||||
# called by .gitea/workflows/rpm.yml after the RPM publish. A feed is one fixed URL
|
||||
# (…/punktfunk-sysext/<feed>/) holding versioned .raw files plus a SHA256SUMS manifest;
|
||||
# punktfunk-sysext(8) on the boxes reads SHA256SUMS to find + verify the newest image
|
||||
# (the layout is also exactly what systemd-sysupdate's url-file source expects, so a
|
||||
# .transfer feed can be added later without re-publishing anything).
|
||||
#
|
||||
# Usage: TOKEN=… [KEEP=6] bash publish-sysext-feed.sh <feed> <image.raw>
|
||||
# <feed> e.g. f43, f43-canary, f44 (Fedora major x channel)
|
||||
# KEEP newest images to keep in the feed; 0/unset-for-stable = keep all
|
||||
# Env: REGISTRY (git.unom.io), OWNER (unom), TOKEN (write:package PAT), CURL_USER (login name)
|
||||
set -euo pipefail
|
||||
|
||||
FEED="${1:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
|
||||
RAW="${2:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
|
||||
[ -f "$RAW" ] || { echo "no such image: $RAW" >&2; exit 1; }
|
||||
REGISTRY="${REGISTRY:-git.unom.io}"
|
||||
OWNER="${OWNER:-unom}"
|
||||
KEEP="${KEEP:-0}"
|
||||
AUTH=(--user "${CURL_USER:-enricobuehler}:${TOKEN:?TOKEN (write:package PAT) required}")
|
||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/punktfunk-sysext/$FEED"
|
||||
|
||||
FNAME="$(basename "$RAW")"
|
||||
SHA="$(sha256sum "$RAW" | cut -d' ' -f1)"
|
||||
|
||||
# Merge into the existing manifest: drop any prior line for this filename, append ours.
|
||||
SUMS="$(mktemp)"; trap 'rm -f "$SUMS"' EXIT
|
||||
curl -fsS "${AUTH[@]}" "$BASE/SHA256SUMS" 2>/dev/null | grep -v " $FNAME\$" > "$SUMS" || true
|
||||
printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS"
|
||||
|
||||
# Prune: keep only the newest $KEEP images (by version sort) in manifest + registry.
|
||||
PRUNE=()
|
||||
if [ "$KEEP" -gt 0 ]; then
|
||||
mapfile -t PRUNE < <(awk '{print $2}' "$SUMS" | sort -V | head -n -"$KEEP")
|
||||
for f in "${PRUNE[@]:-}"; do
|
||||
[ -n "$f" ] && sed -i "\| $f\$|d" "$SUMS"
|
||||
done
|
||||
fi
|
||||
|
||||
# Upload order keeps consumers consistent: image first, then the manifest referencing it,
|
||||
# then prune deletions (already absent from the manifest). Delete-before-put makes workflow
|
||||
# re-runs idempotent (the registry 409s on duplicate filenames; first-publish 404s are fine).
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$FNAME" || true
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$RAW" "$BASE/$FNAME"
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
|
||||
for f in "${PRUNE[@]:-}"; do
|
||||
[ -n "$f" ] && { echo "pruning $f"; curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$f" || true; }
|
||||
done
|
||||
echo "published $FNAME -> $BASE ($(wc -l <"$SUMS") image(s) in the feed)"
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env bash
|
||||
# punktfunk-sysext — install/update the punktfunk host on Bazzite / Fedora Atomic as a
|
||||
# systemd-sysext, the no-layering path (rpm-ostree layering is a last resort per the Bazzite
|
||||
# docs: it slows every update and can block upgrades; a sysext never enters an rpm-ostree
|
||||
# transaction, needs no reboot, and is trivially removable).
|
||||
#
|
||||
# The image overlays /usr from /var/lib/extensions/punktfunk.raw with the host, tray and web
|
||||
# console + their udev/sysctl/systemd-user payload; the RPMs' two /etc files (gamescope
|
||||
# session drop-in, tray autostart) ride inside at /usr/share/punktfunk/etc/ and are copied
|
||||
# into the real /etc here (a sysext can only carry /usr).
|
||||
#
|
||||
# Bootstrap (the script also ships inside the image as /usr/bin/punktfunk-sysext):
|
||||
# curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
|
||||
# sudo bash punktfunk-sysext.sh install # or: install --channel canary
|
||||
# Thereafter:
|
||||
# sudo punktfunk-sysext update | status | remove
|
||||
#
|
||||
# Feed: the Gitea generic package registry, one feed per Fedora major x channel
|
||||
# (…/punktfunk-sysext/f43/, f43-canary, f44, …), each a SHA256SUMS + versioned .raw files —
|
||||
# published by .gitea/workflows/rpm.yml from the same RPMs the (legacy) layering path uses.
|
||||
# The image pins ID=fedora + VERSION_ID, so after a major OS rebase the old image is refused
|
||||
# (not merged broken) and `punktfunk-sysext update` re-resolves against the new release.
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY="${PUNKTFUNK_SYSEXT_REGISTRY:-https://git.unom.io/api/packages/unom/generic/punktfunk-sysext}"
|
||||
CONF=/etc/punktfunk-sysext.conf
|
||||
EXT_DIR=/var/lib/extensions
|
||||
IMG="$EXT_DIR/punktfunk.raw"
|
||||
SIDECAR="$EXT_DIR/.punktfunk.version"
|
||||
MARKER=/usr/lib/extension-release.d/extension-release.punktfunk
|
||||
ETC_SRC=/usr/share/punktfunk/etc
|
||||
|
||||
usage() {
|
||||
sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p'
|
||||
echo "usage: punktfunk-sysext install [--channel stable|canary] [--from-file X.raw]"
|
||||
echo " punktfunk-sysext update [--from-file X.raw] | status | remove"
|
||||
exit "${1:-0}"
|
||||
}
|
||||
need_root() { [ "$(id -u)" = 0 ] || { echo "run as root (sudo)" >&2; exit 1; }; }
|
||||
|
||||
os_version_id() { . /etc/os-release; echo "${VERSION_ID%%.*}"; }
|
||||
channel() { # shellcheck disable=SC1090
|
||||
[ -f "$CONF" ] && . "$CONF"; echo "${CHANNEL:-stable}"; }
|
||||
feed_url() {
|
||||
local suffix=""
|
||||
[ "$(channel)" = canary ] && suffix="-canary"
|
||||
echo "$REGISTRY/f$(os_version_id)$suffix"
|
||||
}
|
||||
|
||||
# latest -> "VERSION FILENAME SHA256" from the feed's SHA256SUMS (highest by version sort).
|
||||
latest() {
|
||||
local feed; feed="$(feed_url)"
|
||||
curl -fsSL "$feed/SHA256SUMS" \
|
||||
| awk '$2 ~ /^punktfunk-.*-x86-64\.raw$/ { v=$2; sub(/^punktfunk-/,"",v); sub(/-x86-64\.raw$/,"",v); print v, $2, $1 }' \
|
||||
| sort -V | tail -n1
|
||||
}
|
||||
|
||||
installed_version() {
|
||||
if [ -f "$MARKER" ]; then
|
||||
sed -n 's/^SYSEXT_VERSION_ID=//p' "$MARKER"
|
||||
elif [ -f "$SIDECAR" ]; then
|
||||
cat "$SIDECAR"
|
||||
fi
|
||||
}
|
||||
merged() { [ -f "$MARKER" ]; }
|
||||
|
||||
post_merge() {
|
||||
if ! merged; then
|
||||
echo "!! image installed but NOT merged — 'systemd-sysext status' / 'journalctl -u systemd-sysext'" >&2
|
||||
echo "!! (an OS release the image doesn't match? 'punktfunk-sysext update' fetches the right one)" >&2
|
||||
return 1
|
||||
fi
|
||||
# What the RPM scriptlets would have done: pick up the uinput/uhid rule + the UDP buffer
|
||||
# sysctl now, no reboot (both also auto-apply at boot once merged — the files live in /usr/lib).
|
||||
udevadm control --reload 2>/dev/null || :
|
||||
udevadm trigger --subsystem-match=misc 2>/dev/null || :
|
||||
for f in /usr/lib/sysctl.d/99-punktfunk-net.conf /usr/lib/sysctl.d/99-punktfunk-client-net.conf; do
|
||||
[ -f "$f" ] && sysctl -q -p "$f" 2>/dev/null || :
|
||||
done
|
||||
# The /etc payload a sysext can't carry. The gamescope-session drop-in is %config(noreplace):
|
||||
# only seed it, never clobber a local edit. The tray autostart entry is not user config.
|
||||
if [ -f "$ETC_SRC/gamescope-session-plus/sessions.d/steam" ] \
|
||||
&& [ ! -e /etc/gamescope-session-plus/sessions.d/steam ]; then
|
||||
install -Dm0644 "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \
|
||||
/etc/gamescope-session-plus/sessions.d/steam
|
||||
fi
|
||||
if [ -f "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" ]; then
|
||||
install -Dm0644 "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" \
|
||||
/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
|
||||
fi
|
||||
}
|
||||
|
||||
# do_install VERSION FILENAME SHA256 | do_install --from-file X.raw
|
||||
do_install() {
|
||||
need_root
|
||||
mkdir -p "$EXT_DIR"
|
||||
local tmp="$EXT_DIR/.punktfunk.raw.new" ver
|
||||
if [ "$1" = --from-file ]; then
|
||||
ver="(local: $(basename "$2"))"
|
||||
cp -f "$2" "$tmp"
|
||||
else
|
||||
ver="$1"
|
||||
echo "downloading punktfunk $ver ($(channel), fedora $(os_version_id))…"
|
||||
curl -fL --progress-bar -o "$tmp" "$(feed_url)/$2"
|
||||
echo "$3 $tmp" | sha256sum -c --quiet
|
||||
fi
|
||||
mv -f "$tmp" "$IMG" # marker inside is extension-release.punktfunk — name must match
|
||||
echo "$ver" > "$SIDECAR"
|
||||
systemctl enable --now systemd-sysext.service >/dev/null 2>&1 || :
|
||||
systemd-sysext refresh
|
||||
post_merge
|
||||
echo "punktfunk $ver merged into /usr."
|
||||
}
|
||||
|
||||
layering_hint() {
|
||||
if command -v rpm-ostree >/dev/null 2>&1 \
|
||||
&& rpm-ostree status 2>/dev/null | grep -q 'LayeredPackages:.*punktfunk'; then
|
||||
cat >&2 <<'EOF'
|
||||
!! punktfunk is ALSO layered via rpm-ostree. The sysext now shadows it, but remove the
|
||||
!! layer so it stops slowing/blocking OS updates (the reason this sysext exists):
|
||||
!! sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_install() {
|
||||
need_root
|
||||
local from_file=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--channel) printf 'CHANNEL=%s\n' "${2:?}" > "$CONF"; shift 2 ;;
|
||||
--from-file) from_file="${2:?}"; shift 2 ;;
|
||||
*) usage 1 ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$from_file" ]; then
|
||||
do_install --from-file "$from_file"
|
||||
else
|
||||
local l; l="$(latest)"
|
||||
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
|
||||
# shellcheck disable=SC2086
|
||||
do_install $l
|
||||
fi
|
||||
layering_hint
|
||||
cat <<'EOF'
|
||||
|
||||
First-run (once):
|
||||
ujust add-user-to-input-group # virtual gamepads; then log out + back in
|
||||
mkdir -p ~/.config/punktfunk
|
||||
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
|
||||
systemctl --user daemon-reload && systemctl --user enable --now punktfunk-host
|
||||
Updates: sudo punktfunk-sysext update
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_update() {
|
||||
need_root
|
||||
if [ "${1:-}" = --from-file ]; then do_install --from-file "${2:?}"; return; fi
|
||||
local cur l ver
|
||||
cur="$(installed_version)"
|
||||
l="$(latest)"
|
||||
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
|
||||
ver="${l%% *}"
|
||||
if [ "$ver" = "$cur" ] && merged; then
|
||||
echo "already on $cur (channel $(channel)) — nothing to do."
|
||||
return
|
||||
fi
|
||||
echo "updating: ${cur:-<none>} -> $ver"
|
||||
# shellcheck disable=SC2086
|
||||
do_install $l
|
||||
echo "restart the host to pick up the new binary: systemctl --user restart punktfunk-host"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
echo "channel: $(channel)"
|
||||
echo "feed: $(feed_url)"
|
||||
echo "image: $([ -f "$IMG" ] && du -h "$IMG" | cut -f1 || echo '(not installed)')"
|
||||
echo "merged: $(merged && echo yes || echo no)"
|
||||
echo "installed: $(installed_version || true)"
|
||||
echo "latest: $(latest 2>/dev/null | cut -d' ' -f1 || true)"
|
||||
}
|
||||
|
||||
cmd_remove() {
|
||||
need_root
|
||||
# /etc cleanup needs the /usr payload for the unmodified-compare — do it BEFORE unmerging.
|
||||
if merged; then
|
||||
if cmp -s "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \
|
||||
/etc/gamescope-session-plus/sessions.d/steam 2>/dev/null; then
|
||||
rm -f /etc/gamescope-session-plus/sessions.d/steam
|
||||
fi
|
||||
fi
|
||||
rm -f /etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
|
||||
rm -f "$IMG" "$SIDECAR" "$CONF"
|
||||
systemd-sysext refresh 2>/dev/null || :
|
||||
echo "punktfunk sysext removed (user config in ~/.config/punktfunk is untouched)."
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
install) shift; cmd_install "$@" ;;
|
||||
update) shift; cmd_update "$@" ;;
|
||||
status) shift; cmd_status ;;
|
||||
remove) shift; cmd_remove ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -23,6 +23,14 @@ if [[ $EUID -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The sysext path (packaging/bazzite/punktfunk-sysext.sh) supersedes layering entirely — if the
|
||||
# box runs the sysext, it shadows any layered copy and THIS script won't change what executes.
|
||||
if [[ -f /var/lib/extensions/punktfunk.raw ]]; then
|
||||
echo "NOTE: the punktfunk sysext is installed — update with 'punktfunk-sysext update' instead." >&2
|
||||
echo " (a layered punktfunk is shadowed by the sysext; consider removing the layer:" >&2
|
||||
echo " rpm-ostree uninstall punktfunk punktfunk-web)" >&2
|
||||
fi
|
||||
|
||||
# Which punktfunk packages are actually layered right now (host, web, or both).
|
||||
mapfile -t layered < <(rpm-ostree status --json 2>/dev/null \
|
||||
| grep -oE '"punktfunk(-web)?"' | tr -d '"' | sort -u)
|
||||
|
||||
Reference in New Issue
Block a user