diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e40a64f..381340d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,8 @@ jobs: working-directory: src-tauri 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 working-directory: src-tauri - run: cargo clippy --all-targets --locked + run: cargo clippy --all-targets --locked --no-default-features diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e8fde7..242ea79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,3 +52,6 @@ jobs: prerelease: false uploadUpdaterJson: 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' diff --git a/package.json b/package.json index 87d6738..c8b2cd2 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "d3-force": "^3.0.0", "framer-motion": "^12.38.0", "react": "^19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec0add6..4192a50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2 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: specifier: ^3.0.0 version: 3.0.0 @@ -662,6 +668,12 @@ packages: '@tauri-apps/plugin-opener@2.5.3': 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': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1462,6 +1474,14 @@ snapshots: dependencies: '@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': dependencies: '@babel/parser': 7.29.2 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b39cfeb..b9e5323 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3495,6 +3495,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3986,6 +3992,18 @@ dependencies = [ "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]] name = "objc2-quartz-core" version = "0.3.2" @@ -4155,6 +4173,20 @@ dependencies = [ "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]] name = "pango" version = "0.18.3" @@ -4457,6 +4489,8 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-updater", "tokenizers", "tokio", "ureq", @@ -5110,15 +5144,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -5234,6 +5273,18 @@ dependencies = [ "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]] name = "rustls-pki-types" version = "1.14.0" @@ -5243,6 +5294,33 @@ dependencies = [ "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]] name = "rustls-webpki" version = "0.103.10" @@ -6251,6 +6329,49 @@ dependencies = [ "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]] name = "tauri-runtime" version = "2.10.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9836e2f..eeb3967 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,6 +55,8 @@ kamadak-exif = "0.5" notify = "6" tauri-plugin-notification = "2" mozjpeg = "0.10.13" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" # ── Dev-mode performance ──────────────────────────────────────────────────── # opt-level=1 on the main crate keeps incremental compile short. diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a526b2c..93b55f7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,6 +14,8 @@ "fs:read-files", "fs:read-dirs", "notification:default", + "updater:default", + "process:allow-restart", "core:window:allow-minimize", "core:window:allow-close", "core:window:allow-toggle-maximize", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0e3ccb..fe6832d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,8 @@ use tauri::Manager; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5ec02ac..70180f3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -31,9 +31,18 @@ } } }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDYyMDVBQzkyOENENjYzOTUKUldTVlk5YU1rcXdGWW9JRklYdHpOVXA1MDNMVEpyell6cVlma0VGS3pYaUVBLzJydy9nNUtJdlUK", + "endpoints": [ + "https://github.com/JezzWTF/phokus/releases/latest/download/latest.json" + ] + } + }, "bundle": { "active": true, "targets": ["nsis"], + "createUpdaterArtifacts": true, "publisher": "JezzWTF", "license": "MIT", "copyright": "Copyright © 2026 JezzWTF", diff --git a/src/App.tsx b/src/App.tsx index 409da27..d1ad17e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { + ); } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 14ec1da..f0c54f9 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -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() { ) : (
+ + + Phokus {appVersion ? `v${appVersion}` : "—"} + {updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? ( + v{updateVersion} available + ) : updateStatus === "upToDate" ? ( + Up to date + ) : null} + + } + description={ + updateStatus === "error" ? ( + Update check failed: {updateError} + ) : 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" ? ( + + ) : ( + + )} + + + 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 ( + + {visible ? ( + + {updateStatus === "available" ? ( + <> +

Update available

+

+ Phokus v{updateVersion} is ready to download and install. +

+
+ + +
+ + ) : ( + <> +

+ {updateStatus === "installing" ? "Installing update..." : "Downloading update..."} +

+
+
+
+

The app will restart when it finishes.

+ + )} + + ) : null} + + ); +} diff --git a/src/store.ts b/src/store.ts index f5364b9..6fcd25d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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; setNotificationsPaused: (paused: boolean) => void; + loadAppVersion: () => Promise; + checkForUpdates: (options?: { quiet?: boolean }) => Promise; + installUpdate: () => Promise; + dismissUpdate: () => void; openAppDataFolder: () => Promise; getDatabaseInfo: () => Promise; vacuumDatabase: () => Promise; @@ -665,6 +685,13 @@ export const useGalleryStore = create((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((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"); },