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 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; + }; +}