1973 年 Xerox PARC 第一次在 Xerox Alto 这款个人计算机上推出带有 GUI 界面的操作系统,自此让极大地降低个人计算机的使用门槛,也开启了更加丰富多彩的计算机发展。
不过作为一个码农,终端依然是平日不可或缺的生产力工具。在 macOS 上,系统自带的 Termianl.app 或者更加好用的开源的 iTerm2.app 是最受欢迎的终端应用(其他 X Windows 系统也有像 xterm 之类的优秀应用)。他们也都是一个 Cocoa App。那么一个 Cocoa App 是如何把自己变成一个能跟用户通过键盘交互,有标准输入输出的“伪终端”(Pseudoterminal)的呢?
〇、历史上的终端 Terminal
在带有电子显示器的终端发明以前,人们真的就是在一台带键盘的打印机上,一边打字输入,一边等待计算机在纸上打印输出。所以大家写 Hello World! 的时候都是用 print("Hello World!")
,因为它是真地在打印。
第一台带有显示器,支持 ANSI escape codes 的终端是 DEC 公司生产的 VT100。在这之前他们已经生产过很多种型号的电子终端,不过这台机器是最成功的。
一、Cocoa App 如何调用系统自带的 Binaries
我们知道 ls
这个命令在 Unix 系统里就是一个 binary,一般放在 /bin
或者 /usr/bin
这样的目录里,用 whereis
可以找到它在哪里。
whereis ls
ObjectiveC 在 Foundation 里提供了 NSTask
这样的高级封装,用它的接口可以非常简单地实现类似 shell command
的效果。
但是首先一个沙盒 App 的能力是有限的,其次就算是沙盒外的 App,NSTask
也不允许直接访问 /usr/bin
目录里的 binaries,直接调用要嘛无响应要嘛直接 crash。
所以我们还得迂回一下,我们不直接运行 binaries,而是利用 bash
来运行:
NSTask *task = [[NSTask alloc] init];
[task setLaunchPath:@"/bin/bash"];
[task setArguments:@[ @"-c", @"/usr/bin/killall Dock" ]];
[task launch];
但是即便如此,想要使用 NSTask
的接口来模拟终端还是非常困难的事情。所以,Termianl Apps 们是怎么实现的呢?
二、iTerm2
iTerm2 的代码是开源的,历史原因内部实现比较复杂,而且 iTerm2 支持在 Cocoa App 里直接和 python 脚本交互,相当于他提供了一套桥接的接口,可以用 python 来实现对 iTerm2 App 的自动化,类似 Hammerspoon 这类 App 的效果。所以阅读过程中我还看到一堆 client/server 的通信,有点绕。
最后我发现真正实现终端功能的地方在这里: iTermPosixTTYReplacements.c
,关键函数是:
int openpty(int *amaster, int *aslave, char *name, struct termios *termp, struct winsize *winp);
这个函数的实现在 Libc
里,可以参考苹果开源页面。
openpty()
是 BSD 函数,并不在 POSIX 标准里,不过 Linux 也有把这个函数 port 过去。从应用层的角度来看,openpty()
会跟 open("/dev/ptmx")
获取一个可用的 pseudoterminal
。iTerm2 的做法就是通过该函数获得一个 pseudoterminal master 和 slave 的 fd
句柄,后续用户在 UI 界面上的输入都通过这两个句柄来交互。
iTerm 在 openpty()
之后还 fork()
了一下自己,然后父进程释放所有的句柄,这样父进程处理 UI 输入,一个窗口对应一个子进程,一个子进程对应一个 pty
。
为什么 Unix 要这么设计 pty
接口呢?历史原因。
早期的计算机比如 1970 年 DEC 生产的 PDP-11,他需要通过一系列的电线跟用户的终端(也就是键盘和打印机)连接到一起。这种只有键盘和打印机的终端也叫做 TTY。后来有了电子显示器之后,就得使用软件模拟一个硬件终端,也叫做"伪终端"(pseudoterminal)。
UNIX 采用的设计是加入了一个中间层,当你使用 openpty()
打开一个伪终端的时候,会给你一个 master 一个 slave 句柄。GUI 软件把键盘输入作为 master 的 input 写入,master 的 output 就会作为 slave 的 input 写入,然后再作为 output 输出。所以对于我们的 Cocoa App 应用层来说,可以简单地把 master fd 作为 writer,把 slave fd 作为 reader。
听起来好像没什么必要但是其实 slave 做了一些特殊的处理。比如 GUI 直接把键盘输入的 CTRL+C(0x03)
写入 master 句柄。这时候 slave 接收到后会把 0x03
转换成 SIGINT
signal 发出。对此感兴趣的同学可以参考微软关于 ConPTY 的这篇文章。
所以 iTerm2 既是一个 Cocoa App 又是一个“终端模拟器”,你可以在这个 App 里跑任意 shell 命令。
三、其他应用
openpty()
这种 master/slave fd 的设计还体现在 SSH 远程登录上。可以参考 macOS 的 OpenSSH 源码。客户端通过 SSH 协议连上服务端时,服务端的 sshd
进程开了一个 pty
用来跑客户端输入的命令。
另外 VSCode 也基于 Node.js 实现了一个编辑器内的 console,源码在这里。
回到我们的 Cocoa App 来,一个 NSTask
对象在被 launch()
之前我们可以当做是一个数据存储的结构体来对待。通常我们会直接调用它的 launch()
方法,然后使用 NSPipe
来读写。
这里如果要绕过上文所述的 crash
问题,我们可以改用 openpty()
:
NSCAssert(openpty(&masterFD, &slaveFD, NULL, NULL, NULL) == 0,
@"A pseudoterminal couldn't be opened.");
*readHandle = [[NSFileHandle alloc] initWithFileDescriptor:masterFD closeOnDealloc:YES];
*writeHandle = [[NSFileHandle alloc] initWithFileDescriptor:slaveFD closeOnDealloc:YES];
有兴趣的读者朋友不妨一试。