Tauri 2.0 デスクトップアプリ開発完全ガイド - Rust + Web技術で作る次世代アプリ


Tauriとは

Tauriは、RustとWeb技術(HTML/CSS/JavaScript)を組み合わせてデスクトップアプリケーションを構築するためのフレームワークです。Electronの代替として注目を集めており、以下の特徴があります。

Electronとの比較

項目TauriElectron
バイナリサイズ3-5MB100-200MB
メモリ使用量50-100MB200-500MB
バックエンドRustNode.js
レンダラーOSのWebViewChromium
セキュリティデフォルトで安全設定が必要
起動速度非常に高速やや遅い

Tauri 2.0の新機能

2024年にリリースされたTauri 2.0では、以下の機能が追加されました。

  • モバイルサポート: iOS/Androidアプリのビルドが可能
  • 改善されたプラグインシステム: より柔軟な拡張性
  • マルチウィンドウサポートの強化: 複数ウィンドウの管理が容易に
  • より強力なセキュリティ機能: Capability-based security model
  • パフォーマンス向上: さらなる最適化

環境構築

必要なツール

Tauriアプリ開発には以下のツールが必要です。

共通:

  • Rust: rustupでインストール
  • Node.js: フロントエンド開発用(npm/yarn/pnpm)

macOS:

# Xcodeコマンドラインツール
xcode-select --install

Windows:

# Microsoft C++ Build Tools
# Visual Studio InstallerでC++デスクトップ開発をインストール

# WebView2(Windows 10/11では通常プリインストール済み)

Linux (Ubuntu/Debian):

sudo apt update
sudo apt install libwebkit2gtk-4.0-dev \
    build-essential \
    curl \
    wget \
    file \
    libssl-dev \
    libgtk-3-dev \
    libayatana-appindicator3-dev \
    librsvg2-dev

Rustのインストール

# rustupのインストール
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# PATHを通す
source $HOME/.cargo/env

# 確認
rustc --version
cargo --version

Tauri CLIのインストール

# Cargo経由でインストール(推奨)
cargo install tauri-cli

# またはnpm経由
npm install -g @tauri-apps/cli

プロジェクトの作成

create-tauriでスタート

# 公式テンプレートから作成
npm create tauri-app@latest

# プロンプトに答える
 Project name · my-tauri-app
 Choose which language to use for your frontend · TypeScript
 Choose your package manager · pnpm
 Choose your UI template · React
 Choose your UI flavor · TypeScript

プロジェクト構造

my-tauri-app/
├── src/               # フロントエンド(React/Vue/Svelteなど)
│   ├── main.tsx
│   ├── App.tsx
│   └── styles.css
├── src-tauri/         # Rustバックエンド
│   ├── src/
│   │   └── main.rs    # エントリーポイント
│   ├── icons/         # アプリアイコン
│   ├── Cargo.toml     # Rust依存関係
│   ├── tauri.conf.json # Tauri設定
│   └── build.rs       # ビルドスクリプト
├── package.json
└── vite.config.ts     # フロントエンドビルド設定

開発サーバーの起動

# フロントエンドとTauriを同時に起動
npm run tauri dev

# または
pnpm tauri dev

フロントエンドとRustの通信

Commandsパターン

Rustの関数をJavaScriptから呼び出す基本パターンです。

Rust側(src-tauri/src/main.rs):

// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

// Commandとして公開する関数
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

#[tauri::command]
async fn fetch_data(url: String) -> Result<String, String> {
    let response = reqwest::get(&url)
        .await
        .map_err(|e| e.to_string())?;

    let body = response.text()
        .await
        .map_err(|e| e.to_string())?;

    Ok(body)
}

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![greet, fetch_data])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

JavaScript側(src/App.tsx):

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

function App() {
  const [greetMsg, setGreetMsg] = useState('');
  const [name, setName] = useState('');

  async function handleGreet() {
    // Rustの関数を呼び出し
    const message = await invoke<string>('greet', { name });
    setGreetMsg(message);
  }

  async function handleFetchData() {
    try {
      const data = await invoke<string>('fetch_data', {
        url: 'https://api.example.com/data'
      });
      console.log('Fetched:', data);
    } catch (error) {
      console.error('Error:', error);
    }
  }

  return (
    <div className="container">
      <h1>Welcome to Tauri!</h1>
      <div>
        <input
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="Enter a name..."
        />
        <button onClick={handleGreet}>Greet</button>
      </div>
      <p>{greetMsg}</p>
      <button onClick={handleFetchData}>Fetch Data</button>
    </div>
  );
}

export default App;

複雑なデータ型の受け渡し

