nocal account connect microsoft runs the PKCE browser flow, reads /me, upserts the deterministic account row, and persists tokens to Secret Service as the final connected-state gate. Account IDs are hashed from the remote subject so reconnect is idempotent, and a secret-store failure leaves the same coherent state as a disconnect. Ships a placeholder client ID with NOCAL_MICROSOFT_CLIENT_ID override until a real Entra registration exists.
17 KiB
Architecture
Nocal is split into four layers. Dependencies point inward, keeping remote sync and terminal rendering replaceable.
src/main.cpp
├── tui/ ANSI rendering, input decoding, terminal lifecycle
├── storage/ iCalendar file adapter and provider-neutral cache
└── domain/ Event, Calendar, civil dates, month layout and queries
future sync workers
├── caldav/ Nextcloud and generic CalDAV
├── google/ Google Calendar REST/OAuth
└── graph/ Microsoft Graph/OAuth
└──────────> domain/ through a SyncProvider interface
Domain
The domain layer uses C++20 <chrono> civil dates and time points. It knows
nothing about escape sequences, files, OAuth, or HTTP. Month layout always
produces 42 cells, starting on an application-selected weekday (Monday by
default), which keeps redraw geometry stable. The selected week start rotates
the header, grid spill cells, and the finite range queried for that grid. It is
presentation input only: agenda windows, recurrence rules, and storage remain
independent of it.
Events retain a stable UID, title, half-open start/end interval, all-day flag,
optional descriptive fields, and calendar identity. Queries define overlap as
event.start < range.end && event.end > range.start; this matters for events
crossing midnight.
Recurring events remain one domain object. Occurrence queries expand only the
requested time window and return values that point back to their source series,
preserving series-level storage and mutation semantics. The supported rule
surface is deliberately bounded to daily/weekly/monthly/yearly frequency,
interval, count or until, weekly BYDAY, monthly BYMONTHDAY, yearly
BYMONTH, compatible DATE or DATE-TIME RDATE, and EXDATE. An occurrence set
merges DTSTART, rule expansion, and additional RDATE starts, deduplicates by
start, and then subtracts exclusions. Mutations continue to address the source
series as a whole. Invalid metadata retains at most the base event rather than
crashing a render.
Search matching is a pure domain predicate over normalized event text; the TUI owns result presentation and occurrence identity. Recurring searches expand a finite window of five years on either side of the selected date so an unbounded rule cannot turn an interactive query into unbounded work.
Calendar visibility is session presentation state. The controller derives its calendar list from event calendar identifiers and applies the same domain predicate to month rendering, agenda queries, focus traversal, and search. Hidden events remain in the canonical collection passed to persistence, and focus identities are always derived from that complete collection so filtering cannot renumber events with duplicate or missing UIDs.
The agenda is a TUI projection over domain occurrence queries, not a second calendar model. It requests exactly one 42-day window at a time so recurrence expansion remains finite, preserves occurrence identity when returning to the month grid, and keeps its window and selection as non-persistent controller state. Calendar and search overlays may be opened above it, but mutations stay in the month workflow and continue to operate on the canonical event collection.
Timed events retain their source basis: UTC, floating process-local time, or an
IANA TZID. C++20 time-zone conversion keeps zoned recurrences at their civil
wall time across daylight-saving transitions. The month grid displays instants
in the user's local zone; the reader also names an explicit source zone.
Storage
Version 0.1 uses a user-owned iCalendar file, defaulting to
$XDG_DATA_HOME/nocal/calendar.ics or ~/.local/share/nocal/calendar.ics.
The adapter unfolds content lines before parsing and escapes text on output.
On POSIX systems, writes are serialized with an advisory lock, staged in a
same-directory private temporary file, flushed, and atomically renamed over the
destination. A load retains an immutable snapshot of the exact source bytes.
Before saving, the writer compares that snapshot with the destination while it
holds the same lock used for replacement, then checks again after staging and
immediately before commit. This catches same-size and same-timestamp changes
and fully serializes cooperating Nocal writers. The lock is advisory: an
unrelated program can ignore it, so no portable filesystem API can provide a
true compare-and-swap against every possible external writer.
Before an existing destination is replaced, its exact bytes are atomically
written to the adjacent .bak file. Explicit backup restoration uses the same
lock, source-revision check, private staging, and atomic replacement path; it
does not consume the backup. The TUI mutates a copyable in-memory model and
rolls it back if persistence fails. Its bounded undo/redo history contains only
mutations that crossed the persistence boundary successfully.
Explicit CLI import and export reuse the storage parser, safety classification,
validation, and atomic persistence boundary; these workflows do not depend on
the TUI and perform no network access. Import loads and validates both source
and target before staging a target replacement. UIDs in both files must be
non-empty and unique, every interval must be valid, and every component in both
files must be safe to round-trip. Matching UID plus identical fields is an
idempotent duplicate; matching UID with different fields is a collision that
aborts the whole operation. A committed merge writes the exact old target to
.bak before atomic replacement. If every source event is already present
exactly, import performs no write and does not alter the backup.
Export accepts only a valid, safely round-trippable source, serializes a canonical calendar through private staging, and atomically creates a destination that must not already exist. Destination refusal is part of the commit boundary, so export never replaces an existing path. Import, export, demo, non-interactive frame printing, and backup restoration are distinct top-level application modes; import and export cannot be selected together or combined with any of the other modes.
The current writer deliberately refuses to mutate an existing file when the
loader encounters information it cannot round-trip, including recurrence
overrides, RDATE periods or values incompatible with DTSTART's time basis or
TZID, custom VTIMEZONE definitions, alarms, attendees, unknown properties,
or malformed components. Supported recurrence rules, compatible additional
starts, exclusions, and system IANA time-zone identifiers round-trip
semantically. Browsing remains available for unsupported files. This
conservative boundary is more important than partial editing because a
successful-looking edit must not erase unrelated calendar data.
Provider synchronization does not write directly into this UI file. Its prerequisite durable boundary is a provider-neutral SQLite database in WAL mode. The database is a private local file, and schema changes run as versioned, all-or-nothing migrations so a failed upgrade cannot leave a partially migrated cache.
The cache separates provider records from normalized projections. It retains raw provider payloads alongside normalized fields so an older Nocal cannot destroy fields it does not understand. Inactive calendars and tombstoned events remain recorded rather than disappearing from history. Normalized event instances are materialized for immutable UI snapshots; provider payload details do not cross into rendering or the domain model.
Incremental progress is scoped by provider account, calendar, and synchronization window. A provider's opaque cursor is committed in the same transaction as each pulled page, preventing a crash from advancing the cursor beyond durable data. Transactional outbox and conflict records are reserved in the boundary for future local writes and deterministic conflict handling. This cache does not itself implement networking, authentication, or Microsoft Graph synchronization. OAuth tokens and other secrets are explicitly excluded from SQLite and belong in the Freedesktop Secret Service.
Terminal backend
The initial backend depends only on POSIX termios, poll, and ANSI/ECMA-48.
It uses the alternate screen, hides the cursor while rendering, restores all
terminal state through RAII, and repaints from a complete frame buffer. A resize
signal only sets a flag; terminal size and rendering are handled safely in the
main loop.
Colors are semantic ANSI slots and default background, intentionally delegating actual RGB values to the terminal theme. This is both simpler and more native than shipping an application theme that fights the desktop palette.
Sync roadmap
All providers implement the same conceptual operations: authenticate, list calendars, perform an incremental pull, push a local mutation, and resolve a conflict. CalDAV uses sync tokens/ETags, Google uses page/sync tokens, and Microsoft uses Graph delta links. Secrets belong in the Freedesktop Secret Service, never in the calendar database or command line.
Conflict resolution is deterministic: unchanged side wins; otherwise retain both versions and mark a conflict for the user. Network work happens outside the render loop and publishes immutable snapshots to it.
HTTP and OAuth boundary
Provider code depends on generic request/response and injected transport interfaces rather than a concrete networking library. Deterministic fakes make success, protocol failure, transport failure, cancellation, and malformed input testable without network access. The generic layer performs no automatic retries: only a provider knows whether an operation is idempotent and how to interpret service-specific throttling or retry guidance.
Microsoft authentication is designed for a public desktop client. It uses the
system browser and authorization-code flow, a fresh PKCE verifier and S256
challenge, and a loopback redirect. The Entra application registration is
multitenant and selects the intended organizational and personal Microsoft
account audience. The read-only Graph scopes are delegated User.Read for
identity and Calendars.Read for calendar data. They normally permit user
consent, while tenant
consent policy remains authoritative and may require administrator approval or
block consent. A desktop binary distributed as open source is not a confidential
client and must not embed or depend on a client secret.
The generic boundary deliberately contains no concrete networking or desktop code. The Linux adapters below supply the transport, browser launch, and loopback listener. Token JSON parsing, Secret Service storage, Graph requests, and usable account setup remain later slices. Read-only Microsoft Graph calendar discovery and delta synchronization follows; delta links remain opaque and are persisted only through the calendar/window transaction described above. Remote writes and conflict resolution come later.
Concrete Linux adapters
The concrete adapter phase provides a libcurl transport that keeps certificate and host verification enabled, bounds response bodies and connection/operation timeouts, and disables automatic redirects. It implements one request attempt only: neither the adapter nor the generic transport retries. Cleartext HTTP is accepted solely for explicit loopback tests; provider traffic uses HTTPS.
The browser adapter launches xdg-open directly without a shell, so an
authorization URL is never interpreted as shell syntax. The callback receiver
binds IPv4 127.0.0.1 on a dynamically assigned port, advertises that port with
the localhost host and fixed /nocal/oauth/callback path, and handles one
bounded callback before closing. Bounded header/input sizes and a deadline keep
a local peer from holding the authorization process indefinitely.
The Entra registration must configure Nocal as a Mobile and desktop public
client with http://localhost/nocal/oauth/callback. Microsoft ignores the port
when matching a localhost redirect, which permits the runtime-selected port.
Using an HTTP redirect with the literal 127.0.0.1 host instead requires editing
the application manifest, and IPv6 loopback redirect addresses are currently
unsupported. The advertised localhost URL therefore remains distinct from the
IPv4-only listener binding.
Token and Secret Service boundary
The token decoder is strict and bounded: oversized or structurally invalid JSON, wrong field types, and invalid token lifetimes fail before any credential is exposed to account state. Initial authorization requires a refresh token. A later refresh response replaces it when Microsoft supplies a rotated token and otherwise retains the previously stored refresh token.
Access and refresh tokens are serialized together in a versioned libsecret secret payload, addressed by an opaque local account identifier. SQLite stores neither token material nor a copy of the secret payload. Token values also stay out of logs, command-line arguments, and environment variables. Schema/version validation prevents an incompatible secret from being mistaken for current credentials.
The libsecret adapter exposes synchronous, cancellable operations and is worker-thread-only because a D-Bus call or keyring unlock may block. Tests run the real adapter against an isolated D-Bus session and gnome-keyring rather than claiming integration from an in-memory fake alone. Missing credentials are a normal disconnected result. A locked collection, cancellation, parse error, or read/write/delete failure remains an explicit failure; none triggers plaintext fallback or partial connected state. An account becomes connected only after the complete versioned payload has been stored successfully.
Graph requests, account UI, and usable account setup remain absent at this
boundary. The read adapter uses delegated User.Read for /me, then delegated
Calendars.Read for calendars and events. Neither permission normally requires
administrator consent, although tenant policy can require approval or prevent
user consent.
Microsoft Graph read boundary
The adapter remains a public desktop, multitenant client for organizational and personal Microsoft accounts. It takes an access token for each operation without retaining it; persistent access and refresh tokens remain solely in the versioned Secret Service payload.
Identity uses /me. Calendar discovery consumes every page before atomically
replacing the account's cached calendar set, so a malformed or failed later page
leaves the prior complete listing intact. Local calendar IDs are deterministic
from stable provider, account, and remote identities. Event requests send the
Graph Prefer: IdType="ImmutableId" header so the provider identity remains
stable when an item moves within its mailbox. Nocal's calendar-scoped local
event identity remains stable for the lifetime of that cached calendar mapping.
JSON parsing is strict and bounded for identity, calendars, events, and paging
metadata. Error construction excludes authorization headers, access tokens, and
credential-bearing response material. Requests use only HTTPS
graph.microsoft.com URLs. Returned @odata.nextLink and @odata.deltaLink
values are opaque continuation capabilities: Nocal validates their scheme and
host but does not reconstruct or reinterpret their query parameters.
Event synchronization uses half-open UTC windows. For the primary calendar, each delta page's normalized events, deletion tombstones, and opaque cursor commit in one cache transaction. A partial round is durable and resumable, and the cursor cannot advance beyond its corresponding changes.
Secondary-calendar reconciliation through stable Microsoft Graph v1.0 uses the
documented per-calendar calendarView route, accumulates every page within
fixed total bounds, and publishes only a completely validated listing. Schema
version 2 tracks durable event membership by calendar and window. Replacing one
window upserts its returned events and removes instances for omitted events only
when no other complete window still references them. The event envelope remains
live and its raw provider payload is retained, because absence can mean that an
event moved outside the requested range rather than that it was deleted
remotely.
This design avoids beta and undocumented per-calendar delta routes. Its tradeoff is bandwidth: every secondary window is fetched completely on each refresh, while the primary calendar remains incremental.
Account setup now has a CLI entry point, nocal account connect microsoft.
It composes the desktop OAuth adapters, libcurl transport, token parsing, one
/me identity read, the sync cache, and the Secret Service store in a fixed
order: the account row is upserted first, and the versioned secret payload is
stored last as the connected-state gate. A secret-store failure therefore
leaves the same coherent state as a disconnect, and reconnecting is idempotent
because the local account id is a deterministic hash of the remote subject.
This build carries a placeholder client ID; a real Entra registration is
supplied through the NOCAL_MICROSOFT_CLIENT_ID environment variable (a
public client ID, never a secret). There is no account TUI and no sync
orchestration yet; tests use injected transports and credentials. Remote
writes remain out of scope. Generic SyncProvider integration and observable
sync status are next.