TauriはRustで書かれた軽量なGUIフレームワークで、Windows、macOS、Linux向けのデスクトップアプリを開発できます。2022年6月に最初の安定版であるバージョン1.
Tauriでは、メインプロセスはRustで記述しますが、UI
Tauriのロゴは、おうし座の二重星であるシータタウリ
本稿では、Tauriを使って簡単なデスクトップアプリ
Electronとの比較
アプリを作る前にElectronと比較してみましょう。Electronは、VS CodeやSlackなどのデスクトップアプリの制作にも使われる有名なGUIフレームワークです。TauriはElectronの設計を参考にしていますが、構成要素に違いがあります。
フレームワーク | UI描画エンジン | メインプロセス |
---|---|---|
Tauri | OSが提供するWebViewを使用 | Rustで記述したネイティブプログラム |
Electron | アプリ同梱のChromiumブラウザを使用 | JavaScriptで記述し、アプリ同梱のNode. |
TauriアプリにはChromiumやNode.
フレームワーク | インストーラサイズ | メモリ使用量 |
---|---|---|
Tauri | 1. |
約450MB |
Electron | 127. |
約590MB |
かんばんボードアプリの作成
では、アプリを作っていきましょう。ソフトウェアの構成を以下に示します。
UIフレームワークにはReactを選択しました。かんばんボードはreact-kanbanというパッケージで実現します。
Tauriでは、UIを表示するWebViewプロセスとRustで記述するメインプロセス
WebViewプロセスはWebブラウザのエンジンを使って実現されており、UIの描画やJavaScriptの実行を担当します。セキュリティ対策のため、デフォルト設定ではJavaScriptからPCのローカルリソース
Coreプロセスはアプリ起動時に最初に立ち上がり、WebViewプロセスを作成・
WebViewプロセスとCoreプロセスの間のデータ連携は、主にTauriが提供するIPC
なお、記事スペースの関係からすべてのコードを説明できません。ポイントだけ説明しますので、全体についてはGitHubにある筆者のリポジトリを参照してください。
開発環境をセットアップする
開発環境をセットアップしましょう。Rustのstableツールチェーンに加えて、以下のツールが必要です。表を参考にインストールしてください。
ツール | Windows | macOS | Linux |
---|---|---|---|
Node. |
Node. |
Homebrewをインストール後、brew install node を実行する |
この記事などを参考にインストールする |
WebView | Windows 11では追加インストールは不要。Windows 10ではWebView2をインストールする |
追加インストールは不要 | Tauriのガイドに従ってwebkit2gtk などをインストールする |
tauri-cliというツールも必要です。また、今回はJavaScriptのパッケージマネージャとしてyarnを使用します。それぞれ以下のコマンドでインストールできます。
$ cargo install tauri-cli $ npm install -g yarn
インストールの確認を兼ねてバージョンを表示しましょう。筆者の環境では以下のように表示されました
$ rustc -V rustc 1.63.0 (4b91a6ea7 2022-08-08) $ node -v v18.8.0 $ yarn -v 1.22.19 $ cargo-tauri -V tauri-cli 1.0.5
プロジェクトを作成する
プロジェクトを作成しましょう。適当なディレクトリに移動して以下のコマンドを実行します。
?
で始まる行は質問です。以下のように入力
質問 | 日本語訳 | 入力 |
---|---|---|
Project name | プロジェクト名 | kanban |
Choose your package manager | パッケージマネージャを選んでください | yarn |
Choose your UI template | UIテンプレートを選んでください | react-ts |
すべてに回答すると、以下のようなメッセージが表示されます。
Done, Now run: cd kanban yarn yarn tauri dev
指示どおりコマンドを入力してください。
cd kanban
yarn
- JavaScriptのパッケージがダウンロードされる
yarn tauri dev
- Rustのパッケージ
(クレート) がダウンロードされ、アプリがビルドされる
- Rustのパッケージ
ビルドに成功すると、アプリが起動してウィンドウが表示されます。
このあとコードを記述していきますが、アプリは起動したままでかまいません。再起動しなくても、ホットリローディングにより変更が反映されるはずです。もしアプリを終了したいときはメニューから終了を選ぶか、コマンドを実行したターミナルでControl+Cを押します。
ディレクトリ構成は以下のようになります。Reactの標準的なプロジェクト構成にsrc-tauri
ディレクトリが追加され、Rustのプロジェクトが入るような形です。
kanban ├── index.html ├── node_modules ├── package.json ├── public │ ├── tauri.svg │ └── vite.svg ├── README.md ├── src │ ├── App.css │ ├── App.tsx │ ├── assets │ │ └── react.svg │ ├── main.tsx │ ├── style.css │ └── vite-env.d.ts ├── src-tauri │ ├── build.rs │ ├── Cargo.toml │ ├── icons │ │ ├── 32x32.png │ │ ├── 128x128.png │ │ └── ... │ ├── src │ │ └── main.rs │ └── tauri.conf.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock
なお、VS Codeなどでsrc-tauri/
を開くとrust-analyzerがエラーを出力するかもしれません。以下のコマンドを実行してエラーを消しておきましょう。
$ yarn build
react-kanbanパッケージを追加する
react-kanbanパッケージを追加しましょう。
$ yarn add @asseinfo/react-kanban
依存ライブラリのバージョンなどについて警告メッセージ
src/
を編集します。コードの内容はコメントで説明します。
// react-kanbanをインポートする
// 型定義ファイル(.d.ts)がないため、`@ts-ignore`を指定することで
// TypeScriptのエラーを抑止している
// @ts-ignore
import Board from '@asseinfo/react-kanban';
import '@asseinfo/react-kanban/dist/styles.css';
// かんばんボードに最初に表示するデータを作成する
const board = {
columns: [
{
id: 0,
title: 'バックログ',
cards: [
{
id: 0,
title: 'かんばんボードを追加する',
description: 'react-kanbanを使用する'
},
]
},
{
id: 1,
title: '開発中',
cards: []
}
]
}
// かんばんボードコンポーネントを表示する
function App() {
return (
<>
<Board
// ボードの初期データ
initialBoard={board}
// カードの追加を許可(トップに「+」ボタンを表示)
allowAddCard={{ on: "top" }}
// カードの削除を許可
allowRemoveCard
// カラム(カードのグループ)のドラッグをオフにする
disableColumnDrag
// 新しいカードの作成時、idに現在時刻の数値表現をセットする
onNewCardConfirm={(draftCard: any) => ({
id: new Date().getTime(),
...draftCard
})}
// 新しいカードが作成されたら、カード等の内容をコンソールに表示する
onCardNew={console.log}
// カードがドラッグされたら、カード等の内容をコンソールに表示する
onCardDragEnd={console.log}
// カードが削除されたら、カード等の内容をコンソールに表示する
onCardRemove={console.log}
/>
</>
)
}
export default App;
筆者の環境ではカードがドラッグできないという問題が起こり、src/
を以下のように修正する必要がありました。もし同じ症状が出たら修正してください。
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
// <React.StrictMode> // この行をコメントアウト
<App />
// </React.StrictMode> // この行をコメントアウト
);
原因はreact-kanbanが依存するドラッグ&ドロップ・
ソースファイルを保存するとホットリローディングが起こり、かんばんボードが表示されるはずです。
カードの追加、削除、マウスドラッグによるカードの移動ができるか試してみてください。
Rustとデータ連携する
ここまでの機能はWebViewプロセスのReact/
連携するデータ型をCoreプロセスに追加します。かんばんのデータ構造を表現するには以下の型が必要です。
型 | 説明 |
---|---|
Board |
ボートを表す。任意個のColumn を持つ |
Column |
カードのカラムCard を持つ |
Card |
カードを表す |
CardPos |
カードの位置を表す |
以下をsrc-tauri/
に追加します。
use serde::{Deserialize, Serialize};
/// ボード
#[derive(Debug, Serialize, Deserialize)]
pub struct Board {
columns: Vec<Column>,
}
/// カラム
#[derive(Debug, Serialize, Deserialize)]
pub struct Column {
id: i64,
title: String,
cards: Vec<Card>,
}
/// カード
#[derive(Debug, Serialize, Deserialize)]
pub struct Card {
id: i64,
title: String,
description: Option<String>,
}
/// カードの位置
#[derive(Debug, Serialize, Deserialize)]
pub struct CardPos {
#[serde(rename = "columnId")]
column_id: i64,
position: i64,
}
TauriのIPCでは、JSON RPCに似たプロトコルを使っており、JSON形式のデータをやりとりします。#[derive(...)]
アトリビュートにSerialize
とDeserialize
を指定することで、Rustで定義したデータをJSON形式に変換できるようになります。
なお、Column
やCard
のデータを簡単に作れるように、サンプルコードではnew
関数やadd_
メソッドを定義してあります。
次にIPCを処理する関数#[tauri::
アトリビュートを付けます。
// ボードのデータを作成して返すハンドラ
#[tauri::command]
fn get_board() -> Result<Board, String> {
let mut col0 = Column::new(0, "バックログ");
col0.add_card(Card::new(0, "かんばんボードを追加する", Some("react-kanbanを使用する")));
let col1 = Column::new(1, "開発中");
let board = Board { columns: vec![col0, col1] };
Ok(board)
}
/// カードの追加直後に呼ばれるハンドラ
#[tauri::command]
async fn handle_add_card(card: Card, pos: CardPos) -> Result<(), String> {
// IPCで受信したデータをデバッグ表示する
println!("handle_add_card ----------");
dbg!(&card);
dbg!(&pos);
Ok(())
}
get_
とhandle_
のみ掲載しましたが、handle_
、handle_
も追加してください。
これらのハンドラ関数をtauri::
のinvoke_
で登録します。
fn main() {
tauri::Builder::default()
// ハンドラを登録する。(元々あったgreetハンドラは削除した)
.invoke_handler(tauri::generate_handler![
get_board,
handle_add_card,
handle_move_card,
handle_remove_card
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
src/
にIPCのためのコードを追加します。@tauri-apps/
モジュールのinvoke
関数を使います。例としてRustのhandle_
ハンドラを呼び出す部分を掲載します。サンプルコードを参照してください。
// Tauriが提供するinvoke関数をインポートする
import { invoke } from '@tauri-apps/api'
// カードの追加直後に呼ばれるハンドラ
async function handleAddCard(board: TBoard, column: TColumn, card: TCard) {
const pos = new CardPos(column.id, 0);
// IPCでCoreプロセスのhandle_add_cardを呼ぶ(引数はJSON形式)
await invoke<void>("handle_add_card", { "card": card, "pos": pos })
};
同様にhandleMoveCard
、handleRemoveCard
、get_
の呼び出しも追加してください。なお、サンプルコードでは、かんばんボードを表す型の定義TBoard
等)
追加できたらアプリのウィンドウに戻り、カードの追加、移動、削除を試してみてください。連携されたカードの情報がアプリを起動したターミナルに表示されるはずです。
SQLiteにデータを保存する
かんばんボードのデータをSQLiteデータベースに保存しましょう。SQLxクレートを使用します。SQLxはRustでSQLデータベースを操作するためのライブラリで、SQLite、PostgreSQL、MySQLなどに対応しています。ORマッパではないので自分でSQLを書く必要がありますが、APIがシンプルで理解しやすいのが特徴です。
ターミナルでsrc-tauri
ディレクトリに移動し、cargo add
で依存クレートを追加します。
## SQLxクレートと関連クレートを追加する $ cargo add sqlx --features 'runtime-tokio-rustls, sqlite, migrate' $ cargo add tokio --features full $ cargo add futures ## ホームディレクトリのパスの取得に必要なクレートを追加する $ cargo add directories
テーブル構成は以下のとおりです。カードの位置を表現するために、joinテーブルとしてcolumns_
テーブルを持たせました。
テーブルを作成するためのマイグレーションSQLを書きましょう。src-tauri
ディレクトリ内にdb
ディレクトリを作り、その中に000_
ファイルを作ります。サンプルコードを参照してください。
-- columnテーブルを作成する
CREATE TABLE IF NOT EXISTS columns (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL
);
-- cardsテーブルを作成する
CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
description TEXT
);
-- columns_cardsテーブルを作成する
(略)
-- サンプルデータを挿入する
(略)
テーブルにアクセスするコードはsrc-tauri/
に書いてもよいのですが、記述する量が多いので別のモジュールに分けましょう。src-tauri/
というファイルを作成して、そこに書いていきます。サンプルコードを参照してください。
テーブルにはコネクションプールを通してアクセスします。コネクションプールを作成する関数と、それを使ってマイグレーションSQLを実行する関数を追加します。
// src-tauri/src/database.rs
use std::{collections::BTreeMap, str::FromStr};
use futures::TryStreamExt;
use sqlx::{
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
Row, Sqlite, SqlitePool, Transaction,
};
/// このモジュール内の関数の戻り値型
type DbResult<T> = Result<T, Box<dyn std::error::Error>>;
/// SQLiteのコネクションプールを作成して返す
pub(crate) async fn create_sqlite_pool(database_url: &str) -> DbResult<SqlitePool> {
// コネクションの設定
let connection_options = SqliteConnectOptions::from_str(database_url)?
// DBが存在しないなら作成する
.create_if_missing(true)
// トランザクション使用時の性能向上のため、WALを使用する
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal);
// 上の設定を使ってコネクションプールを作成する
let sqlite_pool = SqlitePoolOptions::new()
.connect_with(connection_options)
.await?;
Ok(sqlite_pool)
}
/// マイグレーションを行う
pub(crate) async fn migrate_database(pool: &SqlitePool) -> DbResult<()> {
sqlx::migrate!("./db").run(pool).await?;
Ok(())
}
テーブルにアクセスする関数を追加します。get_
、insert_
、move_
、delete_
などが必要です。たとえばinsert_
の定義は以下のようになります。
// src-tauri/src/database.rs
/// posで指定した位置にカードを挿入する
pub(crate) async fn insert_card(pool: &SqlitePool, card: Card, pos: CardPos) -> DbResult<()> {
// トランザクションを開始する
let mut tx = pool.begin().await?;
// cardsテーブルにカードを挿入する
sqlx::query("INSERT INTO cards (id, title, description) VALUES (?, ?, ?)")
.bind(card.id)
.bind(card.title)
.bind(card.description)
.execute(&mut tx)
.await?;
// columns_cardsテーブルに、カードの位置を表す情報を挿入する
insert_card_position(&mut tx, pos.column_id, card.id, pos.position).await?;
// トランザクションをコミットする
tx.commit().await?;
Ok(())
}
src-tauri/
のmain
関数を修正します。サンプルコードを参照してください。
// src-tauri/src/main.rs
use tauri::{Manager, State};
pub(crate) mod database;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// このmain関数はasync fnではないので、asyncな関数を呼ぶのにblock_on関数を使う
use tauri::async_runtime::block_on;
// データベースのファイルパス等を設定する
// ...(略)...
// データベースファイルが存在するかチェックする
let db_exists = std::fs::metadata(&database_file).is_ok();
// 存在しないなら、ファイルを格納するためのディレクトリを作成する
if !db_exists {
std::fs::create_dir(&database_dir)?;
}
// SQLiteのコネクションプールを作成する
let sqlite_pool = block_on(database::create_sqlite_pool(&database_url))?;
// データベースファイルが存在しなかったなら、マイグレーションSQLを実行する
if !db_exists {
block_on(database::migrate_database(&sqlite_pool))?;
}
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
// ...(略)...
])
// ハンドラからコネクションプールにアクセスできるよう、登録する
.setup(|app| {
app.manage(sqlite_pool);
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
Ok(())
}
sqlx::
型のコネクションプールを作成し、tauri::
のapp.
メソッドに渡しています。こうすることで、handle_
などのハンドラからコネクションプールにアクセスできるようになります。
追記: (2023年1月)
上記コードですが、元のサンプルコードではWindows環境でSQLiteデータベースの作成に失敗するというご指摘があり、サンプルコードの方を修正しました。std::
関数が返すWindows UNCパスの扱いに問題があり、dunceクレートのdunce::
関数を使うことで解決しています。
詳しくはこちらのPull Requestをご覧ください。
なお、dunceクレートを使うためにはCargo.
に追加する必要があります。ターミナルでsrc-tauri
ディレクトリに移動し、cargo add dunce
を実行してください。
handle_
関数を以下のように修正します。コネクションプールにはState<'_, sqlx::
型の引数でアクセスできます。
// src-tauri/src/main.rs
// SQLxが提供する`async`関数を使用するために`async fn`に変更する
// sqlite_pool引数を追加する
#[tauri::command]
async fn handle_add_card(
sqlite_pool: State<'_, sqlx::SqlitePool>,
card: Card,
pos: CardPos,
) -> Result<(), String> {
database::insert_card(&*sqlite_pool, card, pos)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
サンプルコードを参照してmain.App.
の変更は不要です。
アプリに戻り、カードを追加してみてください。アプリをいったん終了して立ち上げ直しても終了直前の状態に復元されるはずです。
SQLiteのDBファイルはホームディレクトリ直下のgihyo-kanban-db
ディレクトリに保存されます。DBを初期化したいときは、それを削除してください。
インストーラを作成する
最後にアプリのインストーラを作成しましょう。src-tauri/
の中にあるtauri.
属性の値"com.
)
$ yarn tauri build
これにより、いま使っているOS向けのインストーラが作られます。筆者のmacOS環境ではインストーラ
他のOS向けのインストーラを作るにはbuildコマンドをそのOS上で実行する必要があります。Tauriプロジェクトが提供するGitHub Actionを使うのが楽でしょう。詳しくは公式ドキュメントを参照してください。
まとめ
この記事ではTauriを使ってかんばんボードを作成しました。UIにReactを使うことで、コードをあまり書かずに目的の機能を実装できました。また、インストーラが小さくなったり、Rustを使うことで省メモリだったりと、Electronに対する優位性があることも紹介しました。
いろいろと期待が高まりますが、Tauriは開発が始まってからまだ2年半ほどの若いソフトウェアです。10年近い歴史のあるElectronと比べると未成熟なところがあります。GitHubのIssueを眺めると、WindowsのWebView2がごく最近のバージョンでないと動かない、特定のLinuxディストリビューションでWebViewの挙動がおかしいといった報告が目立ちます。現時点では多数のユーザに広く配布するようなアプリの開発に使うのは難しいでしょう。社内アプリや個人向けのアプリなど、ユーザ数が少なくて環境をコントロールしやすいところから始めるのが無難だといえそうです。
一方で執筆時点のGitHubスター数を見ると、Tauriは約4万9千、Electronは約10万3千となっています。Electronの4分の1の開発期間で半数のスターを獲得していますので、ユーザからの期待は非常に高いといえそうです。現在も活発な開発が続けられており、今後の成長が楽しみなソフトウェアです。
筆者の個人的な感想になりますが、GUIのあるアプリを開発するのは成果が見えやすくて楽しいと感じました。もしTauriに興味を持っていただけたなら、Rustの入門なども兼ねてデスクトップアプリの開発に挑戦してみてください。