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.


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 eager news. Pick on available, preferring tauri.
  • vaultStore.ts:336adapter === fsAdapter is an identity check standing in for a capability. It should ask adapter.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.