Tauri 2.x + React + TypeScript でデスクトップアプリを開発した際に遭遇した問題と解決策をまとめました。これから Tauri でアプリを作る方の参考になれば幸いです。実際の開発で遭遇した以下の問題を解説します。


    1. .app が起動しない — プラグイン設定の罠

    症状

    npm run tauri build でビルドした .app をダブルクリックしても何も起きない。開発版(tauri dev)では動くのに、リリースビルドだけクラッシュする。

    原因

    バイナリを直接実行すると、以下のエラーが出ました。

    thread 'main' panicked at src/lib.rs:23:10:
    Tauri アプリの起動に失敗しました: PluginInitialization("dialog",
    "Error deserializing 'plugins.dialog' within your Tauri configuration:
    invalid type: map, expected unit")
    

    原因は tauri.conf.json の書き方でした。

    // ❌ これがダメ
    "plugins": {
        "dialog": {},
        "store": {},
        "autostart": {}
    }
    

    tauri-plugin-dialog は設定を持たないプラグイン(Config 型が unit)です。空オブジェクト {} は JSON では map 型なので、serde がデシリアライズできずに panic します。

    解決策

    設定を持たないプラグインは tauri.conf.json に書かない。

    // ✅ plugins セクション自体を削除
    // プラグインの初期化は Rust 側で行う
    
    // src-tauri/src/lib.rs
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_store::Builder::default().build())
        .plugin(tauri_plugin_autostart::init(
            MacosLauncher::LaunchAgent,
            None,
        ))
    

    教訓

    • .app が起動しない時は、バイナリを直接実行してエラーを確認する
    • プラグインの Config 型を確認してから tauri.conf.json に書く
    • 設定不要なプラグインは conf.json に記載しない

    2. async コマンドでブロッキングI/O

    症状

    Tauri コマンドを pub async fn で宣言しているのに、ファイル操作中に UI がフリーズする。

    原因

    async 関数内で std::fs の同期関数を使っていました。

    // ❌ async fn 内で同期I/O
    #[tauri::command]
    pub async fn read_file(path: String) -> Result<String, String> {
        std::fs::read_to_string(&path)  // ← tokio ランタイムをブロック
            .map_err(|e| format!("エラー: {}", e))
    }
    

    解決策

    tokio::fs を使って非同期I/Oにする。

    // ✅ tokio::fs で非同期I/O
    #[tauri::command]
    pub async fn read_file(path: String) -> Result<String, String> {
        tokio::fs::read_to_string(&path)
            .await
            .map_err(|e| format!("エラー: {}", e))
    }
    

    Cargo.toml に tokio の fs feature を追加。

    [dependencies]
    tokio = { version = "1", features = ["fs"] }
    

    注意点

    file_exists も同様です。Path::exists() は同期関数なので、tokio::fs::metadata を使います。

    #[tauri::command]
    pub async fn file_exists(path: String) -> Result<bool, String> {
        match tokio::fs::metadata(&path).await {
            Ok(_) => Ok(true),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
            Err(e) => Err(format!("エラー: {}", e)),
        }
    }
    

    3. tauri-plugin-store の API 変更

    症状

    Property 'defaults' is missing in type '{ autoSave: true; }'
    but required in type 'StoreOptions'
    

    原因

    tauri-plugin-store v2.2.0 で load()defaults パラメータが必須になりました。

    // ❌ v2.2.0 ではエラー
    const store = await load("settings.json", { autoSave: true });
    

    解決策

    defaults でデフォルト値を指定する。

    // ✅ defaults を指定
    const STORE_DEFAULTS = {
      baseYmlPath: null,
      autostartEnabled: false,
      espansoAutostart: false,
      espansoPath: null,
    };
    
    const store = await load("settings.json", {
      defaults: STORE_DEFAULTS,
      autoSave: true,
    });
    

    教訓

    • Tauri プラグインは頻繁に API が変わる
    • プラグインの型定義ファイルを確認してから使う
    • @tauri-apps/api のバージョンとプラグインのバージョンを合わせる

    4. CSS-in-JS で keyframes が効かない

    症状

    インラインスタイルで animation: "fadeIn 0.15s ease-out" を指定しても、アニメーションが動かない。

    原因

    CSS-in-JS(インラインスタイル)では @keyframes を定義できません。animation プロパティは keyframes 名を参照するだけで、keyframes 自体はグローバル CSS に定義する必要があります。

    // ❌ keyframes がないのでアニメーションしない
    <div style={{ animation: "fadeIn 0.15s ease-out" }}>
    

    解決策

    useEffect<style> 要素を注入する。

    let keyframesInjected = false;
    
    function injectKeyframes() {
      if (keyframesInjected) return;
      const style = document.createElement("style");
      style.textContent = `
        @keyframes fadeIn {
          from { opacity: 0; transform: translateY(-8px); }
          to { opacity: 1; transform: translateY(0); }
        }
      `;
      document.head.appendChild(style);
      keyframesInjected = true;
    }
    
    export function Toast({ toast }: Props) {
      useEffect(() => {
        injectKeyframes();
      }, []);
    
      // ...
    }
    

    別の選択肢

    • グローバル CSS ファイルに keyframes を定義する
    • CSS Modules を使う
    • Emotion / styled-components などのライブラリを使う

    5. invoke() のテストが書きにくい

    症状

    コンポーネントや hooks から invoke() を直接呼ぶと、テスト時にモックしにくい。

    解決策:TauriBridge パターン

    主要な invoke() を1つのファイルにまとめて、型付きアクセサを提供する。

    // src/lib/tauri-bridge.ts
    import { invoke } from "@tauri-apps/api/core";
    
    export const TauriBridge = {
      readFile: (path: string): Promise<string> =>
        invoke("read_file", { path }),
    
      writeFile: (path: string, content: string): Promise<void> =>
        invoke("write_file", { path, content }),
    
      fileExists: (path: string): Promise<boolean> =>
        invoke("file_exists", { path }),
    
      // Tauri 環境判定
      isTauri: (): boolean =>
        typeof window !== "undefined" && "__TAURI_INTERNALS__" in window,
    };
    

    hooks やコンポーネントは TauriBridge 経由で呼ぶ。

    // src/hooks/useSnippets.ts
    import { TauriBridge } from "../lib/tauri-bridge";
    
    const loadFromPath = async (path: string) => {
      const content = await TauriBridge.readFile(path);
      // ...
    };
    

    テスト時はモックする。

    // tests/useSnippets.test.ts
    vi.mock("../src/lib/tauri-bridge", () => ({
      TauriBridge: {
        readFile: vi.fn().mockResolvedValue("mock content"),
        writeFile: vi.fn().mockResolvedValue(undefined),
        fileExists: vi.fn().mockResolvedValue(true),
      },
    }));
    

    メリット

    • テスト時のモックが簡単
    • 型安全な API
    • 非 Tauri 環境(ブラウザ開発)でのフォールバックが書きやすい

    6. その他のハマりポイント

    Rust テストでの AppHandle 依存

    Tauri コマンドは AppHandle に依存するため、ユニットテストが書きにくい。

    解決策: ロジックを純粋関数として切り出す。

    // ✅ 純粋関数として切り出し
    fn build_default_base_yml_path() -> Option<String> {
        let home = dirs::home_dir()?;
        // ...
    }
    
    #[tauri::command]
    pub fn get_default_base_yml_path() -> Result<String, String> {
        build_default_base_yml_path()
            .ok_or_else(|| "ホームディレクトリが見つかりません".to_string())
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_build_default_base_yml_path() {
            let path = build_default_base_yml_path();
            assert!(path.is_some());
        }
    }
    

    useEffect のクリーンアップ忘れ

    setTimeout を使う hooks で、unmount 時にタイマーをクリアしないとメモリリークが発生する。

    // ✅ cleanup でタイマーをクリア
    export function useToast() {
      const [toast, setToast] = useState<Toast | null>(null);
      const timerRef = useRef<number | null>(null);
    
      useEffect(() => {
        return () => {
          if (timerRef.current) {
            clearTimeout(timerRef.current);
          }
        };
      }, []);
    
      const showToast = (message: string, type: ToastType) => {
        if (timerRef.current) clearTimeout(timerRef.current);
        setToast({ message, type });
        timerRef.current = window.setTimeout(() => setToast(null), 2200);
      };
    
      return { toast, showToast };
    }
    

    label と input の関連付け

    <label><input>htmlFor / id で関連付けないと、スクリーンリーダーがラベルを読み上げない。

    // ✅ useId() で一意IDを生成
    import { useId } from "react";
    
    function TriggerInput({ value, onChange }: Props) {
      const id = useId();
      return (
        <div>
          <label htmlFor={id}>Trigger</label>
          <input id={id} value={value} onChange={onChange} />
        </div>
      );
    }
    

    まとめ

    Tauri 2.x でデスクトップアプリを作る際の主なハマりポイントをまとめました。

    問題 解決策
    .app が起動しない プラグイン設定の型を確認、unit 型には {} を書かない
    async でブロッキング tokio::fs を使う
    plugin-store エラー defaults パラメータを指定
    keyframes が効かない グローバル CSS に注入
    invoke のテスト TauriBridge パターンで集約

    Tauri は Electron より軽量で高速ですが、Rust + TypeScript の両方を扱う必要があり、ハマりポイントも多いです。この記事が参考になれば幸いです。

    参考リンク