🔖

Tauri + Fresh (/Deno) で簡単な端末エミュレータを作ってみた

2023/12/11に公開

Rust Advent Calendar 2023 の11日目の記事です.

Rust で動く GUI フレームワークである Tauri と,Deno の Web フレームワーク Fresh を用いて端末エミュレータを作りました.
結局一部の機能しかまともに再現することができず,実用に耐え得る端末エミュレータは実装できませんでしたが,それはそれとして面白かったのでここに残します.

なお,低レイヤーの話と高レイヤーの話が混在しているので,興味の薄い部分は適宜読み飛ばしてください.

ソースコードは GitHub で公開しています.

https://github.com/naughie/tauri-term-test

使用技術について

以下で Tauri と Fresh について一言ずつ説明します.詳しい解説は先人の分かりやすい記事にお任せします.
ちなみにこれらを選んだ理由は,単に Rust で作られた新しい技術を使ってみたかったからです.

Tauri

Tauri はクロスプラットフォームなデスクトップアプリを作成するためのフレームワークです.

雰囲気としては Electron に近く,TypeScript を用いて Web 開発のようにフロントエンドが実装できます.
また,バックエンドは Rust で実装することができます.

Tauri は本来クロスプラットフォームですが,悲しいことに今回のアプリは *nix 環境でしか動作しません.

Fresh

Deno という Rust 製の JavaScript/TypeScript ランタイムがあるのですが,その上で動かせる Web フレームワークの一つが Fresh です.

中身は Next.js に近く,Preact を用いてコンポーネントごとに実装できます.

実装の前に —— 疑似端末を動かしてみる

Tauri/Fresh でアプリを作成する前に,バックエンドの雛形として Rust で疑似端末を動かしてみましょう.
使用するクレートは

libc = "0.2"
nix = { version = "0.27", features = ["fs", "process", "term"] }

です.

まず openpty() で疑似端末を作成します:

use nix::pty::{openpty, OpenptyResult};
let OpenptyResult { master, slave } = openpty(None, None)?;

この関数は2つの OwnedFd を返し,slave は fork 後の子プロセスで,master は親プロセス内で使用します.

CLOEXEC もセットしておきましょう:

use nix::fcntl::{fcntl, FcntlArg, FdFlag};
use std::os::fd::AsRawFd as _;
fcntl(master.as_raw_fd(), FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC))?;
fcntl(slave.as_raw_fd(), FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC))?;

子プロセスを fork するには,Command::spawn() を用います.
その際,

  1. stdin/out/err を slave にセット
  2. CommandExt::pre_exec() で,setsid() によって新しいセッションを作成
  3. 必要に応じて TIOCSCTTY をセット
  4. spawn 後に slavedrop()(= file descriptor を close)

します.

use std::process::{Command, Stdio};
use std::os::unix::process::CommandExt as _;
use std::os::fd::FromRawFd as _;

let mut cmd = Command::new("/bin/bash");
unsafe {
    cmd.stdin(Stdio::from_raw_fd(slave.as_raw_fd()))
        .stdout(Stdio::from_raw_fd(slave.as_raw_fd()))
        .stderr(Stdio::from_raw_fd(slave.as_raw_fd()))
        .pre_exec(|| {
            let res = libc::setsid();
            if res == -1 {
                println!("Could not create a new session.");
            }

            let res = libc::ioctl(0, libc::TIOCSCTTY, 0);
            if res == -1 {
                println!("Could not be the controlling terminal.");
            }

            Ok(())
        });
}

let mut child = cmd.spawn()?;
drop(slave);

// Do something with master

child.wait()?;

Master への読み書きは,File::from_raw_fd() に対して std の Read/Write トレイトを用いるか,nix::unistdread()/write() を使ってもできます.

let master_fd = master.as_raw_fd();
if let Err(e) = nix::unistd::write(master_fd, b"/bin/echo foo bar\n") {
    println!("Could not write to the master: {e:?}");
}
if let Err(e) = nix::unistd::write(master_fd, b"exit\n") {
    println!("Could not write to the master: {e:?}");
}

std::thread::sleep(std::time::Duration::from_millis(1000));

let mut buf = [0u8; 1024];
match nix::unistd::read(master_fd, &mut buf) {
    Ok(num_bytes) => {
        println!("Read {:?}", &buf[..num_bytes]);
    }
    Err(Errno::EIO) => {
        println!("Could not read the master, or the child has been successfully exited.");
    }
    Err(e) => {
        println!("Could not read the master: {e:?}");
    }
}

アプリの実装

次に,実際に Tauri/Fresh を使って実装していきます.
ただし,細かい実装は省略して,PoC 的に紹介するに留めます.

