Files
WhispAssist/src-tauri/src/reminders.rs
T
iamdoubz f6e0de5486 feat(reminders): schedule/cancel via Windows scheduled toast notifications (T8.6, FR-CAL-5)
Chosen over an app-side polling timer specifically to honor NFR-RES-1
("no polling timers running when not recording") — Windows itself
delivers the toast at due_at, and a scheduled toast is cleared by the
OS after it fires, so there's no "already fired" bookkeeping needed in
this app either. Windows-only, matching hardware::dxgi's pattern: the
real WinRT implementation lives in a #[cfg(windows)] submodule behind
plain top-level functions.

Verified against the live Windows toast API (not just compiled): a
throwaway example scheduled a reminder, confirmed
GetScheduledToastNotifications() reported it back with the correct id,
then confirmed cancel() removed it.
2026-07-02 11:58:44 -05:00

121 lines
4.4 KiB
Rust

//! Local reminders for action items (Phase 8, T8.6, FR-CAL-5), via Windows'
//! native scheduled toast notifications. The OS itself delivers these at the
//! due time — no app-side polling timer runs while idle (NFR-RES-1), and a
//! scheduled toast is `AddToSchedule`d once and cleared by the OS after it
//! fires, so there's no "already fired" bookkeeping to keep in this app
//! either. Windows-only, matching `hardware::dxgi`'s pattern: the real
//! implementation lives in a `#[cfg(windows)]` submodule; call sites use the
//! plain top-level functions either way.
#[cfg(windows)]
pub fn init() {
winrt::init();
}
#[cfg(not(windows))]
pub fn init() {}
/// Schedules (or reschedules) a reminder toast for one action item. `id` is
/// the `action_items` row id, set as the toast's own Id so a later `cancel`
/// can find and remove it. No-ops (rather than erroring) on failure — a
/// missed reminder shouldn't block saving action items.
#[cfg(windows)]
pub fn schedule(id: &str, text: &str, owner: Option<&str>, due_at_unix: i64) {
if let Err(e) = winrt::schedule(id, text, owner, due_at_unix) {
tracing::warn!("failed to schedule reminder for action item {id}: {e}");
}
}
#[cfg(not(windows))]
pub fn schedule(_id: &str, _text: &str, _owner: Option<&str>, _due_at_unix: i64) {}
/// Removes a previously scheduled reminder, if any (e.g. `reminder_set`
/// toggled off, or `due_at` changed before the schedule fired).
#[cfg(windows)]
pub fn cancel(id: &str) {
if let Err(e) = winrt::cancel(id) {
tracing::warn!("failed to cancel reminder for action item {id}: {e}");
}
}
#[cfg(not(windows))]
pub fn cancel(_id: &str) {}
#[cfg(windows)]
mod winrt {
use windows::core::{Result, HSTRING};
use windows::Data::Xml::Dom::XmlDocument;
use windows::Foundation::DateTime;
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
use windows::UI::Notifications::{
ScheduledToastNotification, ToastNotificationManager, ToastNotifier,
};
/// Arbitrary but stable — Windows uses this to associate scheduled
/// toasts (and their Action Center history) with this app specifically.
const APP_ID: &str = "WhispAssist.MeetingAssistant";
/// Seconds between the Windows FILETIME epoch (1601-01-01) and the Unix
/// epoch (1970-01-01) — `windows::Foundation::DateTime` counts 100ns
/// ticks since the former; our stored `due_at` is Unix seconds.
const EPOCH_OFFSET_SECS: i64 = 11_644_473_600;
pub fn init() {
// Best-effort: without this, toasts simply won't schedule — not
// worth failing startup over.
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(&HSTRING::from(APP_ID)) };
if let Err(e) = result {
tracing::warn!("failed to set AppUserModelID for notifications: {e}");
}
}
fn notifier() -> Result<ToastNotifier> {
ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(APP_ID))
}
fn to_windows_datetime(unix_secs: i64) -> DateTime {
DateTime {
UniversalTime: (unix_secs + EPOCH_OFFSET_SECS) * 10_000_000,
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
pub fn schedule(id: &str, text: &str, owner: Option<&str>, due_at_unix: i64) -> Result<()> {
// Replace any existing schedule for this item rather than stacking a
// second toast on top of it.
let _ = cancel(id);
let owner_line = owner
.map(|o| format!("<text>Owner: {}</text>", xml_escape(o)))
.unwrap_or_default();
let xml = format!(
"<toast><visual><binding template=\"ToastGeneric\"><text>Action item due</text><text>{}</text>{owner_line}</binding></visual></toast>",
xml_escape(text),
);
let doc = XmlDocument::new()?;
doc.LoadXml(&HSTRING::from(xml))?;
let toast = ScheduledToastNotification::CreateScheduledToastNotification(
&doc,
to_windows_datetime(due_at_unix),
)?;
toast.SetId(&HSTRING::from(id))?;
notifier()?.AddToSchedule(&toast)?;
Ok(())
}
pub fn cancel(id: &str) -> Result<()> {
let notifier = notifier()?;
for toast in notifier.GetScheduledToastNotifications()? {
if toast.Id()? == id {
notifier.RemoveFromSchedule(&toast)?;
}
}
Ok(())
}
}