MENU

用二十天给自己写了个 Xshell

October 27, 2026 • 程序 • 阅读设置

前言

我在 Windows 上用了很多年 Xshell。它好用,但有几件事一直让我不太舒服:商用要付费;界面多年如一日,颇有一种“能用就行”的气质;有些地方用着不顺手,也只能忍着,毕竟不是自己的软件。

所以这个月我自己写了一个,叫 ZShell。10 月 7 日第一个提交,第二天发了 1.0.0,到 10 月 26 日已经是 3.15.0。中间一共打了 84 个版本 tag,平均一天四个,版本号涨得比我的工资快多了。提交将近一千次。macOS、Windows、Linux 都能用,Windows 版还上架了 Microsoft Store。

丑话说在前面:ZShell 基于 Tauri,界面跑在系统的 WebView 里,背着这么个浏览器内核,资源占用比 Xshell 这种原生程序高,这一点赖不掉。不过我能接受,因为换来的是免费、好看,以后想往哪个方向改也由我自己说了算。现在我在 Windows 上用它,比在 Mac 上用得还多。

代码几乎都是 Claude 写的(当然文章也是)。这年头大概不用特意声明这种事,不过它确实影响了这个项目的方方面面,下文会陆续提到。

ZShell 主界面:会话列表、终端和跟着终端走的文件面板

功能

日常用到的基本都有:SSH、Telnet、串口和本地终端;标签放在标题栏里,可以分屏,可以开多个窗口,标签能在窗口之间拖来拖去,连接不会断;会话可以分文件夹、可以搜索,也能从 ~/.ssh/config 导入。下面挑几个和 Xshell 不太一样的地方说。

一条连接。 Xshell 里开终端是一条连接,要传文件得打开 Xftp 再连一次、再认证一次。碰上要二次验证的服务器,还得再掏一次手机,一天掏几十次是会烦的。ZShell 的终端、文件面板和端口转发共用同一条 SSH 连接,复制标签、分屏也是在这条连接上开新的通道,认证一次就够了。

文件面板跟着终端走。 文件面板打开时就在终端当前所在的目录;打开“跟随”以后,终端里 cd 到哪,面板就跟到哪。Linux 服务器上可以直接查到 shell 的工作目录,其他系统只要 shell 会报告当前目录(OSC 7)也行。文件可以拖进拖出,远程文件可以用本地的编辑器打开,保存时自动传回去。

断线能很快发现。 笔记本合盖再打开,或者换了个网络,SSH 连接其实已经断了,终端却还装作连着的样子,敲了半天没反应,等 keepalive 超时才知道对面早就没了。ZShell 会在你打字、浏览文件、转发的连接有数据、系统从睡眠中唤醒的时候主动确认一下,几秒内就能发现断线并自动重连;同时也照顾到了慢速链路和大流量,不会把它们误判成断线。

安全。 会话和保存的密码放在一个加密文件里,密钥存在系统的凭据管理器中,也可以改成主密码。还可以给应用加锁:闲置一段时间、系统锁屏或者手动锁定时,窗口会被盖住,要用 Windows Hello、Touch ID 或账户密码才能解开。离开工位时,同事想借你的终端敲两行命令,是不行的。

其他还有 ZMODEM(rz / sz)、会话日志、同时向多个终端发送命令的撰写栏、快速命令按钮、GBK 等字符编码、连接期间阻止系统睡眠、编程字体的连字等等。USB 转串口拔掉再插上,串口会话也会自己重连。

也有几样是故意不做的:Xshell 的脚本(快速命令和登录后执行的命令够用了)、Xagent(直接用系统的 ssh-agent、Pageant 或 Windows 自带的 OpenSSH agent)、云同步(导出文件扔进自己的同步盘就行)。

分屏:两个窗格开在同一条 SSH 连接上

设计

