DEV Community

叶师傅
叶师傅

Posted on

One Rust Core, Two App Shells: Tauri + React Native in SwarmNote

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 logo

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:

  1. Product UI layer: React + Tauri WebView on desktop, Expo + React Native on mobile.
  2. Platform adapter layer: desktop exposes Rust through #[tauri::command]; mobile exposes Rust through uniffi-bindgen-react-native and a JSI/Turbo Module.
  3. 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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?;
Enter fullscreen mode Exit fullscreen mode

The frontend still sees familiar Tauri calls:

import { invoke } from "@tauri-apps/api/core";

await invoke("apply_ydoc_update", {
  docUuid,
  update,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Business bridge: RN -> UniFFI -> Rust core
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For a Tauri / Electron / Web project, the minimal install starts here:

pnpm add @swarmnote/editor-core @swarmnote/editor-react
Enter fullscreen mode Exit fullscreen mode

For React Native / Expo:

pnpm add @swarmnote/editor-core @swarmnote/editor-react-native react-native-webview comlink
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .md files.
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/"]
Enter fullscreen mode Exit fullscreen mode

If You Want To Try This Architecture

Think in this order instead of choosing a framework first:

  1. 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.

  2. Make the core unaware of its host.

    Do not use tauri::* in core. Do not depend on RN packages. Inject platform differences through traits or wrapper layers.

  3. Keep the desktop host thin.

    Tauri commands should not become a business logic swamp. They should mostly convert arguments and bridge events.

  4. 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.

  5. 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 .md files.
  • 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:

References

Top comments (0)