2
4
6.1K
摘要
Vela 是一款用 NSH 构建的 macOS + iOS 应用:通过 TSX + Rust 实现原生界面,以 Go Core 和 tsnet 接入 Tailscale,让用户在 iPhone 上访问 Mac 中运行的本地开发服务。本文介绍其跨端架构、网络转发、状态持久化与 iOS 生命周期设计。
01 项目介绍
Vela 要解决的问题很具体:开发服务跑在 Mac 上,OpenCode、DSH、各种 localhost,人却经常不在电脑前。它让同一 Tailnet 里的设备直接访问这些服务:桌面端负责共享、转发和诊断,iPhone 只读浏览。
两端共用一套控制协议、一份 Core 实现和一层纯函数投影。它不装 TUN,不请求 root,也不写系统代理或路由;Core 只连接白名单内的 loopback 服务。iPhone 端不共享本机服务,只在应用内 WebView 打开远端页面。
02 技术选型
界面用 NSH 0.2.0。它是 Rust first 的原生 UI 框架:macOS 落到 AppKit,iOS 落到 UIKit,TSX 负责快速写界面。选它有三个原因:一套业务逻辑、两份交互密度;Rust 宿主能直接注册原生控件、C ABI 和 WebView;一条 nsh 工具链产出 bundle.nshc,塞进签名 App 就能运行。
宿主是 Rust 1.98;核心是 Go 1.27 加嵌入式 tsnet;两端协议是 JSON Lines v1,iOS 额外走 C ABI。
03 整体架构
分三层:TSX 界面 → Rust 宿主 → Go Core。
Core 是唯一的状态与网络实现,内部再分 internal/core 与 internal/app:前者负责状态、映射、认证和诊断;后者是 macOS 专属编排,iOS 构建不链接。再往下是 tsnet 节点和 Tailnet。
依赖方向严格单向:macos 和 ios 可以依赖 shared 与 core,反过来不行。
04 两种运行模型
macOS 是多进程:Core 打包成 Vela.app/Contents/Helpers/vela-core,父子进程用 stdin/stdout 交换 JSON Lines,父进程负责启动、监督和退出。
iOS 不能 fork,就把同一份 Core 编成 c-archive,Rust 宿主通过 C ABI 调 start、request、drain_events、suspend 和 resume。一份实现,两种进程模型。
05 一次请求怎么走
打开远端服务时,TSX 发 mapping.open,宿主转给 Core;Core 通过 tsnet dial 远端 Mac,建一个 loopback 监听并返回 URL。桌面端把 URL 交给系统浏览器,iOS 进应用内 WKWebView。
每个「设备 + 服务」一个监听,引用计数归零才关闭;端口会记住上次的选择。控制协议是 JSON Lines v1:请求和响应按 id 关联,events.subscribe 推增量,单条消息上限 1 MiB,错误带稳定 code 和 retryable。
06 数据连续性
多端最容易忽略的是 Cookie 和 localStorage。macOS 的转发地址不是裸 127.0.0.1,而是每个「服务 + 设备」一个 <服务>-<设备>.localhost。.localhost 按 RFC 6761 解析到 loopback,不用改 hosts,也仍然是 secure context;Cookie 按 host 隔离、忽略端口,两台 Mac 上的同名服务不会互相覆盖登录态。端口写进 config.json 的 mappingPorts,下次优先复用,origin 不变,localStorage 才留得住。
iOS 每个「设备 + 服务」一个 WKWebsiteDataStore,标识由固定 UUID 派生;进入后台关闭所有 loopback 监听,回到前台按原顺序重开映射并复用端口。origin 稳定,Cookie 和 localStorage 都不会丢。
07 认证与生命周期
macOS 把 Tailscale 登录页交给系统浏览器;iOS 用 ASWebAuthenticationSession 在应用内完成,避免跳 Safari 后进程挂起、登录轮询中断。
iOS 后台不提供访问是设计约束:suspend 关闭监听,resume 重建映射;如果个别端口被占用导致重开失败,宿主会在回到 active 后重新加载当前页面。
08 iOS 的 WebView 与键盘
WebView 的底边不能低于 UIKeyboardLayoutGuide.topAnchor:常态底部约束优先级 999,键盘约束 required。不要监听键盘通知手动改 frame,也不要写 contentInset、obscuredContentInsets 或 contentOffset。
收起键盘后交给约束系统恢复。用 DOM transform 或 visualViewport 做位移补偿,在 iOS 26 上会出现整页上移、状态栏遮挡或残留空白。
09 原生控件不硬造
NSH 没有的控件,用原生视图补。桌面端的按钮和开关就是真正的 NSButton 和 NSSwitch:Rust 侧用 view.register_component 注册工厂,TSX 侧用 nativeComponent 声明 props 和事件。iOS 的 WKWebView 也走同一机制。
缺控件时注册原生组件,而不是用基础 View/Text 拼一个看起来像的控件。
10 构建流程
macOS:nsh build + cargo + go build,产出 Vela.app 并签名。
iOS:Go 编成 c-archive → xcframework,再由 Xcode 链接 Swift 壳、Rust staticlib 和 bundle.nshc。
免费个人签名 7 天过期,到期覆盖安装即可;不要删除 App,否则容器里的 tsnet 身份和 WebView Cookie 会一起丢失。
11 经验与边界
Core 是 config.json 的唯一写入者,数据只落在 Application Support/vela/;日志和复制出来的诊断都做脱敏,不包含认证 URL、密钥、Cookie 或页面内容。
Vela 和 vela-core 都以当前用户运行,不安装 LaunchDaemon、Network Extension 或 TUN,也不改系统 DNS、路由和代理。
12 参考
完整使用说明与架构文档:
https://docs.implements.io/docs/building-vela-with-nsh
NSH: https://nsh.dev
GitHub: https://github.com/thonatos/project-v/tree/main/packages/apps/vela

