Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Capycal

Capycal is calendar sync for the Mac, Linux and servers. It copies events from one calendar to another and removes everything you don’t want the other side to see. It works with any CalDAV server and, on macOS, with every calendar in Calendar.app through EventKit.

A meeting called “Board Review: Q3 Financials” in your work calendar becomes “Busy (work)” in your personal calendar. The slot is blocked, the content stays private.

Why

Work and personal calendars usually live with different providers: Google Workspace, Exchange, iCloud, Nextcloud or a self-hosted server. Colleagues need to see when you are busy privately, and your family needs to see when you are in meetings, without either side seeing the details. Most providers can’t share free/busy information with each other, so the usual workaround is copying events by hand.

Capycal does this copying for you:

  • It syncs one way, from a source calendar to a target calendar. Two syncs in opposite directions give you a two-way setup.
  • It copies when an event takes place and whether it blocks time. Titles, descriptions, attendees and locations stay behind unless you list them in fields.
  • Transforms rewrite what is left: set the title to “Busy”, add travel buffers or reminders, drop weekend or all-day events.
  • Created events carry a marker, so Capycal never copies its own blockers back and can remove them again.

On macOS, Capycal can read from and write to Calendar.app directly. Calendar.app already aggregates Google, Exchange, iCloud and local calendars, so you get all of them without dealing with OAuth or Exchange authentication. On any platform, Capycal can also read a calendar from its private iCal address, such as Google’s secret address, as a read-only source.

Two ways to run it

Desktop appcapycal CLI
PlatformsmacOS 14 or later, Linux (GTK)Linux, macOS
Runs asmenu bar or tray app, optional Start at Logindaemon, one-shot (--once), systemd / launchd / Docker service
SetupSettings window with an assistantTOML file
Statusstatus per sync in the menu and in Settings, live log windowlogs (pretty or JSON), --status

Both use the same sync engine and the same config file: the app writes the TOML for you, and you can edit it by hand next to it. Use the app on a computer you are logged in to, and the CLI on a server or for scripting.

This book follows that split. After Installation, Desktop app and CLI and server each start with a quick start. How syncing works explains calendars, syncs, transforms and full syncs for both: every option names its place in Settings and its key in the config. The Cookbook shows common setups both ways, the Reference lists every config option.

Terms used in this book

  • Calendar: a named entry under [calendars.<name>]: a CalDAV collection, a Calendar.app calendar or a read-only feed (an iCal address). A calendar can be the source of one sync and the target of another.
  • Sync: one [syncs.<name>] table that copies events from a source to a target every interval.
  • Blocker: a stripped-down copy of an event that only blocks the time slot.
  • Marker: the X-CAPYCAL-SOURCE / X-CAPYCAL-SYNC properties (or a [capycal:...] line in the notes for Calendar.app) on every event Capycal creates. It names the sync and the source event, so Capycal can find its events again without its state files.

Support

Capycal is free and open source. If it is useful to you, you can support its development through GitHub Sponsors or Ko-fi.

Installation

Prebuilt downloads are on the releases page:

PlatformFile
macOS menu bar app (Apple silicon, macOS 14+)capycal-v<version>-macos.dmg (includes the CLI)
macOS CLI (Apple silicon)capycal-v<version>-aarch64-apple-darwin.tar.gz
Linux x86_64 (static)capycal-v<version>-x86_64-unknown-linux-musl.tar.gz, capycal_<version>-1_amd64.deb, capycal-<version>-1.x86_64.rpm
Linux arm64 (static)capycal-v<version>-aarch64-unknown-linux-musl.tar.gz, capycal_<version>-1_arm64.deb, capycal-<version>-1.aarch64.rpm
Arch LinuxPKGBUILD of the AUR package capycal-bin
Linux desktop app (x86_64, arm64; GTK 4.14+)capycal-gtk_<version>-1_<arch>.deb, capycal-gtk-<version>-1.<arch>.rpm, see Desktop app (Linux)

On macOS and Linux, Homebrew installs the CLI and the macOS app from the tap twiebe/capycal.

Each file has a .sha256 file next to it:

shasum -a 256 -c capycal-v<version>-<target>.tar.gz.sha256

Homebrew

brew install twiebe/capycal/capycal              # CLI (macOS on Apple silicon, Linux)
brew install --cask twiebe/capycal/capycal-app   # menu bar app (macOS)

The formula installs capycal with the man page and shell completions; brew services start capycal runs it in the background with your default config. The cask installs Capycal.app without linking its bundled CLI, so both can be installed side by side. The app is not notarized either: before its first start, remove the quarantine flag as described under macOS app.

macOS app

  1. Open capycal-v<version>-macos.dmg and drag Capycal.app to Applications.

  2. The app is neither signed with an Apple Developer ID nor notarized, so macOS refuses the first start. Right-click Capycal.app in Finder and choose Open, then confirm. Alternatively remove the quarantine flag once:

    xattr -dr com.apple.quarantine /Applications/Capycal.app
    
  3. When Capycal first reads Calendar.app, macOS asks for calendar access for “Capycal”. Allow it. You can change this later in System Settings > Privacy & Security > Calendars.

The bundle also contains the capycal CLI. To use it from a terminal:

sudo ln -s /Applications/Capycal.app/Contents/MacOS/capycal /usr/local/bin/capycal

Continue with Quick start: desktop app.

Linux packages

sudo apt install ./capycal_<version>-1_amd64.deb     # Debian, Ubuntu
sudo dnf install ./capycal-<version>-1.x86_64.rpm    # Fedora, RHEL, openSUSE (zypper)

On Arch Linux, install capycal-bin from the AUR, or build the PKGBUILD from the release with makepkg -si.

The packages install /usr/bin/capycal, the man page (man capycal), completions for bash, zsh and fish, and two systemd units that are not enabled: the user service capycal.service (runs your default config, see User service) and the system service capycal.service (see System service). Then:

capycal --generate-config       # ~/.config/capycal/capycal.toml
capycal --discover https://caldav.example.com/
capycal --once                  # try it
systemctl --user enable --now capycal

CLI binaries

tar -xzf capycal-v<version>-<target>.tar.gz
sudo install capycal-v<version>-<target>/capycal /usr/local/bin/

The archive also contains README.md, LICENSE, CONTRIBUTORS.md, THIRD-PARTY-NOTICES.txt, capycal.example.toml, the man page capycal.1, shell completions in completions/ and the systemd units in systemd/. Completions and the man page can also be generated by the binary itself:

capycal --completions bash > ~/.local/share/bash-completion/completions/capycal
capycal --completions zsh  > ~/.zfunc/_capycal                     # a directory in your fpath
capycal --completions fish > ~/.config/fish/completions/capycal.fish
capycal --man > ~/.local/share/man/man1/capycal.1

macOS: the binary is not notarized. If macOS refuses to start it, remove the quarantine flag once:

xattr -d com.apple.quarantine /usr/local/bin/capycal

Linux: the binaries are static musl builds and do not use NSS for name resolution. Regular DNS and /etc/hosts work, but hostnames that only resolve through NSS modules do not, for example mDNS *.local names via nss-mdns, or LDAP. If your CalDAV server has such a name, use its IP address or build from source, which links against glibc and uses the system’s NSS configuration.

Linux builds support CalDAV only. Calendar.app (EventKit) calendars need macOS.

From source

Requires a recent stable Rust toolchain (edition 2024).

cargo install --git https://github.com/twiebe/capycal

EventKit support is compiled in automatically on macOS. The menu bar app is behind the ui feature; see Building and testing for building Capycal.app.

Docker

The repository contains a Dockerfile and a docker-compose.yml that build the CLI from source:

git clone https://github.com/twiebe/capycal && cd capycal
cp capycal.example.toml capycal.toml   # edit it
cp .env.example .env                 # put your passwords here
docker compose up -d

See Running on a server for details.

Quick start: desktop app

The menu bar app runs the syncs of one config file in the background. This example copies a work calendar (for example an Exchange account added to Calendar.app) as blockers into a calendar of your iCloud account.

1. Prepare the calendars

In Calendar.app, add the accounts you want to sync (System Settings > Internet Accounts) and create an empty calendar for the blockers, here “Work Blockers” in iCloud.

2. Start the app

Start Capycal.app from Applications (see Installation for the first start). A calendar icon appears in the menu bar; the app has no Dock icon. On the first start the app writes a commented-out starter config to ~/Library/Application Support/capycal/capycal.toml and opens Settings.

3. Add the sync

Settings starts an assistant. Once you choose Calendar.app, it says why and macOS asks whether “Capycal” may access your calendars; allow it, so the lists can show them.

  1. Welcome to Capycal: choose the card “Calendars in Calendar.app”, then Continue.
  2. Copy events from: the work calendar, for example “Calendar” under “Exchange”. The calendars are listed under the account they belong to.
  3. Into: “Work Blockers” under “iCloud”. Calendars you may not write to are marked “read-only” and cannot be chosen.
  4. What should the copies show?: the card “Busy blocker” (copies titled “Busy (Work)”, after the source calendar). Each card shows how a copied event will look.
  5. Name the sync: change the proposed name (made from the two calendars) to work-to-blockers, then Create.

For a CalDAV calendar (Nextcloud, Fastmail, …) choose the card “A CalDAV account” in the first step (or go Back to it), or Add Account… in Settings later: server URL, user name and password (the password goes into the Keychain), then its calendars appear in the lists.

Settings writes the config for you, keeping it readable:

[calendars.exchange_calendar]
eventkit_calendar = "A1B2C3D4-..."

[calendars.icloud_work_blockers]
eventkit_calendar = "E5F6..."

[syncs.work-to-blockers]
source = "exchange_calendar"
target = "icloud_work_blockers"
interval = "5m"
transforms = [
  { field = "SUMMARY", action = "set_field", value = "Busy" },
]

You can still edit it by hand with Open in Editor… under Settings > General; the app reloads when the file changes.

4. Allow calendar access

If you have not allowed calendar access yet, macOS asks on the first sync. Allow it. If you dismissed the prompt, enable Capycal in System Settings > Privacy & Security > Calendars and choose Sync Now.

5. Check the status

The menu shows one line per sync, for example “work-to-blockers: synced just now”. Show Log under Settings > General opens the live log if something goes wrong. Turn on Start Capycal at login in Settings > General to keep the syncs running after a restart. To take the blockers out of the target calendar again, use Remove Synced Events.

See Desktop app for every menu item and macOS and Calendar.app for what is synced from Calendar.app.

Menu and status

Capycal.app runs the syncs of one config without a terminal or LaunchAgent. It needs macOS 14 or later on Apple silicon. It lives in the menu bar: everything is in the calendar icon there. While one of its windows is open (Settings, Log, Problems, About), it also shows in the Dock and in Cmd+Tab, with a menu bar of its own: Settings… (Cmd+,), Quit Capycal (Cmd+Q), Edit for the text fields and Window (Cmd+W, Cmd+M). Closing the last window takes it out of the Dock again; a click on its Dock icon with no window open shows Settings. Quitting with unsaved changes in Settings asks first.

The icon shows the overall state; hover over it for a one-line summary.

IconState
CalendarAll syncs ran without errors
Calendar with a dotA sync is running, or events are being removed
Calendar with a red dotA sync failed or had failed events, or the config has an error. The menu shows which
Dimmed calendarStarting, not configured yet, or all syncs disabled
ItemWhat it does
Status linesOne line per sync, or the reason nothing runs (see below)
Sync NowStart a cycle of every sync without waiting for its interval. Enabled while the config is loaded and at least one of its syncs runs (is not disabled)
Settings…Everything you can set: syncs, accounts and, under General, starting at login, the log level and the config file (see Settings)
About CapycalApp icon, version and a random tagline, with buttons for the website, reporting an issue and sponsoring, and Acknowledgements…: Capycal’s license, its contributors and the licenses of the software it is built from
Quit CapycalRemove the menu bar icon and close the windows at once, stop the syncs (waiting up to 35 seconds for a running one to finish its current step) and quit

Repairs live in Settings: Full Sync Now, Remove Synced Events… and Remove All Events… on a sync’s page under Advanced settings > Troubleshooting, the config file and Show Log on General, which also shows what keeps the config from running. A sync is switched off and on in Settings, with the switch next to its name. While events are counted or removed, Sync Now is disabled.

Status lines

LineMeaning
work-to-home: synced 2 min agoThe last cycle succeeded
work-to-home: synced 2 min ago, 3 failedThe last cycle ran, but 3 events could not be written; they are retried every cycle
work-to-home: syncingA cycle is running
work-to-home: error: ...The last cycle failed, for example because the server was unreachable
work-to-home: waitingStarted, no cycle finished yet
work-to-home: disabledenabled = false in the config, for example after Remove Synced Events; switching it on in Settings starts it again
Not set up yet: open Settings…The config is valid but has no syncs, for example the starter config
Counting synced events, Removing the events of work-to-homeRemove Synced Events is running
No config: open Settings… to set one upThe config file doesn’t exist (it was deleted after the start, or could not be written)
Config error: ...The config is invalid. If syncs were running, they continue with the previous config
Another Capycal runs this config (pid N)Another Capycal process runs this config (see config lock)
Removing events failed: ...A target calendar could not be read (the sync keeps running), some events could not be deleted (the sync is disabled), or the config could not be written to disable the sync (it is stopped until Capycal restarts; restart it once the config is writable)

Lines that report a problem (a sync error, failed events, a config error, the lock, a failed removal, enable or disable) can be clicked and open the Problems window.

Problems window

The Problems window shows every current problem with its full message, for example a config error with the line, column and the offending part of the file, followed by the most recent warnings and errors of the log (for failed events, these name the events). It updates while it is open and says “No problems” once everything runs again.

  • Edit Config opens the config in your editor
  • Reload Config loads it again
  • Show Log opens the log window

When loading the config fails, at the start or after you edited the file, Settings opens on General and shows the mistake, so a typo in the config shows up right away. Sync errors don’t open it, since a server that is briefly unreachable would otherwise open it again and again; the red menu bar icon and the status line show them.

Log window

The log window shows the recent log lines and new ones as they arrive, colored by level.

  • Search: show only lines containing the text (case-insensitive).
  • Level: show only lines of this level and more severe ones (Error, Warn, Info, Debug; Debug includes trace lines).
  • Follow: keep the newest line in view. Scrolling up turns it off, scrolling back to the end turns it on again.
  • Clear View: empty the window. The log itself is not affected.
  • Export: save the complete log of the current app session as a text file: all levels, ignoring search and level filter.

The window keeps the last 5000 lines. Which lines are logged at all depends on log_level in [daemon]; set log_level = "debug" and save the config for more detail. The app applies the new level when it reloads the config. RUST_LOG, if set when the app starts, overrides log_level and is not changed by a reload.

Log files

The app writes its log to ~/Library/Logs/capycal/capycal-ui.log (also visible in Console.app):

  • Every app start begins a new file; the previous one becomes capycal-ui.log.1.
  • A file is also rotated when it would exceed 10 MB.
  • Rotation shifts .1 to .2 and so on. The last 5 rotated files are kept, older ones are deleted.

Export covers only the current session. If a very long session was rotated more than 5 times, the export starts with a note that older parts are gone.

Settings

First start

If the config file doesn’t exist when the app starts, or holds nothing but blank lines and comments, the app writes the starter config there (creating the directory, keeping existing comments below it) and logs “starter config written”. Every setting in it is commented out, so the app greets you with a small “Welcome to Capycal” window (once per start): it asks whether your calendars are in Calendar.app or in a CalDAV account (and adds the account), then for the source, the target (in another account added right there, if need be) and what to copy, one question at a time, with dots showing the step and Cancel, Back and Continue at the bottom (Return continues, Escape cancels). At the end it says the sync runs; Open Settings shows the full editor, Done closes the window. Sync Now stays disabled until there is one.

The window

Settings… opens a window (as large as you left it, on the entry you looked at last) with your syncs and accounts on the left (a disabled sync has a grey icon, one whose last sync failed an orange one) and the selected one on the right. Add Sync… below the syncs and Add Account… below the accounts add one. At the bottom of the sidebar is the version of Capycal. Under a sync’s name, a line says how it is doing: “Synced 2 min ago: 3 new, 1 removed”, “Syncing now…”, or why the last sync failed. The bar at the bottom says when there are unsaved changes and holds Revert and Save; a dot after the entry’s name in the list marks them too. Cmd+S saves, Cmd+N adds a sync and Cmd+Delete deletes the entry shown (on Linux with Ctrl).

Add Sync… starts the same assistant in a small window over Settings (a sheet on macOS), with one question per step: the source calendar, the target calendar (the source and calendars you may only read are greyed out) what the copies show: Busy blocker (only the time, titled “Busy (Work)” after the source calendar), Title and time, Everything (title, notes, location and reminders), each card with a preview of a copied event, or Custom, and last its name, proposed as work-to-blockers. Create saves the sync and shows it in the editor below, where you can change everything. A Custom sync starts switched off (enabled = false), so it copies nothing until you set it up and switch it on. Cancel (or closing it) returns to Settings as it was. Set Up in Editor leaves the assistant for the editor with what you picked so far, for those who rather fill in the form. A right click on a sync in the list offers Duplicate: a new, unsaved copy named <sync>-copy. Calendar.app’s calendars are listed under their account (iCloud, Exchange, …), accounts and calendars in alphabetical order. A calendar in a CalDAV account that is not set up yet: Add Another Account… (Add CalDAV Account… while there is none) below the calendars of the source or target step adds the account in the assistant and comes back to that step with its calendars, for example a second server for the target of a CalDAV to CalDAV sync, or a CalDAV target for a Calendar.app source. Add Account… in the sidebar adds one outside the assistant. Add Calendar Feed… below the calendars of the first step shows a Feed address field: paste a calendar’s iCal address there to copy from it.

