feat(updater): self-update via GitHub Releases with launch check and settings UI

Phase 3 of the 0.1.0 release prep:
- tauri-plugin-updater + tauri-plugin-process wired into the builder, with
  updater:default / process:allow-restart capabilities
- bundle.createUpdaterArtifacts: true; endpoint points at the GitHub
  releases latest.json, pubkey baked into tauri.conf.json
- store: update status state machine (check / download progress / install)
  with a quiet launch check (prod only) that stays silent on network errors
- UI: dismissible update toast with download progress, plus an Updates group
  in Settings > General showing current version and manual check/install
This commit is contained in:
2026-06-12 19:00:12 +01:00
parent 34ced67fe1
commit 890c23bdce
13 changed files with 381 additions and 2 deletions
+94
View File
@@ -2,6 +2,9 @@ import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { appDataDir, join } from "@tauri-apps/api/path";
import { getVersion } from "@tauri-apps/api/app";
import { check, Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { notifyTaskComplete } from "./notifications";
// Per-folder debounce timers for batching notifications.
@@ -259,6 +262,12 @@ export type SortOrder =
| "taken_desc"
| "taken_asc";
export type UpdateStatus = "idle" | "checking" | "upToDate" | "available" | "downloading" | "installing" | "error";
// The Update handle from the plugin carries the download method; it's not
// serializable state, so it lives outside the store.
let pendingUpdate: Update | null = null;
interface GalleryState {
folders: Folder[];
selectedFolderId: number | null;
@@ -310,6 +319,13 @@ interface GalleryState {
mutedFolderIds: number[];
notificationsPaused: boolean;
appVersion: string | null;
updateStatus: UpdateStatus;
updateVersion: string | null;
updateProgress: number | null; // 0..1 download progress, null while size unknown
updateError: string | null;
updateDismissed: boolean;
taggerModelStatus: TaggerModelStatus | null;
taggerModelPreparing: boolean;
taggerModelError: string | null;
@@ -387,6 +403,10 @@ interface GalleryState {
toggleMutedFolder: (folderId: number) => void;
loadNotificationsPaused: () => Promise<void>;
setNotificationsPaused: (paused: boolean) => void;
loadAppVersion: () => Promise<void>;
checkForUpdates: (options?: { quiet?: boolean }) => Promise<void>;
installUpdate: () => Promise<void>;
dismissUpdate: () => void;
openAppDataFolder: () => Promise<void>;
getDatabaseInfo: () => Promise<DatabaseInfo>;
vacuumDatabase: () => Promise<VacuumResult>;
@@ -665,6 +685,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
mutedFolderIds: [],
notificationsPaused: false,
appVersion: null,
updateStatus: "idle",
updateVersion: null,
updateProgress: null,
updateError: null,
updateDismissed: false,
taggerModelStatus: null,
taggerModelPreparing: false,
taggerModelError: null,
@@ -1468,6 +1495,73 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void invoke("set_notifications_paused", { paused }).catch(() => {});
},
loadAppVersion: async () => {
try {
set({ appVersion: await getVersion() });
} catch {
// leave null; the UI falls back to a dash
}
},
checkForUpdates: async (options) => {
const quiet = options?.quiet ?? false;
const { updateStatus } = get();
if (updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing") return;
set({ updateStatus: "checking", updateError: null });
try {
const update = await check();
if (update) {
pendingUpdate = update;
set({ updateStatus: "available", updateVersion: update.version, updateDismissed: false });
} else {
pendingUpdate = null;
set({ updateStatus: "upToDate", updateVersion: null });
}
} catch (error) {
pendingUpdate = null;
if (quiet) {
// Launch-time check: stay silent on network/endpoint failures.
set({ updateStatus: "idle" });
} else {
set({ updateStatus: "error", updateError: error instanceof Error ? error.message : String(error) });
}
}
},
installUpdate: async () => {
const update = pendingUpdate;
if (!update || get().updateStatus !== "available") return;
set({ updateStatus: "downloading", updateProgress: null, updateError: null });
try {
let contentLength: number | null = null;
let downloaded = 0;
await update.downloadAndInstall((event) => {
switch (event.event) {
case "Started":
contentLength = event.data.contentLength ?? null;
set({ updateProgress: contentLength ? 0 : null });
break;
case "Progress":
downloaded += event.data.chunkLength;
if (contentLength) {
set({ updateProgress: Math.min(downloaded / contentLength, 1) });
}
break;
case "Finished":
set({ updateStatus: "installing", updateProgress: 1 });
break;
}
});
await relaunch();
} catch (error) {
set({ updateStatus: "error", updateError: error instanceof Error ? error.message : String(error) });
}
},
dismissUpdate: () => set({ updateDismissed: true }),
openAppDataFolder: async () => {
await invoke("open_app_data_folder");
},