一篇写给"想做跨端产品,但不想把业务逻辑在每个平台重写一遍"的工程笔记。主角是 SwarmNote:桌面端用 Tauri + React,移动端用 Expo + React Native,底层共享同一份 Rust 核心。

SwarmNote: Your notes, swarming across your own devices.
先说结论
SwarmNote 的跨端方案不是"桌面一套、手机一套、业务逻辑靠复制粘贴同步",而是把系统拆成三层:
- 产品界面层 :桌面端是
React + Tauri WebView,移动端是Expo + React Native。 - 平台适配层 :桌面端用
#[tauri::command]暴露能力,移动端用uniffi-bindgen-react-native生成 JSI/Turbo Module。 - 共享核心层 :
swarmnote-core是平台无关的 Rust crate,负责工作区、文档、Yjs/yrs 状态、P2P 配对和同步。
换句话说,桌面和移动不是两个产品,而是同一个 Rust 核心外面套了两个不同的壳。
目录
- [为什么不是"一套 Web 跑所有端"](#为什么不是“一套 Web 跑所有端” "#%E4%B8%BA%E4%BB%80%E4%B9%88%E4%B8%8D%E6%98%AF%E4%B8%80%E5%A5%97-web-%E8%B7%91%E6%89%80%E6%9C%89%E7%AB%AF")
- [故事其实从 SwarmDrop 开始](#故事其实从 SwarmDrop 开始 "#%E6%95%85%E4%BA%8B%E5%85%B6%E5%AE%9E%E4%BB%8E-swarmdrop-%E5%BC%80%E5%A7%8B")
- [Tauri v2 mobile 卡在哪里](#Tauri v2 mobile 卡在哪里 "#tauri-v2-mobile-%E5%8D%A1%E5%9C%A8%E5%93%AA%E9%87%8C")
- [桌面端:Tauri 负责"系统壳"](#桌面端:Tauri 负责“系统壳” "#%E6%A1%8C%E9%9D%A2%E7%AB%AFtauri-%E8%B4%9F%E8%B4%A3%E7%B3%BB%E7%BB%9F%E5%A3%B3")
- [移动端:RN 负责"手机体验"](#移动端:RN 负责“手机体验” "#%E7%A7%BB%E5%8A%A8%E7%AB%AFrn-%E8%B4%9F%E8%B4%A3%E6%89%8B%E6%9C%BA%E4%BD%93%E9%AA%8C")
- [编辑器:为什么移动端还需要 WebView](#编辑器:为什么移动端还需要 WebView "#%E7%BC%96%E8%BE%91%E5%99%A8%E4%B8%BA%E4%BB%80%E4%B9%88%E7%A7%BB%E5%8A%A8%E7%AB%AF%E8%BF%98%E9%9C%80%E8%A6%81-webview")
- 顺手把编辑器也产品化
- [真正的核心:把平台差异变成 trait](#真正的核心:把平台差异变成 trait "#%E7%9C%9F%E6%AD%A3%E7%9A%84%E6%A0%B8%E5%BF%83%E6%8A%8A%E5%B9%B3%E5%8F%B0%E5%B7%AE%E5%BC%82%E5%8F%98%E6%88%90-trait")
- [P2P:SwarmNote 为什么需要 Rust core](#P2P:SwarmNote 为什么需要 Rust core "#p2pswarmnote-%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-rust-core")
- 一次"打开文档"的完整链路
- 如果你也想用这套方案
CodeMirror 6 · Zustand · TanStack Router"] Tauri["Tauri 2 Host
Commands · Events · Tray · Updater"] end subgraph Mobile["移动端:SwarmNote Mobile"] RN["Expo + React Native
NativeWind · Expo Router · Zustand"] UniFFI["UniFFI / JSI Turbo Module
生成 TS + C++ 绑定"] WV["WebView Editor
CodeMirror 6 + Comlink"] end Core["swarmnote-core
Workspace · Document CRUD · YDocManager · Pairing"] P2P["swarm-p2p-core
libp2p · mDNS · DHT · DCUtR · Relay · GossipSub"] DB[("SQLite
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
为什么不是"一套 Web 跑所有端"
一开始我也很想要一个特别漂亮的答案:Tauri v2 既能做桌面,也能做移动,那是不是直接一套 Web + Rust 走到底就好了?
这个想法很诱人。Web 技术写 UI,Rust 写核心,桌面包体小,系统能力强;到了移动端,理论上也只是把 Tauri 初始化到 Android / iOS 项目里。听起来像是"跨端开发终于不用再做选择题了"。
但真正写到文件系统、系统分享、移动端权限、手势、键盘、安全区这些地方,事情就开始变得没有那么童话了。
| 维度 | Tauri mobile | React Native |
|---|---|---|
| 移动端 UI 手感 | WebView 为主,需要自己处理大量移动交互细节 | 原生视图,手势、导航、键盘、安全区更自然 |
| 生态 | Tauri 插件 + Web 生态 | Expo / RN 生态,移动能力覆盖更完整 |
| Rust 调用 | WebView IPC,JSON 序列化 | JSI 直调 C++/Rust 绑定,类型生成 |
| 编辑器复用 | 很适合 Web 编辑器 | 需要 WebView 承载 CodeMirror |
| 产品定位 | "把 Web app 带到移动端"很快 | "做一个真正手机 app"更顺 |
所以后来的方向变成:桌面继续用 Tauri,移动端改用 React Native,但 Rust 核心继续复用。
故事其实从 SwarmDrop 开始
SwarmNote 不是凭空长出来的架构。前面还有一个探路项目:SwarmDrop。
SwarmDrop 做的是 P2P 文件传输,可以理解成"跨网络版 LocalSend"。它很适合拿来验证几件硬骨头:
- Rust + libp2p 在真实设备上能不能稳定跑
- mDNS / DHT / Relay / DCUtR 这些发现和连通性方案怎么组合
- 大文件传输、分片、进度、取消、恢复怎么做
- Android 上文件选择、公共目录写入、SAF / MediaStore 怎么接
早期做移动端验证时,SwarmDrop 用过 Tauri mobile。这个阶段很重要,因为它证明了"Rust 核心上移动端"这条路是通的。Tauri 的 #[tauri::command]、event、channel 这套模型,对已经熟悉桌面端的人来说非常自然:前端 invoke(),后端 Rust async 处理,进度再推回前端。
但它也暴露了一个现实:能跑起来 和 适合长期做移动产品 是两件事。
SwarmDrop 里最典型的痛点是文件系统。桌面端拿到路径后,很多事情就是 std::fs / tokio::fs。Android 上则不一样:用户选中的可能是 content:// URI,公共下载目录涉及 Scoped Storage,写入要绕 SAF 或 MediaStore,目录遍历、权限持久化、临时缓存、大文件流式读取都要单独处理。
这些不是 Tauri 的错,而是移动端系统本来就复杂。只是当时 Tauri mobile 生态还比较薄,遇到这种偏底层的移动文件系统需求,很难找到一个像 Expo/RN 生态里那样顺手、成体系、案例足够多的解决方案。最后就会变成:你名义上在写跨端 app,实际上在一边写 WebView UI,一边写 Android 原生插件,一边维护 Rust 传输层,一边补权限和生命周期胶水。
这个阶段给了 SwarmNote 一个很重要的教训:Rust 核心值得保留,但移动端的壳不一定非要继续用 Tauri。
Tauri v2 mobile 卡在哪里
这段需要说得公允一点:Tauri v2 mobile 是有价值的。官方已经把 Android / iOS 支持纳入 v2,也提供移动插件能力;插件可以用 Kotlin / Swift 写原生实现,再暴露给 WebView 前端。对很多"Web 产品加一点移动壳"的场景,它是很有吸引力的。
但对 SwarmNote / SwarmDrop 这种项目,几个限制会变得很明显:
| 问题 | 在项目里表现出来的影响 |
|---|---|
| 移动插件生态还不够厚 | 官方也明确说并非所有插件都支持移动端;遇到细分能力时,常常要自己写插件 |
| 文件系统不是"一个 API 解决所有平台" | App 私有目录还好,公共目录、文件选择、SAF URI、MediaStore、大文件流式读写会迅速复杂 |
| WebView UI 要自己补移动细节 | 键盘避让、安全区、手势导航、bottom sheet、触控反馈都要额外经营 |
| 移动端社区案例少 | 复杂问题搜索到的经验少,很多坑只能自己踩 |
| 调用链仍是 WebView IPC | 对高频、类型复杂的核心调用来说,JSON IPC 不如 JSI 直调舒服 |
所以后面写 SwarmNote 移动端时,我换了一个问题问自己:
如果 Rust 核心已经证明可行,移动端为什么不直接用一个真正成熟的移动 UI 生态?
答案就是 React Native + Expo + UniFFI。
一开始这只是一次尝试:RN 负责移动端体验,Rust 继续负责核心逻辑,中间用 uniffi-bindgen-react-native 接起来。结果有点出乎意料:它不是"退而求其次",反而把两边的长处都放大了。
- RN/Expo 负责手机 app 该有的生态:导航、手势、文件选择、安全存储、图片、权限、系统集成
- Rust 负责不该用 JS 重写的核心:P2P、CRDT、SQLite、同步协议、设备身份
- UniFFI 把 Rust async API 映射成 TypeScript Promise,把事件映射成 callback interface
- Hermes JSI 直调让这条桥比 WebView IPC 更类型化、更低摩擦
最后这条路线也反过来影响了 SwarmDrop。SwarmDrop 早期负责验证 libp2p、NAT 穿透、配对、传输等底层能力;SwarmNote 在此基础上把"共享 Rust 核心 + Host 适配层"整理成更清晰的模式;现在 SwarmDrop 也迁移到同一套架构:桌面端薄 Tauri host,移动端 Expo/RN host,中间共享 swarmdrop-core / swarm-p2p-core。
桌面端:Tauri 负责"系统壳"
Tauri 在 SwarmNote 里不是业务核心,而是桌面 host。它做这些事:
- 创建窗口、托盘、自动更新和系统通知
- 把前端
invoke()调用转成 Rust 命令 - 把 Rust 事件通过
emit()推给前端 - 提供桌面端实现:Keychain、文件监听、窗口到工作区的映射
SwarmNote 的 src-tauri/src/lib.rs 里可以看到典型入口:注册插件、注册 commands,在 setup 阶段创建 AppCore,再把桌面专属能力注入进去。
rust
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?;
前端看到的仍然是熟悉的 Tauri 调用:
ts
import { invoke } from "@tauri-apps/api/core";
await invoke("apply_ydoc_update", {
docUuid,
update,
});
这里的关键不是 invoke 本身,而是边界:Tauri command 只做参数接收、错误转换和事件转发,真正的业务规则尽量下沉到 swarmnote-core。
移动端:RN 负责"手机体验"
移动端仓库 swarmnote-mobile 是 Expo + React Native。它负责手机上该有的东西:
- Expo Router 文件路由
- NativeWind / RN primitives UI
- 安全区、键盘、手势、系统能力
expo-secure-store、expo-file-system等移动端 host 能力
但移动端没有重写工作区、文档、配对、Yjs 状态机。它通过 react-native-swarmnote-core 这个 workspace 包,把 Rust 暴露成 RN 可以直接调用的 Turbo Module。
在 Rust 侧,移动端 wrapper 很薄。它定义 #[derive(uniffi::Object)] 的对象,把 WorkspaceCore 包起来,然后导出 async 方法:
rust
#[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())
}
}
它和 Tauri command 的关系很像:
| 桌面端 Tauri | 移动端 UniFFI |
|---|---|
#[tauri::command] |
#[uniffi::export] |
invoke("cmd", args) |
直接调用生成的 TS 函数/对象方法 |
app.emit("event") |
callback interface / event adapter |
| JSON IPC | JSI / C++ 绑定 |
| 运行时参数匹配 | 生成 TypeScript 类型 |
这就是这套架构最舒服的地方:开发体验像 Tauri,但移动端运行时更贴近原生。
编辑器:为什么移动端还需要 WebView
这里有一个容易误解的点:移动端用了 React Native,不代表所有东西都必须变成 RN 原生组件。
SwarmNote 的编辑器是 CodeMirror 6。它依赖 DOM、Selection、MutationObserver、CSS 布局等 Web 能力,很适合桌面 Tauri WebView,但不能直接塞进 RN 原生渲染树。为了解决这个问题,编辑器后来被独立成了 swarmnote-editor monorepo,并发布成 npm 包,让桌面端、移动端和未来其他 host 都能复用同一个 Markdown live-preview 内核。
所以移动端采用"两条桥":
- 业务桥:RN -> UniFFI -> Rust core
- 编辑器桥:RN -> WebView -> Comlink -> CodeMirror
@swarmnote/editor-react-native/webview"] EditorCore["@swarmnote/editor-core
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
这看起来多了一层,但换来了非常现实的收益:
- 桌面和移动共享同一套 Markdown 编辑器核心
- CodeMirror 插件、Yjs 绑定、数学公式、图片渲染逻辑可以复用
- RN 只负责移动端外壳和交互,不用重写一个编辑器
- WebView 内部仍是完整 Web 环境,调试和打包路径清晰
移动端这条链路的本质,是加载一个自包含的 editor WebView bundle,再由 RN WebView 承载。早期在 swarmnote-mobile/packages/editor-web 里维护这层入口;现在它已经在 swarmnote-editor 里沉淀为 @swarmnote/editor-react-native/webview 这样的 npm subpath。RN 和 WebView 之间用 Comlink 把 postMessage 包装成"像本地函数一样调用"的 RPC。
顺手把编辑器也产品化
swarmnote-editor 不是 SwarmNote 仓库里的一个私有目录,而是独立发布的编辑器工程。它目前拆成三个公开 npm 包:
| 包 | 用途 |
|---|---|
@swarmnote/editor-core |
CodeMirror 6 内核、Markdown live-preview、Plugin SDK,以及 math / table / mermaid / slash / wikilink 等插件 |
@swarmnote/editor-react |
React host 的薄适配,提供 EditorView 和 I18nProvider |
@swarmnote/editor-react-native |
React Native host 的桥接层,提供 useEditorBridge、Comlink adapter 和 WebView HTML bundle |
这里的拆法也延续了 SwarmNote 的跨端思路:运行时内核走 npm,容易被复用;UI primitives 走 shadcn 风格 registry,方便 host 复制后按自己的产品体验改。
CM6 内核 + Plugin SDK"] ReactPkg["@swarmnote/editor-react
React plumbing"] RNPkg["@swarmnote/editor-react-native
RN bridge + WebView bundle"] end subgraph Registry["shadcn-style registry"] WebUI["Web UI primitives
slash-popover · wikilink-popover · toolbar"] RNUI["RN UI primitives
slash-sheet · heading-sheet · markdown-editor"] end ReactPkg --> Core RNPkg --> Core WebUI -. copy to host .-> ReactPkg RNUI -. copy to host .-> RNPkg
如果只想在自己的 Tauri / Electron / Web 项目里嵌一个 Markdown 编辑器,可以从最小安装开始:
bash
pnpm add @swarmnote/editor-core @swarmnote/editor-react
如果是 React Native / Expo,则是:
bash
pnpm add @swarmnote/editor-core @swarmnote/editor-react-native react-native-webview comlink
这也是我觉得 SwarmNote 架构比较值得写出来的原因:不是只把产品做成跨端,而是把过程中沉淀出来的"可复用零件"也顺手开源、发布、文档化。swarmnote-core 解决本地优先和 P2P 同步,swarmnote-editor 则解决 Markdown 编辑体验复用。
真正的核心:把平台差异变成 trait
跨端最容易失败的地方,是一开始把 Tauri、RN、文件系统、通知、密钥存储混在业务逻辑里。SwarmNote 的做法是让 swarmnote-core 保持平台无关:
桌面端实现这些 trait:
| 能力 | 桌面实现 |
|---|---|
| 密钥 | keyring,对接 macOS Keychain / Windows Credential Manager / Linux Secret Service |
| 事件 | TauriEventBus,内部调用 AppHandle::emit |
| 文件监听 | notify + debouncer |
| 本地文件 | 桌面文件系统 |
移动端则换成另一套实现:
| 能力 | 移动实现 |
|---|---|
| 密钥 | RN 侧 expo-secure-store,Rust 侧通过 callback/adapter 使用 |
| 事件 | UniFFI callback interface,推到 RN store |
| 文件监听 | 移动沙盒内通常不需要桌面式 watcher |
| 本地文件 | App sandbox / Expo FileSystem 路径 |
业务核心不问"我现在是不是 Tauri",只问"谁实现了这个 trait"。这就是跨端复用真正成立的原因。
P2P:SwarmNote 为什么需要 Rust core
SwarmNote 不是普通 Markdown 编辑器。它的产品目标是:
- 笔记保存在本地 Markdown 文件夹
- 多台自己的设备组成一个 swarm
- 不依赖云账号或中心服务器
- 通过 libp2p 做设备发现、连接、配对和消息广播
- 用 Yjs/yrs 处理离线编辑后的合并
这类能力如果分别用 JS、Kotlin、Swift、Rust 写四遍,很快会进入维护地狱。Rust core 的价值在这里变得很明确:
mDNS / DHT / Relay"] Net --> Recv["设备 B 收到 update"] Recv --> YB["yrs apply_update"] YB --> Flush["写回 SQLite + .md 文件"] style Net fill:#e8f4ff,stroke:#208aef
Rust 这层同时持有:
- libp2p 网络运行时
- 设备身份和配对状态
- SQLite 元数据
- Y.Doc 状态读写
- 文档增量同步协议
桌面和移动共享它,意味着同一个 bug 只修一次,同一套同步协议不会因为平台不同而悄悄分叉。
这套架构的文件视角
桌面仓库:
text
swarmnote/
├── src/ # React 桌面前端
├── src-tauri/ # Tauri host:commands / plugins / desktop adapters
├── crates/
│ ├── core/ # swarmnote-core:平台无关业务核心
│ ├── entity/ # SeaORM entities
│ └── migration/ # SQLite migrations
├── libs/core/ # swarm-p2p-core:libp2p 封装
└── dev-notes/blog/ # 技术文章和架构笔记
移动仓库:
text
swarmnote-mobile/
├── src/ # Expo Router / RN screens / stores
├── packages/
│ ├── editor-web/ # 早期 WebView 编辑器入口;可迁移到 @swarmnote/editor-react-native
│ └── swarmnote-core/ # react-native-swarmnote-core
│ ├── rust/mobile-core/ # UniFFI wrapper crate
│ ├── src/generated/ # 生成的 TS 绑定
│ └── cpp/generated/ # 生成的 C++ JSI 绑定
└── plugins/ # Expo config plugins
共享编辑器仓库:
text
swarmnote-editor/
├── packages/editor-core/ # @swarmnote/editor-core
├── packages/editor-react/ # @swarmnote/editor-react
├── packages/editor-react-native/ # @swarmnote/editor-react-native
└── registry/ # shadcn 风格 UI primitives
一次"打开文档"的完整链路
把上面的图合起来,看一条用户操作链路会更直观。
桌面端链路几乎一样,只是 RN -> UniFFI 换成了 React -> Tauri invoke,WebView CodeMirror 就是 Tauri 窗口里的前端编辑器。
它带来的工程收益
第一,业务一致性更强。
配对、同步、文档状态、冲突处理都在 Rust core,同一套测试和同一套状态机覆盖桌面与移动。
第二,平台体验不妥协。
桌面端继续享受 Tauri 的系统集成、托盘、自动更新、小包体;移动端使用 RN/Expo 做导航、手势、键盘、安全区和移动原生能力。
第三,迁移路径自然。
SwarmDrop 先验证 libp2p,再抽出 swarm-p2p-core;SwarmNote 进一步抽出 swarmnote-core;现在 SwarmDrop 也可以沿同样边界迁移。这不是一次性重写,而是把已经跑通的能力逐步"下沉成核心"。
第四,编辑器复用现实可行。
CodeMirror 不硬改成 RN 原生组件,而是让 WebView 做它擅长的事。RN 通过 Comlink 拿到类型化 API,编辑器体验保持一致。
代价和坑
这套架构也不是免费午餐。
| 坑 | 解决方式 |
|---|---|
| 移动端不能用 Expo Go | 必须用 development build,因为有原生 Rust Turbo Module |
| Rust 改动后需要重生成绑定 | pnpm --filter react-native-swarmnote-core ubrn:android 或 ubrn:ios |
| WebView 编辑器可能加载旧 bundle | 改 editor-core 或 editor-react-native/webview 后重建对应 npm 包 / WebView bundle |
| 生成代码很大 | 明确约定 src/generated / cpp/generated 不手改 |
| 平台能力边界容易滑坡 | 新功能先判断:业务规则进 core,平台能力进 host adapter |
| 事件链路更长 | 用统一 AppEvent + Tauri emit / UniFFI callback 做映射 |
一个实用判断标准:
桌面和移动各自实现"] D -->|"否:纯界面交互"| F["桌面放 src/
移动放 swarmnote-mobile/src/"]
如果你也想用这套方案
可以按这个顺序思考,而不是一上来就选框架:
-
先找出真正要共享的核心。
如果只是 UI 相似,不一定需要 Rust core;如果有协议、同步、加密、数据库、复杂状态机,就很适合。
-
把 core 做到"不知道宿主是谁"。
不要在 core 里
use tauri::*,也不要让它依赖 RN 包。平台差异通过 trait 或 wrapper 注入。 -
桌面 host 保持薄。
Tauri command 不要变成业务泥潭,尽量只做参数转换和事件桥接。
-
移动 host 保持移动优先。
RN 负责手机体验,不要为了"和桌面完全一样"牺牲原生交互。
-
编辑器/复杂 Web 组件可以单独走 WebView。
WebView 不一定是失败,它可以是非常明确的边界:只承载最适合 Web 的模块。
SwarmNote 现在是什么状态
SwarmNote 正在做的是一个本地优先、P2P 同步的 Markdown 笔记工具:
- 笔记就是本地
.md文件 - 设备通过 6 位配对码加入自己的 swarm
- libp2p 负责设备间连接
- Yjs/yrs 负责离线编辑后的增量合并
- 桌面端是 Tauri + React
- 移动端是 Expo + React Native
- 两端共享 Rust 核心
如果你对"没有云账号、没有中心服务器、自己的设备直接同步笔记"感兴趣,可以关注: