A practical note for developers who want to build across desktop and mobile without rewriting the same business logic for every platform. SwarmNote uses Tauri + React on desktop, Expo + React Native on mobile, and one shared Rust core underneath.
SwarmNote: Your notes, swarming across your own devices.
The Short Version
SwarmNote’s cross-platform architecture is not “one desktop app, one mobile app, and a lot of duplicated business logic.” It is split into three layers:
-
Product UI layer:
React + Tauri WebViewon desktop,Expo + React Nativeon mobile. -
Platform adapter layer: desktop exposes Rust through
#[tauri::command]; mobile exposes Rust throughuniffi-bindgen-react-nativeand a JSI/Turbo Module. -
Shared core layer:
swarmnote-core, a platform-independent Rust crate that owns workspaces, documents, Yjs/yrs state, pairing, and P2P sync.
In other words, desktop and mobile are not two separate products. They are two different shells around the same Rust core.
Table Of Contents
- Why Not “One Web App Everywhere”?
- The Story Started With SwarmDrop
- Where Tauri v2 Mobile Felt Limited
- Desktop: Tauri As The System Shell
- Mobile: React Native As The Phone Experience
- Why The Mobile Editor Still Uses WebView
- Turning The Editor Into A Reusable Package
- The Real Core: Platform Differences As Traits
- Why SwarmNote Needs A Rust Core
- One Full “Open Document” Flow
- If You Want To Try This Architecture
flowchart TB
subgraph Desktop["Desktop: SwarmNote"]
React["React 19 + TypeScript<br/>CodeMirror 6 · Zustand · TanStack Router"]
Tauri["Tauri 2 Host<br/>Commands · Events · Tray · Updater"]
end
subgraph Mobile["Mobile: SwarmNote Mobile"]
RN["Expo + React Native<br/>NativeWind · Expo Router · Zustand"]
UniFFI["UniFFI / JSI Turbo Module<br/>Generated TS + C++ bindings"]
WV["WebView Editor<br/>CodeMirror 6 + Comlink"]
end
Core["swarmnote-core<br/>Workspace · Document CRUD · YDocManager · Pairing"]
P2P["swarm-p2p-core<br/>libp2p · mDNS · DHT · DCUtR · Relay · GossipSub"]
DB[("SQLite<br/>workspace.db · devices.db")]
React --> Tauri --> Core
RN --> UniFFI --> Core
RN --> WV
WV -. "Yjs update bytes" .-> RN
Core --> P2P
Core --> DB
style Core fill:#fff4cc,stroke:#d97706,stroke-width:2px
style P2P fill:#e8f4ff,stroke:#208aef
Why Not “One Web App Everywhere”?
At first, I wanted the cleanest possible answer: Tauri v2 supports both desktop and mobile, so why not ship one Web + Rust app everywhere?
The idea is very tempting. Build the UI with web technologies, keep the core in Rust, get small desktop bundles and strong system integration. On mobile, in theory, you initialize Tauri inside Android and iOS projects and keep going.
It sounds like cross-platform development finally stopped being a trade-off.
Then you start touching file systems, share sheets, mobile permissions, gestures, keyboards, safe areas, and platform lifecycle. That is where the story becomes less magical.
| Dimension | Tauri mobile | React Native |
|---|---|---|
| Mobile UI feel | WebView-first; many mobile interaction details are manual | Native views, gestures, navigation, keyboards, and safe areas feel natural |
| Ecosystem | Tauri plugins + web ecosystem | Expo / RN ecosystem with broad mobile coverage |
| Rust calls | WebView IPC with JSON serialization | JSI direct calls through generated C++/Rust bindings |
| Editor reuse | Great for web-based editors | Needs WebView for CodeMirror |
| Product fit | Fast for bringing a web app to mobile | Better for building a real phone app |
So the direction became: keep Tauri on desktop, use React Native on mobile, and keep the Rust core shared.
The Story Started With SwarmDrop
SwarmNote did not appear out of nowhere. Before it, there was a pathfinder project: SwarmDrop.
SwarmDrop is a P2P file transfer app, roughly “LocalSend across networks.” It was a good place to test the hard parts:
- Can Rust + libp2p run reliably on real devices?
- How should mDNS, DHT, Relay, and DCUtR work together?
- How should large file transfer, chunking, progress, cancellation, and resume work?
- How painful is Android file picking, public directory writes, SAF, and MediaStore?
Early mobile experiments in SwarmDrop used Tauri mobile. That stage mattered because it proved that the “Rust core on mobile” direction was viable. Tauri’s #[tauri::command], events, and channels also felt very natural if you were already familiar with Tauri desktop: frontend calls invoke(), Rust handles async work, progress events flow back to the UI.
But it also revealed a very practical difference: running on mobile and being a good long-term mobile product architecture are not the same thing.
The clearest pain point was the file system. On desktop, once you have a path, many things are just std::fs or tokio::fs. On Android, the user may give you a content:// URI. Public downloads involve Scoped Storage. Writing may require SAF or MediaStore. Directory traversal, permission persistence, temporary cache files, and streaming large reads all need separate handling.
This was not Tauri’s fault. Mobile file systems are inherently complicated. But at the time, the Tauri mobile ecosystem felt thin for this kind of low-level mobile file work. It was hard to find the kind of mature, cohesive, battle-tested library story you get in the Expo/RN ecosystem. You could end up writing a cross-platform app in name, while actually maintaining WebView UI, Android native plugins, Rust transfer logic, permission glue, and lifecycle glue all at once.
That phase taught SwarmNote an important lesson: the Rust core was worth keeping, but the mobile shell did not have to be Tauri.
Where Tauri v2 Mobile Felt Limited
This needs to be said fairly: Tauri v2 mobile is valuable. Android and iOS support are part of Tauri v2, and the plugin system can expose native code written in Kotlin or Swift to the WebView frontend. For many “bring a web product to mobile” use cases, it is attractive.
But for a project like SwarmNote or SwarmDrop, a few limits become very visible:
| Issue | How it shows up in practice |
|---|---|
| Mobile plugin ecosystem is still not deep enough | The official docs also note that not every official plugin supports mobile; for niche native capabilities, you often write your own plugin |
| File system is not “one API solves all platforms” | App-private storage is fine, but public directories, file pickers, SAF URIs, MediaStore, and large streaming I/O get complex quickly |
| WebView UI needs extra mobile care | Keyboard avoidance, safe areas, gesture navigation, bottom sheets, and touch feedback need careful work |
| Fewer mobile-specific community examples | When a complex issue appears, there are fewer existing cases to search |
| Calls still go through WebView IPC | For high-frequency or complex typed calls, JSON IPC is less pleasant than JSI direct calls |
So when building SwarmNote Mobile, I asked a different question:
If the Rust core already works on mobile, why not use a mature mobile UI ecosystem for the shell?
The answer was React Native + Expo + UniFFI.
At first, it was just an experiment: RN owns the mobile experience, Rust keeps the core logic, and uniffi-bindgen-react-native connects them. The result was better than expected. It did not feel like a compromise. It made both sides stronger.
- RN/Expo handles what a phone app needs: navigation, gestures, file picking, secure storage, images, permissions, system integration.
- Rust handles what I do not want to rewrite in JavaScript: P2P, CRDT, SQLite, sync protocol, device identity.
- UniFFI maps Rust async APIs to TypeScript promises and events to callback interfaces.
- Hermes JSI makes the bridge more typed and lower-friction than WebView IPC.
This direction later fed back into SwarmDrop. SwarmDrop validated libp2p, NAT traversal, pairing, and transfer logic. SwarmNote then organized the “shared Rust core + host adapter” model more clearly. Now SwarmDrop is moving toward the same architecture: a thin Tauri host on desktop, an Expo/RN host on mobile, and shared swarmdrop-core / swarm-p2p-core underneath.
timeline
title Timeline Of The Swarm Architecture
section Technical Validation
Early SwarmDrop : Tauri mobile validates Rust + libp2p on mobile
File System Wall : SAF / MediaStore / content URI require lots of mobile glue
P2P Core : Extract swarm-p2p-core for mDNS / DHT / Relay / GossipSub
section SwarmNote
Desktop MVP : Tauri 2 + React + Rust core
Core Extraction : Remove Tauri coupling from swarmnote-core through injected traits
Mobile App : Expo + RN + UniFFI share the same Rust business core
section Feedback
SwarmDrop Migration : Adopt the same Core / Desktop / Mobile layering
Desktop: Tauri As The System Shell
In SwarmNote, Tauri is not the business core. It is the desktop host. It does things such as:
- Creating windows, tray integration, auto-updates, and notifications
- Turning frontend
invoke()calls into Rust commands - Emitting Rust events back to the frontend
- Providing desktop implementations for keychain, file watching, and window-to-workspace mapping
In src-tauri/src/lib.rs, the entry point registers plugins and commands, creates AppCore during setup, and injects desktop-specific capabilities:
let keychain = Arc::new(platform::DesktopKeychain::new());
let event_bus = Arc::new(platform::TauriEventBus::new(app.handle().clone()));
let app_core = AppCoreBuilder::new(keychain, event_bus, app_data_dir)
.with_watcher_factory(|p| Arc::new(platform::NotifyFileWatcher::new(p)))
.build()
.await?;
The frontend still sees familiar Tauri calls:
import { invoke } from "@tauri-apps/api/core";
await invoke("apply_ydoc_update", {
docUuid,
update,
});
The key is not invoke() itself. The key is the boundary: Tauri commands mostly receive arguments, convert errors, and forward events. The actual business rules are pushed down into swarmnote-core.
Mobile: React Native As The Phone Experience
The mobile repository, swarmnote-mobile, is built with Expo + React Native. It owns the phone-specific experience:
- Expo Router file-based routing
- NativeWind / RN primitives UI
- Safe areas, keyboards, gestures, and platform capabilities
-
expo-secure-store,expo-file-system, and other mobile host APIs
But mobile does not rewrite workspaces, documents, pairing, or the Yjs state machine. It uses a workspace package named react-native-swarmnote-core to expose Rust as a Turbo Module callable from RN.
sequenceDiagram
participant UI as React Native UI
participant TS as Generated TypeScript API
participant JSI as Hermes JSI / C++
participant Wrap as mobile-core wrapper
participant Core as swarmnote-core
UI->>TS: workspace.openDoc("daily.md")
TS->>JSI: direct native call
JSI->>Wrap: UniffiWorkspaceCore::open_doc
Wrap->>Core: WorkspaceCore.ydoc().open_doc()
Core-->>Wrap: doc_uuid + full Y.Doc state
Wrap-->>UI: typed result
On the Rust side, the mobile wrapper is thin. It defines a #[derive(uniffi::Object)] object, wraps WorkspaceCore, and exports async methods:
#[derive(uniffi::Object)]
pub struct UniffiWorkspaceCore {
inner: Arc<WorkspaceCore>,
}
#[uniffi::export(async_runtime = "tokio")]
impl UniffiWorkspaceCore {
pub async fn open_doc(&self, rel_path: String) -> Result<UniffiOpenDocResult, FfiError> {
let result = self.inner.ydoc().open_doc(&rel_path).await?;
Ok(result.into())
}
}
The relationship to Tauri commands is easy to understand:
| Desktop Tauri | Mobile UniFFI |
|---|---|
#[tauri::command] |
#[uniffi::export] |
invoke("cmd", args) |
Direct calls to generated TS functions / object methods |
app.emit("event") |
callback interface / event adapter |
| JSON IPC | JSI / C++ bindings |
| Runtime argument matching | Generated TypeScript types |
This is what makes the architecture feel good: the developer experience is close to Tauri, but the mobile runtime is more native.
Why The Mobile Editor Still Uses WebView
One easy misunderstanding: using React Native does not mean every piece of UI must become a native RN component.
SwarmNote’s editor is built on CodeMirror 6. It depends on DOM, Selection, MutationObserver, CSS layout, and other web APIs. That fits Tauri’s desktop WebView very well, but it cannot be placed directly inside RN’s native render tree. To solve this, the editor was later extracted into the swarmnote-editor monorepo and published as npm packages, so desktop, mobile, and future hosts can share the same Markdown live-preview core.
The mobile app uses two bridges:
- Business bridge: RN -> UniFFI -> Rust core
- Editor bridge: RN -> WebView -> Comlink -> CodeMirror
flowchart LR
subgraph Phone["React Native App"]
Screen["Editor Screen"]
Store["Zustand Stores"]
Bridge["Comlink Host Adapter"]
end
subgraph WebView["WebView"]
Endpoint["Comlink WebView Endpoint"]
EditorWeb["WebView bundle<br/>@swarmnote/editor-react-native/webview"]
EditorCore["@swarmnote/editor-core<br/>CodeMirror 6 · Yjs · Markdown"]
end
subgraph Rust["Rust Core"]
YDoc["YDocManager / yrs"]
Sync["WorkspaceSync"]
end
Screen --> Bridge
Bridge <--> Endpoint
Endpoint --> EditorWeb --> EditorCore
Screen --> Store
Store --> YDoc
EditorCore -- "local Y.Update bytes" --> Bridge
Bridge -- "apply_update()" --> YDoc
YDoc -- "remote update bytes" --> Bridge
Bridge -- "applyRemoteUpdate()" --> EditorCore
YDoc --> Sync
This adds a layer, but it buys several practical wins:
- Desktop and mobile share the same Markdown editor core.
- CodeMirror plugins, Yjs bindings, math, and image rendering logic can be reused.
- RN owns the mobile shell and interactions instead of rewriting an editor.
- The WebView remains a full web environment, which makes debugging and bundling clearer.
The mobile editor path is essentially: load a self-contained editor WebView bundle, then host it inside RN WebView. Early versions maintained this inside swarmnote-mobile/packages/editor-web; it has now been extracted into swarmnote-editor as the npm subpath @swarmnote/editor-react-native/webview. Comlink wraps postMessage into an RPC model that feels like calling local functions.
Turning The Editor Into A Reusable Package
swarmnote-editor is not a private folder inside SwarmNote. It is an independent editor project published as public npm packages:
| Package | Purpose |
|---|---|
@swarmnote/editor-core |
CodeMirror 6 core, Markdown live-preview, Plugin SDK, and plugins such as math, table, mermaid, slash, and wikilink |
@swarmnote/editor-react |
Thin React host adapter with EditorView and I18nProvider
|
@swarmnote/editor-react-native |
React Native bridge with useEditorBridge, Comlink adapter, and WebView HTML bundle |
This split follows the same cross-platform philosophy: runtime engine through npm for reuse; UI primitives through a shadcn-style registry so each host can copy, own, and customize them.
flowchart TB
subgraph npm["npm runtime packages"]
Core["@swarmnote/editor-core<br/>CM6 core + Plugin SDK"]
ReactPkg["@swarmnote/editor-react<br/>React plumbing"]
RNPkg["@swarmnote/editor-react-native<br/>RN bridge + WebView bundle"]
end
subgraph Registry["shadcn-style registry"]
WebUI["Web UI primitives<br/>slash-popover · wikilink-popover · toolbar"]
RNUI["RN UI primitives<br/>slash-sheet · heading-sheet · markdown-editor"]
end
ReactPkg --> Core
RNPkg --> Core
WebUI -. copy to host .-> ReactPkg
RNUI -. copy to host .-> RNPkg
For a Tauri / Electron / Web project, the minimal install starts here:
pnpm add @swarmnote/editor-core @swarmnote/editor-react
For React Native / Expo:
pnpm add @swarmnote/editor-core @swarmnote/editor-react-native react-native-webview comlink
This is one reason I find the SwarmNote architecture worth writing about. The product is cross-platform, but the reusable parts are also being extracted, published, and documented. swarmnote-core handles local-first P2P sync; swarmnote-editor handles reusable Markdown editing.
The Real Core: Platform Differences As Traits
Cross-platform projects often fail when Tauri, RN, file systems, notifications, and key storage get mixed into the business logic. SwarmNote keeps swarmnote-core platform-independent:
classDiagram
class AppCore {
identity
pairing
network
recent_workspaces
}
class WorkspaceCore {
documents
filesystem
ydoc
sync
}
class KeychainProvider {
<<trait>>
load_keypair()
save_keypair()
}
class EventBus {
<<trait>>
emit(AppEvent)
}
class FileSystem {
<<trait>>
read_text()
write_text()
scan_tree()
save_media()
}
class FileWatcher {
<<trait>>
watch()
}
AppCore --> KeychainProvider
AppCore --> EventBus
WorkspaceCore --> FileSystem
WorkspaceCore --> FileWatcher
WorkspaceCore --> EventBus
Desktop implements those traits like this:
| Capability | Desktop implementation |
|---|---|
| Key storage |
keyring, backed by macOS Keychain / Windows Credential Manager / Linux Secret Service |
| Events |
TauriEventBus, internally calling AppHandle::emit
|
| File watching |
notify + debouncer |
| Local files | Desktop file system |
Mobile swaps in another set:
| Capability | Mobile implementation |
|---|---|
| Key storage | RN-side expo-secure-store, used by Rust through callback / adapter |
| Events | UniFFI callback interface, forwarded into RN stores |
| File watching | Usually unnecessary inside the mobile app sandbox |
| Local files | App sandbox / Expo FileSystem paths |
The business core does not ask “am I running inside Tauri?” It asks “who implements this trait?” That is what makes cross-platform reuse actually work.
Why SwarmNote Needs A Rust Core
SwarmNote is not just a Markdown editor. Its product goals are:
- Notes are local
.mdfiles. - Your own devices form a swarm.
- No cloud account or central server is required.
- libp2p handles discovery, connections, pairing, and message broadcast.
- Yjs/yrs handles merging after offline edits.
If this logic were implemented separately in JS, Kotlin, Swift, and Rust, maintenance would become painful very quickly. A shared Rust core is valuable here:
flowchart LR
EditA["Device A edits Markdown"] --> YA["Y.Doc produces update"]
YA --> Pub["Publish incremental update through GossipSub"]
Pub --> Net["libp2p network<br/>mDNS / DHT / Relay"]
Net --> Recv["Device B receives update"]
Recv --> YB["yrs apply_update"]
YB --> Flush["Write back to SQLite + .md files"]
style Net fill:#e8f4ff,stroke:#208aef
The Rust layer owns:
- libp2p runtime
- Device identity and pairing state
- SQLite metadata
- Y.Doc state reads and writes
- Document sync protocol
Desktop and mobile sharing this layer means the same bug is fixed once, and the sync protocol does not quietly fork by platform.
File View Of The Architecture
Desktop repository:
swarmnote/
├── src/ # React desktop frontend
├── src-tauri/ # Tauri host: commands / plugins / desktop adapters
├── crates/
│ ├── core/ # swarmnote-core: platform-independent business core
│ ├── entity/ # SeaORM entities
│ └── migration/ # SQLite migrations
├── libs/core/ # swarm-p2p-core: libp2p wrapper
└── dev-notes/blog/ # technical articles and architecture notes
Mobile repository:
swarmnote-mobile/
├── src/ # Expo Router / RN screens / stores
├── packages/
│ ├── editor-web/ # Early WebView editor entry; can migrate to @swarmnote/editor-react-native
│ └── swarmnote-core/ # react-native-swarmnote-core
│ ├── rust/mobile-core/ # UniFFI wrapper crate
│ ├── src/generated/ # Generated TS bindings
│ └── cpp/generated/ # Generated C++ JSI bindings
└── plugins/ # Expo config plugins
Shared editor repository:
swarmnote-editor/
├── packages/editor-core/ # @swarmnote/editor-core
├── packages/editor-react/ # @swarmnote/editor-react
├── packages/editor-react-native/ # @swarmnote/editor-react-native
└── registry/ # shadcn-style UI primitives
One Full “Open Document” Flow
Putting the diagrams together, a user action looks like this:
sequenceDiagram
autonumber
participant User as User
participant RN as RN Editor Screen
participant Core as UniFFI WorkspaceCore
participant Rust as swarmnote-core
participant WV as WebView CodeMirror
participant P2P as libp2p swarm
User->>RN: Open daily.md
RN->>Core: openDoc("daily.md")
Core->>Rust: ydoc.open_doc()
Rust-->>Core: doc_uuid + full_state
Core-->>RN: typed result
RN->>WV: seedDocument(full_state)
User->>WV: Type text
WV-->>RN: local Y.Update bytes
RN->>Core: applyUpdate(doc_uuid, bytes)
Core->>Rust: apply_update + debounce writeback
Rust->>P2P: publish doc update
The desktop flow is almost the same. Replace RN -> UniFFI with React -> Tauri invoke, and the WebView CodeMirror editor is simply the frontend inside the Tauri window.
Engineering Benefits
First, stronger consistency.
Pairing, sync, document state, and conflict handling live in Rust core. The same state machine covers desktop and mobile.
Second, no platform experience compromise.
Desktop keeps Tauri’s system integration, tray, updater, and small bundles. Mobile uses RN/Expo for navigation, gestures, keyboards, safe areas, and native mobile capabilities.
Third, a natural migration path.
SwarmDrop validated libp2p and extracted swarm-p2p-core. SwarmNote extracted swarmnote-core. Now SwarmDrop can move along the same boundary. This is not a rewrite; it is a gradual process of pushing proven logic down into the core.
Fourth, realistic editor reuse.
CodeMirror is not forced into native RN components. WebView is used where it makes sense, and RN talks to it through a typed Comlink bridge.
Costs And Pitfalls
This architecture is not free.
| Pitfall | Mitigation |
|---|---|
| Mobile cannot use Expo Go | Use a development build because the app includes a native Rust Turbo Module |
| Rust changes require regenerated bindings | Run pnpm --filter react-native-swarmnote-core ubrn:android or ubrn:ios
|
| WebView editor may load an old bundle | Rebuild the related npm package / WebView bundle after changing editor-core or editor-react-native/webview
|
| Generated code is large | Treat src/generated and cpp/generated as generated output, never hand-edit |
| Platform boundaries can drift | New features must be classified: business rules go to core; platform capabilities go to host adapters |
| Event flow is longer | Use a unified AppEvent, then map it to Tauri emit or UniFFI callbacks |
A useful decision rule:
flowchart TD
A["Adding a new feature"] --> B{"Is it a business rule?"}
B -->|"Yes: docs, sync, pairing, state machine"| C["Put it in swarmnote-core"]
B -->|"No"| D{"Is it a platform capability?"}
D -->|"Yes: file picker, key storage, notifications, paths"| E["Define / reuse a trait<br/>implement it per host"]
D -->|"No: pure UI interaction"| F["Desktop: src/<br/>Mobile: swarmnote-mobile/src/"]
If You Want To Try This Architecture
Think in this order instead of choosing a framework first:
Find the core that truly needs sharing.
If only the UI is similar, you may not need a Rust core. If you have protocols, sync, encryption, databases, or complex state machines, Rust core can be a great fit.Make the core unaware of its host.
Do notuse tauri::*in core. Do not depend on RN packages. Inject platform differences through traits or wrapper layers.Keep the desktop host thin.
Tauri commands should not become a business logic swamp. They should mostly convert arguments and bridge events.Keep the mobile host mobile-first.
RN should own the phone experience. Do not sacrifice native interactions just to make the mobile app look exactly like desktop.Use WebView intentionally for complex web-native modules.
WebView is not necessarily a failure. It can be a clean boundary for modules that are genuinely better on the web platform.
Current Status Of SwarmNote
SwarmNote is a local-first, P2P-synced Markdown note app:
- Notes are local
.mdfiles. - Devices join your swarm through a 6-digit pairing code.
- libp2p connects devices.
- Yjs/yrs merges edits after offline work.
- Desktop is Tauri + React.
- Mobile is Expo + React Native.
- Both share the Rust core.
If you are interested in notes that sync directly between your own devices without cloud accounts or central servers, you can follow:

Top comments (0)