A sync shows its name at the top (click it to rename; Esc or a click beside it ends editing) with its on/off switch at the right (off is enabled = false: configured but stopped), and below a sentence saying what it does, such as “Copies events from Calendar into Family every 5 min.” The step you work in is outlined in the accent color. Below, the sync is drawn as the steps an event takes, from top to bottom:

  • From: the source calendar and how often it is checked (Check every: 1, 5 or 15 minutes, or Custom for any other, such as 10m or 2h). The calendar menus list the calendars of every account, found on the server when the window opens, and the calendars of Calendar.app. Without a connection they show the error and the calendars already in the config; Refresh next to them asks the servers again. Add Calendar Feed… at the end of the menu reads a calendar from its iCal address instead, such as Google’s secret address: paste it into Feed address below, and the feed is named after the site (“google”). Feeds you added are listed under Feeds, and Feed address shows and changes the address of the one chosen; an address set in the config file through env, file, command or keychain is not shown. See Calendar feeds.
  • Keep: the content copied on top of times, recurrence, status and free/busy: Only the time, Title, Title, notes, location, reminders, or Custom, which shows the fields as checkboxes and others (such as X-CUSTOM) as text.
  • One step per transform, in the order they run, each numbered and as a sentence (“Set SUMMARY to “Busy”“). Click one to edit its fields in it (Field suggests common iCal fields such as LOCATION and takes any other name too, always in upper case) and its conditions: Add Condition adds one, which reads Only if (If for Skip events, which starts with one and keeps at least one) and then if, with what it looks at (Title, Location, Notes, Other field, Start with one checkbox per weekday and From / Until, Event for all-day), its test and its text, arrows to move it up or down, Remove, and a handle (≡) to drag it to another place; the and / or switch between two conditions says whether both have to match or one is enough, and conditions joined by or share a box, One of these (a condition dropped inside it joins it, one dropped right above or below it stays out) (see Conditions); drag it by its handle (≡) to another place, the bin removes it. The + between two steps adds a transform right there, each named with a line saying what it does; below the transforms its menu offers Common steps that come filled in: hide the details (title “Busy”), travel time (30 min before and after), travel time only for events with a location, skip weekends, skip outside working hours (Mon-Fri before 8:00 and from 18:00) and a reminder 10 min before. Cmd+Z undoes adding, moving or removing a step. Without a mouse, a right click on a step (VoiceOver: its context menu) offers Move Up, Move Down and Remove, and Cmd+Option+Up / Down moves the open step (on Linux Ctrl+Alt). A field with a mistake (an invalid regex, a time like 25:00) shows it below the field once you leave it, not while you type. While something keeps Save disabled, the bar at the bottom says what, with a warning sign, for example “Set field (step 1): fill in Field and Value”.
  • Into: the target calendar. A calendar the account may only read, and a feed, is greyed out.
  • Advanced settings below: how often a full sync runs (Full sync every), the window of days read from a Calendar.app source, Parallel writes of a CalDAV target (concurrency of the target calendar, default 2, shared by every sync writing into it), and, for a saved sync, Troubleshooting: Full Sync Now compares all copies with the source and fixes the ones that differ (the status line says “Full sync running…” meanwhile, and the bar at the bottom shows how far it got, as it does for the full sync that follows a save), Remove Synced Events… removes this sync’s copies from the target, after asking, and switches the sync off, Remove All Events… empties the target calendar (see below).

Right of the steps, One event on its way shows a sample meeting (Tuesday 9:00 to 10:00, with a place, notes and a reminder) and the copy this sync would make of it, through the same fields and transforms the sync uses; it follows every change before you save. Next event above it reads the source’s next event and shows that one instead of the sample (again each time you choose it). Picking a calendar that is not in the config yet adds a [calendars.<name>] entry for it when you save; a calendar no sync uses any more is removed after asking. Advanced settings below the steps hold the full sync interval and, for a Calendar.app source, Calendar.app events: the days back and ahead the sync reads; with All events these do not apply and are greyed out.

Save writes the config, keeping your comments and the order of everything else, and the syncs restart with it. Renaming a sync or changing its target first removes the events it copied into the old target, after asking, and then copies the source again; otherwise the old copies would stay behind without a sync that owns them. Changing what a sync copies (source, fields, transforms) makes its next cycle a full sync, so the copies already made get the new content. Delete (at the bottom left) removes a sync together with its copies, or with Keep Copies leaves them in the target. While copies are removed, the bar at the bottom counts them (“Removing the synced events of "work-to-home"… 120 of 340”) next to a progress bar.

An account has a name, the server URL, the user name and the password. A password you type is stored in the Keychain (service app.capycal, the account’s name) and the config only says password = { keychain = "<name>" }. A password from an environment variable, a file or a command stays as it is until you type a new one. When macOS would ask before Capycal reads a Keychain password (after an update to a build with a new signature), the app first says why and which accounts it reads, and starts the syncs once you close that note; choose Always Allow in the macOS prompt so it does not come back. Test Connection looks up the account’s calendars with what you entered. Below the box, the page lists the syncs that use the account; Delete removes it once none does.

If the config file changes while you have unsaved changes to the same sync or account, a yellow Changed on disk banner offers Reload (drop your changes) or Overwrite (keep them; Save writes them). Without unsaved changes the window just shows the new state. Closing the window or choosing another entry with unsaved changes asks first.

What each setting does, with its key in the config: Calendars and accounts, Syncs, Transforms, Full sync.

General

General at the top of the list holds the app’s own settings: Start Capycal at login, the Log level (written as [daemon] log_level; a filter written by hand shows as “Custom”) with Show Log, and the Config file with Open in Editor… and Show in Finder. When something keeps the config from running, General turns orange and starts with Needs attention: the mistake in the config file (where it is and what is wrong) with Open in Editor…, another Capycal process holding the config with Reload Config, or a failed removal of synced events with Show Log. Settings also opens when the config file cannot be read at all, then with only General, and shows everything once the file is fixed. Open in Editor… writes the starter config if the file is missing and opens it in the app for .toml files, or in the default text editor. Saving it reloads the syncs. Settings and your own edits can be mixed: Settings keeps your comments and only replaces the entries it changes. To find calendar URLs and identifiers for hand-written entries, use capycal --discover and capycal --discover-eventkit (see CLI reference).

Removing events

Remove Synced Events

Remove Synced Events… under Advanced settings > Troubleshooting on a sync’s page removes what Capycal created for that sync, and keeps it from creating the events again:

  1. A dialog opens at once and the app counts the sync’s events, in the background (“Counting synced events” with a spinner in the dialog, also in the menu). Cancel or closing the dialog stops here. On a CalDAV target these are the events carrying the marker of exactly this sync; on a Calendar.app target the events whose notes marker names this sync (for events of older versions, which only name the source calendar: those of the sync’s source).
  2. The dialog then asks “Remove N events?” and names the sync, the number of events and the target calendar. The source calendars are never touched. If the target cannot be read, it says so and only offers Done. Remove Events is red and not the default button: Return and Esc pick Cancel.
  3. On Remove Events, the app stops the sync, deletes its events (with the target calendar’s concurrency), forgets its sync state and disables it (enabled = false in the config): the menu shows “work-to-home: disabled”. Meanwhile the dialog shows “Removing synced events” with a progress bar (“264 of 1501 events removed”), Show Log and Close; closing it does not stop the removal.
  4. When done, the dialog says how many events were removed, and any that could not be. Failures also show up in the menu and the Problems window.

The sync stays disabled, also after restarting the app, until you switch it on in Settings; it then copies its source again. Renaming or deleting a sync in Settings removes its events by itself. If the target calendar cannot be read, nothing is deleted and the sync keeps running.

This is the same as capycal --purge <sync> (see CLI reference), without stopping the app first.

Remove All Events

Remove All Events… under Advanced settings > Troubleshooting on a sync’s page deletes every event in the sync’s target calendar, also those Capycal did not create. Use it only on a calendar that holds nothing but Capycal’s copies, for example after copies of an old version or a broken config piled up there.

  1. A dialog opens and counts the events in the target. An empty calendar or one that cannot be read ends here.
  2. A warning in an orange box says how many events are deleted and lists the syncs writing into the calendar, which are switched off afterwards. Continue… goes on, Cancel stops.
  3. A second warning asks you to type the calendar’s name as Settings shows it. Remove All Events is enabled only once the name matches.
  4. The app stops the syncs writing into the calendar, deletes the events with a progress bar, forgets their sync state and switches them off (enabled = false). Closing the dialog does not stop the removal. If the calendar cannot be read, nothing is deleted and the syncs keep running.

Switch the syncs on again in Settings to copy their sources from scratch. This is the same as capycal --purge-all <calendar> (see CLI reference), except that the CLI does not switch the syncs off.

Login, files and the CLI

Start at Login

Start Capycal at login (Settings > General) registers the app as a login item; it appears in System Settings > General > Login Items. If the app does not run from an app bundle, or macOS refuses, it writes a LaunchAgent to ~/Library/LaunchAgents/app.capycal-ui.plist instead. Turning it off removes the registration.

Sponsoring

30 days after the first successful sync, the app asks whether you’d like to sponsor Capycal, and after that about once a year. It only asks at a quiet moment: no sync running, no problem shown. The dialog has a button per way to sponsor, Sponsor on GitHub (GitHub Sponsors) and Support on Ko-fi (Ko-fi). Choosing one opens its page.

  • Sponsor on GitHub, Support on Ko-fi and Already Sponsoring ask again a year later, with a thank-you, in case the sponsorship was a one-time one or has ended.
  • Not Now (also Return, Esc or closing the dialog) asks again half a year later the first time, then a year later.
  • I’d Rather Not ends the asking for good.

The About dialog and Support Capycal at the end of Settings > General always list the same links; opening one there does not change when the app asks.

A line below the buttons says when the app asks again.

The app keeps this in its user defaults (SponsorFirstSeen, SponsorNext, SponsorSponsoring, SponsorNever in app.capycal). A capycal-ui binary started outside the app bundle, for example with cargo run, keeps them in the domain capycal-ui instead; use that name in the commands below. To see the dialog without waiting, start the app with CAPYCAL_ASK_SPONSOR=1; it then asks at the next quiet moment, whatever the defaults say:

CAPYCAL_ASK_SPONSOR=1 /Applications/Capycal.app/Contents/MacOS/capycal-ui

To see the thank-you a sponsor gets a year later, set SponsorSponsoring first:

defaults write app.capycal SponsorSponsoring -bool true

defaults delete app.capycal resets it all.

Another config file

The app uses the default config path, ~/Library/Application Support/capycal/capycal.toml. To run another config, start it with:

open -a Capycal --args -c /path/to/capycal.toml

Running alongside the CLI

The app takes the same config lock as the CLI. Don’t run the same config in the app and in a capycal daemon (for example the LaunchAgent from contrib/) at the same time: the second one shows or logs the first one’s pid and does not sync. Using the CLI for one-off commands such as --validate or --discover-eventkit while the app runs is fine.

A second copy of the app (for example Capycal.app/Contents/MacOS/capycal-ui started from a terminal while the app runs) says “Capycal is already running” and quits; Finder and open bring the running app forward anyway.

The CLI inside the bundle is at /Applications/Capycal.app/Contents/MacOS/capycal.

Where files live

WhatPath
Config~/Library/Application Support/capycal/capycal.toml
State~/Library/Application Support/capycal/state/ (or state_dir)
Config locks<state dir>/locks/
Log files~/Library/Logs/capycal/capycal-ui.log*
Login item fallback~/Library/LaunchAgents/app.capycal-ui.plist
Sponsor dialog answersuser defaults app.capycal

Calendar access is granted to “Capycal” in System Settings > Privacy & Security > Calendars.

Desktop app (Linux)

capycal-gtk runs the syncs of one config in the background, with an icon in the panel’s tray. Its windows are GTK 4 and libadwaita. It works like the macOS app: the same menu, status lines, Problems window, log window, Remove Synced Events and Start at Login. This page lists only what is different on Linux.

Requirements

  • GTK 4.14 and libadwaita 1.5 or later (Ubuntu 24.04, Debian 13, Fedora 40 and newer).
  • A tray that shows StatusNotifierItem icons. KDE Plasma, Cinnamon, XFCE, MATE, Budgie and the Ubuntu desktop do. GNOME needs the extension AppIndicator and KStatusNotifierItem Support (Fedora: gnome-shell-extension-appindicator). Without a tray, the app shows its menu as a window after ten seconds, with a note about the missing tray; it keeps syncing, and the icon appears as soon as a tray starts.

Install and start

sudo apt install ./capycal-gtk_<version>-1_amd64.deb    # Debian, Ubuntu
sudo dnf install ./capycal-gtk-<version>-1.x86_64.rpm   # Fedora

The package adds Capycal to the application menu. Starting it again while it runs opens the menu as a window, with the same status lines and items as the tray menu (without a D-Bus session, the second start says “Capycal is already running” and quits). It uses the default config, ~/.config/capycal/capycal.toml; for another one, start it with capycal-gtk -c /path/to/capycal.toml. On the first start it writes the commented starter config and opens a small “Welcome to Capycal” window: after a short welcome it adds your CalDAV account (server URL, user name, password), then asks for the source and target calendar and what to copy.

Don’t run the app and the systemd user service with the same config: they share a config lock, and the second one does not sync.

Differences from the macOS app

Linux
Tray iconA calendar symbol in the panel’s own color, like the other tray icons: with a red dot when something needs attention, a dot while syncing, faded when inactive. A left click opens the menu
Settings…The same Settings window, in GTK. Without Calendar.app, the source and target lists only show the calendars of your accounts. A calendar you may not write to is marked “(read-only)” in the target list and cannot be chosen
General > Open in Editor…Opens the config with the default application for its file type
General > Start Capycal at loginWrites ~/.config/autostart/app.capycal.desktop (with the config path), which every desktop following the XDG autostart spec runs at login. Show in Files opens the config’s folder in the file manager
Log file~/.local/share/capycal/capycal-gtk.log
Sponsor dialog answers~/.config/capycal/sponsor. CAPYCAL_ASK_SPONSOR=1 asks at the next quiet moment, a line sponsoring=1 shows the thank-you, deleting the file resets it

A password typed in Settings is stored in your keyring through the Secret Service (GNOME Keyring, KWallet), as { keychain = "<account>" } in the config. Passwords can also come from your keyring with a command credential, for example { command = "secret-tool lookup service capycal account work" }.

In the Settings window, Ctrl+W closes it and Ctrl+Q quits the app (both ask first about unsaved changes); Ctrl+S, Ctrl+N, Ctrl+Delete and Ctrl+Z work as Cmd on macOS.

Quick start: CLI

This walks through a first sync from one CalDAV calendar to another. For Calendar.app calendars, see the desktop app quick start or Calendar.app to Nextcloud.

1. Create a config

capycal --generate-config

This writes a commented starter config to the default location and refuses to overwrite an existing file:

OSConfigState
Linux~/.config/capycal/capycal.toml~/.local/state/capycal/
macOS~/Library/Application Support/capycal/capycal.toml~/Library/Application Support/capycal/state/

Use -c <path> with any command for a different config file.

2. Find your calendars

capycal --discover https://caldav.example.com

Capycal asks for username and password, looks up the CalDAV root (RFC 6764, /.well-known/caldav) and prints an [accounts.<name>] snippet for the server and one [calendars.<name>] snippet per calendar:

[accounts.work]
url = "https://caldav.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }

[calendars.work]
account = "work"
url = "https://caldav.example.com/calendars/alice/work/"

On macOS, capycal --discover-eventkit does the same for Calendar.app.

3. Wire a sync

Copy the source calendar and the calendar that should receive the blockers into the config, then edit the sync:

[daemon]
log_level = "info"

[accounts.work]
url = "https://caldav.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }

