feat(decky): the plugin updates the actual client, not only the flatpak one
audit / bun-audit (plugin-kit) (push) Successful in 17s
audit / bun-audit (sdk) (push) Successful in 20s
audit / bun-audit (web) (push) Successful in 27s
audit / pnpm-audit (push) Successful in 18s
audit / cargo-audit (push) Successful in 49s
audit / docs-site-audit (push) Successful in 42s
apple / swift (push) Successful in 1m18s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 2m10s
audit / license-gate (push) Successful in 5m17s
ci / rust-arm64 (push) Successful in 5m33s
decky / build-publish (push) Successful in 35s
deb / build-publish (push) Successful in 5m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
deb / build-publish-client-arm64 (push) Successful in 2m16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 26s
android / android (push) Successful in 9m7s
docker / builders-arm64cross (push) Successful in 8s
arch / build-publish (push) Successful in 9m19s
docker / deploy-docs (push) Successful in 31s
deb / build-publish-host (push) Successful in 7m31s
windows-host / package (push) Failing after 14m54s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
flatpak / build-publish (push) Successful in 7m57s
ci / rust (push) Successful in 15m50s
release / apple (push) Successful in 18m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m52s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m49s
apple / screenshots (push) Canceled after 9m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m55s

The QAM already offered a client update — for the flatpak, by comparing OSTree
commits. A native client (.deb, .rpm, pacman, sysext, nix, a source build), which
the plugin has been able to LAUNCH since it learned to drive one, went stale in
silence: no version, no check, no mention.

check_update now branches on the client the launcher will actually run. The
flatpak keeps its commit comparison, which is the exact one — a flatpak built
from main between releases carries the release's crate version, so a
version-based check would call it current when it isn't. Everything else asks
the client itself, which verifies the signed manifest. update_client routes the
same way: flatpak update --user, or `punktfunk-client --apply-update`.

