feat(packaging): a client-only box can update its client without the host

The root helper, its unit, the group-scoped polkit rule and the install-kind
marker all shipped with the HOST package. A Steam Deck or a handheld has no host
package, so the client half of one-tap updates had nothing to stand on.

The client packages now ship their own copies — deliberately under their own
paths (`pf-update-client`, `punktfunk-client-update.service`,
`49-punktfunk-client-update.rules`, `/usr/share/punktfunk-client/install-kind`)
rather than sharing the host's. Two packages owning one path is a hard conflict
in dpkg, rpm and pacman alike, and the client marker needs its own DIRECTORY
too because the host RPM claims `%{_datadir}/punktfunk/*` with a glob — a
sibling file there would break `dnf install punktfunk punktfunk-client`.

`pf-update` grows the matching `apply-client` verb. The verb comes from a
root-owned unit's fixed ExecStart, never from the caller, so the zero-parameter
invariant holds; both verbs sweep every installed punktfunk package, and what
the verb selects is which marker is read and which binary the run-the-binary
gate executes afterwards. A client sysext is refused with its reason: the signed
feed carries the host image, and installing that over a client-only box is not
an update.

The opt-in group is created by both packages' scriptlets, idempotently, so
whichever lands first wins.

Also fixes the host's own helper install: it read `$R/target/release/pf-update`,
which is the source checkout, while build() exports CARGO_TARGET_DIR="$srcdir/
target" and every other binary in the file installs from `$T`. Both now read
`$T`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
enricobuehler
2026-07-31 09:41:26 +02:00
committed by enricobuehler
co-authored by Claude Opus 5
parent a87fbebc0a
commit b164b4c03e
8 changed files with 284 additions and 58 deletions
+6 -5
View File
@@ -147,15 +147,16 @@ jobs:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
run: |
git config --global --add safe.directory "$PWD"
# THREE binaries ship in the client .deb, so all three are built here: the GTK shell,
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect), and
# punktfunk-cli (the headless `punktfunk` front-end). build-client-deb.sh installs all
# three; leaving punktfunk-cli out here made it fall over on `install: No such file or
# FOUR binaries ship in the client .deb, so all four are built here: the GTK shell,
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect),
# punktfunk-cli (the headless `punktfunk` front-end), and pf-update (the root helper
# behind `punktfunk-client --apply-update`). build-client-deb.sh installs all four;
# leaving punktfunk-cli out here made it fall over on `install: No such file or
# directory`, because its build-if-missing guard only tested the first two and so decided
# everything was already built. The HOST is built separately in the build-publish-host
# job (Ubuntu 24.04 image + bundled FFmpeg 8).
cargo build --release --locked \
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update
- name: Build + smoke-boot web console (bun preset)
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
+129 -45
View File
@@ -1,19 +1,28 @@
//! `pf-update` — the root helper behind web-console-triggered Linux host updates
//! `pf-update` — the root helper behind triggered Linux package updates
//! (planning: `host-update-from-web-console.md` §7, plan U2.1).
//!
//! Invoked as `pf-update apply`, normally via the `punktfunk-update.service` oneshot that a
//! `punktfunk-update`-group member may start through polkit. **It takes zero
//! attacker-influenceable parameters**: no versions, no URLs, no package names from the
//! caller — the install kind comes from root-owned markers, the package list from the local
//! package database, and every payload from the distro package manager's own signed
//! repositories. Compromising the trigger yields "run the system's normal update for the
//! Two verbs, one per product:
//!
//! * `pf-update apply` — the HOST, via `punktfunk-update.service` (triggered from the web
//! console).
//! * `pf-update apply-client` — the CLIENT, via `punktfunk-client-update.service` (triggered
//! by `punktfunk-client --apply-update`, which is what the Decky plugin's one-tap runs).
//!
//! Both are started by an unprivileged process through polkit, authorised for members of the
//! `punktfunk-update` group. **The helper takes zero attacker-influenceable parameters**: no
//! versions, no URLs, no package names from the caller — the verb comes from a root-owned
//! unit's fixed `ExecStart`, the install kind from root-owned markers, the package list from
//! the local package database, and every payload from the distro package manager's own signed
//! repositories. Compromising a trigger yields "run the system's normal update for the
//! punktfunk packages", nothing more.
//!
//! After a successful package-manager run, the **run-the-binary gate** executes the newly
//! installed `/usr/bin/punktfunk-host --version` and requires it to exit cleanly — the
//! CI-green-on-the-wrong-program class (the 0.22.0 clobber) dies here for one binary run's
//! worth of cost. The outcome is written to `/var/lib/punktfunk/update-result.json`
//! (root-written, world-readable) for the unprivileged host to read; stdout/stderr land in
//! Both verbs upgrade every installed `punktfunk*` package — a box with both gets both,
//! whichever unit ran. What the verb changes is which marker is read (the two packages cannot
//! own one marker path: that is a hard conflict in deb, rpm and pacman alike) and which binary
//! the **run-the-binary gate** executes afterwards, requiring a clean exit — the
//! CI-green-on-the-wrong-program class (the 0.22.0 clobber) dies there for one binary run's
//! worth of cost. The outcome is written to `/var/lib/punktfunk/{,client-}update-result.json`
//! (root-written, world-readable) for the unprivileged caller to read; stdout/stderr land in
//! the unit's journal.
#[cfg(target_os = "linux")]
@@ -22,12 +31,61 @@ mod linux_main {
use std::path::Path;
use std::process::Command;
const MARKER: &str = "/usr/share/punktfunk/install-kind";
const SYSEXT_MARKER: &str = "/usr/lib/extension-release.d/extension-release.punktfunk";
const OSTREE_BOOTED: &str = "/run/ostree-booted";
const PACMAN_OPTIN_CONF: &str = "/etc/punktfunk/update.conf";
const RESULT_PATH: &str = "/var/lib/punktfunk/update-result.json";
const HOST_BIN: &str = "/usr/bin/punktfunk-host";
/// Which product this run was started for. It comes from the VERB in a root-owned unit's
/// fixed `ExecStart` — never from an unprivileged caller — so it stays inside the
/// zero-attacker-influenceable-parameters rule: the two units differ only in which
/// product's marker they read and which binary the run-the-binary gate executes.
///
/// Two units exist rather than one because the host and the client are separate packages
/// and every packaging format we ship treats two packages owning one path as a hard
/// conflict — a client-only box (a Steam Deck, a handheld) must be able to install the
/// helper without the host package.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
Host,
Client,
}
impl Mode {
fn marker(self) -> &'static str {
match self {
Mode::Host => "/usr/share/punktfunk/install-kind",
Mode::Client => "/usr/share/punktfunk-client/install-kind",
}
}
fn sysext_marker(self) -> &'static str {
match self {
Mode::Host => "/usr/lib/extension-release.d/extension-release.punktfunk",
Mode::Client => "/usr/lib/extension-release.d/extension-release.punktfunk-client",
}
}
/// The binary the run-the-binary gate executes after a package-manager run.
fn gate_binary(self) -> &'static str {
match self {
Mode::Host => "/usr/bin/punktfunk-host",
Mode::Client => "/usr/bin/punktfunk-client",
}
}
fn result_path(self) -> &'static str {
match self {
Mode::Host => "/var/lib/punktfunk/update-result.json",
Mode::Client => "/var/lib/punktfunk/client-update-result.json",
}
}
fn as_str(self) -> &'static str {
match self {
Mode::Host => "host",
Mode::Client => "client",
}
}
}
/// What the host reads back. Field meanings mirror the mgmt API's `UpdateResultInfo`
/// where they overlap; `changed=false` is the "your package source has nothing newer
@@ -55,12 +113,24 @@ mod linux_main {
/// Root-owned facts → the apply strategy. Mirrors the host's ladder for the kinds a
/// root helper serves (the helper decides for ITSELF — never trusts its caller).
fn detect_kind() -> Result<&'static str, String> {
if Path::new(SYSEXT_MARKER).exists() {
return Ok("sysext");
fn detect_kind(mode: Mode) -> Result<&'static str, String> {
if Path::new(mode.sysext_marker()).exists() {
return match mode {
Mode::Host => Ok("sysext"),
// `punktfunk-sysext update` pulls the HOST image from the signed feed; there
// is no client feed to pull from (a client sysext is the local
// packaging/arch/build-sysext.sh wrapper). Refusing here is the honest answer
// — the alternative would install the host over a client-only box.
Mode::Client => Err(
"this client is a sysext, and the sysext feed carries the host image only \
— rebuild and re-install the client image instead"
.to_string(),
),
};
}
let marker = std::fs::read_to_string(MARKER)
.map_err(|e| format!("no install-kind marker at {MARKER}: {e}"))?;
let marker_path = mode.marker();
let marker = std::fs::read_to_string(marker_path)
.map_err(|e| format!("no install-kind marker at {marker_path}: {e}"))?;
match marker.split_whitespace().next() {
Some("apt") => Ok("apt"),
Some("dnf") if Path::new(OSTREE_BOOTED).exists() => Ok("rpm-ostree"),
@@ -109,10 +179,14 @@ mod linux_main {
Ok(pkgs)
}
fn host_version() -> Result<String, String> {
/// The run-the-binary gate's reading: execute what we just installed and take its
/// `--version`. A binary that cannot run is an update that did NOT stick, whatever the
/// package manager reported (the 0.22.0 clobber lesson).
fn gate_version(mode: Mode) -> Result<String, String> {
let bin = mode.gate_binary();
run_capture(
Command::new(HOST_BIN).arg("--version"),
"punktfunk-host --version",
Command::new(bin).arg("--version"),
&format!("{bin} --version"),
)
}
@@ -211,8 +285,8 @@ mod linux_main {
}
}
fn write_result(result: &HelperResult) {
let path = Path::new(RESULT_PATH);
fn write_result(mode: Mode, result: &HelperResult) {
let path = Path::new(mode.result_path());
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
@@ -226,10 +300,17 @@ mod linux_main {
pub fn main() {
let arg = std::env::args().nth(1).unwrap_or_default();
if arg != "apply" {
eprintln!("usage: pf-update apply (normally via punktfunk-update.service)");
std::process::exit(2);
}
let mode = match arg.as_str() {
"apply" => Mode::Host,
"apply-client" => Mode::Client,
_ => {
eprintln!(
"usage: pf-update apply | apply-client (normally via \
punktfunk-update.service / punktfunk-client-update.service)"
);
std::process::exit(2);
}
};
// Effective root is required for every leg; refuse early with a clear message
// rather than half-running.
// SAFETY: geteuid has no preconditions.
@@ -238,25 +319,28 @@ mod linux_main {
std::process::exit(1);
}
let kind = match detect_kind() {
let kind = match detect_kind(mode) {
Ok(k) => k,
Err(e) => {
eprintln!("pf-update: {e}");
write_result(&HelperResult {
ok: false,
kind: "unknown".into(),
before_version: String::new(),
after_version: String::new(),
changed: false,
staged: false,
error: Some(e),
finished_unix: now_unix(),
});
write_result(
mode,
&HelperResult {
ok: false,
kind: "unknown".into(),
before_version: String::new(),
after_version: String::new(),
changed: false,
staged: false,
error: Some(e),
finished_unix: now_unix(),
},
);
std::process::exit(1);
}
};
println!("pf-update: install kind {kind}");
let before = host_version().unwrap_or_default();
println!("pf-update: {} install kind {kind}", mode.as_str());
let before = gate_version(mode).unwrap_or_default();
let outcome = apply_for_kind(kind).and_then(|staged| {
// The run-the-binary gate: the freshly installed binary must actually run.
@@ -264,7 +348,7 @@ mod linux_main {
let after = if staged {
before.clone()
} else {
host_version()
gate_version(mode)
.map_err(|e| format!("run-the-binary gate: {e} — the update did NOT stick"))?
};
Ok((staged, after))
@@ -296,7 +380,7 @@ mod linux_main {
}
};
let ok = result.ok;
write_result(&result);
write_result(mode, &result);
println!(
"pf-update: {} ({} -> {}, changed: {}, staged: {})",
if ok { "ok" } else { "FAILED" },
+25 -3
View File
@@ -95,9 +95,10 @@ build() {
# Pure Rust `ash` (no new lib, no link-time deps); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0
# opts back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices are safe.
if [ "$CARCH" = 'aarch64' ]; then
# Client-only: no host crate, so none of the encode features below apply.
# Client-only: no host crate, so none of the encode features below apply. pf-update still
# builds — the client package ships its own copy for `--apply-update`.
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session \
-p punktfunk-cli
-p punktfunk-cli -p pf-update
else
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli \
@@ -164,7 +165,7 @@ package_punktfunk-host() {
# unit + group-scoped polkit rule. Same no-libexec relocation as pf-dm-helper, with the
# unit's ExecStart rewritten to match. On pacman the helper additionally requires the
# explicit PACMAN_FULL_SYSUPGRADE opt-in (partial upgrades are against Arch doctrine).
install -Dm0755 "$R/target/release/pf-update" "$pkgdir/usr/lib/punktfunk/pf-update"
install -Dm0755 "$T/pf-update" "$pkgdir/usr/lib/punktfunk/pf-update"
install -Dm0644 "$R/packaging/linux/punktfunk-update.service" \
"$pkgdir/usr/lib/systemd/system/punktfunk-update.service"
sed -i 's#/usr/libexec/punktfunk/pf-update#/usr/lib/punktfunk/pf-update#' \
@@ -282,6 +283,27 @@ package_punktfunk-client() {
# 32 MB UDP recv buffer (so high-bitrate streams don't overflow the kernel socket buffer).
install -Dm0644 "$R/scripts/99-punktfunk-client-net.conf" \
"$pkgdir/usr/lib/sysctl.d/99-punktfunk-client-net.conf"
# One-tap client updates (`punktfunk-client --apply-update`, which is what the Decky plugin
# runs): the same root helper the host package ships, under the CLIENT's own paths — a
# client-only box (a Deck) has no host package, and two packages owning one path is a hard
# pacman conflict. Same no-libexec relocation as the host's copy, with the unit's ExecStart
# rewritten to match. On pacman the helper additionally requires the explicit
# PACMAN_FULL_SYSUPGRADE opt-in (partial upgrades are against Arch doctrine).
install -Dm0755 "$T/pf-update" "$pkgdir/usr/lib/punktfunk/pf-update-client"
install -Dm0644 "$R/packaging/linux/punktfunk-client-update.service" \
"$pkgdir/usr/lib/systemd/system/punktfunk-client-update.service"
sed -i 's#/usr/libexec/punktfunk/pf-update#/usr/lib/punktfunk/pf-update-client#' \
"$pkgdir/usr/lib/systemd/system/punktfunk-client-update.service"
install -Dm0644 "$R/packaging/linux/49-punktfunk-client-update.rules" \
"$pkgdir/usr/share/polkit-1/rules.d/49-punktfunk-client-update.rules"
# Install-kind + channel marker for the CLIENT, read by `punktfunk-client --check-update`
# (planning: host-update-from-web-console.md §4.1). Its own directory, matching the RPM (where
# the host subpackage's %{_datadir}/punktfunk/* glob would otherwise co-own a sibling file) —
# one path per product across every format, so the detector has one answer to remember.
local _pf_client_channel=stable
[[ $pkgrel == 0.* ]] && _pf_client_channel=canary
printf 'pacman %s\n' "$_pf_client_channel" | \
install -Dm0644 /dev/stdin "$pkgdir/usr/share/punktfunk-client/install-kind"
install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-client/LICENSE-MIT"
install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-client/LICENSE-APACHE"
}
+9
View File
@@ -1,5 +1,13 @@
# pacman scriptlet for the GTK4 client — mirrors build-client-deb.sh's postinst.
_ensure_update_group() {
# The (empty) opt-in group for one-tap client updates — nobody is auto-added. Shared with
# punktfunk-host's scriptlet: `groupadd` is idempotent here, so whichever package lands first
# creates it and the other is a no-op.
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || true
}
post_install() {
_ensure_update_group
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=hidraw 2>/dev/null || true
update-desktop-database /usr/share/applications 2>/dev/null || true
@@ -14,6 +22,7 @@ MSG
}
post_upgrade() {
_ensure_update_group
udevadm control --reload-rules 2>/dev/null || true
sysctl -p /usr/lib/sysctl.d/99-punktfunk-client-net.conf >/dev/null 2>&1 || true
}
+34 -3
View File
@@ -45,9 +45,15 @@ CLI_BIN="$OUTDIR/punktfunk"
# satisfied the guard, skipped this build, and then died on `install: No such file or directory`
# for $CLI_BIN — a confusing way to say "the CLI was never built". The arm64 leg never hit it
# because it pre-builds nothing, so the guard always fired there and built all three.
if [ ! -x "$BIN" ] || [ ! -x "$SESSION_BIN" ] || [ ! -x "$CLI_BIN" ]; then
echo "==> building $CRATE + punktfunk-client-session + punktfunk-cli (release${TARGET:+ for $TARGET})"
cargo build --release --locked "${CARGO_TARGET_ARGS[@]}" -p "$CRATE" -p punktfunk-client-session -p punktfunk-cli
# pf-update joins the list: the client ships its own copy of the root helper for one-tap
# updates (`punktfunk-client --apply-update`). Same guard rule as above — every binary this
# script installs is tested here, so a pre-built subset can't skip the build and then die on
# `install: No such file or directory`.
UPDATE_BIN="$OUTDIR/pf-update"
if [ ! -x "$BIN" ] || [ ! -x "$SESSION_BIN" ] || [ ! -x "$CLI_BIN" ] || [ ! -x "$UPDATE_BIN" ]; then
echo "==> building $CRATE + punktfunk-client-session + punktfunk-cli + pf-update (release${TARGET:+ for $TARGET})"
cargo build --release --locked "${CARGO_TARGET_ARGS[@]}" -p "$CRATE" -p punktfunk-client-session \
-p punktfunk-cli -p pf-update
fi
STAGE="$(mktemp -d)"
@@ -72,6 +78,27 @@ install -Dm0644 scripts/70-punktfunk-client.rules \
# 32 MB = 0%). systemd-sysctl applies it at boot; the postinst applies it on install.
install -Dm0644 scripts/99-punktfunk-client-net.conf \
"$STAGE/usr/lib/sysctl.d/99-punktfunk-client-net.conf"
# One-tap client updates (`punktfunk-client --apply-update`, which is what the Decky plugin
# runs): the same root helper the host package ships, under the CLIENT's own paths + its own
# unit and polkit rule. Separate paths because dpkg refuses two packages owning one file, and a
# client-only box must be able to install this without punktfunk-host. Opt-in = joining the
# (shipped-empty) punktfunk-update group; postinst creates it.
install -Dm0755 "$UPDATE_BIN" "$STAGE/usr/libexec/punktfunk/pf-update-client"
install -Dm0644 packaging/linux/punktfunk-client-update.service \
"$STAGE/usr/lib/systemd/system/punktfunk-client-update.service"
sed -i 's#/usr/libexec/punktfunk/pf-update#/usr/libexec/punktfunk/pf-update-client#' \
"$STAGE/usr/lib/systemd/system/punktfunk-client-update.service"
install -Dm0644 packaging/linux/49-punktfunk-client-update.rules \
"$STAGE/usr/share/polkit-1/rules.d/49-punktfunk-client-update.rules"
# Install-kind + channel marker for the CLIENT, read by `punktfunk-client --check-update`
# (planning: host-update-from-web-console.md §4.1). Its own directory, matching the RPM. A
# canary build's version carries `~ciN`; anything else is stable.
case "$VERSION" in
*~ci*) _pf_client_channel=canary ;;
*) _pf_client_channel=stable ;;
esac
printf 'apt %s\n' "$_pf_client_channel" | \
install -Dm0644 /dev/stdin "$STAGE/usr/share/punktfunk-client/install-kind"
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
install -Dm0644 README.md "$DOCDIR/README.md"
@@ -153,6 +180,10 @@ cat > "$STAGE/DEBIAN/postinst" <<'EOF'
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
# The (empty) opt-in group for one-tap client updates — nobody is auto-added. The host
# package's postinst creates the same group; addgroup is idempotent, so whichever is
# configured first wins and the other is a no-op.
getent group punktfunk-update >/dev/null 2>&1 || addgroup --system punktfunk-update 2>/dev/null || true
# Pick up the DualSense hidraw rule without a reboot (best-effort, no-op in containers).
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=hidraw 2>/dev/null || true
@@ -0,0 +1,24 @@
// Members of the `punktfunk-update` group may START exactly one more unit: the punktfunk
// root-helper oneshot that updates the punktfunk CLIENT packages (planning:
// host-update-from-web-console.md §7). Sibling of 49-punktfunk-update.rules, which grants the
// host's unit; the group is the same one, so a box that has opted in has opted in once.
//
// The group ships EMPTY — joining it is the explicit, auditable opt-in for one-tap client
// updates (the Decky plugin's "Update client" button, `punktfunk-client --apply-update`):
//
// sudo usermod -aG punktfunk-update <your user>
//
// Scope notes: `verb == "start"` keeps stop/restart/enable out of the grant, and the unit
// name pin keeps every other unit out. The unit's ExecStart is fixed and parameterless, so
// this grant authorizes "run the system's normal update for the punktfunk packages" and
// nothing else.
polkit.addRule(function (action, subject) {
if (
action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "punktfunk-client-update.service" &&
action.lookup("verb") == "start" &&
subject.isInGroup("punktfunk-update")
) {
return polkit.Result.YES;
}
});
@@ -0,0 +1,23 @@
# The client-triggered package update, as a root oneshot (planning:
# host-update-from-web-console.md §7). Started — never enabled — by the unprivileged client via
# `systemctl start punktfunk-client-update.service` (which is what `punktfunk-client
# --apply-update`, and so the Decky plugin's one-tap update, runs), authorised by the polkit
# rule (49-punktfunk-client-update.rules) for members of the `punktfunk-update` group. systemd
# gives us single-flight (a second start while active fails) and journal capture for free; the
# helper writes its machine-readable outcome to /var/lib/punktfunk/client-update-result.json.
#
# This is a SEPARATE unit from punktfunk-update.service, not a duplicate of it: the client and
# the host are separate packages, and no packaging format we ship lets two packages own one
# path. A client-only box — a Steam Deck, a handheld — must be able to install this without
# pulling in the host.
[Unit]
Description=punktfunk client update (root helper)
# Network is a hard prerequisite for every leg (package manager).
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/libexec/punktfunk/pf-update apply-client
# The helper drives the distro package manager, which needs the real system — no sandboxing
# directives here on purpose; the constraint is the FIXED ExecStart (no parameters exist).
+34 -2
View File
@@ -229,8 +229,10 @@ cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vu
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli \
-p pf-update
%else
# Client-only (aarch64): no host crate, so none of the encode features apply.
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli
# Client-only (aarch64): no host crate, so none of the encode features apply. pf-update still
# builds — the client subpackage ships its own copy for `punktfunk-client --apply-update`.
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session \
-p punktfunk-cli -p pf-update
%endif
# The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features
# across everything in one build, so co-building the tray with the host pulls the host's
@@ -357,6 +359,24 @@ install -Dm0644 scripts/70-punktfunk-client.rules \
install -Dm0644 scripts/99-punktfunk-client-net.conf \
%{buildroot}%{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf
# One-tap client updates (`punktfunk-client --apply-update`, which is what the Decky plugin
# runs): the same root helper the host subpackage ships, under the CLIENT's own paths. Separate
# paths are not tidiness — rpm refuses two subpackages owning one file, and a client-only box
# (a Deck, an aarch64 build with %%{without host}) must be able to install this on its own.
install -Dm0755 target/release/pf-update %{buildroot}%{_libexecdir}/punktfunk/pf-update-client
install -Dm0644 packaging/linux/punktfunk-client-update.service \
%{buildroot}%{_unitdir}/punktfunk-client-update.service
sed -i 's#%{_libexecdir}/punktfunk/pf-update#%{_libexecdir}/punktfunk/pf-update-client#' \
%{buildroot}%{_unitdir}/punktfunk-client-update.service
install -Dm0644 packaging/linux/49-punktfunk-client-update.rules \
%{buildroot}%{_datadir}/polkit-1/rules.d/49-punktfunk-client-update.rules
# Install-kind + channel marker for the CLIENT, read by `punktfunk-client --check-update`. Its
# own DIRECTORY, not just its own filename: the host subpackage claims %{_datadir}/%{name}/*
# with a glob, so a sibling file there would be owned by both and `dnf install punktfunk
# punktfunk-client` would fail on the conflict.
install -d %{buildroot}%{_datadir}/punktfunk-client
printf 'dnf %{?pf_channel}%{!?pf_channel:stable}\n' > %{buildroot}%{_datadir}/punktfunk-client/install-kind
%if %{with host}
# Headless session helpers + example config + OpenAPI doc (reference material).
install -d %{buildroot}%{_datadir}/%{name}/headless
@@ -470,6 +490,14 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%{_datadir}/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg
%{_udevrulesdir}/70-punktfunk-client.rules
%{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf
# Co-owned with the host subpackage (rpm allows that for DIRECTORIES, unlike files) so a
# client-only install — the aarch64 `%%{without host}` build — still owns the dir it created.
%dir %{_libexecdir}/punktfunk
%{_libexecdir}/punktfunk/pf-update-client
%{_unitdir}/punktfunk-client-update.service
%{_datadir}/polkit-1/rules.d/49-punktfunk-client-update.rules
%dir %{_datadir}/punktfunk-client
%{_datadir}/punktfunk-client/install-kind
%if %{with web}
%files web
@@ -497,6 +525,10 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%endif
%post client
# The (empty) opt-in group for one-tap client updates — nobody is auto-added. Also created by
# the host subpackage's %%post; groupadd is idempotent, so whichever lands first wins and the
# other is a no-op.
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || :
# Pick up the DualSense hidraw rule without a reboot (best-effort; on rpm-ostree it
# applies on the next boot into the layered deployment).
udevadm control --reload-rules 2>/dev/null || :