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:
@@ -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");
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user