粥粥
学了个习

手机上的终端:ANSI 屏幕、宽字符,和那条不该抖的 PTY

阅读 6 约 5 分钟 暂无评论

App 里那张「SSH 终端」卡是我做得最上瘾的一页:在手机上跑 vim、top、htop,全屏程序正常刷新,汉字占满两列,切到后台会话也不掉。

这篇讲四个坑:为什么必须自己画一块终端、宽字符、软键盘把 PTY 抖出来的那一串尺寸、以及帮助卡片上那句当时是假话的话。

它是交互式 shell,不是命令回显框

最省事的做法是:发一行命令,把 stdout 当文本追加到一个 Text 上。这条路对 ls、cat、git log 好用,对 vim / top / htop / less 完全不行——它们要的是一块二维网格,靠 ANSI/VT 序列在网格上原地改写。

所以屏幕是一块自己画的终端画布(TerminalSurface),上面跑一份自己写的 ANSI/VT 解析器(TerminalScreen):

  • xterm 256 色(38;2;r;g;b 的真彩降到 256 色立方,手机屏幕上看不出差别)
  • 粗体 / 斜体 / 下划线 / 反显
  • ?1049 / ?1047 / ?47 备用屏切换(全屏程序退出后原来的输出还在)
  • 滚出屏幕的行留在 2000 行回看缓冲里,手指往上拖能翻,翻上去右下角出现「↑ 回到最新」
  • resize 要能反向上报给 PTY(不然 vim 认的是初次那几行几列)

协议层用的是 sshj:密码 / 私钥 / 主机密钥固定(TOFU)那一套。私钥支持 OpenSSH 新格式、PEM/PKCS8 和 PuTTY 的 PPK,带口令的要填口令;私钥得落一次盘给 sshj 读,那是 cacheDir 里 0600 的临时文件,读完立刻删。

汉字必须占两格,于是整块画布得严格等宽

vim 之类的程序按「一行 N 个字符」排版,而一个汉字的显示宽度是两个字符。如果按 Compose 正常的文本流去排,中文行会把后面所有列往右推半格,光标就飘到别的列上去了。

所以整块画布是逐格绘制的:每个单元格一个固定宽度,宽字符(CJK、emoji)占满两列并把右边那格标记为不可写。这件事交给 Compose 的排版做不到——这不是「不好看」,是坐标会错。

软键盘一开一合,vim 抖给你看

手机屏幕上最烦人的其实是键盘。软键盘的可见性一变,画布高度就变,于是行列数一路狂抖:41 → 42 → 39 → 28 → 25 → 24。每个中间值都往 PTY 报一次窗口尺寸,vim 就跟着重排六次,看着像坏了。

解法不是「少报一点」,而是等尺寸稳住 220ms 再报给 PTY:键盘开合整个过程只发一次最终窗口尺寸。

另一件事:量的坐标不能信整屏截图。这条按键条的位置是跟着键盘搬家的,adb shell input text 也不会把 %20 解码成空格(要发空格得走按键事件),这三个假象让我一度以为是终端在吞字符。

手机键盘上没有的键摆在屏幕底下那条按键条上:Esc、^C、^Z、^D、Tab、⌫、↵ 和四个方向键,「更多」那一层再给三十来个符号(竖线、波浪号、反斜杠、引号、美元符、星号那些)、十颗 Ctrl 组合、字号加减、粘贴。带子是横着滚的,不占终端地方。实体键盘(蓝牙、平板键盘、横屏不带软键盘)的方向键、退格、回车走那个 1dp 隐形输入框的按键回调,画布本身不吃焦点。

「退到后台都不会断」这句当时是假的

帮助卡片上我写了「会话挂在前台,切后台不断」。写完没测就发上去了。

实测:App 一退到后台,进程被系统冻结,socket 跟着被拆,二十秒左右回来就报 Software caused connection abort;而在前台空转一分钟什么都不掉。会话确实挂在 App 级的单例上——但单例活着不等于文件描述符活着。

补的是那套下载已经用过的东西:连上之后由 SshSessionService 把进程撑在前台优先级上。

  • foregroundServiceType 用的是 specialUse 而不是 dataSync:那一档从 Android 15 起要交代「同步的是哪份用户数据」,而这条会话是人正坐在终端前面的交互,说明写「保持一条用户主动开启的 SSH 会话」才是实话
  • startForeground 必须在开协程之前同步调用,系统只给这几秒;先 startForegroundService 再慢慢起通知,五秒后被系统掐掉
  • 服务只在真正连上之后才起。认证失败、连不上都不会留一条假通知
  • 通知(setSilent + setOngoing)写的是 SSH 会话 · user@host:2222,点它回终端,上面一颗「断开」
  • 服务里挂了一个 holding 标记:SshRepository.close() 收尾时会回头叫一次 stop(),没有这个标记那次调用会把服务再起一遍、再 close 一遍,来回没完
  • 另一个 sessionToken 是防串场的:散场那段代码跑在 IO 线程上,可能晚于下一轮 open() 好几拍,认了这个号才允许改界面、清连接、停通知——否则上一条会话的收尾会把新会话一起拆掉

现在那句「切后台不断」是真的,验收方式是:连上 → 按 HOME → 等 60 秒 → 回 App 仍然「已连接」。

有意不做的几件

  • 不传文件:上传下载走文件管理器那一页,或者服务器上自己敲 scp / rsync,App 里不再做一个 SFTP 客户端
  • 没有端口转发和跳板机:要 tunnel 请用专业客户端
  • 没有多标签:一次只有一条会话,再点「保存并连接」会先把上一条收掉
  • 网络断了不替你重放:一条已经发出去的 rm 在自动重连后再跑一遍是会被吓到的。断了就摊在终端最后一屏上面那张卡上(原来的输出还在下面看得见),给「重连 / 改配置 / 返回」三只手
  • 回看缓冲 2000 行封顶,yes 那种刷屏只能往上翻一段

安全上只有两条,但都是硬的

密码和私钥只进系统密钥库的密文,界面上只有掩码;表单里「留空表示沿用已存的」就是字面意思,重连不用重填。

第一次连一台服务器要亲手核对指纹(TOFU):弹窗摊开 host:port、密钥类型和 SHA256:…,点了信任才记下来,往后每次自动核对。对上是硬拦,不是「要不要继续」——那意味着中间人或者服务器重装过系统,界面上给的话是「先去面板或机房确认,别急着点」。要主动换一把,去「改配置」最底下点「忘掉这台服务器」。

还有个小提醒写在了错误文案里:这里填的是 sshd 的地址(默认 22),不是面板那个端口。DNS、端口、被拒三种连不上分开说,不合成一句「连接失败」。

粥粥

种一棵树,等它长大

转载请注明出处

不知道看什么?

随机去往一篇

评论 0

添加新评论

还没有评论,来抢沙发