desktop: reminders reach you when the window isn't in front
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 36s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m38s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s

A Rust worker reads due reminders from the local store every 15 s and
announces each occurrence once: always to the main window as a toast, and
as a system notification (tauri-plugin-notification) when that window
isn't focused. The page no longer polls on the desktop; its Notification
went nowhere in WebKitGTK and its timer stopped with the window. The
Reminders page says what each surface actually does.

Core gains store::due_reminders, compared by instant, not by string.

Refs #5171

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 12:46:02 -04:00
co-authored by Claude Opus 5.5
parent 5989ffc1c6
commit df85535ea2
9 changed files with 942 additions and 25 deletions
+6
View File
@@ -69,3 +69,9 @@ tauri-plugin-updater = "2"
# concept of a global shortcut, and its half of this feature is a share-sheet
# intent filter instead.
tauri-plugin-global-shortcut = "2"
# System notifications for due reminders (#5171), raised from the reminder worker in
# Rust. The page's own `Notification` goes nowhere in WebKitGTK, and the page's
# timer only runs while its window does. Used from Rust only, so no capability
# grants it to the webview.
tauri-plugin-notification = "2"
+6
View File
@@ -14,6 +14,7 @@ mod capture;
mod commands;
mod crossover;
mod integration;
mod reminders;
mod update;
/// The build a PERSON reads, baked in by the desktop lane at compile time.
@@ -87,6 +88,8 @@ pub fn run() {
// `setup`, once the store is open and can be asked which one to use — the
// plugin only has to exist before then.
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
// System notifications for due reminders, raised by the `reminders` worker.
.plugin(tauri_plugin_notification::init())
// Attachment bytes are served to the webview from the local blob store
// (M10.7f). Registered on the BUILDER because a scheme has to exist before
// the webview is created; the directory it reads from arrives later, in
@@ -146,6 +149,9 @@ pub fn run() {
// Last: the worker reads the store and the blob store, both managed now.
// Its first cycle is the launch sync.
autosync::start(app.handle())?;
// Announces due reminders from the store, including while the window is
// minimised or covered.
reminders::start(app.handle())?;
// Coming back to the window is when someone is about to look, so it asks
// for a cycle. Rate-limited in autosync, because focus flaps constantly.
if let Some(window) = app.get_webview_window("main") {
+216
View File
@@ -0,0 +1,216 @@
//! Reminders reach you when the window isn't in front (#5171).
//!
//! The shared frontend's reminder check is a 45-second timer in the page, which
//! raises a toast and, if allowed, a page `Notification`. Inside this app that was
//! not enough. The page's timer only does anything while the webview is running
//! it, and WebKitGTK's page notifications go nowhere. So a reminder that came due
//! while Inkwell sat minimised or behind another window was never announced.
//!
//! This worker owns announcing on the desktop instead. Every [`TICK`] it asks the
//! local store which reminders have come due, and announces each occurrence once:
//!
//! - **always** to the main window, as [`DUE_EVENT`], which the page turns into its
//! toast with an Open button;
//! - **and as a system notification** when the main window isn't the focused one,
//! because a toast in a window nobody is looking at is not an announcement.
//!
//! The store is the source, not the server, so this works unlinked and offline, and
//! a reminder set on the phone announces here once a sync brings it in.
//!
//! Clicking the system notification doesn't open the note: the notification plugin
//! reports clicks only on mobile. The toast waiting in the window does.
use std::collections::HashMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_notification::NotificationExt;
use inkwell_core::local::models::DueReminder;
use inkwell_core::local::{store, Db};
/// How often the worker asks the store what has come due. The first look waits one
/// tick too, so the page is listening before anything is sent to it.
const TICK: Duration = Duration::from_secs(15);
/// At launch, only reminders due this recently are announced. Anything older came
/// due while the app was closed, and a burst of stale alerts at every start would
/// be noise. It still shows on the Reminders page.
const RECENT_MS: i64 = 15 * 60 * 1000;
/// Emitted to the main window for each reminder announced, with a [`DueReminder`].
pub const DUE_EVENT: &str = "inkwell://reminder-due";
/// Start the worker. Called from `setup` once the store is managed.
pub fn start(app: &AppHandle) -> std::io::Result<()> {
let handle = app.clone();
std::thread::Builder::new()
.name("reminders".into())
.spawn(move || worker(handle))?;
Ok(())
}
fn worker(app: AppHandle) {
let mut announcer = Announcer::default();
loop {
std::thread::sleep(TICK);
let now = now_ms();
let due = match due(&app, now) {
Ok(due) => due,
Err(e) => {
log::warn!("could not read due reminders: {e}");
continue;
}
};
for reminder in announcer.take(due, now) {
announce(&app, &reminder);
}
}
}
fn due(app: &AppHandle, now: i64) -> Result<Vec<DueReminder>, String> {
let db = app.state::<Db>();
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::due_reminders(&conn, now).map_err(|e| e.to_string())
}
fn announce(app: &AppHandle, reminder: &DueReminder) {
if let Err(e) = app.emit_to("main", DUE_EVENT, reminder) {
log::warn!("could not tell the window about a due reminder: {e}");
}
if main_window_in_front(app) {
return;
}
let body = if reminder.title.is_empty() {
"Reminder"
} else {
reminder.title.as_str()
};
if let Err(e) = app
.notification()
.builder()
.title("Inkwell reminder")
.body(body)
.show()
{
log::warn!("could not show a reminder notification: {e}");
}
}
/// Whether someone is looking at the main window right now. Unknown counts as no:
/// a duplicate alert is a smaller failure than a missed one.
fn main_window_in_front(app: &AppHandle) -> bool {
app.get_webview_window("main")
.is_some_and(|w| w.is_focused().unwrap_or(false) && !w.is_minimized().unwrap_or(true))
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// Which occurrences have been announced. Keyed by note id, holding the `remind_at`
/// announced: completing or snoozing a reminder changes that value, so the next
/// occurrence is new to it.
#[derive(Default)]
struct Announcer {
announced: HashMap<String, String>,
primed: bool,
}
impl Announcer {
/// The reminders in `due` to announce now. Each occurrence comes back once; on
/// the first call, ones due more than [`RECENT_MS`] ago are marked without
/// being returned.
fn take(&mut self, due: Vec<DueReminder>, now: i64) -> Vec<DueReminder> {
// Forget notes that are no longer due (completed, snoozed, trashed), so the
// map stays the size of the due list.
self.announced
.retain(|id, _| due.iter().any(|d| d.id == *id));
let mut fresh = Vec::new();
for reminder in due {
if self.announced.get(&reminder.id) == Some(&reminder.remind_at) {
continue;
}
self.announced
.insert(reminder.id.clone(), reminder.remind_at.clone());
if !self.primed && now - reminder.due_ms > RECENT_MS {
continue;
}
fresh.push(reminder);
}
self.primed = true;
fresh
}
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_800_000_000_000;
fn due(id: &str, remind_at: &str, ago_ms: i64) -> DueReminder {
DueReminder {
id: id.into(),
title: id.into(),
remind_at: remind_at.into(),
due_ms: NOW - ago_ms,
}
}
fn ids(list: &[DueReminder]) -> Vec<&str> {
list.iter().map(|d| d.id.as_str()).collect()
}
#[test]
fn each_occurrence_is_announced_once() {
let mut a = Announcer::default();
let first = a.take(vec![due("a", "t1", 1_000)], NOW);
assert_eq!(ids(&first), ["a"]);
assert!(a
.take(vec![due("a", "t1", 16_000)], NOW + 15_000)
.is_empty());
}
#[test]
fn at_launch_a_stale_backlog_is_marked_not_announced() {
let mut a = Announcer::default();
let first = a.take(
vec![due("old", "t0", RECENT_MS + 1), due("new", "t1", 60_000)],
NOW,
);
assert_eq!(ids(&first), ["new"]);
assert!(a
.take(vec![due("old", "t0", RECENT_MS + 15_001)], NOW + 15_000)
.is_empty());
}
#[test]
fn after_launch_anything_newly_due_is_announced_however_late() {
let mut a = Announcer::default();
a.take(Vec::new(), NOW);
// Pulled in by a sync, already an hour overdue.
let later = a.take(vec![due("synced", "t1", 3_600_000)], NOW);
assert_eq!(ids(&later), ["synced"]);
}
#[test]
fn a_new_occurrence_of_the_same_note_is_announced_again() {
let mut a = Announcer::default();
a.take(vec![due("daily", "mon", 1_000)], NOW);
// Completed: it leaves the due list until the next occurrence comes due.
a.take(Vec::new(), NOW + 15_000);
let next = a.take(vec![due("daily", "tue", 1_000)], NOW + 86_400_000);
assert_eq!(ids(&next), ["daily"]);
}
#[test]
fn a_snoozed_reminder_that_is_due_again_is_announced_again() {
let mut a = Announcer::default();
a.take(vec![due("n", "09:00", 1_000)], NOW);
// Snoozed and due again before a tick saw it leave the list.
let again = a.take(vec![due("n", "09:10", 1_000)], NOW + 600_000);
assert_eq!(ids(&again), ["n"]);
}
}