Where nothing on the box can install it, the plugin says so instead of offering
a button. The QAM banner becomes the command, the About tab names the install
kind so "why is there no Update button?" has a visible answer, and a check that
FAILED reads as a failure — never as "up to date".

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 b164b4c03e
commit 21787ca6cd
6 changed files with 398 additions and 67 deletions
+19
View File
@@ -54,6 +54,25 @@ Loader's own (SHA-256-verified) install. Installs and updates can take a couple
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
before the actual download proceeds.
### Updating the client
The plugin also reports — and where it can, installs — updates for the **client** it launches.
What is possible depends on how that client was installed, and the About tab names the install
kind so the answer is never a mystery:
| Install | Update |
| --- | --- |
| **Flatpak** (the usual Deck client) | One tap. `flatpak update --user io.unom.Punktfunk` — a per-user install, which is why `sudo flatpak update` never touches it. |
| **.deb / .rpm** (and rpm-ostree, which stages for the next reboot) | One tap, *after* an explicit opt-in: `sudo usermod -aG punktfunk-update $USER`. The tap starts a fixed, parameterless root oneshot (`punktfunk-client-update.service`) through polkit — nothing about the request is attacker-influenceable, and the payload comes from your distro's own signed repositories. |
| **pacman** | Same, plus the root-owned `PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf` — a partial upgrade is against Arch doctrine, so the only thing the helper will run is a full `pacman -Syu`. |
| **sysext, nix, a source build** | The plugin shows the command and stops. There is no feed behind those installs, and a button that can only fail is worse than one honest line. |
Whether a *newer* client exists is the client's own answer (`punktfunk-client --check-update`),
read from the Ed25519-signed per-channel manifest the host's update check already trusts —
`PUNKTFUNK_UPDATE_CHECK=0` disables the check, `PUNKTFUNK_UPDATE_APPLY=0` keeps the check but
never offers to install. A client too old to have that mode is reported as such rather than as
up to date.
## Build & sideload (development)
```sh
+165 -15
View File
@@ -22,8 +22,14 @@ The backend's jobs are the things Steam can't do:
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
* **check_update()** — poll the registry's per-channel ``manifest.json`` and report whether a
newer build is available (the frontend then drives Decky's own install RPC to apply it).
* **check_update()** — report pending updates for BOTH the plugin and the client. The plugin's
comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own
install RPC to apply it); the client's depends on how it was installed — a flatpak is compared
by OSTree commit here, anything else is asked of the client itself
(``punktfunk-client --check-update``, which verifies a signed manifest).
* **update_client()** — apply the client update by whichever route that install supports:
``flatpak update --user``, ``punktfunk-client --apply-update`` (the packaged root helper), or
a refusal carrying the command to run by hand.
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
the host advert in ``crates/punktfunk-host/src/discovery.rs``.
@@ -392,6 +398,18 @@ def _client_argv() -> list[str] | None:
return [native] if native else None
def _client_is_flatpak() -> bool:
"""Is the client this plugin actually drives the FLATPAK one?
Not the same question as "is the flatpak installed": `PF_DECKY_CLIENT=native` forces the
native binary on a box that has both, and the update check has to describe the client the
launcher will really run — otherwise a Deck with both would be offered a flatpak update for
a client it never starts.
"""
prefix = _client_argv()
return bool(prefix) and prefix[0] == _flatpak()
def _flatpak_env() -> dict:
"""Environment for a headless client run from the backend (no display needed for pairing).
Reconstruct the user-session bits flatpak wants; the backend may not inherit them. Harmless
@@ -562,7 +580,12 @@ async def _client_update_state() -> dict:
**per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and
it versions independently of this plugin — so we compare the installed commit against the
remote's here and let the QAM offer a user-scope update. Best-effort; all-``False`` on any error
(not installed, no flatpak, offline)."""
(not installed, no flatpak, offline).
Flatpak keeps its OWN comparison (commits, not versions) because it is the exact one: a
flatpak built from main between releases carries the release's crate version, so the
signed-manifest comparison the native path uses would call it up to date when it isn't.
Native installs have no commit to compare and go through :func:`_native_update_state`."""
state = {"available": False, "installed": "", "remote": ""}
rc, info = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
if rc != 0:
@@ -581,6 +604,50 @@ async def _client_update_state() -> dict:
return state
# --- native (non-flatpak) client updates ----------------------------------------------------
#
# A .deb/.rpm/pacman/sysext/nix client is not something this plugin can reason about on its own:
# working out whether a newer build exists means fetching a per-channel manifest and verifying
# its Ed25519 signature, and Decky's embedded Python has no crypto library to do that with (nor
# should the trust rule live in two languages). So the CLIENT answers both questions —
# `--check-update --json` says what is available and who could install it, `--apply-update`
# drives the packaged root helper — and this backend is a UI over those, exactly as it already
# is for `--pair` / `--library` / `--list-hosts`.
#
# Shape of `--check-update --json` (pf_client_core::update::Status):
# {kind, channel, current, latest, update_available, apply, applier, command,
# opt_in_hint?, notes_url, error?}
# `applier` is what this file routes on: "flatpak" (we run flatpak), "helper" (the client runs
# the root helper), "none" (show `command` — nothing here can install it).
async def _native_update_state() -> dict:
"""Ask a NATIVE client whether a newer build exists for its channel. Returns the client's
own status dict, or ``{}`` when it couldn't be asked (no native client, a client too old to
have the mode, offline). Best-effort by design: an unanswerable check must read as "can't
tell", never as "up to date"."""
rc, out, err = await _run_client(["--check-update", "--json"], timeout=30.0)
# The JSON is authoritative whenever there IS JSON, whatever the exit code: the client
# exits 0 up-to-date, 10 update-available, and 1 when the check failed — but in that last
# case it STILL prints a status carrying `error` plus the install kind and the command for
# this box, which is exactly what the UI needs to explain itself. Reading only the exit
# code would throw all of that away and report a bare "couldn't check".
if out.strip():
try:
data = json.loads(out)
if isinstance(data, dict):
return data
except json.JSONDecodeError:
decky.logger.warning("check-update: unparseable output: %s", out[:200])
if rc == -1:
return {}
# A client predating `--check-update` ignores the flag and falls through to GTK init, which
# fails headless — the same signature the other headless modes classify.
code = _classify_library_error(err)
decky.logger.info("native check-update unavailable (rc=%s, %s)", rc, code)
return {"error": code} if code == "client-outdated" else {}
def _split_txt(txt: str) -> list[str]:
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
tokens: list[str] = []
@@ -1138,14 +1205,71 @@ class Plugin:
return {"ok": False}
return {"ok": True}
async def _update_native_client(self) -> dict:
"""The non-flatpak leg of :meth:`update_client` — drive the client's own
``--apply-update``, which starts the packaged root helper.
The timeout is generous because a package-manager run on a stale box is slow; the
client caps its own wait at 30 min, so this one sits below that and reports a timeout
rather than hanging the QAM forever.
"""
state = await _native_update_state()
if not state:
# No client answered at all (none installed, or one too old for the mode) — say
# that, rather than "this install updates by hand", which would be a guess.
return {"ok": False, "updated": False, "error": "client-unavailable"}
applier = state.get("applier")
if applier != "helper":
# Nothing here can install it — hand back the command the client computed, so the
# UI shows one true line instead of guessing per install kind.
return {
"ok": False,
"updated": False,
"error": "manual",
"command": state.get("opt_in_hint") or state.get("command", ""),
}
rc, out, err = await _run_client(["--apply-update", "--json"], timeout=900.0)
if rc == -1:
return {"ok": False, "updated": False, "error": "timeout"}
outcome: dict = {}
if out.strip():
try:
outcome = json.loads(out)
except json.JSONDecodeError:
pass
if not outcome.get("ok"):
detail = outcome.get("error") or (err.strip().splitlines() or ["update failed"])[-1]
decky.logger.warning("native client update failed (rc=%s): %s", rc, detail)
return {"ok": False, "updated": False, "error": "update-failed", "detail": detail}
_update_cache["data"] = None # invalidate the cached "update available" snapshot
decky.logger.info(
"native client update: %s -> %s (changed=%s, staged=%s)",
outcome.get("before", ""), outcome.get("after", ""),
outcome.get("changed"), outcome.get("staged"),
)
return {
"ok": True,
"updated": bool(outcome.get("changed")),
"staged": bool(outcome.get("staged")),
}
async def update_client(self) -> dict:
"""Update the flatpak **client** (io.unom.Punktfunk) in the USER installation — the scope a
Steam Deck install lives in, which ``sudo flatpak update`` (system-scope) never reaches.
Returns whether a new commit was actually pulled. Best-effort; non-fatal. A NATIVE client
is updated by whatever installed it (distro package manager, sysext, nix), never here —
`check_update` reports no client update for one, so the UI never offers this."""
if not _flatpak_installed():
return {"ok": False, "updated": False, "error": "flatpak-not-found"}
"""Update the **client**, by whichever route this box's install actually supports.
* **flatpak** — ``flatpak update --user`` in the USER installation, the scope a Steam
Deck install lives in and which ``sudo flatpak update`` (system-scope) never reaches.
* **native, one-tap capable** (.deb / .rpm / pacman with the packaged root helper and
the operator's group opt-in) — ``punktfunk-client --apply-update``, which starts the
fixed, parameterless ``punktfunk-client-update.service`` through polkit. This backend
passes nothing to it; the helper derives everything from root-owned state.
* **anything else** (sysext, nix, a source build, no opt-in) — refused with the exact
command to run, which the UI shows. `check_update` reports the same, so the UI knows
not to offer a button in the first place.
Returns ``{ok, updated, error?, detail?, command?, staged?}``. Best-effort; non-fatal.
"""
if not _client_is_flatpak():
return await self._update_native_client()
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
before_commit = _field_from(before, "Commit")
rc, out = await _flatpak_capture(["update", "--user", "-y", APP_ID], timeout=300.0)
@@ -1183,6 +1307,13 @@ class Plugin:
"client_update_available": False,
"client_current": "",
"client_latest": "",
# How the client got here (`flatpak`, `apt`, `dnf`, `sysext`, `nix`, `source`, …),
# who could install an update (`flatpak` | `helper` | `none`), and the one line that
# does it by hand. Empty on a flatpak-only box that never reaches the native path.
"client_install": "",
"client_applier": "",
"client_command": "",
"client_opt_in": "",
}
now = time.monotonic()
@@ -1190,12 +1321,31 @@ class Plugin:
if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S:
return cached
# Client (flatpak) update — checked ALWAYS, even on a dev/sideloaded plugin build.
# Client update — checked ALWAYS, even on a dev/sideloaded plugin build. Which check
# runs depends on how the client was installed: the flatpak compares OSTree commits
# (exact for a per-user flatpak), everything else asks the client itself, which
# verifies the signed per-channel manifest. See _client_update_state / _native_update_state.
try:
cu = await _client_update_state()
result["client_update_available"] = bool(cu["available"])
result["client_current"] = (cu["installed"] or "")[:10]
result["client_latest"] = (cu["remote"] or "")[:10]
if _client_is_flatpak():
cu = await _client_update_state()
result["client_update_available"] = bool(cu["available"])
result["client_current"] = (cu["installed"] or "")[:10]
result["client_latest"] = (cu["remote"] or "")[:10]
result["client_install"] = "flatpak"
result["client_applier"] = "flatpak"
result["client_command"] = f"flatpak update --user {APP_ID}"
else:
nu = await _native_update_state()
result["client_update_available"] = bool(nu.get("update_available"))
result["client_current"] = str(nu.get("current", ""))
result["client_latest"] = str(nu.get("latest", ""))
result["client_install"] = str(nu.get("kind", ""))
result["client_applier"] = str(nu.get("applier", ""))
result["client_command"] = str(nu.get("command", ""))
result["client_opt_in"] = str(nu.get("opt_in_hint", "") or "")
if nu.get("error"):
# "Couldn't tell" — never rendered as up to date; the UI shows the reason.
result["client_error"] = str(nu["error"])
except Exception: # noqa: BLE001
decky.logger.warning("client update check failed", exc_info=True)
+25 -6
View File
@@ -124,11 +124,20 @@ export interface UpdateInfo {
hash: string; // sha256 of that zip (Decky verifies it)
channel: string; // "latest" (stable) | "canary"
update_available: boolean; // a newer PLUGIN build is available
// The flatpak CLIENT (io.unom.Punktfunk) versions independently and is a per-user install, so
// `sudo flatpak update` never touches it — the plugin offers a user-scope update instead.
// The CLIENT versions independently of this plugin, and how it updates depends on how it was
// installed. A flatpak is a per-user install `sudo flatpak update` never touches, compared by
// OSTree commit; every other install (.deb/.rpm/pacman/sysext/nix/source) is compared by the
// client itself against the signed per-channel manifest (`punktfunk-client --check-update`).
client_update_available: boolean;
client_current: string; // installed client commit (short) — informational
client_latest: string; // remote client commit (short) — informational
client_current: string; // installed client commit (flatpak) or version (native)
client_latest: string; // newest client commit (flatpak) or version (native)
client_install: string; // "flatpak" | "apt" | "dnf" | "pacman" | "sysext" | "nix" | "source" | ""
// Who can perform the update: "flatpak" (this plugin runs it), "helper" (the client drives the
// packaged root helper), "none" (nothing here can — show `client_command`).
client_applier: string;
client_command: string; // one copy-pastable line that updates this install by hand
client_opt_in: string; // set when one-tap WOULD work after `usermod -aG punktfunk-update`
client_error?: string; // the client check couldn't complete (e.g. "client-outdated")
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
}
@@ -206,8 +215,18 @@ export const probeHost = callable<
{ ok: boolean; online?: boolean; error?: string }
>("probe_host");
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
// Update the client by whichever route its install supports: `flatpak update --user` for the
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
// native install. Everything else comes back `ok: false, error: "manual"` with `command` — the
// line to run by hand. A package-manager run can take minutes; the backend allows 15.
export const updateClient = callable<
[],
{ ok: boolean; updated: boolean; error?: string }
{
ok: boolean;
updated: boolean;
staged?: boolean; // installed, but a reboot activates it (rpm-ostree)
error?: string;
detail?: string;
command?: string; // set with error "manual"
}
>("update_client");
+114 -17
View File
@@ -240,11 +240,61 @@ export function useUpdate() {
return { info, checking, check };
}
/** True when EITHER the plugin or the flatpak client has a pending update. */
/** True when EITHER the plugin or the client has a pending update. */
export function hasUpdate(info: UpdateInfo | null | undefined): boolean {
return !!info && (info.update_available || info.client_update_available);
}
/**
* Can this Deck actually INSTALL the pending client update, or only tell you how?
*
* A flatpak and a one-tap-capable native install (the packaged root helper + the operator's
* group opt-in) get a button; a sysext, a nix profile, a source build or a box that hasn't
* opted in gets the command. Offering a button that can only fail is worse than saying so.
*/
export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boolean {
return (
!!info &&
info.client_update_available &&
(info.client_applier === "flatpak" || info.client_applier === "helper")
);
}
/**
* How the client got onto this box, in words a Deck user recognises. The raw kind comes from
* the client's own detector (`pf_update_check::detect`); anything unmapped falls through as
* itself rather than as "unknown", because the raw word is still more useful than a shrug.
*/
export function clientInstallLabel(kind: string): string {
switch (kind) {
case "flatpak":
return "Flatpak (per-user)";
case "apt":
return "System package (apt)";
case "dnf":
return "System package (dnf)";
case "rpm-ostree":
return "Layered package (rpm-ostree)";
case "pacman":
return "System package (pacman)";
case "sysext":
return "System extension (sysext)";
case "nix":
return "Nix profile";
case "steamos-source":
return "On-device build";
case "source":
return "Built from source";
default:
return kind;
}
}
/** True when the only pending update is one this Deck can't apply itself. */
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
}
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
export async function checkForUpdatesNow(
check: (force: boolean) => Promise<UpdateInfo | null>,
@@ -256,8 +306,20 @@ export async function checkForUpdatesNow(
} else if (hasUpdate(res)) {
const parts: string[] = [];
if (res.update_available) parts.push(`plugin v${res.current} → v${res.latest}`);
if (res.client_update_available) parts.push("client");
if (res.client_update_available) {
parts.push(res.client_latest ? `client ${res.client_latest}` : "client");
}
body = `Update available: ${parts.join(" + ")}.`;
if (clientUpdateIsManualOnly(res)) {
// Say the honest thing up front rather than letting the user find out at the button.
body += " The client updates outside Punktfunk on this install.";
}
} else if (res.client_error) {
// A failed CLIENT check must never read as "up to date" — that is the one wrong answer.
body =
res.client_error === "client-outdated"
? "Couldnt check the client — it predates update checks. Update it once by hand."
: "Couldnt check the client for updates.";
} else if (res.error === "update-channel-unknown") {
body = "Development build — plugin updates are disabled; the client is up to date.";
} else {
@@ -266,32 +328,67 @@ export async function checkForUpdatesNow(
toaster.toast({ title: "Punktfunk", body });
}
/** One line of user-facing copy for whatever `updateClient()` came back with. */
function clientUpdateResultBody(r: Awaited<ReturnType<typeof updateClient>>): string {
if (r.ok) {
if (r.staged) return "Client updated — reboot to finish.";
return r.updated ? "Client updated to the latest version." : "Client is already up to date.";
}
// "manual" is not a failure: the box simply can't install it, and `command` says how.
if (r.error === "manual") {
return r.command
? `This client updates outside Punktfunk. Run: ${r.command}`
: "This client updates outside Punktfunk — use the way you installed it.";
}
if (r.error === "timeout") return "Client update timed out — check the box and try again.";
if (r.error === "client-unavailable")
return "Couldnt reach the client to update it — is it still installed?";
return `Client update failed${r.detail ? `: ${r.detail}` : r.error ? ` (${r.error})` : ""}.`;
}
/**
* Apply whichever updates are pending. The flatpak CLIENT is updated first (a user-scope
* `flatpak update`, awaited); then, if the PLUGIN itself has an update, Decky's install RPC
* reinstalls it — which reloads the plugin and tears this panel down, so it goes last and is
* fire-and-forget. `check` (when passed) refreshes the panel state after a client-only update so
* the "Update available" button clears.
* Apply whichever updates are pending.
*
* The CLIENT goes first and is awaited, by whichever route its install supports — a user-scope
* `flatpak update`, or the packaged root helper via `punktfunk-client --apply-update`. An
* install neither can serve is not attempted at all: the user gets the command in a toast,
* because a button that can only fail teaches nothing.
*
* The PLUGIN goes last and is fire-and-forget: Decky's install RPC reinstalls and reloads the
* plugin, tearing this panel down before any result could arrive. `check` (when passed)
* refreshes the panel state after a client-only update so the "Update available" button clears.
*/
export async function applyUpdate(
info: UpdateInfo,
check?: (force: boolean) => Promise<UpdateInfo | null>,
): Promise<void> {
if (info.client_update_available) {
toaster.toast({ title: "Punktfunk", body: "Updating the client…" });
if (info.client_update_available && clientUpdateIsOneTap(info)) {
toaster.toast({
title: "Punktfunk",
// A package-manager run is not instant; say so before the wait, not after.
body:
info.client_applier === "helper"
? "Updating the client — this can take a few minutes…"
: "Updating the client…",
});
try {
const r = await updateClient();
toaster.toast({
title: "Punktfunk",
body: !r.ok
? `Client update failed${r.error ? ` (${r.error})` : ""}.`
: r.updated
? "Client updated to the latest version."
: "Client is already up to date.",
});
toaster.toast({ title: "Punktfunk", body: clientUpdateResultBody(r) });
} catch {
toaster.toast({ title: "Punktfunk", body: "Client update failed." });
}
} else if (info.client_update_available) {
// Nothing here can install it — hand over the one line that does, rather than a button
// that would fail. `client_opt_in` wins when joining the group is what's missing, since
// that is the step that turns this into a one-tap update from then on.
const line = info.client_opt_in || info.client_command;
toaster.toast({
title: "Punktfunk",
body: line
? `Client update available (${info.client_latest}). Run: ${line}`
: `A newer client (${info.client_latest}) is available — update it the way you installed it.`,
duration: 12_000,
});
}
if (info.update_available) {
+37 -21
View File
@@ -25,6 +25,7 @@ import { PluginErrorBoundary } from "./boundary";
import {
applyUpdate,
checkForUpdatesNow,
clientUpdateIsManualOnly,
hasUpdate,
mergeHosts,
needsPair,
@@ -72,27 +73,42 @@ const QamPanel: FC = () => {
return (
<>
{hasUpdate(update) && (
<PanelSection title="Update available">
<PanelSectionRow>
<ButtonItem
layout="below"
onClick={() => applyUpdate(update!, check)}
label={
update!.update_available
? `Plugin v${update!.current} → v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: "New client version"
}
description="Installing can take a couple of minutes"
>
<FaDownload style={{ marginRight: "0.5em" }} />
Update Punktfunk
</ButtonItem>
</PanelSectionRow>
</PanelSection>
)}
{hasUpdate(update) &&
// A client this Deck can't install (a sysext, a nix profile, a source build, or a box
// that hasn't opted into one-tap updates) gets the command, not a button — tapping
// something that can only fail is worse than reading one line. A pending PLUGIN update
// still wins the button, since that half always works.
(clientUpdateIsManualOnly(update) && !update!.update_available ? (
<PanelSection title="Client update available">
<PanelSectionRow>
<Field
focusable
label={update!.client_latest || "Newer version"}
description={update!.client_opt_in || update!.client_command}
/>
</PanelSectionRow>
</PanelSection>
) : (
<PanelSection title="Update available">
<PanelSectionRow>
<ButtonItem
layout="below"
onClick={() => applyUpdate(update!, check)}
label={
update!.update_available
? `Plugin v${update!.current} → v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: "New client version"
}
description="Installing can take a couple of minutes"
>
<FaDownload style={{ marginRight: "0.5em" }} />
Update Punktfunk
</ButtonItem>
</PanelSectionRow>
</PanelSection>
))}
<PanelSection title="Punktfunk">
<PanelSectionRow>
+38 -8
View File
@@ -37,6 +37,8 @@ import {
PinsApi,
applyUpdate,
checkForUpdatesNow,
clientInstallLabel,
clientUpdateIsManualOnly,
hasUpdate,
mergeHosts,
needsPair,
@@ -391,6 +393,16 @@ const AboutTab: FC<{
</DialogButton>
</RowActions>
</Field>
{/* What the client IS, so "why is there no Update button?" has a visible answer. The
install kind decides everything below it. */}
{!!update?.client_install && (
<Field
label="Client"
description={`${clientInstallLabel(update.client_install)}${
update.client_current ? ` · ${update.client_current}` : ""
}`}
/>
)}
{hasUpdate(update) && (
<Field
label={
@@ -398,19 +410,37 @@ const AboutTab: FC<{
? `Plugin update — v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: "Client update available"
: `Client update ${update!.client_latest || "available"}`
}
description={
// Only promise a one-tap install when there is one. On a notify-only install the
// row becomes the command itself, which is the whole answer for that box.
clientUpdateIsManualOnly(update) && !update!.update_available
? update!.client_opt_in || update!.client_command
: "Installing can take a couple of minutes; Decky reloads the plugin when done"
}
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
</RowActions>
{clientUpdateIsManualOnly(update) && !update!.update_available ? null : (
<RowActions>
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
</RowActions>
)}
</Field>
)}
{!!update?.client_error && (
<Field
label="Client update check"
description={
update.client_error === "client-outdated"
? "This client predates update checks — update it once by hand and the check starts working."
: "Couldnt check the client for updates."
}
/>
)}
<Field
label="Setup guide"
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"