Rust Monthly Topics

軽量RustフレームワークTauriでデスクトップアプリ開発をはじめよう

TauriはRustで書かれた軽量なGUIフレームワークで、Windows、macOS、Linux向けのデスクトップアプリを開発できます。2022年6月に最初の安定版であるバージョン1.0がリリースされました。

Tauriでは、メインプロセスはRustで記述しますが、UI(User Interface)にはWeb技術を利用します。ReactやVue.jsのようなJavaScriptフレームワークがそのまま使えるので、インタラクティブで見栄えの良いUIを簡単に構築できます。同種のフレームワークにElectronがありますが、後発であるTauriにはインストーラのサイズを小さくできるなどの強みがあります。

Tauriのロゴは、おうし座の二重星であるシータタウリ(θ Tauri)をモチーフ[1]にしており、Webとネイティブアプリの相互作用を意味しています。

図1 Tauriロゴ
図1

本稿では、Tauriを使って簡単なデスクトップアプリ「かんばんボード」を制作します。マウスドラッグでかんばん(カード)を移動できます。UIフレームワークにReactを使用し、SQLiteにデータを保存します。

図2 かんばんボードアプリ
図2

Electronとの比較

アプリを作る前にElectronと比較してみましょう。Electronは、VS CodeやSlackなどのデスクトップアプリの制作にも使われる有名なGUIフレームワークです。TauriはElectronの設計を参考にしていますが、構成要素に違いがあります。

表1 構成要素の比較
フレームワーク UI描画エンジン メインプロセス
Tauri OSが提供するWebViewを使用 Rustで記述したネイティブプログラム
Electron アプリ同梱のChromiumブラウザを使用 JavaScriptで記述し、アプリ同梱のNode.jsで実行

TauriアプリにはChromiumやNode.jsが含まれないため、インストーラのサイズが小さくなります。また、メインプロセスをRustで記述するため、メモリ使用量の面でも有利です。Tauriの公式サイトにベンチマークテストの結果が掲載されているので、その3mb_transfer(3MBのデータを転送する)テストの結果を参照してみると、その差は一目瞭然です。

表2 インストーラサイズの比較
フレームワーク インストーラサイズ メモリ使用量(平均)
Tauri 1.71MB 約450MB
Electron 127.47MB 約590MB

かんばんボードアプリの作成

では、アプリを作っていきましょう。ソフトウェアの構成を以下に示します。

図3 かんばんボードアプリの構成
図3

UIフレームワークにはReactを選択しました。かんばんボードはreact-kanbanというパッケージで実現します。

Tauriでは、UIを表示するWebViewプロセスとRustで記述するメインプロセス(Coreプロセス)が別のOSプロセスとして実行されます。

WebViewプロセスはWebブラウザのエンジンを使って実現されており、UIの描画やJavaScriptの実行を担当します。セキュリティ対策のため、デフォルト設定ではJavaScriptからPCのローカルリソース(ファイル等)へのアクセスはブロックされています。

Coreプロセスはアプリ起動時に最初に立ち上がり、WebViewプロセスを作成・管理します。また、PCのローカルリソースに無制限にアクセスできます。Tauriで作ったアプリでは、Coreプロセスは1つだけ動きますが、WebViewプロセスは必要な数だけ作成できます。

WebViewプロセスとCoreプロセスの間のデータ連携は、主にTauriが提供するIPC(Inter-Process Communication;プロセス間通信)を使って実現します。今回作成するアプリでは、ユーザがWebViewプロセスで作成したかんばんボードのデータをIPCでCoreプロセスに送るようにします。CoreプロセスにはSQLiteデータベースライブラリが埋め込まれており、RustのSQL×クレートを使ってデータを保存します。

なお、記事スペースの関係からすべてのコードを説明できません。ポイントだけ説明しますので、全体についてはGitHubにある筆者のリポジトリを参照してください。

開発環境をセットアップする

開発環境をセットアップしましょう。Rustのstableツールチェーンに加えて、以下のツールが必要です。表を参考にインストールしてください。

表3 必要なツールとインストール方法
ツール Windows macOS Linux
Node.jsとnpm Node.js公式サイトからインストーラを入手して実行する Homebrewをインストール後、brew install nodeを実行する この記事などを参考にインストールする
WebView Windows 11では追加インストールは不要。Windows 10ではWebView2をインストールするMicrosoft公式サイトのEvergreen Bootstrapper) 追加インストールは不要 Tauriのガイドに従ってwebkit2gtkなどをインストールする

tauri-cliというツールも必要です。また、今回はJavaScriptのパッケージマネージャとしてyarnを使用します。それぞれ以下のコマンドでインストールできます。

$ cargo install tauri-cli
$ npm install -g yarn

インストールの確認を兼ねてバージョンを表示しましょう。筆者の環境では以下のように表示されました(macOS arm64を使用⁠⁠。

$ 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

プロジェクトを作成する

プロジェクトを作成しましょう。適当なディレクトリに移動して以下のコマンドを実行します。

$ yarn create tauri-app
図4 create tauri-appの出力例
図4

?で始まる行は質問です。以下のように入力(選択)してください。

表4 プロジェクト作成時の質問
質問 日本語訳 入力(選択)
Project name プロジェクト名 kanban
Choose your package manager パッケージマネージャを選んでください yarn
Choose your UI template UIテンプレートを選んでください react-ts

すべてに回答すると、以下のようなメッセージが表示されます。

Done, Now run:
  cd kanban
  yarn
  yarn tauri dev

指示どおりコマンドを入力してください。

  1. cd kanban
  2. yarn
    • JavaScriptのパッケージがダウンロードされる
  3. yarn tauri dev
    • Rustのパッケージ(クレート)がダウンロードされ、アプリがビルドされる

ビルドに成功すると、アプリが起動してウィンドウが表示されます。

図5 アプリのウィンドウ
図5

このあとコードを記述していきますが、アプリは起動したままでかまいません。再起動しなくても、ホットリローディングにより変更が反映されるはずです。もしアプリを終了したいときはメニューから終了を選ぶか、コマンドを実行したターミナルで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/src/main.rsを開くとrust-analyzerがエラーを出力するかもしれません。以下のコマンドを実行してエラーを消しておきましょう。

$ yarn build

react-kanbanパッケージを追加する

react-kanbanパッケージを追加しましょう。

$ yarn add @asseinfo/react-kanban

依存ライブラリのバージョンなどについて警告メッセージ(warning)が表示されるかもしれませんが、無視してかまいません。

src/App.tsxを編集します。コードの内容はコメントで説明します。サンプルコード

// 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/main.tsxを以下のように修正する必要がありました。もし同じ症状が出たら修正してください。

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  // <React.StrictMode>   // この行をコメントアウト
    <App />
  // </React.StrictMode>  // この行をコメントアウト
);

原因はreact-kanbanが依存するドラッグ&ドロップ・ライブラリに問題があり、Reactのstrictモードで動作しないためでした。strictモードの解除で回避しています。

ソースファイルを保存するとホットリローディングが起こり、かんばんボードが表示されるはずです。

図6 かんばんボードが表示された
図6

カードの追加、削除、マウスドラッグによるカードの移動ができるか試してみてください。

Rustとデータ連携する

ここまでの機能はWebViewプロセスのReact/TypeScript内で完結していて、Rustで記述するCoreプロセスと連携していません。ボード情報をSQLiteに保存するには、IPCを使ってデータをCoreプロセスへ連携する必要があります。IPCの部分を実装しましょう。

連携するデータ型をCoreプロセスに追加します。かんばんのデータ構造を表現するには以下の型が必要です。

表5 Coreプロセスとの連携に必要となる型
説明
Board ボートを表す。任意個のColumnを持つ
Column カードのカラム(縦の列によるグループ)を表す。任意個のCardを持つ
Card カードを表す
CardPos カードの位置を表す(どのカラムの何番目のカードか)

以下をsrc-tauri/src/main.rsに追加します。

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(...)]アトリビュートにSerializeDeserializeを指定することで、Rustで定義したデータをJSON形式に変換できるようになります。

なお、ColumnCardのデータを簡単に作れるように、サンプルコードではnew関数やadd_cardメソッドを定義してあります。

次にIPCを処理する関数(コマンドハンドラ)を定義します。#[tauri::command]アトリビュートを付けます。

// ボードのデータを作成して返すハンドラ
#[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_boardhandle_add_cardのみ掲載しましたが、handle_move_cardhandle_remove_cardも追加してください。

これらのハンドラ関数をtauri::Builderinvoke_handlerで登録します。

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/App.tsxにIPCのためのコードを追加します。@tauri-apps/apiモジュールのinvoke関数を使います。例としてRustのhandle_add_cardハンドラを呼び出す部分を掲載します。サンプルコードを参照してください。

// 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 })
};

同様にhandleMoveCardhandleRemoveCardget_boardの呼び出しも追加してください。なお、サンプルコードでは、かんばんボードを表す型の定義TBoard等)も追加してます。

追加できたらアプリのウィンドウに戻り、カードの追加、移動、削除を試してみてください。連携されたカードの情報がアプリを起動したターミナルに表示されるはずです。

図7 ターミナル出力
図7

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_cardsテーブルを持たせました。

図8 かんばんボードアプリのテーブル構成
図8

テーブルを作成するためのマイグレーションSQLを書きましょう。src-tauriディレクトリ内にdbディレクトリを作り、その中に000_init.sqlファイルを作ります。サンプルコードを参照してください。

-- 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/main.rsに書いてもよいのですが、記述する量が多いので別のモジュールに分けましょう。src-tauri/src/database.rsというファイルを作成して、そこに書いていきます。サンプルコードを参照してください。

テーブルにはコネクションプールを通してアクセスします。コネクションプールを作成する関数と、それを使ってマイグレーション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_columnsinsert_cardmove_carddelete_cardなどが必要です。たとえばinsert_cardの定義は以下のようになります。

// 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/src/main.rsmain関数を修正します。サンプルコードを参照してください。

// 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::SqlitePool型のコネクションプールを作成し、tauri::Builderapp.manageメソッドに渡しています。こうすることで、handle_add_cardなどのハンドラからコネクションプールにアクセスできるようになります。

追記 ⁠2023年1月)

上記コードですが、元のサンプルコードではWindows環境でSQLiteデータベースの作成に失敗するというご指摘があり、サンプルコードの方を修正しました。std::fs::canonicalize関数が返すWindows UNCパスの扱いに問題があり、dunceクレートのdunce::canonicalize関数を使うことで解決しています。

詳しくはこちらのPull Requestをご覧ください。

なお、dunceクレートを使うためにはCargo.tomlに追加する必要があります。ターミナルでsrc-tauriディレクトリに移動し、cargo add dunceを実行してください。

handle_add_card関数を以下のように修正します。コネクションプールにはState<'_, sqlx::SqlitePool>型の引数でアクセスできます。

// 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.rsdatabase.rsを完成させてください。なお、App.tsxの変更は不要です。

アプリに戻り、カードを追加してみてください。アプリをいったん終了して立ち上げ直しても終了直前の状態に復元されるはずです。

SQLiteのDBファイルはホームディレクトリ直下のgihyo-kanban-dbディレクトリに保存されます。DBを初期化したいときは、それを削除してください。

インストーラを作成する

最後にアプリのインストーラを作成しましょう。src-tauri/tauri.conf.jsonの中にあるtauri.bundle.identifier属性の値"com.tauri.dev"を別のものに変更してから、以下のコマンドを実行します。

$ yarn tauri build

これにより、いま使っているOS向けのインストーラが作られます。筆者のmacOS環境ではインストーラ(.dmgファイル)のサイズはわずか4.1MBになりました。

他の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の入門なども兼ねてデスクトップアプリの開発に挑戦してみてください。

おすすめ記事

記事・ニュース一覧