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.
121 lines
4.4 KiB
Rust
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('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
}
|
|
|
|
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(())
|
|
}
|
|
}
|