行为照 OpenSSH 来。 认证顺序、known_hosts 的格式和校验、ssh_config 的语义都按 OpenSSH 的规则,主机密钥直接写进 ~/.ssh/known_hosts,和命令行的 ssh 共用。这样就不会出现“命令行能连、ZShell 连不上”或者反过来的灵异事件。提示的方式也一样:主机指纹、密码、私钥口令、验证码都在终端里问,不弹对话框,和在命令行里用 ssh 时一模一样。

三个平台长得一样。 用 Web 技术写界面,好处之一是三个平台能做成一个样子,前提是别用浏览器的原生控件。原生的下拉框、数字输入框、颜色选择器在每个平台的 WebView 里各长各的,Windows 上的复选框还是 Chromium 那种蓝色,跟整个界面格格不入。所以 ZShell 的这些控件全部自己画,颜色取自主题;还有测试专门盯着,代码里一出现原生控件就失败。图标也一样,全部是 SVG,不用 ×、▾ 这类字符,因为它们在每个平台的字体里大小和位置都不一样,对不齐的时候非常难受。

快捷键不和 shell 抢。 应用的快捷键在按键传给终端之前就被截下来。macOS 上用 ⌘,相安无事;Windows 和 Linux 上,Ctrl+F、Ctrl+E 这些是 shell 要用的,所以凡是会冲突的快捷键都加上 Shift。

读屏和多语言。 VoiceOver、讲述人、NVDA 这些读屏软件可以读终端的内容,系统开着读屏时自动打开这个模式,所有操作都能用键盘完成。界面有英语、简体中文、日语、德语和法语,其中日德法三种还没有母语者校对过,懂行的朋友欢迎来挑错。从第二天起,所有界面文字就都走翻译,后端的错误也用错误码区分,不靠匹配文字。

架构和性能

ZShell 的结构

Tauri 的前端跑在系统自带的 WebView 里(Windows 上是 WebView2,macOS 上是 WKWebView,Linux 上是 WebKitGTK),后端是 Rust 写的原生程序。和 Electron 相比,它不用自带一份 Chromium。

后端用 tokio,SSH 和 SFTP 用 russh。每个会话(SSH、Telnet、串口、本地终端)是后端的一个异步任务。键盘输入经命令调用传进来;输出是原始字节,经 Tauri 的 Channel 直接交给 xterm.js,不转成 JSON。SSH 连接认证以后登记在连接表里,shell、SFTP、端口转发都是这条连接上的通道,最后一个会话结束时才断开。

前后端之间交换的类型、命令名和事件名,都由 Rust 这边生成 TypeScript 定义,两边对不上的时候编译就过不了,不用等到运行时才发现。现在代码大约 7.8 万行,Rust 4 万行,TypeScript 3.3 万行。

性能方面先说短板:WebView 本身就要占一部分内存;每个终端的回滚缓冲也在内存里,宽窗口下每 1 万行大约 25 MB,开十几个窗格就很可观了,所以这个数字现在直接写在了设置里,丑话说在前面。剩下的就是尽量不浪费,下面几项在更新日志里都有记录:

  • 背压。 早期 cat 一个大文件,ZShell 的内存会一路往上涨,直到终端开始丢输出。现在前端按处理完的字节数向后端确认,积压超过 2 MB 后端就暂停读取,降到 512 KB 再继续。
  • WebGL 上下文。 xterm.js 用 WebGL 画得最快,但一个 WebView 大约只给 16 个 WebGL 上下文,终端一多就互相抢,抢输了的退回慢的渲染方式,过一会儿再抢回来,循环往复。现在最多 12 个终端用 WebGL,其余按最近使用的顺序改用 DOM 渲染,获得焦点时再换回来;藏起来的终端立刻把上下文交出来。
  • SFTP 小文件。 一开始是一个文件一个文件地传,在往返延迟 100 ms 的服务器上,复制 1 万个小文件要一个多小时,删掉它们也要一刻钟。现在最多同时传 8 个小文件,目录一层一层地列、一层一层地建,上传时在路上的写请求和 OpenSSH 的 sftp 一样多。从窗口里拖出 500 个文件,以前每个文件要依次向服务器查询两次,第一个字节要等一分多钟才开始传,现在改成一次查完。

