Documentation
Shipping & platforms
The Mac build, the clipper, and what Windows and mobile still need.
PRD §7 calls the desktop build M5 and stops there. This document covers the whole venture: a Mac app first, then Windows, then iOS and Android, plus a Chrome extension that captures selected text into a vault.
1. Decide first: the vault needs a capture keypair
The problem the extension creates
sync/crypto.ts is symmetric end to end:
vault password ─┬─PBKDF2+salt─► KEK ──unwraps──► DEK ─┬─HKDF─► content key (AES-GCM)
└─HKDF─► path key (HMAC)
There is no keypair anywhere in the design. So a Chrome extension that can write an encrypted note must hold the DEK — and the DEK reads everything. Path names are HMAC'd with a key derived from the same secret, so it would see the vault's structure too.
That is the wrong amount of trust to put in a browser extension. Extensions are the softest target in this stack: they auto-update from a store you don't control, the MV3 service worker lives in a shared browser process, and extensions with real install bases get bought and repurposed. The vault password is deliberately unrecoverable and the recovery code is shown exactly once — that care is undone by handing the same key to a clipper.
The fix, and why it's cheap right now
Give the vault an asymmetric capture key:
| Piece | Where it lives |
|---|---|
| Public key | in the Keyring, in the clear — the server already stores salts in the clear "public by design" |
| Private key | wrapped by the DEK, exactly like the DEK is wrapped by the KEK |
| Extension holds | the public key, and nothing else |
A capture is sealed with an ephemeral ECDH agreement to the public key, then
AES-GCM — the same primitives already in use, and P-256 is native to WebCrypto,
which preserves crypto.ts:49's "v1 ships with zero crypto dependencies."
Captures land in their own table, not the vault namespace, so the extension
never needs the path key either. The app drains them into inbox.md on the
next sync.
What this buys: an extension compromise leaks nothing already written, grants no read access, and cannot forge an edit to an existing note. The worst case is junk appended to your inbox. That is a security story you can put in the store listing as the pitch.
Cost: ~120 lines and one lazy migration. Keyring already carries
v: number. Mint the keypair on the next unlock — the DEK is in hand at that
exact moment — wrap it, bump to v2, upload. No password re-entry, no flag day,
v1 vaults upgrade the first time they open.
Why now rather than later: crypto.ts:28 already makes this argument about itself — "PBKDF2 to Argon2id abandons every vault created before the change." The envelope is a forever contract once other people's vaults exist. Adding a field before the extension ships costs a morning; adding it after is a migration across every device and every vault.
The same key also serves mobile share-sheet capture, and any future "email into your vault" — all of which are write-only by nature.
2. Decide second: mobile has no folder
On desktop and web, a vault is a folder the user picked. On iOS that folder
does not exist. There is no user-visible directory of .md files an app can
walk and watch; there is the app's own container, and a document picker that
hands over one file at a time.
So mobile's vault lives in the app container, and sync is the only way in or
out. This is not a new adapter — the same TauriFsAdapter from §4 works
against a private container path — it is a new provisioning story:
| Target | How a vault begins |
|---|---|
| Web | "Pick a folder" (File System Access) |
| Desktop | "Pick a folder" (native dialog) |
| Mobile | "Sign in" — the vault materialises from sync |
The consequence to accept deliberately: mobile requires an account. Web and desktop work offline forever with no account and no server; mobile does not. That is a defensible line — it is what every file-backed notes app does — but it must be stated in the product rather than discovered by a user who installed the phone app first and found nothing to open.
Optional later, not first: expose the container through Files.app on iOS and SAF on Android so the notes are reachable from outside. It makes the "they're just files" promise true on mobile too, and it is strictly additive.
3. Mobile is a different UI, not a breakpoint
styles/base.css is ~2,700 lines and contains exactly one
@media (max-width: 720px). The app is palette-first, multi-pane, keyboard-
driven, with CodeMirror and optional vim bindings. None of that survives a
phone, and squeezing it down produces the worst version of both.
Recommendation: mobile ships capture, read, and light edit. No panes, no command palette, no vim, no plugin panels in v1. It shares everything below the UI — vault, sync, crypto, markdown, links, search — and shares almost nothing above it. Budget it as its own milestone with its own design pass, not as a port.
One genuine technical risk to name early: CodeMirror on iOS is a known trouble spot — the virtual keyboard, selection handles, and scroll-into-view fight contenteditable in ways that are fixable but not free. Prototype the editor on a real device in the first week of that milestone, before building anything around it.
4. The recommended order
Given: Macs on hand, no public release for a while, all four targets eventually.
| # | Step | Effort | Why it sits here |
|---|---|---|---|
| 0 | Rust toolchain (rustup) |
30 min | cargo is not installed on this machine — blocked today |
| 1 | Capture keypair (§1) | ~120 lines | Data contract. Cheapest it will ever be. Unblocks 4 and 7 |
| 2 | Tauri window, Mac only | 1 day, ~0 app code | Proves the four assumptions in §5 before any port work |
| 3 | TauriFsAdapter |
2–3 days, ~300 lines | The actual port. Mac app is real at the end of this |
| 4 | Chrome extension | 2–3 days | Unblocked by 1. Highest daily value per line in the list |
| 5 | FS watcher, global hotkey, keychain | 0.5–2 days each | Quick capture (FEATURES.md:40) pairs naturally with 4 |
| 6 | Windows | ~2 days | CI matrix + a cert. Nothing new architecturally |
| 7 | Mobile (iOS then Android) | weeks | Wants 1 and 2 settled; §3 is the real cost |
| 8 | Signing, notarization, updater | ~3 days work, 1–2 weeks calendar | Only when you actually publish |
Signing moves to the end, but not to zero. You do not need a certificate to run your own unsigned builds on your own Macs. Apple's $99 comes back at step 7 — free provisioning gives 7-day device builds, which is fine for a week and miserable as a habit. Budget it when mobile starts, not now.
Windows before mobile is deliberate: after step 3, Windows is a CI matrix and a certificate, because you cannot cross-compile it from macOS. Mobile is the largest item on the list. Take the cheap target first.
5. Step 2 in detail — what the first window is actually for
npm run tauri init in app/, pointed at the existing dist with devUrl on
Vite. The app runs unchanged, still on the FSAccessAdapter… except it won't,
and that is the point. Four assumptions get tested on day one, all of them
cheaper to find now than after the adapter is written:
| Assumption | Risk under Tauri |
|---|---|
crypto.subtle (sync/crypto.ts, sync/binary.ts, ai/openrouter.ts) |
Needs a secure context. tauri:// qualifies; Windows uses http://tauri.localhost — smoke-test there specifically at step 6 |
new Worker(new URL('./worker.ts', import.meta.url)) (plugins/host.ts:89) |
Module workers under a custom protocol |
Supabase realtime (schema-003-realtime.sql) |
WebSocket — needs connect-src wss: in the CSP |
Plugin fetch to granted hosts (netPolicy.ts) |
The real collision — §7 |
Exit criteria: the window opens, a vault opens through the existing web adapter, sync completes, a builtin plugin renders.
6. Step 3 in detail — the adapter
The 15 methods of StorageAdapter against @tauri-apps/plugin-fs, plus
plugin-dialog for the folder picker. One structural difference is worth
naming:
The web adapter persists a FileSystemDirectoryHandle in IndexedDB
(fsAccess.ts:7); the Tauri adapter persists a plain path string. So
restore() on desktop is "does this path still exist," not "does permission
still stand" — simpler, and it never silently loses the vault the way a revoked
handle can.
Two call sites assume the web shape:
vaultStore.ts:107-108— adapter construction becomes a selection, not two eagernews. Pick onavailable, preferringtauri.vaultStore.ts:336—adapter === fsAdapteris an identity check standing in for a capability. It should askadapter.kind !== 'memory'.
The fs plugin's scope must be widened at runtime to the folder the user picked
(FsExt::allow_directory in setup, or after the dialog returns). The static
capabilities/*.json scope cannot know the path in advance.
Exit criteria: the vault/ test suite passes against the Tauri adapter, and a
vault round-trips on disk with the app offline.
7. The one genuinely hard collision: CSP vs the plugin network grant
Tauri applies a strict Content-Security-Policy by default, and that is much of
what makes it safer than Electron. Slate's plugin system applies a runtime
allowlist instead: netPolicy.ts checks each plugin fetch against the hosts
its manifest declared, exactly, no suffix matching — the comment there is
careful that open-meteo.com must not grant evil.open-meteo.com.
These cannot both be authoritative. CSP is static and set at build time; the
plugin allowlist is per-install and known only at runtime. Letting a plugin
reach a host it declared means shipping connect-src * and giving up the
guarantee for every plugin that might ever exist.
Recommendation: route plugin network access through Rust. Use
tauri-plugin-http, whose scope is enforced natively, and keep the webview's
connect-src tight — Supabase and the app's own origin only. The worker calls
a command instead of fetch; checkUrl() stays as the first gate and the Rust
scope becomes the second.
This gets more urgent with mobile in scope, not less: iOS and Android
webviews are stricter, and solving it once in Rust covers all four targets. And
it must be settled before a third-party plugin depends on fetch semantics,
because slate.v1 is a forever contract (PLATFORM.md §2).
8. The Chrome extension
Manifest V3, service worker, and a deliberately tiny surface.
| Decision | Choice |
|---|---|
| Permissions | activeTab, contextMenus, storage; host_permissions for the Supabase URL only |
| Auth | The same signInWithPassword flow the app uses. No OAuth, so no redirect and no chrome.identity |
| What it stores | A Supabase session and the vault's public capture key. Nothing else |
| What it can do | Seal a capture and upload it |
| What it cannot do | List notes, search, read anything, or modify an existing note |
Two commands earn their place: Clip selection and Clip page — title,
URL, timestamp and selection, written as markdown with a source: frontmatter
key so logToday/template conventions already in vault/ apply.
Captures drain into inbox.md, which is the same destination FEATURES.md:40
gives quick capture. Build the drain once and both features use it — which is
the argument for doing step 4 and step 5's global hotkey close together.
Firefox and Safari ports are mostly manifest differences later; nothing in this design is Chrome-specific.