[Rust 実践] #12 Tauri v2 の構造 — フロントと Rust の役割分担

[Rust 実践] #12 Tauri v2 の構造 — フロントと Rust の役割分担

はじめに

今回から、画面を持つアプリに入ります。使うのは Tauri v2 です。画面は HTML と JavaScript で作り、裏側(ファイル、OS、計算)は Rust で書く、という分け方をするフレームワークです。

今回は、機能を作り込まず、どこまでが画面で、どこからが Rust かを、最小のアプリで確かめます。

  • 作ったもの: Rust の関数を 3 つ持つ、小さなウィンドウのアプリ
  • 確かめたこと: 画面から Rust を呼ぶ、エラーを返す、呼び出しを権限で止める、プロセスの構成を見る

構成を先に

hello/
├── dist/
│   └── index.html           ← 画面(HTML と JavaScript)
└── src-tauri/
    ├── Cargo.toml           ← Rust のプロジェクト(第9回と同じ)
    ├── build.rs             ← ビルド時に tauri-build を呼ぶだけ
    ├── tauri.conf.json      ← アプリの設定(名前、窓、画面の置き場所)
    ├── capabilities/
    │   └── default.json     ← 画面に許す権限
    ├── icons/icon.png
    └── src/
        ├── main.rs          ← 入口(lib.rs の run() を呼ぶだけ)
        └── lib.rs           ← Rust 側の本体(コマンドを登録)

gen/ ディレクトリは、ビルドで自動生成されます。

上の図の中で、Rust のプロジェクトは src-tauri/ だけです。dist/ の中身は、Rust から見ると、ただの静的なファイルです。

設定ファイルを見る

Cargo.toml

[package]
name = "hello-tauri"
version = "0.1.0"
edition = "2024"

[lib]
name = "hello_tauri_lib"
crate-type = ["staticlib", "cdylib", "rlib"]

[build-dependencies]
tauri-build = { version = "2.7.4", features = [] }

[dependencies]
tauri = { version = "2.12.5", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

第9回の Cargo.toml と、同じ形です。tauri が普通のクレートとして入るだけです。[lib] の crate-type は、lib.rs をライブラリとしてビルドするための指定です。今回は Linux だけで、この指定の意味までは確かめていません。

tauri.conf.json

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "hello-tauri",
  "version": "0.1.0",
  "identifier": "com.example.hello-tauri",
  "build": {
    "frontendDist": "../dist"
  },
  "app": {
    "withGlobalTauri": true,
    "windows": [
      { "title": "hello-tauri", "width": 480, "height": 320 }
    ],
    "security": { "csp": null }
  },
  "bundle": {
    "active": false,
    "icon": ["icons/icon.png"]
  }
}

画面のファイルの置き場所は、frontendDist で決めます。withGlobalTauri を true にすると、JavaScript から window.__TAURI__ で呼べるようになります。今回は、ビルドツール(Vite など)を使わず、素の HTML で確かめるために付けました。"csp": null は、動作確認を簡単にするために、セキュリティの制限を外しています。実際のアプリでは、外さないほうがよい設定です。

capabilities/default.json

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "main window permissions",
  "windows": ["main"],
  "permissions": ["core:default"]
}

「main という窓の画面に、core:default の権限を与える」という意味です。この意味は、あとで実際に外して確かめます。

Rust 側: コマンドを登録する

src/lib.rs の全体です。

use serde::Serialize;

#[derive(Serialize)]
struct Stats {
    chars: usize,
    bytes: usize,
    lines: usize,
}

// Rust 側の関数。フロントから invoke("count_text", { text }) で呼ばれる。
#[tauri::command]
fn count_text(text: String) -> Stats {
    Stats {
        chars: text.chars().count(),
        bytes: text.len(),
        lines: text.lines().count(),
    }
}

// Result の Err は、フロントでは Promise の reject になる。
#[tauri::command]
fn parse_port(s: &str) -> Result<u16, String> {
    s.trim().parse::<u16>().map_err(|e| format!("'{s}' は port になりません: {e}"))
}

// 動作確認用: フロントから結果を受け取って標準出力に出し、アプリを終了する。
#[tauri::command]
fn report(app: tauri::AppHandle, line: String) {
    println!("[frontend] {line}");
    if line == "done" {
        app.exit(0);
    }
}

pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![count_text, parse_port, report])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

