🐕

はじめての自作ターミナル(macOS編)

2021/08/12に公開

はじめに

道具を自作したい、それは人類の根源的な欲求の一つです。もちろんターミナルエミュレータも例外ではありません。この記事ではiTerm2的なターミナルエミュレーターのアプリ(以降はターミナルと省略)の作り方を説明します。

完成したターミナル

ターミナルでコマンドを実行している様子のgifアニメ

この記事は前提知識なしで読み進められるように考慮しています。安心して読み進めてください。そして、この記事を読み終える頃には上のgif画像のようなターミナルが自作できるようになります。すでに擬似端末についての知識があり、実装手順のみ知りたい場合は「SwiftUIによるアプリの実装」まで読み飛ばしてください。

※私は視覚に障害があるためVoiceOverを利用しています。上記のgif画像には画面の左下に音声読み上げのキャプションパネルが映り込んでいるかと思います。その点は気にしないでください。

擬似端末(pseudo terminal)について

まずはmacOSに搭載されているターミナルを起動しましょう。その後にttyと入力してください。

$ tty
/dev/ttys000

上記のように表示されるはずです。

続けてCommand + Tで新規タブを開いてから、もう一度ttyを入力してください。

$ tty
/dev/ttys001

この/dev/ttys000/dev/ttys001が擬似端末(pseudo terminal)です。

擬似端末への書き込み

実験してみましょう。Command + 1で1つめのタブに切り替えてから、以下のコマンドを入力してください。

$ echo hello > /dev/ttys001

その後、Command + 2で2つめのタブに切り替えてください。helloと表示されているはずです。

$ hello

この実験によって擬似端末への書き込みは画面への出力に対応していることが確認できました。

擬似端末からの読み取り

別の実験をしましょう。Command + 1で1つめのタブに切り替えてから以下のコマンドを実行してください。

$ cat /dev/ttys001

次にCommand + 2で2つめのタブに切り替えてから、何でも良いのでキーボードを連打しましょう。ひとまず、aを連打してください。

$ aaaaaaaaaaaaaaaa...

Command + 1で1つめのタブに切り替えてください。すると、2つめのタブで入力した文字が表示されているはずです。

$ cat /dev/ttys001
aaaaaaaaaaaaaaaa...

ところで、キーボードを連打している時に違和感を覚えたはずです。もう一度Command + 2で2つめのタブに切り替えてから、今度はpwdと入力してください。

# pwdと入力しているのに一部の文字が表示されない
$ p

pwdと入力したのに一部の文字が入力できない、。あるいは全く文字が入力できないはずです。これは正常な挙動ですから安心してください。

この挙動は2つめのタブで実行しているシェルと1つめのタブで実行しているcat /dev/ttys001が擬似端末の入力を同時に読み取っているため発生します。Command + 1で1つめのタブに切り替えてください。入力したときに欠けていた文字が表示されているはずです。

$ cat /dev/ttys001
wd

この実験によって擬似端末の読み取りはキーボード入力の読み取りに対応していることが確認できました。

アプリ実装の流れ

以下がアプリ実装の流れになります。まずは手順の1.と2.について説明します。

  1. 擬似端末(/dev/ttysXXXファイル)を作る
  2. シェルの標準入出力と擬似端末の入出力を接続する
  3. アプリが受け取ったキーボード入力を擬似端末に渡す
  4. 擬似端末から受け取った出力をアプリに渡して画面を描画する

Swiftによる擬似端末の作り方

posix_openpt関数を実行すると擬似端末のマスターファイルとスレーブファイルのペアが作成されます。このとき作成されたスレーブファイルはシェルに接続します。すると、マスターファイルへの書き込みはシェルの入力として、マスターファイルの読み取りはシェルの出力として扱われます。

実装例

まずは、キーボード入力の代わりに擬似端末へ直接コマンド文字列を書き込み、その結果を標準出力へ表示する実装例を示します。

import Darwin
import Foundation

class PTY {
    var process = Process()

    var slaveFile: FileHandle
    var masterFile: FileHandle

    init() {
        let masterFD = posix_openpt(O_RDWR)

        grantpt(masterFD)
        unlockpt(masterFD)

        self.masterFile = FileHandle.init(fileDescriptor: masterFD)

        let slavePath = String.init(cString: ptsname(masterFD))

        self.slaveFile = FileHandle.init(forUpdatingAtPath: slavePath)!

        self.process.executableURL = URL(fileURLWithPath: "/bin/bash")

        self.process.standardOutput = slaveFile
        self.process.standardInput = slaveFile
        self.process.standardError = slaveFile

        DispatchQueue.global(qos: .default).async {
            do {
                try self.process.run()
            } catch {
                fatalError("failed to start shell: \(error)")
            }
        }
        DispatchQueue.global(qos: .default).async {
            while true {
                let data = self.masterFile.availableData
                let output = String(data: data, encoding: String.Encoding.utf8)!

                print(output)
            }
        }
    }
    func write(_ input: String) {
        self.masterFile.write("\(input)\u{0d}".data(using: String.Encoding.utf8)!)
    }
}

