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