后台标签的问题,放到下一节里讲。

我不会 Rust

说来惭愧,我的本职工作是写 C++,做游戏。Rust 我一行都没写过,借用检查器有多折磨人,至今没有切身体会;React、TypeScript 这一套现代前端技术也不会;桌面客户端程序几乎没写过。技术栈是 Claude 推荐的。

有的朋友可能会问了:那你怎么知道它写得对不对?我主要靠两样。

一是十几年工程经验攒下来的直觉。语言不熟,但一个设计会在哪里出问题、一个解释听起来靠不靠谱,这些在哪种语言里都差不多。

二是让不同的模型交叉审查。隔几天我就让模型把整个代码库审查一遍:代码分成几块并行地看,结果写成中文报告,每一条我看过以后决定修不修、放进哪个版本。到现在攒了十几份,有看整体代码的,也有专门看架构和性能的。10 月 9 日那天一口气发了 1.6.2 到 1.6.12 十一个版本,tag 打得跟流水线似的,就是把一份审查报告里的问题按模块分批修掉了。

下面是两件印象比较深的事。

内存一直下不来

10 月中旬,我发现 ZShell 开一堆标签用上一阵子,WebView 的内存就一直下不来。查下来是好几个问题叠在一起:

  1. 切换标签时,旧终端的 WebGL 插件被释放了,但 WebGL 上下文要等垃圾回收来了才真正释放,在那之前内存一直占着。于是补了一行,不用 WebGL 时主动把上下文丢掉。
  2. 不到一个小时又发现,这行补丁根本没生效,修了个寂寞:代码取的是插件创建的第一块画布,而那块是插件画链接下划线用的 2D 画布,向它要 WebGL 上下文只会得到 null。在浏览器里验证了一下,修之前 3 个上下文全都活着,修之后一个不剩。
  3. WebGL 渲染器有一张字形缓存,画过的每个字按每种颜色、每种样式各存一份。英文字母就那么几十个,怎么存都有限;中文可是好几千个常用字,再乘上颜色,缓存就一直涨,没有上限。xterm.js 提供的清空接口只清内容、不还内存,最后的办法是超过约 64 MB 时把渲染器整个推倒重建。
  4. 后台标签的终端一直在重画。后台页面只是用 visibility: hidden 藏起来,还占着视口里的位置,而 xterm.js 靠 IntersectionObserver 判断终端是否可见,这个接口不看 visibility,于是后台终端从来没暂停过。后台跑一个 TUI 程序,20 秒里会生成大约 8.3 万个 DOM 节点,再全部丢给垃圾回收,后台标签比前台还忙。把后台页面移出视口就好了。
  5. 就算暂停了,xterm.js 6.0 每次滚动都会刷新选区,暂停中的终端照样整屏重画。这是 xterm.js 自己的 bug,上游已经有人报告,我们先打补丁绕过去。

这次排查还顺带引出了一次大改:17 日把整个前端按 React Compiler 的规则改写,让它自动做 memo,侧栏不再跟着标签的变化重新渲染,标签栏、文件列表这些只重画变了的那一项。

这些问题我不需要懂 WebGL 也能发现。我只要知道“关掉的标签,内存应该还回来”,然后一直追问下去就行。

连字

Fira Code、JetBrains Mono 这类编程字体会把 ->、!=、=== 画成一个符号,喜欢的人很喜欢,不喜欢的人觉得花里胡哨(所以默认是关的)。终端里做连字比编辑器麻烦:xterm.js 是按格子画字的,得先告诉它哪几个格子是一组,合起来交给浏览器画,最后画成什么样由字体自己的规则(OpenType 的 GSUB 表)决定。

