Lirafy博客Wendo ↗
简体中文

博客

无后端的手机应用:用 SSH 和文件传递 Claude Code 的通知与审批

Wendo 是一个无后端的手机应用:你的电脑和你的手机之间没有我们的服务器。Claude Code 请求权限或完成任务时,你机器上的一个 shell 脚本把事件记到一个文件里,应用再通过 SSH 读取它,用的就是打开终端时那把密钥。审批的回复走同一条路回去,也是一个文件。

如果你想要的是配置方法,请看这篇指南:在手机上收到 Claude Code 通知。

为什么做一个没有后端的手机应用

Wendo 是 SSH 客户端:要让它工作,你的手机本来就得能连到你的电脑并完成认证。既然这条加密通道已经打开了,再在中间放一台服务器专门转发通知,就是多余的部件。

去掉它带来了三个我们想要的结果:

  • 你的代理在做什么(它想运行的命令、要改的文件、最后一条消息)不经过我们的任何机器。它一直留在你的电脑上,直到你的手机来取。
  • 没有账号。没有要保存的邮箱,也没有会泄露的密码。身份就是你的 SSH 密钥。
  • 不需要为每个新用户付钱或维护基础设施。

代价是,服务器本该做的事(保存历史、在应用关闭时提醒、配对设备、校验购买)都得用两端已有的东西来解决。

契约:每个事件一行 JSON

一切的中心是 ~/.wendo/events.jsonl。安装脚本把 wendo-hook.sh 注册到 Claude Code 的六个事件上(PermissionRequest、Notification、Stop、UserPromptSubmit、SessionStart 和 SessionEnd),每次触发时,hook 从标准输入读取 Claude Code 传来的 JSON,并在文件末尾追加一行。一条权限请求大致长这样(有删减):

{"id":"1793530000-4242-12345","time":1793530000,"host":"portatil","agent":"claude",
 "event":"PermissionRequest","category":"approval_required","kind":"permission",
 "title":"Claude Code — wendo","message":"Bash: flutter test","sessionId":"…",
 "toolName":"Bash","pendingActionId":"p1793530000-4242-6789",
 "expiresAt":1793530090000,"options":["Approve","Deny"],
 "mux":{"kind":"tmux","session":"dev","window":"1","pane":"%3","socket":"/tmp/tmux-501/default"}}

这个文件只在末尾增长。没有人修改已有的行:一次已处理的审批会记成一条新的 resolution 类型的行。这让并发简单了很多,因为多个 hook 可以同时写,短行的追加是原子的。文件超过 1000 行时,hook 只保留最后 500 行(如果有 flock 就用它,保证同一时间只有一个在轮转)。目录用 umask 077 创建,文件权限为 600:只有你的用户能读。

mux 字段记录代理运行在 tmux、zellij 或 herdr 的哪个面板里,这样应用就能带你直接过去。

应用怎么读:通过 SSH 轮询

应用不接收任何推送:它主动去问。每次读取时,它会向每台开启了收件箱的主机建立一个 SSH 连接,在不分配终端的情况下运行一条非常简单的命令:

tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true

读取频率取决于收件箱的状态。只要有一行在等你回复,就每 5 秒读一次,因为 hook 很快就会超时。有代理在工作时,每 15 秒一次。没有任何活动时,只在进入这个标签页时读取。你回复之后,它会在 1.2 秒和 3 秒时各再读一次,看看处理结果。

拿到这些行之后,inbox.dart 按时间排序,再折叠成一行行条目:每台主机、每个会话一行。换作别的应用会放在服务器上的逻辑,几乎都在这里。比如,如果一个请求之后来了 Stop 或 UserPromptSubmit,中间却没有处理结果,它就判断这个请求已经在终端里回答过了;又比如,因压缩上下文而产生的 SessionStart 不改变任何东西。

因为文件只能追加,在应用里删除就等于记住删了什么。Wendo 在手机本地保存“墓碑”,按条目或按主机记录。如果代理又在那个会话里写了东西,条目会重新出现。如果某台主机没有响应,应用会保留上次从它那里读到的内容,而不是把列表清空。

Android 上应用关闭时

Android 上有一个 WorkManager 任务,最多每 15 分钟唤醒一次(系统不允许设得更短,还可能为了省电推迟它),它像打开的应用一样读取收件箱,并为新内容发出本地通知。这次读取从不要求生物识别:如果你的 SSH 密钥有保护且处于锁定状态,那台主机就读取失败,仅此而已。已经提醒过的内容存在手机存储里,这样打开的应用和后台任务不会重复提醒同一件事。iOS 上没有这个任务。

一次审批的来回

PermissionRequest 是唯一一个 hook 会停下来等待的事件。完整流程如下:

  1. hook 生成一个 pendingActionId(字母、数字、_ 和 -;应用在把它放进路径之前也用同样的规则校验),计算 expiresAt,写入带有 options: ["Approve","Deny"] 的那一行。
  2. 应用看到一行带有 pendingActionId、没有处理结果也没有过期的条目,就显示按钮。
  3. 你按下其中一个时,应用把 {"decision":"approve"} 写到 ~/.wendo/answers/<id>.tmp,再重命名为 ~/.wendo/answers/<id>。重命名能防止 hook 读到写了一半的回复。
  4. hook 每半秒检查一次这个文件。找到之后,它按 Claude Code 期望的格式输出决定,并追加它的处理结果行:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}

