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:
@@ -10,6 +10,7 @@ import { DuplicateFinder } from "./components/DuplicateFinder";
|
||||
import { Timeline } from "./components/Timeline";
|
||||
import { TitleBar } from "./components/TitleBar";
|
||||
import { SettingsModal } from "./components/SettingsModal";
|
||||
import { UpdateToast } from "./components/UpdateToast";
|
||||
import { initializeNotifications } from "./notifications";
|
||||
|
||||
export default function App() {
|
||||
@@ -21,12 +22,19 @@ export default function App() {
|
||||
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
|
||||
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
|
||||
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
|
||||
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion);
|
||||
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
|
||||
const activeView = useGalleryStore((state) => state.activeView);
|
||||
|
||||
useEffect(() => {
|
||||
void initializeNotifications();
|
||||
void loadMutedFolderIds();
|
||||
void loadNotificationsPaused();
|
||||
void loadAppVersion();
|
||||
// Quiet launch check — dev builds have no signed artifacts to update to.
|
||||
if (import.meta.env.PROD) {
|
||||
void checkForUpdates({ quiet: true });
|
||||
}
|
||||
loadFolders().then(() => {
|
||||
void loadBackgroundJobProgress();
|
||||
void loadCaptionModelStatus();
|
||||
@@ -79,6 +87,7 @@ export default function App() {
|
||||
|
||||
<Lightbox />
|
||||
<SettingsModal />
|
||||
<UpdateToast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,6 +178,12 @@ export function SettingsModal() {
|
||||
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
|
||||
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
|
||||
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
|
||||
const appVersion = useGalleryStore((state) => state.appVersion);
|
||||
const updateStatus = useGalleryStore((state) => state.updateStatus);
|
||||
const updateVersion = useGalleryStore((state) => state.updateVersion);
|
||||
const updateError = useGalleryStore((state) => state.updateError);
|
||||
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
|
||||
const installUpdate = useGalleryStore((state) => state.installUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) return;
|
||||
@@ -578,6 +584,47 @@ export function SettingsModal() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 space-y-9">
|
||||
<SettingsGroup title="Updates">
|
||||
<SettingsItem
|
||||
label={
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
<span>Phokus {appVersion ? `v${appVersion}` : "—"}</span>
|
||||
{updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? (
|
||||
<StatusPill tone="busy">v{updateVersion} available</StatusPill>
|
||||
) : updateStatus === "upToDate" ? (
|
||||
<StatusPill tone="ready">Up to date</StatusPill>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
updateStatus === "error" ? (
|
||||
<span className="text-amber-300/90">Update check failed: {updateError}</span>
|
||||
) : updateStatus === "downloading" || updateStatus === "installing" ? (
|
||||
"Downloading update — the app will restart when it finishes."
|
||||
) : (
|
||||
"Updates are checked quietly at launch and installed only when you choose."
|
||||
)
|
||||
}
|
||||
>
|
||||
{updateStatus === "available" ? (
|
||||
<button
|
||||
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
|
||||
onClick={() => void installUpdate()}
|
||||
>
|
||||
Install & restart
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={() => void checkForUpdates()}
|
||||
disabled={updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing"}
|
||||
>
|
||||
{updateStatus === "checking" ? "Checking..." : "Check for updates"}
|
||||
</button>
|
||||
)}
|
||||
</SettingsItem>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Storage & notifications">
|
||||
<SettingsItem
|
||||
label="App data folder"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useGalleryStore } from "../store";
|
||||
|
||||
export function UpdateToast() {
|
||||
const updateStatus = useGalleryStore((s) => s.updateStatus);
|
||||
const updateVersion = useGalleryStore((s) => s.updateVersion);
|
||||
const updateProgress = useGalleryStore((s) => s.updateProgress);
|
||||
const updateDismissed = useGalleryStore((s) => s.updateDismissed);
|
||||
const installUpdate = useGalleryStore((s) => s.installUpdate);
|
||||
const dismissUpdate = useGalleryStore((s) => s.dismissUpdate);
|
||||
|
||||
const visible =
|
||||
!updateDismissed &&
|
||||
(updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing");
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-white/10 bg-gray-900 p-4 shadow-xl"
|
||||
>
|
||||
{updateStatus === "available" ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-white">Update available</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Phokus v{updateVersion} is ready to download and install.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
|
||||
onClick={() => void installUpdate()}
|
||||
>
|
||||
Install & restart
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
|
||||
onClick={dismissUpdate}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{updateStatus === "installing" ? "Installing update..." : "Downloading update..."}
|
||||
</p>
|
||||
<div className="mt-3 h-1 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className={`h-full rounded-full bg-emerald-400/80 transition-[width] duration-200 ${
|
||||
updateProgress === null ? "w-full animate-pulse" : ""
|
||||
}`}
|
||||
style={updateProgress !== null ? { width: `${Math.round(updateProgress * 100)}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-600">The app will restart when it finishes.</p>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -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