/** * In-app update: decide what to offer, and on which platform. * * TRACES: UR-077 | DR-217 * * Until this existed there was no upgrade path at all. Somebody who installed a * `.AppImage` or ran the NSIS installer stayed on that version permanently and * had no way to learn a newer one existed — the release notes were the only * announcement, and nothing in the app ever read them. * * ## Two platforms, two answers * * `tauri-plugin-updater` replaces the running application's own files. That is * possible on Linux and Windows and impossible on Android, where installing an * APK is the package installer's job and an app may not overwrite itself. So * the plugin is compiled only for desktop (see `Cargo.toml`'s * `cfg(not(any(target_os = "android", target_os = "ios")))` table) and Android * gets the honest alternative: a link to the releases page. * * That asymmetry is the whole reason this module is a pure decision function * plus a thin caller. `decideUpdateAction` takes the platform and what the * endpoint said and returns *what to offer*; the Svelte side does the offering. * The alternative — `if (platform() === "android")` sprinkled through a * component — is exactly the shape that has bitten this codebase before. * * ## Versions * * Comparison is left to the updater plugin on desktop, which compares against * the version baked into the bundle. `isNewerVersion` exists for the Android * path, where nothing native is available to do it, and for tests. */ import { createLogger } from "$lib/utils/logger"; const log = createLogger("UpdateCheck"); /** Where a user goes to fetch a build by hand. */ export const RELEASES_URL = "https://gitea.tourolle.paris/dtourolle/jellytau/releases"; /** What the UI should offer the user. */ export type UpdateAction = /** Nothing to do — already current, or the check failed and we stay quiet. */ | { kind: "none" } /** Desktop: the plugin can download, verify and install this itself. */ | { kind: "install"; version: string; notes: string | null } /** Android: we can only point at the download. */ | { kind: "open-releases"; version: string; url: string }; /** * Compare two semver-ish version strings. * * Deliberately small: JellyTau's versions are `MAJOR.MINOR.PATCH` with an * optional `-rc1`/`-beta` suffix (build-release.yml keys prerelease off exactly * those). A pre-release sorts *below* the same numeric version, so 0.9.2-rc1 * does not offer itself as an upgrade to somebody on 0.9.2. * * Returns true when `candidate` is strictly newer than `current`. */ export function isNewerVersion(candidate: string, current: string): boolean { const parse = (raw: string) => { const cleaned = raw.trim().replace(/^v/, ""); const [core, ...rest] = cleaned.split("-"); const parts = core.split(".").map((n) => Number.parseInt(n, 10)); return { nums: [parts[0] || 0, parts[1] || 0, parts[2] || 0], // Any suffix at all makes it a pre-release. pre: rest.length > 0, }; }; const a = parse(candidate); const b = parse(current); for (let i = 0; i < 3; i++) { if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i]; } // Same numbers: a release beats a pre-release, nothing beats a release. return b.pre && !a.pre; } /** What a platform can do about an available update. */ export type UpdateCapability = "install" | "link-only"; /** * Map a platform string (from `@tauri-apps/plugin-os`) to what it can do. * * Android is link-only because the updater plugin is not compiled there at all; * calling it would throw rather than degrade. */ export function updateCapability(platform: string): UpdateCapability { return platform === "android" || platform === "ios" ? "link-only" : "install"; } /** * Decide what to offer. * * `available` is null when the endpoint reported no newer version, or when the * check failed — the caller collapses both, because the UI treatment is the * same and a failed update check must never interrupt somebody watching * something. */ export function decideUpdateAction( platform: string, available: { version: string; notes?: string | null } | null, ): UpdateAction { if (!available) return { kind: "none" }; if (updateCapability(platform) === "link-only") { return { kind: "open-releases", version: available.version, url: RELEASES_URL }; } return { kind: "install", version: available.version, notes: available.notes ?? null }; } /** * Check for an update and return what to offer. * * Everything here is best-effort: a server that is down, an endpoint that * 404s, or a machine with no network must produce a quiet "none", never an * error the user sees. An update check is not something the user asked for. */ export async function checkForUpdate(): Promise { try { const { platform } = await import("@tauri-apps/plugin-os"); const current = platform(); if (updateCapability(current) === "link-only") { // Nothing native to ask on Android. Offering the releases page // unconditionally would nag on every launch, so the mobile path is // surfaced from Settings on demand rather than checked automatically. return { kind: "none" }; } const { check } = await import("@tauri-apps/plugin-updater"); const update = await check(); if (!update) return { kind: "none" }; log.info("update available", update.version); return decideUpdateAction(current, { version: update.version, notes: update.body ?? null }); } catch (error) { log.warn("update check failed; staying quiet", error); return { kind: "none" }; } } /** * Download, verify and install a desktop update, then relaunch. * * The signature check happens inside the plugin against the public key in * `tauri.conf.json` — an artifact that does not verify is refused there, which * is the entire security value of the updater. `onProgress` is fed the fraction * downloaded so the UI can show something during what may be a 100 MB fetch. */ export async function installUpdate(onProgress?: (fraction: number) => void): Promise { const { check } = await import("@tauri-apps/plugin-updater"); const update = await check(); if (!update) return; let downloaded = 0; let contentLength = 0; await update.downloadAndInstall((event) => { switch (event.event) { case "Started": contentLength = event.data.contentLength ?? 0; break; case "Progress": downloaded += event.data.chunkLength; if (contentLength > 0) onProgress?.(downloaded / contentLength); break; case "Finished": onProgress?.(1); break; } }); const { relaunch } = await import("@tauri-apps/plugin-process"); await relaunch(); }