見るところは、次の 3 つです。

  • #[tauri::command]: 普通の関数に付けると、画面から呼べる「コマンド」になります。第6回の Result も、第5回の derive も、そのまま使えています。
  • generate_handler![...]: 呼べる関数の一覧です。ここに書かない関数は、画面から呼べません。
  • generate_context!(): tauri.conf.json の内容などを、アプリに取り込みます。frontendDist のファイルの扱いは、今回は、確かめていません。

戻り値の型 Stats は、Serialize を付けておくと、JavaScript のオブジェクト(JSON)になって届きます。

src/main.rs は、次の 5 行です。

// リリースビルドで Windows のコンソール窓を出さない
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
    hello_tauri_lib::run()
}

本体を lib.rs に置いて、main.rs は呼ぶだけにする形です。第9回の「ファイルを分ける」の、一番小さい例です。

画面側: invoke で呼ぶ

dist/index.html です。

<!doctype html>
<html lang="ja"><head><meta charset="utf-8"><title>hello-tauri</title></head>
<body>
<p id="out">起動中…</p>
<script>
const { invoke } = window.__TAURI__.core;
(async () => {
  const log = (s) => invoke("report", { line: s });
  try {
    const s = await invoke("count_text", { text: "こんにちは\nRust" });
    await log("count_text -> " + JSON.stringify(s));
    await log("parse_port(8080) -> " + await invoke("parse_port", { s: "8080" }));
    try { await invoke("parse_port", { s: "99999" }); }
    catch (e) { await log("parse_port(99999) rejected -> " + e); }
    try { await invoke("no_such_command"); }
    catch (e) { await log("no_such_command rejected -> " + e); }
    try { await invoke("count_text", { txt: "x" }); }
    catch (e) { await log("wrong arg name rejected -> " + e); }
  } catch (e) { await log("ERROR " + e); }
  await log("done");
})();
</script></body></html>

invoke("コマンド名", { 引数名: 値 }) が、Rust の関数呼び出しに当たります。結果は Promise で返るので、await で受けます。

今回は、画面の中の結果を目で見る代わりに、report コマンドで Rust 側の標準出力に流しています。画面を映さない環境でも、確かめられるようにするためです。

動かして確かめる

ビルドして、起動する

Linux では、画面の部分に WebKitGTK を使うので、開発用のライブラリが必要です。この環境には入っていなかったので、apt で入れました。

apt-get install -y --no-install-recommends \
  libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \
  librsvg2-dev libsoup-3.0-dev build-essential pkg-config

入ったのは、webkit2gtk 2.52.6 と gtk+-3.0 3.24.41 です(pkg-config で確認)。

cd src-tauri
cargo build

最初のビルドは 4 分 4 秒でした(依存は、Cargo.lock に 407 件)。画面のない環境なので、仮想ディスプレイ(Xvfb)の上で起動しました。

export WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 \
       WEBKIT_DISABLE_DMABUF_RENDERER=1 LIBGL_ALWAYS_SOFTWARE=1
xvfb-run -a dbus-run-session -- ./target/debug/hello-tauri

3 つの環境変数は、この環境(コンテナの中の root)で動かすために付けたものです。普段のデスクトップで、付ける必要があるかどうかは、確かめていません。

結果は、次のとおりでした(途中の警告は省略)。

[frontend] count_text -> {"chars":10,"bytes":20,"lines":2}
[frontend] parse_port(8080) -> 8080
[frontend] parse_port(99999) rejected -> '99999' は port になりません: number too large to fit in target type
[frontend] no_such_command rejected -> Command no_such_command not found
[frontend] wrong arg name rejected -> invalid args `text` for command `count_text`: command count_text missing required key text
[frontend] done

終了コードは 0 でした。順に見ます。

  • count_text: "こんにちは\nRust" は、10 文字、20 バイト(日本語 5 文字 × 3 バイト + 改行 1 + Rust 4)、2 行です。第10回の「文字数とバイト数は別」が、そのまま出ています。Stats は JSON のオブジェクトで届きました。
  • parse_port(8080): Ok(8080) は、そのまま値として届きました。
  • parse_port(99999): Err(String) は、Promise の reject になり、catch に文字列が届きました。第6回で ? を使って Rust 内で伝えてきたエラーが、ここで画面のエラー処理につながります。
  • 存在しないコマンド: Command no_such_command not found で reject されます。
  • 引数名の間違い: Rust は text なのに、画面が txt で呼ぶと、missing required key text で reject されます。引数名は、文字列で合わせています。コンパイラは、画面側の綴りの間違いを、見つけてくれません。

