[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 +Rust4)、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 にあるものを使う作りです。
大きさとビルド時間
| ビルド | 実行ファイルの大きさ | 所要時間 |
|---|---|---|
| debug | 196,305,400 バイト | 初回 4 分 4 秒 |
| release | 11,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)で、実際に実行した結果です。
tauri2.12.5、tauri-build2.7.4、tauri-utils2.10.2、wry0.57.1、tao0.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回・完結)
- Rust とは / 環境構築 / Hello World
- 変数・型・関数(Java との対比)
- 所有権(ムーブ)
- 借用と参照
- 構造体・enum・match
- Result / Option とエラー処理
- トレイトとジェネリクス(Java の interface との違い)
- ライフタイム入門と、総まとめの CLI
実践編(8回・予定。内容は、書きながら変わる可能性があります)
- Cargo・クレート・モジュール構成
- ファイル操作・パス・文字コード
- 小さな CLI を作り込む(簡易アーカイバ)
- Tauri v2 の構造(フロントと Rust の役割分担)(今回・連載の第12回)
- コマンドとイベントの橋渡し
- グローバルホットキー常駐(shotkey を分解)
- 設定の保存・トレイ・自動起動
- ビルド・署名・GitHub リリース