Rust側:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct User {
    id: u32,
    name: String,
    email: String,
    roles: Vec<String>,
}

#[tauri::command]
fn get_user(id: u32) -> Result<User, String> {
    // 実際はデータベースから取得
    Ok(User {
        id,
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
        roles: vec!["admin".to_string(), "user".to_string()],
    })
}

#[tauri::command]
fn create_user(user: User) -> Result<User, String> {
    // バリデーション
    if user.name.is_empty() {
        return Err("Name cannot be empty".to_string());
    }

    // データベースに保存(省略)

    Ok(user)
}

TypeScript側:

interface User {
  id: number;
  name: string;
  email: string;
  roles: string[];
}

// ユーザー取得
const user = await invoke<User>('get_user', { id: 1 });
console.log(user.name); // "Alice"

// ユーザー作成
const newUser: User = {
  id: 0, // サーバー側で採番
  name: 'Bob',
  email: 'bob@example.com',
  roles: ['user']
};

const created = await invoke<User>('create_user', { user: newUser });

ファイルシステム操作

Tauriはセキュリティ上、フロントエンドから直接ファイルシステムにアクセスできません。Rust側で処理します。

ファイルの読み書き

Rust側:

use std::fs;
use tauri::api::path::app_data_dir;

#[tauri::command]
async fn save_file(
    filename: String,
    content: String,
    app_handle: tauri::AppHandle,
) -> Result<String, String> {
    let app_dir = app_data_dir(&app_handle.config())
        .ok_or("Failed to get app data dir")?;

    // ディレクトリが存在しない場合は作成
    fs::create_dir_all(&app_dir)
        .map_err(|e| e.to_string())?;

    let file_path = app_dir.join(filename);

    fs::write(&file_path, content)
        .map_err(|e| e.to_string())?;

    Ok(file_path.to_string_lossy().to_string())
}

#[tauri::command]
async fn read_file(
    filename: String,
    app_handle: tauri::AppHandle,
) -> Result<String, String> {
    let app_dir = app_data_dir(&app_handle.config())
        .ok_or("Failed to get app data dir")?;

    let file_path = app_dir.join(filename);

    fs::read_to_string(file_path)
        .map_err(|e| e.to_string())
}

TypeScript側:

// ファイル保存
const savedPath = await invoke<string>('save_file', {
  filename: 'config.json',
  content: JSON.stringify({ theme: 'dark' })
});

console.log('Saved to:', savedPath);

// ファイル読み込み
const content = await invoke<string>('read_file', {
  filename: 'config.json'
});

const config = JSON.parse(content);

ファイルダイアログ

import { open, save } from '@tauri-apps/api/dialog';
import { readTextFile, writeTextFile } from '@tauri-apps/api/fs';

// ファイルを開く
const selected = await open({
  multiple: false,
  filters: [{
    name: 'Text',
    extensions: ['txt', 'md']
  }]
});

if (selected && typeof selected === 'string') {
  const content = await readTextFile(selected);
  console.log(content);
}

// ファイルを保存
const savePath = await save({
  filters: [{
    name: 'Text',
    extensions: ['txt']
  }]
});

if (savePath) {
  await writeTextFile(savePath, 'Hello from Tauri!');
}

データベース統合

SQLiteの使用

Cargo.tomlに依存関係を追加:

