From 21787ca6cd7fcdd548ab482a4405a008a7d732e6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 01:45:23 +0200 Subject: [PATCH] feat(decky): the plugin updates the actual client, not only the flatpak one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- clients/decky/README.md | 19 ++++ clients/decky/main.py | 180 ++++++++++++++++++++++++++++++++--- clients/decky/src/backend.ts | 31 ++++-- clients/decky/src/hooks.ts | 131 +++++++++++++++++++++---- clients/decky/src/index.tsx | 58 +++++++---- clients/decky/src/page.tsx | 46 +++++++-- 6 files changed, 398 insertions(+), 67 deletions(-) diff --git a/clients/decky/README.md b/clients/decky/README.md index be63aa4b..cb6aa0b4 100644 --- a/clients/decky/README.md +++ b/clients/decky/README.md @@ -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 diff --git a/clients/decky/main.py b/clients/decky/main.py index d1edeb18..d804bc0e 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -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) diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index 515e9c9c..890cd5dc 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -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"); diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index 5276a53c..75e9501a 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -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, @@ -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" + ? "Couldn’t check the client — it predates update checks. Update it once by hand." + : "Couldn’t 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>): 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 "Couldn’t 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, ): Promise { - 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) { diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 6d973565..9d1c5a2a 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -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) && ( - - - 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" - > - - Update Punktfunk - - - - )} + {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 ? ( + + + + + + ) : ( + + + 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" + > + + Update Punktfunk + + + + ))} diff --git a/clients/decky/src/page.tsx b/clients/decky/src/page.tsx index c2431906..cae0640f 100644 --- a/clients/decky/src/page.tsx +++ b/clients/decky/src/page.tsx @@ -37,6 +37,8 @@ import { PinsApi, applyUpdate, checkForUpdatesNow, + clientInstallLabel, + clientUpdateIsManualOnly, hasUpdate, mergeHosts, needsPair, @@ -391,6 +393,16 @@ const AboutTab: FC<{ + {/* 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 && ( + + )} {hasUpdate(update) && ( - - applyUpdate(update!, check)}> - - Update - - + {clientUpdateIsManualOnly(update) && !update!.update_available ? null : ( + + applyUpdate(update!, check)}> + + Update + + + )} )} + {!!update?.client_error && ( + + )}