+ {#if a}
+
Update available
+
Prospack {a.version}
+
You have {updates.current}.
+
+ {#if a.notes.trim()}
+
+ {#each a.notes.split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean) as line}
+
{line}
+ {/each}
+
+ {/if}
+
+ {#if updates.installing}
+
+
+ {#if updates.progress?.total && updates.progress.got < updates.progress.total}
+ Downloading {mb(updates.progress.got)} of {mb(updates.progress.total)} MB
+ {:else}
+ Installing. Prospack will restart in a moment.
+ {/if}
+
+ {:else if updates.lastError}
+
The update didn't install: {updates.lastError}
+ {/if}
+
+
+ {/if}
+
+
+
diff --git a/src/lib/api.js b/src/lib/api.js
index b42d608..b7b5392 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -6,15 +6,17 @@ export const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in
let real = null;
async function tauri() {
if (!real) {
- const [core, event, dialog, opener, win, webview] = await Promise.all([
+ const [core, event, dialog, opener, win, webview, updater, process] = await Promise.all([
import("@tauri-apps/api/core"),
import("@tauri-apps/api/event"),
import("@tauri-apps/plugin-dialog"),
import("@tauri-apps/plugin-opener"),
import("@tauri-apps/api/window"),
import("@tauri-apps/api/webview"),
+ import("@tauri-apps/plugin-updater"),
+ import("@tauri-apps/plugin-process"),
]);
- real = { core, event, dialog, opener, win, webview };
+ real = { core, event, dialog, opener, win, webview, updater, process };
}
return real;
}
@@ -37,6 +39,11 @@ export const api = isTauri
ps5Status: (value) => call("ps5_status", { value: value ?? null }),
discoverPs5: () => call("discover_ps5"),
freeSpace: (path) => call("free_space", { path }),
+ installKind: () => call("install_kind"),
+ appVersion: async () => (await import("@tauri-apps/api/app")).getVersion(),
+ // Returns the updater's Update object (or null). Throws if the update feed can't be read.
+ checkUpdate: async () => (await tauri()).updater.check(),
+ relaunch: async () => (await tauri()).process.relaunch(),
on: async (name, fn) => (await tauri()).event.listen(name, (e) => fn(e.payload)),
pickFolder: async (title) => (await tauri()).dialog.open({ directory: true, multiple: false, title }),
reveal: async (path) => (await tauri()).opener.revealItemInDir(path),
diff --git a/src/lib/mock.js b/src/lib/mock.js
index 1921ff3..8318299 100644
--- a/src/lib/mock.js
+++ b/src/lib/mock.js
@@ -9,7 +9,7 @@ let settings = {
setupDone: new URLSearchParams(location.search).has("setup") ? false : true,
ps5Host: "192.168.0.50", ftpPort: 2121, webPort: 8888, remoteDir: "/data/homebrew",
outputDir: "/home/you/PS5 Images", compressionLevel: 7, cpuCount: 0, verifyWorkers: 0,
- autoUpload: true, writeSmpHints: true, markGcValidated: true, deleteLocalAfterUpload: false,
+ autoUpload: true, writeSmpHints: true, markGcValidated: true, checkUpdates: true, deleteLocalAfterUpload: false,
};
const sample = {
@@ -125,6 +125,26 @@ export const mock = {
resumeJob: async (id) => { const j = jobs.find((x) => x.id === id); if (j) { j.status = "running"; j.stages[j.stage].state = "live"; } emit("jobs", structuredClone(jobs)); },
discoverPs5: async () => { await delay(1600); return [{ ip: "192.168.0.50", port: 2121, label: "ftpsrv v1.6.22", banner: "" }]; },
freeSpace: async () => 529_000_000_000,
+ installKind: async () => new URLSearchParams(location.search).get("kind") ?? "appimage",
+ appVersion: async () => "0.1.0",
+ checkUpdate: async () => {
+ await delay(700);
+ if (!new URLSearchParams(location.search).has("update")) return null;
+ return {
+ version: "0.2.0",
+ body: "Faster verify on large dumps.\nThe wizard remembers manual IPs.\nFixes a crash when the image folder was on a removed drive.",
+ downloadAndInstall: async (onEvent) => {
+ const total = 42_000_000;
+ onEvent({ event: "Started", data: { contentLength: total } });
+ for (let got = 0; got < total; got += 1_400_000) {
+ await delay(60);
+ onEvent({ event: "Progress", data: { chunkLength: 1_400_000 } });
+ }
+ onEvent({ event: "Finished" });
+ },
+ };
+ },
+ relaunch: async () => location.reload(),
ps5Status: async () => ({ online: true, latencyMs: 4, error: null, freeBytes: 631_629_479_936, totalBytes: 937_046_507_520, shadowmount: true, gameCompressor: true }),
on: async (name, fn) => { (listeners[name] ||= []).push(fn); return () => {}; },
pickFolder: async () => null,
diff --git a/src/lib/updates.svelte.js b/src/lib/updates.svelte.js
new file mode 100644
index 0000000..a7a0315
--- /dev/null
+++ b/src/lib/updates.svelte.js
@@ -0,0 +1,88 @@
+// Update state shared by the title bar, the update dialog, Help and Settings.
+import { api } from "./api.js";
+
+const REPO = "unicxrn/prospack";
+const SIX_HOURS = 6 * 60 * 60 * 1000;
+
+export const updates = $state({
+ current: null,
+ kind: null, // "windows" | "appimage" | "system"
+ available: null, // { version, notes, canInstall }
+ checking: false,
+ lastError: null,
+ lastChecked: null,
+ // While downloading or installing: { got, total }
+ progress: null,
+ installing: false,
+});
+
+let handle = null; // the updater's Update object, needed to install
+
+/** "0.10.2" > "0.9.9", ignoring a leading "v" and any "-suffix". */
+export function newer(a, b) {
+ const parts = (v) => String(v).replace(/^v/, "").split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
+ const [x, y] = [parts(a), parts(b)];
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
+ if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
+ }
+ return false;
+}
+
+/** A .deb or source build can't replace itself; ask GitHub what the latest release is instead. */
+async function latestFromGitHub() {
+ const r = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { headers: { Accept: "application/vnd.github+json" } });
+ if (!r.ok) throw new Error(`GitHub answered ${r.status}`);
+ const rel = await r.json();
+ return { version: rel.tag_name.replace(/^v/, ""), notes: rel.body ?? "" };
+}
+
+export async function checkForUpdates() {
+ if (updates.checking || updates.installing) return;
+ updates.checking = true;
+ updates.lastError = null;
+ try {
+ updates.current ??= await api.appVersion();
+ updates.kind ??= await api.installKind();
+ if (updates.kind === "system") {
+ const rel = await latestFromGitHub();
+ updates.available = newer(rel.version, updates.current) ? { ...rel, canInstall: false } : null;
+ } else {
+ handle = await api.checkUpdate();
+ updates.available = handle ? { version: handle.version, notes: handle.body ?? "", canInstall: true } : null;
+ }
+ } catch (e) {
+ updates.lastError = String(e?.message ?? e);
+ } finally {
+ updates.checking = false;
+ updates.lastChecked = Date.now();
+ }
+}
+
+/** Downloads, installs and restarts. On Windows the installer closes the app itself. */
+export async function installUpdate() {
+ if (!handle) return;
+ updates.installing = true;
+ updates.progress = { got: 0, total: 0 };
+ try {
+ await handle.downloadAndInstall((ev) => {
+ if (ev.event === "Started") updates.progress = { got: 0, total: ev.data.contentLength ?? 0 };
+ else if (ev.event === "Progress") updates.progress = { ...updates.progress, got: updates.progress.got + ev.data.chunkLength };
+ });
+ await api.relaunch();
+ } catch (e) {
+ updates.lastError = String(e?.message ?? e);
+ updates.installing = false;
+ updates.progress = null;
+ }
+}
+
+export function releasePage(version) {
+ return `https://github.com/${REPO}/releases/tag/v${version}`;
+}
+
+/** Checks now and then every six hours while `enabled()` stays true. Returns a stop function. */
+export function startAutoCheck(enabled) {
+ if (enabled()) checkForUpdates();
+ const t = setInterval(() => enabled() && checkForUpdates(), SIX_HOURS);
+ return () => clearInterval(t);
+}