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
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:
co-authored by
Claude Opus 5
parent
b164b4c03e
commit
21787ca6cd
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
? "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<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 "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<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
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
: "Couldn’t check the client for updates."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Setup guide"
|
||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||
|
||||
Reference in New Issue
Block a user