From df85535ea2892bc2a9dec0c9580083ee0444afcc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 12:46:02 -0400 Subject: [PATCH] desktop: reminders reach you when the window isn't in front 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 --- Cargo.lock | 574 ++++++++++++++++++++++++++- core/src/local/models.rs | 13 + core/src/local/store.rs | 74 ++++ desktop/src-tauri/Cargo.toml | 6 + desktop/src-tauri/src/lib.rs | 6 + desktop/src-tauri/src/reminders.rs | 216 ++++++++++ frontend/src/desktop/bridge.ts | 24 ++ frontend/src/stores/reminders.ts | 43 +- frontend/src/views/RemindersView.vue | 11 +- 9 files changed, 942 insertions(+), 25 deletions(-) create mode 100644 desktop/src-tauri/src/reminders.rs diff --git a/Cargo.lock b/Cargo.lock index b43fc57..b15349f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,6 +144,30 @@ dependencies = [ "winnow 1.0.4", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compat" version = "0.2.5" @@ -157,6 +181,113 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atk" version = "0.18.2" @@ -261,6 +392,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -513,6 +657,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.2" @@ -925,6 +1078,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -962,6 +1142,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1138,6 +1338,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1549,6 +1762,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1883,6 +2102,7 @@ dependencies = [ "tauri-build", "tauri-plugin-global-shortcut", "tauri-plugin-log", + "tauri-plugin-notification", "tauri-plugin-updater", ] @@ -2207,6 +2427,20 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[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.38.0" @@ -2350,6 +2584,20 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify-rust" +version = "4.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe430c879247371c47fdedd328ad4e301e6b30b533c5fd5db1c06d75253d0e46" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2669,6 +2917,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "osakit" version = "0.3.1" @@ -2708,6 +2966,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2796,6 +3060,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2847,6 +3122,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2877,6 +3166,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2975,6 +3273,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3601,6 +3928,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3863,7 +4200,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3945,7 +4282,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4062,6 +4399,25 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2fd40946aef810c4be9fd33a2d1b9b397cb79042b2d21c81a0a8f204354fd1" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "time", + "url", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -4117,7 +4473,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4142,7 +4498,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4195,6 +4551,17 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" +dependencies = [ + "thiserror 2.0.20", + "windows 0.62.2", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4542,9 +4909,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4594,6 +4973,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5041,7 +5431,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -5065,7 +5455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.20", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5130,11 +5520,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5146,6 +5548,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5180,7 +5591,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5227,6 +5649,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5365,6 +5797,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -5596,7 +6037,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5679,6 +6120,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -5777,3 +6288,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/core/src/local/models.rs b/core/src/local/models.rs index 29920c2..f3d7280 100644 --- a/core/src/local/models.rs +++ b/core/src/local/models.rs @@ -95,6 +95,19 @@ pub struct TitleEntry { pub title: String, } +/// A reminder that has come due, as the desktop's reminder worker needs it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DueReminder { + pub id: String, + pub title: String, + /// `remind_at` as stored. It names the occurrence: completing or snoozing a + /// reminder changes it, and that is how an announcer tells a new occurrence + /// from one it has already announced. + pub remind_at: String, + /// The same instant, in milliseconds since the epoch. + pub due_ms: i64, +} + #[derive(Serialize)] pub struct SavedFilter { pub id: String, diff --git a/core/src/local/store.rs b/core/src/local/store.rs index c1a98d1..a0a4d48 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -375,6 +375,41 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result> { ids.iter().map(|id| load_note(conn, id)).collect() } +/// Reminders due at or before `now_ms`, soonest first, trash excluded. +/// +/// Read on a timer by the desktop's reminder worker, so only the notes that are +/// actually due are loaded in full (for their title). A `remind_at` that doesn't +/// parse is left out: it can never come due, and announcing it would be a guess. +pub fn due_reminders(conn: &Connection, now_ms: i64) -> rusqlite::Result> { + let set: Vec<(String, String)> = { + let mut stmt = conn.prepare( + "SELECT id, remind_at FROM notes WHERE trashed = 0 AND remind_at IS NOT NULL ORDER BY remind_at ASC", + )?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; + rows.collect::>()? + }; + let mut due = Vec::new(); + for (id, remind_at) in set { + let Ok(at) = DateTime::parse_from_rfc3339(&remind_at) else { + continue; + }; + let due_ms = at.timestamp_millis(); + if due_ms > now_ms { + continue; + } + let title = load_note(conn, &id)?.display_title; + due.push(DueReminder { + id, + title, + remind_at, + due_ms, + }); + } + // By instant, not by string: a synced `+00:00` and a local `Z` sort apart. + due.sort_by_key(|d| d.due_ms); + Ok(due) +} + pub fn titles(conn: &Connection) -> rusqlite::Result> { // Names come from `load_note` so the palette and the card can never disagree // about what a note is called. The command palette reads this; correctness @@ -1521,6 +1556,45 @@ mod tests { ); } + #[test] + fn only_reminders_already_due_are_due() { + let conn = db(); + let past = note(&conn, "past"); + let future = note(&conn, "future"); + let binned = note(&conn, "binned"); + update_note( + &conn, + &past.id, + &json!({"remind_at": "2026-03-01T09:00:00.000Z"}), + ) + .expect("remind"); + update_note( + &conn, + &future.id, + &json!({"remind_at": "2026-03-01T11:00:00.000Z"}), + ) + .expect("remind"); + update_note( + &conn, + &binned.id, + &json!({"remind_at": "2026-03-01T08:00:00.000Z"}), + ) + .expect("remind"); + trash(&conn, &binned.id).expect("trash"); + let ten = DateTime::parse_from_rfc3339("2026-03-01T10:00:00Z") + .expect("rfc3339") + .timestamp_millis(); + + let due = due_reminders(&conn, ten).expect("due"); + assert_eq!(due.len(), 1); + assert_eq!(due[0].id, past.id); + assert_eq!(due[0].title, past.display_title); + assert_eq!(due[0].remind_at, "2026-03-01T09:00:00.000Z"); + assert_eq!(due[0].due_ms, ten - 3_600_000); + // At the instant itself it is due. + assert_eq!(due_reminders(&conn, ten + 3_600_000).expect("due").len(), 2); + } + // ---- labels ---------------------------------------------------------------- #[test] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 18a217d..ac6906a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c593d43..71daf8b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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") { diff --git a/desktop/src-tauri/src/reminders.rs b/desktop/src-tauri/src/reminders.rs new file mode 100644 index 0000000..02f644f --- /dev/null +++ b/desktop/src-tauri/src/reminders.rs @@ -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, String> { + let db = app.state::(); + 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, + 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, now: i64) -> Vec { + // 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"]); + } +} diff --git a/frontend/src/desktop/bridge.ts b/frontend/src/desktop/bridge.ts index cd8e2e9..35cce97 100644 --- a/frontend/src/desktop/bridge.ts +++ b/frontend/src/desktop/bridge.ts @@ -295,3 +295,27 @@ export async function onCaptured(handler: () => void): Promise<() => void> { if (!events) return () => {}; return events.listen(CAPTURED_EVENT, () => handler()); } + +/** A reminder the desktop's reminder worker found due (desktop/src-tauri/src/reminders.rs). */ +export interface DueReminder { + id: string; + title: string; + remind_at: string; + due_ms: number; +} + +/** Fired at the main window for each reminder occurrence, once. */ +const REMINDER_DUE_EVENT = "inkwell://reminder-due"; + +/** + * Run `handler` for each reminder the desktop announces. On the desktop the Rust + * worker decides what is due, and raises the system notification itself when the + * window isn't in front; the page only shows its toast. + * + * Returns an unlisten function, or a no-op where there is no event API. + */ +export async function onReminderDue(handler: (reminder: DueReminder) => void): Promise<() => void> { + const events = window.__TAURI__?.event; + if (!events) return () => {}; + return events.listen(REMINDER_DUE_EVENT, (e) => handler(e.payload)); +} diff --git a/frontend/src/stores/reminders.ts b/frontend/src/stores/reminders.ts index 5f634e6..28a22b6 100644 --- a/frontend/src/stores/reminders.ts +++ b/frontend/src/stores/reminders.ts @@ -1,23 +1,33 @@ import { defineStore } from "pinia"; import { ref } from "vue"; import { repo } from "../adapters"; +import { isDesktop, onReminderDue } from "../desktop/bridge"; import router from "../router"; import { useUiStore } from "./ui"; import type { Note } from "./notes"; -// Foreground reminder delivery — NO Web Push / service worker (per operator). While -// the app is open, poll for due reminders and surface each once as a toast (and, if -// the user opted in, a page-context OS notification). Real background delivery is a -// native-client concern. +// Reminder delivery. In a browser it is foreground only — NO Web Push / service +// worker (per operator): while the tab is open, poll for due reminders and surface +// each once as a toast (and, if the user opted in, a page-context OS notification). +// +// The desktop doesn't poll here. Its Rust worker reads the local store, decides +// what is due, raises the system notification itself when the window isn't in +// front, and tells this page so it can show the toast (#5171). The page's +// `Notification` went nowhere in WebKitGTK, and its timer stopped with the window. const POLL_MS = 45_000; const RECENT_MS = 15 * 60 * 1000; // on first load, only announce reminders due this recently export const useReminderStore = defineStore("reminders", () => { - const osSupported = typeof Notification !== "undefined"; + const desktop = isDesktop(); + // The desktop raises its own system notifications; there is nothing to opt into. + const osSupported = !desktop && typeof Notification !== "undefined"; const osEnabled = ref(osSupported && Notification.permission === "granted"); // note id → the remind_at we already fired, so each occurrence notifies at most once. const notified = new Map(); let timer: ReturnType | undefined; + // The desktop's subscription to its reminder worker, held as the promise so stop() + // can undo one whose registration hasn't finished yet. + let listening: Promise<() => void> | undefined; let primed = false; // Single owner of the reminders endpoint — the RemindersView reads its list through @@ -26,8 +36,17 @@ export const useReminderStore = defineStore("reminders", () => { return repo.notes.reminders(); } + /** Show the in-app toast for a due reminder; returns the title it showed. */ + function toast(id: string, displayTitle: string): string { + const title = displayTitle || "Reminder"; + useUiStore().showToast(`⏰ ${title}`, { + label: "Open", + run: () => void router.push({ path: "/", query: { open: id } }), + }); + return title; + } + async function check(): Promise { - const ui = useUiStore(); let notes: Note[]; try { notes = await fetchReminders(); @@ -42,11 +61,7 @@ export const useReminderStore = defineStore("reminders", () => { notified.set(n.id, n.remind_at); // Silently prime a stale backlog on first load; only announce recently-due ones. if (!primed && now - due > RECENT_MS) continue; - const title = n.display_title || "Reminder"; - ui.showToast(`⏰ ${title}`, { - label: "Open", - run: () => void router.push({ path: "/", query: { open: n.id } }), - }); + const title = toast(n.id, n.display_title); if (osEnabled.value) { try { new Notification("Inkwell reminder", { body: title }); @@ -59,6 +74,10 @@ export const useReminderStore = defineStore("reminders", () => { } function start(): void { + if (desktop) { + if (!listening) listening = onReminderDue((r) => void toast(r.id, r.title)); + return; + } if (timer) return; void check(); timer = setInterval(() => void check(), POLL_MS); @@ -66,6 +85,8 @@ export const useReminderStore = defineStore("reminders", () => { function stop(): void { if (timer) clearInterval(timer); timer = undefined; + void listening?.then((off) => off()); + listening = undefined; } async function enableOs(): Promise { if (!osSupported) return; diff --git a/frontend/src/views/RemindersView.vue b/frontend/src/views/RemindersView.vue index 3e8b8c1..4c18019 100644 --- a/frontend/src/views/RemindersView.vue +++ b/frontend/src/views/RemindersView.vue @@ -7,9 +7,16 @@ import AsyncState from "../components/AsyncState.vue"; import EmptyState from "../components/EmptyState.vue"; import NoteEditor from "../components/NoteEditor.vue"; import NoteGrid from "../components/NoteGrid.vue"; +import { isDesktop } from "../desktop/bridge"; const reminders = useReminderStore(); +// What this surface actually does with a due reminder. The desktop's worker raises +// system notifications while it runs; a browser tab can only alert while it is open. +const deliveryNote = isDesktop() + ? "Due reminders show as system notifications while Inkwell is running, even minimised." + : "Due reminders pop up while this tab is open. The desktop and Android apps alert you in the background."; + const { items, loading, error, load } = useNoteList(() => reminders.fetchReminders(), "Couldn't load reminders."); const { editing, open: openEditor, close: closeEditor, navigate: onNavigate } = useNoteEditor({ @@ -37,9 +44,7 @@ onMounted(load); Notifications on ✓ -

- Due reminders pop up while Inkwell is open. Background alerts arrive with the desktop & mobile apps. -

+

{{ deliveryNote }}