Prerequisites

  1. Rust をインストールします.
  2. ここ を参考にして Tauri の依存パッケージをインストールします.
  3. cargo install tauri-cli (cf. https://tauri.app/v1/guides/getting-started/setup/html-css-js#create-the-rust-project)
  4. Deno をインストール,または Docker 等で動かせる環境を作ります.

Tauri のインストールには時間がかかるかもしれません.

プロジェクト作成

最終的なディレクトリ構成はこんな感じになります:

tauri-term/
  |
  +-- components/, islands/, routes/, static/
  |
  +-- dist/
  |
  +-- その他の Fresh 関連のファイル・ディレクトリ
  |
  +-- src-tauri/
        |
	+-- src/
	|     |
	|     +-- main.rs
	|
	+-- Cargo.toml, etc.

まず Fresh プロジェクトを作成します
プロジェクト名(Project Name)は何でも良いですが,ここでは tauri-term としています.
Tailwind や VS Code に関する設定はお好みで大丈夫です.

$ deno run -A -r https://fresh.deno.dev
$ cd tauri-term

次に,tauri-term/ 以下に Tauri プロジェクトを作成します.設定項目は次のようにします:

  1. App name: 任意(e.g. tauri-term
  2. Window title: 任意
  3. Web assets: ../dist
  4. Dev server: http://localhost:8000(ポートは任意,後述)
  5. Dev command: deno task start
  6. Build command: deno run -A dev.ts build
$ cargo tauri init

ポートに関しては,dev.ts/main.tsfresh.config.ts で指定できます.
dist/ ディレクトリの設定も fresh.config.ts に書きます.

fresh.config.ts
import { defineConfig } from "$fresh/server.ts";

export default defineConfig({
  build: {
    outDir: "./dist",
  },
  server: {
    port: 8000,
  },
});

続いて src-tauri/tauri.conf.jsonbuild.withGlobalTauri を true にします.

tauri.conf.json
{
  "build": {
    "withGlobalTauri": true
  }
}

これは Fresh 上で npm:@tauri-apps/api が正常に動作しないため,必要です(記事執筆時点).
おそらく Fresh が SSR (server side rendering) していることが原因だと思うのですが,正確なことは分かりません.

最後に,deno task check 等で余計なファイル検査されるのを防ぐために,deno.jsonexcludedist, src-tauri, static を追加します.

deno.json
{
  "exclude": [
    "**/_fresh/*",
    "dist/*",
    "src-tauri/*",
    "static"
  ]
}

バックエンドの実装

バックエンドに最低限必要な機能は

  1. pty の master fd の保持
  2. master への write(コマンド実行)
  3. master からの read(コマンドの出力,プロンプト)

です.

今回はタブ機能は考えないので,Window は一つだけ,pty も一つだけ,シェルが exit したら Window も閉じる,としてしまいます.

Pty の master fd の保持

「実装の前に」と同じように,openpty() で pty を作成します.

main.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
    use tauri::Manager as _;

    let OpenptyResult { master, slave } = openpty(None, None)?;

    // Omitted

    let mut child = cmd.spawn()?;
    drop(slave);

    let master_fd = master.as_raw_fd();

    tauri::Builder::default()
        .manage(master_fd)
        .run(tauri::generate_context!())?;

    child.wait()?;

    Ok(())
}

Master への write(コマンド実行)

Write には invoke という仕組みを使います.

フロントエンドから

invoke("execute", { command: "/bin/echo foo\n" });

のように呼び出すと想定します.

main.rs
use std::os::fd::RawFd;
use tauri::State;

#[tauri::command]
fn execute(command: &str, master_fd: State<RawFd>) {
    if let Err(e) = nix::unistd::write(*master_fd, command.as_bytes()) {
        println!("Error when writing to the master: {e:?}");
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ...

    tauri::Builder::default()
        .manage(master_fd)
        .invoke_handler(tauri::generate_handler![execute])
        .run(tauri::generate_context!())?;

    // ...
}

Master からの read(コマンドの出力,プロンプト)

Read されたデータは,は Tauri の event を用いて受け渡されます.

なお,Tauri が起動してからフロントエンドが準備完了するまでにラグがあるため,

  1. フロントエンドから,useEffect() を用いて "start" event を送信,
  2. バックエンドで "start" event を受信した後,read のためのプロセスを spawn する,
  3. そのプロセス内から,read する度に "read" event を送信する

という流れで実装します.

main.rs
use serde::Serialize;
use tauri::Window;

#[derive(Clone, Serialize)]
struct ReadResult {
    read: Vec<u8>,
}

fn spawn_reader(window: Window, master: RawFd) {
    use nix::errno::Errno;

    std::thread::spawn(move || {
        let mut buf = [0; 1024];
        loop {
            std::thread::sleep(std::time::Duration::from_millis(300));
            match nix::unistd::read(master, &mut buf) {
                Ok(num_bytes) => {
                    let result = ReadResult {
                        read: buf[..num_bytes].to_vec(),
                    };

                    if let Err(e) = window.emit("read", result) {
                        println!("Could not emit the read event: {e:?}");
                    }
                }
                Err(Errno::EIO) => {
                    if let Err(e) = window.close() {
                        println!("Could not close a window: {e:?}");
                    }
                    break;
                }
                Err(e) => {
                    println!("Could not read the master: {e:?}");
                    break;
                }
            }
        }
    });
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ...

    tauri::Builder::default()
        .manage(master_fd)
        .setup(move |app| {
            if let Some(app_win) = app.get_window("main") {
                let app_win_cloned = app_win.clone();

                let _id = app_win.once("start", move |_| {
                    spawn_reader(app_win_cloned, master_fd);
                });
            }
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![execute])
        .run(tauri::generate_context!())?;

    // ...
}

spawn_reader() 内で 300 ms スリーブしているのですが,残念ながら useState() のラグのために必要です.
このあたりは下でも説明します.

フロントエンドの実装

ここでは分かりやすいように,「コマンド入力用の <Execute />」と「出力表示用の <Output />」を分けて実装しています.
具体的なデザインについては Web のものとほぼ同じ機能が使えるので,CSS や JavaScript で制御可能です.

最終的に作られた <Execute /><Output /> は,routes/index.tsx から呼び出します:

index.tsx
import Execute from "../islands/execute.tsx";
import Output from "../islands/output.tsx";

export default function Home() {
  return (
    <div class="terminal">
      <Execute />
      <Output />
    </div>
  );
}

フォントの設定

もちろんフォントも CSS で設定できます.

たとえば static/ 以下に fonts/SourceHanCodeJP-Regular.otf を配置し,フォント設定用の static/terminal.css を作成します.

ついでにその他のデザインも付けておきましょう.

terminal.css
@font-face {
    font-family: "source-han-code-jp";
    src: url("/fonts/SourceHanCodeJP-Regular.otf") format("opentype");
}

body {
  margin: 0 0 0 0;
}

.terminal {
  padding: 1rem .6rem 1rem .6rem;

  font-family: "source-han-code-jp";
  font-style: normal;

  word-break: break-all;

  height: 100%;

  color: #d3c6aa;
  background-color: #333c43;

  overflow-y: auto;
}

textarea {
  font-family: "source-han-code-jp";
  font-style: normal;

  color: #d3c6aa;
  background-color: #333c43;
  resize: none;
}

続いて routes/_app.tsx に,CSS をロードするための <link> タグを追加します.

_app.tsx
import { AppProps } from "$fresh/server.ts";

export default function App({ Component }: AppProps) {
  return (
    <html style={{ height: "100%" }}>
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>test-tauri</title>
        <link rel="stylesheet" href="/terminal.css" />
      </head>
      <body style={{ height: "100%" }}>
        <Component />
      </body>
    </html>
  );
}

__TAURI__ オブジェクトについて

バックエンドのところで述べたように,withGlobalTauri を true にしています.
つまり,window.__TAURI__ オブジェクトを通じて Tauri の API を呼び出すことになります.

まず型については,こちらを参考にして手動で書きましょう.
(あるいは npm の @tauri-apps/api.d.ts ファイルも使えるかもしれませんが,方法は分かりません…….)

型定義は適当なファイルで良いので,lib/ ディレクトリを作成して lib/window.ts にでも書いていきます.

window.ts
type Invoke = (cmd: "execute", args: { command: string }) => Promise<void>;

type Unlisten = () => void;
type Event = {
  payload: {
    read: number[];
  };
};
type Once = (
  event: "read",
  handler: (event: Event) => void,
) => Promise<Unlisten>;

type WebviewWindow = {
  emit: (event: "start") => Promise<void>;
};

declare global {
  interface Window {
    __TAURI__?: {
      tauri: {
        invoke: Invoke;
      };
      event: {
        once: Once;
      };
      window: {
        appWindow: WebviewWindow;
      };
    };
  }
}

また,window.__TAURI__ の使用には注意が必要です.
というのも,SSR 時には __TAURI__ は定義されていないので,useEffect() 等を使って SSR を回避しなければなりません.

<Execute /> の実装

ここでは非常に簡素な端末エミュレータを作りたいので,一行ごとに送信としてしまいます.

islands/ 以下に execute.tsx を配置します.

islands/execute.tsx
execute.tsx
import { JSX } from "preact";
import { useEffect, useReducer } from "preact/hooks";
import "../lib/window.ts";

type CmdStates = {
  cmd: string;
  disabled: boolean;
};
type CmdAction = {
  type: "executed";
} | {
  type: "updateCmd";
  newCmd: string;
};

const reducer = (_: CmdStates, action: CmdAction) => {
  switch (action.type) {
    case "updateCmd":
      if (action.newCmd.endsWith("\n")) {
        return { cmd: action.newCmd, disabled: true };
      } else {
        return { cmd: action.newCmd, disabled: false };
      }
    case "executed":
      return { cmd: "", disabled: false };
  }
};

const CommandEditor = () => {
  const [states, dispatch] = useReducer(reducer, { cmd: "", disabled: false });

  const onInput = (e: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => {
    dispatch({ type: "updateCmd", newCmd: e.currentTarget.value });
  };

  useEffect(() => {
    if (!states.disabled) {
      return;
    }
    if (window.__TAURI__ === undefined) {
      return;
    }
    const { invoke } = window.__TAURI__.tauri;

    invoke("execute", { command: states.cmd }).then(() => {
      dispatch({ type: "executed" });
    });
  }, [states.disabled]);

  return (
    <>
      $&nbsp;
      <textarea
        disabled={states.disabled}
        onInput={onInput}
        value={states.cmd}
        autofocus
      />
    </>
  );
};

export default function Execute() {
  return (
    <div>
      <CommandEditor />
    </div>
  );
}

<Output /> の実装

同様に,islands/ 以下に output.tsx を配置します.

本来なら ansi escape を考慮する必要がありますが,ここでは全部無視してしまうことにします.

islands/output.tsx
output.tsx
import { useEffect, useState } from "preact/hooks";
import "../lib/window.ts";

export default function Output() {
  const [output, setOutput] = useState<Uint8Array>(new Uint8Array());
  const [started, setStarted] = useState(false);

  useEffect(() => {
    const appWindow = window.__TAURI__?.window.appWindow;
    if (appWindow === undefined) {
      return;
    }
    appWindow.emit("start").then(() => {
      setStarted(true);
    });
  }, []);

  useEffect(() => {
    if (!started) {
      return;
    }

    const event = window.__TAURI__?.event;
    if (event === undefined) {
      return;
    }
    const once = event.once;
    const unlisten = once("read", (e) => {
      const newOutput = new Uint8Array(output.length + e.payload.read.length);
      newOutput.set(output);
      newOutput.set(e.payload.read, output.length);
      setOutput(newOutput);
    });

    return () => {
      unlisten.then((f) => f());
    };
  });

  const outputStr = new TextDecoder().decode(output);

  return (
    <div>
      Output:

      <Items items={outputStr} />
    </div>
  );
}

const Items = ({ items }: { items: string }) => {
  return (
    <>
      {items.split("\r\n").map((line, i) => <div key={i}>{line}</div>)}
    </>
  );
};

問題点

上記の実装には,いくつかの大きな問題点があります.

  1. Master への read の前にスリーブを挟んでいる.
  2. 出力が数千行規模になると,仮想 DOM(Preact)の処理が重くなる.
  3. キーボードのリマップ(xmodmap 等)に対応できない.

これらの問題点のために,開発を断念せざるを得ませんでした.
良い解決策が見つかれば開発を再開するかもしれません.

Master への read の前にスリーブを挟んでいる.

このスリーブは,setState とコンポーネントの再描画にラグがあるために必要となります.
スリーブがなければ再描画されるまでに複数回 setState が呼び出されてしまい,正しく動作しません.

解決策としては,出力をバックエンド側でキャッシュしておき,必要に応じてフロントエンドから invoke する等でしょうか.

出力が数千行規模になると,仮想 DOM(Preact)の処理が重くなる.

今回は ansi escape を考慮せず実装しましたが,これを真面目に対応しようとすると仮想 DOM が大変になります.

キーボードのリマップ(xmodmap 等)に対応できない.

Deno ランタイムには getLayoutMap() に相当する機能が無いので,キーボードレイアウトの変更に対応するのが難しくなります.

@tauri-apps/api でも見つからないので,Tauri にも無いのでしょう.

まとめ

ということで Tauri と Deno (Fresh) を使いたかっただけのお話でした.

実用性はともかく,Rust 製の新しいツールで遊べたので楽しい経験になりました.

おしまい

Discussion