権限(capabilities)を外す

Tauri の標準の機能(コア)も、画面からは invoke で呼びます。たとえば、アプリのバージョンを返す plugin:app|version です。core:default を与えたときと、permissions を空にしたときを、比べました。

== core:default
[frontend] app|version -> 0.1.0
== permissions: []
[frontend] app|version rejected -> app.version not allowed. Permissions associated with this command: core:app:allow-version, core:app:default

権限がないと、not allowed で拒否され、必要な権限の名前まで教えてくれます。一方で、自分で書いた report コマンドは、permissions が空のときも呼べていて、結果が出力されています(このときは count_text などは呼んでいません)。自分で書いたコマンドにも権限を要求する設定があるようですが、今回は試していません。

プロセスを見る

画面を出したまま、ps で親子関係を見ました。

 7467  7465 hello-tauri
 7477  7467 WebKitNetworkPr
 7481  7467 WebKitWebProces

hello-tauri(Rust のプロセス)の子として、WebKitNetworkProcess と WebKitWebProcess(名前は ps の表示で切れています)が動いていました。HTML と JavaScript は、後者の中で動いています。Rust と画面は、別のプロセスで、invoke は、その間をまたぐ呼び出しです。

また、ldd で、実行ファイルが libwebkit2gtk-4.1.so.0 と libgtk-3.so.0 を使っていることを確認しました。ブラウザそのものは同梱せず、OS にあるものを使う作りです。

大きさとビルド時間

ビルド実行ファイルの大きさ所要時間
debug196,305,400 バイト初回 4 分 4 秒
release11,147,712 バイト初回 6 分 14 秒

release は約 11 MB でした。ただし、WebKitGTK は OS 側のライブラリなので、この数字には、含まれていません。配布するときは、利用者の環境に、そのライブラリが入っていることが前提になります。

Java と並べる

今回は、Java のコードは動かしていません。Java で画面と処理を分けるなら、JavaFX の WebView が近い選択肢ですが、この環境には入っておらず、確認していません。ここでは、動かした範囲の、Rust 側の見取り図だけを書きます。

見るところ今回の Tauri(確認したこと)
呼べる関数#[tauri::command] を付けて、generate_handler! に並べたものだけ
引数と戻り値Serialize などで JSON に変換して渡す。引数名は文字列で一致させる
エラーResult の Err が、画面の reject になる
型の保証Rust 側は、コンパイル時に確認される。画面側の綴りは、実行して初めて分かる

設計の考え方

動かした結果から、役割の分け方を、次のようにまとめられます。

  • 画面側は、表示と入力だけ: 文字数を数える、ポート番号を検証するといった判断は、Rust 側に置くと、型とテスト(第9回の cargo test)が使えます。実際、count_text と parse_port は、画面なしで、普通の関数としてテストできます(今回は、テストを書いては確かめていません)。
  • 境界は文字列と JSON: コマンド名と引数名が、画面と Rust の約束です。変えるときは、両方を直します。
  • 権限は、画面が持つもの: どの画面(窓)に、どの機能を許すかは、capabilities に書きます。

初めて出てきた書き方

書き方意味
#[tauri::command]関数を、画面から呼べるコマンドにする
tauri::generate_handler![a, b]呼べるコマンドの一覧を作る
tauri::generate_context!()設定などを、アプリに取り込む
tauri::AppHandleアプリ全体を指すハンドル。コマンドの引数に書くと、受け取れる(今回は exit に使用)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]リリースビルドの Windows で、コンソール窓を出さない(Windows では動かしていません)
invoke("名前", { 引数 })(JavaScript)Rust のコマンドを呼ぶ。Promise を返す

歴史

年月できごと
2020年6月19日最初のリリース(Wikipedia の記述)
2021年5月16日Commons Conservancy の規約の日付(Wikipedia が引用)
2022年6月Tauri 1.0 の公開(Wikipedia の記述。日付はなし)
2024年10月2日Tauri v2 の安定版(公式ブログの投稿日。Wikipedia も同じ日付)