微妙的地方在于你没回复时会怎样。等待默认持续 90 秒(WENDO_APPROVAL_TIMEOUT)。超时后 hook 什么也不输出,Claude Code 收不到 hook 的回复,就在终端里照常提问。hook 记下一条 timeout 处理结果然后退出。如果你先在终端里回答了,Claude Code 会取消 hook,由一个 trap 把处理结果记为 local。

设计原则是:手机出任何问题,都不能让代理卡住。没电、没信号或应用关着,最坏的情况也只是老样子:在终端里提问。

还剩一个缝隙:hook 刚放弃的那一刻,你在手机上回复了。为了补上它,留下回复之后,同一条 SSH 命令会最多再等 3 秒,看有没有这个 id 的处理结果行。如果结果是 timeout,或者没有人取走那个文件,它就把文件删掉,应用会告诉你“已过期 — 主机将回退到本地”,而不是当作已发送。

Wendo 收件箱:一条待批准的 Claude Code 请求(my-app 里的 npm test),以及拒绝和批准按钮 一次批准的流程:Claude Code 请求权限,hook 把它记进 events.jsonl,Wendo 通过 SSH 读取,你在手机上回复,hook 把 allow 或 deny 交还给 Claude Code

没有自己的服务器也能发推送通知:ntfy,而且是可选的

要在 iOS 上应用关闭时提醒你,或者比 Android 的 15 分钟更早提醒你,就需要有人把通知推过来,这就离不开推送服务。我们选了 ntfy,因为一个 HTTP 请求就能发布,而且可以自己托管。

如果你设置了 NTFY_TOPIC,hook 会发布权限请求、通知和任务完成这几类提醒(不包括会话开始和结束,也不包括每次提交提示词)。提醒里带有一个 wendo://attach?event=…&host=…&sid=… 链接,指向多路复用器的那个面板,点一下应用就会打开那个确切的位置。

应用也会读取这个 topic(/json?poll=1&since=24h),作为收件箱的另一个来源,但以 SSH 读到的为准。ntfy 只知道有件事需要你,不知道它是否已经处理,所以如果某台主机的 SSH 读取失败,来自 ntfy 的该主机权限提醒就不算数。隐私上的代价也在这里:在公共服务器上,topic 就相当于密码,而提醒里包含项目名和命令的片段。所以 topic 必须又长又随机,也正因为如此才有 NTFY_URL,让你用自己的服务器。

不用服务器,用二维码把手机和电脑配对

没有账号,手机怎么添加一台电脑?靠 wendo-pair.sh 生成的二维码,里面是一个 URL:

wendo://pair?h=<host>&p=<puerto>&u=<usuario>&n=<nombre>&hk=<huella>

hk 是 ed25519 主机密钥的 SHA256 指纹,用 ssh-keyscan 对那个端口上的 sshd 读出来。应用用严格的模式校验它(SHA256: 加 43 个 base64 字符),并把它固定给这台主机。第一次连接会被验证,而不是盲目接受;如果哪天服务器出示了另一把密钥,连接就会被拒绝。不带指纹的二维码依然可用,按首次连接信任处理;指纹格式错误的二维码则不行。最后一步是用应用显示的命令,把手机的公钥装进 authorized_keys。这一切都不会离开你的网络。

没有许可证服务器的 Pro

没有后端还要卖订阅,这是最别扭的部分。Wendo Pro 通过 Google Play 和 App Store 的结算收费,校验放在原生模块里,而不是 Dart 里。在 Android 上(ProLicense.kt),每笔购买都附带它的 JSON 和一个用应用许可密钥做的 RSA 签名,用公钥来验证。它还会检查应用有没有被重新签名,状态用 Keystore 里的一把密钥封存保存,所以改一个文件拿不到 Pro。每次和商店完整同步都会续期;没有同步时,订阅的 Pro 能撑 7 天,终身购买能撑 30 天,把时钟往回调也延长不了。在 iOS 上,原生层用 StoreKit 2 校验。免费版的 5 次试用审批也由原生层计数。

代码本身写明了它能做到哪一步:有 root 和 Frida,或者给应用打补丁,就能绕过全部校验,因为没有一台服务器去问 Google,就没办法阻止。目标要朴素得多:不能让一个一键工具就搞定。

无后端应用有哪些真实限制

  • 手机必须能连到你的电脑。在同一个 Wi-Fi 下,本地 IP 就够了;在外面,你需要 Tailscale 之类的东西。电脑关机或休眠时,应用就没有可以询问的对象。
  • 通知的延迟取决于轮询:应用打开时是几秒钟(有东西等回复时 5 秒,只有代理在工作时 15 秒),Android 后台最长 15 分钟甚至更久,iOS 上应用关闭时只有 ntfy 才能送达。
  • 每次读取都是一个 SSH 连接。有多台主机时,在有东西等待的期间,每隔几秒就是好几个连接。
  • 在一部手机上删除的内容,只有那部手机记得。
  • hook 需要电脑上装有 jq,目前也只注册到 Claude Code。
  • 公共 ntfy 能看到你发布到 topic 的内容。如果你介意,就自己托管一个。

想看这些部件在应用里怎么配合,Wendo 的页面上有其余部分(终端、tmux 工作区、聊天视图),hook 的变量说明在通知与审批文档里。这一切所依赖的 SSH,我们在在 Flutter 里实现 SSH、Mosh 和 Eternal Terminal里讲过;另一个不靠服务器的部分是听写,见在手机上离线运行 Whisper。Wendo 还没有上架应用商店。