{"slug":"apple-calendar-caldav-sync","meta":{"title":"Apple Calendar (iCloud) Two-Way Sync via CalDAV","slug":"apple-calendar-caldav-sync","category":"Integrations","summary":"Show a shared Apple/iCloud calendar inside your app and write events back onto it — no gems, no OAuth app, just Net::HTTP speaking CalDAV with an app-specific password.","tags":["caldav","icloud","apple","calendar","ics","integrations","net-http","no-gem"],"status":"stable","visibility":"public","source_project":"duke-homes.leo.llamapress.ai","layers":["model","controller","view"],"related":[{"title":"Google Calendar Integration","url":"/cookbook/google-calendar-integration","summary":"The Google-side equivalent — OAuth + the official Calendar API."}]},"body":"# Apple Calendar (iCloud) Two-Way Sync via CalDAV\n\n\u003e ⚠️ **Cookbook example — not live code.** Every code block below is an **example\n\u003e snippet**, **not part of the llamapress.ai codebase**, and **not running on this\n\u003e server**. This is a reference recipe for a **Leo instance (an AI coding agent) to\n\u003e implement in its own app** — read it to understand the pattern, then recreate it\n\u003e there. (A copy-ready install package lives in\n\u003e `app/views/cookbook/apple-calendar-caldav-sync/` — see its `INSTALL.md`.)\n\nApple has **no public REST API for iCloud Calendar** — but it runs a standards-compliant\n**CalDAV** server at `caldav.icloud.com`, the same one Thunderbird and every third-party\ncalendar app use. That means a Rails app can read AND write a customer's shared Apple\ncalendar with nothing but `Net::HTTP` and Nokogiri (both ship with Rails — important on\nLeo boxes, where you cannot add gems). Auth is the customer's Apple ID plus an\n**app-specific password** they mint at account.apple.com in about a minute.\n\nThe result: a `/calendar` page with a month grid + upcoming list fed live from iCloud,\nand an \"Add event\" form whose events land on the shared calendar — and on everyone's\niPhones — seconds later. Proven end-to-end (read + create + delete round-trip) against a\nreal iCloud account.\n\n\u003e **When to use:** a client's team lives on a shared Apple calendar and wants those\n\u003e events inside the app, or wants the app to put events onto it. Works for any calendar\n\u003e the Apple ID owns or can edit.\n\u003e **When not to:** the client is on Google Calendar (use the Google integration — real\n\u003e API, webhooks). Or you need push updates — CalDAV here is poll-on-page-load; there are\n\u003e no webhooks.\n\n---\n\n## The 80/20 in one breath\n\n1. Copy `AppleCalendarClient` (the install package has it verbatim) — discovery, read,\n   write, ICS parse/build in one ~250-line service, zero gems.\n2. One singleton table `apple_calendar_settings` (apple_id, app_password, calendar_url,\n   calendar_name) + model with `.current` / `connected?`.\n3. One controller: `show` (month grid), `settings`/`connect`/`select_calendar`\n   (three-step connect flow), `create_event`, `disconnect`.\n4. Three views: month grid + add-event modal, a settings page with the\n   app-specific-password walkthrough, and a calendar picker.\n5. Six routes under `/calendar`, one nav link.\n6. The customer mints an app-specific password and connects on `/calendar/settings`.\n\n---\n\n## Layer 1 — How the CalDAV conversation works (the part worth understanding)\n\nFour HTTP verbs against `https://caldav.icloud.com/`, all Basic-auth'd with\n`apple_id:app_specific_password`:\n\n```ruby\n# app/services/apple_calendar_client.rb (excerpts — full file in the install package)\n\n# 1) WHO AM I — PROPFIND / for the principal URL\nPRINCIPAL_XML = \u003c\u003c~XML.freeze\n  \u003cd:propfind xmlns:d=\"DAV:\"\u003e\u003cd:prop\u003e\u003cd:current-user-principal/\u003e\u003c/d:prop\u003e\u003c/d:propfind\u003e\nXML\n\n# 2) WHERE ARE MY CALENDARS — PROPFIND the principal for calendar-home-set,\n#    then PROPFIND Depth:1 on the home to list calendars. Keep only real\n#    VEVENT-capable calendars (skip inbox/outbox/reminders):\nnext unless node.at_xpath(\".//*[local-name()='resourcetype']/*[local-name()='calendar']\")\ncomps = node.xpath(\".//*[local-name()='supported-calendar-component-set']/*[local-name()='comp']\").map { |c| c[\"name\"] }\nnext if comps.any? \u0026\u0026 !comps.include?(\"VEVENT\")\n\n# 3) READ — REPORT calendar-query with a time window; \u003cc:expand\u003e makes APPLE\n#    expand recurring events into individual occurrences (no RRULE engine needed):\ndef query_xml(from_utc, to_utc, expand:)\n  data = expand ? %(\u003cc:calendar-data\u003e\u003cc:expand start=\"#{from_utc}\" end=\"#{to_utc}\"/\u003e\u003c/c:calendar-data\u003e) : \"\u003cc:calendar-data/\u003e\"\n  \u003c\u003c~XML\n    \u003cc:calendar-query xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"\u003e\n      \u003cd:prop\u003e\u003cd:getetag/\u003e#{data}\u003c/d:prop\u003e\n      \u003cc:filter\u003e\u003cc:comp-filter name=\"VCALENDAR\"\u003e\u003cc:comp-filter name=\"VEVENT\"\u003e\n        \u003cc:time-range start=\"#{from_utc}\" end=\"#{to_utc}\"/\u003e\n      \u003c/c:comp-filter\u003e\u003c/c:comp-filter\u003e\u003c/c:filter\u003e\n    \u003c/c:calendar-query\u003e\n  XML\nend\n\n# 4) WRITE — PUT a minimal VCALENDAR to \u003ccalendar_url\u003e\u003cuuid\u003e.ics\n#    with If-None-Match: * (create-only, never overwrite).\n#    DELETE on that same URL removes the event (204).\n```\n\nTwo infrastructure details the request core must handle (the package file does):\n\n```ruby\n# iCloud 3xx-redirects you to a partition host (p112-caldav.icloud.com) —\n# follow redirects AND resolve returned hrefs against the FINAL uri, not BASE_URL.\n# And because every request carries the credentials, hard-refuse foreign hosts:\nALLOWED_HOST = /(\\A|\\.)icloud\\.com\\z/\nraise Error, \"Refusing to send Apple credentials to #{uri.host}\" unless uri.host.to_s.match?(ALLOWED_HOST)\n```\n\n## Layer 2 — Model \u0026 migration\n\n```ruby\n# app/models/apple_calendar_setting.rb — singleton row\nclass AppleCalendarSetting \u003c ApplicationRecord\n  # Password is a PLAIN column ON PURPOSE: Rails `encrypts` derives its key from\n  # SECRET_KEY_BASE, which rotates on Leo-instance sleep/wake — an encrypted\n  # password would silently brick on the next wake. App-specific passwords are\n  # revocable at account.apple.com, so plain is the safer fleet trade.\n  validates :calendar_url, format: { with: %r{\\Ahttps://[\\w.-]*icloud\\.com[/:]}, allow_blank: true }\n\n  def self.current = first || create!\n  def credentialed? = apple_id.present? \u0026\u0026 app_password.present?\n  def connected? = credentialed? \u0026\u0026 calendar_url.present?\n  def client = AppleCalendarClient.new(apple_id: apple_id, app_password: app_password)\nend\n```\n\n```ruby\n# db/migrate/\u003ctimestamp\u003e_create_apple_calendar_settings.rb\ncreate_table :apple_calendar_settings do |t|\n  t.string :apple_id, :app_password, :calendar_url, :calendar_name\n  t.datetime :connected_at\n  t.timestamps\nend\n```\n\n## Layer 3 — Controller flow\n\n```ruby\n# app/controllers/calendar_controller.rb (shape — full file in the package)\n# show:            month grid; fetch events for the visible grid window, live, per load\n# settings:        the walkthrough + credential form\n# connect (POST):  verify creds by running discovery; on success SAVE creds and\n#                  render the calendar picker  ← this render is why the form is turbo:false\n# select_calendar: save the chosen {name, url}; done\n# create_event:    build times from the form (all-day or timed), PUT to iCloud, redirect\n# disconnect:      nil out the row\ndef show\n  @month = parse_month\n  return unless @settings.connected?\n  grid_from = @month.beginning_of_month.beginning_of_week(:sunday)\n  @grid_days = (grid_from..@month.end_of_month.end_of_week(:sunday)).to_a\n  events = @settings.client.events(@settings.calendar_url,\n                                   from: @grid_days.first.in_time_zone,\n                                   to: (@grid_days.last + 1).in_time_zone)\n  @events_by_date = index_by_date(events)   # multi-day events on every day they span\n  @upcoming = events.select { |e| (e.ends_at || e.starts_at) \u003e= Time.zone.now }.first(12)\nrescue AppleCalendarClient::AuthError =\u003e e\n  @calendar_error = e.message               # amber banner + \"Reconnect\" link, never a 500\nend\n```\n\n## Layer 4 — The views\n\nFull files in the package: a 7-column month grid (all-day events as solid pills, timed\nones outlined with the start time, \"+N more\" past three, today badged, sideways-scroll\non phones), a \"Coming up\" list that doubles as the phone view, an add-event modal in\nplain delegated JS (no Stimulus dependency), and the settings/picker pages. All Tailwind\n+ Font Awesome FREE icons.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Turbo eats the picker page — a user connects and \"nothing happens.\"** The `connect`\n  action *renders* the calendar picker on success, and Turbo silently drops non-redirect\n  responses to form posts. Symptom in production: the loading bar flashes, the page\n  doesn't change, yet the credentials SAVED (discovery ran before the render). The\n  connect form must carry `data: { turbo: false }`. This shipped broken once; don't\n  repeat it.\n- **The user's normal Apple password can never work** — CalDAV requires an\n  app-specific password (account.apple.com → Sign-In and Security → App-Specific\n  Passwords). Say so in the UI or every first attempt fails.\n- **`DTEND` is EXCLUSIVE.** A one-day all-day event has `DTEND` = the NEXT date. Add a\n  day when writing all-day events; subtract one when deciding which grid days an event\n  occupies, or every all-day event paints one day too many.\n- **Let Apple expand recurrences.** `\u003cc:expand\u003e` in the REPORT returns individual\n  occurrences, so you never write an RRULE engine. Keep a no-expand retry fallback and\n  flag masters `recurring`.\n- **Parse `...Z` timestamps with `Time.utc` explicitly** — `Time.strptime` without zone\n  info assumes process-local time and shifts every event.\n- **ICS text is folded and escaped**: continuation lines start with a space/tab (unfold\n  before parsing), and `\\n` `\\,` `\\;` `\\\\` need unescaping (and escaping on write). Skip\n  `STATUS:CANCELLED` events.\n- **Follow redirects to the partition host** and resolve DAV `href`s against the final\n  URI. And **refuse to send credentials to any non-icloud.com host** — the stored\n  `calendar_url` is user-influencable, and every request carries Basic auth.\n- **Don't `encrypts` the password on a Leo box** — `SECRET_KEY_BASE` rotates on\n  sleep/wake and bricks encrypted columns. Plain + revocable beats encrypted + bricked.\n- **`allow_browser versions: :modern` blocks iPhones below iOS 17.2** with a dead\n  \"browser not supported\" page — and a calendar is a phone page. Override the check in\n  this controller.\n- **Latency:** the page fetches iCloud live on every load (~1–2s). Fine for an internal\n  tool; add a cache table if it ever isn't. There are no webhooks in CalDAV-land.\n\n---\n\n## Files this pattern touches\n\n```\napp/services/apple_calendar_client.rb        # the CalDAV client (verbatim drop-in)\napp/models/apple_calendar_setting.rb         # singleton connection row\napp/controllers/calendar_controller.rb\napp/views/calendar/show.html.erb             # month grid + upcoming + add-event modal\napp/views/calendar/settings.html.erb         # walkthrough + credential form (turbo:false)\napp/views/calendar/choose.html.erb           # calendar picker\ndb/migrate/\u003ctimestamp\u003e_create_apple_calendar_settings.rb\nconfig/routes.rb                             # six routes under /calendar\n```\n\n## How to adapt to your schema\n\n1. The client and model are drop-ins — rebrand only the `PRODID` string.\n2. Point `CalendarController` at your app's base controller / layout; keep the\n   `allow_browser` override.\n3. Restyle the views to your app's palette; the grid logic (`@grid_days`,\n   `@events_by_date`) is view-agnostic.\n4. Deleting events from the app: `client.send(:request, :delete,\n   \"#{calendar_url}#{uid}.ics\")` returns 204 — promote it to a public\n   `delete_event(calendar_url, uid)` if you build event management UI.\n5. Multiple calendars: drop the singleton (`.current`) pattern for a\n   `has_many`-style table keyed by calendar_url; the client doesn't care.\n6. Read-only variant with zero credentials: skip all of this and subscribe to the\n   calendar's public `webcal://` URL instead — fetch and parse the ICS with the same\n   `parse_ics`.\n"}