Use osascript for macOS reminders
- SHA
9694ee567d53ca48480d5ffa3ee53ce16f8ca87f- Parents
-
adeab44 - Tree
56cc91d
9694ee5
9694ee567d53ca48480d5ffa3ee53ce16f8ca87fadeab44
56cc91d| Status | File | + | - |
|---|---|---|---|
| M |
README.md
|
4 | 2 |
| M |
src/cli.rs
|
47 | 6 |
| M |
src/reminders.rs
|
83 | 6 |
README.mdmodified@@ -30,7 +30,7 @@ rcal reminders run [--events-file PATH] [--state-file PATH] [--once] | ||
| 30 | 30 | rcal reminders install [--events-file PATH] |
| 31 | 31 | rcal reminders uninstall |
| 32 | 32 | rcal reminders status |
| 33 | -rcal reminders test | |
| 33 | +rcal reminders test [--verbose] | |
| 34 | 34 | ``` |
| 35 | 35 | |
| 36 | 36 | Options: |
@@ -76,7 +76,9 @@ week, and day views. The create/edit modal supports timed events, single-day | ||
| 76 | 76 | all-day events, recurrence, location, notes, and multiple reminder offsets. |
| 77 | 77 | Reminder notifications are delivered by a user-level background service. Use |
| 78 | 78 | `rcal reminders install` to install it, `rcal reminders status` to inspect it, |
| 79 | -and `rcal reminders test` to send a test notification. | |
| 79 | +and `rcal reminders test` to send a test notification. On macOS, notification | |
| 80 | +delivery uses `osascript` because it is more reliable for CLI-launched | |
| 81 | +notifications than the generic notification backend. | |
| 80 | 82 | |
| 81 | 83 | ## Layout |
| 82 | 84 | |
src/cli.rsmodified@@ -23,8 +23,8 @@ use crate::{ | ||
| 23 | 23 | }, |
| 24 | 24 | calendar::CalendarDate, |
| 25 | 25 | reminders::{ |
| 26 | - ReminderDaemonConfig, ReminderError, SystemNotifier, default_state_file, run_daemon, | |
| 27 | - run_once, test_notification, | |
| 26 | + ReminderDaemonConfig, ReminderError, SystemNotifier, default_state_file, | |
| 27 | + notification_backend_name, run_daemon, run_once, test_notification, | |
| 28 | 28 | }, |
| 29 | 29 | services::{ |
| 30 | 30 | ServiceConfig, ServiceError, SystemCommandRunner, install_service, service_status, |
@@ -46,7 +46,7 @@ const HELP: &str = concat!( | ||
| 46 | 46 | " rcal reminders install [--events-file PATH]\n", |
| 47 | 47 | " rcal reminders uninstall\n", |
| 48 | 48 | " rcal reminders status\n", |
| 49 | - " rcal reminders test\n\n", | |
| 49 | + " rcal reminders test [--verbose]\n\n", | |
| 50 | 50 | "Options:\n", |
| 51 | 51 | " --date YYYY-MM-DD Open with the given date selected.\n", |
| 52 | 52 | " --events-file PATH Read and write local user events at PATH.\n", |
@@ -113,7 +113,7 @@ pub enum ReminderCliAction { | ||
| 113 | 113 | Install { events_file: PathBuf }, |
| 114 | 114 | Uninstall, |
| 115 | 115 | Status, |
| 116 | - Test, | |
| 116 | + Test { verbose: bool }, | |
| 117 | 117 | } |
| 118 | 118 | |
| 119 | 119 | #[derive(Debug, Clone, PartialEq, Eq)] |
@@ -436,7 +436,7 @@ where | ||
| 436 | 436 | "install" => parse_reminder_install_args(args), |
| 437 | 437 | "uninstall" => no_extra_reminder_args(args, ReminderCliAction::Uninstall), |
| 438 | 438 | "status" => no_extra_reminder_args(args, ReminderCliAction::Status), |
| 439 | - "test" => no_extra_reminder_args(args, ReminderCliAction::Test), | |
| 439 | + "test" => parse_reminder_test_args(args), | |
| 440 | 440 | "--help" | "-h" => Ok(CliAction::Help), |
| 441 | 441 | _ => Err(CliError::UnknownReminderCommand(command.to_string())), |
| 442 | 442 | } |
@@ -543,6 +543,24 @@ where | ||
| 543 | 543 | })) |
| 544 | 544 | } |
| 545 | 545 | |
| 546 | +fn parse_reminder_test_args<I>(args: I) -> Result<CliAction, CliError> | |
| 547 | +where | |
| 548 | + I: IntoIterator<Item = OsString>, | |
| 549 | +{ | |
| 550 | + let mut verbose = false; | |
| 551 | + | |
| 552 | + for arg in args { | |
| 553 | + if arg == "--verbose" { | |
| 554 | + verbose = true; | |
| 555 | + continue; | |
| 556 | + } | |
| 557 | + | |
| 558 | + return Err(CliError::UnknownArgument(display_arg(&arg))); | |
| 559 | + } | |
| 560 | + | |
| 561 | + Ok(CliAction::Reminders(ReminderCliAction::Test { verbose })) | |
| 562 | +} | |
| 563 | + | |
| 546 | 564 | fn no_extra_reminder_args<I>(args: I, action: ReminderCliAction) -> Result<CliAction, CliError> |
| 547 | 565 | where |
| 548 | 566 | I: IntoIterator<Item = OsString>, |
@@ -644,8 +662,15 @@ fn run_reminder_action( | ||
| 644 | 662 | Err(err) => service_error_exit(stderr, err), |
| 645 | 663 | } |
| 646 | 664 | } |
| 647 | - ReminderCliAction::Test => { | |
| 665 | + ReminderCliAction::Test { verbose } => { | |
| 648 | 666 | let mut notifier = SystemNotifier; |
| 667 | + if verbose { | |
| 668 | + let _ = writeln!( | |
| 669 | + stdout, | |
| 670 | + "notification_backend={}", | |
| 671 | + notification_backend_name() | |
| 672 | + ); | |
| 673 | + } | |
| 649 | 674 | match test_notification(&mut notifier) { |
| 650 | 675 | Ok(()) => { |
| 651 | 676 | let _ = writeln!(stdout, "sent test reminder notification"); |
@@ -1177,6 +1202,22 @@ mod tests { | ||
| 1177 | 1202 | ); |
| 1178 | 1203 | } |
| 1179 | 1204 | |
| 1205 | + #[test] | |
| 1206 | + fn reminder_test_accepts_verbose_diagnostic_flag() { | |
| 1207 | + let today = date(2026, Month::April, 23); | |
| 1208 | + | |
| 1209 | + let action = parse_args( | |
| 1210 | + [arg("reminders"), arg("test"), arg("--verbose")], | |
| 1211 | + today.into(), | |
| 1212 | + ) | |
| 1213 | + .expect("parse succeeds"); | |
| 1214 | + | |
| 1215 | + assert_eq!( | |
| 1216 | + action, | |
| 1217 | + CliAction::Reminders(ReminderCliAction::Test { verbose: true }) | |
| 1218 | + ); | |
| 1219 | + } | |
| 1220 | + | |
| 1180 | 1221 | #[test] |
| 1181 | 1222 | fn reminder_args_are_rejected_when_invalid() { |
| 1182 | 1223 | let today = date(2026, Month::April, 23); |
src/reminders.rsmodified@@ -9,8 +9,11 @@ use std::{ | ||
| 9 | 9 | |
| 10 | 10 | use directories::ProjectDirs; |
| 11 | 11 | use fs2::FileExt; |
| 12 | +#[cfg(not(target_os = "macos"))] | |
| 12 | 13 | use notify_rust::Notification; |
| 13 | 14 | use serde::{Deserialize, Serialize}; |
| 15 | +#[cfg(target_os = "macos")] | |
| 16 | +use std::process::Command; | |
| 14 | 17 | use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time}; |
| 15 | 18 | |
| 16 | 19 | use crate::{ |
@@ -103,15 +106,70 @@ pub struct SystemNotifier; | ||
| 103 | 106 | |
| 104 | 107 | impl Notifier for SystemNotifier { |
| 105 | 108 | 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())) | |
| 109 | + show_system_notification( | |
| 110 | + &reminder.notification_title(), | |
| 111 | + &reminder.notification_body(), | |
| 112 | + ) | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +pub const fn notification_backend_name() -> &'static str { | |
| 117 | + #[cfg(target_os = "macos")] | |
| 118 | + { | |
| 119 | + "macos-osascript" | |
| 120 | + } | |
| 121 | + #[cfg(not(target_os = "macos"))] | |
| 122 | + { | |
| 123 | + "notify-rust" | |
| 112 | 124 | } |
| 113 | 125 | } |
| 114 | 126 | |
| 127 | +#[cfg(target_os = "macos")] | |
| 128 | +fn show_system_notification(summary: &str, body: &str) -> Result<(), ReminderError> { | |
| 129 | + let output = Command::new("osascript") | |
| 130 | + .args(macos_display_notification_args(summary, body)) | |
| 131 | + .output() | |
| 132 | + .map_err(|err| ReminderError::Notification(format!("osascript failed: {err}")))?; | |
| 133 | + | |
| 134 | + if output.status.success() { | |
| 135 | + return Ok(()); | |
| 136 | + } | |
| 137 | + | |
| 138 | + let stderr = String::from_utf8_lossy(&output.stderr); | |
| 139 | + Err(ReminderError::Notification(format!( | |
| 140 | + "osascript exited with {}: {}", | |
| 141 | + output.status, | |
| 142 | + stderr.trim() | |
| 143 | + ))) | |
| 144 | +} | |
| 145 | + | |
| 146 | +#[cfg(target_os = "macos")] | |
| 147 | +fn macos_display_notification_args(summary: &str, body: &str) -> Vec<String> { | |
| 148 | + [ | |
| 149 | + "-e", | |
| 150 | + "on run argv", | |
| 151 | + "-e", | |
| 152 | + "display notification (item 2 of argv) with title (item 1 of argv)", | |
| 153 | + "-e", | |
| 154 | + "end run", | |
| 155 | + summary, | |
| 156 | + body, | |
| 157 | + ] | |
| 158 | + .into_iter() | |
| 159 | + .map(str::to_string) | |
| 160 | + .collect() | |
| 161 | +} | |
| 162 | + | |
| 163 | +#[cfg(not(target_os = "macos"))] | |
| 164 | +fn show_system_notification(summary: &str, body: &str) -> Result<(), ReminderError> { | |
| 165 | + Notification::new() | |
| 166 | + .summary(summary) | |
| 167 | + .body(body) | |
| 168 | + .show() | |
| 169 | + .map(|_| ()) | |
| 170 | + .map_err(|err| ReminderError::Notification(err.to_string())) | |
| 171 | +} | |
| 172 | + | |
| 115 | 173 | pub fn default_state_file() -> PathBuf { |
| 116 | 174 | if let Some(project_dirs) = ProjectDirs::from("com", "tenseleyFlow", "rcal") { |
| 117 | 175 | if let Some(state_dir) = project_dirs.state_dir() { |
@@ -759,4 +817,23 @@ mod tests { | ||
| 759 | 817 | assert!(reminder.notification_body().contains("Starts at 09:00")); |
| 760 | 818 | assert!(reminder.notification_body().contains("Location: Room 1")); |
| 761 | 819 | } |
| 820 | + | |
| 821 | + #[cfg(target_os = "macos")] | |
| 822 | + #[test] | |
| 823 | + fn macos_notification_uses_osascript_with_separate_user_text_args() { | |
| 824 | + let args = macos_display_notification_args( | |
| 825 | + "Reminder: Planning \"review\"", | |
| 826 | + "Starts now\nLocation: Room 1", | |
| 827 | + ); | |
| 828 | + | |
| 829 | + assert_eq!(args[0], "-e"); | |
| 830 | + assert_eq!(args[1], "on run argv"); | |
| 831 | + assert_eq!( | |
| 832 | + args[3], | |
| 833 | + "display notification (item 2 of argv) with title (item 1 of argv)" | |
| 834 | + ); | |
| 835 | + assert_eq!(args[5], "end run"); | |
| 836 | + assert_eq!(args[6], "Reminder: Planning \"review\""); | |
| 837 | + assert_eq!(args[7], "Starts now\nLocation: Room 1"); | |
| 838 | + } | |
| 762 | 839 | } |