Skip to content

feat(calendar): Apple Calendar (EventKit) integration for macOS#1237

Open
gabrielste1n wants to merge 6 commits into
mainfrom
feat/apple-calendar-eventkit
Open

feat(calendar): Apple Calendar (EventKit) integration for macOS#1237
gabrielste1n wants to merge 6 commits into
mainfrom
feat/apple-calendar-eventkit

Conversation

@gabrielste1n

Copy link
Copy Markdown
Collaborator

Summary

Adds Apple Calendar as a second calendar provider on macOS, reading the local EventKit store that Calendar.app already aggregates (iCloud, Google, Exchange, Outlook). Connecting is a single native permission prompt — no OAuth, tokens, or API quotas — and updates arrive via EKEventStoreChanged push instead of 2-minute polling.

Everything downstream of sync (meeting reminders, the notification overlay, Upcoming Meetings, the agent's calendar tool, note linking) reads the provider-neutral calendar_events table, so both providers flow through unchanged consumers.

How it works

Native helperresources/macos-calendar-listener.swift, built by scripts/build-macos-calendar-listener.js following the existing mic-listener pattern. It emits line-delimited JSON: a permission message on launch, then full snapshot messages (calendars + 7-day event window) on store changes, on "sync" via stdin (focus/wake), and on a 5-minute window-roll timer. --request triggers the TCC prompt. The build script also patches dev Electron's Info.plist with the calendar usage strings (packaged builds get them via extendInfo + the calendars entitlement).

Databasecalendar_events gains a provider column ('google' default); new apple_calendars table mirrors google_calendars (source_name instead of account email). Google-side deletes are now provider-scoped so the two providers can't clobber each other. Apple syncs are full snapshot replaces, which also handles deletions.

Cross-provider dedupe — when a user connects Google and Apple (with Calendar.app mirroring the same Google account), every meeting would exist twice. The shared read queries suppress Apple rows whose time slot + title match a Google row, with datetime() normalization since the providers store different timestamp formats. Google rows are never collapsed, so Google-only behavior is byte-identical to main.

Reminder scheduling — extracted from GoogleCalendarManager into a provider-agnostic CalendarReminderScheduler shared by both managers (it only reads the deduped DB queries), so one scheduler fires each reminder exactly once. Pure refactor for Google-only users.

Join links — EventKit has no structured conference data; a new unit-tested extractMeetingUrl() pulls Zoom/Meet/Teams/Webex/Chime links out of url/location/notes at sync time into the existing hangout_link column, so every Join button and note-creation flow works unchanged.

UI — Apple Calendar row in Settings → Integrations (macOS only), mirroring the Google row: connect with the same system-audio pre-gate, permission-denied dialog that deep-links to Privacy & Security → Calendars, connected sources sub-row, and disconnect confirmation. i18n keys added to all 10 locales.

Testing

  • npm run lint, cd src && tsc --noEmit, node --test (561 tests), npm run i18n:check all green
  • Swift helper compiles for arm64 and emits the correct permission JSON (verified live)
  • Dedupe/migration/replace SQL verified against real better-sqlite3, including mixed timestamp formats (offset-form Google + Z-form Apple rows for the same instant), preservation of distinct same-slot Google events, and is_selected surviving snapshot prunes
  • Windows/Linux untouched: build script exits 0 off-macOS, UI row hidden, manager no-ops

Notes for reviewers

  • Dev-mode caveat: the build script re-ad-hoc-signs dev Electron after patching its Info.plist, which drops previously granted TCC permissions (Accessibility/Microphone/Screen Recording) once — re-grant when prompted.
  • v1 has no per-calendar selection UI for Apple (Google has none either); apple_calendars.is_selected exists in the schema and is honored by the sync filter for future parity.

Add a second calendar provider that reads the local EventKit store —
covering iCloud, Google, Exchange, and Outlook accounts already in
Calendar.app — with one native permission prompt and push-based updates
instead of OAuth + polling.

- New macos-calendar-listener Swift helper (mic-listener pattern):
  emits permission + calendars/events snapshots as line-delimited JSON,
  re-emits on EKEventStoreChanged, stdin "sync", and a 5-min window roll;
  --request triggers the TCC prompt attributed to OpenWhispr
- Build script patches dev Electron's Info.plist with calendar usage
  strings; packaged builds get them via electron-builder extendInfo,
  plus the calendars entitlement for hardened runtime
- calendar_events gains a provider column; new apple_calendars table;
  Google deletes are provider-scoped; shared read queries suppress
  Apple copies of Google-mirrored meetings (datetime-normalized, Google
  rows never collapsed) so reminders fire once per meeting
- Meeting reminder scheduling extracted from GoogleCalendarManager into
  a provider-agnostic CalendarReminderScheduler shared by both managers
- AppleCalendarManager owns the helper lifecycle; meeting join links are
  extracted from url/location/notes into hangout_link at sync time so
  Join buttons and note linking work unchanged
- Integrations UI gets an Apple Calendar row (macOS only) with connect,
  denied → System Settings flow, source list, and disconnect; i18n keys
  added to all 10 locales

Downstream consumers (Upcoming Meetings, overlay reminders, agent
calendar tool, note linking) read the shared table and work for both
providers with no changes.

@Chadpiha Chadpiha left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review fixes — Apple Calendar (EventKit) integration

Correctness

[P1] Apple meeting links were deleted after meetings ended. Snapshots only cover Date() forward, and the old replace logic wiped every stored Apple event first — so after the next 5-minute refresh, a finished meeting's calendar_event_id pointed at nothing and linked notes lost their calendar metadata. replaceAppleCalendarEvents now retains events still referenced by non-deleted notes (database.js:2386), with a regression test covering retention vs. pruning (calendarDatabase.test.js).

[P2] Disconnecting one calendar provider repeated the other's reminders. Disconnect used to reset the shared scheduler wholesale, clearing notification history for both providers and re-prompting an already-dismissed active meeting (reproduced both directions). The scheduler's reset(provider) is now provider-scoped (calendarReminderScheduler.js:118): it clears only that provider's delivered-reminder keys and active meeting, so Google reminders survive an Apple disconnect and vice versa. Tested in calendarReminderScheduler.test.js.

[P2] Apple-only users saw "Sign in required." The Upcoming Meetings empty state checked app sign-in status even when the events source was local Apple Calendar. It now treats a connected Apple Calendar as sufficient and shows the normal "no more meetings" state (UpcomingMeetings.tsx:113).

[P2] A helper crash silently left Calendar permanently stale. An unexpected helper exit previously just cleared the process reference — the UI kept saying "Connected" while sync became a no-op until app restart. The manager now schedules an automatic restart with exponential backoff (1s → 30s cap) whenever the helper dies while a connection exists, and resets the backoff on each successful snapshot (appleCalendarManager.js:184). Tests cover crash-restart and deliberate-stop paths (appleCalendarManager.test.js).

Code quality (follow-up review pass)

Accurate connect-failure UX: connect failures are routed by reason — only a real permission denial opens the Privacy-settings dialog; helper-missing/snapshot errors show a generic failure dialog instead of a misleading permissions prompt (IntegrationsView.tsx:91), with new connectFailed strings in all 10 locales.
Removed speculative/dead code: the is_selected column and getSelectedAppleCalendars() (no UI ever deselects a calendar), the unused AppleCalendar interface, and an unused windowManager constructor param.
Deduplicated the macOS Swift build scripts: calendar, mic, globe, and text-monitor scripts are now thin wrappers over a shared build lib (scripts/lib/build-macos-swift-binary.js) — ~740 duplicated lines removed; the calendar script keeps its dev-Electron plist patch as a hook.
Extracted shared main-process helpers: bundled-binary resolution (binaryResolver.js) and window broadcasting (windowBroadcast.js), replacing four identical broadcastToWindows implementations across ~40 call sites.
Observability: acal-get-connection-status failures are now logged instead of silently swallowed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants