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:
co-authored by
Claude Opus 5
parent
a87fbebc0a
commit
b164b4c03e
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -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 || :
|
||||
|
||||
Reference in New Issue
Block a user