diff --git a/package.json b/package.json index 5d541c0..1836830 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build:vite": "tsc && vite build", "dev:app": "tauri dev", "dev:vite": "vite", -"preview": "vite preview", + "preview": "vite preview", "tauri": "tauri" }, "dependencies": { @@ -16,6 +16,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-fs": "^2.5.0", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "d3-force": "^3.0.0", "framer-motion": "^12.38.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2dd733..ec0add6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@tauri-apps/plugin-fs': specifier: ^2.5.0 version: 2.5.0 + '@tauri-apps/plugin-notification': + specifier: ^2.3.3 + version: 2.3.3 '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.3 @@ -653,6 +656,9 @@ packages: '@tauri-apps/plugin-fs@2.5.0': resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==} + '@tauri-apps/plugin-notification@2.3.3': + resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} + '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} @@ -1448,6 +1454,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-notification@2.3.3': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-opener@2.5.3': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 195eddf..edd9078 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3293,6 +3293,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50efa634682b3fc5a1ab6f3dd5b2bce7b848011fc485b53b063dc68f2f74feae" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "mach2" version = "0.4.3" @@ -3594,6 +3606,20 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify-rust" +version = "4.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -4279,6 +4305,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-notification", "tauri-plugin-opener", "tokenizers", "tokio", @@ -4326,7 +4353,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.1", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -4543,6 +4570,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -6016,6 +6052,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -6138,6 +6193,18 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2b2919e..2bd2dde 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,6 +50,7 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "n ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] } zip = { version = "4.6.1", default-features = false, features = ["deflate"] } csv = "1" +tauri-plugin-notification = "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 c3a561a..a526b2c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,7 @@ "fs:allow-read-dir", "fs:read-files", "fs:read-dirs", + "notification:default", "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 0ef2241..8c820db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_notification::init()) .setup(|app| { let app_dir = app .path() diff --git a/src/App.tsx b/src/App.tsx index 2486ba9..ce2a339 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { TagCloud } from "./components/TagCloud"; import { DuplicateFinder } from "./components/DuplicateFinder"; import { TitleBar } from "./components/TitleBar"; import { SettingsModal } from "./components/SettingsModal"; +import { initializeNotifications } from "./notifications"; export default function App() { const loadFolders = useGalleryStore((state) => state.loadFolders); @@ -20,6 +21,7 @@ export default function App() { const activeView = useGalleryStore((state) => state.activeView); useEffect(() => { + void initializeNotifications(); loadFolders().then(() => { void loadBackgroundJobProgress(); void loadCaptionModelStatus(); diff --git a/src/notifications.ts b/src/notifications.ts new file mode 100644 index 0000000..ccfcac8 --- /dev/null +++ b/src/notifications.ts @@ -0,0 +1,31 @@ +import { + isPermissionGranted, + requestPermission, + sendNotification, +} from "@tauri-apps/plugin-notification"; + +let permissionPromise: Promise | null = null; + +export function initializeNotifications(): Promise { + permissionPromise ??= (async () => { + try { + if (await isPermissionGranted()) return true; + return (await requestPermission()) === "granted"; + } catch (error) { + console.warn("Windows notifications are unavailable:", error); + return false; + } + })(); + + return permissionPromise; +} + +export async function notifyTaskComplete(title: string, body: string): Promise { + if (!(await initializeNotifications())) return; + + try { + sendNotification({ title, body }); + } catch (error) { + console.warn("Could not send task completion notification:", error); + } +} diff --git a/src/store.ts b/src/store.ts index 1aba18c..ac37299 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2,6 +2,7 @@ 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 { notifyTaskComplete } from "./notifications"; export interface Folder { id: number; @@ -1394,6 +1395,10 @@ export const useGalleryStore = create((set, get) => ({ try { const groups = await invoke("find_duplicates", { folderId: folderId ?? null }); set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000) }); + void notifyTaskComplete( + "Duplicate scan complete", + groups.length === 1 ? "Found 1 duplicate group." : `Found ${groups.length.toLocaleString()} duplicate groups.`, + ); } finally { unlisten(); set({ duplicateScanning: false }); @@ -1466,6 +1471,7 @@ export const useGalleryStore = create((set, get) => ({ subscribeToProgress: async () => { const unlistenProgress = await listen("index-progress", (event) => { const progress = event.payload; + const previous = get().indexingProgress[progress.folder_id]; set((state) => ({ indexingProgress: { ...state.indexingProgress, @@ -1474,6 +1480,18 @@ export const useGalleryStore = create((set, get) => ({ })); if (progress.done) { + if ( + previous && + !previous.done && + progress.total > 0 && + progress.indexed >= progress.total + ) { + const folderName = get().folders.find((folder) => folder.id === progress.folder_id)?.name; + void notifyTaskComplete( + "Folder scan complete", + folderName ? `${folderName} has finished scanning.` : "A folder has finished scanning.", + ); + } void get().loadFolders(); void get().loadBackgroundJobProgress(); if (get().activeView !== "explore" && !isDerivedCollectionTitle(get().collectionTitle)) { @@ -1491,6 +1509,38 @@ export const useGalleryStore = create((set, get) => ({ }); const unlistenMediaJobs = await listen("media-job-progress", (event) => { + const previousProgress = get().mediaJobProgress; + + for (const progress of event.payload.progress) { + const previous = previousProgress[progress.folder_id]; + if (!previous) continue; + + const folderName = + get().folders.find((folder) => folder.id === progress.folder_id)?.name ?? "Folder"; + + if (previous.embedding_pending > 0 && progress.embedding_pending === 0) { + const failureDetail = + progress.embedding_failed > 0 + ? ` ${progress.embedding_failed.toLocaleString()} failed.` + : ""; + void notifyTaskComplete( + "Embeddings complete", + `${folderName} finished generating embeddings.${failureDetail}`, + ); + } + + if (previous.tagging_pending > 0 && progress.tagging_pending === 0) { + const failureDetail = + progress.tagging_failed > 0 + ? ` ${progress.tagging_failed.toLocaleString()} failed.` + : ""; + void notifyTaskComplete( + "AI tagging complete", + `${folderName} finished generating tags.${failureDetail}`, + ); + } + } + set((state) => { const next = { ...state.mediaJobProgress }; for (const progress of event.payload.progress) {