技术方案拆解 · 硬核但寓教于乐

Paseo 是怎么做到
「一套代码,通吃六端」的?

iOS · Android · macOS · Windows · Linux · 浏览器 —— 看起来完全一样,又能灵活适配,还改一处多端生效。这份文档把它讲透。

实证来源:本地 fork /Users/park0er/coding/paseo-contrib/paseo

0 · 一句话结论

能「一套代码多端通吃」,靠的不是一个魔法,而是三根支柱叠在一起:

  1. 一份 UI,三处渲染 —— React Native + react-native-web,同一套界面描述能渲染成 iOS 原生、Android 原生、或网页。
  2. 桌面不是重写,是套壳 —— Mac/Win/Linux 桌面 App 本质是 Electron「浏览器外壳」,里面装的就是那份网页版 UI,所以天生长得一样。
  3. 大脑和四肢分离 —— 业务逻辑全在后台 daemon 里,各端只是通过同一套 WebSocket 协议连过去的「遥控器」。

前两条解决「看起来一样」,第三条解决「改一处、多端生效」。

1先破除误解:「端」到底是什么

传统认知:iOS 一份 Swift、Android 一份 Kotlin、Mac 一份、网页又一份 —— 四份代码,改一个功能改四遍。Paseo 把「端」拆成了两块:

关键洞察:壳很薄,芯很厚。你 99% 的工作都在写只有一份的「芯」。代码库结构直接印证了这个设计 —— 这是一个 monorepo:

paseo/packages/
├── app/       ← 【芯·UI】Expo/RN 界面,iOS/Android/Web 共用
├── desktop/   ← 【壳】Electron 外壳,装的是 app 的 web 版
├── server/    ← 【大脑】daemon 守护进程,业务逻辑在这
├── protocol/  ← 【契约】前后端通信的共同语言
├── client/    ← 【SDK】连 daemon 的客户端库
├── cli/       ← 命令行遥控器
└── relay/     ← 可选加密中继(远程穿透用)
术语补给monorepo:多个项目放一个 Git 仓库,用 npm workspaces 让它们互相引用本地代码。改一个共享包,所有用它的地方立刻拿到新版。

2第一根支柱:一份 UI,三处渲染

支柱一

2.1 从 React 说起:声明式 UI

传统写界面是「命令式」:手动创建元素、手动同步数据。React 换成「声明式」—— 你只写「界面 = 当前数据的函数」,数据变了 React 自己算出哪里要改。

function Greeting({ name }) {
  return <Text>你好,{name}</Text>;   // 界面 = 数据的函数
}

2.2 React Native:同一份描述 → 原生控件

普通 React 渲染成网页 HTML。React Native 的天才在于:把你写的 <View><Text> 在 iOS 上翻译成苹果原生控件、在 Android 上翻译成安卓原生控件。你写的不是网页,是「界面意图」,由 RN 翻译成真正的原生 UI。

术语补给Expo:架在 React Native 之上的豪华全家桶脚手架,搞定构建、原生模块、OTA 更新等脏活。packages/app 就是一个 Expo 项目。

2.3 react-native-web:同一份描述 → 网页

这个库把 RN 的 <View>/<Text> 反向翻译回浏览器的 <div>/<span> + CSS。于是同一段组件有了三个归宿:

       你写的一份组件 <View><Text>你好</Text></View>
                          │
        ┌─────────────────┼─────────────────┐
        ▼                 ▼                 ▼
   iOS 原生 UIView   Android 原生 View   浏览器 <div>
   (React Native)    (React Native)   (react-native-web)

这就是「iOS、Android、桌面页面感觉完全一样」的第一层原因:它们本来就从同一份组件树生成,只是最后一步翻译目标不同。

3「看起来一样」又能「灵活适配」——差异怎么优雅处理

三端不可能 100% 相同(摄像头、终端渲染都不同)。难点是:处理差异又不写成一堆 if(平台==='ios') 的意大利面。Paseo 用两个干净机制:

3.1 机制一:平台后缀文件(Metro 编译期自动选)

打包器 Metro 遇到 import './terminal-emulator',按当前构建平台自动挑对应后缀:

构建平台优先选中的文件
iOSterminal-emulator.ios.tsx
Androidterminal-emulator.android.tsx
Webterminal-emulator.web.tsx
iOS + Android 共用terminal-emulator.native.tsx
都没有 → 通用兜底terminal-emulator.tsx

调用方完全无感,只写 import './terminal-emulator',永远拿到当前平台正确的实现。代码库里实际扫到 31 个这样的分叉文件,例如:

browser-pane.web.tsx        ← 网页版内嵌浏览器
browser-pane.electron.tsx   ← 桌面版内嵌浏览器(Electron webview)
markdown-text.ios/android/web.tsx  ← 三端各自的 Markdown 渲染
voice/audio-engine.native.ts / .web.ts  ← 原生 vs Web Audio

这就是「灵活适配」的核心:99% 共享,真正因平台而异的 1% 拆成 .平台 小文件,编译期自动挑。没有运行时判断,每个平台产物只含它自己那份。

3.2 机制二:Electron 专属覆盖层(桌面版的隐藏开关)

.electron.tsx 是 Paseo 自己发明的后缀。因为桌面版本质是「网页版」(平台=web),但又多了原生能力。怎么区分「普通浏览器」和「Electron 桌面」?答案在 metro.config.cjs:

const customWebPlatform = process.env.PASEO_WEB_PLATFORM; // "electron"
// web 构建 + electron 时:先找 .electron 版,没有就退回普通 web 版
if (platform === "web" && customWebPlatform === "electron") {
  try { return resolve(..., ["electron.tsx", "electron.ts"]); }
  catch { /* 退回普通 web */ }
}

桌面构建脚本正是这么触发:PASEO_WEB_PLATFORM=electron npx expo export --platform web。一个环境变量,切换整层行为 —— 桌面版 = 网页版 + 一层薄薄的原生增强。

4第二根支柱:桌面 App 是「装了网页的浏览器壳」

术语补给Electron:把 Chromium 浏览器内核 + Node.js 打包成桌面应用的框架(VS Code、Slack、Discord 都是它)。可理解为「一个只显示指定网页、去掉地址栏的专用浏览器,后台还能跑 Node 访问操作系统」。
支柱二

4.1 桌面版加载的,就是 app 的网页导出

第一步 —— 把 app 导出成静态网页,产物 app/dist 塞进桌面安装包(electron-builder.yml):

extraResources:
  - from: ../app/dist    # app 的网页导出
    to: app-dist         # 打进桌面 App

第二步 —— Electron 主进程注册自定义协议,让窗口加载它(main.ts):

protocol.handle("paseo", (request) => {
  // 映射到资源里的 app-dist;找不到文件就回退 index.html(SPA 路由)
  return net.fetch(pathToFileURL(path.join(appDistDir, ...)));
});
await mainWindow.loadURL("paseo://app/");  // ← 窗口里装的就是 app 网页版!

结论坐实:桌面 App 窗口里跑的,和浏览器 web 版是字节级相同的同一份网页产物。它们不是「长得像」,是「本来就是同一个东西」,一个套在 Electron 窗口、一个套在 Chrome 标签。Mac/Win/Linux 三桌面又共用这同一份 app-dist,区别只在外壳的打包格式。

4.2 桌面壳额外负责什么(不管 UI,只管操作系统权限)

5样式为何三端统一又自适应?——Unistyles

react-native-unistyles v3,解决两件事:

  1. 主题一致:引用 theme.colors.surface0 这种语义变量而非写死颜色,切深浅色时全端自动换肤。
  2. 性能:用 Babel 插件(编译期改写)把样式挂到原生「影子样式表」,主题变化时直接更新原生视图、绕过 React 重渲染
const styles = StyleSheet.create((theme) => ({
  container: { backgroundColor: theme.colors.surface0 }, // 换肤不触发重渲染
}));

团队甚至立铁律禁用 useUnistyles() Hook(它会导致全界面重渲染)。这种性能纪律侧面说明:跨端共享 UI 要跑得顺,样式架构选择很关键。

6导航:文件即路由,三端同一套 URL(Expo Router)

目录结构直接就是页面结构:

src/app/h/[serverId]/workspace/[workspaceId]/index.tsx
   → URL:  /h/某主机/workspace/某工作区

同一套 URL 路由在手机(原生导航栈)、桌面(Electron)、网页(真 URL)上都成立。一份路由定义,三端通吃。

7第三根支柱(最深一层):大脑与四肢分离

支柱三

你观察到的「改一处、多端生效」,更根本的原因是:Paseo 绝大部分聪明逻辑压根不在客户端。

┌─────────┐  ┌─────────┐  ┌─────────┐
│ 手机 App │  │   CLI   │  │ 桌面 App │
└────┬────┘  └────┬────┘  └────┬────┘
     │  WebSocket │  WebSocket │
     └────────────┼────────────┘
                  ▼
           ┌─────────────┐
           │   Daemon    │ ← 大脑:所有业务逻辑在这
           │  (Node.js)  │
           └──────┬──────┘
      Claude · Codex · Copilot · OpenCode · Pi  ← 各 AI agent

7.1 daemon 是唯一真源

