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
Generated
+563 -11
View File
@@ -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",
]
+13
View File
@@ -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,
+74
View File
@@ -375,6 +375,41 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
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<Vec<DueReminder>> {
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::<rusqlite::Result<_>>()?
};
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<Vec<TitleEntry>> {
// 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]
+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"]);
}
}
+24
View File
@@ -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<DueReminder>(REMINDER_DUE_EVENT, (e) => handler(e.payload));
}
+32 -11
View File
@@ -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<string, string>();
let timer: ReturnType<typeof setInterval> | 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<void> {
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<void> {
if (!osSupported) return;
+8 -3
View File
@@ -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);
</button>
<span v-else-if="reminders.osEnabled" class="text-xs text-neutral-400">Notifications on ✓</span>
</div>
<p class="mb-5 text-xs text-neutral-400">
Due reminders pop up while Inkwell is open. Background alerts arrive with the desktop &amp; mobile apps.
</p>
<p class="mb-5 text-xs text-neutral-400">{{ deliveryNote }}</p>
<AsyncState :loading="loading" :error="error || undefined" error-title="Couldn't load reminders" @retry="load">
<EmptyState