Add reminder notification daemon
- SHA
adeab446f350a29061aeeb3596e7b14613273313- Parents
-
b2c7109 - Tree
a32f3d2
adeab44
adeab446f350a29061aeeb3596e7b14613273313b2c7109
a32f3d2| Status | File | + | - |
|---|---|---|---|
| M |
Cargo.lock
|
767 | 3 |
| M |
Cargo.toml
|
3 | 0 |
| M |
README.md
|
8 | 2 |
| M |
src/cli.rs
|
361 | 1 |
| M |
src/lib.rs
|
2 | 0 |
| A |
src/reminders.rs
|
762 | 0 |
| A |
src/services.rs
|
564 | 0 |
Cargo.lockmodified@@ -23,6 +23,137 @@ version = "1.0.102" | ||
| 23 | 23 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 24 | 24 | checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" |
| 25 | 25 | |
| 26 | +[[package]] | |
| 27 | +name = "async-broadcast" | |
| 28 | +version = "0.7.2" | |
| 29 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 30 | +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" | |
| 31 | +dependencies = [ | |
| 32 | + "event-listener", | |
| 33 | + "event-listener-strategy", | |
| 34 | + "futures-core", | |
| 35 | + "pin-project-lite", | |
| 36 | +] | |
| 37 | + | |
| 38 | +[[package]] | |
| 39 | +name = "async-channel" | |
| 40 | +version = "2.5.0" | |
| 41 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 42 | +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" | |
| 43 | +dependencies = [ | |
| 44 | + "concurrent-queue", | |
| 45 | + "event-listener-strategy", | |
| 46 | + "futures-core", | |
| 47 | + "pin-project-lite", | |
| 48 | +] | |
| 49 | + | |
| 50 | +[[package]] | |
| 51 | +name = "async-executor" | |
| 52 | +version = "1.14.0" | |
| 53 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 54 | +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" | |
| 55 | +dependencies = [ | |
| 56 | + "async-task", | |
| 57 | + "concurrent-queue", | |
| 58 | + "fastrand", | |
| 59 | + "futures-lite", | |
| 60 | + "pin-project-lite", | |
| 61 | + "slab", | |
| 62 | +] | |
| 63 | + | |
| 64 | +[[package]] | |
| 65 | +name = "async-io" | |
| 66 | +version = "2.6.0" | |
| 67 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 68 | +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" | |
| 69 | +dependencies = [ | |
| 70 | + "autocfg", | |
| 71 | + "cfg-if", | |
| 72 | + "concurrent-queue", | |
| 73 | + "futures-io", | |
| 74 | + "futures-lite", | |
| 75 | + "parking", | |
| 76 | + "polling", | |
| 77 | + "rustix", | |
| 78 | + "slab", | |
| 79 | + "windows-sys 0.61.2", | |
| 80 | +] | |
| 81 | + | |
| 82 | +[[package]] | |
| 83 | +name = "async-lock" | |
| 84 | +version = "3.4.2" | |
| 85 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 86 | +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" | |
| 87 | +dependencies = [ | |
| 88 | + "event-listener", | |
| 89 | + "event-listener-strategy", | |
| 90 | + "pin-project-lite", | |
| 91 | +] | |
| 92 | + | |
| 93 | +[[package]] | |
| 94 | +name = "async-process" | |
| 95 | +version = "2.5.0" | |
| 96 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 97 | +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" | |
| 98 | +dependencies = [ | |
| 99 | + "async-channel", | |
| 100 | + "async-io", | |
| 101 | + "async-lock", | |
| 102 | + "async-signal", | |
| 103 | + "async-task", | |
| 104 | + "blocking", | |
| 105 | + "cfg-if", | |
| 106 | + "event-listener", | |
| 107 | + "futures-lite", | |
| 108 | + "rustix", | |
| 109 | +] | |
| 110 | + | |
| 111 | +[[package]] | |
| 112 | +name = "async-recursion" | |
| 113 | +version = "1.1.1" | |
| 114 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 115 | +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" | |
| 116 | +dependencies = [ | |
| 117 | + "proc-macro2", | |
| 118 | + "quote", | |
| 119 | + "syn 2.0.117", | |
| 120 | +] | |
| 121 | + | |
| 122 | +[[package]] | |
| 123 | +name = "async-signal" | |
| 124 | +version = "0.2.14" | |
| 125 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 126 | +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" | |
| 127 | +dependencies = [ | |
| 128 | + "async-io", | |
| 129 | + "async-lock", | |
| 130 | + "atomic-waker", | |
| 131 | + "cfg-if", | |
| 132 | + "futures-core", | |
| 133 | + "futures-io", | |
| 134 | + "rustix", | |
| 135 | + "signal-hook-registry", | |
| 136 | + "slab", | |
| 137 | + "windows-sys 0.61.2", | |
| 138 | +] | |
| 139 | + | |
| 140 | +[[package]] | |
| 141 | +name = "async-task" | |
| 142 | +version = "4.7.1" | |
| 143 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 144 | +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" | |
| 145 | + | |
| 146 | +[[package]] | |
| 147 | +name = "async-trait" | |
| 148 | +version = "0.1.89" | |
| 149 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 150 | +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" | |
| 151 | +dependencies = [ | |
| 152 | + "proc-macro2", | |
| 153 | + "quote", | |
| 154 | + "syn 2.0.117", | |
| 155 | +] | |
| 156 | + | |
| 26 | 157 | [[package]] |
| 27 | 158 | name = "atomic" |
| 28 | 159 | version = "0.6.1" |
@@ -86,6 +217,28 @@ dependencies = [ | ||
| 86 | 217 | "generic-array", |
| 87 | 218 | ] |
| 88 | 219 | |
| 220 | +[[package]] | |
| 221 | +name = "block2" | |
| 222 | +version = "0.6.2" | |
| 223 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 224 | +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" | |
| 225 | +dependencies = [ | |
| 226 | + "objc2", | |
| 227 | +] | |
| 228 | + | |
| 229 | +[[package]] | |
| 230 | +name = "blocking" | |
| 231 | +version = "1.6.2" | |
| 232 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 233 | +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" | |
| 234 | +dependencies = [ | |
| 235 | + "async-channel", | |
| 236 | + "async-task", | |
| 237 | + "futures-io", | |
| 238 | + "futures-lite", | |
| 239 | + "piper", | |
| 240 | +] | |
| 241 | + | |
| 89 | 242 | [[package]] |
| 90 | 243 | name = "bumpalo" |
| 91 | 244 | version = "3.20.2" |
@@ -149,6 +302,15 @@ dependencies = [ | ||
| 149 | 302 | "static_assertions", |
| 150 | 303 | ] |
| 151 | 304 | |
| 305 | +[[package]] | |
| 306 | +name = "concurrent-queue" | |
| 307 | +version = "2.5.0" | |
| 308 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 309 | +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | |
| 310 | +dependencies = [ | |
| 311 | + "crossbeam-utils", | |
| 312 | +] | |
| 313 | + | |
| 152 | 314 | [[package]] |
| 153 | 315 | name = "convert_case" |
| 154 | 316 | version = "0.10.0" |
@@ -167,6 +329,12 @@ dependencies = [ | ||
| 167 | 329 | "libc", |
| 168 | 330 | ] |
| 169 | 331 | |
| 332 | +[[package]] | |
| 333 | +name = "crossbeam-utils" | |
| 334 | +version = "0.8.21" | |
| 335 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 336 | +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" | |
| 337 | + | |
| 170 | 338 | [[package]] |
| 171 | 339 | name = "crossterm" |
| 172 | 340 | version = "0.29.0" |
@@ -295,6 +463,37 @@ dependencies = [ | ||
| 295 | 463 | "crypto-common", |
| 296 | 464 | ] |
| 297 | 465 | |
| 466 | +[[package]] | |
| 467 | +name = "directories" | |
| 468 | +version = "6.0.0" | |
| 469 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 470 | +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" | |
| 471 | +dependencies = [ | |
| 472 | + "dirs-sys", | |
| 473 | +] | |
| 474 | + | |
| 475 | +[[package]] | |
| 476 | +name = "dirs-sys" | |
| 477 | +version = "0.5.0" | |
| 478 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 479 | +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" | |
| 480 | +dependencies = [ | |
| 481 | + "libc", | |
| 482 | + "option-ext", | |
| 483 | + "redox_users", | |
| 484 | + "windows-sys 0.61.2", | |
| 485 | +] | |
| 486 | + | |
| 487 | +[[package]] | |
| 488 | +name = "dispatch2" | |
| 489 | +version = "0.3.1" | |
| 490 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 491 | +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" | |
| 492 | +dependencies = [ | |
| 493 | + "bitflags 2.11.1", | |
| 494 | + "objc2", | |
| 495 | +] | |
| 496 | + | |
| 298 | 497 | [[package]] |
| 299 | 498 | name = "displaydoc" |
| 300 | 499 | version = "0.2.5" |
@@ -321,6 +520,33 @@ version = "1.15.0" | ||
| 321 | 520 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 322 | 521 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" |
| 323 | 522 | |
| 523 | +[[package]] | |
| 524 | +name = "endi" | |
| 525 | +version = "1.1.1" | |
| 526 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 527 | +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" | |
| 528 | + | |
| 529 | +[[package]] | |
| 530 | +name = "enumflags2" | |
| 531 | +version = "0.7.12" | |
| 532 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 533 | +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" | |
| 534 | +dependencies = [ | |
| 535 | + "enumflags2_derive", | |
| 536 | + "serde", | |
| 537 | +] | |
| 538 | + | |
| 539 | +[[package]] | |
| 540 | +name = "enumflags2_derive" | |
| 541 | +version = "0.7.12" | |
| 542 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 543 | +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" | |
| 544 | +dependencies = [ | |
| 545 | + "proc-macro2", | |
| 546 | + "quote", | |
| 547 | + "syn 2.0.117", | |
| 548 | +] | |
| 549 | + | |
| 324 | 550 | [[package]] |
| 325 | 551 | name = "equivalent" |
| 326 | 552 | version = "1.0.2" |
@@ -346,6 +572,27 @@ dependencies = [ | ||
| 346 | 572 | "num-traits", |
| 347 | 573 | ] |
| 348 | 574 | |
| 575 | +[[package]] | |
| 576 | +name = "event-listener" | |
| 577 | +version = "5.4.1" | |
| 578 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 579 | +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" | |
| 580 | +dependencies = [ | |
| 581 | + "concurrent-queue", | |
| 582 | + "parking", | |
| 583 | + "pin-project-lite", | |
| 584 | +] | |
| 585 | + | |
| 586 | +[[package]] | |
| 587 | +name = "event-listener-strategy" | |
| 588 | +version = "0.5.4" | |
| 589 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 590 | +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" | |
| 591 | +dependencies = [ | |
| 592 | + "event-listener", | |
| 593 | + "pin-project-lite", | |
| 594 | +] | |
| 595 | + | |
| 349 | 596 | [[package]] |
| 350 | 597 | name = "fancy-regex" |
| 351 | 598 | version = "0.11.0" |
@@ -356,6 +603,12 @@ dependencies = [ | ||
| 356 | 603 | "regex", |
| 357 | 604 | ] |
| 358 | 605 | |
| 606 | +[[package]] | |
| 607 | +name = "fastrand" | |
| 608 | +version = "2.4.1" | |
| 609 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 610 | +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" | |
| 611 | + | |
| 359 | 612 | [[package]] |
| 360 | 613 | name = "filedescriptor" |
| 361 | 614 | version = "0.8.3" |
@@ -412,6 +665,16 @@ dependencies = [ | ||
| 412 | 665 | "percent-encoding", |
| 413 | 666 | ] |
| 414 | 667 | |
| 668 | +[[package]] | |
| 669 | +name = "fs2" | |
| 670 | +version = "0.4.3" | |
| 671 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 672 | +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" | |
| 673 | +dependencies = [ | |
| 674 | + "libc", | |
| 675 | + "winapi", | |
| 676 | +] | |
| 677 | + | |
| 415 | 678 | [[package]] |
| 416 | 679 | name = "futures-channel" |
| 417 | 680 | version = "0.3.32" |
@@ -434,6 +697,19 @@ version = "0.3.32" | ||
| 434 | 697 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 435 | 698 | checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" |
| 436 | 699 | |
| 700 | +[[package]] | |
| 701 | +name = "futures-lite" | |
| 702 | +version = "2.6.1" | |
| 703 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 704 | +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" | |
| 705 | +dependencies = [ | |
| 706 | + "fastrand", | |
| 707 | + "futures-core", | |
| 708 | + "futures-io", | |
| 709 | + "parking", | |
| 710 | + "pin-project-lite", | |
| 711 | +] | |
| 712 | + | |
| 437 | 713 | [[package]] |
| 438 | 714 | name = "futures-sink" |
| 439 | 715 | version = "0.3.32" |
@@ -543,6 +819,12 @@ version = "0.5.0" | ||
| 543 | 819 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 544 | 820 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" |
| 545 | 821 | |
| 822 | +[[package]] | |
| 823 | +name = "hermit-abi" | |
| 824 | +version = "0.5.2" | |
| 825 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 826 | +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" | |
| 827 | + | |
| 546 | 828 | [[package]] |
| 547 | 829 | name = "hex" |
| 548 | 830 | version = "0.4.3" |
@@ -874,6 +1156,15 @@ version = "0.2.185" | ||
| 874 | 1156 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 875 | 1157 | checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" |
| 876 | 1158 | |
| 1159 | +[[package]] | |
| 1160 | +name = "libredox" | |
| 1161 | +version = "0.1.16" | |
| 1162 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1163 | +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" | |
| 1164 | +dependencies = [ | |
| 1165 | + "libc", | |
| 1166 | +] | |
| 1167 | + | |
| 877 | 1168 | [[package]] |
| 878 | 1169 | name = "line-clipping" |
| 879 | 1170 | version = "0.3.7" |
@@ -931,6 +1222,18 @@ version = "0.1.2" | ||
| 931 | 1222 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 932 | 1223 | checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" |
| 933 | 1224 | |
| 1225 | +[[package]] | |
| 1226 | +name = "mac-notification-sys" | |
| 1227 | +version = "0.6.12" | |
| 1228 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1229 | +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" | |
| 1230 | +dependencies = [ | |
| 1231 | + "cc", | |
| 1232 | + "objc2", | |
| 1233 | + "objc2-foundation", | |
| 1234 | + "time", | |
| 1235 | +] | |
| 1236 | + | |
| 934 | 1237 | [[package]] |
| 935 | 1238 | name = "mac_address" |
| 936 | 1239 | version = "1.1.8" |
@@ -1003,6 +1306,20 @@ dependencies = [ | ||
| 1003 | 1306 | "minimal-lexical", |
| 1004 | 1307 | ] |
| 1005 | 1308 | |
| 1309 | +[[package]] | |
| 1310 | +name = "notify-rust" | |
| 1311 | +version = "4.16.0" | |
| 1312 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1313 | +checksum = "5e551a9f0db223eaf3eb156906f99f46897fd951ee66dd1cb0be14db4d36d2fa" | |
| 1314 | +dependencies = [ | |
| 1315 | + "futures-lite", | |
| 1316 | + "log", | |
| 1317 | + "mac-notification-sys", | |
| 1318 | + "serde", | |
| 1319 | + "tauri-winrt-notification", | |
| 1320 | + "zbus", | |
| 1321 | +] | |
| 1322 | + | |
| 1006 | 1323 | [[package]] |
| 1007 | 1324 | name = "num-conv" |
| 1008 | 1325 | version = "0.2.1" |
@@ -1038,12 +1355,57 @@ dependencies = [ | ||
| 1038 | 1355 | "libc", |
| 1039 | 1356 | ] |
| 1040 | 1357 | |
| 1358 | +[[package]] | |
| 1359 | +name = "objc2" | |
| 1360 | +version = "0.6.4" | |
| 1361 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1362 | +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" | |
| 1363 | +dependencies = [ | |
| 1364 | + "objc2-encode", | |
| 1365 | +] | |
| 1366 | + | |
| 1367 | +[[package]] | |
| 1368 | +name = "objc2-core-foundation" | |
| 1369 | +version = "0.3.2" | |
| 1370 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1371 | +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" | |
| 1372 | +dependencies = [ | |
| 1373 | + "bitflags 2.11.1", | |
| 1374 | + "dispatch2", | |
| 1375 | + "objc2", | |
| 1376 | +] | |
| 1377 | + | |
| 1378 | +[[package]] | |
| 1379 | +name = "objc2-encode" | |
| 1380 | +version = "4.1.0" | |
| 1381 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1382 | +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" | |
| 1383 | + | |
| 1384 | +[[package]] | |
| 1385 | +name = "objc2-foundation" | |
| 1386 | +version = "0.3.2" | |
| 1387 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1388 | +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" | |
| 1389 | +dependencies = [ | |
| 1390 | + "bitflags 2.11.1", | |
| 1391 | + "block2", | |
| 1392 | + "libc", | |
| 1393 | + "objc2", | |
| 1394 | + "objc2-core-foundation", | |
| 1395 | +] | |
| 1396 | + | |
| 1041 | 1397 | [[package]] |
| 1042 | 1398 | name = "once_cell" |
| 1043 | 1399 | version = "1.21.4" |
| 1044 | 1400 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1045 | 1401 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" |
| 1046 | 1402 | |
| 1403 | +[[package]] | |
| 1404 | +name = "option-ext" | |
| 1405 | +version = "0.2.0" | |
| 1406 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1407 | +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" | |
| 1408 | + | |
| 1047 | 1409 | [[package]] |
| 1048 | 1410 | name = "ordered-float" |
| 1049 | 1411 | version = "4.6.0" |
@@ -1053,6 +1415,22 @@ dependencies = [ | ||
| 1053 | 1415 | "num-traits", |
| 1054 | 1416 | ] |
| 1055 | 1417 | |
| 1418 | +[[package]] | |
| 1419 | +name = "ordered-stream" | |
| 1420 | +version = "0.2.0" | |
| 1421 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1422 | +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" | |
| 1423 | +dependencies = [ | |
| 1424 | + "futures-core", | |
| 1425 | + "pin-project-lite", | |
| 1426 | +] | |
| 1427 | + | |
| 1428 | +[[package]] | |
| 1429 | +name = "parking" | |
| 1430 | +version = "2.2.1" | |
| 1431 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1432 | +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" | |
| 1433 | + | |
| 1056 | 1434 | [[package]] |
| 1057 | 1435 | name = "parking_lot" |
| 1058 | 1436 | version = "0.12.5" |
@@ -1073,7 +1451,7 @@ dependencies = [ | ||
| 1073 | 1451 | "libc", |
| 1074 | 1452 | "redox_syscall", |
| 1075 | 1453 | "smallvec", |
| 1076 | - "windows-link", | |
| 1454 | + "windows-link 0.2.1", | |
| 1077 | 1455 | ] |
| 1078 | 1456 | |
| 1079 | 1457 | [[package]] |
@@ -1183,6 +1561,31 @@ version = "0.2.17" | ||
| 1183 | 1561 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1184 | 1562 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" |
| 1185 | 1563 | |
| 1564 | +[[package]] | |
| 1565 | +name = "piper" | |
| 1566 | +version = "0.2.5" | |
| 1567 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1568 | +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" | |
| 1569 | +dependencies = [ | |
| 1570 | + "atomic-waker", | |
| 1571 | + "fastrand", | |
| 1572 | + "futures-io", | |
| 1573 | +] | |
| 1574 | + | |
| 1575 | +[[package]] | |
| 1576 | +name = "polling" | |
| 1577 | +version = "3.11.0" | |
| 1578 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1579 | +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" | |
| 1580 | +dependencies = [ | |
| 1581 | + "cfg-if", | |
| 1582 | + "concurrent-queue", | |
| 1583 | + "hermit-abi", | |
| 1584 | + "pin-project-lite", | |
| 1585 | + "rustix", | |
| 1586 | + "windows-sys 0.61.2", | |
| 1587 | +] | |
| 1588 | + | |
| 1186 | 1589 | [[package]] |
| 1187 | 1590 | name = "portable-atomic" |
| 1188 | 1591 | version = "1.13.1" |
@@ -1223,6 +1626,15 @@ dependencies = [ | ||
| 1223 | 1626 | "syn 2.0.117", |
| 1224 | 1627 | ] |
| 1225 | 1628 | |
| 1629 | +[[package]] | |
| 1630 | +name = "proc-macro-crate" | |
| 1631 | +version = "3.5.0" | |
| 1632 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1633 | +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" | |
| 1634 | +dependencies = [ | |
| 1635 | + "toml_edit", | |
| 1636 | +] | |
| 1637 | + | |
| 1226 | 1638 | [[package]] |
| 1227 | 1639 | name = "proc-macro2" |
| 1228 | 1640 | version = "1.0.106" |
@@ -1232,6 +1644,15 @@ dependencies = [ | ||
| 1232 | 1644 | "unicode-ident", |
| 1233 | 1645 | ] |
| 1234 | 1646 | |
| 1647 | +[[package]] | |
| 1648 | +name = "quick-xml" | |
| 1649 | +version = "0.37.5" | |
| 1650 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1651 | +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" | |
| 1652 | +dependencies = [ | |
| 1653 | + "memchr", | |
| 1654 | +] | |
| 1655 | + | |
| 1235 | 1656 | [[package]] |
| 1236 | 1657 | name = "quinn" |
| 1237 | 1658 | version = "0.11.9" |
@@ -1442,6 +1863,9 @@ name = "rcal" | ||
| 1442 | 1863 | version = "0.1.0" |
| 1443 | 1864 | dependencies = [ |
| 1444 | 1865 | "crossterm", |
| 1866 | + "directories", | |
| 1867 | + "fs2", | |
| 1868 | + "notify-rust", | |
| 1445 | 1869 | "ratatui", |
| 1446 | 1870 | "reqwest", |
| 1447 | 1871 | "serde", |
@@ -1458,6 +1882,17 @@ dependencies = [ | ||
| 1458 | 1882 | "bitflags 2.11.1", |
| 1459 | 1883 | ] |
| 1460 | 1884 | |
| 1885 | +[[package]] | |
| 1886 | +name = "redox_users" | |
| 1887 | +version = "0.5.2" | |
| 1888 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1889 | +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" | |
| 1890 | +dependencies = [ | |
| 1891 | + "getrandom 0.2.17", | |
| 1892 | + "libredox", | |
| 1893 | + "thiserror 2.0.18", | |
| 1894 | +] | |
| 1895 | + | |
| 1461 | 1896 | [[package]] |
| 1462 | 1897 | name = "regex" |
| 1463 | 1898 | version = "1.12.3" |
@@ -1671,6 +2106,17 @@ dependencies = [ | ||
| 1671 | 2106 | "zmij", |
| 1672 | 2107 | ] |
| 1673 | 2108 | |
| 2109 | +[[package]] | |
| 2110 | +name = "serde_repr" | |
| 2111 | +version = "0.1.20" | |
| 2112 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2113 | +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" | |
| 2114 | +dependencies = [ | |
| 2115 | + "proc-macro2", | |
| 2116 | + "quote", | |
| 2117 | + "syn 2.0.117", | |
| 2118 | +] | |
| 2119 | + | |
| 1674 | 2120 | [[package]] |
| 1675 | 2121 | name = "serde_urlencoded" |
| 1676 | 2122 | version = "0.7.1" |
@@ -1846,6 +2292,31 @@ dependencies = [ | ||
| 1846 | 2292 | "syn 2.0.117", |
| 1847 | 2293 | ] |
| 1848 | 2294 | |
| 2295 | +[[package]] | |
| 2296 | +name = "tauri-winrt-notification" | |
| 2297 | +version = "0.7.2" | |
| 2298 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2299 | +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" | |
| 2300 | +dependencies = [ | |
| 2301 | + "quick-xml", | |
| 2302 | + "thiserror 2.0.18", | |
| 2303 | + "windows", | |
| 2304 | + "windows-version", | |
| 2305 | +] | |
| 2306 | + | |
| 2307 | +[[package]] | |
| 2308 | +name = "tempfile" | |
| 2309 | +version = "3.27.0" | |
| 2310 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2311 | +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 2312 | +dependencies = [ | |
| 2313 | + "fastrand", | |
| 2314 | + "getrandom 0.4.2", | |
| 2315 | + "once_cell", | |
| 2316 | + "rustix", | |
| 2317 | + "windows-sys 0.61.2", | |
| 2318 | +] | |
| 2319 | + | |
| 1849 | 2320 | [[package]] |
| 1850 | 2321 | name = "terminfo" |
| 1851 | 2322 | version = "0.9.0" |
@@ -2030,6 +2501,36 @@ dependencies = [ | ||
| 2030 | 2501 | "tokio", |
| 2031 | 2502 | ] |
| 2032 | 2503 | |
| 2504 | +[[package]] | |
| 2505 | +name = "toml_datetime" | |
| 2506 | +version = "1.1.1+spec-1.1.0" | |
| 2507 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2508 | +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" | |
| 2509 | +dependencies = [ | |
| 2510 | + "serde_core", | |
| 2511 | +] | |
| 2512 | + | |
| 2513 | +[[package]] | |
| 2514 | +name = "toml_edit" | |
| 2515 | +version = "0.25.11+spec-1.1.0" | |
| 2516 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2517 | +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" | |
| 2518 | +dependencies = [ | |
| 2519 | + "indexmap", | |
| 2520 | + "toml_datetime", | |
| 2521 | + "toml_parser", | |
| 2522 | + "winnow 1.0.2", | |
| 2523 | +] | |
| 2524 | + | |
| 2525 | +[[package]] | |
| 2526 | +name = "toml_parser" | |
| 2527 | +version = "1.1.2+spec-1.1.0" | |
| 2528 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2529 | +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" | |
| 2530 | +dependencies = [ | |
| 2531 | + "winnow 1.0.2", | |
| 2532 | +] | |
| 2533 | + | |
| 2033 | 2534 | [[package]] |
| 2034 | 2535 | name = "tower" |
| 2035 | 2536 | version = "0.5.3" |
@@ -2082,9 +2583,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2082 | 2583 | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" |
| 2083 | 2584 | dependencies = [ |
| 2084 | 2585 | "pin-project-lite", |
| 2586 | + "tracing-attributes", | |
| 2085 | 2587 | "tracing-core", |
| 2086 | 2588 | ] |
| 2087 | 2589 | |
| 2590 | +[[package]] | |
| 2591 | +name = "tracing-attributes" | |
| 2592 | +version = "0.1.31" | |
| 2593 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2594 | +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" | |
| 2595 | +dependencies = [ | |
| 2596 | + "proc-macro2", | |
| 2597 | + "quote", | |
| 2598 | + "syn 2.0.117", | |
| 2599 | +] | |
| 2600 | + | |
| 2088 | 2601 | [[package]] |
| 2089 | 2602 | name = "tracing-core" |
| 2090 | 2603 | version = "0.1.36" |
@@ -2112,6 +2625,17 @@ version = "0.1.7" | ||
| 2112 | 2625 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2113 | 2626 | checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" |
| 2114 | 2627 | |
| 2628 | +[[package]] | |
| 2629 | +name = "uds_windows" | |
| 2630 | +version = "1.2.1" | |
| 2631 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2632 | +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" | |
| 2633 | +dependencies = [ | |
| 2634 | + "memoffset", | |
| 2635 | + "tempfile", | |
| 2636 | + "windows-sys 0.61.2", | |
| 2637 | +] | |
| 2638 | + | |
| 2115 | 2639 | [[package]] |
| 2116 | 2640 | name = "unicode-ident" |
| 2117 | 2641 | version = "1.0.24" |
@@ -2186,6 +2710,7 @@ dependencies = [ | ||
| 2186 | 2710 | "atomic", |
| 2187 | 2711 | "getrandom 0.4.2", |
| 2188 | 2712 | "js-sys", |
| 2713 | + "serde_core", | |
| 2189 | 2714 | "wasm-bindgen", |
| 2190 | 2715 | ] |
| 2191 | 2716 | |
@@ -2449,12 +2974,114 @@ version = "0.4.0" | ||
| 2449 | 2974 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2450 | 2975 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" |
| 2451 | 2976 | |
| 2977 | +[[package]] | |
| 2978 | +name = "windows" | |
| 2979 | +version = "0.61.3" | |
| 2980 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2981 | +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" | |
| 2982 | +dependencies = [ | |
| 2983 | + "windows-collections", | |
| 2984 | + "windows-core", | |
| 2985 | + "windows-future", | |
| 2986 | + "windows-link 0.1.3", | |
| 2987 | + "windows-numerics", | |
| 2988 | +] | |
| 2989 | + | |
| 2990 | +[[package]] | |
| 2991 | +name = "windows-collections" | |
| 2992 | +version = "0.2.0" | |
| 2993 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2994 | +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" | |
| 2995 | +dependencies = [ | |
| 2996 | + "windows-core", | |
| 2997 | +] | |
| 2998 | + | |
| 2999 | +[[package]] | |
| 3000 | +name = "windows-core" | |
| 3001 | +version = "0.61.2" | |
| 3002 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3003 | +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" | |
| 3004 | +dependencies = [ | |
| 3005 | + "windows-implement", | |
| 3006 | + "windows-interface", | |
| 3007 | + "windows-link 0.1.3", | |
| 3008 | + "windows-result", | |
| 3009 | + "windows-strings", | |
| 3010 | +] | |
| 3011 | + | |
| 3012 | +[[package]] | |
| 3013 | +name = "windows-future" | |
| 3014 | +version = "0.2.1" | |
| 3015 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3016 | +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" | |
| 3017 | +dependencies = [ | |
| 3018 | + "windows-core", | |
| 3019 | + "windows-link 0.1.3", | |
| 3020 | + "windows-threading", | |
| 3021 | +] | |
| 3022 | + | |
| 3023 | +[[package]] | |
| 3024 | +name = "windows-implement" | |
| 3025 | +version = "0.60.2" | |
| 3026 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3027 | +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | |
| 3028 | +dependencies = [ | |
| 3029 | + "proc-macro2", | |
| 3030 | + "quote", | |
| 3031 | + "syn 2.0.117", | |
| 3032 | +] | |
| 3033 | + | |
| 3034 | +[[package]] | |
| 3035 | +name = "windows-interface" | |
| 3036 | +version = "0.59.3" | |
| 3037 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3038 | +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | |
| 3039 | +dependencies = [ | |
| 3040 | + "proc-macro2", | |
| 3041 | + "quote", | |
| 3042 | + "syn 2.0.117", | |
| 3043 | +] | |
| 3044 | + | |
| 3045 | +[[package]] | |
| 3046 | +name = "windows-link" | |
| 3047 | +version = "0.1.3" | |
| 3048 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3049 | +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" | |
| 3050 | + | |
| 2452 | 3051 | [[package]] |
| 2453 | 3052 | name = "windows-link" |
| 2454 | 3053 | version = "0.2.1" |
| 2455 | 3054 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2456 | 3055 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" |
| 2457 | 3056 | |
| 3057 | +[[package]] | |
| 3058 | +name = "windows-numerics" | |
| 3059 | +version = "0.2.0" | |
| 3060 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3061 | +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" | |
| 3062 | +dependencies = [ | |
| 3063 | + "windows-core", | |
| 3064 | + "windows-link 0.1.3", | |
| 3065 | +] | |
| 3066 | + | |
| 3067 | +[[package]] | |
| 3068 | +name = "windows-result" | |
| 3069 | +version = "0.3.4" | |
| 3070 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3071 | +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" | |
| 3072 | +dependencies = [ | |
| 3073 | + "windows-link 0.1.3", | |
| 3074 | +] | |
| 3075 | + | |
| 3076 | +[[package]] | |
| 3077 | +name = "windows-strings" | |
| 3078 | +version = "0.4.2" | |
| 3079 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3080 | +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" | |
| 3081 | +dependencies = [ | |
| 3082 | + "windows-link 0.1.3", | |
| 3083 | +] | |
| 3084 | + | |
| 2458 | 3085 | [[package]] |
| 2459 | 3086 | name = "windows-sys" |
| 2460 | 3087 | version = "0.52.0" |
@@ -2479,7 +3106,7 @@ version = "0.61.2" | ||
| 2479 | 3106 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2480 | 3107 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" |
| 2481 | 3108 | dependencies = [ |
| 2482 | - "windows-link", | |
| 3109 | + "windows-link 0.2.1", | |
| 2483 | 3110 | ] |
| 2484 | 3111 | |
| 2485 | 3112 | [[package]] |
@@ -2504,7 +3131,7 @@ version = "0.53.5" | ||
| 2504 | 3131 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2505 | 3132 | checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" |
| 2506 | 3133 | dependencies = [ |
| 2507 | - "windows-link", | |
| 3134 | + "windows-link 0.2.1", | |
| 2508 | 3135 | "windows_aarch64_gnullvm 0.53.1", |
| 2509 | 3136 | "windows_aarch64_msvc 0.53.1", |
| 2510 | 3137 | "windows_i686_gnu 0.53.1", |
@@ -2515,6 +3142,24 @@ dependencies = [ | ||
| 2515 | 3142 | "windows_x86_64_msvc 0.53.1", |
| 2516 | 3143 | ] |
| 2517 | 3144 | |
| 3145 | +[[package]] | |
| 3146 | +name = "windows-threading" | |
| 3147 | +version = "0.1.0" | |
| 3148 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3149 | +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" | |
| 3150 | +dependencies = [ | |
| 3151 | + "windows-link 0.1.3", | |
| 3152 | +] | |
| 3153 | + | |
| 3154 | +[[package]] | |
| 3155 | +name = "windows-version" | |
| 3156 | +version = "0.1.7" | |
| 3157 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3158 | +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" | |
| 3159 | +dependencies = [ | |
| 3160 | + "windows-link 0.2.1", | |
| 3161 | +] | |
| 3162 | + | |
| 2518 | 3163 | [[package]] |
| 2519 | 3164 | name = "windows_aarch64_gnullvm" |
| 2520 | 3165 | version = "0.52.6" |
@@ -2611,6 +3256,24 @@ version = "0.53.1" | ||
| 2611 | 3256 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2612 | 3257 | checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" |
| 2613 | 3258 | |
| 3259 | +[[package]] | |
| 3260 | +name = "winnow" | |
| 3261 | +version = "0.7.15" | |
| 3262 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3263 | +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" | |
| 3264 | +dependencies = [ | |
| 3265 | + "memchr", | |
| 3266 | +] | |
| 3267 | + | |
| 3268 | +[[package]] | |
| 3269 | +name = "winnow" | |
| 3270 | +version = "1.0.2" | |
| 3271 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3272 | +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" | |
| 3273 | +dependencies = [ | |
| 3274 | + "memchr", | |
| 3275 | +] | |
| 3276 | + | |
| 2614 | 3277 | [[package]] |
| 2615 | 3278 | name = "wit-bindgen" |
| 2616 | 3279 | version = "0.51.0" |
@@ -2734,6 +3397,67 @@ dependencies = [ | ||
| 2734 | 3397 | "synstructure", |
| 2735 | 3398 | ] |
| 2736 | 3399 | |
| 3400 | +[[package]] | |
| 3401 | +name = "zbus" | |
| 3402 | +version = "5.14.0" | |
| 3403 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3404 | +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" | |
| 3405 | +dependencies = [ | |
| 3406 | + "async-broadcast", | |
| 3407 | + "async-executor", | |
| 3408 | + "async-io", | |
| 3409 | + "async-lock", | |
| 3410 | + "async-process", | |
| 3411 | + "async-recursion", | |
| 3412 | + "async-task", | |
| 3413 | + "async-trait", | |
| 3414 | + "blocking", | |
| 3415 | + "enumflags2", | |
| 3416 | + "event-listener", | |
| 3417 | + "futures-core", | |
| 3418 | + "futures-lite", | |
| 3419 | + "hex", | |
| 3420 | + "libc", | |
| 3421 | + "ordered-stream", | |
| 3422 | + "rustix", | |
| 3423 | + "serde", | |
| 3424 | + "serde_repr", | |
| 3425 | + "tracing", | |
| 3426 | + "uds_windows", | |
| 3427 | + "uuid", | |
| 3428 | + "windows-sys 0.61.2", | |
| 3429 | + "winnow 0.7.15", | |
| 3430 | + "zbus_macros", | |
| 3431 | + "zbus_names", | |
| 3432 | + "zvariant", | |
| 3433 | +] | |
| 3434 | + | |
| 3435 | +[[package]] | |
| 3436 | +name = "zbus_macros" | |
| 3437 | +version = "5.14.0" | |
| 3438 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3439 | +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" | |
| 3440 | +dependencies = [ | |
| 3441 | + "proc-macro-crate", | |
| 3442 | + "proc-macro2", | |
| 3443 | + "quote", | |
| 3444 | + "syn 2.0.117", | |
| 3445 | + "zbus_names", | |
| 3446 | + "zvariant", | |
| 3447 | + "zvariant_utils", | |
| 3448 | +] | |
| 3449 | + | |
| 3450 | +[[package]] | |
| 3451 | +name = "zbus_names" | |
| 3452 | +version = "4.3.1" | |
| 3453 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3454 | +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" | |
| 3455 | +dependencies = [ | |
| 3456 | + "serde", | |
| 3457 | + "winnow 0.7.15", | |
| 3458 | + "zvariant", | |
| 3459 | +] | |
| 3460 | + | |
| 2737 | 3461 | [[package]] |
| 2738 | 3462 | name = "zerocopy" |
| 2739 | 3463 | version = "0.8.48" |
@@ -2819,3 +3543,43 @@ name = "zmij" | ||
| 2819 | 3543 | version = "1.0.21" |
| 2820 | 3544 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2821 | 3545 | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" |
| 3546 | + | |
| 3547 | +[[package]] | |
| 3548 | +name = "zvariant" | |
| 3549 | +version = "5.10.0" | |
| 3550 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3551 | +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" | |
| 3552 | +dependencies = [ | |
| 3553 | + "endi", | |
| 3554 | + "enumflags2", | |
| 3555 | + "serde", | |
| 3556 | + "winnow 0.7.15", | |
| 3557 | + "zvariant_derive", | |
| 3558 | + "zvariant_utils", | |
| 3559 | +] | |
| 3560 | + | |
| 3561 | +[[package]] | |
| 3562 | +name = "zvariant_derive" | |
| 3563 | +version = "5.10.0" | |
| 3564 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3565 | +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" | |
| 3566 | +dependencies = [ | |
| 3567 | + "proc-macro-crate", | |
| 3568 | + "proc-macro2", | |
| 3569 | + "quote", | |
| 3570 | + "syn 2.0.117", | |
| 3571 | + "zvariant_utils", | |
| 3572 | +] | |
| 3573 | + | |
| 3574 | +[[package]] | |
| 3575 | +name = "zvariant_utils" | |
| 3576 | +version = "3.3.0" | |
| 3577 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3578 | +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" | |
| 3579 | +dependencies = [ | |
| 3580 | + "proc-macro2", | |
| 3581 | + "quote", | |
| 3582 | + "serde", | |
| 3583 | + "syn 2.0.117", | |
| 3584 | + "winnow 0.7.15", | |
| 3585 | +] | |
Cargo.tomlmodified@@ -5,6 +5,9 @@ edition = "2024" | ||
| 5 | 5 | |
| 6 | 6 | [dependencies] |
| 7 | 7 | crossterm = "0.29.0" |
| 8 | +directories = "6.0.0" | |
| 9 | +fs2 = "0.4.3" | |
| 10 | +notify-rust = "4" | |
| 8 | 11 | ratatui = "0.30.0" |
| 9 | 12 | reqwest = { version = "0.12.24", default-features = false, features = ["blocking", "rustls-tls"] } |
| 10 | 13 | serde = { version = "1.0.228", features = ["derive"] } |
README.mdmodified@@ -26,6 +26,11 @@ cargo run -- --date 2026-04-23 | ||
| 26 | 26 | |
| 27 | 27 | ```sh |
| 28 | 28 | rcal [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC] |
| 29 | +rcal reminders run [--events-file PATH] [--state-file PATH] [--once] | |
| 30 | +rcal reminders install [--events-file PATH] | |
| 31 | +rcal reminders uninstall | |
| 32 | +rcal reminders status | |
| 33 | +rcal reminders test | |
| 29 | 34 | ``` |
| 30 | 35 | |
| 31 | 36 | Options: |
@@ -69,7 +74,9 @@ access. | ||
| 69 | 74 | Created events are stored locally as JSON and are shown immediately in month, |
| 70 | 75 | week, and day views. The create/edit modal supports timed events, single-day |
| 71 | 76 | all-day events, recurrence, location, notes, and multiple reminder offsets. |
| 72 | -Reminder notifications are not delivered yet. | |
| 77 | +Reminder notifications are delivered by a user-level background service. Use | |
| 78 | +`rcal reminders install` to install it, `rcal reminders status` to inspect it, | |
| 79 | +and `rcal reminders test` to send a test notification. | |
| 73 | 80 | |
| 74 | 81 | ## Layout |
| 75 | 82 | |
@@ -81,7 +88,6 @@ back to a focused day summary. | ||
| 81 | 88 | |
| 82 | 89 | - Real account integrations for Outlook, Google Calendar, Exchange, and similar |
| 83 | 90 | providers are not implemented yet. |
| 84 | -- Reminder offsets are stored but do not trigger notifications yet. | |
| 85 | 91 | - Packaging is currently source-based through Cargo. |
| 86 | 92 | |
| 87 | 93 | ## Development |
src/cli.rsmodified@@ -22,6 +22,14 @@ use crate::{ | ||
| 22 | 22 | KeyboardInput, MouseInput, RecurrenceChoiceInputResult, |
| 23 | 23 | }, |
| 24 | 24 | calendar::CalendarDate, |
| 25 | + reminders::{ | |
| 26 | + ReminderDaemonConfig, ReminderError, SystemNotifier, default_state_file, run_daemon, | |
| 27 | + run_once, test_notification, | |
| 28 | + }, | |
| 29 | + services::{ | |
| 30 | + ServiceConfig, ServiceError, SystemCommandRunner, install_service, service_status, | |
| 31 | + uninstall_service, | |
| 32 | + }, | |
| 25 | 33 | tui::{ |
| 26 | 34 | AppView, DEFAULT_RENDER_HEIGHT, DEFAULT_RENDER_WIDTH, hit_test_app_date, |
| 27 | 35 | render_app_to_string_with_agenda_source, |
@@ -34,6 +42,11 @@ const HELP: &str = concat!( | ||
| 34 | 42 | "\n\n", |
| 35 | 43 | "Usage:\n", |
| 36 | 44 | " rcal [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC]\n\n", |
| 45 | + " rcal reminders run [--events-file PATH] [--state-file PATH] [--once]\n", | |
| 46 | + " rcal reminders install [--events-file PATH]\n", | |
| 47 | + " rcal reminders uninstall\n", | |
| 48 | + " rcal reminders status\n", | |
| 49 | + " rcal reminders test\n\n", | |
| 37 | 50 | "Options:\n", |
| 38 | 51 | " --date YYYY-MM-DD Open with the given date selected.\n", |
| 39 | 52 | " --events-file PATH Read and write local user events at PATH.\n", |
@@ -54,7 +67,7 @@ const HELP: &str = concat!( | ||
| 54 | 67 | "Mouse:\n", |
| 55 | 68 | " Left click selects a visible date; double-click a visible date to open day view.\n\n", |
| 56 | 69 | "Notes:\n", |
| 57 | - " Real calendar-account integration and reminder notifications are not in this milestone.\n", | |
| 70 | + " Reminder services are user-level background jobs. Provider account integration is not in this milestone.\n", | |
| 58 | 71 | ); |
| 59 | 72 | |
| 60 | 73 | const VERSION: &str = concat!(env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION"), "\n"); |
@@ -89,10 +102,27 @@ pub enum HolidaySourceConfig { | ||
| 89 | 102 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 90 | 103 | pub enum CliAction { |
| 91 | 104 | Run(AppConfig), |
| 105 | + Reminders(ReminderCliAction), | |
| 92 | 106 | Help, |
| 93 | 107 | Version, |
| 94 | 108 | } |
| 95 | 109 | |
| 110 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 111 | +pub enum ReminderCliAction { | |
| 112 | + Run(ReminderRunConfig), | |
| 113 | + Install { events_file: PathBuf }, | |
| 114 | + Uninstall, | |
| 115 | + Status, | |
| 116 | + Test, | |
| 117 | +} | |
| 118 | + | |
| 119 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 120 | +pub struct ReminderRunConfig { | |
| 121 | + pub events_file: PathBuf, | |
| 122 | + pub state_file: PathBuf, | |
| 123 | + pub once: bool, | |
| 124 | +} | |
| 125 | + | |
| 96 | 126 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 97 | 127 | pub enum CliError { |
| 98 | 128 | DuplicateDate, |
@@ -106,6 +136,10 @@ pub enum CliError { | ||
| 106 | 136 | MissingHolidayCountryValue, |
| 107 | 137 | InvalidHolidayCountry(String), |
| 108 | 138 | HolidayCountryRequiresNager, |
| 139 | + MissingReminderCommand, | |
| 140 | + UnknownReminderCommand(String), | |
| 141 | + DuplicateStateFile, | |
| 142 | + MissingStateFileValue, | |
| 109 | 143 | UnknownArgument(String), |
| 110 | 144 | InvalidDate { input: String, reason: String }, |
| 111 | 145 | } |
@@ -144,6 +178,15 @@ impl fmt::Display for CliError { | ||
| 144 | 178 | "--holiday-country may only be used with --holiday-source nager" |
| 145 | 179 | ) |
| 146 | 180 | } |
| 181 | + Self::MissingReminderCommand => write!( | |
| 182 | + f, | |
| 183 | + "reminders requires one of: run, install, uninstall, status, test" | |
| 184 | + ), | |
| 185 | + Self::UnknownReminderCommand(command) => { | |
| 186 | + write!(f, "unknown reminders command: {command}") | |
| 187 | + } | |
| 188 | + Self::DuplicateStateFile => write!(f, "--state-file may only be provided once"), | |
| 189 | + Self::MissingStateFileValue => write!(f, "--state-file requires a path"), | |
| 147 | 190 | Self::UnknownArgument(arg) => write!(f, "unknown argument: {arg}"), |
| 148 | 191 | Self::InvalidDate { input, reason } => { |
| 149 | 192 | write!(f, "invalid --date value '{input}': {reason}") |
@@ -189,6 +232,7 @@ where | ||
| 189 | 232 | Err(err) => io_error_exit(&mut stderr, err), |
| 190 | 233 | } |
| 191 | 234 | } |
| 235 | + Ok(CliAction::Reminders(action)) => run_reminder_action(action, &mut stdout, &mut stderr), | |
| 192 | 236 | Ok(CliAction::Help) => match write!(stdout, "{HELP}") { |
| 193 | 237 | Ok(()) => std::process::ExitCode::SUCCESS, |
| 194 | 238 | Err(err) => io_error_exit(&mut stderr, err), |
@@ -222,6 +266,7 @@ where | ||
| 222 | 266 | Err(err) => io_error_exit(&mut stderr, err), |
| 223 | 267 | } |
| 224 | 268 | } |
| 269 | + Ok(CliAction::Reminders(action)) => run_reminder_action(action, &mut stdout, &mut stderr), | |
| 225 | 270 | Ok(CliAction::Help) => match write!(stdout, "{HELP}") { |
| 226 | 271 | Ok(()) => std::process::ExitCode::SUCCESS, |
| 227 | 272 | Err(err) => io_error_exit(&mut stderr, err), |
@@ -238,6 +283,20 @@ where | ||
| 238 | 283 | } |
| 239 | 284 | |
| 240 | 285 | pub fn parse_args<I>(args: I, today: Date) -> Result<CliAction, CliError> |
| 286 | +where | |
| 287 | + I: IntoIterator<Item = OsString>, | |
| 288 | +{ | |
| 289 | + let args = args.into_iter().collect::<Vec<_>>(); | |
| 290 | + if let Some(first) = args.first() | |
| 291 | + && first == "reminders" | |
| 292 | + { | |
| 293 | + return parse_reminder_args(args.into_iter().skip(1)); | |
| 294 | + } | |
| 295 | + | |
| 296 | + parse_calendar_args(args, today) | |
| 297 | +} | |
| 298 | + | |
| 299 | +fn parse_calendar_args<I>(args: I, today: Date) -> Result<CliAction, CliError> | |
| 241 | 300 | where |
| 242 | 301 | I: IntoIterator<Item = OsString>, |
| 243 | 302 | { |
@@ -362,6 +421,140 @@ where | ||
| 362 | 421 | Ok(CliAction::Run(config)) |
| 363 | 422 | } |
| 364 | 423 | |
| 424 | +fn parse_reminder_args<I>(args: I) -> Result<CliAction, CliError> | |
| 425 | +where | |
| 426 | + I: IntoIterator<Item = OsString>, | |
| 427 | +{ | |
| 428 | + let mut args = args.into_iter(); | |
| 429 | + let command = args.next().ok_or(CliError::MissingReminderCommand)?; | |
| 430 | + let Some(command) = command.to_str() else { | |
| 431 | + return Err(CliError::UnknownReminderCommand(display_arg(&command))); | |
| 432 | + }; | |
| 433 | + | |
| 434 | + match command { | |
| 435 | + "run" => parse_reminder_run_args(args), | |
| 436 | + "install" => parse_reminder_install_args(args), | |
| 437 | + "uninstall" => no_extra_reminder_args(args, ReminderCliAction::Uninstall), | |
| 438 | + "status" => no_extra_reminder_args(args, ReminderCliAction::Status), | |
| 439 | + "test" => no_extra_reminder_args(args, ReminderCliAction::Test), | |
| 440 | + "--help" | "-h" => Ok(CliAction::Help), | |
| 441 | + _ => Err(CliError::UnknownReminderCommand(command.to_string())), | |
| 442 | + } | |
| 443 | +} | |
| 444 | + | |
| 445 | +fn parse_reminder_run_args<I>(args: I) -> Result<CliAction, CliError> | |
| 446 | +where | |
| 447 | + I: IntoIterator<Item = OsString>, | |
| 448 | +{ | |
| 449 | + let mut events_file = None; | |
| 450 | + let mut state_file = None; | |
| 451 | + let mut once = false; | |
| 452 | + let mut args = args.into_iter(); | |
| 453 | + | |
| 454 | + while let Some(arg) = args.next() { | |
| 455 | + if arg == "--events-file" { | |
| 456 | + if events_file.is_some() { | |
| 457 | + return Err(CliError::DuplicateEventsFile); | |
| 458 | + } | |
| 459 | + events_file = Some(PathBuf::from( | |
| 460 | + args.next().ok_or(CliError::MissingEventsFileValue)?, | |
| 461 | + )); | |
| 462 | + continue; | |
| 463 | + } | |
| 464 | + if let Some(value) = arg | |
| 465 | + .to_str() | |
| 466 | + .and_then(|value| value.strip_prefix("--events-file=")) | |
| 467 | + { | |
| 468 | + if events_file.is_some() { | |
| 469 | + return Err(CliError::DuplicateEventsFile); | |
| 470 | + } | |
| 471 | + events_file = Some(PathBuf::from(value)); | |
| 472 | + continue; | |
| 473 | + } | |
| 474 | + if arg == "--state-file" { | |
| 475 | + if state_file.is_some() { | |
| 476 | + return Err(CliError::DuplicateStateFile); | |
| 477 | + } | |
| 478 | + state_file = Some(PathBuf::from( | |
| 479 | + args.next().ok_or(CliError::MissingStateFileValue)?, | |
| 480 | + )); | |
| 481 | + continue; | |
| 482 | + } | |
| 483 | + if let Some(value) = arg | |
| 484 | + .to_str() | |
| 485 | + .and_then(|value| value.strip_prefix("--state-file=")) | |
| 486 | + { | |
| 487 | + if state_file.is_some() { | |
| 488 | + return Err(CliError::DuplicateStateFile); | |
| 489 | + } | |
| 490 | + state_file = Some(PathBuf::from(value)); | |
| 491 | + continue; | |
| 492 | + } | |
| 493 | + if arg == "--once" { | |
| 494 | + once = true; | |
| 495 | + continue; | |
| 496 | + } | |
| 497 | + | |
| 498 | + return Err(CliError::UnknownArgument(display_arg(&arg))); | |
| 499 | + } | |
| 500 | + | |
| 501 | + Ok(CliAction::Reminders(ReminderCliAction::Run( | |
| 502 | + ReminderRunConfig { | |
| 503 | + events_file: events_file.unwrap_or_else(default_events_file), | |
| 504 | + state_file: state_file.unwrap_or_else(default_state_file), | |
| 505 | + once, | |
| 506 | + }, | |
| 507 | + ))) | |
| 508 | +} | |
| 509 | + | |
| 510 | +fn parse_reminder_install_args<I>(args: I) -> Result<CliAction, CliError> | |
| 511 | +where | |
| 512 | + I: IntoIterator<Item = OsString>, | |
| 513 | +{ | |
| 514 | + let mut events_file = None; | |
| 515 | + let mut args = args.into_iter(); | |
| 516 | + | |
| 517 | + while let Some(arg) = args.next() { | |
| 518 | + if arg == "--events-file" { | |
| 519 | + if events_file.is_some() { | |
| 520 | + return Err(CliError::DuplicateEventsFile); | |
| 521 | + } | |
| 522 | + events_file = Some(PathBuf::from( | |
| 523 | + args.next().ok_or(CliError::MissingEventsFileValue)?, | |
| 524 | + )); | |
| 525 | + continue; | |
| 526 | + } | |
| 527 | + if let Some(value) = arg | |
| 528 | + .to_str() | |
| 529 | + .and_then(|value| value.strip_prefix("--events-file=")) | |
| 530 | + { | |
| 531 | + if events_file.is_some() { | |
| 532 | + return Err(CliError::DuplicateEventsFile); | |
| 533 | + } | |
| 534 | + events_file = Some(PathBuf::from(value)); | |
| 535 | + continue; | |
| 536 | + } | |
| 537 | + | |
| 538 | + return Err(CliError::UnknownArgument(display_arg(&arg))); | |
| 539 | + } | |
| 540 | + | |
| 541 | + Ok(CliAction::Reminders(ReminderCliAction::Install { | |
| 542 | + events_file: events_file.unwrap_or_else(default_events_file), | |
| 543 | + })) | |
| 544 | +} | |
| 545 | + | |
| 546 | +fn no_extra_reminder_args<I>(args: I, action: ReminderCliAction) -> Result<CliAction, CliError> | |
| 547 | +where | |
| 548 | + I: IntoIterator<Item = OsString>, | |
| 549 | +{ | |
| 550 | + let mut args = args.into_iter(); | |
| 551 | + if let Some(arg) = args.next() { | |
| 552 | + return Err(CliError::UnknownArgument(display_arg(&arg))); | |
| 553 | + } | |
| 554 | + | |
| 555 | + Ok(CliAction::Reminders(action)) | |
| 556 | +} | |
| 557 | + | |
| 365 | 558 | fn default_start_date() -> Date { |
| 366 | 559 | OffsetDateTime::now_local() |
| 367 | 560 | .unwrap_or_else(|_| OffsetDateTime::now_utc()) |
@@ -385,6 +578,85 @@ fn agenda_source(config: &AppConfig) -> Result<ConfiguredAgendaSource, LocalEven | ||
| 385 | 578 | ConfiguredAgendaSource::from_events_file(config.events_file.clone(), holidays) |
| 386 | 579 | } |
| 387 | 580 | |
| 581 | +fn run_reminder_action( | |
| 582 | + action: ReminderCliAction, | |
| 583 | + stdout: &mut impl Write, | |
| 584 | + stderr: &mut impl Write, | |
| 585 | +) -> std::process::ExitCode { | |
| 586 | + match action { | |
| 587 | + ReminderCliAction::Run(config) => { | |
| 588 | + let daemon_config = ReminderDaemonConfig::new(config.events_file, config.state_file); | |
| 589 | + let mut notifier = SystemNotifier; | |
| 590 | + if config.once { | |
| 591 | + match run_once( | |
| 592 | + &daemon_config, | |
| 593 | + crate::reminders::current_local_datetime(), | |
| 594 | + &mut notifier, | |
| 595 | + ) { | |
| 596 | + Ok(summary) => { | |
| 597 | + let _ = writeln!( | |
| 598 | + stdout, | |
| 599 | + "delivered={} skipped={} failed={}", | |
| 600 | + summary.delivered, summary.skipped, summary.failed | |
| 601 | + ); | |
| 602 | + std::process::ExitCode::SUCCESS | |
| 603 | + } | |
| 604 | + Err(err) => reminder_error_exit(stderr, err), | |
| 605 | + } | |
| 606 | + } else { | |
| 607 | + match run_daemon(daemon_config, &mut notifier) { | |
| 608 | + Ok(()) => std::process::ExitCode::SUCCESS, | |
| 609 | + Err(err) => reminder_error_exit(stderr, err), | |
| 610 | + } | |
| 611 | + } | |
| 612 | + } | |
| 613 | + ReminderCliAction::Install { events_file } => { | |
| 614 | + let config = match ServiceConfig::new(events_file) { | |
| 615 | + Ok(config) => config, | |
| 616 | + Err(err) => return service_error_exit(stderr, err), | |
| 617 | + }; | |
| 618 | + let mut runner = SystemCommandRunner; | |
| 619 | + match install_service(&config, &mut runner) { | |
| 620 | + Ok(()) => { | |
| 621 | + let _ = writeln!(stdout, "installed reminder service"); | |
| 622 | + std::process::ExitCode::SUCCESS | |
| 623 | + } | |
| 624 | + Err(err) => service_error_exit(stderr, err), | |
| 625 | + } | |
| 626 | + } | |
| 627 | + ReminderCliAction::Uninstall => { | |
| 628 | + let mut runner = SystemCommandRunner; | |
| 629 | + match uninstall_service(&mut runner) { | |
| 630 | + Ok(()) => { | |
| 631 | + let _ = writeln!(stdout, "uninstalled reminder service"); | |
| 632 | + std::process::ExitCode::SUCCESS | |
| 633 | + } | |
| 634 | + Err(err) => service_error_exit(stderr, err), | |
| 635 | + } | |
| 636 | + } | |
| 637 | + ReminderCliAction::Status => { | |
| 638 | + let mut runner = SystemCommandRunner; | |
| 639 | + match service_status(&mut runner) { | |
| 640 | + Ok(status) => { | |
| 641 | + let _ = writeln!(stdout, "{status}"); | |
| 642 | + std::process::ExitCode::SUCCESS | |
| 643 | + } | |
| 644 | + Err(err) => service_error_exit(stderr, err), | |
| 645 | + } | |
| 646 | + } | |
| 647 | + ReminderCliAction::Test => { | |
| 648 | + let mut notifier = SystemNotifier; | |
| 649 | + match test_notification(&mut notifier) { | |
| 650 | + Ok(()) => { | |
| 651 | + let _ = writeln!(stdout, "sent test reminder notification"); | |
| 652 | + std::process::ExitCode::SUCCESS | |
| 653 | + } | |
| 654 | + Err(err) => reminder_error_exit(stderr, err), | |
| 655 | + } | |
| 656 | + } | |
| 657 | + } | |
| 658 | +} | |
| 659 | + | |
| 388 | 660 | fn run_interactive_terminal<W>( |
| 389 | 661 | stdout: W, |
| 390 | 662 | app: AppState, |
@@ -687,6 +959,16 @@ fn local_event_error_exit( | ||
| 687 | 959 | std::process::ExitCode::from(2) |
| 688 | 960 | } |
| 689 | 961 | |
| 962 | +fn reminder_error_exit(stderr: &mut impl Write, err: ReminderError) -> std::process::ExitCode { | |
| 963 | + let _ = writeln!(stderr, "error: {err}"); | |
| 964 | + std::process::ExitCode::FAILURE | |
| 965 | +} | |
| 966 | + | |
| 967 | +fn service_error_exit(stderr: &mut impl Write, err: ServiceError) -> std::process::ExitCode { | |
| 968 | + let _ = writeln!(stderr, "error: {err}"); | |
| 969 | + std::process::ExitCode::FAILURE | |
| 970 | +} | |
| 971 | + | |
| 690 | 972 | #[cfg(test)] |
| 691 | 973 | mod tests { |
| 692 | 974 | use super::*; |
@@ -846,6 +1128,84 @@ mod tests { | ||
| 846 | 1128 | ); |
| 847 | 1129 | } |
| 848 | 1130 | |
| 1131 | + #[test] | |
| 1132 | + fn reminder_run_args_set_events_and_state_paths() { | |
| 1133 | + let today = date(2026, Month::April, 23); | |
| 1134 | + | |
| 1135 | + let action = parse_args( | |
| 1136 | + [ | |
| 1137 | + arg("reminders"), | |
| 1138 | + arg("run"), | |
| 1139 | + arg("--events-file"), | |
| 1140 | + arg("/tmp/events.json"), | |
| 1141 | + arg("--state-file=/tmp/state.json"), | |
| 1142 | + arg("--once"), | |
| 1143 | + ], | |
| 1144 | + today.into(), | |
| 1145 | + ) | |
| 1146 | + .expect("parse succeeds"); | |
| 1147 | + | |
| 1148 | + assert_eq!( | |
| 1149 | + action, | |
| 1150 | + CliAction::Reminders(ReminderCliAction::Run(ReminderRunConfig { | |
| 1151 | + events_file: PathBuf::from("/tmp/events.json"), | |
| 1152 | + state_file: PathBuf::from("/tmp/state.json"), | |
| 1153 | + once: true, | |
| 1154 | + })) | |
| 1155 | + ); | |
| 1156 | + } | |
| 1157 | + | |
| 1158 | + #[test] | |
| 1159 | + fn reminder_install_args_set_events_path() { | |
| 1160 | + let today = date(2026, Month::April, 23); | |
| 1161 | + | |
| 1162 | + let action = parse_args( | |
| 1163 | + [ | |
| 1164 | + arg("reminders"), | |
| 1165 | + arg("install"), | |
| 1166 | + arg("--events-file=/tmp/events.json"), | |
| 1167 | + ], | |
| 1168 | + today.into(), | |
| 1169 | + ) | |
| 1170 | + .expect("parse succeeds"); | |
| 1171 | + | |
| 1172 | + assert_eq!( | |
| 1173 | + action, | |
| 1174 | + CliAction::Reminders(ReminderCliAction::Install { | |
| 1175 | + events_file: PathBuf::from("/tmp/events.json"), | |
| 1176 | + }) | |
| 1177 | + ); | |
| 1178 | + } | |
| 1179 | + | |
| 1180 | + #[test] | |
| 1181 | + fn reminder_args_are_rejected_when_invalid() { | |
| 1182 | + let today = date(2026, Month::April, 23); | |
| 1183 | + | |
| 1184 | + assert_eq!( | |
| 1185 | + parse_args([arg("reminders")], today.into()).expect_err("missing command fails"), | |
| 1186 | + CliError::MissingReminderCommand | |
| 1187 | + ); | |
| 1188 | + assert_eq!( | |
| 1189 | + parse_args([arg("reminders"), arg("bogus")], today.into()) | |
| 1190 | + .expect_err("unknown command fails"), | |
| 1191 | + CliError::UnknownReminderCommand("bogus".to_string()) | |
| 1192 | + ); | |
| 1193 | + assert_eq!( | |
| 1194 | + parse_args( | |
| 1195 | + [ | |
| 1196 | + arg("reminders"), | |
| 1197 | + arg("run"), | |
| 1198 | + arg("--state-file"), | |
| 1199 | + arg("/tmp/one.json"), | |
| 1200 | + arg("--state-file=/tmp/two.json"), | |
| 1201 | + ], | |
| 1202 | + today.into(), | |
| 1203 | + ) | |
| 1204 | + .expect_err("duplicate state path fails"), | |
| 1205 | + CliError::DuplicateStateFile | |
| 1206 | + ); | |
| 1207 | + } | |
| 1208 | + | |
| 849 | 1209 | #[test] |
| 850 | 1210 | fn invalid_holiday_options_are_rejected() { |
| 851 | 1211 | let today = date(2026, Month::April, 23); |
src/lib.rsmodified@@ -3,4 +3,6 @@ pub mod app; | ||
| 3 | 3 | pub mod calendar; |
| 4 | 4 | pub mod cli; |
| 5 | 5 | pub mod layout; |
| 6 | +pub mod reminders; | |
| 7 | +pub mod services; | |
| 6 | 8 | pub mod tui; |
src/reminders.rsadded@@ -0,0 +1,762 @@ | ||
| 1 | +use std::{ | |
| 2 | + collections::HashMap, | |
| 3 | + error::Error, | |
| 4 | + fmt, fs, io, | |
| 5 | + path::{Path, PathBuf}, | |
| 6 | + thread, | |
| 7 | + time::Duration as StdDuration, | |
| 8 | +}; | |
| 9 | + | |
| 10 | +use directories::ProjectDirs; | |
| 11 | +use fs2::FileExt; | |
| 12 | +use notify_rust::Notification; | |
| 13 | +use serde::{Deserialize, Serialize}; | |
| 14 | +use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time}; | |
| 15 | + | |
| 16 | +use crate::{ | |
| 17 | + agenda::{ | |
| 18 | + AgendaSource, ConfiguredAgendaSource, DateRange, Event, EventDateTime, EventTiming, | |
| 19 | + HolidayProvider, | |
| 20 | + }, | |
| 21 | + calendar::CalendarDate, | |
| 22 | +}; | |
| 23 | + | |
| 24 | +const STATE_VERSION: u8 = 1; | |
| 25 | +const GRACE_MINUTES: i64 = 10; | |
| 26 | +const PRUNE_AFTER_DAYS: i32 = 30; | |
| 27 | +const LOOKAHEAD_DAYS: i32 = 47; | |
| 28 | +const POLL_INTERVAL: StdDuration = StdDuration::from_secs(30); | |
| 29 | + | |
| 30 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 31 | +pub struct ReminderDaemonConfig { | |
| 32 | + pub events_file: PathBuf, | |
| 33 | + pub state_file: PathBuf, | |
| 34 | + pub poll_interval: StdDuration, | |
| 35 | + pub grace: Duration, | |
| 36 | +} | |
| 37 | + | |
| 38 | +impl ReminderDaemonConfig { | |
| 39 | + pub fn new(events_file: PathBuf, state_file: PathBuf) -> Self { | |
| 40 | + Self { | |
| 41 | + events_file, | |
| 42 | + state_file, | |
| 43 | + poll_interval: POLL_INTERVAL, | |
| 44 | + grace: Duration::minutes(GRACE_MINUTES), | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + pub fn lock_file(&self) -> PathBuf { | |
| 49 | + self.state_file.with_extension("lock") | |
| 50 | + } | |
| 51 | +} | |
| 52 | + | |
| 53 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 54 | +pub struct ReminderInstance { | |
| 55 | + pub key: String, | |
| 56 | + pub event_id: String, | |
| 57 | + pub title: String, | |
| 58 | + pub location: Option<String>, | |
| 59 | + pub event_start: EventDateTime, | |
| 60 | + pub fire_at: PrimitiveDateTime, | |
| 61 | + pub minutes_before: u16, | |
| 62 | + pub all_day: bool, | |
| 63 | +} | |
| 64 | + | |
| 65 | +impl ReminderInstance { | |
| 66 | + pub fn notification_title(&self) -> String { | |
| 67 | + format!("Reminder: {}", self.title) | |
| 68 | + } | |
| 69 | + | |
| 70 | + pub fn notification_body(&self) -> String { | |
| 71 | + let when = if self.all_day { | |
| 72 | + format!("All day on {}", self.event_start.date) | |
| 73 | + } else { | |
| 74 | + format!( | |
| 75 | + "Starts at {:02}:{:02} on {}", | |
| 76 | + self.event_start.time.hour(), | |
| 77 | + self.event_start.time.minute(), | |
| 78 | + self.event_start.date | |
| 79 | + ) | |
| 80 | + }; | |
| 81 | + | |
| 82 | + if let Some(location) = &self.location { | |
| 83 | + format!("{when}\nLocation: {location}") | |
| 84 | + } else { | |
| 85 | + when | |
| 86 | + } | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 91 | +pub struct ReminderRunSummary { | |
| 92 | + pub delivered: usize, | |
| 93 | + pub skipped: usize, | |
| 94 | + pub failed: usize, | |
| 95 | +} | |
| 96 | + | |
| 97 | +pub trait Notifier { | |
| 98 | + fn notify(&mut self, reminder: &ReminderInstance) -> Result<(), ReminderError>; | |
| 99 | +} | |
| 100 | + | |
| 101 | +#[derive(Debug, Default)] | |
| 102 | +pub struct SystemNotifier; | |
| 103 | + | |
| 104 | +impl Notifier for SystemNotifier { | |
| 105 | + fn notify(&mut self, reminder: &ReminderInstance) -> Result<(), ReminderError> { | |
| 106 | + Notification::new() | |
| 107 | + .summary(&reminder.notification_title()) | |
| 108 | + .body(&reminder.notification_body()) | |
| 109 | + .show() | |
| 110 | + .map(|_| ()) | |
| 111 | + .map_err(|err| ReminderError::Notification(err.to_string())) | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +pub fn default_state_file() -> PathBuf { | |
| 116 | + if let Some(project_dirs) = ProjectDirs::from("com", "tenseleyFlow", "rcal") { | |
| 117 | + if let Some(state_dir) = project_dirs.state_dir() { | |
| 118 | + return state_dir.join("reminders-state.json"); | |
| 119 | + } | |
| 120 | + | |
| 121 | + return project_dirs.data_local_dir().join("reminders-state.json"); | |
| 122 | + } | |
| 123 | + | |
| 124 | + std::env::temp_dir() | |
| 125 | + .join("rcal") | |
| 126 | + .join("reminders-state.json") | |
| 127 | +} | |
| 128 | + | |
| 129 | +pub fn default_log_file() -> PathBuf { | |
| 130 | + if let Some(project_dirs) = ProjectDirs::from("com", "tenseleyFlow", "rcal") { | |
| 131 | + return project_dirs.cache_dir().join("reminders.log"); | |
| 132 | + } | |
| 133 | + | |
| 134 | + std::env::temp_dir().join("rcal").join("reminders.log") | |
| 135 | +} | |
| 136 | + | |
| 137 | +pub fn current_local_datetime() -> PrimitiveDateTime { | |
| 138 | + let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); | |
| 139 | + PrimitiveDateTime::new(now.date(), now.time()) | |
| 140 | +} | |
| 141 | + | |
| 142 | +pub fn run_daemon( | |
| 143 | + config: ReminderDaemonConfig, | |
| 144 | + notifier: &mut dyn Notifier, | |
| 145 | +) -> Result<(), ReminderError> { | |
| 146 | + let _lock = DaemonLock::acquire(&config.lock_file())?; | |
| 147 | + loop { | |
| 148 | + let now = current_local_datetime(); | |
| 149 | + let _ = run_once(&config, now, notifier)?; | |
| 150 | + thread::sleep(config.poll_interval); | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +pub fn run_once( | |
| 155 | + config: &ReminderDaemonConfig, | |
| 156 | + now: PrimitiveDateTime, | |
| 157 | + notifier: &mut dyn Notifier, | |
| 158 | +) -> Result<ReminderRunSummary, ReminderError> { | |
| 159 | + let source = | |
| 160 | + ConfiguredAgendaSource::from_events_file(&config.events_file, HolidayProvider::off()) | |
| 161 | + .map_err(|err| ReminderError::Events(err.to_string()))?; | |
| 162 | + let mut state = ReminderState::load(&config.state_file)?; | |
| 163 | + let instances = reminder_instances(&source, now); | |
| 164 | + let mut delivered = 0; | |
| 165 | + let mut skipped = 0; | |
| 166 | + let mut failed = 0; | |
| 167 | + let expires_before = now - config.grace; | |
| 168 | + | |
| 169 | + for instance in instances { | |
| 170 | + if state.contains(&instance.key) { | |
| 171 | + continue; | |
| 172 | + } | |
| 173 | + | |
| 174 | + if instance.fire_at > now { | |
| 175 | + continue; | |
| 176 | + } | |
| 177 | + | |
| 178 | + if instance.fire_at < expires_before { | |
| 179 | + state.record(instance, ReminderStatus::Skipped); | |
| 180 | + skipped += 1; | |
| 181 | + continue; | |
| 182 | + } | |
| 183 | + | |
| 184 | + match notifier.notify(&instance) { | |
| 185 | + Ok(()) => { | |
| 186 | + state.record(instance, ReminderStatus::Delivered); | |
| 187 | + delivered += 1; | |
| 188 | + } | |
| 189 | + Err(_) => { | |
| 190 | + failed += 1; | |
| 191 | + } | |
| 192 | + } | |
| 193 | + } | |
| 194 | + | |
| 195 | + state.prune(now.date()); | |
| 196 | + state.save(&config.state_file)?; | |
| 197 | + Ok(ReminderRunSummary { | |
| 198 | + delivered, | |
| 199 | + skipped, | |
| 200 | + failed, | |
| 201 | + }) | |
| 202 | +} | |
| 203 | + | |
| 204 | +pub fn test_notification(notifier: &mut dyn Notifier) -> Result<(), ReminderError> { | |
| 205 | + let date = CalendarDate::from(current_local_datetime().date()); | |
| 206 | + let reminder = ReminderInstance { | |
| 207 | + key: "test".to_string(), | |
| 208 | + event_id: "test".to_string(), | |
| 209 | + title: "rcal reminder test".to_string(), | |
| 210 | + location: None, | |
| 211 | + event_start: EventDateTime::new(date, current_local_datetime().time()), | |
| 212 | + fire_at: current_local_datetime(), | |
| 213 | + minutes_before: 0, | |
| 214 | + all_day: false, | |
| 215 | + }; | |
| 216 | + notifier.notify(&reminder) | |
| 217 | +} | |
| 218 | + | |
| 219 | +pub fn reminder_instances( | |
| 220 | + source: &dyn AgendaSource, | |
| 221 | + now: PrimitiveDateTime, | |
| 222 | +) -> Vec<ReminderInstance> { | |
| 223 | + let range = DateRange::new( | |
| 224 | + CalendarDate::from(now.date()).add_days(-PRUNE_AFTER_DAYS), | |
| 225 | + CalendarDate::from(now.date()).add_days(LOOKAHEAD_DAYS), | |
| 226 | + ) | |
| 227 | + .expect("reminder scan range is valid"); | |
| 228 | + let mut instances = source | |
| 229 | + .events_intersecting(range) | |
| 230 | + .into_iter() | |
| 231 | + .filter(Event::is_local) | |
| 232 | + .flat_map(reminders_for_event) | |
| 233 | + .collect::<Vec<_>>(); | |
| 234 | + instances.sort_by(|left, right| { | |
| 235 | + left.fire_at | |
| 236 | + .cmp(&right.fire_at) | |
| 237 | + .then(left.title.cmp(&right.title)) | |
| 238 | + .then(left.key.cmp(&right.key)) | |
| 239 | + }); | |
| 240 | + instances | |
| 241 | +} | |
| 242 | + | |
| 243 | +fn reminders_for_event(event: Event) -> Vec<ReminderInstance> { | |
| 244 | + let Some(event_start) = reminder_event_start(&event) else { | |
| 245 | + return Vec::new(); | |
| 246 | + }; | |
| 247 | + let all_day = matches!(event.timing, EventTiming::AllDay { .. }); | |
| 248 | + let start_at = event_datetime_to_primitive(event_start); | |
| 249 | + | |
| 250 | + event | |
| 251 | + .reminders | |
| 252 | + .iter() | |
| 253 | + .map(|reminder| { | |
| 254 | + let fire_at = start_at - Duration::minutes(i64::from(reminder.minutes_before)); | |
| 255 | + ReminderInstance { | |
| 256 | + key: reminder_key(&event, event_start, reminder.minutes_before), | |
| 257 | + event_id: event.id.clone(), | |
| 258 | + title: event.title.clone(), | |
| 259 | + location: event.location.clone(), | |
| 260 | + event_start, | |
| 261 | + fire_at, | |
| 262 | + minutes_before: reminder.minutes_before, | |
| 263 | + all_day, | |
| 264 | + } | |
| 265 | + }) | |
| 266 | + .collect() | |
| 267 | +} | |
| 268 | + | |
| 269 | +fn reminder_event_start(event: &Event) -> Option<EventDateTime> { | |
| 270 | + match event.timing { | |
| 271 | + EventTiming::AllDay { date } => Some(EventDateTime::new(date, Time::MIDNIGHT)), | |
| 272 | + EventTiming::Timed { start, .. } => Some(start), | |
| 273 | + } | |
| 274 | +} | |
| 275 | + | |
| 276 | +fn event_datetime_to_primitive(datetime: EventDateTime) -> PrimitiveDateTime { | |
| 277 | + PrimitiveDateTime::new(datetime.date.into(), datetime.time) | |
| 278 | +} | |
| 279 | + | |
| 280 | +fn reminder_key(event: &Event, event_start: EventDateTime, minutes_before: u16) -> String { | |
| 281 | + format!( | |
| 282 | + "{}|{}T{:02}:{:02}|{}m", | |
| 283 | + event.id, | |
| 284 | + event_start.date, | |
| 285 | + event_start.time.hour(), | |
| 286 | + event_start.time.minute(), | |
| 287 | + minutes_before | |
| 288 | + ) | |
| 289 | +} | |
| 290 | + | |
| 291 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 292 | +struct ReminderState { | |
| 293 | + records: HashMap<String, ReminderStateRecord>, | |
| 294 | +} | |
| 295 | + | |
| 296 | +impl ReminderState { | |
| 297 | + fn empty() -> Self { | |
| 298 | + Self { | |
| 299 | + records: HashMap::new(), | |
| 300 | + } | |
| 301 | + } | |
| 302 | + | |
| 303 | + fn load(path: &Path) -> Result<Self, ReminderError> { | |
| 304 | + let body = match fs::read_to_string(path) { | |
| 305 | + Ok(body) => body, | |
| 306 | + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::empty()), | |
| 307 | + Err(err) => { | |
| 308 | + return Err(ReminderError::StateRead { | |
| 309 | + path: path.to_path_buf(), | |
| 310 | + reason: err.to_string(), | |
| 311 | + }); | |
| 312 | + } | |
| 313 | + }; | |
| 314 | + let file = serde_json::from_str::<ReminderStateFile>(&body).map_err(|err| { | |
| 315 | + ReminderError::StateParse { | |
| 316 | + path: path.to_path_buf(), | |
| 317 | + reason: err.to_string(), | |
| 318 | + } | |
| 319 | + })?; | |
| 320 | + if file.version != STATE_VERSION { | |
| 321 | + return Err(ReminderError::StateParse { | |
| 322 | + path: path.to_path_buf(), | |
| 323 | + reason: format!("unsupported reminder state version {}", file.version), | |
| 324 | + }); | |
| 325 | + } | |
| 326 | + for record in &file.reminders { | |
| 327 | + if parse_date(&record.fire_date).is_none() || parse_time(&record.fire_time).is_none() { | |
| 328 | + return Err(ReminderError::StateParse { | |
| 329 | + path: path.to_path_buf(), | |
| 330 | + reason: format!("invalid reminder fire time for key '{}'", record.key), | |
| 331 | + }); | |
| 332 | + } | |
| 333 | + } | |
| 334 | + | |
| 335 | + Ok(Self { | |
| 336 | + records: file | |
| 337 | + .reminders | |
| 338 | + .into_iter() | |
| 339 | + .map(|record| (record.key.clone(), record)) | |
| 340 | + .collect(), | |
| 341 | + }) | |
| 342 | + } | |
| 343 | + | |
| 344 | + fn save(&self, path: &Path) -> Result<(), ReminderError> { | |
| 345 | + if let Some(parent) = path.parent() { | |
| 346 | + fs::create_dir_all(parent).map_err(|err| ReminderError::StateWrite { | |
| 347 | + path: parent.to_path_buf(), | |
| 348 | + reason: err.to_string(), | |
| 349 | + })?; | |
| 350 | + } | |
| 351 | + | |
| 352 | + let mut reminders = self.records.values().cloned().collect::<Vec<_>>(); | |
| 353 | + reminders.sort_by(|left, right| left.key.cmp(&right.key)); | |
| 354 | + let file = ReminderStateFile { | |
| 355 | + version: STATE_VERSION, | |
| 356 | + reminders, | |
| 357 | + }; | |
| 358 | + let body = | |
| 359 | + serde_json::to_string_pretty(&file).map_err(|err| ReminderError::StateWrite { | |
| 360 | + path: path.to_path_buf(), | |
| 361 | + reason: err.to_string(), | |
| 362 | + })?; | |
| 363 | + let temp_path = path.with_extension(format!( | |
| 364 | + "{}.tmp", | |
| 365 | + path.extension() | |
| 366 | + .and_then(|extension| extension.to_str()) | |
| 367 | + .unwrap_or("json") | |
| 368 | + )); | |
| 369 | + fs::write(&temp_path, body).map_err(|err| ReminderError::StateWrite { | |
| 370 | + path: temp_path.clone(), | |
| 371 | + reason: err.to_string(), | |
| 372 | + })?; | |
| 373 | + fs::rename(&temp_path, path).map_err(|err| ReminderError::StateWrite { | |
| 374 | + path: path.to_path_buf(), | |
| 375 | + reason: err.to_string(), | |
| 376 | + }) | |
| 377 | + } | |
| 378 | + | |
| 379 | + fn contains(&self, key: &str) -> bool { | |
| 380 | + self.records.contains_key(key) | |
| 381 | + } | |
| 382 | + | |
| 383 | + fn record(&mut self, instance: ReminderInstance, status: ReminderStatus) { | |
| 384 | + self.records.insert( | |
| 385 | + instance.key.clone(), | |
| 386 | + ReminderStateRecord { | |
| 387 | + key: instance.key, | |
| 388 | + status, | |
| 389 | + fire_date: instance.fire_at.date().to_string(), | |
| 390 | + fire_time: format_time(instance.fire_at.time()), | |
| 391 | + }, | |
| 392 | + ); | |
| 393 | + } | |
| 394 | + | |
| 395 | + fn prune(&mut self, today: Date) { | |
| 396 | + let cutoff = CalendarDate::from(today).add_days(-PRUNE_AFTER_DAYS); | |
| 397 | + self.records.retain(|_, record| { | |
| 398 | + record | |
| 399 | + .fire_date_value() | |
| 400 | + .map(|date| date >= cutoff) | |
| 401 | + .unwrap_or(true) | |
| 402 | + }); | |
| 403 | + } | |
| 404 | +} | |
| 405 | + | |
| 406 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] | |
| 407 | +struct ReminderStateFile { | |
| 408 | + version: u8, | |
| 409 | + #[serde(default)] | |
| 410 | + reminders: Vec<ReminderStateRecord>, | |
| 411 | +} | |
| 412 | + | |
| 413 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] | |
| 414 | +struct ReminderStateRecord { | |
| 415 | + key: String, | |
| 416 | + status: ReminderStatus, | |
| 417 | + fire_date: String, | |
| 418 | + fire_time: String, | |
| 419 | +} | |
| 420 | + | |
| 421 | +impl ReminderStateRecord { | |
| 422 | + fn fire_date_value(&self) -> Option<CalendarDate> { | |
| 423 | + parse_date(&self.fire_date) | |
| 424 | + } | |
| 425 | +} | |
| 426 | + | |
| 427 | +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] | |
| 428 | +#[serde(rename_all = "snake_case")] | |
| 429 | +enum ReminderStatus { | |
| 430 | + Delivered, | |
| 431 | + Skipped, | |
| 432 | +} | |
| 433 | + | |
| 434 | +struct DaemonLock { | |
| 435 | + _file: fs::File, | |
| 436 | +} | |
| 437 | + | |
| 438 | +impl DaemonLock { | |
| 439 | + fn acquire(path: &Path) -> Result<Self, ReminderError> { | |
| 440 | + if let Some(parent) = path.parent() { | |
| 441 | + fs::create_dir_all(parent).map_err(|err| ReminderError::Lock { | |
| 442 | + path: parent.to_path_buf(), | |
| 443 | + reason: err.to_string(), | |
| 444 | + })?; | |
| 445 | + } | |
| 446 | + let file = fs::OpenOptions::new() | |
| 447 | + .read(true) | |
| 448 | + .write(true) | |
| 449 | + .create(true) | |
| 450 | + .truncate(false) | |
| 451 | + .open(path) | |
| 452 | + .map_err(|err| ReminderError::Lock { | |
| 453 | + path: path.to_path_buf(), | |
| 454 | + reason: err.to_string(), | |
| 455 | + })?; | |
| 456 | + file.try_lock_exclusive() | |
| 457 | + .map_err(|err| ReminderError::Lock { | |
| 458 | + path: path.to_path_buf(), | |
| 459 | + reason: err.to_string(), | |
| 460 | + })?; | |
| 461 | + | |
| 462 | + Ok(Self { _file: file }) | |
| 463 | + } | |
| 464 | +} | |
| 465 | + | |
| 466 | +#[derive(Debug)] | |
| 467 | +pub enum ReminderError { | |
| 468 | + Events(String), | |
| 469 | + Notification(String), | |
| 470 | + StateRead { path: PathBuf, reason: String }, | |
| 471 | + StateParse { path: PathBuf, reason: String }, | |
| 472 | + StateWrite { path: PathBuf, reason: String }, | |
| 473 | + Lock { path: PathBuf, reason: String }, | |
| 474 | +} | |
| 475 | + | |
| 476 | +impl fmt::Display for ReminderError { | |
| 477 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 478 | + match self { | |
| 479 | + Self::Events(reason) => write!(f, "failed to load reminder events: {reason}"), | |
| 480 | + Self::Notification(reason) => write!(f, "failed to send notification: {reason}"), | |
| 481 | + Self::StateRead { path, reason } => { | |
| 482 | + write!( | |
| 483 | + f, | |
| 484 | + "failed to read reminder state {}: {reason}", | |
| 485 | + path.display() | |
| 486 | + ) | |
| 487 | + } | |
| 488 | + Self::StateParse { path, reason } => { | |
| 489 | + write!( | |
| 490 | + f, | |
| 491 | + "failed to parse reminder state {}: {reason}", | |
| 492 | + path.display() | |
| 493 | + ) | |
| 494 | + } | |
| 495 | + Self::StateWrite { path, reason } => { | |
| 496 | + write!( | |
| 497 | + f, | |
| 498 | + "failed to write reminder state {}: {reason}", | |
| 499 | + path.display() | |
| 500 | + ) | |
| 501 | + } | |
| 502 | + Self::Lock { path, reason } => { | |
| 503 | + write!( | |
| 504 | + f, | |
| 505 | + "failed to lock reminder daemon {}: {reason}", | |
| 506 | + path.display() | |
| 507 | + ) | |
| 508 | + } | |
| 509 | + } | |
| 510 | + } | |
| 511 | +} | |
| 512 | + | |
| 513 | +impl Error for ReminderError {} | |
| 514 | + | |
| 515 | +fn format_time(time: Time) -> String { | |
| 516 | + format!("{:02}:{:02}", time.hour(), time.minute()) | |
| 517 | +} | |
| 518 | + | |
| 519 | +fn parse_date(value: &str) -> Option<CalendarDate> { | |
| 520 | + let mut parts = value.split('-'); | |
| 521 | + let year = parts.next()?.parse::<i32>().ok()?; | |
| 522 | + let month = parts.next()?.parse::<u8>().ok()?; | |
| 523 | + let day = parts.next()?.parse::<u8>().ok()?; | |
| 524 | + if parts.next().is_some() { | |
| 525 | + return None; | |
| 526 | + } | |
| 527 | + CalendarDate::from_ymd(year, Month::try_from(month).ok()?, day).ok() | |
| 528 | +} | |
| 529 | + | |
| 530 | +fn parse_time(value: &str) -> Option<Time> { | |
| 531 | + let mut parts = value.split(':'); | |
| 532 | + let hour = parts.next()?.parse::<u8>().ok()?; | |
| 533 | + let minute = parts.next()?.parse::<u8>().ok()?; | |
| 534 | + if parts.next().is_some() { | |
| 535 | + return None; | |
| 536 | + } | |
| 537 | + Time::from_hms(hour, minute, 0).ok() | |
| 538 | +} | |
| 539 | + | |
| 540 | +#[cfg(test)] | |
| 541 | +mod tests { | |
| 542 | + use super::*; | |
| 543 | + use time::Month; | |
| 544 | + | |
| 545 | + use crate::agenda::{ | |
| 546 | + CreateEventDraft, CreateEventTiming, Event, InMemoryAgendaSource, RecurrenceEnd, | |
| 547 | + RecurrenceFrequency, RecurrenceRule, Reminder, SourceMetadata, | |
| 548 | + }; | |
| 549 | + | |
| 550 | + #[derive(Default)] | |
| 551 | + struct FakeNotifier { | |
| 552 | + sent: Vec<String>, | |
| 553 | + fail: bool, | |
| 554 | + } | |
| 555 | + | |
| 556 | + impl Notifier for FakeNotifier { | |
| 557 | + fn notify(&mut self, reminder: &ReminderInstance) -> Result<(), ReminderError> { | |
| 558 | + if self.fail { | |
| 559 | + Err(ReminderError::Notification("boom".to_string())) | |
| 560 | + } else { | |
| 561 | + self.sent.push(reminder.key.clone()); | |
| 562 | + Ok(()) | |
| 563 | + } | |
| 564 | + } | |
| 565 | + } | |
| 566 | + | |
| 567 | + fn date(day: u8) -> CalendarDate { | |
| 568 | + CalendarDate::from_ymd(2026, Month::April, day).expect("valid test date") | |
| 569 | + } | |
| 570 | + | |
| 571 | + fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime { | |
| 572 | + EventDateTime::new(date, Time::from_hms(hour, minute, 0).expect("valid time")) | |
| 573 | + } | |
| 574 | + | |
| 575 | + fn now(day: u8, hour: u8, minute: u8) -> PrimitiveDateTime { | |
| 576 | + PrimitiveDateTime::new( | |
| 577 | + date(day).into(), | |
| 578 | + Time::from_hms(hour, minute, 0).expect("valid time"), | |
| 579 | + ) | |
| 580 | + } | |
| 581 | + | |
| 582 | + fn timed_event(id: &str, title: &str, start: EventDateTime, end: EventDateTime) -> Event { | |
| 583 | + Event::timed(id, title, start, end, SourceMetadata::local()).expect("valid timed event") | |
| 584 | + } | |
| 585 | + | |
| 586 | + fn temp_path(name: &str, file: &str) -> PathBuf { | |
| 587 | + std::env::temp_dir() | |
| 588 | + .join(format!("rcal-reminders-test-{}", std::process::id())) | |
| 589 | + .join(name) | |
| 590 | + .join(file) | |
| 591 | + } | |
| 592 | + | |
| 593 | + #[test] | |
| 594 | + fn reminder_fire_times_cover_timed_all_day_cross_midnight_and_recurring_events() { | |
| 595 | + let day = date(23); | |
| 596 | + let timed = timed_event("timed", "Timed", at(day, 9, 0), at(day, 10, 0)) | |
| 597 | + .with_reminders(vec![Reminder::minutes_before(15)]); | |
| 598 | + let all_day = Event::all_day("all-day", "All day", day, SourceMetadata::local()) | |
| 599 | + .with_reminders(vec![Reminder::minutes_before(60)]); | |
| 600 | + let cross_midnight = | |
| 601 | + timed_event("late", "Late", at(day, 23, 30), at(day.add_days(1), 1, 0)) | |
| 602 | + .with_reminders(vec![Reminder::minutes_before(30)]); | |
| 603 | + let recurring = timed_event("daily", "Daily", at(day, 8, 0), at(day, 8, 30)) | |
| 604 | + .with_reminders(vec![Reminder::minutes_before(10)]) | |
| 605 | + .with_recurrence(RecurrenceRule { | |
| 606 | + frequency: RecurrenceFrequency::Daily, | |
| 607 | + interval: 1, | |
| 608 | + end: RecurrenceEnd::Count(2), | |
| 609 | + weekdays: Vec::new(), | |
| 610 | + monthly: None, | |
| 611 | + yearly: None, | |
| 612 | + }); | |
| 613 | + let source = InMemoryAgendaSource::with_events_and_holidays( | |
| 614 | + vec![timed, all_day, cross_midnight, recurring], | |
| 615 | + Vec::new(), | |
| 616 | + ); | |
| 617 | + | |
| 618 | + let instances = reminder_instances(&source, now(23, 9, 0)); | |
| 619 | + let keys = instances | |
| 620 | + .iter() | |
| 621 | + .map(|instance| (instance.event_id.as_str(), instance.fire_at)) | |
| 622 | + .collect::<Vec<_>>(); | |
| 623 | + | |
| 624 | + assert!(keys.contains(&("timed", now(23, 8, 45)))); | |
| 625 | + assert!(keys.contains(&("all-day", now(22, 23, 0)))); | |
| 626 | + assert!(keys.contains(&("late", now(23, 23, 0)))); | |
| 627 | + assert!( | |
| 628 | + keys.iter() | |
| 629 | + .any(|(id, fire_at)| id.starts_with("daily#") && *fire_at == now(24, 7, 50)) | |
| 630 | + ); | |
| 631 | + } | |
| 632 | + | |
| 633 | + #[test] | |
| 634 | + fn run_once_delivers_within_grace_and_skips_older_reminders() { | |
| 635 | + let dir = temp_path("grace", "events.json"); | |
| 636 | + let _ = std::fs::remove_dir_all(dir.parent().expect("path has parent")); | |
| 637 | + let state_file = temp_path("grace", "state.json"); | |
| 638 | + let mut source = ConfiguredAgendaSource::from_events_file(&dir, HolidayProvider::off()) | |
| 639 | + .expect("events load"); | |
| 640 | + source | |
| 641 | + .create_event(CreateEventDraft { | |
| 642 | + title: "Recent".to_string(), | |
| 643 | + timing: CreateEventTiming::Timed { | |
| 644 | + start: at(date(23), 9, 5), | |
| 645 | + end: at(date(23), 10, 0), | |
| 646 | + }, | |
| 647 | + location: None, | |
| 648 | + notes: None, | |
| 649 | + reminders: vec![Reminder::minutes_before(10)], | |
| 650 | + recurrence: None, | |
| 651 | + }) | |
| 652 | + .expect("event saves"); | |
| 653 | + source | |
| 654 | + .create_event(CreateEventDraft { | |
| 655 | + title: "Old".to_string(), | |
| 656 | + timing: CreateEventTiming::Timed { | |
| 657 | + start: at(date(23), 8, 30), | |
| 658 | + end: at(date(23), 9, 0), | |
| 659 | + }, | |
| 660 | + location: None, | |
| 661 | + notes: None, | |
| 662 | + reminders: vec![Reminder::minutes_before(30)], | |
| 663 | + recurrence: None, | |
| 664 | + }) | |
| 665 | + .expect("event saves"); | |
| 666 | + let config = ReminderDaemonConfig::new(dir.clone(), state_file); | |
| 667 | + let mut notifier = FakeNotifier::default(); | |
| 668 | + | |
| 669 | + let summary = run_once(&config, now(23, 9, 0), &mut notifier).expect("run succeeds"); | |
| 670 | + | |
| 671 | + let _ = std::fs::remove_dir_all(dir.parent().expect("test dir exists")); | |
| 672 | + assert_eq!(summary.delivered, 1); | |
| 673 | + assert_eq!(summary.skipped, 1); | |
| 674 | + assert_eq!(notifier.sent.len(), 1); | |
| 675 | + } | |
| 676 | + | |
| 677 | + #[test] | |
| 678 | + fn delivered_state_dedupes_across_runs() { | |
| 679 | + let events_file = temp_path("dedupe", "events.json"); | |
| 680 | + let _ = std::fs::remove_dir_all(events_file.parent().expect("path has parent")); | |
| 681 | + let state_file = temp_path("dedupe", "state.json"); | |
| 682 | + let mut source = | |
| 683 | + ConfiguredAgendaSource::from_events_file(&events_file, HolidayProvider::off()) | |
| 684 | + .expect("events load"); | |
| 685 | + source | |
| 686 | + .create_event(CreateEventDraft { | |
| 687 | + title: "Planning".to_string(), | |
| 688 | + timing: CreateEventTiming::Timed { | |
| 689 | + start: at(date(23), 9, 5), | |
| 690 | + end: at(date(23), 10, 0), | |
| 691 | + }, | |
| 692 | + location: None, | |
| 693 | + notes: None, | |
| 694 | + reminders: vec![Reminder::minutes_before(10)], | |
| 695 | + recurrence: None, | |
| 696 | + }) | |
| 697 | + .expect("event saves"); | |
| 698 | + let config = ReminderDaemonConfig::new(events_file.clone(), state_file); | |
| 699 | + let mut notifier = FakeNotifier::default(); | |
| 700 | + let first = run_once(&config, now(23, 9, 0), &mut notifier).expect("first run"); | |
| 701 | + let second = run_once(&config, now(23, 9, 1), &mut notifier).expect("second run"); | |
| 702 | + | |
| 703 | + let _ = std::fs::remove_dir_all(events_file.parent().expect("test dir exists")); | |
| 704 | + assert_eq!(first.delivered, 1); | |
| 705 | + assert_eq!(second.delivered, 0); | |
| 706 | + assert_eq!(notifier.sent.len(), 1); | |
| 707 | + } | |
| 708 | + | |
| 709 | + #[test] | |
| 710 | + fn malformed_state_is_a_clear_error_and_prune_drops_old_records() { | |
| 711 | + let state_file = temp_path("state", "state.json"); | |
| 712 | + let _ = std::fs::remove_dir_all(state_file.parent().expect("path has parent")); | |
| 713 | + std::fs::create_dir_all(state_file.parent().expect("path has parent")) | |
| 714 | + .expect("dir creates"); | |
| 715 | + std::fs::write(&state_file, "not-json").expect("state writes"); | |
| 716 | + let err = ReminderState::load(&state_file).expect_err("malformed state fails"); | |
| 717 | + assert!(err.to_string().contains("failed to parse reminder state")); | |
| 718 | + | |
| 719 | + let mut state = ReminderState::empty(); | |
| 720 | + state.records.insert( | |
| 721 | + "old".to_string(), | |
| 722 | + ReminderStateRecord { | |
| 723 | + key: "old".to_string(), | |
| 724 | + status: ReminderStatus::Delivered, | |
| 725 | + fire_date: "2026-03-01".to_string(), | |
| 726 | + fire_time: "09:00".to_string(), | |
| 727 | + }, | |
| 728 | + ); | |
| 729 | + state.records.insert( | |
| 730 | + "new".to_string(), | |
| 731 | + ReminderStateRecord { | |
| 732 | + key: "new".to_string(), | |
| 733 | + status: ReminderStatus::Delivered, | |
| 734 | + fire_date: "2026-04-20".to_string(), | |
| 735 | + fire_time: "09:00".to_string(), | |
| 736 | + }, | |
| 737 | + ); | |
| 738 | + state.prune(date(23).into()); | |
| 739 | + | |
| 740 | + let _ = std::fs::remove_dir_all(state_file.parent().expect("test dir exists")); | |
| 741 | + assert!(!state.records.contains_key("old")); | |
| 742 | + assert!(state.records.contains_key("new")); | |
| 743 | + } | |
| 744 | + | |
| 745 | + #[test] | |
| 746 | + fn notification_body_includes_time_and_location() { | |
| 747 | + let reminder = ReminderInstance { | |
| 748 | + key: "key".to_string(), | |
| 749 | + event_id: "event".to_string(), | |
| 750 | + title: "Planning".to_string(), | |
| 751 | + location: Some("Room 1".to_string()), | |
| 752 | + event_start: at(date(23), 9, 0), | |
| 753 | + fire_at: now(23, 8, 50), | |
| 754 | + minutes_before: 10, | |
| 755 | + all_day: false, | |
| 756 | + }; | |
| 757 | + | |
| 758 | + assert_eq!(reminder.notification_title(), "Reminder: Planning"); | |
| 759 | + assert!(reminder.notification_body().contains("Starts at 09:00")); | |
| 760 | + assert!(reminder.notification_body().contains("Location: Room 1")); | |
| 761 | + } | |
| 762 | +} | |
src/services.rsadded@@ -0,0 +1,564 @@ | ||
| 1 | +use std::{ | |
| 2 | + error::Error, | |
| 3 | + fmt, fs, | |
| 4 | + path::{Path, PathBuf}, | |
| 5 | + process::Command, | |
| 6 | +}; | |
| 7 | + | |
| 8 | +use directories::BaseDirs; | |
| 9 | + | |
| 10 | +use crate::{ | |
| 11 | + agenda::default_events_file, | |
| 12 | + reminders::{default_log_file, default_state_file}, | |
| 13 | +}; | |
| 14 | + | |
| 15 | +const SERVICE_LABEL: &str = "com.tenseleyflow.rcal.reminders"; | |
| 16 | +#[cfg(target_os = "linux")] | |
| 17 | +const SYSTEMD_SERVICE_NAME: &str = "rcal-reminders.service"; | |
| 18 | +const WINDOWS_TASK_NAME: &str = "rcal-reminders"; | |
| 19 | + | |
| 20 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 21 | +pub struct ServiceConfig { | |
| 22 | + pub executable: PathBuf, | |
| 23 | + pub events_file: PathBuf, | |
| 24 | + pub state_file: PathBuf, | |
| 25 | + pub log_file: PathBuf, | |
| 26 | +} | |
| 27 | + | |
| 28 | +impl ServiceConfig { | |
| 29 | + pub fn new(events_file: PathBuf) -> Result<Self, ServiceError> { | |
| 30 | + let executable = std::env::current_exe().map_err(|err| ServiceError::CurrentExe { | |
| 31 | + reason: err.to_string(), | |
| 32 | + })?; | |
| 33 | + Ok(Self { | |
| 34 | + executable, | |
| 35 | + events_file, | |
| 36 | + state_file: default_state_file(), | |
| 37 | + log_file: default_log_file(), | |
| 38 | + }) | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +impl Default for ServiceConfig { | |
| 43 | + fn default() -> Self { | |
| 44 | + Self { | |
| 45 | + executable: PathBuf::from("rcal"), | |
| 46 | + events_file: default_events_file(), | |
| 47 | + state_file: default_state_file(), | |
| 48 | + log_file: default_log_file(), | |
| 49 | + } | |
| 50 | + } | |
| 51 | +} | |
| 52 | + | |
| 53 | +pub trait CommandRunner { | |
| 54 | + fn run(&mut self, program: &str, args: &[String]) -> Result<(), ServiceError>; | |
| 55 | + fn status(&mut self, program: &str, args: &[String]) -> Result<bool, ServiceError>; | |
| 56 | +} | |
| 57 | + | |
| 58 | +#[derive(Debug, Default)] | |
| 59 | +pub struct SystemCommandRunner; | |
| 60 | + | |
| 61 | +impl CommandRunner for SystemCommandRunner { | |
| 62 | + fn run(&mut self, program: &str, args: &[String]) -> Result<(), ServiceError> { | |
| 63 | + let status = | |
| 64 | + Command::new(program) | |
| 65 | + .args(args) | |
| 66 | + .status() | |
| 67 | + .map_err(|err| ServiceError::Command { | |
| 68 | + program: program.to_string(), | |
| 69 | + reason: err.to_string(), | |
| 70 | + })?; | |
| 71 | + if status.success() { | |
| 72 | + Ok(()) | |
| 73 | + } else { | |
| 74 | + Err(ServiceError::Command { | |
| 75 | + program: program.to_string(), | |
| 76 | + reason: format!("exited with status {status}"), | |
| 77 | + }) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + fn status(&mut self, program: &str, args: &[String]) -> Result<bool, ServiceError> { | |
| 82 | + let status = | |
| 83 | + Command::new(program) | |
| 84 | + .args(args) | |
| 85 | + .status() | |
| 86 | + .map_err(|err| ServiceError::Command { | |
| 87 | + program: program.to_string(), | |
| 88 | + reason: err.to_string(), | |
| 89 | + })?; | |
| 90 | + Ok(status.success()) | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +pub fn install_service( | |
| 95 | + config: &ServiceConfig, | |
| 96 | + runner: &mut dyn CommandRunner, | |
| 97 | +) -> Result<(), ServiceError> { | |
| 98 | + platform_installer().install(config, runner) | |
| 99 | +} | |
| 100 | + | |
| 101 | +pub fn uninstall_service(runner: &mut dyn CommandRunner) -> Result<(), ServiceError> { | |
| 102 | + platform_installer().uninstall(runner) | |
| 103 | +} | |
| 104 | + | |
| 105 | +pub fn service_status(runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> { | |
| 106 | + platform_installer().status(runner) | |
| 107 | +} | |
| 108 | + | |
| 109 | +fn platform_installer() -> Box<dyn ServiceInstaller> { | |
| 110 | + #[cfg(target_os = "macos")] | |
| 111 | + { | |
| 112 | + Box::new(MacLaunchAgent) | |
| 113 | + } | |
| 114 | + #[cfg(target_os = "linux")] | |
| 115 | + { | |
| 116 | + Box::new(LinuxSystemdUser) | |
| 117 | + } | |
| 118 | + #[cfg(target_os = "windows")] | |
| 119 | + { | |
| 120 | + Box::new(WindowsScheduledTask) | |
| 121 | + } | |
| 122 | + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] | |
| 123 | + { | |
| 124 | + Box::new(UnsupportedInstaller) | |
| 125 | + } | |
| 126 | +} | |
| 127 | + | |
| 128 | +trait ServiceInstaller { | |
| 129 | + fn install( | |
| 130 | + &self, | |
| 131 | + config: &ServiceConfig, | |
| 132 | + runner: &mut dyn CommandRunner, | |
| 133 | + ) -> Result<(), ServiceError>; | |
| 134 | + fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError>; | |
| 135 | + fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError>; | |
| 136 | +} | |
| 137 | + | |
| 138 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 139 | +pub enum ServiceStatus { | |
| 140 | + Installed, | |
| 141 | + NotInstalled, | |
| 142 | +} | |
| 143 | + | |
| 144 | +impl fmt::Display for ServiceStatus { | |
| 145 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 146 | + match self { | |
| 147 | + Self::Installed => write!(f, "installed"), | |
| 148 | + Self::NotInstalled => write!(f, "not installed"), | |
| 149 | + } | |
| 150 | + } | |
| 151 | +} | |
| 152 | + | |
| 153 | +#[derive(Debug)] | |
| 154 | +struct MacLaunchAgent; | |
| 155 | + | |
| 156 | +impl MacLaunchAgent { | |
| 157 | + fn plist_path() -> Result<PathBuf, ServiceError> { | |
| 158 | + Ok(home_dir()? | |
| 159 | + .join("Library") | |
| 160 | + .join("LaunchAgents") | |
| 161 | + .join(format!("{SERVICE_LABEL}.plist"))) | |
| 162 | + } | |
| 163 | +} | |
| 164 | + | |
| 165 | +impl ServiceInstaller for MacLaunchAgent { | |
| 166 | + fn install( | |
| 167 | + &self, | |
| 168 | + config: &ServiceConfig, | |
| 169 | + runner: &mut dyn CommandRunner, | |
| 170 | + ) -> Result<(), ServiceError> { | |
| 171 | + let path = Self::plist_path()?; | |
| 172 | + if let Some(parent) = config.log_file.parent() { | |
| 173 | + fs::create_dir_all(parent).map_err(|err| ServiceError::Write { | |
| 174 | + path: parent.to_path_buf(), | |
| 175 | + reason: err.to_string(), | |
| 176 | + })?; | |
| 177 | + } | |
| 178 | + write_file(&path, &mac_launch_agent_plist(config))?; | |
| 179 | + let _ = runner.run("launchctl", &["unload".to_string(), path_string(&path)]); | |
| 180 | + runner.run( | |
| 181 | + "launchctl", | |
| 182 | + &["load".to_string(), "-w".to_string(), path_string(&path)], | |
| 183 | + ) | |
| 184 | + } | |
| 185 | + | |
| 186 | + fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError> { | |
| 187 | + let path = Self::plist_path()?; | |
| 188 | + if path.exists() { | |
| 189 | + let _ = runner.run("launchctl", &["unload".to_string(), path_string(&path)]); | |
| 190 | + fs::remove_file(&path).map_err(|err| ServiceError::Write { | |
| 191 | + path: path.clone(), | |
| 192 | + reason: err.to_string(), | |
| 193 | + })?; | |
| 194 | + } | |
| 195 | + Ok(()) | |
| 196 | + } | |
| 197 | + | |
| 198 | + fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> { | |
| 199 | + let path = Self::plist_path()?; | |
| 200 | + if !path.exists() { | |
| 201 | + return Ok(ServiceStatus::NotInstalled); | |
| 202 | + } | |
| 203 | + if runner.status( | |
| 204 | + "launchctl", | |
| 205 | + &["list".to_string(), SERVICE_LABEL.to_string()], | |
| 206 | + )? { | |
| 207 | + Ok(ServiceStatus::Installed) | |
| 208 | + } else { | |
| 209 | + Ok(ServiceStatus::NotInstalled) | |
| 210 | + } | |
| 211 | + } | |
| 212 | +} | |
| 213 | + | |
| 214 | +#[cfg(target_os = "linux")] | |
| 215 | +#[derive(Debug)] | |
| 216 | +struct LinuxSystemdUser; | |
| 217 | + | |
| 218 | +#[cfg(target_os = "linux")] | |
| 219 | +impl LinuxSystemdUser { | |
| 220 | + fn unit_path() -> Result<PathBuf, ServiceError> { | |
| 221 | + let config_home = if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") { | |
| 222 | + PathBuf::from(config_home) | |
| 223 | + } else { | |
| 224 | + home_dir()?.join(".config") | |
| 225 | + }; | |
| 226 | + Ok(config_home | |
| 227 | + .join("systemd") | |
| 228 | + .join("user") | |
| 229 | + .join(SYSTEMD_SERVICE_NAME)) | |
| 230 | + } | |
| 231 | +} | |
| 232 | + | |
| 233 | +#[cfg(target_os = "linux")] | |
| 234 | +impl ServiceInstaller for LinuxSystemdUser { | |
| 235 | + fn install( | |
| 236 | + &self, | |
| 237 | + config: &ServiceConfig, | |
| 238 | + runner: &mut dyn CommandRunner, | |
| 239 | + ) -> Result<(), ServiceError> { | |
| 240 | + let path = Self::unit_path()?; | |
| 241 | + write_file(&path, &linux_systemd_unit(config))?; | |
| 242 | + runner.run( | |
| 243 | + "systemctl", | |
| 244 | + &["--user".to_string(), "daemon-reload".to_string()], | |
| 245 | + )?; | |
| 246 | + runner.run( | |
| 247 | + "systemctl", | |
| 248 | + &[ | |
| 249 | + "--user".to_string(), | |
| 250 | + "enable".to_string(), | |
| 251 | + "--now".to_string(), | |
| 252 | + SYSTEMD_SERVICE_NAME.to_string(), | |
| 253 | + ], | |
| 254 | + ) | |
| 255 | + } | |
| 256 | + | |
| 257 | + fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError> { | |
| 258 | + let path = Self::unit_path()?; | |
| 259 | + let _ = runner.run( | |
| 260 | + "systemctl", | |
| 261 | + &[ | |
| 262 | + "--user".to_string(), | |
| 263 | + "disable".to_string(), | |
| 264 | + "--now".to_string(), | |
| 265 | + SYSTEMD_SERVICE_NAME.to_string(), | |
| 266 | + ], | |
| 267 | + ); | |
| 268 | + if path.exists() { | |
| 269 | + fs::remove_file(&path).map_err(|err| ServiceError::Write { | |
| 270 | + path: path.clone(), | |
| 271 | + reason: err.to_string(), | |
| 272 | + })?; | |
| 273 | + } | |
| 274 | + runner.run( | |
| 275 | + "systemctl", | |
| 276 | + &["--user".to_string(), "daemon-reload".to_string()], | |
| 277 | + ) | |
| 278 | + } | |
| 279 | + | |
| 280 | + fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> { | |
| 281 | + let path = Self::unit_path()?; | |
| 282 | + if !path.exists() { | |
| 283 | + return Ok(ServiceStatus::NotInstalled); | |
| 284 | + } | |
| 285 | + if runner.status( | |
| 286 | + "systemctl", | |
| 287 | + &[ | |
| 288 | + "--user".to_string(), | |
| 289 | + "is-active".to_string(), | |
| 290 | + "--quiet".to_string(), | |
| 291 | + SYSTEMD_SERVICE_NAME.to_string(), | |
| 292 | + ], | |
| 293 | + )? { | |
| 294 | + Ok(ServiceStatus::Installed) | |
| 295 | + } else { | |
| 296 | + Ok(ServiceStatus::NotInstalled) | |
| 297 | + } | |
| 298 | + } | |
| 299 | +} | |
| 300 | + | |
| 301 | +#[cfg(target_os = "windows")] | |
| 302 | +#[derive(Debug)] | |
| 303 | +struct WindowsScheduledTask; | |
| 304 | + | |
| 305 | +#[cfg(target_os = "windows")] | |
| 306 | +impl ServiceInstaller for WindowsScheduledTask { | |
| 307 | + fn install( | |
| 308 | + &self, | |
| 309 | + config: &ServiceConfig, | |
| 310 | + runner: &mut dyn CommandRunner, | |
| 311 | + ) -> Result<(), ServiceError> { | |
| 312 | + runner.run("schtasks", &windows_schtasks_create_args(config))?; | |
| 313 | + runner.run( | |
| 314 | + "schtasks", | |
| 315 | + &[ | |
| 316 | + "/Run".to_string(), | |
| 317 | + "/TN".to_string(), | |
| 318 | + WINDOWS_TASK_NAME.to_string(), | |
| 319 | + ], | |
| 320 | + ) | |
| 321 | + } | |
| 322 | + | |
| 323 | + fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError> { | |
| 324 | + let _ = runner.run( | |
| 325 | + "schtasks", | |
| 326 | + &[ | |
| 327 | + "/End".to_string(), | |
| 328 | + "/TN".to_string(), | |
| 329 | + WINDOWS_TASK_NAME.to_string(), | |
| 330 | + ], | |
| 331 | + ); | |
| 332 | + runner.run( | |
| 333 | + "schtasks", | |
| 334 | + &[ | |
| 335 | + "/Delete".to_string(), | |
| 336 | + "/TN".to_string(), | |
| 337 | + WINDOWS_TASK_NAME.to_string(), | |
| 338 | + "/F".to_string(), | |
| 339 | + ], | |
| 340 | + ) | |
| 341 | + } | |
| 342 | + | |
| 343 | + fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> { | |
| 344 | + if runner.status( | |
| 345 | + "schtasks", | |
| 346 | + &[ | |
| 347 | + "/Query".to_string(), | |
| 348 | + "/TN".to_string(), | |
| 349 | + WINDOWS_TASK_NAME.to_string(), | |
| 350 | + ], | |
| 351 | + )? { | |
| 352 | + Ok(ServiceStatus::Installed) | |
| 353 | + } else { | |
| 354 | + Ok(ServiceStatus::NotInstalled) | |
| 355 | + } | |
| 356 | + } | |
| 357 | +} | |
| 358 | + | |
| 359 | +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] | |
| 360 | +#[derive(Debug)] | |
| 361 | +struct UnsupportedInstaller; | |
| 362 | + | |
| 363 | +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] | |
| 364 | +impl ServiceInstaller for UnsupportedInstaller { | |
| 365 | + fn install( | |
| 366 | + &self, | |
| 367 | + _config: &ServiceConfig, | |
| 368 | + _runner: &mut dyn CommandRunner, | |
| 369 | + ) -> Result<(), ServiceError> { | |
| 370 | + Err(ServiceError::UnsupportedPlatform) | |
| 371 | + } | |
| 372 | + | |
| 373 | + fn uninstall(&self, _runner: &mut dyn CommandRunner) -> Result<(), ServiceError> { | |
| 374 | + Err(ServiceError::UnsupportedPlatform) | |
| 375 | + } | |
| 376 | + | |
| 377 | + fn status(&self, _runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> { | |
| 378 | + Err(ServiceError::UnsupportedPlatform) | |
| 379 | + } | |
| 380 | +} | |
| 381 | + | |
| 382 | +pub fn mac_launch_agent_plist(config: &ServiceConfig) -> String { | |
| 383 | + let stdout = config.log_file.with_extension("out.log"); | |
| 384 | + let stderr = config.log_file.with_extension("err.log"); | |
| 385 | + format!( | |
| 386 | + r#"<?xml version="1.0" encoding="UTF-8"?> | |
| 387 | +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 388 | +<plist version="1.0"> | |
| 389 | +<dict> | |
| 390 | + <key>Label</key> | |
| 391 | + <string>{}</string> | |
| 392 | + <key>ProgramArguments</key> | |
| 393 | + <array> | |
| 394 | + <string>{}</string> | |
| 395 | + <string>reminders</string> | |
| 396 | + <string>run</string> | |
| 397 | + <string>--events-file</string> | |
| 398 | + <string>{}</string> | |
| 399 | + <string>--state-file</string> | |
| 400 | + <string>{}</string> | |
| 401 | + </array> | |
| 402 | + <key>RunAtLoad</key> | |
| 403 | + <true/> | |
| 404 | + <key>KeepAlive</key> | |
| 405 | + <true/> | |
| 406 | + <key>StandardOutPath</key> | |
| 407 | + <string>{}</string> | |
| 408 | + <key>StandardErrorPath</key> | |
| 409 | + <string>{}</string> | |
| 410 | +</dict> | |
| 411 | +</plist> | |
| 412 | +"#, | |
| 413 | + SERVICE_LABEL, | |
| 414 | + xml_escape(&path_string(&config.executable)), | |
| 415 | + xml_escape(&path_string(&config.events_file)), | |
| 416 | + xml_escape(&path_string(&config.state_file)), | |
| 417 | + xml_escape(&path_string(&stdout)), | |
| 418 | + xml_escape(&path_string(&stderr)), | |
| 419 | + ) | |
| 420 | +} | |
| 421 | + | |
| 422 | +pub fn linux_systemd_unit(config: &ServiceConfig) -> String { | |
| 423 | + format!( | |
| 424 | + "[Unit]\nDescription=rcal reminder notifications\n\n[Service]\nExecStart={} reminders run --events-file {} --state-file {}\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n", | |
| 425 | + systemd_escape(&path_string(&config.executable)), | |
| 426 | + systemd_escape(&path_string(&config.events_file)), | |
| 427 | + systemd_escape(&path_string(&config.state_file)), | |
| 428 | + ) | |
| 429 | +} | |
| 430 | + | |
| 431 | +pub fn windows_schtasks_create_args(config: &ServiceConfig) -> Vec<String> { | |
| 432 | + let command = format!( | |
| 433 | + "\"{}\" reminders run --events-file \"{}\" --state-file \"{}\"", | |
| 434 | + path_string(&config.executable), | |
| 435 | + path_string(&config.events_file), | |
| 436 | + path_string(&config.state_file), | |
| 437 | + ); | |
| 438 | + vec![ | |
| 439 | + "/Create".to_string(), | |
| 440 | + "/TN".to_string(), | |
| 441 | + WINDOWS_TASK_NAME.to_string(), | |
| 442 | + "/SC".to_string(), | |
| 443 | + "ONLOGON".to_string(), | |
| 444 | + "/TR".to_string(), | |
| 445 | + command, | |
| 446 | + "/F".to_string(), | |
| 447 | + ] | |
| 448 | +} | |
| 449 | + | |
| 450 | +#[derive(Debug)] | |
| 451 | +pub enum ServiceError { | |
| 452 | + CurrentExe { reason: String }, | |
| 453 | + MissingHome, | |
| 454 | + UnsupportedPlatform, | |
| 455 | + Write { path: PathBuf, reason: String }, | |
| 456 | + Command { program: String, reason: String }, | |
| 457 | +} | |
| 458 | + | |
| 459 | +impl fmt::Display for ServiceError { | |
| 460 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 461 | + match self { | |
| 462 | + Self::CurrentExe { reason } => { | |
| 463 | + write!(f, "failed to locate current executable: {reason}") | |
| 464 | + } | |
| 465 | + Self::MissingHome => write!(f, "failed to locate a user home directory"), | |
| 466 | + Self::UnsupportedPlatform => { | |
| 467 | + write!(f, "reminder services are unsupported on this platform") | |
| 468 | + } | |
| 469 | + Self::Write { path, reason } => { | |
| 470 | + write!(f, "failed to write {}: {reason}", path.display()) | |
| 471 | + } | |
| 472 | + Self::Command { program, reason } => write!(f, "{program} failed: {reason}"), | |
| 473 | + } | |
| 474 | + } | |
| 475 | +} | |
| 476 | + | |
| 477 | +impl Error for ServiceError {} | |
| 478 | + | |
| 479 | +fn write_file(path: &Path, body: &str) -> Result<(), ServiceError> { | |
| 480 | + if let Some(parent) = path.parent() { | |
| 481 | + fs::create_dir_all(parent).map_err(|err| ServiceError::Write { | |
| 482 | + path: parent.to_path_buf(), | |
| 483 | + reason: err.to_string(), | |
| 484 | + })?; | |
| 485 | + } | |
| 486 | + | |
| 487 | + fs::write(path, body).map_err(|err| ServiceError::Write { | |
| 488 | + path: path.to_path_buf(), | |
| 489 | + reason: err.to_string(), | |
| 490 | + }) | |
| 491 | +} | |
| 492 | + | |
| 493 | +fn home_dir() -> Result<PathBuf, ServiceError> { | |
| 494 | + BaseDirs::new() | |
| 495 | + .map(|dirs| dirs.home_dir().to_path_buf()) | |
| 496 | + .ok_or(ServiceError::MissingHome) | |
| 497 | +} | |
| 498 | + | |
| 499 | +fn path_string(path: &Path) -> String { | |
| 500 | + path.to_string_lossy().into_owned() | |
| 501 | +} | |
| 502 | + | |
| 503 | +fn xml_escape(value: &str) -> String { | |
| 504 | + value | |
| 505 | + .replace('&', "&") | |
| 506 | + .replace('<', "<") | |
| 507 | + .replace('>', ">") | |
| 508 | + .replace('"', """) | |
| 509 | + .replace('\'', "'") | |
| 510 | +} | |
| 511 | + | |
| 512 | +fn systemd_escape(value: &str) -> String { | |
| 513 | + if value | |
| 514 | + .chars() | |
| 515 | + .all(|ch| !ch.is_whitespace() && ch != '\\' && ch != '"') | |
| 516 | + { | |
| 517 | + value.to_string() | |
| 518 | + } else { | |
| 519 | + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) | |
| 520 | + } | |
| 521 | +} | |
| 522 | + | |
| 523 | +#[cfg(test)] | |
| 524 | +mod tests { | |
| 525 | + use super::*; | |
| 526 | + | |
| 527 | + fn config() -> ServiceConfig { | |
| 528 | + ServiceConfig { | |
| 529 | + executable: PathBuf::from("/usr/local/bin/rcal"), | |
| 530 | + events_file: PathBuf::from("/tmp/rcal/events.json"), | |
| 531 | + state_file: PathBuf::from("/tmp/rcal/state.json"), | |
| 532 | + log_file: PathBuf::from("/tmp/rcal/reminders.log"), | |
| 533 | + } | |
| 534 | + } | |
| 535 | + | |
| 536 | + #[test] | |
| 537 | + fn mac_launch_agent_contains_reminder_run_command() { | |
| 538 | + let plist = mac_launch_agent_plist(&config()); | |
| 539 | + | |
| 540 | + assert!(plist.contains("com.tenseleyflow.rcal.reminders")); | |
| 541 | + assert!(plist.contains("<string>/usr/local/bin/rcal</string>")); | |
| 542 | + assert!(plist.contains("<string>reminders</string>")); | |
| 543 | + assert!(plist.contains("<string>--events-file</string>")); | |
| 544 | + } | |
| 545 | + | |
| 546 | + #[test] | |
| 547 | + fn linux_unit_contains_reminder_run_command() { | |
| 548 | + let unit = linux_systemd_unit(&config()); | |
| 549 | + | |
| 550 | + assert!(unit.contains("Description=rcal reminder notifications")); | |
| 551 | + assert!(unit.contains("ExecStart=/usr/local/bin/rcal reminders run")); | |
| 552 | + assert!(unit.contains("Restart=always")); | |
| 553 | + } | |
| 554 | + | |
| 555 | + #[test] | |
| 556 | + fn windows_task_command_contains_reminder_run_command() { | |
| 557 | + let args = windows_schtasks_create_args(&config()); | |
| 558 | + let joined = args.join(" "); | |
| 559 | + | |
| 560 | + assert!(joined.contains("/Create")); | |
| 561 | + assert!(joined.contains("rcal-reminders")); | |
| 562 | + assert!(joined.contains("\"/usr/local/bin/rcal\" reminders run")); | |
| 563 | + } | |
| 564 | +} | |