Every existing test handed seal and open a constant key, so the derivation
they are used with had no coverage at all: a key that disagreed with itself
between two calls would have passed the whole suite and broken every sign-in.
This drives store, load and delete through the real salt file and the real
machine id, and loads twice on purpose, because the second load derives the
key afresh from what the first one left on disk.
The machine identifier mixed into the key was read from /etc/machine-id,
which does not exist on macOS, so it was the empty string there. The salt
sits next to the ciphertext and the key context is a constant in a public
binary, which makes that identifier the only thing standing between a copied
home directory and a readable refresh token. Empty is not a neutral
contribution of no entropy, it is the absence of the binding: until now a
home directory lifted off a Mac decrypted anywhere. The file has claimed
otherwise since it was written.
macOS now reads IOPlatformUUID from ioreg, a stock binary called by absolute
path because an app launched from Finder inherits a minimal environment. The
result is cached, since a process spawn is real money when key() runs on
every load and every store. A failure yields an empty id and a working key
rather than an error, because refusing here would lock a user out of a token
that is perfectly good.
iOS and Android stay empty deliberately. The sandbox is the real boundary
there, and every identifier those platforms offer is reset by a reinstall,
which would strand a token that was never in any danger.
No migration, and that is a decision rather than an oversight. Nothing has
been released, so the only install this can orphan is a development machine,
and doing it now costs one reconnect instead of costing every Mac user one
later.
For the same reason the keychain-fallback.* paths are gone. They migrated
nobody, and they were subtly wrong anyway: they carried the old salt across
under the new name while deriving with the new key context, which silently
produced a token that could not be opened. A migration that looks like it
works and does not is worse than no migration.
key() also no longer treats every failure to read the salt as absence. A
missing file and a file too short to be a salt both take a fresh one, since
neither has anything left to lose, but a salt that exists and will not read
is now an error. Writing over it turned one transient read failure into the
permanent loss of every stored token.
Mobile OAuth reused the desktop client all along; what stopped it was the
browser. Sending the user out to Safari or Chrome backgrounds the app, iOS
suspends it, and the redirect carrying the code arrives at a socket nobody
is accepting on. The consent page now opens in front of the app instead, in
SFSafariViewController or a Chrome Custom Tab, so the loopback listener
stays live and the existing `installed` client is enough. Verified against
Google's real consent screen on a simulator and an emulator.
A per-platform client is still supported and is now an upgrade rather than a
prerequisite. On iOS it buys ASWebAuthenticationSession, which shares
Safari's session so nobody is asked to sign in to Google twice. Android
needs nothing: Custom Tabs share Chrome's cookies, measured rather than
assumed. iOS session sharing could not be confirmed on the simulator and
wants a real device.
Never an app-owned WebView: Google blocks it, and rightly, since a webview
the app controls can read the password typed into it.
Cancelling is no longer reported as a failure. AuthEvent carries a
`cancelled` flag, set by comparing against the constant every back-out path
returns, and Google's `access_denied` on desktop counts too.
Five frontend bugs found by driving the real UI, not by reading it: the
details card slid under the tab bar leaving its buttons unhittable; the
ghost click after a touch pressed a button in the card that tap had just
opened, opening the editor by itself; the swipe that pages the day was dead
over every read-only block; 84px of macOS traffic-light lane was reserved on
platforms with no traffic lights; and the desktop header ignored the top
safe area on an iPad. A first launch now says what to do next rather than
showing an empty grid, and accounts are named as Google accounts throughout.
Tauri 2, React 19 and zustand on the front, Rust behind. Rust owns auth,
all HTTP to Google, the SQLite store, the sync loop, recurrence expansion
and timezone maths. TypeScript owns rendering and never talks to Google,
which keeps the content security policy locked to ipc:.
Week, day and agenda views, and no month view: it would be a second layout
engine, and the fit and fold logic that makes a day fit the window without
scrolling is the whole point of the app.
Runs on macOS, Linux, Android and iOS. Desktop catches Google's OAuth
redirect on a loopback port. A phone cannot, and Google rejects loopback
for mobile client types anyway, so it redirects to a custom URI scheme and
needs its own public OAuth clients, which docs/mobile.md covers. Refresh
tokens are sealed with XChaCha20-Poly1305 in the app data directory on
every platform, with no OS credential store in the picture.
On a phone the chrome becomes a top bar and a bottom tab bar, overlays
become sheets, hover affordances become taps, and dragging out an event
waits for a long press. Navigation moves one day at a time everywhere,
a swipe included.