Architecture
The three layers
Section titled “The three layers”┌──────────────────────────────────────────────────────────────┐│ Frontend (React + TypeScript, src/) ││ Accessible views, dialogs, the keyboard model. No provider ││ logic, no persistence — it only calls Tauri commands and ││ listens for events. │└───────────────▲───────────────────────────┬──────────────────┘ │ invoke<T>(cmd, args) │ emit(event) │ (request/response) │ (host → UI push)┌───────────────┴───────────────────────────▼──────────────────┐│ Host (Rust, src-tauri/, crate `aperio`) ││ • Tauri command handlers (src-tauri/src/commands) ││ • SQLite database + migrations (src-tauri/src/db) ││ • Snapshot cache for external data (src-tauri/src/cache) ││ • Event-log sync engine (src-tauri/src/event_log, /sync) ││ • Plugin host: loads adapters over a C ABI │└───────────────▲───────────────────────────┬──────────────────┘ │ cal_core traits │ C ABI vtables │ (in-process, local store) │ (FFI, plugins)┌───────────────┴────────────┐ ┌────────────▼──────────────────┐│ cal-core (crates/cal-core) │ │ Adapters (crates/adapter-││ Domain types + feature │ │ *) — Google, CalDAV, Graph, ││ traits (CalendarFeature, │ │ EWS, Vikunja, Todoist, local ││ TasksFeature, …) │ │ …, each behind a plugin. │└─────────────────────────────┘ └───────────────────────────────┘The Cargo workspace
Section titled “The Cargo workspace”The repository is one Cargo workspace. Key members:
crates/cal-core— the shared vocabulary:Event,Task,Calendar,TaskList,Contact, colour types, and the feature traits (Adapter,CalendarFeature,TasksFeature,ContactsFeature). Every adapter implements a subset of these; the host speaks onlycal-core.crates/adapter-*— one crate per provider (-local,-google,-caldav,-microsoft-graph,-ews,-vikunja,-todoist,-ical). Each is a normal Rust library implementing the relevantcal-coretraits.crates/adapter-*-plugin— a thin wrapper that exposes an adapter over the C ABI as a loadable plugin (see below).crates/plugin-core— the ABI contract: the#[repr(C)]vtable layouts, the call/marshalling shim, and the C header.crates/plugin-sdk— helper macros so a plugin author writes safe Rust, not raw FFI.crates/sync-core— the sync event log types (SyncEvent,EventEnvelope) shared between host and the sync machinery.src-tauri(crateaperio) — the Tauri application/host.
Frontend ↔ host communication
Section titled “Frontend ↔ host communication”Two directions, both standard Tauri:
- Commands (request/response). The UI calls
invoke<T>('command_name', { args }); the host runs a#[tauri::command]handler and returns a serde-serialisable value. Wrappers live insrc/api/client.ts; handlers insrc-tauri/src/commands/. - Events (host → UI push). The host
emits named events (e.g.cache-updated,sync-conflicts-changed); the UI subscribes and reacts (refetch, show a dialog). This drives the stale-while-revalidate cache refresh and sync status updates.
The domain types are generated, not mirrored
Section titled “The domain types are generated, not mirrored”The shapes both frontends parse are generated from the Rust types, not
written twice. cal-core, plugin-core and host-core carry
#[derive(ts_rs::TS)] behind an off-by-default ts-export feature;
cargo xtask ts-types writes shared/generated/, and shared/types.ts
re-exports from there. Run it after changing any of those types — CI runs
cargo xtask ts-types --check and fails, naming the files, when the committed
declarations no longer match the Rust.
The feature is off by default on purpose: cargo unifies features across the
whole graph, so turning it on would pull ts-rs into every adapter crate.
Rules the UI needs during render come from the core, synchronously
Section titled “Rules the UI needs during render come from the core, synchronously”Some rules are needed while React renders, and invoke cannot serve them:
IPC is asynchronous, a render function must return finished UI in one turn,
and an Array.prototype.sort comparator cannot await at all. So the desktop
reaches those rules through WebAssembly — crates/cal-core-wasm, compiled
into the webview and instantiated once at startup (main.tsx awaits
initCoreRules() before rendering). Mobile reaches the same Rust through a
synchronous Expo Function, and a future native frontend links it directly.
cal-core-wasm is pure marshalling: strings in, values out, every body a
match. No rule may live there — it exists so rules can live in the core.
The app’s Content Security Policy (src-tauri/tauri.conf.json) carries
'wasm-unsafe-eval' in script-src: a webview compiles WebAssembly only with
it, and it allows nothing broader (unlike 'unsafe-eval'). tauri dev and the
tests run without that policy, so src/wasm/csp.test.ts guards it.
Some of the rules that cross today:
-
priorityRank,isImportantPriority,normalPriority; -
text ordering,
cal_core::collation—compare_namesfor names of things (case- and accent-insensitive) andcompare_titlesfor text the user wrote (digit runs by value: “Kapitel 2” before “Kapitel 10”); -
the series clock,
cal_core::series_clock:seriesClockZoneanswers which stored zone names a series repeats on;canonicalZoneresolves a tzdata name to its zone;expansionClockanswers which clock a series is read on at all —device-daysfor an all-day series (it repeats on the days its reader sees, whatever zone it carries),zonefor the zone it stores,utcotherwise. WHICH zone the device is in stays with the caller: the core reads no clock, so the views hand itIntl’s answer and host-core hands it the device’s IANA name.
All three are plain strings, with
''for none, because every expansion of a series asks. The names are generated from chrono-tz bycargo xtask tz-list. The Exchange adapter’s Windows zone ids are generated on top of them, from a pinned CLDR file, bycargo xtask windows-zones. -
the world zone list,
cal_core::zone_list:zoneLabels,zoneSearchandzoneChoicegive the names, the search and where a stored zone stands. Its offsets need chrono-tz, and they are the one part that does not cross here: the desktop asks its host (zone_offsets, a Tauri command) and the phone its native core.
src/wasm/coreRules.ts holds every WebAssembly door.
Do not reach for localeCompare. Use compareNames / compareTitles
from @aperio/shared for text a person reads — each surface installs its own
door into the core at startup (installTextCollation), bound to the language
the user chose rather than the one the operating system reports. For machine
strings (ISO day keys, RFC-3339 instants, ids) use compareMachineStrings,
which is a plain codepoint compare and needs no collation at all.
The collation data is a megabyte of baked CLDR, so cal-core’s collation
feature is off by default for the same reason ts-export is: only the two
frontend bindings switch it on.
The zones feature (chrono-tz, for the zone list’s offsets) is off by default
too, and it stays off in the WebAssembly door, where it would cost about
879 KB. The phone’s binding and the desktop host switch it on; both link
chrono-tz already. CI checks that the WebAssembly door’s dependency graph
stays without chrono-tz.
A module behind an optional feature needs #[cfg(feature = "…")] on both
the pub mod and its pub use. cargo build --workspace will not tell you
otherwise: cargo unifies features across everything in one build, so a
frontend binding that switches a feature on switches it on for the whole
workspace — while an adapter repository compiling the crate alone fails on the
missing dependency. CI therefore also runs cargo check -p cal-core,
-p plugin-core and -p plugin-sdk on their own.
The plugin host & the C ABI
Section titled “The plugin host & the C ABI”Adapters are loaded as plugins over a stable C ABI rather than linked
in directly. The host talks to a plugin through a #[repr(C)] vtable of
function pointers (Option<VtableMethodFn>), one table per capability
(calendar, tasks, contacts). Domain data crosses the boundary as serde
JSON, so adding an optional #[serde(default)] field is ABI-transparent;
adding a method means appending a new slot to the end of the vtable
(never reordering — that preserves binary compatibility).
On the host side, plugin_core::shim adapts a loaded vtable back into an
ordinary cal-core trait object (FfiCalendarAdapter, FfiTasksAdapter,
…), so the rest of the host is plugin-agnostic. The full contract lives in
the Plugin Developer docs.
Data path: local vs. external
Section titled “Data path: local vs. external”- Local data (the built-in
localadapter) lives in the host’s own SQLite tables and is the source of truth for user-created calendars, events, tasks, contacts and colour labels. It also participates in sync. - External data (Google, iCloud, …) is cached, not owned. The host
keeps a snapshot in
cache_*tables (seesrc-tauri/src/cache) so the app paints instantly on launch, then revalidates in the background (stale-while-revalidate). Provider mutations go out through the adapter; the provider remains the source of truth.- Both the item reads (
get_events/get_tasks/get_contacts) and the catalog reads (list_calendars/list_task_lists/list_contact_lists) are non-blocking: a cold snapshot is served as the (possibly empty) cached rows plus one deduplicated background refresh — never an inlineawaiton the network. A slow provider therefore can’t gate first paint. When the refresh lands it registers the container→account routes and emitscache-updated;CacheSyncListenerthen re-runs the affected catalog and invalidates the item hooks so rows that couldn’t be routed on the cold paint fill in. - One read only looks:
get_series_rows(on the phone,get_events_jsonwith aseries_id) returns the cached rows of one series besides its master — its changed and cancelled occurrences, whatever their dates — by id, with how far the cache reaches (SeriesReach:complete, awindow, orunknownafter a write). Splitting a series needs every such row, so this read never refreshes, never repairs bindings and never hides cancelled rows. - On the frontend,
CalendarStoreexposes a per-source loading flag (calendarsLoading/taskListsLoading/contactListsLoading), and each data hook gates on the one catalog it needs (events → calendars, tasks → task lists, contacts → contact lists). A slow calendar enumeration no longer holds the task or contacts view’s first paint.
- Both the item reads (
All persisted files go through paths::resolve_data_dir() so the app
stays portable (a data/ next to the executable when present, otherwise
the OS data dir) — never hard-code an OS path.
The sync engine (event log)
Section titled “The sync engine (event log)”Cross-device sync is multi-writer, peer-to-peer — there is no
coordinating “primary device”. Local mutations are appended to an event
log (sync-core’s SyncEvent variants like calendar.created,
event.updated, color_label.deleted). The log is exchanged through a
sync backend (WebDAV, Dropbox, …, themselves plugins) and an applier
(src-tauri/src/event_log/applier.rs) merges incoming events into the
local store with field-level, last-writer-wins conflict resolution;
genuine conflicts surface to the user. DESIGN.md § 19 is the full spec.