#[cfg(any(test, target_os = "macos"))]
use std::process::Command;
#[cfg(any(test, target_os = "macos"))]
use std::time::Duration;
use chrono::{DateTime, Local};
#[cfg(target_os = "macos")]
use chrono::{Datelike, Timelike};
#[cfg(any(test, target_os = "macos"))]
const SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(3);
#[cfg(any(test, target_os = "macos"))]
const EVENTKIT_OVERLAP_LOOKAHEAD_MINUTES: u32 = 120;
#[cfg(any(test, target_os = "macos"))]
const EVENTKIT_OVERLAP_LOOKBACK_MINUTES: u32 = 120;
#[cfg(any(test, target_os = "macos"))]
pub(crate) fn output_with_timeout(
mut cmd: Command,
timeout: Duration,
) -> Option<std::process::Output> {
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setpgid(0, 0);
Ok(())
});
}
}
let child = cmd.spawn().ok()?;
let (tx, rx) = std::sync::mpsc::channel();
let child_id = child.id();
let handle = std::thread::spawn(move || {
let result = child.wait_with_output();
let _ = tx.send(result);
});
match rx.recv_timeout(timeout) {
Ok(result) => {
let _ = handle.join();
result.ok()
}
Err(_) => {
eprintln!(
"[calendar] subprocess {} timed out after {:?}, killing",
child_id, timeout
);
#[cfg(unix)]
{
unsafe {
libc::kill(-(child_id as i32), libc::SIGKILL);
libc::kill(child_id as i32, libc::SIGKILL);
}
}
let _ = handle.join().ok();
None
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CalendarEvent {
pub title: String,
pub start: String,
pub minutes_until: i64,
#[serde(default)]
pub attendees: Vec<String>,
#[serde(default)]
pub url: Option<String>,
}
pub fn extract_meeting_url(text: &str) -> Option<String> {
let patterns = [
"https://zoom.us/j/",
"https://us02web.zoom.us/j/",
"https://us04web.zoom.us/j/",
"https://us05web.zoom.us/j/",
"https://us06web.zoom.us/j/",
"https://meet.google.com/",
"https://teams.microsoft.com/l/meetup-join/",
"https://teams.live.com/meet/",
"https://webex.com/meet/",
"https://facetime.apple.com/",
];
for pattern in &patterns {
if let Some(start) = text.find(pattern) {
let url_text = &text[start..];
let end = url_text
.find(|c: char| c.is_whitespace() || c == '>' || c == '"' || c == ')')
.unwrap_or(url_text.len());
let url = &url_text[..end];
if url.len() > pattern.len() {
return Some(url.to_string());
}
}
}
for keyword in &[
"zoom.us",
"meet.google",
"teams.microsoft",
"webex.com",
"facetime.apple",
] {
if let Some(https_pos) = text.find("https://") {
let url_text = &text[https_pos..];
if url_text.contains(keyword) {
let end = url_text
.find(|c: char| c.is_whitespace() || c == '>' || c == '"' || c == ')')
.unwrap_or(url_text.len());
return Some(url_text[..end].to_string());
}
}
}
None
}
pub fn upcoming_events(lookahead_minutes: u32) -> Vec<CalendarEvent> {
#[cfg(not(target_os = "macos"))]
{
let _ = lookahead_minutes;
Vec::new()
}
#[cfg(target_os = "macos")]
{
if !calendar_integration_enabled() {
return Vec::new();
}
if let Some(events) = query_via_eventkit(lookahead_minutes) {
return events;
}
query_via_applescript(lookahead_minutes)
}
}
pub fn events_overlapping(at: DateTime<Local>) -> Vec<CalendarEvent> {
#[cfg(not(target_os = "macos"))]
{
let _ = at;
Vec::new()
}
#[cfg(target_os = "macos")]
{
if !calendar_integration_enabled() {
return Vec::new();
}
if (Local::now() - at).num_seconds().abs() <= 60 {
return events_overlapping_now();
}
if let Some(events) = query_overlap_via_eventkit(Some(at.timestamp())) {
return events;
}
query_events_with_attendees_at(at)
}
}
pub fn events_overlapping_now() -> Vec<CalendarEvent> {
#[cfg(not(target_os = "macos"))]
{
Vec::new()
}
#[cfg(target_os = "macos")]
{
if !calendar_integration_enabled() {
return Vec::new();
}
if let Some(events) = query_overlap_via_eventkit(None) {
return events;
}
query_events_with_attendees()
}
}
#[cfg(target_os = "macos")]
fn calendar_integration_enabled() -> bool {
crate::config::Config::load().calendar.enabled
}
#[cfg(target_os = "macos")]
fn is_calendar_app_running() -> bool {
Command::new("pgrep")
.args(["-x", "Calendar"])
.output()
.map(|out| out.status.success() && !out.stdout.is_empty())
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
fn query_events_with_attendees() -> Vec<CalendarEvent> {
query_events_with_attendees_at(Local::now())
}
#[cfg(target_os = "macos")]
fn applescript_month(month: u32) -> &'static str {
match month {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
12 => "December",
_ => "January",
}
}
#[cfg(target_os = "macos")]
fn query_events_with_attendees_at(center: DateTime<Local>) -> Vec<CalendarEvent> {
if !is_calendar_app_running() {
return Vec::new();
}
let script = r#"set now to current date
set year of now to __YEAR__
set month of now to __MONTH__
set day of now to __DAY__
set hours of now to __HOUR__
set minutes of now to __MINUTE__
set seconds of now to __SECOND__
set windowStart to now - (2 * 60 * 60)
set windowEnd to now + (2 * 60 * 60)
set todayStart to current date
set year of todayStart to __YEAR__
set month of todayStart to __MONTH__
set day of todayStart to __DAY__
set hours of todayStart to 0
set minutes of todayStart to 0
set seconds of todayStart to 0
set tomorrowEnd to todayStart + (2 * 24 * 60 * 60)
set output to ""
set unitSep to (ASCII character 31)
set fieldSep to (ASCII character 30)
tell application "Calendar"
repeat with cal in calendars
try
set evts to (every event of cal whose start date >= todayStart and start date <= tomorrowEnd)
repeat with evt in evts
set s to start date of evt
set e to end date of evt
if (s <= windowEnd and e >= windowStart) then
set t to summary of evt
set mins to ((s - now) / 60) as integer
set attendeeNames to ""
try
set theAttendees to attendees of evt
repeat with anAttendee in theAttendees
if attendeeNames is not "" then
set attendeeNames to attendeeNames & fieldSep
end if
set attendeeNames to attendeeNames & (name of anAttendee)
end repeat
end try
set loc to ""
try
set loc to location of evt
if loc is missing value then set loc to ""
end try
set output to output & t & unitSep & (s as string) & unitSep & mins & unitSep & attendeeNames & unitSep & loc & linefeed
end if
end repeat
end try
end repeat
end tell
return output"#
.replace("__YEAR__", ¢er.year().to_string())
.replace("__MONTH__", applescript_month(center.month()))
.replace("__DAY__", ¢er.day().to_string())
.replace("__HOUR__", ¢er.hour().to_string())
.replace("__MINUTE__", ¢er.minute().to_string())
.replace("__SECOND__", ¢er.second().to_string());
let mut cmd = Command::new("osascript");
cmd.arg("-e").arg(script);
let output = match output_with_timeout(cmd, SUBPROCESS_TIMEOUT) {
Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
_ => return Vec::new(),
};
let unit_sep = '\x1F';
let field_sep = '\x1E';
let mut events: Vec<CalendarEvent> = output
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(5, unit_sep).collect();
if parts.len() >= 3 {
let attendees = if parts.len() >= 4 && !parts[3].trim().is_empty() {
parts[3]
.split(field_sep)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
} else {
Vec::new()
};
let url = parts.get(4).and_then(|loc| extract_meeting_url(loc.trim()));
Some(CalendarEvent {
title: parts[0].trim().to_string(),
start: parts[1].trim().to_string(),
minutes_until: parts[2].trim().parse().unwrap_or(0),
attendees,
url,
})
} else {
None
}
})
.collect();
dedup_events(&mut events);
events
}
#[cfg(any(test, target_os = "macos"))]
fn parse_eventkit_output(stdout: &str) -> Vec<CalendarEvent> {
stdout
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|line| {
let mut event: CalendarEvent = serde_json::from_str(line).ok()?;
event.url = event.url.as_deref().and_then(extract_meeting_url);
Some(event)
})
.collect()
}
#[cfg(any(test, target_os = "macos"))]
fn dedup_events(events: &mut Vec<CalendarEvent>) {
events.sort_by_key(|e| (e.title.clone(), e.minutes_until.abs()));
events.dedup_by(|a, b| a.title == b.title);
events.sort_by_key(|e| e.minutes_until.abs());
}
#[cfg(target_os = "macos")]
fn query_via_eventkit(lookahead_minutes: u32) -> Option<Vec<CalendarEvent>> {
let helper = find_calendar_helper()?;
let mut cmd = Command::new(&helper);
cmd.arg(lookahead_minutes.to_string());
let output = output_with_timeout(cmd, SUBPROCESS_TIMEOUT)?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
Some(parse_eventkit_output(&stdout))
}
#[cfg(target_os = "macos")]
fn query_overlap_via_eventkit(reference_epoch_seconds: Option<i64>) -> Option<Vec<CalendarEvent>> {
let helper = find_calendar_helper()?;
query_overlap_via_eventkit_with_helper(&helper, reference_epoch_seconds)
}
#[cfg(any(test, target_os = "macos"))]
fn query_overlap_via_eventkit_with_helper(
helper: &std::path::Path,
reference_epoch_seconds: Option<i64>,
) -> Option<Vec<CalendarEvent>> {
tracing::info!(
lookahead_minutes = EVENTKIT_OVERLAP_LOOKAHEAD_MINUTES,
lookback_minutes = EVENTKIT_OVERLAP_LOOKBACK_MINUTES,
reference_epoch_seconds,
"querying calendar overlap via EventKit helper"
);
let mut cmd = Command::new(helper);
cmd.arg(EVENTKIT_OVERLAP_LOOKAHEAD_MINUTES.to_string())
.arg(EVENTKIT_OVERLAP_LOOKBACK_MINUTES.to_string());
if let Some(reference_epoch_seconds) = reference_epoch_seconds {
cmd.arg(reference_epoch_seconds.to_string());
}
let output = output_with_timeout(cmd, SUBPROCESS_TIMEOUT)?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut events = parse_eventkit_output(&stdout);
dedup_events(&mut events);
Some(events)
}
#[cfg(target_os = "macos")]
fn find_calendar_helper() -> Option<std::path::PathBuf> {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let beside = dir.join("calendar-events");
if beside.exists() {
return Some(beside);
}
}
}
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent());
if let Some(root) = workspace_root {
let staged = root.join("tauri/src-tauri/bin/calendar-events");
if staged.exists() {
return Some(staged);
}
let legacy_staged = root.join("tauri/src-tauri/resources/calendar-events");
if legacy_staged.exists() {
return Some(legacy_staged);
}
let legacy = root.join("target/release/calendar-events");
if legacy.exists() {
return Some(legacy);
}
}
None
}
#[cfg(target_os = "macos")]
fn query_via_applescript(lookahead_minutes: u32) -> Vec<CalendarEvent> {
if !is_calendar_app_running() {
return Vec::new();
}
let script = format!(
r#"set now to current date
set todayStart to current date
set hours of todayStart to 0
set minutes of todayStart to 0
set seconds of todayStart to 0
set tomorrowEnd to todayStart + (2 * 24 * 60 * 60)
set lookaheadSecs to {minutes} * 60
set horizon to now + lookaheadSecs
set output to ""
tell application "Calendar"
repeat with cal in calendars
try
set evts to (every event of cal whose start date >= todayStart and start date <= tomorrowEnd)
repeat with evt in evts
set s to start date of evt
if s >= now and s <= horizon then
set t to summary of evt
set mins to ((s - now) / 60) as integer
set loc to ""
try
set loc to location of evt
if loc is missing value then set loc to ""
end try
set output to output & t & (ASCII character 31) & (s as string) & (ASCII character 31) & mins & (ASCII character 31) & loc & linefeed
end if
end repeat
end try
end repeat
end tell
return output"#,
minutes = lookahead_minutes
);
let mut cmd = Command::new("osascript");
cmd.arg("-e").arg(&script);
let output = match output_with_timeout(cmd, SUBPROCESS_TIMEOUT) {
Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
Some(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
eprintln!("[calendar] applescript failed: {}", stderr.trim());
return Vec::new();
}
None => {
eprintln!("[calendar] osascript timed out or failed to start");
return Vec::new();
}
};
let sep = '\x1F'; let mut events: Vec<CalendarEvent> = output
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, sep).collect();
if parts.len() >= 3 {
let url = parts.get(3).and_then(|loc| extract_meeting_url(loc.trim()));
Some(CalendarEvent {
title: parts[0].trim().to_string(),
start: parts[1].trim().to_string(),
minutes_until: parts[2].trim().parse().unwrap_or(0),
attendees: Vec::new(),
url,
})
} else {
None
}
})
.collect();
events.sort_by_key(|e| (e.minutes_until, e.title.clone()));
events.dedup_by(|a, b| a.title == b.title && a.minutes_until == b.minutes_until);
events
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[test]
fn extract_zoom_url() {
let text = "https://zoom.us/j/1234567890?pwd=abc123";
assert_eq!(
extract_meeting_url(text),
Some("https://zoom.us/j/1234567890?pwd=abc123".to_string())
);
}
#[test]
fn extract_google_meet_url() {
let text = "Join: https://meet.google.com/abc-defg-hij";
assert_eq!(
extract_meeting_url(text),
Some("https://meet.google.com/abc-defg-hij".to_string())
);
}
#[test]
fn extract_teams_url() {
let text = "https://teams.microsoft.com/l/meetup-join/19%3ameeting_abc";
assert_eq!(
extract_meeting_url(text),
Some("https://teams.microsoft.com/l/meetup-join/19%3ameeting_abc".to_string())
);
}
#[test]
fn extract_no_url() {
assert_eq!(extract_meeting_url("Conference Room B"), None);
assert_eq!(extract_meeting_url(""), None);
assert_eq!(extract_meeting_url("https://docs.google.com/doc/123"), None);
}
#[test]
fn extract_url_from_mixed_text() {
let text = "Location: Building 4, Room 201\nhttps://zoom.us/j/999 (backup link)";
assert_eq!(
extract_meeting_url(text),
Some("https://zoom.us/j/999".to_string())
);
}
#[test]
fn extract_zoom_subdomain_url() {
let text = "https://us02web.zoom.us/j/8765432?pwd=xyz";
assert_eq!(
extract_meeting_url(text),
Some("https://us02web.zoom.us/j/8765432?pwd=xyz".to_string())
);
}
#[cfg(unix)]
#[test]
fn query_overlap_via_eventkit_passes_reference_timestamp() {
let tempdir = tempfile::tempdir().unwrap();
let helper_path = tempdir.path().join("calendar-events");
let args_path = tempdir.path().join("args.txt");
let script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n",
args_path.display()
);
std::fs::write(&helper_path, script).unwrap();
let mut permissions = std::fs::metadata(&helper_path).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&helper_path, permissions).unwrap();
let events = query_overlap_via_eventkit_with_helper(&helper_path, Some(1_700_000_000))
.expect("helper should run");
assert!(events.is_empty());
let args = std::fs::read_to_string(&args_path).unwrap();
assert_eq!(
args.lines().collect::<Vec<_>>(),
["120", "120", "1700000000"]
);
}
#[cfg(unix)]
#[test]
fn query_overlap_via_eventkit_omits_reference_timestamp_when_not_provided() {
let tempdir = tempfile::tempdir().unwrap();
let helper_path = tempdir.path().join("calendar-events");
let args_path = tempdir.path().join("args.txt");
let script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n",
args_path.display()
);
std::fs::write(&helper_path, script).unwrap();
let mut permissions = std::fs::metadata(&helper_path).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&helper_path, permissions).unwrap();
let events =
query_overlap_via_eventkit_with_helper(&helper_path, None).expect("helper should run");
assert!(events.is_empty());
let args = std::fs::read_to_string(&args_path).unwrap();
assert_eq!(args.lines().collect::<Vec<_>>(), ["120", "120"]);
}
}