🦍

ブラウザをターミナル代わりにするrttyを作った

2021/07/24に公開

初めに

以前にブラウザからターミナルを操作したくて、周りの方に聞いたらSecure Shell Appを教えていただいたんですが、なぜか自分の環境ではうまく動作しませんでした。
他にも似たようなことができるツールはありましたが、メンテナンスがされていなかったりビルドもインストールできなかったりして使えなかったので、自作しました。

https://github.com/skanehira/rtty

しくみ自体はイメージできていたので、そんなに難しくないだろうと思っていましたが、意外と罠があってそれを解決するのに時間がかかってしまいました。

rttyの概要と使い方

rttyはプロセスの入出力をwebsocketを通して、ブラウザでターミナルのような操作を可能にするCLIです。
簡単にいうとブラウザがターミナル代わりになる、という感じです。

+---------+     http     +------+   stdin   +------+
| browser | <==========> | rtty | <=======> | bash |
+---------+   websocket  +------+   stdout  +------+

普段、ぼくはVivaldiというブラウザを使用しています。このブラウザはタブタイリングという機能があり、複数のタブを1画面にまとめられます。次のスクショのようにコーディングしつつブラウジングするにはもってこいです。

使い方はシンプルでrtty run {実行したいコマンド} -vするだけです。詳細なオプションはREADMEを参照していただければと思います。

実装

もう少し詳細を説明するとrttyPTYを使って、プロセス入出力を読み書きしています。

+---------+     http     +------+       +------+       +-----+    stdin   +------+
| browser | <==========> | rtty | <===> | ptmx | <===> | pts |  <=======> | bash |
+---------+   websocket  +------+       +------+       +-----+    stdout  +------+

PTYptmxなどについてはこちらの記事がわかりやすいので、わからない方は一度そちらを読むとよいと思います。
簡単にいうと、rttyはターミナル(ptmxpts)の入出力を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されてしまうという問題がありました。

https://twitter.com/gorilla0513/status/1391413068744122370?s=20

メッセージを読むと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に送るという処理が必要になります。
rttyx/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