这个功能是 10 月 19 日下午做的:

  • 15:39 写计划。
  • 15:53 第一档“常用序列”:用一张固定的序列表分组,和 VS Code 的默认列表一样。
  • 16:19 第二档“按字体规则”。本来想直接用 xterm.js 官方的连字插件,结果用不了:它在浏览器里通过 queryLocalFonts 读字体,这个接口只有 Windows 的 WebView2 有;它读不了 TTC 字体集;而且每个终端各解析一次,像更纱黑体这种 43 MB 的字体,每个终端要常驻约 190 MB,开十个终端就是快 2 GB。于是改成由后端用各平台的接口(Windows 的 DirectWrite、macOS 的 CoreText、Linux 的 fontconfig)找到终端实际使用的字体文件,去掉字形轮廓,只留需要的表(更纱从 43 MB 瘦身到 0.6 MB),交给前端,用从插件里拿来的解析代码处理,同一个字体的所有终端共用一份。
  • 能用了,但分组经常和实际画出来的对不上:插件的解析代码只实现了 GSUB 的一部分,liga 不认,上下文替换也只认一部分。
  • 17:49 干脆重写。后端自己读 cmap、GDEF、GSUB 三张表,编成紧凑的规则;前端按 HarfBuzz(Chromium 用的排版引擎)的方式套用这些规则。验证的办法是拿 harfbuzzjs 对照:更纱、Cascadia Code、Consolas、Fira Code、JetBrains Mono,再加一个没有 GSUB 的字体,每种 81,104 行,逐个字形比较,全部一致;而用插件的做法,一种字体最多有 37,926 处分错。前端读规则的开销也从 15 到 54 ms、最多 12.5 MB,降到约 1 ms、几百 KB,大量输出时每帧快 2 到 4 倍。

从写计划到重写完成,两个多小时。

顺便说一句,VS Code 的终端用的就是这个官方插件:能读到字体时用插件的解析代码,读不到时退回那张固定的序列表。也就是说,ZShell 的前两档大致就是 VS Code 的水平,而重写之后的第二档,分组和 HarfBuzz 逐字形一致,这一点上我们比 VS Code 做得好。

当然好景不长,三天后又冒出一个问题:GSUB 里的偏移可以反复指向同一块数据,层层套娃,有的字体这张表只有几百 KB,展开以后能变成几十亿个值,一打开终端就把程序撑崩。现在读到 400 万个值就停手,这种字体退回到常用序列。

写代码以外的事

Logo 和官网

Logo 也是 Claude 设计的,我负责点头。10 月 8 日凌晨就提交了,那时 1.0 都还没发。图形是一个 Z:从贝壳的螺旋起笔,代表 shell;以一个方块光标收尾,代表 terminal。底色是沙色,线条是珊瑚色。母版是 SVG,一个脚本生成各个平台要的全部尺寸;macOS 另有一份按苹果的图标网格摆放的版本,Microsoft Store 要的那一大堆不同缩放比例的图,也是这个脚本一并生成的。

ZShell 的 logo

官网 zshell.boyin.org 是 10 月 14 日做的,沿用了同一套配色。源码放在同一个仓库的 site/ 目录里,单独用 Vite 构建,推送到 main 以后由 GitHub Actions 部署到 GitHub Pages。

CI 和发布

每次推送都会在 macOS、Windows x64、Windows ARM64、Ubuntu 22.04 四种机器上各跑一遍构建、单元测试、clippy 和 Rust 的测试。各平台专用的代码(Windows 的 ConPTY 和 Pageant、macOS 的 AppKit 和 CoreText、Linux 的 GTK 和 fontconfig)在别的平台上根本编译不到,只能靠 CI 把关。另外还有两个检查:一个用 cargo audit 和 pnpm audit 查依赖的安全公告,每周一还会定时单独跑一次;一个检查依赖的许可证,因为 ZShell 不是开源软件,依赖里不能混进 GPL 一类的许可证。Dependabot 每周准时来提依赖更新。