公式ブログによると、v2 では、アクセス制御が、以前の許可リスト(allowlist)から、permissions(権限)、scopes(範囲)、capabilities(窓や画面ごとの権限の割り当て) に変わりました。権限は、Tauri のコマンドの「オン・オフのスイッチ」と説明されています。また、コアの機能の多くが、公式のプラグインに移り、iOS と Android にも対応しました。今回、capabilities/default.json で確かめた仕組みが、この変更に当たると思われますが、1.x で動かしたわけではないので、1.x との違いは確認していません。

画面の表示は WRY、ウィンドウの作成は TAO という部分が担当している、と Wikipedia に書かれています。今回の依存の中にも、wry v0.57.1、tao v0.37.2 がありました(cargo tree で確認)。

動作確認

確認できたこと

  • 上のコード、設定、出力は、すべて Linux(Rust 1.97.0、Cargo 1.97.0、edition 2024)で、実際に実行した結果です。tauri 2.12.5、tauri-build 2.7.4、tauri-utils 2.10.2、wry 0.57.1、tao 0.37.2、webkit2gtk(Rust クレート)2.0.2、システムの webkit2gtk 2.52.6
  • ビルドと、Xvfb の上での起動、画面からの invoke が Rust に届き、結果と、エラー 3 種類(Err、存在しないコマンド、引数名違い)が返ること、終了コード 0
  • permissions を空にすると、plugin:app|version が not allowed で拒否され、core:default では通ること。空のときも、自分で書いた report は呼べたこと
  • 親子プロセスの構成、ldd の結果、debug と release の実行ファイルの大きさとビルド時間
  • 歴史の表の、公式ブログの投稿日(2024年10月2日)

歴史の出典

  • Tauri v2 の安定版の記述は、公式ブログ「Tauri 2.0 Stable Release」(v2.tauri.app)の内容です。取得ツールが返した要約を通して確認しました
  • 2020年6月19日、2022年6月、2021年5月16日、WRY と TAO の役割は、Wikipedia「Tauri (software framework)」のミラーの記述です。同じく要約を通して確認しました。このミラーは「Tauri Foundation」と書いていて、「Tauri Programme」とは書いていません。組織の名前の細かい違いは、確認していません
  • どれも 1 つの出典だけです。全文は読んでいません

確認できていないこと

  • tauri-cli(cargo tauri dev / cargo tauri build)と、Vite などの画面側のビルドツールを使う、普通の開発の流れ。今回は、手で作った最小の構成を、cargo build で動かしました
  • 画面の中を、目で見ること(スクリーンショット)。結果は、report コマンドの標準出力で確認しました
  • dist/ を書き換えたあとに、cargo build をせずに起動したときの挙動。書き換えたあとは、毎回ビルドしてから実行しました
  • 3 つの環境変数なしで、Linux のデスクトップ上で動くかどうか
  • Windows と macOS、iOS、Android
  • Rust の引数名(snake_case)と JavaScript の引数名の対応(大文字小文字の変換)。今回は、1 語の名前(text、s、line)だけを使いました
  • 自分で書いたコマンドに、権限を要求する設定
  • Tauri 1.x の動作と、1.x から 2.x への違い
  • 画面を持たない、count_text などの単体テスト
  • Java(JavaFX の WebView など)側の実行

次回

次回は、コマンドとイベントの橋渡しを扱います。今回は「画面から Rust を呼ぶ」一方向でしたが、次回は、Rust から画面へ知らせる向きと、状態(State)の持ち方を見ます。

連載の目次(予定)

入門編(8回・完結)

  1. Rust とは / 環境構築 / Hello World
  2. 変数・型・関数(Java との対比)
  3. 所有権(ムーブ)
  4. 借用と参照
  5. 構造体・enum・match
  6. Result / Option とエラー処理
  7. トレイトとジェネリクス(Java の interface との違い)
  8. ライフタイム入門と、総まとめの CLI

実践編(8回・予定。内容は、書きながら変わる可能性があります)

  1. Cargo・クレート・モジュール構成
  2. ファイル操作・パス・文字コード
  3. 小さな CLI を作り込む(簡易アーカイバ)
  4. Tauri v2 の構造(フロントと Rust の役割分担)(今回・連載の第12回)
  5. コマンドとイベントの橋渡し
  6. グローバルホットキー常駐(shotkey を分解)
  7. 設定の保存・トレイ・自動起動
  8. ビルド・署名・GitHub リリース