var pty = PTY()

// 1秒ごとにdateコマンド実行する
for _ in 0 ..< 5 {
    sleep(1)

    pty.write("date")
}

// 出力している途中でプログラムが終了するのを防ぐため1秒待つ
sleep(1)

試運転

それでは試運転しましょう。Swiftコードをmain.swiftファイルとして保存したら以下のコマンドを入力してください。

$ swift main.swift

実行すると以下のように表示されます。私の開発環境はデフォルトのシェルをzshに設定しているためchshの使い方が表示されています。その点は無視してください。

$ swift main.swift
The default interactive shell is now zsh.
To update your account to use zsh, please run `chsh -s /bin/zsh`.
For more details, please visit https://support.apple.com/kb/HT208050.
bash-3.2$
date
Wed Aug 11 08:54:14 JST 2021
bash-3.2$
date
Wed Aug 11 08:54:15 JST 2021
bash-3.2$
date
Wed Aug 11 08:54:16 JST 2021
bash-3.2$
d
ate
Wed Aug 11 08:54:17 JST 2021
bash-3.2$
dat
e
Wed Aug 11 08:54:18 JST 2021
bash-3.2$

dateコマンドが実行される様子が表示されています。擬似端末への読み書きは正常に行われていることが確認できました。

なお、dateの入力が途中で途切れたり改行が挟まれたりしているのは意図した挙動です。マスターファイルの読み書きが同時に行われるとタイミングによっては上記のような表示のずれが発生します。

解説

main.swiftで行っている処理を説明します。

  1. posix_openptを実行してマスターファイルのファイルディスクリプタを取得する
  2. grantptunlockptを実行して擬似端末のセットアップを行う
  3. マスターファイルを作成する
  4. ptsnameを実行してスレーブファイルのパスを取得する
  5. そのパスをもとにスレーブファイルを作成する
  6. スレーブファイルをシェルの標準入出力に接続する
  7. マスターファイルの読み書きを行う

以上が擬似端末の作り方と使い方の流れになります。

なお、実装例では/bin/bashをシェルとしてハードコードしています。もちろんbashでもzshでも、シェルは何を指定しても構いません。

ところで、write()メソッドに注目してください。擬似端末へ書き込む文字列に\u{0d}を連結しています。これは改行コードの0x0dを意味します。キーボードのリターンキーが押されたことをシェルに伝えるため必要になります。

SwiftUIによるアプリの実装

ターミナルの実装に必要な知識は揃いました。あとはキーボード入力の受け取りと画面描画を実装すればアプリは完成です。

今回はSwiftUIを利用して簡易的なターミナルを実装します。文字の入力にはSwiftUIのテキストフィールドを利用するため、Ctrl + Cなどのシグナルは受け付けません。

実装例

それでは、Xcodeを起動してFile→New→New Projectを洗濯してください。アプリ名は何でも構いません。プラットフォームとしてmacOS、ユーザーインターフェースとしてSwiftUIを指定してアプリの雛形を作ってください。

すると、ContentView.swiftファイルが作成されるはずです。ContentView.swiftの中身をすべて消してから、以下のコードを貼り付けてください。

import Darwin
import SwiftUI

class PTY: ObservableObject {
    var process = Process()

    var slaveFile: FileHandle
    var masterFile: FileHandle

    var outputBuffer = ""

    @Published var outputLines: [String] = []

    init() {
        let masterFD = posix_openpt(O_RDWR)

        grantpt(masterFD)
        unlockpt(masterFD)

        self.masterFile = FileHandle.init(fileDescriptor: masterFD)

        let slavePath = String.init(cString: ptsname(masterFD))

        self.slaveFile = FileHandle.init(forUpdatingAtPath: slavePath)!

        self.process.executableURL = URL(fileURLWithPath: "/bin/bash")

        // 日本語の入出力を可能にするため環境変数を設定しておく
        self.process.environment = [
            "LANG": "en_US.UTF-8",
            "LC_COLLATE": "en_US.UTF-8",
            "LC_CTYPE": "en_US.UTF-8",
            "LC_MESSAGES": "en_US.UTF-8",
            "LC_MONETARY": "en_US.UTF-8",
            "LC_NUMERIC": "en_US.UTF-8",
            "LC_TIME": "en_US.UTF-8",
            "LC_ALL": "en_US.UTF-8"
        ]

        self.process.standardOutput = slaveFile
        self.process.standardInput = slaveFile
        self.process.standardError = slaveFile

        DispatchQueue.global(qos: .default).async {
            do {
                try self.process.run()
            } catch {
                fatalError("failed to start process: \(error)")
            }
        }
        DispatchQueue.global(qos: .default).async {
            while true {
                let data = self.masterFile.availableData
                let output = String(data: data, encoding: String.Encoding.utf8)!

                // シェルの出力は途中で分割されることがあるので一度バッファにためる
                self.outputBuffer += output

                var lines = [String]()

                self.outputBuffer.enumerateLines { (line, stop) -> () in
                    lines.append(line)
                }

                // SwiftUIのObservableなプロパティはメインスレッド内で更新する必要があるためDispatchQueueで処理する
                DispatchQueue.main.async {
                    self.outputLines = lines
                }
            }
        }
    }
    func clear() {
        // 現在表示されている最後の行のみ残して他は削除する
        self.outputBuffer = self.outputLines[self.outputLines.count - 1]

        DispatchQueue.main.async {
            self.outputLines = [self.outputBuffer]
        }
    }
    func write(_ input: String) {
        switch input {
        case "clear":
            self.clear()
        default:
            self.masterFile.write("\(input)\u{0d}".data(using: String.Encoding.utf8)!)
        }
    }
}

