Architecture
Capycal follows a pragmatic ports-and-adapters layout. The crate is a library (src/lib.rs) plus three binaries: the capycal CLI (src/main.rs), the capycal-ui menu bar app for macOS (src/bin/capycal-ui/, feature ui) and the capycal-gtk tray app for Linux (src/bin/capycal-gtk/, feature gtk), which reuse the same sync engine. The domain core (iCal processing, sync loop, etag diff, UID mapping) knows nothing about CalDAV, EventKit, the file system or the config file format. Adapters plug into small traits (ports).
Dependency rule
ical/, sync/runner.rs, sync/diff.rs, sync/status.rs and state/mapping.rs must not depend on config, the adapters (caldav, eventkit), reqwest or std::fs. Config is translated into domain types (RunnerSettings, Transform) at the edge, in the coordinator. Time is passed in as a parameter instead of read from the wall clock.
Source tree
src/
lib.rs Library root: module tree and the public API for front ends
main.rs The `capycal` binary: parse the CLI and hand over to `cli::execute`
bin/capycal-ui/ The menu bar app (feature `ui`, macOS only; a stub elsewhere)
main.rs Module gating, stub main on other platforms
args.rs -c / --config of both desktop apps
app.rs macOS glue: tao event loop, tray menu, dialogs (rfd)
text_window_macos.rs macOS glue shared by the windows: toolbar row above a read-only text view, focus, close
log_window_macos.rs macOS glue: the native AppKit log window
panel_window_macos.rs macOS glue: header, buttons and text ("Problems")
about_window_macos.rs macOS glue: About window like libadwaita's (built-in icon, links)
dialog_window_macos.rs macOS glue: centered dialog (icon, heading, spinner, message, links, popup, buttons, footnote) changed in place
backend.rs Config, ConfigLock and Coordinator on the tokio thread; commands in, updates out; applies a SettingsChange (purge, keychain copy, ConfigEditor save, keychain cleanup, reload, resume)
menu.rs Menu model from a snapshot, status lines, relative time, menu bar icon state
problems.rs Problems window text: current problems in full plus recent warnings and errors
dialog.rs Dialog contents (heading, message, buttons, links, spinner) shared by the dialog models
purge.rs Remove Synced Events dialog model: counting, choosing the syncs, confirm, removing, result
purge_all.rs Remove All Events dialog model: counting, warning, confirm by typing the calendar's name, removing, result
settings_view.rs Settings window as a display-free view (SettingsView of typed controls with stable ids) and its input handling; the glue only draws it
preview.rs the Settings preview: a sample event and the copy a sync makes of it (SyncConfig::preview)
settings_window_macos.rs macOS glue: sidebar and editor drawn from a SettingsView, updated in place while its shape stays
settings.rs Settings model: syncs and accounts of the config, selection, drafts, changes on disk; save and delete build one SettingsChange
sync_form.rs Sync form: texts, field errors, fields, transforms, what a save does to existing copies
transform_form.rs Transform forms: fields per action, parsing and errors, titles and summaries
account_form.rs Account form: URL, user name, password in the keychain (KeychainOp)
picker.rs Calendar picker: calendars per account and Calendar.app, matched with [calendars] entries, key proposals
sponsor.rs Ways to sponsor (OPTIONS, logos in assets/), when to ask, its dialog and answers
logview.rs Log view model (filter, follow, trimming, line colors), export
autostart.rs Start at Login: SMAppService, LaunchAgent fallback
bin/capycal-gtk/ The Linux tray app (feature `gtk`), sharing the models above by #[path]
app.rs GTK glue: application, backend thread, actions, Problems, purge and sponsor dialogs
tray.rs StatusNotifierItem tray via ksni (Linux only)
tray_icon.rs Tray icon pixels: badge and fade per state, ARGB
log_window.rs Log window on a GtkTextBuffer, driven by logview
settings_window.rs Settings window drawn from the shared SettingsView (DropDowns, entries, toggle buttons), updated in place
text_window.rs Heading, header buttons and read-only text ("Problems")
dialog_window.rs adw::Dialog built from dialog::View, changed in place
about_window.rs About on one page like the macOS window (tagline, links)
menu_window.rs The menu as a window: no tray, a second start, macOS
icons.rs App and symbolic icons for the icon theme when not installed
autostart.rs Start at Login: XDG autostart entry
cli/
mod.rs CLI flags, dispatch, logging init, CliError, config loading, --validate, --only
discover.rs --discover (CalDAV) and --discover-eventkit, snippet formatting shared with the app
generate.rs --generate-config starter config
purge.rs --purge and --purge-all (with its confirmation)
status.rs --status: lock holder and per-sync state, read-only
service.rs --install-service / --uninstall-service: systemd user unit
run.rs --once, --full-sync and daemon mode with signal handling
config_watch.rs ConfigWatcher: debounced notifications when the config file changes
logging.rs LogBuffer tracing layer and file + buffer logging for front ends
config.rs TOML config types, validation, transform compilation, credential resolution
config_edit.rs ConfigEditor: writes accounts, calendars and syncs into capycal.toml for the desktop apps (toml_edit, keeps comments and order, validates before an atomic save)
source.rs EventSource / EventSink ports
error.rs Error types
caldav/
client.rs CalDAV HTTP client (PROPFIND, REPORT, PUT, DELETE, retry policy); discovery reads privileges and component sets
auth.rs ResolvedAuth (Basic auth credentials)
source.rs CalDavSource + CalDavSink (port implementations)
eventkit/
client.rs macOS EventKit adapter (cfg(target_os = "macos")): reads/writes EKEvent
convert.rs Pure EventKit <-> iCal conversion, etag and notes marker (all platforms)
tzid.rs TZIDs NSTimeZone does not know: Windows and prefixed names, VTIMEZONE offsets (all platforms)
ics/
mod.rs IcsSource: read-only iCalendar feed (ics_url), one GET split into events per UID
ical/
mod.rs Processing pipeline, line unfolding, shared iCal helpers
filter.rs Field filtering (STRUCTURAL_FIELDS always, `fields` on top), VTIMEZONE carry-over
timezone.rs VTIMEZONE offsets: local <-> UTC for zones the event defines (buffers, unknown TZIDs)
transform.rs Transform (action: set, regex, remove, buffer, alarm, skip; conditions) and application
marker.rs X-CAPYCAL-SOURCE/SYNC stamping and detection
sync/
runner.rs Generic sync loop (source -> transform -> sink) and full sync
diff.rs Etag-based changed/removed detection shared by sources
status.rs SyncStatus, CycleStats and the StatusReporter port
coordinator.rs Multi-sync management, shared semaphores, lifecycle, reload
maintenance.rs Purge: find a sync's sink events by marker, delete them, clear state;
the next source event for the Settings preview
factory.rs AnySource / AnySink, adapter construction (the one place to add a backend)
state/
mod.rs SyncState and the StateStore port
store.rs Atomic JSON file store, moves corrupt files aside
lock.rs ConfigLock: one process per config, sync name collision check
mapping.rs Source UID -> sink UID/href mapping
Library and binaries
The modules are private to the library. lib.rs re-exports only what a front end needs (Config, Coordinator, Error, …), so the compiler still reports unused code inside the modules. The CLI lives in the library too (cli::execute), which keeps main.rs a few lines long. The library and the default build have no GUI dependencies; a GUI binary brings its own as optional dependencies behind a feature.
Ports
#![allow(unused)]
fn main() {
pub trait EventSource: Send + Sync {
/// Changed events and removed hrefs since the last sync.
fn fetch_changes(&self, state: &mut SyncState)
-> impl Future<Output = Result<(Vec<SourceEvent>, Vec<String>)>> + Send;
}
pub trait EventSink: Send + Sync {
fn put_event(&self, href: &str, ical_data: &str)
-> impl Future<Output = Result<PutResult>> + Send;
fn delete_event(&self, href: &str) -> impl Future<Output = Result<()>> + Send;
/// The events this sync created in the sink, found by marker.
fn list_synced(&self, sync_name: &str, source_name: &str)
-> impl Future<Output = Result<Vec<SinkEvent>>> + Send;
/// Hrefs changed since `token` and a new token; None if the sink cannot tell.
fn changes_since(&self, token: Option<&str>)
-> impl Future<Output = Result<Option<SinkChanges>>> + Send { ready(Ok(None)) }
/// Whether an in-flight put may be abandoned on shutdown (default false).
fn cancel_safe(&self) -> bool { false }
}
pub trait StateStore: Send + Sync {
/// None if missing, or corrupt and moved aside.
fn load(&self, sync_name: &str) -> Result<Option<SyncState>>;
fn save(&self, sync_name: &str, state: &SyncState) -> Result<()>;
fn remove(&self, sync_name: &str) -> Result<()>;
}
pub trait StatusReporter: Send + Sync {
fn report(&self, status: &SyncStatus);
}
}
The sync’s target calendar in the config is the sink in the code. CalDAV and EventKit implement both EventSource and EventSink, so every combination works: CalDAV to CalDAV, EventKit to CalDAV, CalDAV to EventKit, EventKit to EventKit. A feed (ics::IcsSource) is only an EventSource: validation rejects it as a target and build_sink refuses it. AnySource / AnySink in sync/factory.rs are enums that dispatch to the concrete adapter, so the runner stays generic without dyn.
Status and sync now
A running sync reports its SyncStatus (running flag, start time of the last cycle, and either the CycleStats with created/updated/deleted/error counts or the error message) through the StatusReporter port when a cycle starts and when it ends. A full sync also reports its progress in SyncStatus::full_sync (FullSyncProgress: changes written of a total that is known once source and sink are read) and counts finished full syncs in full_syncs, so the desktop apps can follow a requested full sync even when a normal cycle runs first. The coordinator implements the port with a tokio::sync::watch channel holding the status of all syncs; Coordinator::status() subscribes to it. Coordinator::sync_now(name) wakes one sync (or all with None) through a per-runner Wake, so the next cycle starts without waiting for the interval; Coordinator::full_sync(name) does the same and sets the Wake’s flag, so that cycle is a full sync. The CLI uses neither.
Coordinator::pause(name) stops one sync and remembers its name in memory; its status stays in the list with paused set. start and reload skip paused syncs and forget the names of syncs no longer configured, so a pause survives reloads but not a restart of the process. Coordinator::resume builds and spawns the runner again, sharing the sink semaphores of the running config.
Markers and state as a cache
Every event Capycal creates carries a marker naming the sync and the source event:
- CalDAV (and iCal in general):
X-CAPYCAL-SOURCE:<source calendar>/<source uid>andX-CAPYCAL-SYNC:<sync>(ical/marker.rs). Sink hrefs are deterministic (capycal-<uuidv5(sync, source uid)>.ics). - EventKit keeps no custom properties, so the sink writes a notes line
[capycal:sync=<sync>;source=<source calendar>;uid=<source uid>], values percent-encoded where they contain%,;,=,]or line breaks (eventkit/convert.rs:NotesMarker,find_marker,with_marker). The legacy[capycal:<source>]line of older versions is still read everywhere; every put replaces all marker lines with the current one. Reading an EventKit event with either marker emitsX-CAPYCAL-SOURCE, so a bidirectional setup skips it (loop prevention). A legacy marker names no sync, so purge and listing attribute it by source.
EventSink::list_synced returns the events of one sync from the sink (SinkEvent: href, sink UID if known, source UID from the marker, stored iCal if available). CalDAV lists the calendar, fetches it with a multiget and keeps the events with exactly this X-CAPYCAL-SYNC (caldav::source::synced_events, shared with purge). EventKit queries the sink calendar 20 years back and 4 ahead in chunks, deduplicated by identifier, and returns no UID and no content.
So the state file (SyncState: cursor, UidMapping, pending_removals, last_full_sync) is a cache: everything in it can be rebuilt from the source and the sink markers.
Full sync
The runner’s cycle picks an incremental cycle or a full sync (full_sync_at, internally the reconcile). A full sync runs when
- the state is missing or corrupt:
FsStateStore::loadmoves a file it cannot parse to<name>.json.corrupt-<unix seconds>and logs a warning; without a state the runner setslast_full_syncto the Unix epoch, meaning “never”; full_sync_interval(per sync, default 7 days) passed sincelast_full_sync. A state saved before the field existed (None) is set to the first cycle’snowand saved, so an upgrade does not fully sync everything at once;- it was requested:
Wake::full_sync(Coordinator::full_sync, the app’s Full Sync,--full-syncin daemon mode) orSyncRunner::full_sync_once(--full-sync --once); - the last one did not complete:
full_sync_atsetsSyncState::full_sync_pendingwhen it starts and clears it on completion, so a cancelled or failed full sync, even a requested one, runs again in the next cycle. A disabled sync getsfull_sync_pendingtoo (mark_disabledin the coordinator), so enabling it starts with a full sync; - the sink changed elsewhere:
changed_elsewhereasksEventSink::changes_since(SyncState::sink_token)at the start of a cycle and compares the changed hrefs by file name with the mapping’s sink hrefs. One of them, or an expired token (Error::SyncTokenExpired), makes the cycle a full sync. After every cycle that wrote,note_own_writesmoves the token past the cycle’s own writes, so they don’t count. The CalDAV sink answers with a sync-collection REPORT withoutcalendar-data(CalDavClient::sync_collection_hrefs); a server without sync-collection and the EventKit sink answerNone, which skips the check. This lets several computers run the same sync with their own state; - the output settings changed:
RunnerSettings::output_key(fromSyncConfig::output_key: source and target calendar,fields, transforms, EventKit window) differs fromSyncState::output_key, which a completed full sync stores. A state without one takes the current key, so an upgrade does not fully sync everything at once.
Algorithm:
- Fetch the source against an empty
SyncState: without cursor and mapping, CalDAV (sync-collection without token or the ctag/etag fallback), EventKit and feeds (EtagDiffagainst no known etags, a feed without its HTTP validator) report every current event. Thenlist_syncedthe sink. Both are cancellable and change nothing. rebuild_mapping: group the sink events by source UID (a legacy EventKit event without UID is attributed through the old mapping by sink href, compared by last path segment because CalDAV listings are absolute). Old entries stay unless the sink names another source UID for their event (they may point outside the listing range). Of several sink events for one UID keep the one in the old mapping, else the one at the deterministic href, else the first; the others and unattributed legacy events are “stray” and deleted directly. A rebuilt entry without source event gets the source hreforphan:<sink href>, so its removal goes through the normal path.- Every mapped source UID that is no longer in the source, and every mapped event the transforms now reject, is removed with the usual guarantees: the mapping entry is dropped after a successful delete, a failed delete goes to
pending_removals. - Every other source event is processed.
list_syncedreturns with each sink event what the sink keeps of it (EventSink::Stored), andEventSink::is_unchangedtells whether a put would change it. If it would not, only the mapping is updated; otherwise the event is put to its mapped href (update in place) or created. CalDAV keeps the stored iCal and compares withical::same_content(lines in order after unfolding, ignoring line endings, blank lines andDTSTAMP,LAST-MODIFIED,CREATED,SEQUENCE). EventKit keeps the fieldsapply_updatesets (convert::StoredEvent, read while listing) and compares them withconvert::is_unchanged: title, location, notes including the marker line (a legacy marker counts as a change), start and end as instants (all-day boundaries as local dates, aDURATIONend from the start), the all-day flag, alarms as a set, and availability only if the calendar supports it. Fields the iCal leaves out are not compared, since a put keeps them too. - Puts, deletes and stray deletes share
pushwith the incremental cycle. On success the cursor from step 1 andlast_full_sync = noware saved. If a put failed, the cursor stays empty, so the next cycle fetches everything again. A cancelled full sync saves its progress (mapping, pending removals) with an empty cursor, withoutlast_full_syncand withfull_sync_pending, so the next cycle runs it again and nothing is duplicated.
Purge
sync::maintenance::count_synced_events and purge_sync find the events one sync created in its sink: on CalDAV by listing the calendar, fetching the events with a multiget and keeping those whose X-CAPYCAL-SYNC is exactly the sync’s name; on EventKit by the notes marker naming the sync, or for a legacy marker the sync’s source (count_events_by_marker, delete_events_by_marker, sharing synced_events with the sink listing). EventKit calls run in spawn_blocking, each inside an autoreleasepool. purge_sync deletes with the sink’s concurrency (delete_concurrently), reports the number of handled events to a Progress callback (the menu bar app sends it throttled as Update::PurgeProgress), counts single failures and then removes the sync’s state through the StateStore. --purge and the menu bar app share it; Coordinator::purge pauses the sync first and resumes it if the sink could not be read. count_calendar_events and purge_calendar do the same for every event of one calendar (CalDAV list_events, EventKit count_all_events / delete_all_events) and remove the state of every sync with that target; --purge-all and the menu bar app’s Remove All Events share them, and Coordinator::purge_calendar pauses the syncs writing into the calendar meanwhile. next_source_event reads a sync’s source with an empty state and returns the first event starting at or after now (by DTSTART), for the Settings window’s preview.
Config lock
Two processes running the same config would write the same state files and create duplicates. ConfigLock::acquire(config, config_path) takes an exclusive flock (File::try_lock) on <state_dir>/locks/<hash>.lock, where the hash is a truncated UUIDv5 of the canonical config path (stable across builds, unlike std::hash). The file holds pid, config path and sync names; the lock is released when the ConfigLock is dropped or the process exits, and the file is left in place. After locking its own file, a process probes the other lock files with a shared lock: one that is still held and names a sync of the same name means another config shares the state directory and a state file, so acquiring fails. Both processes write their own file before probing, so of two configs starting at once at least one sees the other. The CLI locks for daemon mode, --once, --full-sync, --purge and --purge-all; its sync names are recorded at start and not updated on reload. ConfigLock::update rewrites the names of a held lock (and checks them for collisions, keeping the old names on failure); the menu bar app calls it on every reload and holds the lock for its lifetime.
Config watcher
ConfigWatcher::new(path) returns the watcher and a tokio::sync::mpsc::Receiver<()>; changes are reported while the watcher is alive. It uses notify (inotify on Linux, FSEvents on macOS), ignores access events (inotify reports the open of every reload, which would loop), debounces on a small thread (a change is reported 500 ms after the last event) and watches the parent directory non-recursively, filtering by file name. That way a replace by rename (how TextEdit, vim and VS Code save), deletion and re-creation are seen, which a watch on the file itself would miss after the first rename. Several changes in a row become one message (the channel holds at most one). The CLI daemon creates a watcher when watch_config is set and turns a change into the same reload as SIGHUP. The menu bar app always watches, creating the config directory first so a config written later by “Config > Edit” is picked up. Capycal never writes the config itself except the starter config and the commented calendar list of “Config > Edit”, whose reloads are harmless.
Log buffer
LogBuffer is a tracing Layer that formats each event into a LogLine (timestamp, level, target and the full text with message and key=value fields), keeps the last N lines in a ring buffer and sends new lines through a tokio::sync::broadcast channel. subscribe() returns the backlog and a receiver under one lock, so a live log view has no gap or overlap; snapshot() is the export fallback when the log file cannot be read. init_with_buffer(level, file, capacity) installs the global logger of a front end: a LogFile (default_log_file("capycal-ui") is ~/Library/Logs/capycal/capycal-ui.log on macOS) plus the buffer, and returns both.
LogFile is the file writer (MakeWriter over a Mutex; tracing-appender only rotates by time). Opening it rotates a non-empty file away, so each session starts a fresh file; a write that would push the file over LOG_FILE_MAX_SIZE (10 MB) rotates it first, so an event is never split across files. Rotation shifts <file> to <file>.1, .1 to .2 and so on and deletes .5 (LOG_FILES_KEPT). The writer counts its rotations since start, so session_content() (the export of the menu bar app) concatenates <file>.<rotations> down to .1 and the current file: the whole unfiltered session, without earlier sessions. It reads under the lock, so no rotation happens in between. Parts deleted by the retention are replaced by a note in the first line. The CLI keeps its own stdout logging (pretty or JSON).
Menu bar app
capycal-ui is a driving adapter like the CLI: it only uses the library’s public API (Config, ConfigLock, Coordinator, LogBuffer, …). Its GUI crates (tao, tray-icon/muda, rfd, objc2-service-management, objc2-app-kit) are optional dependencies enabled by the ui feature and declared for macOS only, so cargo build never compiles them. On other platforms capycal-ui is a stub that exits with a message; its display-free modules still compile under cfg(test), so their tests run on Linux.
Threading:
main thread (AppKit) "capycal-sync" thread (tokio runtime)
tao event loop Backend::run
tray icon + muda menu --- Command ------> Config::load, ConfigLock, Coordinator
log window (AppKit) <-- Snapshot ----- status watch channel (throttled 300 ms)
rfd dialogs (EventLoopProxy user event)
- AppKit requires the event loop, the tray icon and the windows on the main thread. The tray icon is created on
StartCause::Init.ActivationPolicy::Accessory(andLSUIElementin the bundle) hides the Dock icon. - Menu clicks arrive through
MenuEvent::set_event_handler, forwarded as user events. Menu item ids are stable strings (menu::Action::id), so the menu can be rebuilt frommenu::build(snapshot, start_at_login, now)on every snapshot and every 30 seconds (for “synced N min ago”). - The app writes the starter config at start if the file is missing. A config without syncs is no
Problem; the menu shows it as not configured yet. - The backend owns config, lock and coordinator and handles
SyncNow,FullSync,Reload,EditConfig,CountPurge,Purge(which disables the purged syncs in the config afterwards),CountCalendar,PurgeCalendar(the same for all syncs writing into the calendar),SetEnabled, the Settings commands (DiscoverAccount,TestAccount,ListCalendarApp,SaveSettings) andShutdown. It answers withUpdates: aSnapshotafter every change,ConfigChangedafter every reload, andConfigReady,PurgePreview,PurgeProgress,CalendarCounted,CalendarPurgeProgress,CalendarPurged,AccountCalendars,AccountTested,CalendarAppCalendarsandSettingsSavedfor the commands that need an answer.ListCalendarAppcalls EventKit (the access prompt may block for minutes), so it runs inspawn_blocking; keychain and credential lookups do too; counting and purging run in the command loop, with anactivityline in the snapshot meanwhile. The confirmations stay on the main thread: Full Sync asks in a dialog before sending the command, Remove Synced Events opens its window before counting and enables Remove Events when thePurgePreviewarrives. A missing, invalid or locked config is aProblemin the snapshot, not a fatal error: the app keeps running and “Config > Reload” or a change of the config file retries. A failed reload leaves the running syncs and the lock’s sync names as they were. - Log window: a native AppKit window (no WebKit, which would start helper processes and grow the memory). It is split into a platform-independent model and thin platform glue, so a later GTK window (
GtkTextView) can reuse the model:logview::LogViewholds the received lines (at most the buffer capacity), the level and search filter and the follow state. Every operation returns aChange: reset the text, trim N UTF-16 units from the start, append runs of text with oneTone(error, warn, normal, dim) each, scroll to the end. New lines are only appended; a filter change or “Clear View” rebuilds. The view may grow to capacity plus a tenth before the oldest lines are trimmed, so trimming is not per line. Follow turns off when the view is scrolled up away from the end and on again when it is scrolled down to the end; resizing and trimming do not move the offset up, so they leave it alone.text_window_macos.rsholds what the windows share:TextWindowcreates the window with a toolbar row pinned to the top andNSTextView::scrollableTextViewfilling the rest (Auto Layout anchors), makes the text read-only and selectable, brings the window to the front (focus) and clears the delegate and closes the window when dropped. Each window keeps its owndefine_class!target and clears the targets of its controls in itsDrop, before theTextWindowis dropped.panel_window_macos.rs(PanelWindow) is a header, a row of buttons (reported asPanelInput::Button(index)through the sender’s tag) and a monospaced text;set_textkeeps the scroll position when the text is unchanged. “Problems” showsproblems::textwith Edit Config, Reload Config and Show Log, or an empty state (problems::nothing_to_show) without problems and recent warnings; the app refreshes it with the menu and opens it onUpdate::ReloadFailed(a config or lock error while loading). “Remove Synced Events” opens with “Counting synced events” and a disabled Remove Events button, sendsCommand::CountPurgeand fills inPurgePreview::confirmationwhen the preview arrives; a preview for a window closed in the meantime is ignored. The text view is the first responder, so Cmd-A and Cmd-C work through the Edit menu.log_window_macos.rsbuilds the log window on aTextWindow(toolbar row withNSSearchField, levelNSPopUpButton, “Follow” checkbox, “Clear View” and “Export” buttons; the search field takes the free width with a minimum of 240 pt) and appliesChanges to the text storage withNSAttributedStringruns (monospaced system font, semantic colors for dark mode). Onedefine_class!object (CapycalLogTarget) is the controls’ target, the observer ofNSViewBoundsDidChangeNotificationon the clip view and the window delegate. It only sends alogview::Inputas a user event; the event loop then reads the control values and calls the model, so nothing re-enters the window from an AppKit callback.LogBuffer::subscribe()gives the backlog and a broadcast receiver. While the window is open the event loop wakes every 250 ms and drains the receiver; lagging behind the broadcast inserts a “lines skipped” marker. Closing the window (windowWillClose:) drops it with its views and the receiver; AppKit holds the target only weakly, and the drop clears the targets, the delegate and the observer. Showing it again starts from the backlog. Showing the window activates the app withactivateIgnoringOtherAppsand makes the window key (the search field is the initial first responder): an accessory app is not activated by opening a window, and a window that is not key takes no text input.activate()is not enough, since it may decline under cooperative activation (macOS 14).
- Quit hides the icon and windows, sends
Shutdownand waits up to 35 seconds for the syncs to stop (longer than the coordinator waits for one sync). - Start at Login uses
SMAppService.mainAppinside an app bundle and falls back to a LaunchAgent plist (app.capycal-ui) otherwise or on error, logging which method was used.
Packaging lives in packaging/macos/: build-app.sh assembles Capycal.app (capycal-ui as CFBundleExecutable, the capycal CLI next to it, Info.plist with the calendar usage strings and LSUIElement) and signs it ad hoc; build-dmg.sh makes the release disk image.
Both apps share dialog::layout (margins, dialog width, text and log window sizes) and follow the platform guides for dialogs: the last button is the default unless View::destructive, which shows it in red and makes Cancel the default; dialogs have no window title, their heading says it; window titles name the menu item (“Log”, “Problems”, “Settings”). Errors on macOS use the app’s dialog (App::show_error), the GTK app uses adw::AlertDialog. A View::choice is a popup above the buttons (NSPopUpButton, gtk::DropDown); choosing an item reports it as button FIRST_CHOICE plus its index, like the links with FIRST_LINK, so the dialog models handle it in press. View::lines follow the message as “label: text” with a bold label (View::runs; an attributed string on macOS, Pango markup in GTK).
Linux desktop app
capycal-gtk (feature gtk, binary in src/bin/capycal-gtk/) is the same driving adapter with GTK 4 and libadwaita windows. It includes the display-free modules of capycal-ui with #[path] (args, backend, dialog, logview, menu, problems, purge, sponsor), so both apps share the menu model, icon state, Problems text, log view model and the dialog models; each uses only part of them, hence allow(dead_code) on those includes.
main thread (GLib main loop) "capycal-sync" thread (tokio) ksni thread
adw::Application (held, no main window) Backend::run StatusNotifierItem
windows, dialogs --- Command ------------> + dbusmenu over D-Bus
async_channel <-- Update -------------- menu click -> Action
async_channel <-- Action, host online/offline ----------------------------
- The tray is a StatusNotifierItem (
ksni, blocking API with its own runtime thread), Linux only.Tray::updatereplaces the menu items, icon state and tooltip; the D-Bus menu is updated in place, so an open menu stays open. A left click opens the menu (MENU_ON_ACTIVATE). The icon is a symbolic name permenu::Icon(capycal-tray-*-symbolic, SVGs with the GTKerrorand KDEColorScheme-*classes, so GNOME and Plasma draw them in the panel’s colors), withicons::cache_diras theme path when not installed; the app icon as ARGB pixmaps with a red or blue dot (tray_icon.rs) is the fallback.assume_sni_availablelets the app start before the panel; if no StatusNotifierWatcher appears within ten seconds, the app opensmenu_window.rs(themenu::builditems as libadwaita rows, rebuilt when they change) with a banner about the GNOME extension. The same window opens when the app is started a second time (GApplicationactivate) and, on macOS, at start. - The GApplication id
app.capycalmakes the app single-instance. Windows (log_window.rs,text_window.rs,settings_window.rs) report back through callbacks that run on the main loop;defermoves their work to an idle callback, so a window callback never re-enters a borrowedApp; Quit drops the windows (destroying them without close callbacks) before it waits for the syncs. The log window renderslogview::Changes into aGtkTextBufferwith tags (it converts the model’s UTF-16 trim counts to characters) and polls the broadcast receiver every 250 ms. - Dialogs are an
adw::Dialog(dialog_window.rs) built fromdialog::View, sinceadw::AlertDialogcloses on every response: Remove Synced Events, Remove All Events, Sponsor and the Full Sync question (dialog::full_sync_view, with the two circular arrows like the macOS app’sarrow.triangle.2.circlepath). Errors and “already running” areadw::AlertDialogs. About is anadw::Dialog(about_window.rs) laid out like the macOS About window, so the tagline (dialog::quip) and the links (dialog::about_links) are on its only page;adw::AboutDialogputs both on a Details page. - Icons: packages install them into
/usr/share/icons/hicolor; otherwiseicons::installwrites the app icon and the symbolic SVGs (heart, calendar with minus, the GitHub mark of the sponsor link) into the user cache and adds it to the icon theme search path. - Start at Login writes an XDG autostart entry (
autostart.rs); the sponsor answers are akey=valuefile (SponsorState::to_text). - The windows build on macOS too (Homebrew’s gtk4 and libadwaita), without the tray, for developing them on a Mac.
Sync algorithm
Each cycle is incremental unless a full sync is due (see Full sync):
- Fetch changes from the source (RFC 6578 sync-token, ctag/etag diff, EventKit query, or a feed’s conditional GET)
- Skip events that carry a Capycal marker (prevents sync loops)
- Process each event: skip a cancelled one (
transform::is_cancelled, master VEVENT only; the runner then removes an earlier copy like a filtered event), apply the transforms to the whole event, each only where its conditions match (a condition can look at a property that is not copied;add_alarmonly collects its alarm), filter fields (filter::STRUCTURAL_FIELDS,fieldsand the fields a matchingset_fieldset), add the collected alarms, stamp markers, assign a deterministic sink UID - Push to the sink, limited by the per-calendar semaphore. If any put fails, the cursor from before the fetch (
sync_token,ctag) is kept while the mappings of the successful puts are saved, so the next cycle fetches the batch again: a change log source reports the whole batch again (the events already put are updated in place through their mapping), a ctag/etag source diffs against the mappings and reports only the failed event. An event the sink keeps rejecting is fetched again every cycle until its put succeeds; it is counted inCycleStats.errorseach time and does not block other events - Delete sink events whose source event was removed (the mapping is dropped only after a successful delete). Before the state changes,
defer_removalsstops a cycle that removes events when at least 5 are synced and the source now has less than half as many (synced minus removed plus new events; a full sync counts the fetched events) and setsSyncState::removals_deferred(a full sync checks the rebuilt mapping against the listing the same way and stays pending). The cycle succeeds withCycleStats::deferred; the next one that finds the events gone again removes them, so a source that is briefly empty without an error (Calendar.app loading at login) removes nothing, while a cleared calendar loses its copies one cycle later. The CalDAV client follows truncated sync-collection results (507) and rejects answers that are no multistatus. Failed deletes are kept inpending_removalsand retried in the next cycle, because RFC 6578 sources report a removal only once - Persist state atomically (write to a temp file, then rename)
Between cycles SyncRunner::run waits interval. After a cycle that failed or counted CycleStats.errors it waits the next step of ERROR_RETRY (1, 2, 5, then 15 minutes, at most interval) instead, so an outage longer than the CalDAV client’s own retries does not wait a whole interval; a successful cycle starts over. A cancelled cycle changes nothing.
Cancellation
Shutdown (also the stop before a reload or a purge) cancels a running cycle, incremental or full, instead of waiting for it, since a CalDAV request in retry backoff can take over 30 seconds. The runner watches its shutdown receiver at these points and ends the cycle with Error::Cancelled, which leaves the last reported result unchanged:
- Fetch: the source runs against a copy of
SyncStatethat replaces the state only on success. A cancelled (or failed) fetch is dropped with any request or backoff sleep in it and leaves the state exactly as before; nothing was written to the sink yet. - Waiting for a sink permit and between deletes: no new put or delete starts.
- In-flight puts: aborted only if
EventSink::cancel_safe()is true. CalDAV is: the sink href is derived from the sink UID (or taken from the mapping), so a put whose result was lost is repeated to the same resource. EventKit is not: a newEKEventgets its identifier from EventKit on save, and a put dropped before its mapping is recorded would create a duplicate next cycle. Its puts are local and have no retry waits, so the runner simply lets them finish. - In-flight deletes: always cancellable. Deletes are idempotent and the mapping is dropped only after success.
After a cancellation during the push phase the runner keeps the mappings of the completed puts, adds every delete that did not complete to pending_removals, restores the cursor from before the fetch (sync_token, ctag) and saves the state. The next cycle therefore fetches the unprocessed changes again: a change log source reports them again from the old token, a ctag/etag source sees the old ctag differ and diffs the etags against the mappings. Events that were already put are updated in place through their mapping.
The coordinator waits up to 30 seconds (SHUTDOWN_TIMEOUT) for a stopped sync’s task and aborts it after that, so a hung task cannot save state while a reload’s new runner or a purge works on the same sync. A panic inside a runner is caught (CatchUnwind), logged and reported as the sync’s last result, so the sync is not shown as running forever.
Feed specifics
IcsSource::fetch_changessends one GET withIf-None-MatchorIf-Modified-Sincefrom the validator it keeps inSyncState::ctag(etag:ormodified:prefix). 304 means no changes; an empty state (full sync) never sends a validator.split_feedunfolds the body, rejects one withoutBEGIN:VCALENDAR(an HTML sign-in page), and groups the VEVENTs by UID, so a series and its overrides become one source event with the VTIMEZONEs its TZIDs name. The UID is the href. The etag is a v5 UUID of the content withoutDTSTAMPandUIDlines, since feeds such as Google’s stamp every event with the request time. Some feeds are generated on each request with new random UIDs:keep_uidsgives an event whose UID is not in the mapping the UID (href andUIDline) of a mapped event that is gone from the feed and had the same etag, in href order for identical events, so it counts as unchanged.EtagDiffagainst the mapping then gives changed and removed events. An event of such a feed whose content changed becomes a new copy plus a removal, and a full sync (empty mapping) replaces their copies once. Events without UID are skipped.- The address is a
Credential(CalendarConfig::ics_url), resolved inbuild_source. It is never logged: errors usereqwest::Error::without_urland name the calendar key,{{source_url}}isics://feed, andSyncConfig::output_keyonly hashes it. Auser:password@in the address becomes Basic auth (reqwest).webcal://is HTTPS. - No retry loop: a failed GET fails the cycle, and the runner retries it after
ERROR_RETRY(see Sync algorithm).
EventKit specifics
- Apple limits EventKit predicate queries to about 4 years.
eventkit_all_eventsuses chunked queries, each inside its ownautoreleasepool. The source’sfetch_changesruns the whole query inspawn_blockinginside anautoreleasepool, so it does not stall the tokio workers of other syncs and a shutdown can drop the cycle while it waits. - As a source, recurring events are synced as individual occurrences without RRULE. When a single occurrence is deleted, EventKit simply stops returning it; an RRULE in the target would bring it back.
- As a sink, a recurring event becomes a native series:
convert::parse_seriesturns the RRULE into aRecurrence(anEKRecurrenceRulein its terms), EXDATEs and cancelled overrides into occurrences to remove and the other overrides into occurrences to change.client.rssaves the master with the rule, thenapply_exceptionsfinds each occurrence by itsoccurrenceDateand removes or changes it withEKSpan::ThisEvent. A put rewrites an existing series as a whole (deleted withEKSpan::FutureEvents, saved new), because EventKit keeps the exceptions of an existing series. Deletes and purge remove whole series. A full sync never skips a series (StoredEvent::recurring). Rules EventKit can’t represent are logged and written as their first occurrence; RDATE is left out. - Occurrence identity is
{calendarItemExternalIdentifier}_{start rounded to the minute}. - Timed events keep their timezone (
DTSTART;TZID=) so local times survive DST changes. NSTimeZone only knows IANA names, soparse_icalandparse_seriesfirst runeventkit/tzid.rs(resolve_timezones): Windows names map to IANA zones, prefixed IANA names lose the prefix, other names defined by a VTIMEZONE become UTC values computed from its observances.ical_to_nsdatetreats floating times and still unknown names as local time.apply_updatealso setsEKEvent.timeZone(ical_time_zone), without which Calendar.app keeps a timed event at the same wall-clock time in every zone;convert::is_unchangedtreats a stored timed event without a zone (StoredEvent::floating) as changed, so a full sync fixes older copies. All-day events use local dates. EventKit ends an all-day event at the last second of its last day, iCal at the following midnight:apply_updatewrites the end one second before the iCal end, andall_day_endreads one second after the stored end, which gives the following day for both conventions. - Alarms map to
VALARM(relative or absoluteTRIGGER), availability “free” maps toTRANSP:TRANSPARENT.STATUSis read-only in EventKit. - capycal-created events are recognised by a
[capycal:sync=...;source=...;uid=...]line in the notes (or the legacy[capycal:<source>]), because EventKit does not keep custom iCal properties. See Markers. - Everything except the ObjC calls lives in
eventkit/convert.rsand is unit tested on Linux.