一天撸一个 Android SSH 客户端:WebSSH 开发全记录

起因

手上有一个 WebSSH 后端(Node.js + Express + ssh2),浏览器端功能齐全:SSH 终端、SFTP 文件管理、服务器配置管理。但用手机浏览器操作体验很差——键盘遮挡终端、没有快捷键、界面不适配移动端。

于是决定:一天之内,做一个 Android 客户端

技术选型

组件 选择 原因
语言 Kotlin Android 主流
UI Jetpack Compose + Material 3 声明式 UI,开发快
网络 Retrofit 2 + OkHttp 成熟稳定
SSH 终端 xterm.js (WebView) 复用后端已有前端,省时间
持久化 DataStore 比 SharedPreferences 更现代

为什么 SSH 终端用 WebView 而不是原生渲染?因为快。 后端已经有完整的 xterm.js 前端,直接在 WebView 里跑,一套代码两端复用。虽然性能不如 Termux 那种原生方案,但够用了。

三阶段开发

Phase 1:核心功能(2 小时)

服务器增删改、SSH 终端、SFTP 文件浏览、文件上传/下载/预览。

第一版很快就跑起来了,但 SSH 终端死活连不上。调试了半天发现:

WebSocket URL 参数名写错了。

后端读的是 ?server=123,Android 发的是 ?serverId=123。一个字母的差异,排查了整整 30 分钟。从此立下规矩:前后端联调必须严格核对参数名。

Phase 2:体验增强(2 小时)

批量文件 ZIP 下载、设置页面(备份/恢复/改密码)、标签筛选。

这个阶段比较顺利,主要是 UI 拼装。但有一个隐藏 Bug:服务器更新接口会把密码覆盖为空字符串。 因为 Android 的 updateServer 没传密码参数,后端收到空字符串就直接保存了。后面导致一批服务器 SSH 连不上。

Phase 3:锦上添花(1 小时)

SSH 密钥认证、文件搜索、权限显示、暗色主题。

踩坑大赏

坑 1:HTTPS + ws:// = 混合内容错误

Android WebView 从 CDN 加载 xterm.js(HTTPS),然后发起 ws:// WebSocket 连接。浏览器安全策略直接拦截,报错:

An insecure WebSocket connection may not be initiated from a page loaded over HTTPS

解决:把 xterm.js 下载到 assets/ 目录,通过 file:///android_asset/ 协议加载。file:// 页面允许 ws://,没有混合内容限制。

坑 2:后端 WebSocket 崩溃

用户 SSH 认证失败时,后端进程直接退出:

Error: All configured authentication methods failed
Emitted 'error' event on Client instance

原因是 Node.js 的 EventEmitter 在没有 error listener 时会抛异常。虽然代码里有 conn.on('error'),但里面的 ws.send() 在 WebSocket 已关闭时又抛了一个异常,这个没被捕获。

解决:用 safeSend() 包装所有 ws.send(),加 ws.on('error') 兜底,加 try/catch 保护 ws.close()

坑 3:手机键盘遮挡终端

这是最头疼的问题,改了 6 个版本 才搞定。

方案 结果
adjustResize + visualViewport 动态调整 终端内容跳动,不稳定
65vh 固定高度 vh 用的是原始视口,键盘弹出后还是被遮挡
ResizeObserver + 动态高度 时序问题,适配延迟
visualViewport.height - toolbar 部分机型不触发 resize

最终方案:固定比例布局。终端 height: 45vh,工具栏 position: fixed; top: 0。键盘弹不弹,终端都是 45% 屏幕高度,不跟键盘斗智斗勇。

教训:移动端 Web 终端的键盘适配是业界难题。 Termius、JuiceSSH 等专业 App 都是原生渲染,Web 方案能用但有天花板。

坑 4:Adaptive Icon 不生效

Android 8+ 使用 Adaptive Icon 系统。如果只提供 mipmap-*/ic_launcher.png,系统会显示默认的绿色安卓机器人。

解决:添加 mipmap-anydpi-v26/ic_launcher.xml,定义前景(自定义图标)+ 背景(深灰色 #1e1e1e)。

坑 5:Compose 图标缺失

想用 Icons.Default.Fingerprint 做指纹登录图标,编译报错:Unresolved reference: Fingerprint

虽然引入了 material-icons-extended,但不是所有 Icons.Default.* 都有。最后用 emoji 🔐 代替。

最终成果

功能清单

  • ✅ SSH 终端(xterm.js + WebSocket + 虚拟快捷键工具栏)
  • ✅ SFTP 文件管理(上传/下载/批量ZIP/预览/搜索/权限)
  • ✅ 服务器管理(增删改/密码+密钥认证/标签筛选)
  • ✅ 指纹生物识别登录
  • ✅ 数据备份/恢复
  • ✅ 暗色主题(Android 12 动态取色)
  • ✅ 自定义应用图标

开发数据

  • 时间:约 11 小时(09:00 - 20:30)
  • 代码量:~2500 行 Kotlin + ~200 行 HTML/JS
  • Git 提交:22 次
  • Bug 修复:10+ 个
  • SSH 终端键盘适配版本:6 个

项目结构

webssh-android/
├── app/src/main/
│   ├── assets/                  # xterm.js 终端
│   ├── java/com/webssh/
│   │   ├── api/                 # Retrofit + OkHttp + DataStore
│   │   ├── ui/screens/          # 7 个 Compose 页面
│   │   └── viewmodel/           # 业务逻辑
│   └── res/                     # 图标 + 字符串
└── webssh-app.apk               # 最终产物

经验总结

  1. 参数名必须对齐。 前后端差一个字母就全完。
  2. WebSocket 错误处理是生死线。 Node.js 进程崩了就全挂。
  3. 移动端键盘适配别跟它斗。 固定比例比动态调整靠谱。
  4. Adaptive Icon 别省配置。 否则用户看到的是绿色机器人。
  5. WebView 做终端是权宜之计。 真要做专业产品,还是得原生渲染。
  6. 一天能做出 MVP。 Compose + 现有后端,效率惊人。

仓库地址


本文由博客助手大龙虾整理。