Tauri 2.0とReactで超軽量デスクトップアプリを構築:RustでElectronを置き換える

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

クイックスタート(5分)

ElectronはWeb開発者にとってデスクトップアプリ開発の革命となりましたが、軽量なユーティリティを動かすためだけに120MBものChromiumバイナリを同梱するのは非効率です。Tauri 2.0は、ブラウザエンジンを同梱しないことでこの問題を解決します。代わりに、Reactと高速でメモリ安全なRustバックエンドを組み合わせ、OS標準搭載のWebviewを活用します。

わずか5分ほどで実際に動作するデスクトップアプリケーションを立ち上げられます。まずは、マシンに以下の3つの事前準備が整っているか確認してください:

  • Node.js: バージョン18以降(node -v で確認)
  • Rust: rustup 経由でインストール済み(Rust 1.77.2以上)
  • プラットフォーム固有のツールチェーン: WindowsではVisual Studio C++ Build Tools、macOSではXcode Command Line Tools(xcode-select --install)、LinuxではWebKitGTKおよびビルドツール(sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libayatana-appindicator3-dev librsvg2-dev)

ターミナルで公式のスキャフォールディングCLIを実行します:

npm create tauri-app@latest

プロンプトが表示されたら、次のオプションを選択します:

  1. Project name: my-desktop-app
  2. Frontend language: TypeScript / JavaScript
  3. Package manager: npm(または pnpm / yarn)
  4. UI template: React
  5. Flavor: TypeScript

生成されたフォルダへ移動し、パッケージをインストールして開発サーバーを起動します:

cd my-desktop-app
npm install
npm run tauri dev

初回実行時はCargoが背後のRust依存関係をコンパイルするため、約60〜90秒かかります。コンパイルが完了すると、独立したネイティブウィンドウが立ち上がり、Reactインターフェースを描画します。ホットモジュールリプレイスメント(HMR)も即座に機能し、src/ 配下のコンポーネントを編集すると、アプリケーションの状態を保持したまま数ミリ秒でネイティブウィンドウが更新されます。

詳細解説

信頼性の高いデスクトップツールを構築するには、Tauriのランタイムモデルと、UIスレッドとネイティブシステムコード間でメッセージがどのようにやり取りされるかを正確に理解しておく必要があります。

Tauri 2.0のアーキテクチャ

Slack、VS Code、DiscordなどはElectronを採用しています。しかし、各Electronアプリケーションが独自のChromiumブラウザエンジンとNode.jsランタイムを丸ごと抱えているため、インストーラーのファイルサイズは120MBを軽々と超え、アイドル時のRAM消費量もウィンドウ1つあたり200MBを突破することが珍しくありません。

Tauriは根本から異なるスリムなアプローチをとっています。HTML、CSS、JavaScriptのレンダリングを、ホストOSによってすでに管理・セキュリティパッチが適用されているWebviewエンジンに委譲するのです:

  • Windows: Microsoft Edge WebView2(Evergreenランタイム)
  • macOS: WKWebView(WebKit)
  • Linux: WebKitGTK 4.1

ビジネスロジック、システムコール、セキュリティポリシーは、コンパイルされたRust内部で実行されます。重複するブラウザエンジンを配布する必要がないため、製品版インストーラーのサイズは8MB〜15MB程度まで劇的に縮小します。アイドル時のメモリ使用量も激減し、通常25MB〜45MB程度のRAMで安定動作します。

プロジェクト構成

ワークスペースはフロントエンドコードとネイティブロジックで明確に分離されています:

  • src/: JSXファイル、スタイル、フック、静的アセットなど、通常のVite + Reactアプリケーションを格納します。
  • src-tauri/: Rustアプリケーション本体を格納します。主要なファイルには、Cargo.toml(クレート依存関係)、tauri.conf.json(ウィンドウサイズ、セキュリティ識別子、バンドル設定)、src/lib.rs(Tauri 2.0がプラグインの初期化やIPCコマンドの登録を行うエントリポイント)があります。

Command IPC:ReactとRustの接続

Reactインターフェースは、非同期プロセス間通信(IPC)を介してRustプロセスと対話します。Webviewに公開されるネイティブ関数はCommands(コマンド)と呼ばれます。

