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
+3 -2
View File
@@ -47,7 +47,8 @@ jobs:
working-directory: src-tauri working-directory: src-tauri
run: cargo fmt --check run: cargo fmt --check
# Default features only — candle-cuda must never be enabled in CI. # --no-default-features: default enables candle-cuda for the main dev
# machine; CI runners have no CUDA toolkit and must build CPU-only.
- name: Clippy - name: Clippy
working-directory: src-tauri working-directory: src-tauri
run: cargo clippy --all-targets --locked run: cargo clippy --all-targets --locked --no-default-features
+3
View File
@@ -52,3 +52,6 @@ jobs:
prerelease: false prerelease: false
uploadUpdaterJson: true uploadUpdaterJson: true
updaterJsonPreferNsis: true updaterJsonPreferNsis: true
# Cargo args after `--`: ship the CPU/DirectML build — default
# features enable candle-cuda, which runners (and most users) lack.
args: '-- --no-default-features'
+2
View File
@@ -22,6 +22,8 @@
"@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"d3-force": "^3.0.0", "d3-force": "^3.0.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"react": "^19.1.0", "react": "^19.1.0",
+20
View File
@@ -26,6 +26,12 @@ importers:
'@tauri-apps/plugin-opener': '@tauri-apps/plugin-opener':
specifier: ^2 specifier: ^2
version: 2.5.3 version: 2.5.3
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-updater':
specifier: ^2.10.1
version: 2.10.1
d3-force: d3-force:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
@@ -662,6 +668,12 @@ packages:
'@tauri-apps/plugin-opener@2.5.3': '@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
'@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/plugin-updater@2.10.1':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1462,6 +1474,14 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-updater@2.10.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.2
+121
View File
@@ -3495,6 +3495,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -3986,6 +3992,18 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -4155,6 +4173,20 @@ dependencies = [
"ureq", "ureq",
] ]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -4457,6 +4489,8 @@ dependencies = [
"tauri-plugin-fs", "tauri-plugin-fs",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
"tokenizers", "tokenizers",
"tokio", "tokio",
"ureq", "ureq",
@@ -5110,15 +5144,20 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -5234,6 +5273,18 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.0" version = "1.14.0"
@@ -5243,6 +5294,33 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.10" version = "0.103.10"
@@ -6251,6 +6329,49 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip 4.6.1",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.10.1" version = "2.10.1"
+2
View File
@@ -55,6 +55,8 @@ kamadak-exif = "0.5"
notify = "6" notify = "6"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
mozjpeg = "0.10.13" mozjpeg = "0.10.13"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
# ── Dev-mode performance ──────────────────────────────────────────────────── # ── Dev-mode performance ────────────────────────────────────────────────────
# opt-level=1 on the main crate keeps incremental compile short. # opt-level=1 on the main crate keeps incremental compile short.
+2
View File
@@ -14,6 +14,8 @@
"fs:read-files", "fs:read-files",
"fs:read-dirs", "fs:read-dirs",
"notification:default", "notification:default",
"updater:default",
"process:allow-restart",
"core:window:allow-minimize", "core:window:allow-minimize",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
+2
View File
@@ -16,6 +16,8 @@ use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
+9
View File
@@ -31,9 +31,18 @@
} }
} }
}, },
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDYyMDVBQzkyOENENjYzOTUKUldTVlk5YU1rcXdGWW9JRklYdHpOVXA1MDNMVEpyell6cVlma0VGS3pYaUVBLzJydy9nNUtJdlUK",
"endpoints": [
"https://github.com/JezzWTF/phokus/releases/latest/download/latest.json"
]
}
},
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["nsis"], "targets": ["nsis"],
"createUpdaterArtifacts": true,
"publisher": "JezzWTF", "publisher": "JezzWTF",
"license": "MIT", "license": "MIT",
"copyright": "Copyright © 2026 JezzWTF", "copyright": "Copyright © 2026 JezzWTF",
+9
View File
@@ -10,6 +10,7 @@ import { DuplicateFinder } from "./components/DuplicateFinder";
import { Timeline } from "./components/Timeline"; import { Timeline } from "./components/Timeline";
import { TitleBar } from "./components/TitleBar"; import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal"; import { SettingsModal } from "./components/SettingsModal";
import { UpdateToast } from "./components/UpdateToast";
import { initializeNotifications } from "./notifications"; import { initializeNotifications } from "./notifications";
export default function App() { export default function App() {
@@ -21,12 +22,19 @@ export default function App() {
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds); const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused); const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView);
useEffect(() => { useEffect(() => {
void initializeNotifications(); void initializeNotifications();
void loadMutedFolderIds(); void loadMutedFolderIds();
void loadNotificationsPaused(); 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(() => { loadFolders().then(() => {
void loadBackgroundJobProgress(); void loadBackgroundJobProgress();
void loadCaptionModelStatus(); void loadCaptionModelStatus();
@@ -79,6 +87,7 @@ export default function App() {
<Lightbox /> <Lightbox />
<SettingsModal /> <SettingsModal />
<UpdateToast />
</div> </div>
); );
} }
+47
View File
@@ -178,6 +178,12 @@ export function SettingsModal() {
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo); const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails); 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(() => { useEffect(() => {
if (!settingsOpen) return; if (!settingsOpen) return;
@@ -578,6 +584,47 @@ export function SettingsModal() {
</div> </div>
) : ( ) : (
<div className="mt-8 space-y-9"> <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 &amp; 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"> <SettingsGroup title="Storage & notifications">
<SettingsItem <SettingsItem
label="App data folder" label="App data folder"
+67
View File
@@ -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 &amp; 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>
);
}
+94
View File
@@ -2,6 +2,9 @@ import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event"; import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { appDataDir, join } from "@tauri-apps/api/path"; 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"; import { notifyTaskComplete } from "./notifications";
// Per-folder debounce timers for batching notifications. // Per-folder debounce timers for batching notifications.
@@ -259,6 +262,12 @@ export type SortOrder =
| "taken_desc" | "taken_desc"
| "taken_asc"; | "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 { interface GalleryState {
folders: Folder[]; folders: Folder[];
selectedFolderId: number | null; selectedFolderId: number | null;
@@ -310,6 +319,13 @@ interface GalleryState {
mutedFolderIds: number[]; mutedFolderIds: number[];
notificationsPaused: boolean; 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; taggerModelStatus: TaggerModelStatus | null;
taggerModelPreparing: boolean; taggerModelPreparing: boolean;
taggerModelError: string | null; taggerModelError: string | null;
@@ -387,6 +403,10 @@ interface GalleryState {
toggleMutedFolder: (folderId: number) => void; toggleMutedFolder: (folderId: number) => void;
loadNotificationsPaused: () => Promise<void>; loadNotificationsPaused: () => Promise<void>;
setNotificationsPaused: (paused: boolean) => void; setNotificationsPaused: (paused: boolean) => void;
loadAppVersion: () => Promise<void>;
checkForUpdates: (options?: { quiet?: boolean }) => Promise<void>;
installUpdate: () => Promise<void>;
dismissUpdate: () => void;
openAppDataFolder: () => Promise<void>; openAppDataFolder: () => Promise<void>;
getDatabaseInfo: () => Promise<DatabaseInfo>; getDatabaseInfo: () => Promise<DatabaseInfo>;
vacuumDatabase: () => Promise<VacuumResult>; vacuumDatabase: () => Promise<VacuumResult>;
@@ -665,6 +685,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
mutedFolderIds: [], mutedFolderIds: [],
notificationsPaused: false, notificationsPaused: false,
appVersion: null,
updateStatus: "idle",
updateVersion: null,
updateProgress: null,
updateError: null,
updateDismissed: false,
taggerModelStatus: null, taggerModelStatus: null,
taggerModelPreparing: false, taggerModelPreparing: false,
taggerModelError: null, taggerModelError: null,
@@ -1468,6 +1495,73 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void invoke("set_notifications_paused", { paused }).catch(() => {}); 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 () => { openAppDataFolder: async () => {
await invoke("open_app_data_folder"); await invoke("open_app_data_folder");
}, },