Add a bounded libcurl transport with verified TLS, no redirects or retries, sanitized failures, binary-safe requests, and resolved-address enforcement for cleartext loopback traffic. Add shell-free xdg-open launching and a one-shot IPv4 loopback callback receiver with strict HTTP/query parsing, deadlines, fixed callback path, and RAII cleanup. Token parsing, Secret Service, and Graph remain follow-up phases.
223 lines
12 KiB
Markdown
223 lines
12 KiB
Markdown
# Architecture
|
|
|
|
Nocal is split into four layers. Dependencies point inward, keeping remote sync
|
|
and terminal rendering replaceable.
|
|
|
|
```text
|
|
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 initial read-only Graph scope is the delegated
|
|
`Calendars.Read` permission. It normally permits 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.
|
|
|
|
Concrete token JSON parsing, Secret Service storage, Graph requests, account UI,
|
|
and usable account setup are still absent. libsecret's synchronous calls may
|
|
block; the next adapter isolates them behind an off-render-thread boundary
|
|
rather than calling them from the TUI loop. Read-only Graph discovery and delta
|
|
follow token parsing and secret persistence.
|