[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_HOME_PASSWORD" }

[calendars.work]
account = "work"
url = "https://caldav.example.com/calendars/alice/work/"

[calendars.blockers]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"

[syncs.work-to-blockers]
source = "work"
target = "blockers"
interval = "5m"

transforms = [
  { field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]

Use a dedicated, empty calendar as the target. Capycal only touches events it created, but a separate calendar is easier to hide, share or clean up.

4. Check and run once

export CAPYCAL_WORK_PASSWORD=... CAPYCAL_HOME_PASSWORD=...
capycal --validate
capycal --once -v

--validate parses the config and checks references, transforms and placeholders without contacting a server. --once runs every sync one time and exits with a non-zero code if any sync failed; -v adds debug output.

5. Run as a daemon

capycal

Without --once, Capycal keeps running and syncs every interval until it receives SIGINT or SIGTERM. For a permanent setup, see Running on a server.

Next: Config file and the Cookbook.

Config file

Capycal reads one TOML file. The desktop app writes it for you; this page is about the file itself. The CLI looks at the platform default path unless you pass -c <path>; the desktop app uses the same default (see Another config file for another file).

OSDefault config path
Linux~/.config/capycal/capycal.toml
macOS~/Library/Application Support/capycal/capycal.toml

A config has these parts:

  • Accounts ([accounts.<name>]): a CalDAV server and the credentials for it. Every CalDAV calendar names its account.
  • Calendars ([calendars.<name>]): where events live. Each is a CalDAV collection or a Calendar.app calendar. The <name> is your own key; syncs refer to it and it appears in markers and the {{source_name}} placeholder.
  • Syncs ([syncs.<name>]): which calendar is copied to which, how often, and which iCal properties are kept.
  • Transforms (the transforms list of a sync): what happens to each event on the way, in order.
[daemon]
log_level = "info"

[accounts.work]
url = "https://caldav.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }

[calendars.work]
account = "work"
url = "https://caldav.example.com/calendars/alice/work/"

[calendars.blockers]
account = "work"
url = "https://caldav.example.com/calendars/alice/blockers/"

[syncs.work-to-blockers]
source = "work"
target = "blockers"
interval = "5m"

transforms = [
  { field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]

What each part means, with its place in the desktop app: Calendars and accounts, Syncs, Transforms. Every key: Config options.

Starter config

capycal --generate-config and the desktop app (on its first start) write a starter config. Every setting in it is commented out, so it loads as a valid config without syncs. It explains and shows, ready to uncomment:

  • every [daemon] option with its default,
  • a CalDAV calendar with the three credential forms and a Calendar.app calendar with the accepted naming forms,
  • a complete sync, including what fields keeps and the Calendar.app query window,
  • one example of every transform action.

Lines starting with ## explain, lines starting with # are settings: remove the # to use one. The CLI refuses to run the starter config as it is, since it has no syncs.

Required parts

  • The [daemon] table is optional; without it every daemon setting has its default.
  • Every sync needs a name (its table key), source, target and interval. Without fields it copies busy blockers.
  • The CLI needs at least one [syncs.<name>] table to run (see CLI reference). A config without syncs is still valid, so the desktop app can show a config that is not set up yet.

Unknown keys are errors, at every level (top level, [daemon], calendars, credentials, syncs, transforms). The message names the key, its line and the keys allowed there:

error: configuration error: failed to parse /etc/capycal/capycal.toml: TOML parse error at line 16, column 1
   |
16 | intervl = "5m"
   | ^^^^^^^
unknown field `intervl`, expected one of `source`, `target`, `interval`, `fields`, ...

Validating

capycal --validate            # default config
capycal --validate -c my.toml

This checks the TOML, unknown keys, required options, calendar references, durations, regexes, days, times, placeholders and the Calendar.app query window. A config without syncs is reported as valid, but --validate exits with code 1, since the CLI could not run it. It does not resolve credentials or contact a server; use capycal --once -v for that.

Daemon settings

The [daemon] table holds process-wide settings. The table and every option in it are optional.

[daemon]
state_dir = "/var/lib/capycal"
log_level = "info"
log_format = "pretty"
watch_config = false

state_dir

Directory for the per-sync state files (<sync name>.json) and the config locks (locks/). Precedence: state_dir in the config, then the CAPYCAL_STATE_DIR environment variable (if not empty), then the OS default:

OSDefault state directory
Linux$XDG_STATE_HOME/capycal, usually ~/.local/state/capycal/
macOS~/Library/Application Support/capycal/state/

Services that run without a home directory need one of the first two. The systemd system unit and the Docker image set CAPYCAL_STATE_DIR=/var/lib/capycal, so their configs need no state_dir. A relative path is resolved against the working directory of the process, not the config file, so use an absolute path.

The state remembers which source event belongs to which target event. It is a cache: if it is lost, the next cycle is a full sync that rebuilds it from the markers Capycal puts on every event it creates, on CalDAV and Calendar.app targets alike, without creating events twice. Only Calendar.app events written by old versions, whose marker names no sync, are deleted and created again. See Troubleshooting.

log_level

Default "info". One of trace, debug, info, warn, error, or a tracing filter directive such as "info,capycal=debug".

Precedence: the RUST_LOG environment variable wins over everything, then -v / -vv on the command line (CLI only), then log_level.

A changed log_level takes effect on a config reload, in the CLI daemon and in the desktop app, and is logged as “log level changed”. If RUST_LOG or -v set the level at start, a reload doesn’t change it.

log_format

"pretty" (default) or "json". Applies to the CLI’s output on stdout. JSON gives one object per line for log collectors. The desktop app always writes plain text to its log file.

watch_config

Default false. When true, the CLI daemon reloads the config by itself when the file changes, the same way as on SIGHUP. See Reloading the config. The option is read at start and ignored for --once. The desktop app always watches its config, regardless of this option.

Reloading the config

A running Capycal applies config changes without a restart:

HowCLI daemonDesktop app
SIGHUP (kill -HUP <pid>, systemctl reload capycal, docker compose kill -s HUP capycal)yes-
File change with watch_config = true in [daemon]yesalways on
Reload Config (Problems window, or Settings > General while another process holds the config)-yes

On reload Capycal parses and validates the new file first. If it is invalid, the error is logged (and shown in the app’s menu) and the running syncs continue with the previous config. If it is valid, all syncs are stopped and started again with the new config; each starts with a cycle right away. State files are kept, so nothing is synced twice.

The file watcher notices saves, replacements by rename (how most editors save) and deletion followed by re-creation, about half a second after the last write. The CLI logs “config changed, reloading”.

A changed log_level applies after a successful reload, unless RUST_LOG or -v fixed the level at start. Not affected by a reload: log_format (set at start) and watch_config itself. The config lock follows a reload: the new sync names are recorded, and a changed state_dir takes the lock in the new directory. A reload whose sync names another config already runs in the same state directory, or whose new state directory is locked by another process, is rejected like an invalid config.

A CLI daemon started with --only applies the same selection to the reloaded config; a reload that no longer contains a selected sync is rejected like an invalid config.

Removing a sync from the config stops it but leaves its events in the target and its state file in the state directory. Run capycal --purge <sync> before removing it if the events should go too.

Config lock

One config runs in one process at a time. Two processes with the same config would write the same state files and create duplicates. The daemon, --once, --full-sync, --purge, --purge-all and the desktop app take an exclusive lock for the config file; a second process with the same config stops with an error naming the config and the pid that holds it. The app shows “Another Capycal runs this config (pid N)” instead and keeps checking on reload.

Different configs can run in parallel. If they share a state directory and both have a sync with the same name, both would write <name>.json, so the second one fails to start with an error naming the conflicting config.

The lock files are in <state dir>/locks/, one per config (named after a hash of the config’s absolute path). They record the pid and program of the holder, the config path and its sync names; capycal --status shows the pid, and a second desktop app quits when another one holds the lock. The lock is released when the process exits, even after a crash; the files themselves stay and are harmless.

--validate, --generate-config and the discovery commands don’t take the lock. To purge a config that a daemon or the desktop app runs, stop it first; otherwise the purge fails with the same “already in use” error.

Running on a server

For a permanent setup, run the capycal daemon under a service manager. Ready-made service definitions are in contrib/; they expect the binary at /usr/local/bin/capycal. Install steps are in the comment at the top of each file.

Before enabling a service, check the config with capycal --validate -c <config> and one manual capycal --once -v -c <config>.

systemd

System service

contrib/systemd/capycal.service runs Capycal as a dynamic, unprivileged user with a hardened sandbox:

  • Config: /etc/capycal/capycal.toml.
  • Passwords: VAR=value lines in /etc/capycal/capycal.env (mode 0600), referenced as password = { env = "VAR" }.
  • State: /var/lib/capycal, created by systemd (StateDirectory=) and passed to Capycal as CAPYCAL_STATE_DIR, since the dynamic user has no home directory. Don’t set a different state_dir in the config.
sudo install -m 0755 capycal /usr/local/bin/capycal
sudo install -D -m 0644 capycal.toml /etc/capycal/capycal.toml
sudo install -D -m 0600 capycal.env /etc/capycal/capycal.env
sudo cp contrib/systemd/capycal.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now capycal

User service

The quickest way to run Capycal as your user is:

capycal --install-service            # or: capycal -c /path/to/capycal.toml --install-service

It writes ~/.config/systemd/user/capycal.service with the absolute paths of the Capycal binary and the config, reloads systemd, enables and starts the service, and tells you if lingering is off (without it the service stops when you log out; loginctl enable-linger $USER changes that). Run it again after moving the binary or the config. It refuses while another Capycal process runs the config, and it never overwrites a capycal.service it didn’t write. capycal --uninstall-service stops and removes it again. Install the binary to its final place first (for example /usr/local/bin), since the unit points to the binary you run it with.

Passwords go into ~/.config/capycal/capycal.env, files referenced with { file = "..." } or a password manager command.

To set it up by hand instead, contrib/systemd/capycal-user.service is the same unit with the default config and state paths:

mkdir -p ~/.config/systemd/user
cp contrib/systemd/capycal-user.service ~/.config/systemd/user/capycal.service
systemctl --user daemon-reload && systemctl --user enable --now capycal
loginctl enable-linger $USER   # keep it running while you are logged out

Logs and reload

journalctl -u capycal -f            # system service
journalctl --user -u capycal -f     # user service
sudo systemctl reload capycal       # re-read the config (SIGHUP)

Docker

The repository’s Dockerfile builds the CLI from source into a Debian slim image that runs as an unprivileged user. docker-compose.yml mounts the config read-only and keeps the state in a volume:

git clone https://github.com/twiebe/capycal && cd capycal
cp capycal.example.toml capycal.toml
cp .env.example .env
docker compose up -d
  • The image sets CAPYCAL_STATE_DIR=/var/lib/capycal, where the compose file mounts the state volume, so the state survives a recreated container. Leave state_dir unset in capycal.toml, or set it to a path inside that volume.
  • Put the passwords into .env and reference them as password = { env = "..." }. RUST_LOG in .env overrides log_level, also on reload.
  • Time conditions of transforms use the container’s timezone, UTC by default. Add - /etc/localtime:/etc/localtime:ro to the volumes to use the host’s.
docker compose logs -f capycal          # logs
docker compose kill -s HUP capycal      # reload the config
docker compose exec capycal capycal --validate --config /etc/capycal/capycal.toml

The Makefile has shortcuts: make up, make down, make logs, make reload.

launchd (headless macOS)

On a Mac that should sync without the desktop app, run the CLI as a LaunchAgent with contrib/launchd/app.capycal.plist. It runs in your login session, which Calendar.app (EventKit) access requires; a system LaunchDaemon can’t read calendars.

  1. Replace USERNAME in the plist with your user name.

  2. Install and start it:

    cp contrib/launchd/app.capycal.plist ~/Library/LaunchAgents/
    launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/app.capycal.plist
    
  3. Under launchd the calendar permission belongs to the capycal binary itself. Run launchctl kickstart gui/$(id -u)/app.capycal once while logged in and confirm the prompt.

tail -f ~/Library/Logs/capycal.log                          # logs
launchctl kill HUP gui/$(id -u)/app.capycal     # reload the config
launchctl bootout gui/$(id -u)/app.capycal      # stop and remove

Use passwords from files ({ file = "..." }) rather than environment variables, so they don’t end up in the plist.

Don’t run the LaunchAgent and the desktop app with the same config; they share a config lock and the second one does not sync.

Logs

The CLI logs to stdout. With log_format = "json" in [daemon], each line is a JSON object with timestamp, level, message and fields (sync, error, …), ready for Loki, Elasticsearch or jq:

journalctl -u capycal -o cat | jq 'select(.level == "ERROR")'

Useful messages at info level: capycal started, sync started, sync cycle done, deleted synced event, config reloaded successfully, sync cycle failed (error). A block of work starts with <name> started and ends with <name> done (with took, the duration), <name> done with errors (warning), <name> cancelled or <name> failed, for example full sync started and full sync done. Use -v or log_level = "debug" for per-event details.

Reload

A running daemon re-reads its config on SIGHUP (all service managers above forward their reload command as SIGHUP), or by itself with watch_config = true. An invalid new config is rejected and the syncs continue unchanged. See Reloading the config.

CLI reference

capycal [OPTIONS]
OptionDescription
-c, --config <PATH>Config file. Default: ~/.config/capycal/capycal.toml (Linux), ~/Library/Application Support/capycal/capycal.toml (macOS)
-v, --verboseMore log output: -v debug, -vv trace. Overrides log_level; RUST_LOG overrides both
--onceRun every enabled sync once and exit
--only <NAMES>Run only these syncs (comma-separated), with the daemon, --once or --full-sync
--full-syncStart every sync with a full sync, then continue as daemon (or exit with --once)
--validateCheck the config and exit. Exits with 1 if it has no syncs
--statusShow whether a Capycal process runs the config and what each sync’s state says, then exit
--install-serviceInstall, enable and start Capycal with this config as a systemd user service (Linux), see User service
--uninstall-serviceStop and remove that service (Linux)
--generate-configWrite a starter config to the config path and exit. Refuses to overwrite
--discover <URL>List the calendars of a CalDAV account and print config snippets
--discover-eventkitList Calendar.app calendars and print config snippets (macOS)
--purge <SYNC>Delete the events a sync created from its target and forget its state
--purge-all <CALENDAR>Delete all events from a calendar and forget the state of the syncs writing to it
--yesWith --purge-all: don’t ask for confirmation
--completions <SHELL>Print shell completions (bash, zsh, fish, elvish, powershell)
--manPrint the man page
-V, --versionPrint the version
-h, --helpShow help

Without --once, --validate, --generate-config, discovery or purge options, Capycal runs as a daemon: it syncs every interval until SIGINT (Ctrl-C) or SIGTERM, and reloads the config on SIGHUP.

Exit codes

CodeMeaning
0Success. For --once: every sync completed
1Any error: invalid config, a config without syncs (also for --validate), lock held by another process, a failed sync in --once, a purge or discovery that could not list the calendar. Single failed deletes during a purge are logged and counted but do not change the exit code

Errors are printed to stderr or logged; with log_format = "json", errors after config loading are JSON log lines.

Running

capycal                         # daemon with the default config
capycal -c /etc/capycal/capycal.toml
capycal --once -v               # one cycle with debug output, e.g. from cron
capycal --once --only work-to-home

--once and the daemon take the config lock; a second process with the same config exits with code 1.

A daemon started with --only keeps the selection on every reload (SIGHUP or watch_config). If a selected sync is no longer in the reloaded config, the reload fails with sync '<name>' selected with --only is no longer in the config and the daemon continues with the previous config.

The daemon, --once and --full-sync need at least one [syncs.<name>] table. Without one they exit with code 1 and no syncs defined in <path> - add a [syncs.<name>] table. A reload to a config without syncs is rejected and the daemon keeps the previous config.

Validate and generate

capycal --generate-config                 # writes the starter config to the default path
capycal --generate-config -c ./test.toml  # or elsewhere
capycal --validate                        # prints "configuration is valid" with the state directory

The starter config is fully commented out: uncomment a calendar pair and a sync before running it.

--validate exits with code 0 only for a config the CLI can run. A config without syncs is valid (the desktop app accepts it), so it prints “configuration is valid” and then the “no syncs defined” error, and exits with code 1. This keeps capycal --validate && systemctl restart capycal from starting a daemon that would exit at once.

When no -c is given and the default config is missing or broken, the error includes the path that was looked at and a hint to run --generate-config.

Status

capycal --status
config:  /home/alice/.config/capycal/capycal.toml
state:   /home/alice/.local/state/capycal
daemon:  running (pid 4242)

SYNC              FROM -> TO                 EVENTS  LAST CHANGE       LAST FULL SYNC
work-to-personal  work -> personal_blockers     128  2026-10-14 09:12  2026-10-01 08:00
personal-to-work  personal -> work_blockers       -  -                 -                 no state yet, starts with a full sync

--status reads the state files and the config lock without changing anything or contacting a server, so it works while a daemon or the desktop app runs. Per sync it shows the number of synced events, when the state last changed (a cycle saves only when something changed), when the last full sync completed and notes such as an interrupted full sync or deletes waiting for a retry. Errors of the last cycles are in the log (journalctl --user -u capycal for the user service).

Discover

capycal --discover https://cloud.example.org
capycal --discover-eventkit

--discover asks for username and password on the terminal, finds the account’s calendars (via /.well-known/caldav and the principal’s calendar home) and prints each calendar’s name and URL to stderr, and to stdout an [accounts.<name>] snippet for the server (named after its host, with password = { env = "CAPYCAL_<NAME>_PASSWORD" }) plus a [calendars.<name>] snippet per calendar that refers to it. Task lists and other collections without events are left out. Redirect stdout to collect them: capycal --discover https://... >> snippets.toml.

--discover-eventkit lists every Calendar.app calendar with its identifier, grouped by account, and prints snippets in the same groups, named <account>_<calendar> and using the identifier, with the Account/Name form as a comment.

Neither needs a config file.

Full sync

capycal --full-sync --once
capycal --full-sync --once --only work-to-home

Compares the whole source of each sync with its target: creates missing events, updates differing ones and removes Capycal’s own duplicates, events whose source event is gone and events the current fields or transforms exclude. Nothing else in the target is touched, and the source never is. Use it after changing fields or transforms. Without --once the daemon starts with a full sync and then continues as usual.

The log line full sync done reports took (the duration), created, updated, unchanged, deleted and duplicates and legacy_markers (both included in deleted) per sync; with failed events it is a warning full sync done with errors that adds errors. Full syncs also run on their own, see Full sync.

Purge

capycal --purge work-to-home
capycal --purge-all blockers

--purge <SYNC> deletes the events this sync created from its target calendar, then removes the sync’s state. Other events in the calendar are not touched:

  • CalDAV target: Capycal fetches every event in the calendar and deletes those carrying the marker of exactly this sync (X-CAPYCAL-SYNC:<sync>).
  • Calendar.app target (macOS): Capycal deletes the events whose notes marker names this sync. Events created by older Capycal versions carry only the source calendar ([capycal:<source>]); for these the source decides, so if two syncs read the same source and write to the same Calendar.app calendar, such old events of both are deleted. On other platforms the command fails with “EventKit calendars are only supported on macOS”.

The source calendar is never touched. The sync must still be in the config, so purge before removing or renaming a sync. A daemon running the same config holds its lock, so stop it first; afterwards remove the sync from the config, or the next start creates the events again.

--purge-all <CALENDAR> deletes every event in the calendar, including ones Capycal didn’t create, then removes the state of all syncs that write to it. It works for CalDAV and Calendar.app calendars. Only use it on calendars dedicated to Capycal. It asks you to type the calendar name first; in a script or without a terminal, add --yes instead (capycal --purge-all blockers --yes), otherwise it refuses.

Both use the calendar’s concurrency for parallel deletes and report the number of deleted events and errors. Both take the config lock, so they exit with code 1 while a daemon or the desktop app runs the same config. In the desktop app, use Remove Synced Events… and Remove All Events… on the sync’s page in Settings instead.

Calendars

Each [calendars.<name>] table defines one calendar. The type follows from the keys: a table with eventkit_calendar is a Calendar.app calendar, one with ics_url a calendar feed, any other table is a CalDAV calendar. A calendar can be the source of some syncs and the target of others; a feed can only be a source.

The <name> is yours to choose. It is used in source / target of syncs, in the {{source_name}} placeholder and in the markers on created events.

In the app: you don’t write calendar entries yourself. Picking a calendar in a sync’s From or Into adds its [calendars.<name>] entry when you save, and a calendar no sync uses any more is removed after asking.

Accounts

In the app: Add Account… in Settings asks for a name, the server URL, the user name and the password; Test Connection looks up the calendars with them.

A CalDAV calendar belongs to an [accounts.<name>] table, which holds the server and the credentials. Several calendars on one server share one account, so the password is set once.

[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_NEXTCLOUD_PASSWORD" }

[calendars.personal]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/personal/"

[calendars.blockers]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"
KeyRequiredDescription
urlyesServer address, the one you would give a calendar app; capycal --discover starts from it
usernameyesUser name for HTTP Basic authentication
passwordyesA credential

CalDAV

In the app: the calendar menus of a sync list the calendars every account finds on the server. concurrency is Parallel writes in the Advanced settings of a sync that writes into the calendar.

[accounts.work]
url = "https://caldav.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }

[calendars.work]
account = "work"
url = "https://caldav.example.com/calendars/alice/work/"
concurrency = 4
KeyRequiredDescription
accountyesName of the account whose credentials the calendar uses
urlyesURL of the calendar collection (not the server root)
concurrencyno, default 2Maximum events written (PUT) to this calendar at the same time as a target, and parallel deletes of a purge

capycal --discover <server> lists the collection URLs of an account. Some examples of collection URLs:

ServerCollection URL
Nextcloudhttps://cloud.example.org/remote.php/dav/calendars/<user>/<calendar>/
Radicalehttps://radicale.example.org/<user>/<calendar-id>/
Fastmailhttps://caldav.fastmail.com/dav/calendars/user/<email>/<calendar-id>/
iCloudfound with --discover https://caldav.icloud.com and an app-specific password

Only HTTP Basic authentication is supported. Providers that require OAuth (Google, Microsoft 365) can’t be used over CalDAV directly. To copy from them, use their calendar feed, or on macOS add the account to Calendar.app and use it through EventKit.

concurrency is shared by every sync that writes to the calendar and also sets the parallel deletes of --purge / --purge-all. Raise it for large initial syncs to a server that handles parallel requests well, set it to 1 for one that answers parallel writes with errors. The desktop app sets it as Parallel writes in a sync’s Advanced settings when the target is a CalDAV calendar. Reads are not limited by it, and the deletes of a sync cycle run one at a time per sync. Capycal retries on HTTP 408, 429 and 5xx with exponential backoff and honours Retry-After.

Calendar.app (EventKit, macOS only)

In the app: the calendar menus list Calendar.app’s calendars under their account; Settings writes the identifier.

[calendars.work_mac]
eventkit_calendar = "Exchange/Calendar"

eventkit_calendar accepts three forms, tried in this order:

  1. The calendar identifier, for example "A1B2C3D4-...". Stable across renames.
  2. "Account/Name", for example "iCloud/Family" or "Exchange/Calendar". Use this when several accounts have a calendar with the same name.
  3. The calendar name alone, for example "Work". The first calendar with that name wins.

capycal --discover-eventkit prints every calendar with its account and identifier, and a snippet with both the identifier and the Account/Name form:

[calendars.work]
eventkit_calendar = "A1B2C3D4-0000-0000-0000-000000000000"
# eventkit_calendar = "Exchange/Work"

A config with an eventkit_calendar fails validation on Linux. How long the source is queried is set per sync with the EventKit window options. See macOS and Calendar.app for what is read and written.

Calendar feeds (iCal address)

In the app: Add Calendar Feed… at the end of a sync’s From menu, or below the calendars of the assistant’s first step, asks for the address. Feeds you added are listed under Feeds; as a target they are greyed out.

Many calendar services publish a calendar as one iCalendar file at a private address. Capycal reads such a feed as a source: no account, no password, and it works with Google Calendar and Outlook.com, which don’t offer CalDAV with a password.

[calendars.google]
ics_url = "https://calendar.google.com/calendar/ical/alice%40gmail.com/private-0123456789abcdef/basic.ics"
KeyRequiredDescription
ics_urlyesThe feed’s address, https://, http:// or webcal://. A credential like a password: a plain string or { env = ... }, { file = ... }, { command = ... }, { keychain = ... }

Where to find the address:

ServiceAddress
Google CalendarSettings, Settings for my calendars, the calendar, Integrate calendar: Secret address in iCal format
Outlook.com, Microsoft 365Settings, Calendar, Shared calendars, Publish a calendar: the calendar with Can view all details, then the ICS link
OthersLook for “publish”, “share link”, “subscribe” or “iCal”. Capycal reads any address that returns an .ics file

A feed is read-only: a sync with a feed as target fails validation, and --purge-all refuses it. Every check downloads the whole calendar, unless the server answers that it did not change (ETag / Last-Modified). Capycal finds the changed events by their content, so a feed that sets every event’s DTSTAMP to the time of the request doesn’t rewrite all copies. Some feeds (waste collection calendars, for example) are generated on each request and give every event a new UID: Capycal recognizes the events by their content and keeps their copies. For such a feed, an event whose time or title changes gets a new copy in place of the old one, and a full sync replaces all its copies once.

The service decides how fresh its feed is. Google updates the secret address only every few hours, so a change in Google Calendar can take that long to reach the target, whatever interval says. An interval of "30m" or "1h" is plenty.

Treat the address like a password: anyone who has it can read the calendar. It never appears in Capycal’s logs, and {{source_url}} gives ics://feed for a feed. To keep it out of the config file, use another credential form, for example ics_url = { keychain = "google-feed" }; the desktop app then shows the address as set in the config file. A feed behind HTTP Basic authentication takes the user name and password in the address: https://user:password@example.com/calendar.ics. If the address leaks, reset it with the service (Google: Reset next to the secret address) and enter the new one.

Passwords

In the app: a password typed into an account is stored in the Keychain (macOS) or the Secret Service (Linux) and written as password = { keychain = "<account>" }. A password from env, file or command stays as it is until you type a new one.

The password of a CalDAV account ([accounts.<name>]) can come from five places:

password = { env = "CAPYCAL_WORK_PASSWORD" }   # environment variable
password = { file = "secrets/work.txt" }      # file content
password = { command = "pass show capycal/work" }  # output of a command
password = { keychain = "work" }              # macOS Keychain or Secret Service
password = "literal"                          # plain text in the config
  • env: the value of the environment variable. Works well with systemd EnvironmentFile=, Docker env_file and .env files.
  • file: the content of the file, with leading and trailing whitespace (including the final newline) removed. A relative path is resolved against the directory of the config file. Works well with Docker or systemd secrets (/run/secrets/...).
  • command: the output of a shell command (sh -c, run in the config file’s directory), with leading and trailing whitespace removed. Use it for password managers and keyrings: secret-tool lookup service capycal account work (GNOME Keyring, KWallet and other Secret Service keyrings), pass show capycal/work | head -n1, op read op://Private/work/password (1Password) or security find-generic-password -s capycal-work -w (macOS Keychain). If the command fails or prints nothing, the sync does not start; its error output goes to Capycal’s standard error (the journal under systemd), its regular output (the password) is never shown. A keyring that needs an unlocked desktop session doesn’t work in a system service.
  • keychain: an entry in the macOS Keychain or, on Linux, the Secret Service (GNOME Keyring, KWallet). The entry has the service app.capycal and the name given here as its account (username attribute). To set one by hand: security add-generic-password -s app.capycal -a work -w on macOS, secret-tool store --label "capycal work" service app.capycal username work on Linux. A server or a system service without a desktop session has no Secret Service; use env or file there.
  • Plain string: kept in the config file. Not recommended; if you use it, make the config readable only by you (chmod 600).

Credentials are resolved when the syncs start and on every config reload, so a missing variable or file shows up on capycal --once, not on --validate. Capycal never logs passwords.

Use an app-specific password where the provider offers one (iCloud, Fastmail, Nextcloud).

Syncs

Each [syncs.<name>] table copies events from one calendar to another. The key after syncs. is the name of the sync.

In the app: Add Sync… asks for the source, the target and what to copy, and writes the table. The sync’s page in Settings shows each key below as a step or under Advanced settings.

[syncs.work-to-blockers]
source = "work"
target = "blockers"
interval = "5m"
KeyRequiredDescription
name (table key)yesUnique name. Used for the state file, markers, --only, --purge and the {{sync_name}} placeholder
sourceyesCalendar key to read from
targetyesCalendar key to write to, different from source
intervalyesTime between cycles, e.g. "30s", "5m", "1h"
fieldsnoContent properties to copy on top of time, recurrence and availability (allow-list), default none
transformsnoList of transforms, applied in order
full_sync_intervalnoTime between automatic full syncs, default "7d"
eventkit_days_pastnoSee Calendar.app query window
eventkit_days_futurenoSee below
eventkit_all_eventsnoSee below

Name

In the app: click the name at the top of the sync’s page to rename it. Settings removes the copies in the target first, after asking, and copies the source again.

The name is the key of the sync’s table: [syncs.work-to-blockers] names the sync work-to-blockers. It must not be empty, contain / or \, or start with a dot. Names with spaces, dots or other characters outside letters, digits, _ and - need quotes: [syncs."work to blockers"]. TOML rejects two syncs with the same name. Syncs keep their order from the file. The name is part of every target event’s UID and marker, so renaming a sync makes Capycal create its events again while the old ones stay. Run capycal --purge <old name> (or Remove Synced Events in the desktop app) before renaming.

interval

In the app: Check every in the From step: 1, 5 or 15 minutes, or Custom.

A humantime duration such as "90s", "5m", "1h 30m". A cycle is cheap when nothing changed: CalDAV sources use RFC 6578 sync tokens or a ctag check, Calendar.app sources compare a local query. "5m" is a good default; the desktop app’s Sync Now and a reload start a cycle immediately. After a cycle that failed or could not write every event, Capycal tries again sooner: after 1, 2, 5 and then every 15 minutes, never later than interval, until a cycle succeeds.

fields

In the app: the Keep step: Only the time (no fields), Title, Title, notes, location, reminders, or Custom with a checkbox per field.

Every copied event keeps what defines when it takes place and how it blocks time: DTSTART, DTEND, DURATION, RRULE, RDATE, EXDATE, STATUS and TRANSP, plus UID (replaced by Capycal), DTSTAMP, RECURRENCE-ID and the VTIMEZONE definitions the kept properties refer to. Dropping any of them would put the copy at the wrong time or turn a series into a single event, so they are not configurable.

fields is an allow-list of the content properties to copy on top (uppercase, as in the iCal data). Everything else is dropped after the transforms have run, except properties a set_field transform sets. Without fields, a sync creates busy blockers: the times, recurrence, status and free/busy flag of the original and nothing else. "VALARM" keeps the event’s alarm components as a whole. Listing a structural property such as "DTSTART" is allowed and changes nothing.

Typical choices:

Goalfields
Busy blockerleave it out
Keep the source’s reminders["VALARM"]
Copy the visible details["SUMMARY", "LOCATION", "DESCRIPTION"]
Keep categories and privacy classadd "CATEGORIES", "CLASS"

Notes:

  • TRANSP keeps events marked “free” as free in the target. To show every blocker as busy, remove it with a remove_field transform.
  • Recurrence: a recurring CalDAV event is copied as one series. Modified single occurrences are stored as extra VEVENTs with their RECURRENCE-ID. Calendar.app sources deliver every occurrence as its own event.
  • Only events are copied. Tasks and journal entries (VTODO, VJOURNAL), which some servers such as Nextcloud keep in the same calendar, are skipped.
  • Cancelled events (STATUS:CANCELLED on the event or the whole series) are not copied, and a copy Capycal made earlier is removed. A cancelled single occurrence of a series is copied as cancelled, so the series stays intact.
  • Privacy: you don’t need SUMMARY in fields to set a title. A set_field transform inserts it. Leaving SUMMARY, DESCRIPTION, LOCATION, ATTENDEE, ORGANIZER and URL out is the safe default for blockers.
  • Calendar.app sources only provide DTSTART, DTEND, SUMMARY, LOCATION, DESCRIPTION, TRANSP and VALARM. See macOS and Calendar.app for what Calendar.app targets write.

How an event is processed

For each new or changed source event, every cycle:

  1. Events that carry a Capycal marker are skipped, so blockers are never copied back. Cancelled events are skipped too, and an earlier copy is removed. Tasks and journal entries in the same calendar are never copied.
  2. The transforms run in order on the whole source event, so they can look at properties the sync does not copy. A skip can leave the event out here.
  3. Time, recurrence, status and availability (DTSTART, DTEND, DURATION, RRULE, RDATE, EXDATE, STATUS, TRANSP), UID, DTSTAMP, RECURRENCE-ID, the properties in fields and those a set_field set in this event are kept. Timezone definitions referenced by the kept properties are carried over. Alarms of add_alarm are added after this step.
  4. Capycal adds its markers, replaces the UID with one derived from the sync name and the source UID, and writes the event to the target.

When a source event is deleted (or, for Calendar.app sources, leaves the query window), its copy in the target is deleted too.

Changing fields or transforms only affects events that change afterwards, until the next automatic full sync. To apply the new rules to all events now, run a full sync.

Calendar.app query window

In the app: Calendar.app events under Advanced settings: the days back and ahead, or All events.

A Calendar.app source has no change log, so Capycal queries a time window every cycle:

KeyDefaultDescription
eventkit_days_past30Days before now to include
eventkit_days_future365Days after now to include
eventkit_all_eventsfalseQuery 20 years back and 4 years ahead in chunks, ignoring the two options above

The options are set on the sync and only apply when its source is a Calendar.app calendar. They are accepted on Linux too, so a config stays portable.

Events that leave the window (older than eventkit_days_past) count as removed and their blockers are deleted from the target.

eventkit_days_past + eventkit_days_future must be at most 1460 days: Calendar.app returns nothing for a single query over about four years, so a larger window is rejected as a config error. Use smaller values, or eventkit_all_events = true, which splits its range into several queries and is not limited by this.

Disabling a sync

In the app: the switch next to the sync’s name.

enabled = false keeps a sync in the config but stops it:

[syncs.work-to-home]
source = "work"
target = "home"
interval = "5m"
enabled = false

A disabled sync is still validated, so its calendars and transforms must be correct. The daemon starts no runner for it, --once skips it and --status shows it as disabled. --purge and “Remove Synced Events” in the app still remove its events. The first cycle after enabling it again is a full sync, so whatever changed in the meantime is picked up.

Two syncs between the same calendars

Two syncs in opposite directions (A to B and B to A) are safe: events created by Capycal carry a marker and are never synced back. See Two-way blockers.

Transforms

Transforms change each event before it is written to the target, or leave it out. They are the transforms list of a sync and run in the order they appear, on the whole source event; fields decides afterwards which properties are copied. Every transform can have conditions, so it only changes the events they match:

In the app: each transform is a step between Keep and Into. The + between two steps adds one there, its menu also offers common steps that come filled in. Each action below names its step.

[syncs.work-to-home]
source = "work"
target = "home"
interval = "5m"
transforms = [
  { field = "SUMMARY", action = "set_field", value = "Busy" },
  { action = "skip", when = [{ all_day = true }] },
]

Each transform is one inline table. The list may span several lines and contain comments (TOML 1.1). The option reference also shows the [[syncs.<name>.transforms]] table form.

ActionEffectParameters
set_fieldSet a property to a fixed valuefield, value
replace_fieldReplace text in a propertyfield, search, replacement, count
replace_field_regexFind and replace in a property with a regexfield, pattern, replacement
remove_fieldRemove a propertyfield
add_bufferStart earlier and/or end laterpre_event, post_event
add_alarmAdd a remindertrigger
skipLeave out the events its conditions matchwhen (required)

Every action takes when, a list of conditions.

The examples on this page show only the transforms list; it goes into the table of a sync.

set_field

In the app: Set field.

Sets field to value. An existing property line is replaced as a whole (including parameters such as LANGUAGE=); if the event has no such property, it is added. The property doesn’t need to be in fields.

transforms = [
  { field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]

value is written as is. For text properties such as SUMMARY, escape , ; and \ as \, \; \\ (see Text escaping).

replace_field

In the app: Replace text.

Replaces search with replacement in the value of field: every occurrence, or only the first count ones. Both are plain text, without regex; replacement may contain placeholders. Like replace_field_regex, it needs the property in fields, and it sees the escaped value (see Text escaping).

transforms = [
  # "Zoom: Weekly" -> "Call: Weekly"
  { field = "SUMMARY", action = "replace_field", search = "Zoom", replacement = "Call" },
  # Only the first "-": "2024-01-15 - Review" -> "2024 01-15 - Review"
  { field = "SUMMARY", action = "replace_field", search = "-", replacement = " ", count = 1 },
]

replace_field_regex

In the app: Replace regex.

Replaces every match of pattern in the value of field with replacement. Only the value is searched, not the property name or parameters. Events without the property are left alone.

The property must be copied, so it has to be in fields (or be always kept, or be added by an earlier set_field); otherwise the config is rejected, since the transform would do nothing. Keep in mind that events the pattern does not match keep the original value. To hide a property, use set_field instead, which replaces it in every event.

transforms = [
  # "Call with ACME" -> "Call", "Lunch with Bob" -> "Lunch"
  { field = "SUMMARY", action = "replace_field_regex", pattern = ' with .*$', replacement = "" },
]

pattern uses Rust regex syntax; (?i) makes it case-insensitive. replacement can refer to groups as $1 or ${name}; write $$ for a literal $. Use TOML literal strings (single quotes) for patterns, so backslashes need no doubling.

transforms = [
  # Keep only a ticket number: "Review [OPS-123] rollout" -> "OPS-123"
  { field = "SUMMARY", action = "replace_field_regex", pattern = '^.*\[(?P<ticket>[A-Z]+-\d+)\].*$', replacement = '${ticket}' },
]

remove_field

In the app: Remove field.

Removes every line of property field.

transforms = [
  { field = "LOCATION", action = "remove_field" },
]

Usually it is simpler to leave a property out of fields. remove_field is useful after a broad fields list, to drop something a previous transform added, or to drop an always-kept property such as TRANSP (every blocker then shows as busy).

add_buffer

In the app: Add buffer.

Moves DTSTART earlier by pre_event and DTEND later by post_event. At least one of them is required. All-day events are not changed.

transforms = [
  { action = "add_buffer", pre_event = "30m", post_event = "15m" },
]

For events stored with DURATION instead of DTEND, the duration grows by pre_event plus post_event. In a recurring series, the dates that name single occurrences (EXDATE, RDATE, RECURRENCE-ID) move with DTSTART, so excluded and changed occurrences still match. Buffers are real time: a 30 minute buffer before an event at 03:00 on the night clocks spring forward starts at 01:30, not at the skipped 02:30, as long as Capycal knows the event’s timezone: from its definition in the event (VTIMEZONE, which CalDAV servers include) or, for IANA names such as Europe/Berlin (Calendar.app sources), from the built-in timezone database.

add_alarm

In the app: Add alarm.

Adds a display alarm (“Reminder”) trigger before the start. Repeat the transform for several alarms.

transforms = [
  { action = "add_alarm", trigger = "15m" },
  { action = "add_alarm", trigger = "1m" },
]

Alarms from the source are only copied with "VALARM" in fields; add_alarm adds to those. See Travel buffers and reminders.

skip

In the app: Skip events. It starts with one condition and keeps at least one.

Leaves out the events that match all its conditions. Later transforms don’t run for them, and a copy synced earlier is deleted from the target. skip without when is a config error, since it would skip every event.

transforms = [
  # Leave out weekend events
  { action = "skip", when = [{ days = ["sat", "sun"] }] },
  # Leave out all-day events
  { action = "skip", when = [{ all_day = true }] },
  # Leave out events with "private" in the title, in any case
  { action = "skip", when = [{ field = "SUMMARY", matches = "(?i)private" }] },
  # Leave out events without a location
  { action = "skip", when = [{ field = "LOCATION", empty = true }] },
]

Since transforms see the whole event, a condition can look at a property the sync does not copy: a busy blocker without fields can still leave out events by their title.

Recipes: Weekends, nights and business hours.

Conditions

In the app: open a step and press Add Condition. The first condition reads Only if (If for Skip events), the others if, after a switch between and and or that joins them to the one above. Each has a popup for what it looks at (Title, Location, Notes, Other field, Start, Event), one for the test, and its text, days or times; drag it by its handle (≡) to another place, or use the arrows (Move Up, Move Down) to swap it with its neighbour; Remove takes it away.

when is a list of conditions. A transform with conditions only changes an event when all of them match; without when it changes every event.

transforms = [
  # 45 minutes of travel time, only for events at a place that is not a video call
  { action = "add_buffer", pre_event = "45m", post_event = "30m", when = [
      { field = "LOCATION", empty = false },
      { field = "LOCATION", contains = "zoom.us", not = true },
  ] },
]

One of several

In the app: switch the and between two conditions to or. Conditions joined by or share a box, One of these; switching back to and splits it. To join conditions that are not next to each other, drag one into the box: dropped inside it, at its top, between two of its conditions or at its bottom, it joins them with or. Dropped outside a box, right above or below one included, it comes with and.

{ any = [...] } in when is a group of conditions of which one is enough. It stands for one condition among the others, which still all have to match:

transforms = [
  # An earlier alarm for events at the airport or at the station
  { action = "add_alarm", trigger = "2h", when = [{ any = [
      { field = "LOCATION", contains = "Airport" },
      { field = "LOCATION", contains = "Station" },
  ] }] },
  # Leave out weekday events that are all-day or marked as out of office
  { action = "skip", when = [
      { any = [{ all_day = true }, { field = "SUMMARY", contains = "OOO" }] },
      { days = ["mon", "tue", "wed", "thu", "fri"] },
  ] },
]
  • any holds at least two conditions, takes no other keys and cannot hold another any.
  • For “this and that, or something else”, use two transforms: two skips leave out what either matches. Steps that would add twice, such as add_alarm or add_buffer, need any instead.

Tests

Each condition has exactly one test:

TestKeysMatches whenIn the app
containsfield, containsthe value contains the text, case matterscontains, does not contain
matchesfield, matchesthe value has a match of the regexmatches regex, does not match regex
emptyfield, empty = truethe property is missing or empty; empty = false: it has a valueis empty, is not empty
timedays, optional start_time, end_timethe event starts on one of the days, within the timesStart with is on, is not on, the days, From and Until
all-dayall_day = truethe event is all-day (DTSTART with VALUE=DATE); all_day = false: it is notEvent with is all-day, is not all-day
  • not = true turns contains, matches and time around. empty and all_day say it with their value, so not is an error there.
  • field is an iCal property name in upper case such as "SUMMARY" (Title), "LOCATION" (Location) or "DESCRIPTION" (Notes); any other is Other field. Only contains, matches and empty take a field.
  • A property that appears several times, such as ATTENDEE or CATEGORIES, matches when any of its lines does; “does not contain” means none of them does, and “is empty” that all are empty.
  • A missing property counts as an empty value: contains doesn’t match it, matches = "^$" does.
  • The value is unescaped first (see Text escaping), so Lunch, Room 5 matches as written.
  • matches uses Rust regex syntax and looks for a match anywhere in the value; use ^ and $ for the whole value and (?i) to ignore case.
  • Conditions look at the series itself (the VEVENT without RECURRENCE-ID). A transform whose conditions match changes, or skips, the whole resource, so a single occurrence moved into or out of a time range follows its series. The same holds for its text: a single occurrence renamed to “Doctor” is not checked against a condition on SUMMARY, only the series title is.

Time conditions

days are "mon", "tue", "wed", "thu", "fri", "sat", "sun" (lowercase). start_time and end_time are HH:MM and both inclusive at minute precision; they default to "00:00" and "23:59", so without times the whole day matches.

transforms = [
  # Leave out events starting after 18:00 on weekdays
  { action = "skip", when = [{ days = ["mon", "tue", "wed", "thu", "fri"], start_time = "18:00" }] },
  # Leave out events outside working hours: before 08:00 or from 18:00 on weekdays
  { action = "skip", when = [{ days = ["mon", "tue", "wed", "thu", "fri"], start_time = "18:00", end_time = "07:59" }] },
]
  • Times are in the timezone of the machine running Capycal. The event’s start is converted from UTC or its TZID first; floating times are taken as local.
  • Only the start counts. An event from 17:00 to 19:00 does not match start_time = "18:00".
  • A start_time after the end_time wraps around midnight: start_time = "22:00", end_time = "05:59" matches starts from 22:00 to midnight and from midnight to 05:59 on each of days. Both parts belong to the day the event starts on: with days = ["fri"], Friday 23:00 matches, Saturday 01:00 doesn’t.
  • All-day events have no start time: they match a time condition without times on their weekday, and never one with start_time or end_time, not even with not = true. To match them, add a condition with all_day.
  • The desktop app’s common step Skip outside working hours fills in Mon-Fri, 18:00 to 07:59; Skip weekends fills in Saturday and Sunday without times.

Placeholders

value (of set_field) and replacement (of replace_field and replace_field_regex) may contain:

PlaceholderValue
{{source_name}}Key of the source calendar, e.g. work for [calendars.work]
{{source_url}}URL of a CalDAV source, eventkit://local for Calendar.app, ics://feed for a feed (its address is a secret)
{{sync_name}}Name of the sync (its table key)

An unknown placeholder is a config error. Placeholder values are inserted without escaping.

Order

Transforms run top to bottom on the whole source event, each on the result of the previous one. fields is applied after the last one:

  • A skip leaves the event out as soon as its conditions match; later transforms don’t run.
  • Conditions see the event as the transforms above them left it: a time condition sees the start moved by an earlier add_buffer, a condition on SUMMARY the title set by an earlier set_field. Put a transform above the others to look at the original values. A condition on a property that an earlier set_field or remove_field without conditions already fixed would always give the same answer, so it is a config error that names the transform to move.
  • A property a set_field sets is copied, even if it is not in fields, but only in the events its conditions matched. In the others the property stays out unless it is in fields, so a title that should have become “Busy” is not copied from the source either.
  • replace_field and replace_field_regex change only properties that are copied, so their property has to be in fields (or be set by an earlier set_field).
  • add_alarm checks its conditions at its place but adds its alarm after fields, so the source event’s own alarms are only copied with "VALARM" in fields.

Scope

  • set_field, replace_field, replace_field_regex, remove_field and add_buffer change the event’s own properties in every VEVENT of the resource. A recurring CalDAV series stores modified occurrences as extra VEVENTs, so a set_field of SUMMARY covers them too. Alarms (VALARM) and timezone definitions (VTIMEZONE) are never touched; add_alarm adds an alarm to every VEVENT.
  • Conditions look at the series itself (the VEVENT without RECURRENCE-ID); a skip keeps or leaves out the whole resource.
  • An event that was synced earlier and is skipped later (for example, moved to a weekend) is deleted from the target, the same way as an event deleted in the source.

Text escaping

Transforms work on the raw iCal values, where text is escaped as in RFC 5545 (except conditions, which match the unescaped value). This also applies to Calendar.app sources: their SUMMARY, LOCATION and DESCRIPTION are escaped before transforms run. A regex or a search of replace_field therefore has to match \,, \; and \\ instead of ,, ; and \, and a value or replacement should be escaped the same way. A line break in a description is \n.

To rewrite Lunch, Room 5 (stored as Lunch\, Room 5) to Lunch:

transforms = [
  # pattern is a TOML literal string; as a basic string: "\\\\, Room \\d+"
  { field = "SUMMARY", action = "replace_field_regex", pattern = '\\, Room \d+', replacement = "" },
]

Full sync

A normal cycle only processes what changed in the source since the last one. A full sync compares the whole source with the target:

In the app: Full Sync Now under Advanced settings > Troubleshooting starts one, Full sync every sets full_sync_interval.

  1. Read every current event of the source (for a Calendar.app source: its query window).
  2. Find the events this sync created in the target by their marker. The state is rebuilt from these markers, so the state files are a cache that can be lost.
  3. Create missing events and update events whose content differs. Remove Capycal’s own events that are duplicates, whose source event is gone, or that the current fields and transforms exclude. Events Capycal did not create are never touched, and neither is the source.

When events disappear from the source

When events are gone from the source and it now has less than half as many events as were synced (from 5 synced events on), Capycal waits one cycle before it removes their copies. A source can be briefly empty or incomplete without an error, for example Calendar.app still loading its accounts right after login. If the events are back in the next cycle, nothing was removed; if they are still gone, for example because you cleared the calendar, the next cycle removes them. A source that replaced its events with as many new ones is not held back. The log says “most synced events are gone from the source, removing them in the next cycle”, and the desktop app’s menu shows “synced …, N to remove next cycle”. Failed or incomplete answers of the source (errors, cut-off listings) never remove anything.

Unchanged events

An event whose stored content is unchanged is not written again. On a CalDAV target the comparison ignores the DTSTAMP, LAST-MODIFIED, CREATED and SEQUENCE lines, line folding and line endings. On a Calendar.app target Capycal compares the fields it writes (title, location, notes with the marker, start, end, all-day flag, alarms and availability; times as instants, alarms in any order), so unchanged events keep their modification date and are not uploaded to the account again. An event with an old-style marker is written again to update the marker.

When it runs

A full sync runs:

  • when a sync has no usable state: its first start, after the state directory was lost, or when the state file is corrupt (it is moved aside, see Troubleshooting);
  • every full_sync_interval, counted from the last full sync. State files from versions before full syncs count from their first cycle, so an upgrade doesn’t run every full sync at once;
  • after a change of the sync’s source or target calendar, fields, transforms or Calendar.app query window, so existing copies get the new content and a new target gets every event. A change of interval or full_sync_interval alone does not trigger one. Copies in an old target stay there: run capycal --purge <sync> before you change the target or rename the sync;
  • on the first cycle after a disabled sync is enabled again;
  • when this sync’s events in a CalDAV target were changed or removed by someone else: another computer running the same sync, a purge there, or a hand edit (see One sync on several computers);
  • on request: capycal --full-sync (see CLI reference) or Full Sync Now in the desktop app (Settings, the sync’s Advanced settings > Troubleshooting).

full_sync_interval is a humantime duration greater than zero, default "7d". A full sync of a CalDAV target downloads every event of the target calendar, so on large calendars keep it long. A full sync stopped by a shutdown or reload, or one that failed, keeps its progress and is not recorded; the next cycle is a full sync again, also when it was only requested.

Calendar.app targets: Capycal marks its events with a notes line naming the sync, the source calendar and the source event. Events written by older versions carry only [capycal:<source>]. A full sync rewrites the marker of such events when the state knows them; without state it deletes them and creates them again with the full marker. If two syncs read the same source into the same Calendar.app calendar, an old-style event of one sync is not in the state of the other, so the first full sync of the other may delete it; its own sync creates it again at its next full sync.

One sync on several computers

You can run the same syncs on two or more computers, for example a Mac and a Linux laptop you switch between, so the copies stay current whichever one is on. Give every computer the same config, for example through Syncthing, a cloud folder or Git:

  • Keep the sync names the same. A copy’s address and marker are derived from the sync name and the source event, so every computer writes the same copy instead of a second one.
  • Keep the state local. Leave state_dir out (each computer uses its OS default) or set CAPYCAL_STATE_DIR per computer; never share the state directory.
  • On a Mac the default state directory is inside the config’s folder (~/Library/Application Support/capycal/state/), so share only the file: keep it in the shared folder and link it, for example ln -s ~/Sync/capycal.toml ~/Library/Application\ Support/capycal/capycal.toml, or start the app with -c (see Another config file). Settings writes through the link.
  • Credentials from the Keychain or the Secret Service are stored per computer, so set the password on each one. { env = "..." } and { command = "..." } work if each computer provides them.

Each computer keeps its own state, so it does not see what another one wrote. Writes of the same source event go to the same address, so normal cycles converge on their own. What a computer has to notice is a change in the target without a change in the source: the other computer removed the copies (--purge, Remove Synced Events), wrote them with other settings, or you edited or deleted a copy by hand. Capycal asks a CalDAV target at the start of each cycle what changed since its own last write (RFC 6578 sync-collection, without downloading events). If one of this sync’s own events changed, the cycle is a full sync, which restores the copies; the log says “synced events changed in the target, running a full sync”. Changes to other events in the calendar, including the copies of other syncs, do not count.

Limits:

  • Only CalDAV targets. A Calendar.app target is not checked, a full sync there runs only on the other triggers above.
  • The target server must support sync-collection; Nextcloud, Radicale, Baikal, SabreDAV, Fastmail, iCloud and most others do. Without it Capycal logs this at debug level and relies on full_sync_interval.
  • A change made elsewhere while a cycle writes to the target counts as the cycle’s own and is picked up by the next full sync.
  • Switching a sync off (enabled = false) or changing its transforms reaches the other computer with the config. A purge with the desktop app writes enabled = false, so the other computer stops too; a purge with --purge does not, and the other computer restores the copies at its next cycle.

macOS and Calendar.app

On macOS, Capycal reads and writes Calendar.app calendars through Apple’s EventKit framework. Every account Calendar.app knows works: iCloud, Exchange, Google, CalDAV and local calendars. Capycal does not talk to Google or Exchange itself; Calendar.app does the syncing with the provider.

Permissions

macOS asks for calendar access the first time Capycal reads Calendar.app. Who gets the permission depends on how Capycal runs:

Started fromPermission belongs to
Desktop app“Capycal”
Terminal, iTerm, …the terminal app
LaunchAgentthe capycal binary

Change it in System Settings > Privacy & Security > Calendars. Capycal needs full access, since it writes events too. If the prompt is not answered within two minutes, the sync fails and is retried on the next cycle.

What is read

A Calendar.app source provides these properties per event:

iCalFrom Calendar.app
DTSTART, DTENDstart and end, with the event’s timezone (TZID); all-day events as dates
SUMMARYtitle
LOCATIONlocation
DESCRIPTIONnotes (without Capycal’s marker line)
TRANSPavailability: “free” becomes TRANSPARENT, busy, tentative and unavailable become OPAQUE
VALARMalarms, relative or absolute

Attendees, organizer, URL, status and recurrence rules are not available. Cancelled events are skipped.

Recurring events

A Calendar.app source delivers recurring events as individual occurrences, without RRULE. Each occurrence becomes its own event in the target. The reason: when you delete a single occurrence of a series, EventKit simply stops returning it. A copied RRULE would bring it back in the target; single occurrences mirror exactly what Calendar.app shows.

Query window

There is no change log in EventKit, so every cycle queries a time window around now: by default 30 days back and 365 days ahead (eventkit_days_past, eventkit_days_future on the sync). Events leaving the window are removed from the target.

EventKit returns nothing for a single query spanning more than about four years. Keep the sum of both options at or below 1460 days. eventkit_all_events = true instead queries 20 years back and 4 years ahead in four-year chunks. See Syncs.

Timezones and all-day events

Timed events keep their timezone (DTSTART;TZID=Europe/Berlin:...), so local times survive daylight saving changes. All-day events use local dates; their iCal end is the day after the last day, which Capycal converts from and to Calendar.app, where an all-day event ends at the last second of its last day. Time conditions of transforms compare in the Mac’s timezone.

Events Capycal writes to Calendar.app carry the timezone of their source event (UTC for UTC times), so they stay at the right moment when you travel; all-day events and times without a timezone stay floating, like in the source. A full sync adds the timezone to timed events written by earlier versions.

When writing to Calendar.app, Capycal translates timezone names macOS doesn’t know: Windows names such as W. Europe Standard Time (from Outlook and Exchange) become their IANA zone, and prefixed names such as /freeassociation.sourceforge.net/Europe/Berlin lose the prefix. Another unknown name is converted to UTC with the VTIMEZONE the event carries. Times without a timezone (floating) are local times; a name that still can’t be resolved falls back to local time with a warning in the log.

What is written

A Calendar.app target sets title, location, notes, start, end, all-day flag, availability, alarms and recurrence from the synced event:

  • DTEND, or DURATION if there is no DTEND, sets the end.
  • TRANSP:TRANSPARENT sets availability “free”, anything else “busy” (if the calendar supports availability).
  • Alarms mirror the synced VALARMs: alarms of the target event are replaced on every update. Leave VALARM out of fields and don’t use add_alarm for blockers without reminders.
  • STATUS can’t be set; EventKit treats it as read-only. Cancelled events are not copied anyway, and cancelled single occurrences of a series are removed from it.
  • A recurring event (RRULE, from a CalDAV source) becomes a series in Calendar.app with the same rule. EXDATEs remove single occurrences, moved or changed occurrences (RECURRENCE-ID) are changed in the series. When the series changes, Capycal deletes it and writes it again as a whole, and a full sync always rewrites it.
  • Calendar.app can’t represent every RRULE: hourly or more frequent rules, BYHOUR/BYMINUTE/BYSECOND and several rules on one event are logged as a warning, and only the first occurrence is written. Extra dates (RDATE) are left out with a warning.
  • Removing a series (deleted in the source, --purge, Remove Synced Events) removes all its occurrences.

Marker

EventKit doesn’t keep custom iCal properties, so Capycal marks the events it creates with a line [capycal:sync=<sync>;source=<source calendar>;uid=<source event UID>] at the end of the notes (values containing %, ;, =, ] or line breaks are percent-encoded). The marker prevents sync loops when the calendar is also a source, and lets a full sync rebuild the state and purge find Capycal’s events. Keep it when you edit such an event. Events written by older versions carry [capycal:<source calendar>]; Capycal still reads it and replaces it with the full marker the next time it writes the event.

Calendar.app as source and target

Every combination works: Calendar.app to CalDAV, CalDAV to Calendar.app and Calendar.app to Calendar.app, for example from an Exchange calendar to an iCloud calendar in the same Calendar.app. See the desktop app quick start and the Cookbook.

Recipes

Common setups, each first as the steps in the desktop app’s Settings, then as a complete config. Each config passes capycal --validate; replace URLs, user names and calendar names with yours. Recipes that use Calendar.app calendars only validate on macOS.

All recipes use a dedicated, empty calendar or the main calendar of the other side as the target. Capycal only changes and deletes events it created itself, recognised by their marker.

Work calendar as private blockers

Your work meetings appear as “Busy (work)” in a calendar your family can see. No titles, locations, attendees or descriptions leave the work calendar.

In the app: Add Sync…, the work calendar as the source, the calendar your family sees as the target, and Busy blocker. The copies are titled “Busy (Work)” after the source calendar.

In the config:

[daemon]
log_level = "info"

[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }

[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { file = "nextcloud.secret" }

[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"

# An empty calendar on the family Nextcloud, shared with the family
[calendars.work_blockers]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"

[syncs.work-to-family]
source = "work"
target = "work_blockers"
interval = "5m"

transforms = [
  { field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]

What happens:

  • Without fields, only the times, the recurrence rules, the status and the free/busy flag are copied. SUMMARY is not copied; the set_field transform adds a new one.
  • Events you marked as “free” stay free, so they don’t block the family calendar. Cancelled meetings are not copied.
  • Recurring meetings stay a single series in the target.
  • nextcloud.secret is read from the config’s directory.

If the work calendar is in Calendar.app (Exchange, Google), use Calendar.app to Nextcloud instead.

Variations:

  • Add fields = ["LOCATION"] to see where a meeting takes place.
  • Add { action = "skip", when = [{ all_day = true }] } (in the app Skip events with Event set to is all-day) if all-day events (holidays, out-of-office) shouldn’t block a whole day; see skip.

Two-way blockers

Work and personal calendars block each other: private appointments appear as “Private” in the work calendar, work meetings as “Work” in the personal calendar. Both syncs write straight into the main calendars, so colleagues and family see the blockers without subscribing to anything.

In the app: Add Sync… twice, work to personal and personal to work, each with Busy blocker. Change the title in each sync’s Set field step to “Work” and “Private”, and add Skip events with Event set to is all-day with + to the second.

In the config:

[daemon]
log_level = "info"

[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }

[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_PERSONAL_PASSWORD" }

[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"

[calendars.personal]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/personal/"

[syncs.work-to-personal]
source = "work"
target = "personal"
interval = "5m"

transforms = [
  { field = "SUMMARY", action = "set_field", value = "Work" },
]

[syncs.personal-to-work]
source = "personal"
target = "work"
interval = "5m"

transforms = [
  { field = "SUMMARY", action = "set_field", value = "Private" },
  { action = "skip", when = [{ all_day = true }] },
]

Why this doesn’t loop

Every event Capycal writes carries two extra properties:

X-CAPYCAL-SOURCE:work/<uid of the original event>
X-CAPYCAL-SYNC:work-to-personal

When personal-to-work reads the personal calendar, it skips every event with an X-CAPYCAL-SOURCE property. The “Work” blockers are therefore never copied back into the work calendar, and the same holds the other way round. In Calendar.app, which can’t store custom properties, the marker is a [capycal:sync=...;source=...;uid=...] line at the end of the event notes.

Consequences:

  • Edit or delete the original, not the blocker. A blocker is overwritten on the next change of its original. A blocker deleted or changed by hand in a CalDAV target is restored by the next cycle when the server supports sync-collection (see One sync on several computers), otherwise by the next full sync.
  • Chains don’t work: with A to B and B to C, the blockers from A in B are skipped and never reach C. Sync A to C directly.
  • The CalDAV server must keep unknown X- properties. Common servers (Nextcloud, Radicale, Baikal, iCloud, Fastmail) do. If a server drops them, two-way syncs loop; use separate blocker calendars there, so no sync reads a calendar another sync writes to.

Weekends, nights and business hours

A skip with a time condition leaves out events by weekday and start time in the local timezone of the machine running Capycal. These recipes block only the work hours of your private calendar in the work calendar: private appointments on weekends, at night or in the evening are nobody’s business at work.

Only business hours

Mon-Fri, 08:00-18:00. Everything that starts outside this window is left out:

In the app: add the sync with Busy blocker, then add the common steps Skip weekends and Skip outside working hours with +, and Skip events with Event set to is all-day.

In the config:

[daemon]
log_level = "info"

[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_PERSONAL_PASSWORD" }

[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }

[calendars.personal]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/personal/"

[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"

[syncs.personal-to-work]
source = "personal"
target = "work"
interval = "10m"

transforms = [
  # Weekends
  { action = "skip", when = [{ days = ["sat", "sun"] }] },
  # Weekdays before 08:00 and from 18:00: the window wraps around midnight
  { action = "skip", when = [{ days = ["mon", "tue", "wed", "thu", "fri"], start_time = "18:00", end_time = "07:59" }] },
  # Holidays, birthdays and other all-day entries
  { action = "skip", when = [{ all_day = true }] },
  { field = "SUMMARY", action = "set_field", value = "Private appointment" },
]

Notes:

  • Only the start counts. A private appointment from 17:00 to 19:00 is copied and blocks until 19:00.
  • end_time is inclusive, so "07:59" keeps an 08:00 start.
  • A time condition with times never matches all-day events, not even with not = true, so holidays need their own skip with all_day = true.
  • A recurring CalDAV series is checked by its first start: the whole series is kept or left out together. Calendar.app sources deliver every occurrence as its own event, so each occurrence is checked on its own.

Nights

A window whose start is after its end wraps around midnight, so one condition covers the night:

In the app: one Skip events step with Start, every day ticked, From 22:00 and Until 05:59.

In the config:

[daemon]

[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }

[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_PERSONAL_PASSWORD" }

[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"

[calendars.family]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"

[syncs.work-to-family]
source = "work"
target = "family"
interval = "5m"

transforms = [
  # 22:00 - 05:59
  { action = "skip", when = [{ days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], start_time = "22:00", end_time = "05:59" }] },
  { field = "SUMMARY", action = "set_field", value = "Busy" },
]

Timezone

The times are compared in the system timezone of the machine running Capycal. On a server in UTC, start_time = "18:00" means 18:00 UTC. Set the server’s timezone, or for Docker mount the host’s zone into the container (/etc/localtime:/etc/localtime:ro).

Travel buffers and reminders

Travel buffers

On-site appointments in the personal calendar need travel time. add_buffer blocks 30 minutes before and 15 minutes after each event in the work calendar:

In the app: + > Add buffer with 30 minutes before and 15 after; Travel time among the common steps fills in 30 minutes on both sides. Skip events with Event set to is all-day goes before it.

In the config:

[daemon]

[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_PERSONAL_PASSWORD" }

[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }

[calendars.personal]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/personal/"

[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"

[syncs.personal-to-work]
source = "personal"
target = "work"
interval = "10m"

transforms = [
  { action = "skip", when = [{ all_day = true }] },
  { action = "add_buffer", pre_event = "30m", post_event = "15m" },
  { field = "SUMMARY", action = "set_field", value = "Private (incl. travel)" },
]
  • All-day events are never buffered. They are dropped here, because a blocked whole day in the work calendar is rarely wanted.
  • post_event extends DTEND. Events stored with DURATION instead of DTEND get a longer DURATION. Recurring events stay consistent: excluded and moved occurrences move with the series.
  • Put a skip with a time condition before add_buffer, so it looks at the original times.

Only events with a place

Travel time only makes sense for appointments somewhere. With conditions, the buffer goes only to events that have a location which is not a video call link; the others are still copied, without a buffer:

In the app: Travel time for events at a place among the common steps adds the buffer with Location is not empty. Open it, press Add Condition, choose Location, does not contain and type https://.

In the config, instead of the add_buffer above:

transforms = [
  { action = "skip", when = [{ all_day = true }] },
  { action = "add_buffer", pre_event = "30m", post_event = "15m", when = [
      { field = "LOCATION", empty = false },
      { field = "LOCATION", contains = "https://", not = true },
  ] },
  { field = "SUMMARY", action = "set_field", value = "Private (incl. travel)" },
]

An event without a location counts as empty. To leave out events without a place altogether, use action = "skip" with { field = "LOCATION", empty = true } instead.

Reminders on blockers

There are two ways to get alarms on the blockers:

Keep the source’s alarms: add "VALARM" to fields. Every alarm of the original is copied, so the blocker reminds you exactly like the original.

Add your own: add_alarm adds a display alarm a fixed time before the (buffered) start. Repeat it for several alarms.

In the app: to keep the source’s alarms, tick Reminders of the source event under Keep > Custom (Title, notes, location, reminders keeps them too). For your own, + > Add alarm, or Reminder 10 min before among the common steps.

In the config:

[daemon]

[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }

[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_PERSONAL_PASSWORD" }

[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"

[calendars.phone]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"

[syncs.work-to-phone]
source = "work"
target = "phone"
interval = "5m"

transforms = [
  { field = "SUMMARY", action = "set_field", value = "Work meeting" },
  { action = "add_alarm", trigger = "10m" },
  { action = "add_alarm", trigger = "1m" },
]

With both "VALARM" in fields and add_alarm, the blocker gets the source’s alarms plus yours. Without either, blockers have no alarms, which is usually what you want for blockers in a calendar other people see.

For Calendar.app targets, the target event’s alarms are replaced by the alarms of the synced data on every update.

Calendar.app to Nextcloud

Your employer’s Exchange (or Google Workspace) calendar is in Calendar.app on your Mac, but not reachable over CalDAV. Capycal reads it through EventKit and writes blockers to a Nextcloud calendar. Run it with the desktop app or a LaunchAgent.

In the app: Add Sync…, pick the Exchange calendar under its account in the source list (the app asks for calendar access first) and the Nextcloud calendar as the target. The window of days is Calendar.app events under Advanced settings.

In the config:

[daemon]
log_level = "info"

# The Exchange account's calendar as shown in Calendar.app
[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { file = "nextcloud.secret" }

[calendars.work]
eventkit_calendar = "Exchange/Calendar"

[calendars.nextcloud]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"

[syncs.work-to-nextcloud]
source = "work"
target = "nextcloud"
interval = "5m"
eventkit_days_past = 7
eventkit_days_future = 180

transforms = [
  { field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]
  • Find the exact eventkit_calendar value with capycal --discover-eventkit. The account name (“Exchange” here) is the one in Calendar.app’s sidebar; use the identifier if names are ambiguous.
  • Calendar.app delivers recurring events as single occurrences, so the target gets one event per occurrence within the window.
  • The window here is one week back and six months ahead, which keeps each cycle fast. Blockers older than a week are removed from Nextcloud.
  • nextcloud.secret is read from the config’s directory (~/Library/Application Support/capycal/ by default). Make it readable only by you.

This config only validates on macOS. On Linux, --validate reports eventkit_calendar is only supported on macOS.

Calendar.app as target

Calendar.app can also receive events. This is the way to write blockers into a calendar that Capycal can’t reach over CalDAV, such as an Exchange calendar your colleagues see. Here private appointments from Nextcloud appear as “Private” in the work calendar:

In the app: Add Sync… with the Nextcloud calendar as the source and the Exchange calendar under its account as the target, then set the title in the Set field step to “Private”.

In the config:

[daemon]
log_level = "info"

[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { file = "nextcloud.secret" }

[calendars.personal]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/personal/"

# Your Exchange calendar in Calendar.app
[calendars.work]
eventkit_calendar = "Exchange/Calendar"

[syncs.personal-to-work]
source = "personal"
target = "work"
interval = "10m"

transforms = [
  { field = "SUMMARY", action = "set_field", value = "Private" },
]

What to know about Calendar.app targets:

  • Title, location, notes, start, end, all-day, availability, alarms and recurrence are written. A recurring CalDAV event becomes a series in Calendar.app, with its excluded and moved occurrences. Rules Calendar.app can’t represent (hourly, BYHOUR, several rules) are written as their first occurrence only, with a warning in the log. See What is written.
  • Capycal marks its events with a [capycal:sync=...;source=personal;uid=...] line at the end of the notes. Don’t remove it; it prevents loops and lets a full sync and purge find the events.
  • The target event’s alarms are replaced with the alarms of the synced data on every update. With neither "VALARM" in fields nor add_alarm, blockers have no alarms.
  • TRANSP:TRANSPARENT becomes availability “free”; everything else is “busy”.
  • --purge <sync> (or Remove Synced Events in the desktop app) deletes the events of a sync by this marker. --purge-all empties a dedicated calendar.
  • Calendar.app sends the events to Exchange, Google or iCloud as it normally would. Your Mac has to be running for the blockers to update.

This config only validates on macOS.

Google Calendar as blockers

Your Google Calendar appears as “Busy (google)” in your work calendar, without a Google password or OAuth: Capycal reads the calendar’s secret iCal address. The same works with Outlook.com and any other service that publishes a calendar as an .ics address. See Calendar feeds for where to find the address.

In the app: in Google Calendar, copy Settings for my calendars, the calendar, Integrate calendar, Secret address in iCal format. Then in Capycal Add Sync…, Add Calendar Feed… below the calendars, paste the address into Feed address and Continue; pick the work calendar as the target and Busy blocker. In the sync’s From, set Check every to Custom and 30m.

In the config:

[daemon]
log_level = "info"

[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }

# The secret address, kept out of the config file
[calendars.google]
ics_url = { env = "CAPYCAL_GOOGLE_FEED" }

[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"

[syncs.google-to-work]
source = "google"
target = "work"
# Google refreshes the feed only every few hours
interval = "30m"

transforms = [
  { field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]

What happens:

  • Every 30 minutes Capycal downloads the feed and copies new, changed and removed events. Google itself updates the feed with a delay of several hours, so a new private appointment can take that long to block your work calendar. For changes within minutes, read Google through Calendar.app on a Mac instead (Calendar.app to Nextcloud).
  • Without fields, only times, recurrence, status and free/busy are copied; an event you marked as “free” in Google stays free.
  • The feed is read-only: Capycal never writes to Google. A feed cannot be the target of a sync.
  • Anyone with the secret address can read the calendar. If it leaks, Reset it in Google Calendar and enter the new one.

Option reference

Every option the config parser accepts. Durations use humantime syntax ("30s", "15m", "1h 30m"). Unknown keys are errors.

[daemon]

The table and all its options are optional.

OptionTypeDefaultDescription
state_dirpath$CAPYCAL_STATE_DIR, else Linux ~/.local/state/capycal, macOS ~/Library/Application Support/capycal/stateState files and config locks. Wins over CAPYCAL_STATE_DIR
log_levelstring"info"trace, debug, info, warn, error or a tracing filter directive. RUST_LOG and -v take precedence. Applied again on reload
log_format"pretty" | "json""pretty"CLI log output format
watch_configboolfalseCLI daemon: reload when the config file changes. The desktop app always does

Details: Daemon settings.

[accounts.<name>]

OptionTypeDefaultDescription
urlstringrequiredServer address to discover the account’s calendars on
usernamestringrequiredHTTP Basic user name
passwordcredentialrequired"plain", { env = "VAR" }, { file = "path" }, { command = "..." } or { keychain = "name" }, see Credentials

Details: Calendars.

[calendars.<name>]

OptionTypeDefaultDescription
accountstringrequired for CalDAVName of the [accounts.<name>] whose credentials the calendar uses
urlstringrequired for CalDAVCalDAV calendar collection URL
eventkit_calendarstring-macOS only. Calendar identifier, "Account/Name" or name. Makes this a Calendar.app calendar
ics_urlcredential-Address of a read-only iCalendar feed (https://, http:// or webcal://), as a string or any credential form. Makes this a feed, usable only as a source; takes no account or url. Details: Calendar feeds
concurrencyinteger2Maximum events written to this calendar at the same time as a target, shared by all syncs writing to it; also the parallel deletes of --purge / --purge-all. 0 counts as 1

Details: Calendars, Credentials.

[syncs.<name>]

Each sync is a table named by its key, for example [syncs.work-to-home]. The name is non-empty, has no / or \ and no leading .. Names with spaces, dots or other characters outside A-Z a-z 0-9 _ - need quotes: [syncs."work to home"]. Two syncs with the same name are a TOML error (“duplicate key”). Syncs run, log and show up in the menu bar app in file order.

OptionTypeDefaultDescription
sourcestringrequiredCalendar key to read from
targetstringrequiredCalendar key to write to; must differ from source
intervaldurationrequiredTime between sync cycles
fieldslist of strings[]Content properties to copy, such as "SUMMARY", "LOCATION", "DESCRIPTION"; "VALARM" keeps alarm components. DTSTART, DTEND, DURATION, RRULE, RDATE, EXDATE, STATUS, TRANSP, UID, DTSTAMP and RECURRENCE-ID are always kept. See fields
enabledbooltruefalse keeps the sync configured but stops it. See Disabling a sync
full_sync_intervalduration"7d"Time between automatic full syncs; must be greater than zero. See Full sync
eventkit_days_pastinteger30Calendar.app source: days before now to query. Together with eventkit_days_future at most 1460
eventkit_days_futureinteger365Calendar.app source: days after now to query
eventkit_all_eventsboolfalseCalendar.app source: query 20 years back and 4 years ahead in chunks
transformslist of tables[]Transforms, run in order. See Transforms below

Details: Syncs.

Transforms

A sync’s transforms are the transforms list in its table, one inline table per transform:

[syncs.work-to-home]
# source, target, ...
transforms = [
  { field = "SUMMARY", action = "set_field", value = "Busy" },
  # comments are allowed between entries
  { action = "add_alarm", trigger = "10m" },
]

Multi-line lists of inline tables need TOML 1.1, which Capycal reads. Some editors and linters still only know TOML 1.0 and flag them. The TOML 1.0 form, one [[syncs.<name>.transforms]] table per transform, works too:

[[syncs.work-to-home.transforms]]
field = "SUMMARY"
action = "set_field"
value = "Busy"

A sync uses one form or the other; mixing both for the same sync is a TOML error.

OptionTypeUsed byDescription
actionstringall, requiredset_field, replace_field, replace_field_regex, remove_field, add_buffer, add_alarm, skip
fieldstringset_field, replace_field, replace_field_regex, remove_field (required)iCal property name, e.g. "SUMMARY"
valuestringset_field (required)New value, may contain placeholders
patternstringreplace_field_regex (required)Regular expression (Rust regex syntax)
searchstringreplace_field (required)Text to find, not empty
replacementstringreplace_field, replace_field_regex (required)Replacement, may contain placeholders; for replace_field_regex also $1 / ${name}
countintegerreplace_fieldReplace only the first count occurrences, default all
pre_eventdurationadd_bufferMove the start earlier
post_eventdurationadd_bufferMove the end later. At least one of the two is required
triggerdurationadd_alarm (required)Alarm time before the start
whenlist of conditionsall; skip (required)The transform only changes events that match all of them; { any = [...] } is one of them that matches when any of its conditions does

Placeholders in value and replacement: {{source_name}}, {{source_url}}, {{sync_name}}. Details: Transforms.

Conditions

when is a list of inline tables. In the table form, each condition is a [[syncs.<name>.transforms.when]] table after its transform. A condition has exactly one test: contains, matches or empty (with field), days (with optional start_time and end_time), or all_day. Or it is { any = [...] }, at least two such conditions of which one has to match, with no other keys.

OptionTypeUsed byDescription
anylist of conditionsAt least two conditions, one of them has to match; nothing else in the same table, no any inside
fieldstringcontains, matches, empty (required)iCal property name in upper case, e.g. "LOCATION"
containsstringThe unescaped value contains this text, not empty
matchesstringThe unescaped value has a match of this regex, not empty
emptybooleantrue: the property is missing or empty; false: it has a value
dayslist of stringsThe event starts on one of "mon" … "sun", at least one
start_time"HH:MM"daysWindow start, local time, default "00:00"; after end_time the window wraps around midnight
end_time"HH:MM"daysWindow end (inclusive), local time, default "23:59"
all_daybooleantrue: the event is all-day; false: it is not
notbooleancontains, matches, daysTurn the test around, default false

Details: Conditions.

Troubleshooting and FAQ

Getting debug logs

  • CLI: capycal --once -v (debug) or -vv (trace) runs one cycle with details per event. RUST_LOG=capycal=debug works too and overrides both -v and log_level.
  • Daemon: set log_level = "debug" in [daemon] and reload (SIGHUP, or save with watch_config = true). This has no effect if RUST_LOG or -v set the level at start.
  • Desktop app: a click on a red status line in the menu opens the Problems window, which shows the full error message and the recent warnings and errors. For more, set log_level = "debug" and save the config (the app reloads it), reproduce the problem, then Show Log (Settings > General) > Export.

Logs never contain passwords. They do contain calendar URLs and event UIDs, so check before sharing them.

Sync

Duplicate events

Capycal recognises its own events by the marker of the sync and the source event, so a full sync removes duplicates of one sync: run capycal --full-sync --once or Full Sync Now on the sync’s page in Settings (Advanced settings > Troubleshooting). Duplicates that come back keep a cause:

  • Copied config on a second machine, or the same config in the desktop app and a LaunchAgent on different state directories. Both write the same events; run each sync in one place only.
  • Renamed sync: the marker and the target UIDs depend on the sync name, so the old events are no longer this sync’s. Purge before renaming (see Syncs).

To remove everything a sync created, run capycal --purge <sync> (on the machine whose config has this sync), or use Remove Synced Events… (Settings, the sync’s Advanced settings > Troubleshooting) in the desktop app. --purge-all (or Remove All Events… next to it in the app) empties a calendar used only by Capycal.

A deleted copy comes back

A sync owns its copies: one deleted or changed by hand in a CalDAV target is restored by the next cycle, and by the next full sync on any target (see One sync on several computers). To keep an event out of the target, leave it out with a skip transform, switch the sync off, or remove its copies with Remove Synced Events… or capycal --purge <sync>.

Lost or corrupt state

The state files are a cache. If one is missing (deleted state directory, new machine with the same target) the sync starts with a full sync that rebuilds it from the markers in the target, without creating events twice. A state file that cannot be parsed is renamed to <sync>.json.corrupt-<timestamp>, logged as a warning (“state file corrupt, moved aside”), and the sync rebuilds it the same way. The renamed file can be deleted.

“most synced events are gone from the source, removing them in the next cycle”

When the source suddenly has less than half as many events as were synced, Capycal waits one cycle before removing their copies (see Full sync). If you cleared the source on purpose, the copies go with the next cycle; Now in the desktop app or another capycal --once starts it right away. If the source events are still there, the source answered incompletely and the next cycle normally finds them again.

Changes to fields or transforms do not show

Changing a sync’s source, target, fields or transforms makes its next cycle a full sync by itself: it applies the new rules to every event, updates the events in the target and removes those the new filters drop. The copies change at the next cycle (interval), or at once with Sync Now. If they still look old, check the log for errors of that cycle, or start a full sync yourself: capycal --full-sync --once, or Full Sync Now in the desktop app (Settings, the sync’s Advanced settings > Troubleshooting).

Changes in a calendar feed show up hours late

Capycal reads a feed at every interval, but the service decides how fresh it is: Google refreshes the secret iCal address only every few hours. Nothing in Capycal makes it faster. For changes within minutes, read the calendar through Calendar.app on a Mac, or over CalDAV where the service offers it with a password.

“the feed answered 404 Not Found” / “does not return an iCalendar file”

The feed’s address is wrong or no longer valid, for example after resetting Google’s secret address. Copy it again from the service and paste it into the sync’s Feed address (or ics_url). “does not return an iCalendar file” means the address answers with a web page, often a sign-in page: use the address meant for calendar apps (Google: Secret address in iCal format, not the public or embed link).

Blockers in the wrong place with a time condition

A condition on the start (days, start_time, end_time) uses the timezone of the machine running Capycal, which is often UTC on servers and in containers. See Timezone.

Desktop app

macOS asks for the Keychain password after an update

macOS ties the permission to read a Keychain item to the exact build of the app, since Capycal is not signed with an Apple Developer ID. Always Allow keeps it until the next update; after an update macOS asks once more for each account. The app explains this before the prompt appears. To avoid the prompts, take the password from a file or an environment variable instead (see Credentials).

The app won’t open (“cannot be opened”, “unidentified developer”)

The app is not notarized. Right-click Capycal.app in Finder and choose Open, or run xattr -dr com.apple.quarantine /Applications/Capycal.app. For the CLI binary: xattr -d com.apple.quarantine /usr/local/bin/capycal. If the app opens but no menu bar icon appears, the menu bar may be full; quit other menu bar apps or check with pgrep -l capycal-ui.

Calendar access denied / no Calendar.app events

  • Check System Settings > Privacy & Security > Calendars. The permission belongs to “Capycal” for the app, to the terminal app for the CLI in a terminal, and to the capycal binary for a LaunchAgent (see Permissions).
  • If you denied the prompt once, macOS doesn’t ask again. Enable Capycal in the settings, or reset it with tccutil reset Calendar app.capycal (app) and start again.
  • calendar '...' not found in Calendar.app: compare the eventkit_calendar value with the output of capycal --discover-eventkit.
  • Events outside the query window are not synced.

Linux desktop app

The Linux app is capycal-gtk (see Desktop app (Linux)); capycal-ui is the macOS app and only prints a message on Linux. If the app says it has no tray icon, your desktop doesn’t show StatusNotifierItem icons: on GNOME install the AppIndicator extension. The icon then appears without a restart.

Config file and CLI

“already in use by another Capycal process”

Another process holds the config lock: a running daemon, a LaunchAgent or systemd service, or the desktop app. The message names the pid. Stop that process, or use it instead (send it SIGHUP to reload). The desktop app shows this as “Another Capycal runs this config (pid N)”.

“sync ‘…’ is also run by config … with the same state directory”

Two different configs share a state directory and have a sync with the same name; both would write the same state file. Rename one of the syncs, or give one config its own state_dir.

“unknown field ...”

The config has a key Capycal doesn’t know at this place. The message shows the line and the keys allowed there. Check spelling and placement: eventkit_days_past belongs to the [syncs.<name>] table, not to the calendar; concurrency belongs to the calendar; [daemon] options don’t go into a sync.

“syncs are named tables now: replace [[syncs]] …”

The config uses the format of older Capycal versions, where each sync was a [[syncs]] entry with a name key and its transforms followed as [[syncs.transforms]]. Each sync is now a table named by its key:

# before
[[syncs]]
name = "work-to-home"
source = "work"

[[syncs.transforms]]
field = "SUMMARY"
action = "set_field"
value = "Busy"

# now
[syncs.work-to-home]
source = "work"
transforms = [
  { field = "SUMMARY", action = "set_field", value = "Busy" },
]

See [syncs.<name>] and Transforms.

“no syncs defined in … - add a [syncs.<name>] table”

The config is valid but has no [syncs.<name>] table, so the CLI has nothing to run. Add a sync (see Syncs). A reload to a config without syncs is rejected the same way, and the running syncs continue.

Name resolution fails on Linux (*.local, LDAP hosts)

The static Linux binaries don’t use NSS. Use the server’s IP address or a DNS name, or build from source.

Building and testing

Capycal is a Rust crate (edition 2024): a library with the capycal CLI and the capycal-ui menu bar app (feature ui). Contributor conventions are in CLAUDE.md.

Build and test

make build        # cargo build
make build-ui     # with the menu bar app (feature ui)
make run-ui       # run the menu bar app as a plain binary (macOS)
make run-app      # build Capycal.app and open it (macOS)
make test         # cargo test
make test-ui      # tests of the menu bar app
make test-caldav  # end-to-end tests against a throwaway Radicale server
make test-caldav-docker # the same in a Linux container, needs only Docker
make test-eventkit # tests against Calendar.app (macOS)
make lint         # cargo fmt --check + clippy -D warnings, without and with the app
make fmt          # format the code

make lint test test-ui checks what CI checks, except the GTK app. make help lists every target with a short description.

EventKit code is compiled on macOS only. Its conversion logic (eventkit/convert.rs) and the app’s display-free modules are compiled and tested on Linux too, so make test test-ui works on both platforms.

make test-caldav (scripts/test-caldav.sh) installs Radicale into target/radicale, starts it on 127.0.0.1:5299 with a temporary storage directory and runs tests/caldav_e2e.rs: the capycal binary syncs between two calendars named capycal-test-... and the tests check what arrives in the target (busy blocker fields, updates, series with exceptions, buffers, cancelled events, full sync after lost state, purge). The tests are #[ignore]d, so a plain cargo test skips them. It needs python3 and curl.

make test-eventkit runs the #[ignore]d live_tests in src/eventkit/client.rs against the real Calendar.app database: Calendar.app as a source (all-day, multi-day, timed, alarms, series as occurrences) copied into a second calendar with nothing left to change for a full sync, series with excluded, moved and cancelled occurrences, UNTIL, monthly and all-day rules, rewriting a series, a single event round trip with the full sync comparison, unsupported rules, delete and purge. Every test creates its own calendar named capycal-test-<pid>-<test> (“On My Mac” if available, else in the account of the default calendar) and removes it afterwards, also when the test fails. Calendars with that prefix left over by an aborted run are removed at the next run; no other calendar is touched. Run it from Terminal: macOS asks once whether Terminal may access your calendars.

The first line of the output names the source the calendars go to. Prefer “On My Mac” (type 0): in an account that syncs (iCloud and CalDAV report type 2), a removed calendar can come back when the account syncs its creation afterwards, so the tests remove it again until it stays away, and other devices briefly see it. macOS hides “On My Mac” while an account syncs calendars. To get it back, turn off Calendars for every account in System Settings > Internet Accounts, create a calendar under “On My Mac” in Calendar.app (not named capycal-test-..., the tests would remove it), and turn the accounts back on. “On My Mac” stays as long as it holds a calendar.

The Linux desktop app needs the GTK 4 and libadwaita development files (sudo apt install libgtk-4-dev libadwaita-1-dev, or sudo dnf install gtk4-devel libadwaita-devel): make build-gtk, make run-gtk, make test-gtk, make clippy-gtk. It is not part of make lint, so working on the rest needs no GTK. On macOS, brew install gtk4 libadwaita pkgconf lets make run-gtk show its windows for development; the tray exists only on Linux, so there the app opens its menu as a window.

For manual tests against a real server, make up-dev starts a local Radicale CalDAV server on port 5232 without authentication (docker-compose.dev.yml); make down-dev stops it.

Release builds

The distribution targets write to dist/:

TargetOutput in dist/Host
make dist-macosaarch64-apple-darwin/capycal (CLI)macOS, Apple silicon
make appCapycal.app (app with the CLI inside, signed ad hoc, see below)macOS, Apple silicon
make dmgcapycal-v<version>-macos.dmgmacOS, Apple silicon
make dist-linux-x86_64x86_64-unknown-linux-musl/capycal (static)any, needs Docker buildx
make dist-linux-arm64aarch64-unknown-linux-musl/capycal (static)any, needs Docker buildx
make disteverything the host can build

The Linux builds run in Docker (packaging/linux/Dockerfile.musl); a foreign architecture is emulated and therefore slow. The Linux release packages are made by packaging/linux/build-packages.sh <target> from a native release build: it generates man page, completions and /usr/bin units into dist/pkg, then writes the tarball, a .deb (cargo-deb) and an .rpm (cargo-generate-rpm) according to [package.metadata.deb] and [package.metadata.generate-rpm] in Cargo.toml. The desktop app is packaged separately by packaging/linux/build-gtk-packages.sh <target> from a glibc build with --features gtk (the gtk variants in the same metadata: desktop entry, icons, GTK dependencies); the release builds it on Ubuntu 24.04, whose GTK 4.14 and libadwaita 1.5 are the oldest it supports. The release job writes the AUR PKGBUILD from packaging/linux/aur/PKGBUILD.in with the checksums of the musl tarballs; publishing it to the AUR is manual. It also writes the Homebrew formula capycal.rb and cask capycal-app.rb from the templates in packaging/homebrew/ and attaches them to the release; with the secret HOMEBREW_TAP_TOKEN (write access to twiebe/homebrew-capycal) it commits them to the tap. The app is assembled by packaging/macos/build-app.sh and packed by build-dmg.sh.

Every package and the app’s About > Acknowledgements include THIRD-PARTY-NOTICES.txt, the licenses of all crates compiled into the binaries. After adding or updating a dependency, regenerate it with make notices (needs cargo install cargo-about --features cli); CI fails with make check-notices when it is out of date or a crate uses a license about.toml does not accept.

build-app.sh signs the bundle ad hoc. macOS ties Always Allow of a Keychain item to the code hash of the build unless the app is signed with a Developer ID (an Apple team), so every changed build, including make run-ui, asks again for each Keychain item. A config whose passwords come from files or environment variables avoids the prompts during development. Its icon comes from packaging/macos/icon/: make-icon.py draws capycal.svg, the PNGs in AppIcon.iconset/ and the book’s favicons in docs/theme/ (all checked in; rerun it after changing the drawing, see its header), and build-app.sh turns the iconset into AppIcon.icns with iconutil.

Documentation

This book is built with mdBook from docs/. Install mdBook (cargo install mdbook or a release binary), then:

make docs         # build into docs/book/
make docs-serve   # serve on http://localhost:3000 with live reload

Without mdBook, make docs-docker and make docs-serve-docker do the same in a container and need only Docker.

The book is published at capycal.app/docs by the Pages workflow of the website repository twiebe/capycal-web, which builds it from main. Pushes to main that change docs/ check the build and trigger that workflow (.github/workflows/docs.yml, needs the secret WEBSITE_DISPATCH_TOKEN).

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> and X-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 emits X-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::load moves a file it cannot parse to <name>.json.corrupt-<unix seconds> and logs a warning; without a state the runner sets last_full_sync to the Unix epoch, meaning “never”;
  • full_sync_interval (per sync, default 7 days) passed since last_full_sync. A state saved before the field existed (None) is set to the first cycle’s now and 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-sync in daemon mode) or SyncRunner::full_sync_once (--full-sync --once);
  • the last one did not complete: full_sync_at sets SyncState::full_sync_pending when 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 gets full_sync_pending too (mark_disabled in the coordinator), so enabling it starts with a full sync;
  • the sink changed elsewhere: changed_elsewhere asks EventSink::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_writes moves the token past the cycle’s own writes, so they don’t count. The CalDAV sink answers with a sync-collection REPORT without calendar-data (CalDavClient::sync_collection_hrefs); a server without sync-collection and the EventKit sink answer None, which skips the check. This lets several computers run the same sync with their own state;
  • the output settings changed: RunnerSettings::output_key (from SyncConfig::output_key: source and target calendar, fields, transforms, EventKit window) differs from SyncState::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:

  1. Fetch the source against an empty SyncState: without cursor and mapping, CalDAV (sync-collection without token or the ctag/etag fallback), EventKit and feeds (EtagDiff against no known etags, a feed without its HTTP validator) report every current event. Then list_synced the sink. Both are cancellable and change nothing.
  2. 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 href orphan:<sink href>, so its removal goes through the normal path.
  3. 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.
  4. Every other source event is processed. list_synced returns with each sink event what the sink keeps of it (EventSink::Stored), and EventSink::is_unchanged tells 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 with ical::same_content (lines in order after unfolding, ignoring line endings, blank lines and DTSTAMP, LAST-MODIFIED, CREATED, SEQUENCE). EventKit keeps the fields apply_update sets (convert::StoredEvent, read while listing) and compares them with convert::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, a DURATION end 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.
  5. Puts, deletes and stray deletes share push with the incremental cycle. On success the cursor from step 1 and last_full_sync = now are 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, without last_full_sync and with full_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).

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 (and LSUIElement in 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 from menu::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) and Shutdown. It answers with Updates: a Snapshot after every change, ConfigChanged after every reload, and ConfigReady, PurgePreview, PurgeProgress, CalendarCounted, CalendarPurgeProgress, CalendarPurged, AccountCalendars, AccountTested, CalendarAppCalendars and SettingsSaved for the commands that need an answer. ListCalendarApp calls EventKit (the access prompt may block for minutes), so it runs in spawn_blocking; keychain and credential lookups do too; counting and purging run in the command loop, with an activity line 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 the PurgePreview arrives. A missing, invalid or locked config is a Problem in 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::LogView holds the received lines (at most the buffer capacity), the level and search filter and the follow state. Every operation returns a Change: reset the text, trim N UTF-16 units from the start, append runs of text with one Tone (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.rs holds what the windows share: TextWindow creates the window with a toolbar row pinned to the top and NSTextView::scrollableTextView filling 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 own define_class! target and clears the targets of its controls in its Drop, before the TextWindow is dropped.
    • panel_window_macos.rs (PanelWindow) is a header, a row of buttons (reported as PanelInput::Button(index) through the sender’s tag) and a monospaced text; set_text keeps the scroll position when the text is unchanged. “Problems” shows problems::text with 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 on Update::ReloadFailed (a config or lock error while loading). “Remove Synced Events” opens with “Counting synced events” and a disabled Remove Events button, sends Command::CountPurge and fills in PurgePreview::confirmation when 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.rs builds the log window on a TextWindow (toolbar row with NSSearchField, level NSPopUpButton, “Follow” checkbox, “Clear View” and “Export” buttons; the search field takes the free width with a minimum of 240 pt) and applies Changes to the text storage with NSAttributedString runs (monospaced system font, semantic colors for dark mode). One define_class! object (CapycalLogTarget) is the controls’ target, the observer of NSViewBoundsDidChangeNotification on the clip view and the window delegate. It only sends a logview::Input as 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 with activateIgnoringOtherApps and 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 Shutdown and waits up to 35 seconds for the syncs to stop (longer than the coordinator waits for one sync).
  • Start at Login uses SMAppService.mainApp inside 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::update replaces 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 per menu::Icon (capycal-tray-*-symbolic, SVGs with the GTK error and KDE ColorScheme-* classes, so GNOME and Plasma draw them in the panel’s colors), with icons::cache_dir as 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_available lets the app start before the panel; if no StatusNotifierWatcher appears within ten seconds, the app opens menu_window.rs (the menu::build items 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 (GApplication activate) and, on macOS, at start.
  • The GApplication id app.capycal makes the app single-instance. Windows (log_window.rs, text_window.rs, settings_window.rs) report back through callbacks that run on the main loop; defer moves their work to an idle callback, so a window callback never re-enters a borrowed App; Quit drops the windows (destroying them without close callbacks) before it waits for the syncs. The log window renders logview::Changes into a GtkTextBuffer with 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 from dialog::View, since adw::AlertDialog closes 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’s arrow.triangle.2.circlepath). Errors and “already running” are adw::AlertDialogs. About is an adw::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::AboutDialog puts both on a Details page.
  • Icons: packages install them into /usr/share/icons/hicolor; otherwise icons::install writes 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 a key=value file (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):

  1. Fetch changes from the source (RFC 6578 sync-token, ctag/etag diff, EventKit query, or a feed’s conditional GET)
  2. Skip events that carry a Capycal marker (prevents sync loops)
  3. 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_alarm only collects its alarm), filter fields (filter::STRUCTURAL_FIELDS, fields and the fields a matching set_field set), add the collected alarms, stamp markers, assign a deterministic sink UID
  4. 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 in CycleStats.errors each time and does not block other events
  5. Delete sink events whose source event was removed (the mapping is dropped only after a successful delete). Before the state changes, defer_removals stops 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 sets SyncState::removals_deferred (a full sync checks the rebuilt mapping against the listing the same way and stays pending). The cycle succeeds with CycleStats::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 in pending_removals and retried in the next cycle, because RFC 6578 sources report a removal only once
  6. 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 SyncState that 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 new EKEvent gets 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_changes sends one GET with If-None-Match or If-Modified-Since from the validator it keeps in SyncState::ctag (etag: or modified: prefix). 304 means no changes; an empty state (full sync) never sends a validator.
  • split_feed unfolds the body, rejects one without BEGIN: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 without DTSTAMP and UID lines, 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_uids gives an event whose UID is not in the mapping the UID (href and UID line) 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. EtagDiff against 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 in build_source. It is never logged: errors use reqwest::Error::without_url and name the calendar key, {{source_url}} is ics://feed, and SyncConfig::output_key only hashes it. A user: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_events uses chunked queries, each inside its own autoreleasepool. The source’s fetch_changes runs the whole query in spawn_blocking inside an autoreleasepool, 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_series turns the RRULE into a Recurrence (an EKRecurrenceRule in its terms), EXDATEs and cancelled overrides into occurrences to remove and the other overrides into occurrences to change. client.rs saves the master with the rule, then apply_exceptions finds each occurrence by its occurrenceDate and removes or changes it with EKSpan::ThisEvent. A put rewrites an existing series as a whole (deleted with EKSpan::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, so parse_ical and parse_series first run eventkit/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_nsdate treats floating times and still unknown names as local time. apply_update also sets EKEvent.timeZone (ical_time_zone), without which Calendar.app keeps a timed event at the same wall-clock time in every zone; convert::is_unchanged treats 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_update writes the end one second before the iCal end, and all_day_end reads one second after the stored end, which gives the following day for both conventions.
  • Alarms map to VALARM (relative or absolute TRIGGER), availability “free” maps to TRANSP:TRANSPARENT. STATUS is 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.rs and is unit tested on Linux.

UI and UX guidelines

These rules hold for the desktop apps: the macOS menu bar app (capycal-ui) and the Linux app (capycal-gtk). Every change to a window follows them; a change that needs a new pattern adds it here first. /ui-review checks a change against this page, and the tests in settings_view::tests::ui_rules catch the rules a machine can check.

Principles

  • The model decides, the renderers draw. Logic, texts and states live in the display-free models (settings_view.rs, sync_form.rs, transform_form.rs, menu.rs, dialog.rs, …) with unit tests. settings_window_macos.rs and the GTK windows only draw a view and report input.
  • Both platforms, always. A control, a text or a behaviour added to one renderer is added to the other in the same change. Where a platform lacks something (GTK cannot grey out dropdown items), the model copes, not the user.
  • Calm by default. Nothing blinks, nothing is red. Attention goes to the one thing that needs it: the accent marks what is chosen or active, orange marks a problem.
  • One way to do a thing. A task has one entry point (a sync is added through the assistant, which has a way out to the editor). Similar tasks start from what exists (Duplicate), not from an empty form.
  • Show the effect. The preview shows what a sync does to an event, the header says how the sync is doing. Prefer showing over explaining.

Layout

  • Spacing scale, on a 4 point grid: GAP_S 4, GAP_M 8, GAP_L 12, GAP_XL 20 (settings_window_macos.rs; GTK uses the same values in pixels). No other gaps.
  • Labels sit left of their control in a column of LABEL_WIDTH, left-aligned. A row of short fields (Control::Row) puts each field’s label after it (“30 days back”).
  • Boxes: a sync is a pipeline of steps (Stage: From, Keep, one per transform, Into), each a rounded box without border, joined by a Joint whose “+” adds a step there. Other content goes in Group boxes. Header, groups and the Advanced pill line up with the steps (the gutter column on the right holds only drag handles).
  • Corners follow where a control stands: what stands on its own line or ends a part of the window is a pill, like the buttons of a dialog: the footer buttons, a row of buttons (Control::Buttons, e.g. Add Condition, Test Connection), the and / or switch, Sample / Next event above the preview, the Advanced pill (macOS NSControlSize::Large, GTK capycal-round / capycal-join). What sits in a form row next to a field keeps the field’s corners: popups, text fields, the buttons beside a popup, segments with a label (Check every, Keep). Boxes (steps, groups, cards) keep their own rounded corners.
  • The footer is the save bar: Delete on the left; on the right a text, Revert and Save (the default button). The text says what is unsaved, what is saving, or what keeps Save off. Its buttons sit centered between the hairline above and the window’s edge (MARGIN on macOS, 12 px in GTK); the content scrolls up to the hairline.
  • No Liquid Glass of our own. macOS 27 puts glass only on toolbar controls; sidebars are full height again (the floating glass sidebar was macOS 26), sheet footers are flat with a hairline, and buttons in content are plain bezels with the default one filled in the accent color. Settings has no toolbar, so it uses no NSGlassEffectView: no floating sidebar, no glass capsule around the footer, no glass bezels. A control AppKit draws in glass by itself stays as it comes. Check how macOS’s own apps draw a pattern before adopting one.
  • The aside holds the preview, a column of its own.
  • Sidebar rows are source-list rows: icon, name, a dot for unsaved changes. “Add …” rows have an accent “+”. The app’s version sits small and in secondary text at the bottom of the sidebar, level with the footer.
  • Settings is the one place to set things. The menu bar menu holds actions (Sync Now, Settings…, About, Quit), not settings, not even switching a sync off; repairs are in Settings too, next to what they repair (a sync’s Advanced settings > Troubleshooting); the app’s own settings, the config file and the log are under General at the top of the Settings sidebar.

Color

UsemacOSGTK
Chosen, active, the “+”controlAccentColor@accent_bg_color / @accent_color
A problem (warning sign, sidebar icon)systemOrangeColor#e66100
Secondary text (notes, captions, status)secondaryLabelColordim-label
Inactive (disabled sync, chevrons)systemGrayColor, tertiaryLabelColoralpha(@window_fg_color, …)
  • No red, not even for Delete: the question before deleting is the warning. Delete still reports a destructive action to accessibility (setHasDestructiveAction).
  • A problem’s text is in the normal text color, after an orange warning sign. Never colored text.
  • Account and sync icons use the accent alike.

Text

  • Buttons and menu items use title case and say what happens (“Set Up in Editor”, “Move Down”). One that opens a window or asks first ends with “…” (“Add Sync…”).
  • Everything else uses sentence case: labels, notes, status lines, errors.
  • A menu of things to add says what each does in a second, secondary line under its title (PopupItem::hint: macOS NSMenuItem.subtitle, a tooltip before macOS 14.4; GTK a dim caption label and the accessible description). Keep it to one line, about 50 characters, starting with a verb (“Leaves out events on Saturday and Sunday”).
  • Ellipsis is “…” (U+2026), never three dots, also for progress (“Saving…”, “Syncing now…”).
  • No dashes as punctuation: no em or en dash, no “–”. Use a colon, a comma or a new sentence.
  • Errors say what to do: “Set field (step 1): fill in Field and Value”, “Use a duration such as 15m or 1h 30m”. Name the field by its label. No “invalid”, no “error:” prefix, no blame.
  • Name things by what the user sees: “Field” not “property”, “target” in user text (the code says “sink”).
  • A placeholder in a summary reads as “…” or “a field” (“Set a field to …”), never as empty quotes.

Feedback and errors

  • A field’s mistake shows below the field, with the warning sign, once the user leaves the field (SettingsInput::Left), not while typing.
  • What keeps Save off shows in the footer, next to Save, with the warning sign. Save stays disabled meanwhile.
  • Long work shows its progress where it was started: what is happening and how far (“Removing the synced events of “work”… 40 of 120“) with a bar, counted first so the bar has an end. A save that removes copies shows it in the footer, so does a full sync of the shown sync (after a save that changed what it copies, or Full Sync Now), the Remove Synced Events dialog in its window.
  • A saved sync’s status line under its name says how it is doing (“Synced 2 min ago: 3 new, 1 removed”); a failed sync shows the warning sign and turns its sidebar icon orange.
  • Long texts are cut off with “…” and are complete in the tooltip.

Interaction

  • Undo over questions. Small, reversible changes (adding, moving, removing a step) happen at once and Cmd+Z takes them back. Ask only before data is lost (deleting a sync, discarding a draft, removing copies).
  • Deleting what Capycal did not create takes two steps: a warning in an orange box (dialog::View::warning, bold, with the warning sign) saying what else goes, then a second warning where the user types the name shown in Settings (View::entry) before the destructive button enables (View::last_disabled). Remove All Events is the only such action.
  • Keyboard: Cmd+S saves, Cmd+N adds a sync, Cmd+Delete deletes, Cmd+Z undoes (outside a field), Cmd+Option+Up / Down moves the open step; Ctrl instead of Cmd on Linux. Each only while its button is enabled. Esc or a click on the background ends typing in a field. Tab follows the order on screen.
  • Nothing only by mouse: what a drag does, a context menu does too (Move Up, Move Down).
  • Suggestions, not restrictions: a field with common values offers them (field names) and takes any other; field names are upper case as they are stored.
  • An assistant opens over its window, not in place of it: the new-sync wizard is a sheet on Settings (macOS) or a modal window (GTK), so what was shown stays in view and Cancel returns to it (SettingsScreen::wizard_view).
  • Defaults that work: presets first (interval 1 / 5 / 15 min, what to copy), “Custom” for the rest. Common steps come filled in. An assistant step asks one thing; a page says that everything can be changed later.
  • The way out comes with the problem: a problem of the whole config shows at the top of General (Needs attention, orange General entry) with the one button that fixes it; Settings opens even for a file it cannot read, with only General, so the way to fix it is never closed.
  • Fixes belong to what they fix: an action on one sync (full sync, removing its copies) sits on its page under Advanced settings > Troubleshooting, not only in a menu with a picker.
  • Something that is not listed is added where it is chosen: a choice menu ends with an action for what it cannot list (“Add Account…”, “Add Calendar Feed…”), and what that needs appears right below it as a field (Feed address), not in another window. A secret the config keeps elsewhere (env, keychain) shows as a disabled field saying so.
  • Conditions read as a sentence down the form: below a step’s own fields, each condition is a popup of what it looks at labelled Only if (If for Skip events), then if, with Move Up and Move Down (arrows, where there is a neighbour) and Remove as its buttons, then the test popup and only the inputs that test needs (text, weekday checkboxes, From / Until); Add Condition ends the list. A step that needs a condition starts with one and cannot lose its last.
  • A join is a switch between the parts it joins: between two conditions an and / or Segments control without a label sits centered on its own line (centered). Conditions joined by or share a thin outlined box (Control::AnyOf, caption One of these) inside the step, so the group shows by its frame and its words, not by color. Each condition is a card (Control::Condition, a light fill, no border) with a drag handle; the model places a drop mark (Control::DropMark) at each place it can land, two at the edge of a box (inside at its top or bottom, and outside), and the renderers snap to the mark nearest the pointer, none where the dragged one would stay as it is. Dropped inside a box it joins it with or, outside with and; its Move Up / Move Down buttons, small arrows with their titles as tooltip and accessibility label so moving works without a mouse, swap it with its neighbour and leave the joins where they are. A condition drag carries a different value type than a step drag (GTK u64 vs u32), so neither lands in the other’s target. The join switch is a pill like the dialog buttons (macOS NSControlSize::Large, GTK capycal-join). A rebuild keeps the scroll position of the same entry (GTK drops the focus first, else it scrolls to the first field). An update in place leaves a focused field alone, except after a move (SettingsView::moves changed): the field then shows another condition and takes its text.
  • Tags on a list row (“the source”, “read-only”) are small pills in secondary text, centered on the row.
  • The window remembers its size and the entry shown.
  • A menu bar app with a window behaves like an app: on macOS it shows in the Dock, in Cmd+Tab and with its own menu bar (app menu with Settings… and Quit, Edit, Window) while any of its windows is open, and leaves the Dock with the last one. Cmd+W closes, Cmd+Q quits (Ctrl on Linux), asking only about unsaved changes.
  • Ask for permissions in context: say why before the system asks (Calendar.app access). Before a Keychain prompt (keychain_notice.rs), check silently whether one will come and only then explain what Capycal reads, what stays closed and which button keeps the system from asking again.

Motion

  • Motion answers an action and shows what changed: a chevron turns (0.2 s, ease in and out), opened fields fade in, the worked-in step gets a ring. Nothing moves by itself.
  • With “Reduce motion” on (macOS), changes happen without animation.
  • A change of state never changes the size of what is next to it (fixed boxes for icons that change).

Accessibility

  • Every control without a title has an accessibility label that says what it is and what it is about (“Remove step 2”, “Step 2: Add buffer: 15 min before, closed”).
  • Information is never only color: a problem has a sign and words, a draft a dot and the footer text.

Checking a change

  1. The model change has unit tests; ui_rules passes (make test-ui, make test-gtk).
  2. Both renderers draw it: macOS type-checks (clippy --target aarch64-apple-darwin --features ui), GTK builds and passes clippy.
  3. Look at it: screenshots of the macOS app (in the VM) and the GTK app (under Xvfb), light and dark, including a problem state and the keyboard path.
  4. The user docs (app/, and the In the app lines in concepts/ and cookbook/) describe what changed.

Releasing

Releases are built by GitHub Actions (.github/workflows/release.yml) when a tag v* is pushed.

  1. Set version in Cargo.toml and update Cargo.lock (cargo build).

  2. Commit, then tag and push. The tag must be v plus the version from Cargo.toml; the workflow fails otherwise.

    git tag v0.2.0
    git push origin main v0.2.0
    

The workflow:

  • builds and tests the CLI for aarch64-apple-darwin, x86_64-unknown-linux-musl and aarch64-unknown-linux-musl on native runners, and packs each into capycal-v<version>-<target>.tar.gz (binary, README.md, LICENSE, CONTRIBUTORS.md, THIRD-PARTY-NOTICES.txt, capycal.example.toml);
  • builds Capycal.app and capycal-v<version>-macos.dmg;
  • writes a .sha256 file per archive;
  • publishes a GitHub release with generated notes.

A manual run (workflow_dispatch) builds and uploads the artifacts without publishing a release.

Other workflows

WorkflowTriggerDoes
ci.ymlpush to main, pull requestsfmt, clippy and tests on Linux and macOS, with and without ui
docs.ymlpush to main or pull request changing docs/, manualbuilds this book; on main it asks twiebe/capycal-web to publish it on capycal.app/docs

Licensing

Capycal is licensed under the GPL-3.0-only (LICENSE), while contributions come in under the MIT License (CONTRIBUTING.md, confirmed in the pull request template). This page explains why, so the setup is not changed by accident.

Why the GPL

App store terms (Apple’s in particular) are widely read as incompatible with the GPL; the FSF holds this view, and VLC was removed from the App Store over it in 2011. So nobody but the copyright holder can ship a copy of Capycal to an app store. The maintainer, who owns the copyright to their own code, can. No court has decided this, so it is a practical protection, not a legal guarantee; clones have to be found and reported to the store.

Why contributions are MIT

A contributor keeps the copyright to their code and only licenses it. Code contributed under the GPL could not go into an app store build without breaking the contributor’s license: they could demand it be taken down and claim damages. Under the MIT License the maintainer may ship it anywhere, including app stores and a later change of the project’s license.

Under German copyright law an author has a claim to fair remuneration (§ 32 UrhG) and, for a big success, to more afterwards (§ 32a UrhG). Both claims do not exist when the author grants a simple right of use to everyone free of charge (§ 32 Abs. 3 Satz 3, § 32a Abs. 3 Satz 3, the “Linux clause”). The MIT License is such a grant, so a paid build owes contributors nothing.

How a contribution comes inRemuneration claimIn an app store buildLater relicensing
GPL (a pull request merged as is)nonono
MIT (the project’s way)noyesyes
A CLA granting rights to the maintainer onlypossibleyesyes

BSD, ISC, 0BSD and Apache-2.0 would work like MIT here, they also grant rights to everyone for free. They differ only in their duties (notices, Apache’s NOTICE file and patent grant). MIT keeps it simplest.

Rules that follow

  • Every contribution needs the MIT confirmation of the pull request template and a line in CONTRIBUTORS.md, whose notices every package ships.
  • No CLA that grants rights only to the maintainer: it would bring back the remuneration claim.
  • Contributed portions can be reused by anyone under MIT. That is fine as long as most of Capycal is the maintainer’s GPL code; a clone of the app still needs it.
  • No GPL or LGPL crate may be compiled into the binaries, or the app store build breaks. about.toml lists the accepted licenses and make check-notices (run by CI) rejects any other; decide before adding one. GTK and libadwaita are system libraries of the Linux app only.

This is a reading of the law, not legal advice; have the wording of CONTRIBUTING.md checked before the first store release.