feat(client/linux): the client says whether it is current, and can update itself

`punktfunk-client --check-update` fetches the same Ed25519-signed per-channel
manifest the host trusts, works out how this client was installed, and reports
what — if anything — can be done about a newer build. `--apply-update` does the
part it owns. `--version` prints the CI-stamped build string, which is both what
the comparison needs and what a packaging gate can run the binary for.

The engine lives here rather than in the Decky plugin for two reasons: verifying
a signature needs crypto that Decky's embedded Python does not have, and the
plugin is not the only surface that wants the answer. Every UI becomes a caller,
exactly as it already is for --pair, --library and --list-hosts.

What is possible depends on the install, and the module refuses to pretend
otherwise. A flatpak is a per-user install its launcher can update. A .deb/.rpm/
pacman client goes through the packaged root helper — a fixed, parameterless
systemd oneshot polkit authorises for members of the punktfunk-update group, so
the request carries nothing an attacker could influence and the payload comes
from the distro's own signed repositories. A sysext, a nix profile or a source
build gets the command and nothing else: the signed sysext feed carries the HOST
image, so a client sysext has no feed to update from, and a button that can only
fail is worse than one honest line.

A failed check reports the failure. "Could not tell" rendering as "up to date"
is the one answer that must never happen.

The box's capabilities are probed once into a `Caps` struct so the routing rules
are a pure function of (install kind, caps) — all seven of them are tested
without a box, including that the kill switch beats every kind and that pacman
needs its own root-owned full-sysupgrade opt-in on top of the group.

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 f6cfe382fd
commit a87fbebc0a
5 changed files with 801 additions and 1 deletions
+109
View File
@@ -446,6 +446,106 @@ pub fn headless_reset() -> glib::ExitCode {
}
}
/// The version this binary was built as — the CI-stamped string (`0.23.0~ci10250.gab12cd34`
/// and friends) when built by a packaging job, the crate version otherwise. See `build.rs`.
pub fn version_string() -> &'static str {
env!("PUNKTFUNK_VERSION")
}
/// `--version` — one line, so a packaging script's run-the-binary gate and the Decky plugin
/// can both ask "what is installed here?" without a display or a config dir.
fn headless_version() -> glib::ExitCode {
println!("punktfunk-client {}", version_string());
glib::ExitCode::SUCCESS
}
/// `--check-update [--json]` — is a newer client available for this box's channel, and can
/// anything here install it? The answer comes from the same Ed25519-signed manifest the host
/// checks (`pf_client_core::update`); this is only its presentation.
///
/// Exit code carries the verdict for scripts: 0 = up to date, 10 = an update is available,
/// 1 = the check could not be completed (offline, bad signature, disabled). The distinction
/// matters — "could not tell" must never be scripted as "up to date".
fn headless_check_update() -> glib::ExitCode {
let status = pf_client_core::update::check(version_string());
if arg_flag("--json") {
match serde_json::to_string(&status) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("check-update: {e}");
return glib::ExitCode::FAILURE;
}
}
} else {
println!(
"installed {} ({}, {})",
status.current, status.kind, status.channel
);
println!("available {}", status.latest);
if let Some(err) = &status.error {
eprintln!("check-update: {err}");
} else if status.update_available {
println!("update yes");
println!("apply {}", update_apply_line(&status));
} else {
println!("update no");
}
if let Some(hint) = &status.opt_in_hint {
println!("opt-in {hint}");
}
}
if status.error.is_some() {
glib::ExitCode::FAILURE
} else if status.update_available {
// Distinct from both success and failure so `--check-update` is scriptable.
glib::ExitCode::from(10u8)
} else {
glib::ExitCode::SUCCESS
}
}
/// The human line describing how an update would be applied.
fn update_apply_line(status: &pf_client_core::update::Status) -> String {
use pf_client_core::update::Applier;
match status.applier {
Applier::Helper => format!("punktfunk-client --apply-update ({})", status.command),
Applier::Flatpak => status.command.clone(),
Applier::None => status.command.clone(),
}
}
/// `--apply-update [--json]` — drive the packaged root helper for this install. Refuses (with
/// the command to run instead) for every kind it does not own; see
/// `pf_client_core::update::apply`.
fn headless_apply_update() -> glib::ExitCode {
let outcome = pf_client_core::update::apply(version_string());
if arg_flag("--json") {
match serde_json::to_string(&outcome) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("apply-update: {e}");
return glib::ExitCode::FAILURE;
}
}
} else if let Some(err) = &outcome.error {
eprintln!("apply-update: {err}");
} else if outcome.staged {
println!(
"staged {} -> {} (reboot to finish)",
outcome.before, outcome.after
);
} else if outcome.changed {
println!("updated {} -> {}", outcome.before, outcome.after);
} else {
println!("already up to date ({})", outcome.before);
}
if outcome.ok {
glib::ExitCode::SUCCESS
} else {
glib::ExitCode::FAILURE
}
}
/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the
/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the
/// dispatch in `app.rs` is a single line.
@@ -468,6 +568,15 @@ pub fn headless_host_command() -> Option<glib::ExitCode> {
if arg_flag("--reset") {
return Some(headless_reset());
}
if arg_flag("--version") {
return Some(headless_version());
}
if arg_flag("--check-update") {
return Some(headless_check_update());
}
if arg_flag("--apply-update") {
return Some(headless_apply_update());
}
None
}