diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts
index 84e7cdf8d..51b59f94c 100644
--- a/frontend/jest.setup.ts
+++ b/frontend/jest.setup.ts
@@ -104,6 +104,12 @@ jest.mock('@tauri-apps/plugin-shell', () => ({
open: jest.fn().mockResolvedValue(undefined),
}));
+jest.mock('@tauri-apps/plugin-notification', () => ({
+ isPermissionGranted: jest.fn().mockResolvedValue(true),
+ requestPermission: jest.fn().mockResolvedValue('granted'),
+ sendNotification: jest.fn(),
+}));
+
jest.mock('@tauri-apps/plugin-store', () => ({
Store: jest.fn().mockImplementation(() => ({
get: jest.fn().mockResolvedValue(null),
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4dad1553a..94f46e293 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -27,6 +27,7 @@
"@tauri-apps/api": ">=2.0.0-beta.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
+ "@tauri-apps/plugin-notification": "^2.3.1",
"@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-shell": "^2.2.0",
@@ -5475,6 +5476,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
+ "node_modules/@tauri-apps/plugin-notification": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
+ "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.8.0"
+ }
+ },
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index c79c5db49..5969fe901 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -42,6 +42,7 @@
"@tauri-apps/api": ">=2.0.0-beta.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
+ "@tauri-apps/plugin-notification": "^2.3.1",
"@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-shell": "^2.2.0",
diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock
index 635f9a569..09d59ca5a 100644
--- a/frontend/src-tauri/Cargo.lock
+++ b/frontend/src-tauri/Cargo.lock
@@ -830,7 +830,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -1022,7 +1022,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -2239,6 +2239,20 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+[[package]]
+name = "mac-notification-sys"
+version = "0.6.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
+dependencies = [
+ "cc",
+ "log",
+ "objc2",
+ "objc2-foundation",
+ "time",
+ "uuid",
+]
+
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -2411,6 +2425,20 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+[[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.2"
@@ -2451,7 +2479,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [
- "proc-macro-crate 3.4.0",
+ "proc-macro-crate 1.3.1",
"proc-macro2",
"quote",
"syn 2.0.114",
@@ -2776,7 +2804,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.45.0",
]
[[package]]
@@ -3015,6 +3043,7 @@ dependencies = [
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-fs",
+ "tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-shell",
@@ -3263,7 +3292,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -3633,7 +3662,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -4542,6 +4571,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.17",
+ "time",
+ "url",
+]
+
[[package]]
name = "tauri-plugin-opener"
version = "2.5.2"
@@ -4744,6 +4792,17 @@ dependencies = [
"toml 0.9.10+spec-1.1.0",
]
+[[package]]
+name = "tauri-winrt-notification"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
+dependencies = [
+ "thiserror 2.0.17",
+ "windows",
+ "windows-version",
+]
+
[[package]]
name = "tempfile"
version = "3.24.0"
@@ -4754,7 +4813,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5619,7 +5678,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml
index e25427b89..647a561ba 100644
--- a/frontend/src-tauri/Cargo.toml
+++ b/frontend/src-tauri/Cargo.toml
@@ -12,7 +12,7 @@ tauri-build = { version = "2.0.6", features = [] }
[dependencies]
tauri = { version = "2.9.1", features = ["protocol-asset", "devtools", "tray-icon"] }
-reqwest = { version = "0.12", features = ["blocking"] }
+reqwest = { version = "0.12", features = ["blocking", "json"] }
walkdir = "2.3"
sysinfo = "0.37.2"
serde = { version = "1.0", features = ["derive"] }
@@ -21,7 +21,7 @@ anyhow = "1.0"
image = "0.24.6"
ring = "0.16.20"
data-encoding = "2.3.2"
-tokio = { version = "1", features = ["macros"] }
+tokio = { version = "1", features = ["macros", "time"] }
tempfile = "3"
arrayref = "0.3.6"
directories = "4.0"
@@ -37,6 +37,7 @@ tauri-plugin-process = "2.3.1"
tauri-plugin-store = "2.4.1"
tauri-plugin-updater = "2.9.0"
tauri-plugin-opener = "2.5.2"
+tauri-plugin-notification = "2.3.3"
tauri-plugin-autostart = "2"
[features]
diff --git a/frontend/src-tauri/capabilities/migrated.json b/frontend/src-tauri/capabilities/migrated.json
index b47fbeeb4..957739db0 100644
--- a/frontend/src-tauri/capabilities/migrated.json
+++ b/frontend/src-tauri/capabilities/migrated.json
@@ -92,6 +92,7 @@
"fs:default",
"dialog:default",
"store:default",
- "opener:allow-reveal-item-in-dir"
+ "opener:allow-reveal-item-in-dir",
+ "notification:default"
]
}
diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs
index 3bad56856..e1227bbde 100644
--- a/frontend/src-tauri/src/main.rs
+++ b/frontend/src-tauri/src/main.rs
@@ -1,6 +1,7 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+mod memories;
mod services;
use sysinfo::System;
@@ -52,6 +53,13 @@ fn is_process_alive() -> bool {
}
fn on_window_event(window: &Window, event: &WindowEvent) {
+ // Covers the app being left open across midnight, and coming back from the
+ // tray. Throttled inside.
+ if matches!(event, WindowEvent::Focused(true)) && window.label() == "main" {
+ memories::check_on_resume(window.app_handle().clone());
+ return;
+ }
+
if let WindowEvent::CloseRequested { api, .. } = event {
// Secondary windows (e.g. model-manager) close normally.
if window.label() != "main" {
@@ -284,12 +292,16 @@ fn main() {
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
+ .plugin(tauri_plugin_notification::init())
.setup(|app| {
let resource_path = app.path().resolve("resources", BaseDirectory::Resource)?;
println!("Resource path: {:?}", resource_path);
prod(app.handle(), &resource_path)?;
+ app.manage(memories::MemoryTaskState::default());
+ memories::spawn_memory_task(app.handle().clone());
+
// When auto-started at boot (--minimized flag), keep the window hidden
if std::env::args().any(|a| a == "--minimized") {
if let Some(window) = app.get_webview_window("main") {
@@ -347,6 +359,8 @@ fn main() {
.invoke_handler(tauri::generate_handler![
services::get_resources_folder_path,
open_model_manager,
+ memories::open_memory,
+ memories::take_pending_memory,
enable_autostart,
disable_autostart,
is_autostart_enabled,
diff --git a/frontend/src-tauri/src/memories.rs b/frontend/src-tauri/src/memories.rs
new file mode 100644
index 000000000..68236e72c
--- /dev/null
+++ b/frontend/src-tauri/src/memories.rs
@@ -0,0 +1,389 @@
+//! Daily memory curation, driven from the desktop shell.
+//!
+//! The backend only curates when something asks it to, so nothing would ever
+//! surface on a day with no import. This task asks once per launch (and again
+//! on resume), then reports the result through the OS notification centre.
+
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Mutex;
+use std::time::{Duration, Instant};
+
+use serde::Deserialize;
+use serde_json::{json, Value};
+use tauri::{AppHandle, Emitter, Manager};
+use tauri_plugin_notification::NotificationExt;
+use tokio::time::sleep;
+
+const BACKEND_URL: &str = "http://localhost:52123";
+
+const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
+/// The backend is spawned alongside the window and takes a while to bind.
+const HEALTH_POLL: Duration = Duration::from_secs(2);
+const HEALTH_TIMEOUT: Duration = Duration::from_secs(180);
+/// Curating mid-index would score a half-written library, so wait it out.
+const INDEXING_POLL: Duration = Duration::from_secs(20);
+const INDEXING_TIMEOUT: Duration = Duration::from_secs(30 * 60);
+const RUN_POLL: Duration = Duration::from_secs(5);
+const RUN_TIMEOUT: Duration = Duration::from_secs(30 * 60);
+/// How long the app must have gone unchecked before a focus re-runs the task.
+const RESUME_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
+
+/// Guards against overlapping runs and throttles the resume check.
+#[derive(Default)]
+pub struct MemoryTaskState {
+ running: AtomicBool,
+ last_check: Mutex