DocumentationDecision recordsADR-0048
A native mobile shell over the shared Rust core
Skriuw reaches phones today as the installed browser build: the compact shell (ADR-0041, ADR-0047) over worker-owned SQLite WASM and OPFS (ADR-0027).
accepted
Context
Skriuw reaches phones today as the installed browser build: the compact shell (ADR-0041, ADR-0047) over worker-owned SQLite WASM and OPFS (ADR-0027). That path cannot reach the share sheet on iOS, widgets, biometric unlock, background sync, or durable storage that the platform will not evict, and it inherits every WebKit quirk of a page rather than an app.
Three properties of the current architecture make a native client cheap where it matters:
- The Rust core is framework-free.
skriuw-domain,skriuw-runtime,skriuw-storage,skriuw-sqlite,skriuw-syncandskriuw-cryptocarry no Tauri, filesystem-layout, or operating-system dependency, and no crate manifest names Tauri. - The renderer already talks to storage through one seam,
apps/workspace/src/bridge/commands.ts, with two implementations: Tauriinvokeand the browser storage worker. - Rust/TypeScript contracts are generated, committed and drift-checked.
The editor is the opposite: raw ProseMirror with roughly 2,500 lines of editor CSS, node views, and a decoration-based AI review surface. It cannot run in React Native and must not be rewritten.
Decision
Build the mobile client as an Expo (React Native) application with three layers:
- Shared core, native. A new
crates/skriuw-mobilefacade exposes the use cases the mobile client needs over UniFFI. An Expo native module (apps/mobile/modules/skriuw-core) wraps the generated bindings. SQLite stays canonical and native (rusqlite, bundled); durable writes stay serialized and transactional inside Rust. The mobile client is the third implementation of the bridge seam, not a new protocol. - Shared renderer logic, extracted. The dependency-free store, operation
queue, tree model and route model move from
apps/workspace/srcintopackages/renderer-core, consumed by bothapps/workspace/andapps/mobile/. Theme tokens stay authored inthemes.css; a generator emits a committed, drift-checked TypeScript token map for React Native. - Native chrome, web editor. Navigation, toolbar, tab bar, sheets, lists, search and settings are React Native views. The editor is the existing ProseMirror surface mounted once in a persistent Expo DOM component (webview) that swaps documents by message. It is never remounted on navigation.
Excluded from the mobile build: skriuw-history-git (libgit2),
skriuw-ai-ollama, import adapters, scheduled backups, tabs and split view.
Alternatives considered
- Tauri 2 mobile. Reuses the compact shell and all Rust unchanged and is the cheapest route to an identical interface. Rejected as the primary path because it keeps the whole interface in a webview: no native navigation, lists, keyboard handling or share extension without per-platform plugins, which is the gap that motivates a native client at all. It remains the fallback if the UniFFI spike (Mobile 02) fails.
- Installed browser build only. No new code, and it stays supported. Rejected as sufficient because of the platform gaps in Context.
- Whole renderer in one webview inside Expo. A worse Tauri: the costs of both stacks and the benefits of neither.
- Rewriting the editor natively. Rejected outright; two editors cannot hold document-format parity.
Consequences
- Two interfaces must hold parity. Shared core, shared store and generated tokens bound the drift; chrome is duplicated on purpose.
- The repository root gains JavaScript workspaces so
apps/workspace/,apps/mobile/, andpackages/*resolve one another. - iOS artifacts need macOS: the xcframework is produced by a macOS CI runner and the application by EAS Build. Android builds and emulator end-to-end tests run on Linux and are the local development target.
- Expo Go is not usable once the native module exists; development builds are required.
- The performance contract applies unchanged: navigation must not wait on the native module or the webview. The snapshot is hydrated into the store at startup and the editor webview is kept warm.
- Background sync on iOS cannot rely on the WebSocket wake channel and needs silent push plus background tasks.
Implementation contract: docs/specs/mobile-app.md.
On this page