From 130ea0fbe9142874c4704117b1cd9df5fe0b20de Mon Sep 17 00:00:00 2001 From: Rob Weaver Date: Sat, 16 May 2026 16:32:57 -0600 Subject: [PATCH] Add Go rewrite of work-cal-sync alongside Python version Implements the improve-setup spec: a Go reimplementation of the calendar sync tool that ships as a code-signed .app bundle plus CLI binary, with subcommands for setup, install, uninstall, and status. The Python version is preserved unchanged and the two coexist via different launchd labels. - cmd/work-cal-sync: subcommand dispatch and bundle routing for EventKit access - cmd/ekprobe: diagnostic helper for EventKit auth and calendar listing - pkg/eventkit: cgo bridge to EventKit.framework with Info.plist for TCC - internal/sync: config, caldav, ical, keyring, plist, wizard, diff, managed, sync engine - Makefile: build/install targets for both the CLI and the .app bundle, with auto-detected codesign identity - docs: architecture, user guide, troubleshooting - Spec at .kiro/specs/improve-setup/ --- .gitignore | 19 + .kiro/specs/improve-setup/design.md | 135 ++++ .kiro/specs/improve-setup/requirements.md | 197 +++++ .kiro/specs/improve-setup/tasks.md | 159 ++++ Makefile | 98 +++ README.md | 222 +++++- cmd/ekprobe/main.go | 111 +++ cmd/ekprobe/probe.m | 92 +++ cmd/work-cal-sync/doc.go | 7 + cmd/work-cal-sync/main.go | 886 ++++++++++++++++++++++ docs/architecture.md | 251 ++++++ docs/troubleshooting.md | 158 ++++ docs/user-guide.md | 319 ++++++++ go.mod | 17 + go.sum | 18 + internal/sync/caldav.go | 415 ++++++++++ internal/sync/caldav_test.go | 231 ++++++ internal/sync/config.go | 124 +++ internal/sync/config_test.go | 118 +++ internal/sync/diff.go | 98 +++ internal/sync/diff_test.go | 414 ++++++++++ internal/sync/doc.go | 9 + internal/sync/ical.go | 227 ++++++ internal/sync/ical_test.go | 263 +++++++ internal/sync/keyring.go | 54 ++ internal/sync/keyring_test.go | 111 +++ internal/sync/managed.go | 96 +++ internal/sync/managed_test.go | 266 +++++++ internal/sync/plist.go | 265 +++++++ internal/sync/plist_test.go | 428 +++++++++++ internal/sync/prompt.go | 217 ++++++ internal/sync/prompt_test.go | 335 ++++++++ internal/sync/sync.go | 347 +++++++++ internal/sync/sync_test.go | 704 +++++++++++++++++ internal/sync/url.go | 54 ++ internal/sync/url_test.go | 41 + internal/sync/wizard.go | 370 +++++++++ internal/sync/wizard_test.go | 369 +++++++++ pkg/eventkit/Info.plist | 28 + pkg/eventkit/doc.go | 7 + pkg/eventkit/eventkit.go | 259 +++++++ pkg/eventkit/eventkit.h | 125 +++ pkg/eventkit/eventkit.m | 362 +++++++++ pkg/eventkit/eventkit_test.go | 51 ++ 44 files changed, 9036 insertions(+), 41 deletions(-) create mode 100644 .gitignore create mode 100644 .kiro/specs/improve-setup/design.md create mode 100644 .kiro/specs/improve-setup/requirements.md create mode 100644 .kiro/specs/improve-setup/tasks.md create mode 100644 Makefile create mode 100644 cmd/ekprobe/main.go create mode 100644 cmd/ekprobe/probe.m create mode 100644 cmd/work-cal-sync/doc.go create mode 100644 cmd/work-cal-sync/main.go create mode 100644 docs/architecture.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/user-guide.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/sync/caldav.go create mode 100644 internal/sync/caldav_test.go create mode 100644 internal/sync/config.go create mode 100644 internal/sync/config_test.go create mode 100644 internal/sync/diff.go create mode 100644 internal/sync/diff_test.go create mode 100644 internal/sync/doc.go create mode 100644 internal/sync/ical.go create mode 100644 internal/sync/ical_test.go create mode 100644 internal/sync/keyring.go create mode 100644 internal/sync/keyring_test.go create mode 100644 internal/sync/managed.go create mode 100644 internal/sync/managed_test.go create mode 100644 internal/sync/plist.go create mode 100644 internal/sync/plist_test.go create mode 100644 internal/sync/prompt.go create mode 100644 internal/sync/prompt_test.go create mode 100644 internal/sync/sync.go create mode 100644 internal/sync/sync_test.go create mode 100644 internal/sync/url.go create mode 100644 internal/sync/url_test.go create mode 100644 internal/sync/wizard.go create mode 100644 internal/sync/wizard_test.go create mode 100644 pkg/eventkit/Info.plist create mode 100644 pkg/eventkit/doc.go create mode 100644 pkg/eventkit/eventkit.go create mode 100644 pkg/eventkit/eventkit.h create mode 100644 pkg/eventkit/eventkit.m create mode 100644 pkg/eventkit/eventkit_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8704868 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Compiled binaries (repo-root only, not nested paths) +/work-cal-sync +/meeting-alerter +/ekprobe + +# macOS .app bundles produced by the Makefile +/work-cal-sync.app/ +/meeting-alerter.app/ + +# Go build artifacts +*.exe +*.test +*.out + +# Mypy cache +.mypy_cache/ + +# Editor / OS noise +.DS_Store diff --git a/.kiro/specs/improve-setup/design.md b/.kiro/specs/improve-setup/design.md new file mode 100644 index 0000000..0b03f34 --- /dev/null +++ b/.kiro/specs/improve-setup/design.md @@ -0,0 +1,135 @@ +# Design: Rewrite in Go with Improved Setup + +## Overview +A single Go binary with subcommands that replaces the Python+bash setup. The Python implementation stays in the repo as an alternative. + +## Project layout + +``` +work-cal-sync/ +├── cmd/work-cal-sync/ +│ └── main.go # Entry point: subcommand dispatch +├── internal/ +│ ├── eventkit/ # cgo bindings to EventKit.framework +│ │ ├── eventkit.go +│ │ └── eventkit.m +│ └── sync/ # Everything else: config, caldav, wizard, plist, sync logic +│ ├── config.go +│ ├── caldav.go +│ ├── wizard.go +│ ├── plist.go +│ └── sync.go +├── go.mod +├── go.sum +├── Makefile +├── README.md +├── work-cal-sync.py # Preserved Python implementation +├── setup.sh # Preserved Python installer +└── tel.dead.work-cal-sync.plist # Preserved Python plist template +``` + +Two internal packages: `eventkit` (isolated because of cgo) and `sync` (everything else). If `sync` grows large enough to warrant splitting later, that's easy. + +## Key design decisions + +### Single binary, subcommands via standard library `flag` or a small library +- For a handful of subcommands, `flag.NewFlagSet` per subcommand is enough +- Avoid pulling in `cobra`/`urfave/cli` unless the surface grows +- Root `main.go` dispatches on `os.Args[1]` to the appropriate handler + +### cgo only for EventKit +- EventKit has no Go binding and no REST surface, so cgo is required +- Thin ObjC bridge in `eventkit.m` with extern "C" functions +- Go side defines structs and calls bridge functions +- Keychain uses `github.com/zalando/go-keyring` instead of custom cgo + +### CalDAV via go-webdav + go-ical +- `github.com/emersion/go-webdav` for CalDAV protocol +- `github.com/emersion/go-ical` for VCALENDAR/VEVENT generation and parsing +- Custom properties (`X-WORK-CAL-ID`, `X-WORK-CAL-MODIFIED`) attached to VEVENT for tracking + +### Config +- Path: `~/.config/work-cal-sync/config.json` +- Permissions: directory `0700`, file `0600` +- Format: + ```go + type Config struct { + WorkCalendarID string `json:"work_calendar_id"` + WorkCalendarName string `json:"work_calendar_name"` + CalDAVServer string `json:"caldav_server"` + CalDAVUsername string `json:"caldav_username"` + CalDAVCalendarURL string `json:"caldav_calendar_url"` + DaysBack int `json:"days_back"` + DaysForward int `json:"days_forward"` + } + ``` + Stores both the calendar ID (stable) and the name (for display). + +### HTTPS validation +- A small helper checks the parsed URL: scheme must be `https`, OR host must be `localhost`/`127.0.0.1`/`::1` +- No `--allow-http` flag; loopback exception covers the legitimate case + +### launchd plist +- Generated from a Go string template at install time +- Plist label: `tel.dead.work-cal-sync.go` (different from Python version's `tel.dead.work-cal-sync` so they can coexist during migration) +- Path: `~/Library/LaunchAgents/tel.dead.work-cal-sync.go.plist` +- `RunAtLoad=false`, `StartInterval=900`, `KeepAlive=false` +- Log to `~/Library/Logs/work-cal-sync.log` + +### Status command +- Reads config (no password) +- Runs `launchctl list tel.dead.work-cal-sync.go` to check job status +- Tails last ~20 lines of the log file + +## Subcommand dispatch (sketch) + +```go +func main() { + if len(os.Args) < 2 { + runSync() + return + } + switch os.Args[1] { + case "setup": runSetup(os.Args[2:]) + case "install": runInstall(os.Args[2:]) + case "uninstall": runUninstall(os.Args[2:]) + case "status": runStatus(os.Args[2:]) + case "-h", "--help", "help": + printUsage() + default: + // Treat unknown args as flags to the sync command + runSync() + } +} +``` + +## Dependencies + +| Dependency | Purpose | +|-----------|---------| +| `github.com/emersion/go-webdav` | CalDAV client | +| `github.com/emersion/go-ical` | iCalendar parsing/generation | +| `github.com/zalando/go-keyring` | macOS Keychain | +| Standard library + cgo | EventKit bridge | + +## Build + +- `go build ./cmd/work-cal-sync` produces the binary +- `go install github.com//work-cal-sync/cmd/work-cal-sync@latest` for users +- Makefile targets: `build`, `install` (local copy to `~/bin/`), `clean` +- Min Go version: 1.22 +- Requires Xcode Command Line Tools for cgo + +## Coexistence with Python version + +- Different launchd label means both can be installed without conflict +- README will warn against running both pointed at the same CalDAV calendar (they'd fight over the same events) +- A user migrating from Python would: stop the Python launchd job, run `work-cal-sync setup`, run `work-cal-sync install` + +## Risks and mitigations + +| Risk | Mitigation | +|------|-----------| +| EventKit cgo bridge complexity | Keep ObjC layer to data extraction only; all logic in Go | +| `go-webdav` edge cases vs Python `caldav` | Test against Radicale; document supported servers | +| Users accidentally run both implementations | Different plist labels + README warning | diff --git a/.kiro/specs/improve-setup/requirements.md b/.kiro/specs/improve-setup/requirements.md new file mode 100644 index 0000000..30d67f7 --- /dev/null +++ b/.kiro/specs/improve-setup/requirements.md @@ -0,0 +1,197 @@ +# Requirements Document + +## Introduction + +`work-cal-sync` copies work Exchange/O365 calendar events from macOS Calendar.app to a self-hosted CalDAV server. This document captures the as-built Go rewrite that runs alongside the original Python implementation. The original Python script (`work-cal-sync.py`) and bash installer (`setup.sh`) are preserved in the repository for users who prefer not to install Go, but the Go implementation is the recommended path. + +A separate spec (`meeting-alerter`) covers an unrelated tool that shares the EventKit bridge but is otherwise independent. Meeting-alerter is **out of scope** for this document; only the calendar sync tool and its diagnostic helper are described here. + +## Glossary + +- **CalDAV**: RFC 4791 calendar protocol used to talk to self-hosted servers like Radicale, Nextcloud, and Baikal. +- **EventKit**: macOS framework for reading and writing local calendar data, including calendars synced from Exchange/O365 accounts configured in **System Settings → Internet Accounts**. +- **launchd**: macOS service manager. A LaunchAgent plist runs the sync binary on a 15-minute schedule. +- **TCC**: Apple's Transparency, Consent, and Control privacy subsystem. Calendar access permissions are recorded against a process's **bundle id**, not its binary path, which is why this tool ships as a `.app` bundle rather than a bare binary. +- **`.app` bundle**: macOS application directory layout (`Foo.app/Contents/Info.plist`, `Foo.app/Contents/MacOS/Foo`). Required so TCC honors the Calendar permission grant. +- **Loopback host**: `localhost`, `127.0.0.1`, or `::1`. The wizard allows plain HTTP for these only. +- **Managed event**: A CalDAV event created by this tool, identified by the custom iCalendar properties `X-WORK-CAL-ID` and `X-WORK-CAL-MODIFIED`. + +## Architecture (as built) + +Go module: `github.com/djdead/work-cal-sync` + +```text +cmd/ + work-cal-sync/ main binary: sync, setup, install, uninstall, status, + plus hidden bundle-routing subcommands + ekprobe/ diagnostic helper for verifying EventKit access +pkg/ + eventkit/ cgo bridge to EventKit.framework + Info.plist used + by the .app bundle +internal/ + sync/ config, caldav, ical, keyring, plist, prompt, url, + wizard, diff, managed, sync engine +work-cal-sync.app/ built .app bundle (gitignored, produced by `make app`) +work-cal-sync.py preserved Python implementation +setup.sh preserved Python installer +tel.dead.work-cal-sync.plist preserved Python launchd template +``` + +The Python implementation continues to work. The Go implementation uses a different launchd label (`tel.dead.work-cal-sync.go`) so both can be installed without conflict during migration, though they should not point at the same CalDAV calendar. + +## Requirements + +### REQ-1: Single binary with subcommands + +- **What**: One Go binary at `cmd/work-cal-sync/` that handles `sync`, `setup`, `install`, `uninstall`, and `status` via subcommands. +- **Why**: Simpler to build, distribute, and document than multiple binaries for a tool of this size. +- **Acceptance criteria**: + - Default invocation (no args) runs sync; this is the path launchd takes. + - `setup`, `install`, `uninstall`, `status` are public subcommands. + - `help`, `-h`, `--help` print usage to stdout and exit 0. + - Unknown args are passed through to the sync command rather than rejected (forward-compat for sync flags). + +### REQ-2: Bundle-routing subcommands + +- **What**: Hidden subcommands (`request-access`, `list-calendars-internal`, `fetch-events-internal`) exist so the main binary can re-invoke itself inside the `.app` bundle to perform EventKit work, then read results back through a temp file. +- **Why**: macOS attaches Calendar (TCC) permissions to a bundle id, but only honors them when the process is launched via Launch Services (`open -a Foo.app`). A direct shell exec of the binary inside the bundle is attributed to the shell, not the bundle, and silently returns no calendars. Routing through `open -W -n -a … --args …` makes Launch Services attribute the work to the bundle. +- **Acceptance criteria**: + - `request-access` calls `eventkit.RequestAccess` and exits 0/1/2 (granted/error/denied). + - `list-calendars-internal ` writes the calendar list as JSON. + - `fetch-events-internal ` writes events as JSON. + - These subcommands are not advertised in `help` output. + - The main binary's `setup` and default `sync` paths use bundle-routed helpers (`listCalendarsViaBundle`, `fetchEventsViaBundle`) when running outside the bundle. + +### REQ-3: `.app` bundle distribution + +- **What**: The Go binary is shipped as a code-signed macOS `.app` bundle at `~/Applications/work-cal-sync.app`. +- **Why**: TCC will not grant Calendar access to a bare binary, and a stable code-signing identity prevents TCC from treating each rebuild as a new app and re-prompting. +- **Acceptance criteria**: + - `make app` produces `work-cal-sync.app/` with `Contents/MacOS/work-cal-sync`, `Contents/Info.plist` (with `NSCalendarsFullAccessUsageDescription`), and `Contents/PkgInfo`. + - The bundle is signed with `codesign --identifier tel.dead.work-cal-sync` using a developer identity (defaults to the first `Apple Development:` identity in the keychain; falls back to ad-hoc `-` if none). + - `make install-app` copies the bundle to `~/Applications/work-cal-sync.app`. + - Bundle-routing helpers in the main binary look for the bundle in `~/Applications` first, then `/Applications`, and fail with a clear "run `make install-app`" message if neither exists. + +### REQ-4: Sync logic + +- **What**: Sync work events to a CalDAV server using rsync-delete semantics. +- **Why**: Keep the CalDAV calendar an exact mirror of the work calendar inside the configured time window. +- **Acceptance criteria**: + - Reads work events via the EventKit bridge (`pkg/eventkit`). + - Writes to CalDAV via `github.com/emersion/go-webdav` and `github.com/emersion/go-ical`. + - Each managed VEVENT carries `X-WORK-CAL-ID` (EventKit external id) and `X-WORK-CAL-MODIFIED` (Unix seconds). + - Diff produces three sets: create (in work, not in CalDAV), update (in both, work modified later), delete (in CalDAV, not in work). + - Sync window is `[now - DaysBack, now + DaysForward]` from config. + - Logs counts and per-event actions via `log/slog`. + +### REQ-5: Setup subcommand + +- **What**: `work-cal-sync setup` runs the interactive configuration wizard. +- **Why**: Co-locating with the sync binary lets the wizard reuse the same EventKit and CalDAV code paths. +- **Acceptance criteria**: + - Lists Exchange/O365 calendars via the bundle-routed EventKit helper. + - Prompts for CalDAV server URL, username, password (password via terminal echo-off). + - Rejects URLs that aren't `https://` unless the host is loopback (`localhost`, `127.0.0.1`, `::1`). + - Lists calendars from the CalDAV server for selection. + - Prompts for `DaysBack` (default 7) and `DaysForward` (default 90). + - Saves config to `~/.config/work-cal-sync/config.json` with directory `0700` and file `0600`. + - Saves password to macOS Keychain via `github.com/zalando/go-keyring` (service `work-cal-sync`, account = CalDAV username). + - When config already exists, current values are shown as defaults and Enter keeps them. + +### REQ-6: Install subcommand + +- **What**: `work-cal-sync install` installs the launchd plist and loads the job. +- **Why**: Users shouldn't write plists by hand or remember `launchctl` syntax. +- **Acceptance criteria**: + - Resolves the running binary path with `os.Executable` and `filepath.EvalSymlinks`. + - If no config exists, runs `setup` first. + - Generates the plist from an in-code Go template (no external files); fields: `Label`, `BinaryPath`, `LogPath`. + - Plist label: `tel.dead.work-cal-sync.go` (distinct from Python version). + - Plist location: `~/Library/LaunchAgents/tel.dead.work-cal-sync.go.plist`. + - Plist values: `StartInterval=900`, `RunAtLoad=false`, `KeepAlive=false`, stdout and stderr redirected to `~/Library/Logs/work-cal-sync.log`. + - Atomic write: temp file in same directory, rename over destination. + - Idempotent: unloads any existing job before loading, so re-running is safe. + +### REQ-7: Uninstall subcommand + +- **What**: `work-cal-sync uninstall` removes the launchd plist and config. +- **Why**: Users shouldn't have to remember multiple manual cleanup commands. +- **Acceptance criteria**: + - Lists what will be removed and asks for y/n confirmation (default `no`). + - Unloads the launchd job (idempotent: succeeds even if not loaded). + - Removes the plist file (idempotent on missing file). + - Removes the entire `~/.config/work-cal-sync/` directory. + - Does **not** remove the binary (user may have installed via `go install`, Homebrew, copy, etc.). + - Does **not** delete the Keychain entry; instead prints the `security delete-generic-password -s work-cal-sync -a ` command. + - Failures during removal are logged but don't abort the rest of the uninstall. + +### REQ-8: Status subcommand + +- **What**: `work-cal-sync status` shows current state. +- **Why**: Quick way to verify configuration and recent activity without opening multiple files. +- **Acceptance criteria**: + - Prints config path and pretty-printed JSON contents (no password — `Config` has no password field). + - Prints launchd state by running `launchctl list