tenseleyflow/rcal / 9694ee5

Browse files

Use osascript for macOS reminders

Authored by espadonne
SHA
9694ee567d53ca48480d5ffa3ee53ce16f8ca87f
Parents
adeab44
Tree
56cc91d

3 changed files

StatusFile+-
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]
3030
 rcal reminders install [--events-file PATH]
3131
 rcal reminders uninstall
3232
 rcal reminders status
33
-rcal reminders test
33
+rcal reminders test [--verbose]
3434
 ```
3535
 
3636
 Options:
@@ -76,7 +76,9 @@ week, and day views. The create/edit modal supports timed events, single-day
7676
 all-day events, recurrence, location, notes, and multiple reminder offsets.
7777
 Reminder notifications are delivered by a user-level background service. Use
7878
 `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.
8082
 
8183
 ## Layout
8284
 
src/cli.rsmodified
@@ -23,8 +23,8 @@ use crate::{
2323
     },
2424
     calendar::CalendarDate,
2525
     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,
2828
     },
2929
     services::{
3030
         ServiceConfig, ServiceError, SystemCommandRunner, install_service, service_status,
@@ -46,7 +46,7 @@ const HELP: &str = concat!(
4646
     "  rcal reminders install [--events-file PATH]\n",
4747
     "  rcal reminders uninstall\n",
4848
     "  rcal reminders status\n",
49
-    "  rcal reminders test\n\n",
49
+    "  rcal reminders test [--verbose]\n\n",
5050
     "Options:\n",
5151
     "  --date YYYY-MM-DD                   Open with the given date selected.\n",
5252
     "  --events-file PATH                  Read and write local user events at PATH.\n",
@@ -113,7 +113,7 @@ pub enum ReminderCliAction {
113113
     Install { events_file: PathBuf },
114114
     Uninstall,
115115
     Status,
116
-    Test,
116
+    Test { verbose: bool },
117117
 }
118118
 
119119
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -436,7 +436,7 @@ where
436436
         "install" => parse_reminder_install_args(args),
437437
         "uninstall" => no_extra_reminder_args(args, ReminderCliAction::Uninstall),
438438
         "status" => no_extra_reminder_args(args, ReminderCliAction::Status),
439
-        "test" => no_extra_reminder_args(args, ReminderCliAction::Test),
439
+        "test" => parse_reminder_test_args(args),
440440
         "--help" | "-h" => Ok(CliAction::Help),
441441
         _ => Err(CliError::UnknownReminderCommand(command.to_string())),
442442
     }
@@ -543,6 +543,24 @@ where
543543
     }))
544544
 }
545545
 
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
+
546564
 fn no_extra_reminder_args<I>(args: I, action: ReminderCliAction) -> Result<CliAction, CliError>
547565
 where
548566
     I: IntoIterator<Item = OsString>,
@@ -644,8 +662,15 @@ fn run_reminder_action(
644662
                 Err(err) => service_error_exit(stderr, err),
645663
             }
646664
         }
647
-        ReminderCliAction::Test => {
665
+        ReminderCliAction::Test { verbose } => {
648666
             let mut notifier = SystemNotifier;
667
+            if verbose {
668
+                let _ = writeln!(
669
+                    stdout,
670
+                    "notification_backend={}",
671
+                    notification_backend_name()
672
+                );
673
+            }
649674
             match test_notification(&mut notifier) {
650675
                 Ok(()) => {
651676
                     let _ = writeln!(stdout, "sent test reminder notification");
@@ -1177,6 +1202,22 @@ mod tests {
11771202
         );
11781203
     }
11791204
 
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
+
11801221
     #[test]
11811222
     fn reminder_args_are_rejected_when_invalid() {
11821223
         let today = date(2026, Month::April, 23);
src/reminders.rsmodified
@@ -9,8 +9,11 @@ use std::{
99
 
1010
 use directories::ProjectDirs;
1111
 use fs2::FileExt;
12
+#[cfg(not(target_os = "macos"))]
1213
 use notify_rust::Notification;
1314
 use serde::{Deserialize, Serialize};
15
+#[cfg(target_os = "macos")]
16
+use std::process::Command;
1417
 use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time};
1518
 
1619
 use crate::{
@@ -103,15 +106,70 @@ pub struct SystemNotifier;
103106
 
104107
 impl Notifier for SystemNotifier {
105108
     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"
112124
     }
113125
 }
114126
 
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
+
115173
 pub fn default_state_file() -> PathBuf {
116174
     if let Some(project_dirs) = ProjectDirs::from("com", "tenseleyFlow", "rcal") {
117175
         if let Some(state_dir) = project_dirs.state_dir() {
@@ -759,4 +817,23 @@ mod tests {
759817
         assert!(reminder.notification_body().contains("Starts at 09:00"));
760818
         assert!(reminder.notification_body().contains("Location: Room 1"));
761819
     }
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
+    }
762839
 }