feat(notifications): report completed background tasks

Register the Tauri notification plugin and request notification permission during startup. Send completion notifications for folder scans, embeddings, AI tagging, and duplicate scans, including failure counts where available.
This commit is contained in:
2026-06-06 19:52:24 +01:00
parent 2cdab000fb
commit c1070649fa
9 changed files with 166 additions and 2 deletions
+2 -1
View File
@@ -8,7 +8,7 @@
"build:vite": "tsc && vite build", "build:vite": "tsc && vite build",
"dev:app": "tauri dev", "dev:app": "tauri dev",
"dev:vite": "vite", "dev:vite": "vite",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
@@ -16,6 +16,7 @@
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"d3-force": "^3.0.0", "d3-force": "^3.0.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
+10
View File
@@ -20,6 +20,9 @@ importers:
'@tauri-apps/plugin-fs': '@tauri-apps/plugin-fs':
specifier: ^2.5.0 specifier: ^2.5.0
version: 2.5.0 version: 2.5.0
'@tauri-apps/plugin-notification':
specifier: ^2.3.3
version: 2.3.3
'@tauri-apps/plugin-opener': '@tauri-apps/plugin-opener':
specifier: ^2 specifier: ^2
version: 2.5.3 version: 2.5.3
@@ -653,6 +656,9 @@ packages:
'@tauri-apps/plugin-fs@2.5.0': '@tauri-apps/plugin-fs@2.5.0':
resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==} 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': '@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
@@ -1448,6 +1454,10 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@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': '@tauri-apps/plugin-opener@2.5.3':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1
+68 -1
View File
@@ -3293,6 +3293,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" 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]] [[package]]
name = "mach2" name = "mach2"
version = "0.4.3" version = "0.4.3"
@@ -3594,6 +3606,20 @@ dependencies = [
"minimal-lexical", "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]] [[package]]
name = "ntapi" name = "ntapi"
version = "0.4.3" version = "0.4.3"
@@ -4279,6 +4305,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-fs", "tauri-plugin-fs",
"tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tokenizers", "tokenizers",
"tokio", "tokio",
@@ -4326,7 +4353,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.1", "indexmap 2.13.1",
"quick-xml", "quick-xml 0.38.4",
"serde", "serde",
"time", "time",
] ]
@@ -4543,6 +4570,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" 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]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -6016,6 +6052,25 @@ dependencies = [
"url", "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]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.3" version = "2.5.3"
@@ -6138,6 +6193,18 @@ dependencies = [
"toml 0.9.12+spec-1.1.0", "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]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
+1
View File
@@ -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"] } ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] }
zip = { version = "4.6.1", default-features = false, features = ["deflate"] } zip = { version = "4.6.1", default-features = false, features = ["deflate"] }
csv = "1" csv = "1"
tauri-plugin-notification = "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.
+1
View File
@@ -13,6 +13,7 @@
"fs:allow-read-dir", "fs:allow-read-dir",
"fs:read-files", "fs:read-files",
"fs:read-dirs", "fs:read-dirs",
"notification:default",
"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",
+1
View File
@@ -19,6 +19,7 @@ pub fn run() {
.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())
.plugin(tauri_plugin_notification::init())
.setup(|app| { .setup(|app| {
let app_dir = app let app_dir = app
.path() .path()
+2
View File
@@ -9,6 +9,7 @@ import { TagCloud } from "./components/TagCloud";
import { DuplicateFinder } from "./components/DuplicateFinder"; import { DuplicateFinder } from "./components/DuplicateFinder";
import { TitleBar } from "./components/TitleBar"; import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal"; import { SettingsModal } from "./components/SettingsModal";
import { initializeNotifications } from "./notifications";
export default function App() { export default function App() {
const loadFolders = useGalleryStore((state) => state.loadFolders); const loadFolders = useGalleryStore((state) => state.loadFolders);
@@ -20,6 +21,7 @@ export default function App() {
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView);
useEffect(() => { useEffect(() => {
void initializeNotifications();
loadFolders().then(() => { loadFolders().then(() => {
void loadBackgroundJobProgress(); void loadBackgroundJobProgress();
void loadCaptionModelStatus(); void loadCaptionModelStatus();
+31
View File
@@ -0,0 +1,31 @@
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from "@tauri-apps/plugin-notification";
let permissionPromise: Promise<boolean> | null = null;
export function initializeNotifications(): Promise<boolean> {
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<void> {
if (!(await initializeNotifications())) return;
try {
sendNotification({ title, body });
} catch (error) {
console.warn("Could not send task completion notification:", error);
}
}
+50
View File
@@ -2,6 +2,7 @@ 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 { notifyTaskComplete } from "./notifications";
export interface Folder { export interface Folder {
id: number; id: number;
@@ -1394,6 +1395,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
try { try {
const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null }); const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null });
set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000) }); 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 { } finally {
unlisten(); unlisten();
set({ duplicateScanning: false }); set({ duplicateScanning: false });
@@ -1466,6 +1471,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
subscribeToProgress: async () => { subscribeToProgress: async () => {
const unlistenProgress = await listen<IndexProgress>("index-progress", (event) => { const unlistenProgress = await listen<IndexProgress>("index-progress", (event) => {
const progress = event.payload; const progress = event.payload;
const previous = get().indexingProgress[progress.folder_id];
set((state) => ({ set((state) => ({
indexingProgress: { indexingProgress: {
...state.indexingProgress, ...state.indexingProgress,
@@ -1474,6 +1480,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
})); }));
if (progress.done) { 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().loadFolders();
void get().loadBackgroundJobProgress(); void get().loadBackgroundJobProgress();
if (get().activeView !== "explore" && !isDerivedCollectionTitle(get().collectionTitle)) { if (get().activeView !== "explore" && !isDerivedCollectionTitle(get().collectionTitle)) {
@@ -1491,6 +1509,38 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}); });
const unlistenMediaJobs = await listen<MediaJobProgressEvent>("media-job-progress", (event) => { const unlistenMediaJobs = await listen<MediaJobProgressEvent>("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) => { set((state) => {
const next = { ...state.mediaJobProgress }; const next = { ...state.mediaJobProgress };
for (const progress of event.payload.progress) { for (const progress of event.payload.progress) {