daemon(packages/server)启动/管理 AI agent、流式输出、管 git、跑终端。三个客户端全是遥控器,自己不含业务逻辑。改一个业务功能(如 agent 状态机),只改 daemon,三个客户端一行不动就同时变聪明 —— 因为它们本就只是显示 daemon 的状态。

7.2 protocol 包:前后端的契约

WebSocket 消息格式、二进制帧编码全在 packages/protocol 定义一次,server 和三个客户端都依赖它。改一个字段,TypeScript 在所有端同时报错,逼你全改对 —— 用类型系统把「多端一致性」变成编译期硬约束。协议还规定只能追加不能删字段,让新老客户端和同一 daemon 共存。

7.3 client 包:连接逻辑也只写一次

「怎么连、断线重连、发 RPC」封装在 packages/client,App 和 CLI 共用。连接层也是共享的一份。

8把「改一次处处生效」讲透:共享代码的层次

平台外壳(每端不同,很薄):iOS · Android · Electron(Mac/Win/Linux)· 浏览器
薄壳
平台分叉 .ios/.android/.web/.electron(约 1%,自动选)
改这里 → 只影响对应平台
共享 UI:packages/app 组件/屏幕/样式(约 99%)
改这里 → 所有端一起变
共享连接层:packages/client
改这里 → 所有端一起变
共享契约:packages/protocol
前后端编译期强制一致
共享大脑:packages/server(daemon)
改这里 → 所有端行为一起变

结论:日常改功能 90% 落在「共享 UI」「共享大脑」两层,天然多端生效。只有真正的平台差异才落到最上面那薄薄一层分叉文件。这套分层就是「既统一又能适配」的全部秘密。

9发布:一次代码,多条管线出包,还能各自灰度

平台打包工具更新机制
iOS / AndroidEAS Build(云构建)→ App Store / PlayEAS Update(OTA 热更新,纯 JS 改动不发商店直接推)
macOSelectron-builder → dmg + zip(公证)electron-updater(源:GitHub Releases)+ 分阶段灰度
Windowselectron-builder → nsis + zip(x64/arm64)同上,含 beta 通道
Linuxelectron-builder → AppImage/deb/rpm/tar.gz同上
浏览器expo export → Cloudflare Pages部署即更新

monorepo 用 npm workspaces + sync-workspace-versions 让所有包版本同步,发布脚本一条龙:检查 → 升版本 → 发 npm → 打 tag。docs/release.md 里还有完整的分阶段放量(先放一小撮、逐步扩大、可放慢可回滚)。

10全景图 + 一句话总结

              packages/app(一份 UI 代码,RN + RN-Web)
                          │
        ┌─────────────────┼──────────────────┐
   expo run           expo export           expo run
   (原生编译)       --platform web          (原生编译)
        ▼                 ▼                    ▼
  iOS / Android     app/dist(网页)       (同左,原生)
                    ┌────┴────┐
                    ▼         ▼
              浏览器直接跑   Electron 套壳
               (web 版)    Mac/Win/Linux

  所有客户端 ── 同一套 WebSocket 协议(protocol)──▶ daemon(大脑)

一句话

Paseo 用「RN + RN-Web 让一份 UI 渲染到原生和网页」+「Electron 把网页版套成桌面 App」+「业务逻辑全下沉到共享 daemon」这三招,把「四份代码四个平台」压缩成「一份芯 + 几层薄壳」。看起来一样是因为本来就是同一份;能灵活适配是因为差异被隔离进 .平台 分叉文件和薄原生壳;改一处多端生效是因为逻辑都在共享层。

11硬核收尾:天下没有免费的午餐

不讲代价是不诚实的。从代码库文档能看到团队实实在在踩过的坑:

  1. 样式层暗礁:被迫立「禁用某些 Hook」的铁律,遇过 web 端 CSS 规则泄漏(> * 子选择器污染)、滚动容器主题不更新等诡异 bug。抽象越厚,漏抽象时越烧脑。
  2. 路由树脆弱:Expo Router 嵌套层级错了会静默白屏、不报错,团队专门写文档警告。
  3. 平台分叉维护:31 个 .平台 文件意味着某些功能要为多平台各写一遍,保持一致需要纪律。
  4. 协议只能追加:为让新老客户端共存,永远不能删字段,长期积累历史包袱。
  5. 桌面特性未完整:多窗口状态持久化、内嵌浏览器窗口隔离都还是 v1、有已知限制。
  6. Electron 体积:每个桌面 App 自带整个 Chromium,安装包偏大 —— 所有 Electron 应用的通病。

这些代价换来的,是几人小团队能同时维护 6 个平台、改一次多端生效。对 Paseo 这种产品这笔交易显然划算 —— 但它是有意识的工程权衡,不是魔法

附:想自己顺着代码验证