iOS で動くターミナルエミュレータを作っていた話。
デモ
概要
ターミナルエミュレータの実装であって、これ自体に SSH クライアントの機能はない。使いたかったら別途
NMSSH/NMSSH
などを使うと良い。2020年の6月頃には出来ていたんだけど、あとで技術的なことを書こうと思ってたら半年も放置してしまった。
中身の実装は hterm.js を
WKWebView
にレンダリングさせて、 WebView + JavaScript の世界と Swift ネイティブの世界を頑張ってブリッジしてる。ほぼ日曜 OSS みたい気持ちでやってるんだけど、いつの間にか フォークされて CocoaPods 対応されてた。
この記事を書くにあたって調べ直したりしたところ、 より良さそうなライブラリ を見付けてしまった。なぜ当時見付けられなかったのか…。
実装の方針を考えたときの話。
ターミナルエミュレータの実装を完全に新規でやるのは現実的でないと考えて、いくつか先行事例がないかを探すことからやった。
iOS アプリで SSH が出来ることを売りにしているものを探して、そこからコードが公開されおり現時点で活発に開発が続いてそうされていそうなものに目星を付けた。
この中で iSH は別に SSH クライアントではなくて x86 の エミュレーション をしてて alpine が iOS 端末ローカルで動かせるっていう凄いやつなんだけど、気になる人はソースを追うと良いと思う。
この辺の参考になりそうなプロダクトのコードを見ると、内部的に hterm.js を持っていて、それを WKWebView
に描画しているようだった。
歴史的経緯の塊であるターミナルの複雑怪奇な仕様を満たす実装を自前でやっているところはないということが分かったし、まぁそうだよな、という感想だった。
先行実装を見付けたまではいいが、コピペするのはライセンス的にダメ。まぁ良くみると a-Shell は BSD 3-Clause だけど、Blink も iSH も GPL なのでコードを丸々使う訳にはいかない。幸い中身の一番大事なところは hterm.js なので使い方とか設計とかを実装の参考程度にして、ブリッジ部分を自前で実装することにした。
hterm.js と xterm.js
ターミナルエミュレータが実装されている JavaScript ライブラリを WKWebView
に流し込む方針で考えた場合、 hterm.js 以外にも xterm.js (GitHub) の採用も選択肢に挙がる。
実際に iOS ネイティブアプリに採用している例もあるようで Pisth (GitHub) という SSH/SFTP クライアントが見付かるのだが、日本の AppStore では販売していないようだ。
販売はされてないが手元でビルドすれば動作確認程度は試せるのでやってみたが、どちらを選んでも大差はないような感じだった。
WKWebView
と戦う。
ネイティブアプリのように振る舞わせようとすると意外とやることが多い。
元々 PC のブラウザで操作することを念頭に置いて作られているものを利用する訳で、それをモバイルアプリの UI/UX の文脈で違和感なく使えるようにするためには様々な工夫が必要だ。
具体的にやったことはキーボードやマウスなど、入力系の制御を丁寧に作ることや、長押しによるコピペをサポートすることだった。
キーボード入力を良い感じにしたい(良い感じとは…)
WKWebView
にキーボード入力処理を任せてしまうと、どうしても WebView 感のある挙動になってしまう。具体的には特徴的な InputAccessoryView
が出てしまうとかそのあたりだが、今回はそれを嫌って WKWebView
側の isUserInteractionEnabled
を false
にしてタッチイベントの受け取りを完全に殺し、上に被せている UIView
側でタッチイベントやキーボードイベントを受け取れるようにネイティブで実装して、それをブリッジするような実装をした。
自前でキーボード入力のハンドリングをしているところで、ちゃんと CJK の 2 バイト文字が入れられるように UITextInput
周りの実装もしてあるのだが、この辺は個人的にも実装が雑であることに自覚的で、少々バギーであることは否めない。難しいですね。(これは欧米人あるあるなのだが、彼らは 2 バイト文字を入力するために入力に変換を挟むということをそもそも知らないことが多いため実装すらされない場合がある。)
Control キーや Meta キーを使った複合的な入力ができるようにする必要もある。というかこれが無いとターミナルとして成り立たないため、ASCII コードやビットマスクについて理解しつつ実装する必要があった。 mintty の wiki などが情報としては参考になった。
またその過程で Shift + Tab の特殊性が分かったのが面白かった。Shift キーの役割は大文字小文字の変換であるが、歴史的なハードウェアの実装面で見ると 0x20
の XOR
を取ると言える。ちなみに Control の場合は 0x40
の XOR
だ。
Tab のキーコードは 0x09
で、そのまま XOR
を取ってみると 0x29
となって、これは )
になってしまう。もちろん実際にそういう訳ではないのだが。じゃあ何を送信してるのかというと ^[[Z
らしい。これは前述の mintty の wiki にもあったし、ググっていた過程で https://stuff.mit.edu/afs/sipb/user/daveg/Info/backtab-howto.txt という文書も見付けて興味深かった。
ターミナルエミュレータを作っておいて「ハードウェアキーボードを Bluetooth で繋げたり iPad で物理キーボードを繋げたときに快適に使えなかったらダメだろう」ということで、その辺りにも対応できるように UIKeyCommand
関連の実装もしてある。
スクロールをどうにかする。
GUI 環境でターミナルの履歴を眺めるのにマウスでスクロールしたりなんだりする訳だが、この辺になってくると自分がエミュレートしているターミナルが VT100 なのか xterm なのかといったことに自覚的にならざるを得ない。(実際にはキーボード入力を扱ってるあたりから自覚的にならざるを得ないのだが…。)
上の方に挙げた hterm.js を利用している iOS アプリでも、スクロール時の挙動は千差万別であった。この辺を自前で頑張って実装することによってよりネイティブっぽい挙動を実現している勢や、スクロールイベントだけは WKWebView
の方に任せて hterm.js の実装に従う勢もある。
スクロールイベントをネイティブに寄せるように作ると、どうしてもターミナルエミュレータとしてのエミュレーション精度が犠牲になるような気がしたのと、ライブラリを使う側が実装の詳細を知らないといけないような感じになってしまいそうな気がしたので、スクロールモードとキー入力モードの 2 つを持って、縦の Pan Gesture イベントによって制御をどちらに渡すかを切り替えるような感じの実装をした。vim みたいなもんである。
ただやはりここは難しいところで、 hterm.js 側に処理を移譲することによって WebView 感というか非ネイティブアプリのような挙動となってしまうことは否めない。
競合とか。
しかしこの記事を書くにあたって調べ直したりしたところ、より良さそうなライブラリ migueldeicaza/SwiftTerm を見付けてしまった(自分で作り始めた当時は見付けられなかったような気がしている…)
「歴史的経緯の塊であるターミナルの複雑怪奇な仕様を満たす実装を自前でやるのは無理だろう」という前提から物事を始めていたが、上記ライブラリは xterm.js を参考にしているとは言え、複雑怪奇な仕様をちゃんと Swift の世界で実装しているようなので、こちらを使った方が WKWebView
と戦うという不毛なことをせずとも UI/UX を作り込めそうな気がしているし、なによりもあちらの方が筋が良いように思われる。
締め
個人的なスタンスとして、こういうのは必要になったから自分が使うために作ることが多い。
特に宣伝とかしてる訳でもなかったが、いつの間にか フォークされて CocoaPods 対応されてた のが良かった。ギッハブは出会い系みたいなもん。