From 5352551d25696a5e12787a5989d1d2be0498fb06 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 19 Aug 2026 18:10:02 +0800 Subject: [PATCH 1/2] fix(ci): pin the ruff download checksum in CI Applies the same fix as #9 to ci.yml, which was left with the identical gap. ruff-action v4.1.0 ships known checksums only up to ruff 0.15.20 and resolves no checksum from the manifest unless manifest-file is set, so pinning 0.16.0 installed ruff with no integrity verification over the Astral CDN mirror. Same artifact and runner as the release workflow (ubuntu-latest, x86_64, unknown-linux-gnu), so the same SHA-256 applies. --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8d580d..9766b27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,11 @@ jobs: uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0 with: version: "0.16.0" + # v4.1.0 ships known checksums only up to 0.15.20, and the action + # downloads from the Astral CDN mirror, so 0.16.0 would otherwise be + # installed unverified. SHA-256 of ruff-x86_64-unknown-linux-gnu.tar.gz + # (ubuntu-latest target) from the ruff 0.16.0 release assets. + checksum: "98001c995a134d95f9bc83106a7f94b552971b583f1c0ab75fb656a881e13865" args: check main.py decky.pyi tests scripts - name: Check Python formatting From 33010830757c5f141e6a0a504b751b726d1fdb4e Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Fri, 28 Aug 2026 18:37:47 +0800 Subject: [PATCH 2/2] feat: show Core update status and offer to update The plugin bootstraps Core and then leaves updating to it, but the panel never said so. Nothing showed whether an update existed, and there was no way to ask for one from the Deck. Core's new update.status reports what its last check found without contacting the release server or writing anything, so it rides along with the status poll that already runs rather than needing a button press before anything can be known. update.check and update.apply are the two that cost something and only ever run from the button. The line is ordered by what already happened to the device: an update that was rolled back outranks one that is waiting, which outranks one merely available. A staged rollout says so, because being shown a version that then does not install reads as something broken. A successful update says nothing, since the version beside it is already the announcement. The button installs when there is something to install and checks otherwise, and is disabled where updates do not apply at all or where the gate has already refused and explained itself in the status line. --- main.py | 18 ++++++- src/Content.tsx | 40 +++++++++++++++ src/api.ts | 8 +++ src/display.test.ts | 118 ++++++++++++++++++++++++++++++++++++++++++++ src/display.ts | 46 ++++++++++++++++- src/types.ts | 23 +++++++++ 6 files changed, 251 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 767f278..dd8b10c 100644 --- a/main.py +++ b/main.py @@ -841,9 +841,14 @@ async def get_status(self) -> dict[str, Any]: self._rpc("clients"), self._rpc("settings.backup.status"), self._rpc("inbox"), + # Cheap on purpose: update.status reports what Core's last check + # found, without contacting the release server or writing anything, + # so it can ride along with the status poll instead of needing a + # button press to learn anything. + self._rpc("update.status"), ) results = await asyncio.gather(*calls, return_exceptions=True) - keys = ("readers", "tokens", "media", "settings", "clients", "backup", "inbox") + keys = ("readers", "tokens", "media", "settings", "clients", "backup", "inbox", "update") status: dict[str, Any] = { "connected": True, "pluginVersion": PLUGIN_VERSION, @@ -860,6 +865,17 @@ async def get_status(self) -> dict[str, Any]: async def stop_media(self) -> Any: return await self._rpc("stop", timeout=5.0) + async def check_for_update(self) -> Any: + # The one update call that goes to the network. Only ever from a button, + # never from the status poll. + return await self._rpc("update.check", timeout=60.0) + + async def apply_update(self) -> Any: + # Core stages, swaps the binary and restarts itself, which is well past + # the default timeout. The plugin only bootstraps Core; the update + # itself, and the rollback if it will not start, are Core's own. + return await self._rpc("update.apply", timeout=600.0) + async def write_tag(self, text: str, reader_id: str | None = None) -> Any: params: dict[str, Any] = {"text": text} if reader_id: diff --git a/src/Content.tsx b/src/Content.tsx index 3ff018c..7c73e66 100644 --- a/src/Content.tsx +++ b/src/Content.tsx @@ -19,8 +19,10 @@ import { cancelOnlineLink, claimClientPairing, claimOnlineLink, + applyUpdate, cancelWrite, completeClientPairing, + checkForUpdate, dismissInboxMessage, dismissSecurityPrompt, getBootstrapStatus, @@ -61,6 +63,9 @@ import { onlineAccountLabel, pairingCountdown, readerCountLabel, + updateActionDisabled, + updateActionLabel, + updateStatusLabel, } from "./display"; import { closeModal, isModalLifecycleActive, registerModal } from "./modalLifecycle"; import { indexingStatusFromNotification, notificationInvalidatesStatus } from "./notifications"; @@ -777,6 +782,31 @@ export function Content() { } }; + // The plugin bootstraps Core and then stays out of the way: this asks Core to + // check or install, and Core owns the staging, the restart, and the rollback + // if the new version will not start. + const runUpdateAction = async () => { + setBusy("update"); + setActionError(undefined); + try { + if (status.update?.updateAvailable) { + const applied = await applyUpdate(); + showActionFailure( + `Zaparoo Core ${applied.newVersion} is installed and is restarting. ` + + "If it does not start correctly the previous version is restored automatically.", + ); + } else { + const checked = await checkForUpdate(); + if (!checked.updateAvailable) showActionFailure("Zaparoo Core is up to date."); + } + } catch (error) { + showActionFailure(`Could not update Zaparoo Core: ${String(error)}`); + } finally { + await refresh(); + if (mounted.current) setBusy(null); + } + }; + const saveReaderSettings = async (params: ReaderSettingsUpdate) => { setBusy("reader-settings"); setActionError(undefined); @@ -1521,10 +1551,20 @@ export function Content() { + + + + {busy === "update" ? "Working…" : updateActionLabel(status.update)} + + ("update_media_database"); export const cancelMediaDatabaseUpdate = callable<[], void>("cancel_media_database_update"); export const resumeMediaDatabaseUpdate = callable<[], void>("resume_media_database_update"); +// Core update controls. Status arrives with the ordinary status poll because +// it is a local read; these two are the ones that cost something and so only +// ever run from a button. +export const checkForUpdate = callable<[], UpdateStatus>("check_for_update"); +export const applyUpdate = callable<[], { previousVersion: string; newVersion: string }>( + "apply_update", +); const CORE_NOTIFICATION_EVENT = "core_notification"; const CORE_CONNECTION_EVENT = "core_connection"; diff --git a/src/display.test.ts b/src/display.test.ts index c3c3867..8a5c498 100644 --- a/src/display.test.ts +++ b/src/display.test.ts @@ -6,6 +6,9 @@ import { formatPairingPIN, inboxSeverityLabel, lastScannedID, + updateActionDisabled, + updateActionLabel, + updateStatusLabel, onlineAccountLabel, pairingCountdown, readerCountLabel, @@ -101,3 +104,118 @@ describe("lastScannedID", () => { expect(lastScannedID({ ...token, uid: "game" })).toBeUndefined(); }); }); + +describe("updateStatusLabel", () => { + it("says nothing is known when Core has not answered", () => { + expect(updateStatusLabel(undefined)).toBe("Unknown"); + }); + + it("reports an available update", () => { + expect( + updateStatusLabel({ + currentVersion: "2.10.0", + latestVersion: "2.11.0", + updateAvailable: true, + autoInstall: false, + }), + ).toBe("2.11.0 available"); + }); + + // Being told a version exists without being told why it is not installing + // reads as something broken. + it("explains a staged rollout rather than just offering the version", () => { + expect( + updateStatusLabel({ + currentVersion: "2.10.0", + latestVersion: "2.11.0", + updateAvailable: true, + rolloutHeld: true, + autoInstall: true, + }), + ).toBe("2.11.0 available, rolling out gradually"); + }); + + // Something already happened to this device, so it outranks the offer. + it("puts a rollback ahead of an available update", () => { + expect( + updateStatusLabel({ + currentVersion: "2.10.0", + latestVersion: "2.11.0", + updateAvailable: true, + autoInstall: true, + lastResult: { at: "2026-08-28T09:21:57Z", outcome: "rolledBack", toVersion: "2.11.0" }, + }), + ).toBe("Update to 2.11.0 was rolled back"); + }); + + it("does not announce an update that simply worked", () => { + expect( + updateStatusLabel({ + currentVersion: "2.11.0", + updateAvailable: false, + autoInstall: false, + lastResult: { at: "2026-08-28T09:16:28Z", outcome: "succeeded", toVersion: "2.11.0" }, + }), + ).toBe("Up to date"); + }); + + it("says when Core will install on its own", () => { + expect( + updateStatusLabel({ currentVersion: "2.11.0", updateAvailable: false, autoInstall: true }), + ).toBe("Up to date, installs automatically"); + }); + + it("explains why a development build never updates", () => { + expect( + updateStatusLabel({ + currentVersion: "abc123-dev", + updateAvailable: false, + autoInstall: false, + eligibility: "development", + }), + ).toBe("Development build"); + }); +}); + +describe("updateActionLabel and updateActionDisabled", () => { + it("offers to install when there is something to install", () => { + const update = { + currentVersion: "2.10.0", + latestVersion: "2.11.0", + updateAvailable: true, + autoInstall: false, + }; + expect(updateActionLabel(update)).toBe("Install Update"); + expect(updateActionDisabled(update)).toBe(false); + }); + + it("offers to check when nothing is known to be available", () => { + const update = { currentVersion: "2.11.0", updateAvailable: false, autoInstall: false }; + expect(updateActionLabel(update)).toBe("Check for Updates"); + expect(updateActionDisabled(update)).toBe(false); + }); + + // The gate has already said no and explained itself in the status line. + it("cannot act through a gate that will not be forced", () => { + expect( + updateActionDisabled({ + currentVersion: "2.10.0", + latestVersion: "2.11.0", + updateAvailable: true, + autoInstall: false, + blockedBy: { reason: "indexing", message: "media is being indexed", forceable: false }, + }), + ).toBe(true); + }); + + it("cannot act where updates do not apply at all", () => { + expect( + updateActionDisabled({ + currentVersion: "2.11.0", + updateAvailable: false, + autoInstall: false, + eligibility: "managed", + }), + ).toBe(true); + }); +}); diff --git a/src/display.ts b/src/display.ts index 4e03524..c6fbcad 100644 --- a/src/display.ts +++ b/src/display.ts @@ -1,4 +1,4 @@ -import type { DatabaseStatus, InboxMessage, RemoteBackupStatus, TokenInfo } from "./types"; +import type { DatabaseStatus, InboxMessage, RemoteBackupStatus, TokenInfo, UpdateStatus } from "./types"; export function readerCountLabel(count: number): string { if (count === 0) return "None connected"; @@ -58,3 +58,47 @@ export function lastScannedID(token?: TokenInfo): string | undefined { if (!token?.uid || token.uid === "__api__" || token.uid === token.text) return undefined; return token.uid; } + +// updateStatusLabel is the line shown beside the Core version. +// +// Ordered by what already happened to the device: an update that failed and was +// undone matters more than one that is waiting, which matters more than one +// merely available. A successful update says nothing, because the version shown +// next to it is already the announcement. +export function updateStatusLabel(update?: UpdateStatus): string { + if (!update) return "Unknown"; + + if (update.eligibility === "development") return "Development build"; + if (update.eligibility === "managed") return "Managed externally"; + if (update.eligibility === "unsupported") return "Not supported here"; + + switch (update.lastResult?.outcome) { + case "rolledBack": + return `Update to ${update.lastResult.toVersion ?? "a new version"} was rolled back`; + case "rollbackBlocked": + return "Update failed and could not be undone"; + case "recoveryRequired": + return "An interrupted update needs attention"; + } + + if (update.updateAvailable) { + if (update.blockedBy) return `${update.latestVersion} available - ${update.blockedBy.message}`; + if (update.rolloutHeld) return `${update.latestVersion} available, rolling out gradually`; + return `${update.latestVersion} available`; + } + return update.autoInstall ? "Up to date, installs automatically" : "Up to date"; +} + +// updateActionLabel names the button. Core only installs on its own when the +// owner turned that on, so the button is the ordinary way an update happens and +// should say which of the two things it is about to do. +export function updateActionLabel(update?: UpdateStatus): string { + return update?.updateAvailable ? "Install Update" : "Check for Updates"; +} + +// updateActionDisabled reports whether the button can do anything at all. +export function updateActionDisabled(update?: UpdateStatus): boolean { + if (!update) return true; + if (["development", "managed", "unsupported"].includes(update.eligibility ?? "")) return true; + return Boolean(update.updateAvailable && update.blockedBy && !update.blockedBy.forceable); +} diff --git a/src/types.ts b/src/types.ts index 406c0dd..620a8cc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -168,5 +168,28 @@ export interface PluginStatus { clients?: { clients: PairedClient[] }; backup?: BackupStatus; inbox?: { messages: InboxMessage[] }; + update?: UpdateStatus; errors?: Record; } + +// UpdateStatus is Core's update.status response. The plugin bootstraps Core and +// then leaves updating to it, so this only reports what Core already decided. +export interface UpdateStatus { + currentVersion: string; + latestVersion?: string; + updateAvailable: boolean; + autoInstall: boolean; + rolloutHeld?: boolean; + channel?: string; + eligibility?: string; + checkedAt?: string; + deferredReason?: string; + blockedBy?: { reason: string; message: string; forceable: boolean }; + lastResult?: { + at: string; + outcome: string; + fromVersion?: string; + toVersion?: string; + detail?: string; + }; +}