[dependencies]
tauri = { version = "1.5", features = ["shell-open"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rusqlite = { version = "0.30", features = ["bundled"] }

Rust側:

use rusqlite::{Connection, Result as SqlResult};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri::State;

#[derive(Debug, Serialize, Deserialize)]
struct Todo {
    id: Option<i64>,
    title: String,
    completed: bool,
}

struct DbConnection(Mutex<Connection>);

fn init_db(conn: &Connection) -> SqlResult<()> {
    conn.execute(
        "CREATE TABLE IF NOT EXISTS todos (
            id INTEGER PRIMARY KEY,
            title TEXT NOT NULL,
            completed BOOLEAN NOT NULL
        )",
        [],
    )?;
    Ok(())
}

#[tauri::command]
fn add_todo(
    title: String,
    db: State<DbConnection>,
) -> Result<Todo, String> {
    let conn = db.0.lock().unwrap();

    conn.execute(
        "INSERT INTO todos (title, completed) VALUES (?1, ?2)",
        [&title, &false.to_string()],
    ).map_err(|e| e.to_string())?;

    let id = conn.last_insert_rowid();

    Ok(Todo {
        id: Some(id),
        title,
        completed: false,
    })
}

#[tauri::command]
fn get_todos(db: State<DbConnection>) -> Result<Vec<Todo>, String> {
    let conn = db.0.lock().unwrap();

    let mut stmt = conn
        .prepare("SELECT id, title, completed FROM todos")
        .map_err(|e| e.to_string())?;

    let todos = stmt
        .query_map([], |row| {
            Ok(Todo {
                id: Some(row.get(0)?),
                title: row.get(1)?,
                completed: row.get(2)?,
            })
        })
        .map_err(|e| e.to_string())?
        .collect::<SqlResult<Vec<Todo>>>()
        .map_err(|e| e.to_string())?;

    Ok(todos)
}

fn main() {
    let conn = Connection::open("todos.db").expect("Failed to open database");
    init_db(&conn).expect("Failed to initialize database");

    tauri::Builder::default()
        .manage(DbConnection(Mutex::new(conn)))
        .invoke_handler(tauri::generate_handler![add_todo, get_todos])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

ウィンドウ管理

マルチウィンドウアプリケーション

use tauri::{CustomMenuItem, Menu, MenuItem, Submenu, WindowBuilder};

fn main() {
    let menu = Menu::new()
        .add_submenu(Submenu::new(
            "File",
            Menu::new()
                .add_item(CustomMenuItem::new("new_window", "New Window"))
                .add_native_item(MenuItem::Separator)
                .add_native_item(MenuItem::Quit),
        ));

    tauri::Builder::default()
        .menu(menu)
        .on_menu_event(|event| {
            match event.menu_item_id() {
                "new_window" => {
                    let _window = WindowBuilder::new(
                        &event.window().app_handle(),
                        "secondary",
                        tauri::WindowUrl::App("index.html".into())
                    )
                    .title("Secondary Window")
                    .build()
                    .unwrap();
                }
                _ => {}
            }
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

TypeScript側での制御:

import { WebviewWindow } from '@tauri-apps/api/window';

// 新しいウィンドウを開く
const webview = new WebviewWindow('secondary', {
  url: '/settings',
  title: 'Settings',
  width: 600,
  height: 400
});

// ウィンドウイベントのリスニング
webview.once('tauri://created', () => {
  console.log('Window created');
});

webview.once('tauri://error', (e) => {
  console.error('Error:', e);
});

// 現在のウィンドウを操作
import { appWindow } from '@tauri-apps/api/window';

await appWindow.maximize();
await appWindow.minimize();
await appWindow.close();

ビルドとリリース

開発ビルド

# デバッグビルド
pnpm tauri build --debug

プロダクションビルド

# リリースビルド
pnpm tauri build

ビルド成果物は src-tauri/target/release/bundle/ に生成されます。

プラットフォーム別の成果物

macOS:

  • .app: アプリケーションバンドル
  • .dmg: インストーラー

Windows:

  • .exe: 実行ファイル
  • .msi: インストーラー

Linux:

  • .AppImage: ポータブル実行ファイル
  • .deb: Debian/Ubuntuパッケージ

アップデーターの実装

tauri.conf.jsonで設定:

{
  "tauri": {
    "updater": {
      "active": true,
      "endpoints": [
        "https://releases.myapp.com/{{target}}/{{current_version}}"
      ],
      "dialog": true,
      "pubkey": "YOUR_PUBLIC_KEY"
    }
  }
}

Rust側:

use tauri::updater::UpdateResponse;

#[tauri::command]
async fn check_update(app_handle: tauri::AppHandle) -> Result<String, String> {
    match app_handle.updater().check().await {
        Ok(update) => {
            if update.is_update_available() {
                update.download_and_install().await
                    .map_err(|e| e.to_string())?;
                Ok("Update installed".to_string())
            } else {
                Ok("No update available".to_string())
            }
        }
        Err(e) => Err(e.to_string()),
    }
}

まとめ

Tauriは、以下の点で優れたデスクトップアプリ開発フレームワークです。

メリット:

  • 非常に小さいバイナリサイズ(3-5MB)
  • 高速な起動とパフォーマンス
  • Rustの安全性とパフォーマンスを活用
  • 既存のWeb技術スタックが使える
  • クロスプラットフォーム対応(Windows/macOS/Linux)
  • Tauri 2.0でモバイル対応も追加

デメリット:

  • Rustの学習コストがやや高い
  • Electronほどエコシステムが成熟していない
  • OSごとのWebViewの挙動差異に注意が必要

2025年現在、Tauriは軽量で高速なデスクトップアプリケーションを開発する最有力候補の一つです。特にパフォーマンスとバイナリサイズが重要なプロジェクトでは、Electronよりも優れた選択肢となるでしょう。