struct ContentView: View {
    @State var input = ""
    @ObservedObject var pty = PTY()

    var body: some View {
        VStack(alignment: .leading) {
            ScrollView {
                ForEach(self.pty.outputLines, id: \.self) { line in
                    Text(line)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .foregroundColor(Color.white)
                }
            }
                .padding()
                .frame(maxWidth: .infinity, alignment: .leading)
                .background(Color.black)
                .accessibilityLabel("Output area")
            Spacer()
            TextField("Press the return key to execute.", text: $input) { isEditing in
                // do nothing while editing
            } onCommit: {
                self.pty.write(self.input)
                self.input = ""
            }
                .padding()
                // .frame(maxWidth: .infinity, alignment: .leading)
                .accessibilityLabel("Input area")
        }
            .font(.system(size: 18, weight: .regular, design: .monospaced))
            .frame(width: 960, height: 540)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

アプリのビルド設定

試運転をする前にアプリのサンドボックスを解除しましょう。この設定をしなくてもアプリは起動できますが、例えばgitコマンドを実行すると"xcrun: error: cannot be used within an App Sandbox."エラーが発生します。

サンドボックスを解除するにはプロジェクト設定のSigning & Capabilitiesタブを選択します。その後、Removeボタンを押してApp Sandboxを削除してください。

試運転

それではアプリの試運転をしましょう。Xcodeのメニューを開いてProduct→Runを選択するとアプリが起動します。

おめでとうございます。この記事の冒頭に貼り付けた画像のような外観のアプリが起動したはずです。自作ターミナルが完成した喜びを噛み締めてください。

エスケープシーケンスについて

アプリは完成しましたが、例えばvimやemacsを起動すると画面が乱れることに気づいたはずです。よく見ると、^H^Mなど文字が表示されています。これは何でしょうか?

画面が乱れるのはエスケープシーケンスが原因です。エスケープシーケンスは0x1bからはじまる特殊文字であり、通常は画面に出力されません。しかし、エスケープシーケンスの種類によっては表示可能な文字が含まれるため、^H^Mといった文字が表示されるのです。

エスケープシーケンスはシェルの出力を制御するための特殊文字です。出力される文字に色をつけたり、カーソルの位置を制御したりするのに使われます。

もちろんエスケープシーケンスを利用しているプログラムはvimやemacsだけではありません。例えば、進行状況を表示するプログレスバーはエスケープシーケンスを利用して実装されています。

さて、今回作成したターミナルはエスケープシーケンスを全く考慮していません。そのためvimやemacsに限らず、エスケープシーケンスを利用しているプログラムは表示が乱れます。

ターミナル自作は茨の道

アプリは完成したもの実用には耐えられません。なぜでしょうか?

まずはエスケープシーケンスを正しく扱う必要があります。現状、カーソルの移動や文字の装飾を実装していないため表示の乱れが頻繁に発生します。そして、残念ながらSwiftUIはエスケープシーケンスに対応していません。この問題を解決するには独自の画面描画エンジンを実装する必要があります。

次に、キーボード入力に関連する処理が不足しています。例えばシグナルの送信処理は必須機能です。もちろんSwiftUIのテキストフィールドはCtrl + CをSIGINTに変換するなんて親切なことはしてくれません。この問題を解決するにはキーフックの実装が必要になります。

一般的なターミナルに備わっている便利な機能も不足しています。フォント指定や画面分割などの基本的な機能から、プロセスの実行中にウィンドウを閉じると警告のダイアログを表示するといった細かな機能まで、様々な機能が不足しています。

さらに、ターミナルの実装には不要かと思いきや音声処理が必要になります。echo "\a"を実行するとビープ音(macOSの場合はシステムの効果音)が再生されます。カーソルが左端に到達してこれ以上文字を入力できないときにもビープ音が再生されます。多くの人にとっては不要な機能ですが、私のような視覚に障害のあるユーザーにとっては必須機能です。

おわりに

ここでiTerm2のコードの行数を確認してみましょう。iTerm2はObj-Cで実装されているため.hファイルと.mファイルの行数を表示してみます。

$ git clone https://github.com/gnachman/iTerm2
$ cd ./iTerm2/sources
$ cat *.h *.m | wc -l
287308

iTerm2は約29万行のコードで実装されていることが判明しました。巨大プロジェクトですね。

とはいえ時間と根気さえあれば実用的なターミナルが自作できそうな気がしませんか?

Enjoy :D

参考資料

Discussion