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
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:
@@ -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"
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user