ブラウザをターミナル代わりにするrttyを作った
初めに
以前にブラウザからターミナルを操作したくて、周りの方に聞いたらSecure Shell Appを教えていただいたんですが、なぜか自分の環境ではうまく動作しませんでした。
他にも似たようなことができるツールはありましたが、メンテナンスがされていなかったりビルドもインストールできなかったりして使えなかったので、自作しました。
しくみ自体はイメージできていたので、そんなに難しくないだろうと思っていましたが、意外と罠があってそれを解決するのに時間がかかってしまいました。
rtty
の概要と使い方
rtty
はプロセスの入出力をwebsocket
を通して、ブラウザでターミナルのような操作を可能にするCLIです。
簡単にいうとブラウザがターミナル代わりになる、という感じです。
+---------+ http +------+ stdin +------+
| browser | <==========> | rtty | <=======> | bash |
+---------+ websocket +------+ stdout +------+
普段、ぼくはVivaldiというブラウザを使用しています。このブラウザはタブタイリングという機能があり、複数のタブを1画面にまとめられます。次のスクショのようにコーディングしつつブラウジングするにはもってこいです。
使い方はシンプルでrtty run {実行したいコマンド} -v
するだけです。詳細なオプションはREADMEを参照していただければと思います。
実装
もう少し詳細を説明するとrtty
はPTY
を使って、プロセス入出力を読み書きしています。
+---------+ http +------+ +------+ +-----+ stdin +------+
| browser | <==========> | rtty | <===> | ptmx | <===> | pts | <=======> | bash |
+---------+ websocket +------+ +------+ +-----+ stdout +------+
PTY
やptmx
などについてはこちらの記事がわかりやすいので、わからない方は一度そちらを読むとよいと思います。
簡単にいうと、rtty
はターミナル(ptmx
とpts
)の入出力をwebsocket
でブラウザとつないでいるというイメージです。
ptmx
を作成する部分はgithub.com/creack/ptyというパッケージを使用しています。2行でptmx
取れるので便利です。
// Create arbitrary command.
c := exec.Command("bash")
// Start the command with a pty.
ptmx, err := pty.Start(c)
あとはio.Copy
を使って、入出力をよしなに処理しています。
// Make sure to close the pty at the end.
defer func() {
_ = ptmx.Close()
_ = c.Process.Kill()
_, _ = c.Process.Wait()
}() // Best effort.
go func() {
_, _ = io.Copy(ptmx, ws)
}()
w := &wsConn{
conn: ws,
}
_, _ = io.Copy(w, ptmx)
ブラウザ側はxterm.jsを使用しています。VSCodeのターミナル機能もこれを使って実装されています。
これだけでブラウザ側でいつもの黒い画面になり、入力を受け取りWebsocketに送信でき、受信したデータをDOMに書き込めます。エスケープシーケンスもすべてxterm.js
が処理して変換してくれるので、ほぼ何もしていないといってよいレベルです。
const terminal = new Terminal(option);
const fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(document.getElementById('terminal'))
fitAddon.fit();
terminal.onData(data => {
socket.send(data);
})
socket.onmessage = (e) => {
terminal.write(e.data);
}
問題
こんな感じで処理自体はとてもシンプルですが、実はVim
などのTUIを使うとたまにメッセージをデコードできなくて、Websocketがcloseされてしまうという問題がありました。
メッセージを読むとUTF-8として正しくないデータなのでデコードできない、と言っています。
最初は検討もつかなかったんですが、デバッグしていくうちにio.Copy
の部分の実装が良くないことに気付きました。
さきほど載せたコードは修正版ですが、初期の実装は次になっていました。
_, _ = io.Copy(ws, ptmx)
これだと、プロセスの出力がio.Copy
のバッファサイズを越えた場合、データが途中Websocketに送られてしまいます。それによって、UTF-8として正しくないテキストデータが送られてしまい、ブラウザ側でデコード失敗してしまいます。
簡単にいうと、あ
は切断されるとinvalidなUTF-8になります。このようなことが発生していました。
a := []byte{0xe3, 0x81, 0x82} // あ
utf8.Valid(a[:2]) // false
そのため、読み取ったデータがUTF-8としてinvalidならバッファリングして、バッファの中身がvalidならWebsocketに送るという処理が必要になります。
rtty
はx/net/websocket
を使用していますが、残念ながらWrite
はそれを考慮した実装になっていないため、今回の問題が起きました。
対策としてwebsocket.Conn
とバッファを持った構造体を用意して、それにio.Writer
を実装して問題を解決しました。
func (ws *wsConn) Write(b []byte) (i int, err error) {
if !utf8.Valid(b) {
buflen := len(ws.buf)
blen := len(b)
ws.buf = append(ws.buf, b...)[:buflen+blen]
if utf8.Valid(ws.buf) {
_, e := ws.conn.Write(ws.buf)
ws.buf = ws.buf[:0]
return blen, e
}
return blen, nil
}
if len(ws.buf) > 0 {
n, err := ws.conn.Write(ws.buf)
ws.buf = ws.buf[:0]
if err != nil {
return n, err
}
}
n, e := ws.conn.Write(b)
return n, e
}
残課題
致命的な問題は解消しましたが、PCがスリープモードになるとWebsocketが切れてしまったり、ウィンドウサイズを変えてもターミナルの画面がリサイズされないという細かい問題はまだ残っています。
ひとまず最低限使えるレベルになってきたので、使いながら改善していこうと思っています。
ちなみに、この記事はrtty
を使ってプレビューしながら書きました。便利やなって思いました。
みなさんも良ければ使ってみてください。
Discussion