发布由一个只能手动触发的 workflow 完成。它在版本 tag 上运行,先确认这个提交上的 CI 已经通过,然后并行打包:macOS 的通用包;Windows 的安装包、MSI 和免安装的 exe;Linux 的 deb 和 rpm(在 Ubuntu 22.04 上编译,glibc 足够老,Debian 12 和 Fedora 都能跑);还有 x64 和 ARM64 合在一起的 Store 包。x64 的 exe 只编译一次,安装包、免安装版和 Store 包里用的是同一个。最后建一个草稿 release,我检查一遍再手动发布。

版本号涨得这么快,是因为每个版本都打 tag,但不一定发布:修了几个 bug 也算一个版本,攒几个再一起发。版本由 scripts/release.sh 的三个命令管理:prepare 改版本号,并用 git-cliff 从提交记录生成更新日志;tag 在本地跑一遍和 CI 一样的检查后打 tag;publish 触发打包。提交信息按 Conventional Commits 写,feat、fix、perf 会进更新日志,其余的不进。

Microsoft Store

GitHub 上下载的安装包没有代码签名,第一次运行时 SmartScreen 会弹出那个“Windows 已保护你的电脑”,每个新用户都要被吓一次。上架 Store 有一个很实际的好处:应用以 MSIX 包上架,由微软用自己的证书重新签名,我不用花钱买证书,安装和更新也都交给 Store 负责。x64 和 ARM64 都有。

ZShell 是以完全信任的桌面应用(runFullTrust)上架的,不进应用沙箱。这是一项受限能力,提交时要写一段小作文说明理由。我还没走到提交那一步,Claude 就已经预料到了,提前写好了范本,大意是:ZShell 要启动本地 PowerShell(ConPTY),要经命名管道和 Pageant 连接 SSH agent,要打开串口,要监听本地端口做端口转发,还要用用户选的编辑器打开远程文件,这些在应用容器里都做不到。同一份文件里还附上了给审核人员看的试用说明:不需要注册账号;手头没有服务器的话,点标签栏上的 + 打开本地终端就能试。

商店页面的介绍文字、年龄分级问卷怎么填、截图的尺寸要求,也都写在仓库里,每次提交照着填就行。还有两处和 GitHub 版不同:Store 版不自己检查更新;Windows 的 S 模式不允许启动命令行程序,所以会把本地终端的入口藏起来。

给上游提 PR

有一阵我发现,ZShell 停在密码提示那里等输入时,会有一个 CPU 核被占满,风扇呼呼地转。追下去是 russh 的问题:客户端在认证完成之前不发 keepalive,但 keepalive 的计时器到点以后也没有重新计时,而 tokio 里到期的 Sleep 会一直处于就绪状态,于是事件循环一圈又一圈地走进这个分支空转,直到用户输入密码,或者服务器等不及了把连接断开(OpenSSH 默认等 120 秒)。等手机上的二次验证推送时也是一样。