デスクトップ開発に初めて挑戦するWeb開発者が陥りがちな落とし穴は、負荷の高い計算処理をJavaScript側で実行してしまうことです。ファイルの解析、暗号化、システム情報の取得などをRust側にオフロードすることで、60 FPSの滑らかなUIを維持できます。

src-tauri/src/lib.rs を開きます。以下は、入力文字列をバリデーションし、ホストのCPUメトリクスを収集して、構造化データを返すカスタムコマンドの例です:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct SystemStats {
    os_name: String,
    cpu_cores: usize,
    status: String,
}

#[tauri::command]
fn get_system_stats(user_label: &str) -> Result<SystemStats, String> {
    if user_label.trim().is_empty() {
        return Err("ラベルを空にすることはできません".into());
    }

    Ok(SystemStats {
        os_name: std::env::consts::OS.to_string(),
        cpu_cores: std::thread::available_parallelism()
            .map(|n| n.get())
            .unwrap_or(1),
        status: format!("アクティブ (プロファイル: {})", user_label),
    })
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![get_system_stats])
        .run(tauri::generate_context!())
        .expect("Tauriアプリケーションの実行中にエラーが発生しました");
}

次にReactレイヤーに移ります。@tauri-apps/api/core から invoke をインポートし、Rustハンドラーを直接呼び出します:

import { useState } from 'react';
import { invoke } from '@tauri-apps/api/core';

interface SystemStats {
  os_name: string;
  cpu_cores: number;
  status: string;
}

export function SystemMonitor() {
  const [stats, setStats] = useState<SystemStats | null>(null);
  const [label, setLabel] = useState('本番ワークステーション');
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function fetchStats() {
    setLoading(true);
    setError(null);
    try {
      const result = await invoke<SystemStats>('get_system_stats', {
        userLabel: label,
      });
      setStats(result);
    } catch (err) {
      setError(String(err));
    } finally {
      setLoading(false);
    }
  }

  return (
    <div style={{ padding: '1.5rem', fontFamily: 'sans-serif' }}>
      <h3>デスクトップシステムモニター</h3>
      <input
        value={label}
        onChange={(e) => setLabel(e.target.value)}
        placeholder="プロファイルラベルを入力"
        style={{ marginRight: '8px', padding: '6px' }}
      />
      <button onClick={fetchStats} disabled={loading}>
        {loading ? '取得中...' : 'Rustバックエンドへ問い合わせ'}
      </button>

      {error && <p style={{ color: 'red' }}>エラー: {error}</p>}

      {stats && (
        <div style={{ marginTop: '1rem', background: '#f4f4f5', padding: '1rem', borderRadius: '6px' }}>
          <p><strong>OS:</strong> {stats.os_name}</p>
          <p><strong>利用可能なコア数:</strong> {stats.cpu_cores}</p>
          <p><strong>ステータス:</strong> {stats.status}</p>
        </div>
      )}
    </div>
  );
}

フィールド名の変換はTauriが自動的に処理します。JavaScript側でキャメルケースのキー(userLabel)を渡すと、SerdeがRust側のスネークケース識別子(user_label)へとデシリアライズしてくれます。

高度な使い方

双方向イベントストリーミング

単純なリクエスト・レスポンス方式は、高速なデータ取得には最適です。しかし、2GBのファイルダウンロードの進行状況をストリーミングしたり、アーカイブの解凍を追跡したり、ハードウェアセンサーをポーリングしたりといった長時間実行される処理では、RustからReactへリアルタイムな更新をプッシュする必要があります。

Tauriのイベントシステムを使えば、これをすっきりと実装できます。Rust側では、Emitter トレイトを使用して継続的にイベントを発火させます:

use tauri::Emitter;
use tokio::time::{sleep, Duration};

#[tauri::command]
async fn start_progress_task(app: tauri::AppHandle) -> Result<(), String> {
    tauri::async_runtime::spawn(async move {
        for step in 1..=100 {
            sleep(Duration::from_millis(30)).await;
            let _ = app.emit("task-progress", step);
        }
    });
    Ok(())
}

lib.rs 内の tauri::generate_handler![] に start_progress_task を登録します。フロントエンド側では、標準的なReactフック内でイベントリスナーを設定します:

import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';