Claude 写了修复和测试。测试里把 keepalive 间隔设成 50 ms,让客户端停在认证之前:修复前 500 ms 里会读 socket 大约 40 万次,修复后每个间隔一次。PR 是 10 月 15 日提的,25 日合并(Eugeny/russh#791),russh 发了新版以后,ZShell 在 3.15.0 跟着升级。russh 的 PR 模板要求标明 AI 参与的程度,这个 PR 老老实实勾的是“Fully vibe coded”,诚实是美德。

另一个是 xterm.js 的:切换标签时,终端会空白一帧,不仔细看看不出来,看出来了就再也忘不掉。ZShell 里已经绕过去了,同时向上游报告了问题(xtermjs/xterm.js#6239),也提了 PR(#6240),还在等合并。

写这篇文章时的插曲

这篇文章也是和 Claude 一起写的。到了配图这一步,我让它自己拉起一个测试环境来截图:拿一个临时目录当 HOME,起一个只监听本机的临时 sshd,再用 debug 版的 ZShell 连上去,这样不会碰到我的真实配置。听起来很周到。

结果第一件事就出了岔子。我自己的 ZShell 当时也开着,连着一台真实的服务器。debug 版和正式版的 bundle id 一样,它的后台操作工具分不清两个实例,只好接管整个屏幕来点。截到一半,测试窗口退到了后面,我的窗口到了最前面,它没注意,照着计划点了两下,敲了一行命令再回车,全都进了我那台服务器的终端。好在那行命令是 clear && git log,服务器上那个目录也不是 git 仓库,报了一句 fatal: not a git repository 就完事了,唯一的损失是我的屏幕被清空了。事后它给自己定了条规矩:我的实例开着的时候,每次打字之前先截图,确认最前面是哪个窗口。我则选了更朴素的办法:截图期间把自己的 ZShell 退掉。

第二件事更有意思。我退掉自己的实例以后,它接着截分屏,测试实例却卡死了,点哪儿都没反应。它抓了一份进程采样:主线程卡在调整终端尺寸的命令里等一把锁;另一个线程正在打开新会话,卡在另一把锁上,而这把锁就是它自己拿着的。问题出在 3.14.0 给文件面板加收发指示灯时写的一行 Rust:

self.traffic.browse_channel.lock().unwrap().is_some().then(|| self.traffic.event(connection))

lock() 返回的锁守卫是个临时值,要到整条语句结束才释放,而 then 里调用的 event() 又去锁同一把锁。Rust 标准库的 Mutex 不可重入,于是这个线程自己把自己锁死了。它锁死的时候手里还握着会话管理器的锁,主线程跟在后面排队,整个界面就冻住了。触发条件是:在一条连接上打开过文件面板,然后在这条连接上分屏或者复制标签。这个用法一点也不罕见,它从 3.14.0 起就埋在那里,只是一直没人踩到。

修起来很简单:把加锁单独拆成一条语句,再补一个测试,在旧代码上这个测试会超时失败。修复会进 3.16.0。写一篇介绍文章,顺手修掉一个死锁,这篇文章也算没白写。

顺带一提,它截出来的图太糊,最后的配图还是我自己截的。

还没做的

  • kitty 键盘协议。 在远端跑 Claude Code、Codex 这类程序时,需要它来区分 Shift+Enter 和 Enter。用 AI 写出来的终端,还得等这个功能才能更好地跑 AI。xterm.js 已经实现了,但只在 beta 版里,我打算等正式版。
  • 在终端里显示图片(Sixel、kitty、iTerm2 三种协议)。同样等 xterm.js 的正式版,难点在性能:图片按 RGBA 存在内存里,插件默认每个终端缓存 128 MB,乘上十几个窗格,内存就不用要了。
  • 自定义快捷键。 快捷键已经集中在一张表里,做起来不难,只是暂时没有需要。
  • Store 自动提交。 现在每个版本都要在 Partner Center 手动上传一次。
  • Linux 上 GNOME 以外的桌面。 现在只针对 GNOME + Wayland 做了适配,其他桌面能用,但闲置自动锁定之类的功能还没有。

总结

现在 ZShell 是我日常用的终端。资源占用确实比 Xshell 高一些,但以后有什么不顺手的地方,改就是了,反正写代码的也不是我。

ZShell 可以免费使用,包括在工作中使用。源码公开,但它不是开源软件:可以看、可以自己编译和修改,只是不能再分发。Windows 用户可以直接从 Microsoft Store 安装;macOS、Windows 和 Linux 的安装包在 GitHub Releases 下载;功能介绍可以看官网。有问题或建议,欢迎到 GitHub Issues 提。

Archives Tip
QR Code for this page
Tipping QR Code