export function ProgressBar() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    let unlisten: (() => void) | undefined;

    async function setupListener() {
      unlisten = await listen<number>('task-progress', (event) => {
        setProgress(event.payload);
      });
    }

    setupListener();
    return () => {
      if (unlisten) unlisten();
    };
  }, []);

  return (
    <div>
      <button onClick={() => invoke('start_progress_task')}>ワーカーを開始</button>
      <div style={{ background: '#e2e8f0', width: '240px', height: '16px', margin: '10px 0', borderRadius: '4px', overflow: 'hidden' }}>
        <div style={{ background: '#2563eb', width: `${progress}%`, height: '100%', transition: 'width 0.1s linear' }} />
      </div>
      <span>進捗状況: {progress}%</span>
    </div>
  );
}

Tauri 2.0のセキュリティとCapabilities

Tauri 2.0では、従来のv1のアローリスト方式が廃止され、明示的なCapabilities(ケイパビリティ)モデルへと刷新されました。Webviewは完全にサンドボックス化された状態で起動します。JSON設定ファイルで明示的に権限を付与しない限り、ファイルの読み書き、システムダイアログの表示、ターミナルコマンドの実行などは一切行えません。

src-tauri/capabilities/default.json のケイパビリティ定義を確認してみましょう:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "メインアプリケーションウィンドウの機能パーミッション設定",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "opener:default"
  ]
}

このゼロトラストアーキテクチャにより、ユーザーを強固に保護します。仮に攻撃者がフロントエンドの依存ライブラリでXSS脆弱性を発見したとしても、Webviewサンドボックス内に閉じ込められたままとなり、ホストシステムのAPIにはアクセスできません。

実践のヒント

1. アグレッシブなRustリリースプロファイル設定

デフォルトのリリースプロファイルは、バイナリサイズの極小化よりもコンパイル速度を優先しています。src-tauri/Cargo.toml に以下の5つの最適化ディレクティブを追加することで、コンパイル後のバイナリから不要なオーバーヘッドを大幅に削ぎ落とせます:

[profile.release]
opt-level = "z"     # バイナリサイズを徹底的に最適化
lto = true          # 全クレートにわたってリンク時最適化(LTO)を有効化
codegen-units = 1   # コード生成ユニットを1つに削減してLTOの効果を向上
panic = "abort"     # アンワインドのランディングパッドとテーブルを除去
strip = true        # シンボル情報とデバッグ情報を自動的に除去

これらのフラグにより、実行速度に一切影響を与えることなく、WindowsやmacOS上で未加工のネイティブバイナリを約18MBから約4.5MBへとスリム化できます。

2. フロントエンドバンドルの軽量化

Viteは本番用Webアセットを圧縮し、コンパイル済みバイナリに直接埋め込みます。フロントエンドの容量も常にスリムに保ちましょう:

  • リリース前に rollup-plugin-visualizer を使ってバンドル出力をプロファイルし、肥大化したnpmパッケージを特定します。
  • lodash や moment.js などのユーティリティライブラリを、Intl.DateTimeFormat やモダンな配列メソッドといったJavaScript標準のネイティブ機能に置き換えます。
  • 設定画面やグラフ描画ライブラリなどの重いビューは、React.lazy() と Suspense で遅延ロードを行い、初回ウィンドウの起動時間を300ms未満に抑えます。

3. アプリ内Webviewのデバッグ

デスクトップWebviewのデバッグは、通常のブラウザ開発と全く同じ感覚で行えます。WindowsやLinuxでは、F12 キーを押すか右クリックして要素を検証を選択すればDevToolsが開きます。macOSでは、tauri.conf.json のウィンドウ設定で "devtools": true を指定するか、TAURI_DEVTOOLS=1 cargo tauri dev でアプリを起動することでWebKit Web Inspectorにアクセスできます。

4. ネイティブインストーラーのパッケージ化

公開の準備が整ったら、ターミナルでコマンドを1回実行するだけです:

npm run tauri build

TauriがViteバンドルをコンパイルし、最大限に最適化されたRust実行ファイルをビルドして、署名済みのプラットフォーム向けパッケージを src-tauri/target/release/bundle/ に出力します。Windows用の .msi や .exe、macOS用の .dmg や .app、Linux用の .deb や .AppImage など、洗練されたインストーラーが手に入ります。ユーザーに100MB以上の巨大なインストーラーをダウンロードさせる代わりに、一瞬で起動する軽快でレスポンシブな12MBのバイナリを届けましょう。

Share: