T-CREATOR

Tauri でマルチウィンドウアプリを実現する方法

Tauri でマルチウィンドウアプリを実現する方法

デスクトップアプリケーション開発において、複数のウィンドウを扱うことは多くの開発者が直面する課題です。Tauri は、Web 技術を使ってネイティブアプリを構築できるフレームワークですが、マルチウィンドウ機能の実装には独特のアプローチが必要になります。

この記事では、Tauri でマルチウィンドウアプリを構築するための実践的な手順を、実際のエラーと解決策とともに詳しく解説していきます。初心者の方でも理解しやすいよう、段階的に進めていきましょう。

Tauri マルチウィンドウの基本概念

Tauri では、ウィンドウは「メインウィンドウ」と「子ウィンドウ」という概念で管理されます。メインウィンドウはアプリケーションの起動時に作成され、子ウィンドウは必要に応じて動的に作成できます。

ウィンドウの種類と特徴

種類特徴用途
メインウィンドウアプリ起動時に自動作成アプリのメイン画面
子ウィンドウ動的に作成可能モーダル、設定画面、詳細表示

Tauri のウィンドウ管理は、Rust のバックエンドとフロントエンド(HTML/CSS/JavaScript)の連携によって実現されます。この仕組みを理解することで、より柔軟なアプリケーション設計が可能になります。

開発環境の準備

まず、Tauri の開発環境を整備しましょう。必要なツールとセットアップ手順を確認します。

必要なツールのインストール

bash# Rustのインストール(未インストールの場合)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Node.jsの確認(v16以上推奨)
node --version

# Yarnのインストール
npm install -g yarn

# Tauri CLIのインストール
yarn add -g @tauri-apps/cli

プロジェクトの作成

bash# 新しいTauriプロジェクトを作成
yarn create tauri-app my-multi-window-app

# プロジェクトディレクトリに移動
cd my-multi-window-app

# 依存関係のインストール
yarn install

よくあるエラーと解決策

エラー 1: tauri: command not found

bash# エラーの原因:Tauri CLIがインストールされていない
# 解決策:グローバルインストールを実行
yarn add -g @tauri-apps/cli

エラー 2: error: linker 'cc' not found

bash# エラーの原因:Cコンパイラが不足
# 解決策:Xcode Command Line Toolsをインストール(macOS)
xcode-select --install

メインウィンドウの設定

メインウィンドウは、アプリケーションの顔となる重要な要素です。適切な設定により、ユーザーエクスペリエンスを大幅に向上させることができます。

tauri.conf.json の基本設定

json{
  "tauri": {
    "windows": [
      {
        "label": "main",
        "title": "マルチウィンドウアプリ",
        "width": 800,
        "height": 600,
        "resizable": true,
        "fullscreen": false,
        "visible": true
      }
    ]
  }
}

ウィンドウの詳細設定

json{
  "tauri": {
    "windows": [
      {
        "label": "main",
        "title": "メインウィンドウ",
        "width": 1024,
        "height": 768,
        "minWidth": 400,
        "minHeight": 300,
        "maxWidth": 1920,
        "maxHeight": 1080,
        "center": true,
        "decorations": true,
        "transparent": false,
        "alwaysOnTop": false,
        "skipTaskbar": false
      }
    ]
  }
}

ウィンドウの初期化処理

typescript// src/main.ts
import { app, BrowserWindow } from '@tauri-apps/api/window';

// メインウィンドウの作成
const mainWindow = new BrowserWindow('main');

// ウィンドウが準備完了したときの処理
mainWindow.once('tauri://created', () => {
  console.log('メインウィンドウが作成されました');
});

// ウィンドウが閉じられたときの処理
mainWindow.once('tauri://close-requested', () => {
  console.log('メインウィンドウが閉じられようとしています');
});

子ウィンドウの作成方法

子ウィンドウの作成は、Tauri マルチウィンドウ機能の核心部分です。適切な実装により、ユーザーの作業効率を大幅に向上させることができます。

基本的な子ウィンドウ作成

typescript// src/windows/child-window.ts
import { WebviewWindow } from '@tauri-apps/api/window';

// 子ウィンドウを作成する関数
async function createChildWindow() {
  try {
    const childWindow = new WebviewWindow('child', {
      url: 'child.html',
      title: '子ウィンドウ',
      width: 600,
      height: 400,
      resizable: true,
      center: true,
    });

    return childWindow;
  } catch (error) {
    console.error(
      '子ウィンドウの作成に失敗しました:',
      error
    );
    throw error;
  }
}

ウィンドウの種類別作成方法

typescript// モーダルウィンドウの作成
async function createModalWindow() {
  const modalWindow = new WebviewWindow('modal', {
    url: 'modal.html',
    title: 'モーダル',
    width: 400,
    height: 300,
    resizable: false,
    center: true,
    alwaysOnTop: true,
    decorations: false,
  });

  return modalWindow;
}

// 設定ウィンドウの作成
async function createSettingsWindow() {
  const settingsWindow = new WebviewWindow('settings', {
    url: 'settings.html',
    title: '設定',
    width: 800,
    height: 600,
    resizable: true,
    center: true,
    minWidth: 600,
    minHeight: 400,
  });

  return settingsWindow;
}

よくあるエラーと解決策

エラー 3: Window label must be unique

typescript// エラーの原因:同じラベルのウィンドウが既に存在
// 解決策:ウィンドウの存在確認を行う
async function createUniqueWindow(label: string) {
  const existingWindow = WebviewWindow.getByLabel(label);

  if (existingWindow) {
    // 既存のウィンドウをフォーカス
    await existingWindow.setFocus();
    return existingWindow;
  }

  // 新しいウィンドウを作成
  return new WebviewWindow(label, {
    url: `${label}.html`,
    title: label,
    width: 600,
    height: 400,
  });
}

エラー 4: Failed to load resource: net::ERR_FILE_NOT_FOUND

typescript// エラーの原因:HTMLファイルが見つからない
// 解決策:正しいパスを指定する
const childWindow = new WebviewWindow('child', {
  url: '/src/pages/child.html', // 正しいパスを指定
  title: '子ウィンドウ',
});

ウィンドウ間通信の実装

ウィンドウ間でデータをやり取りすることは、マルチウィンドウアプリの重要な機能です。Tauri では、イベントシステムとコマンドシステムを活用して実現します。

イベントベースの通信

typescript// src/communication/events.ts
import { emit, listen } from '@tauri-apps/api/event';

// イベントを送信する関数
export async function sendMessageToAllWindows(
  message: string
) {
  try {
    await emit('window-message', {
      message,
      timestamp: new Date().toISOString(),
    });
  } catch (error) {
    console.error('メッセージの送信に失敗しました:', error);
  }
}

// イベントを受信する関数
export async function listenToMessages() {
  try {
    await listen('window-message', (event) => {
      console.log('受信したメッセージ:', event.payload);
      // メッセージの処理
      handleMessage(event.payload);
    });
  } catch (error) {
    console.error('メッセージの受信に失敗しました:', error);
  }
}

コマンドベースの通信

typescript// src/communication/commands.ts
import { invoke } from '@tauri-apps/api/tauri';

// メインウィンドウから子ウィンドウにデータを送信
export async function sendDataToChild(data: any) {
  try {
    const result = await invoke('send_data_to_child', {
      windowLabel: 'child',
      data: data,
    });
    return result;
  } catch (error) {
    console.error('データの送信に失敗しました:', error);
    throw error;
  }
}

// 子ウィンドウからメインウィンドウにデータを送信
export async function sendDataToMain(data: any) {
  try {
    const result = await invoke('send_data_to_main', {
      data: data,
    });
    return result;
  } catch (error) {
    console.error('データの送信に失敗しました:', error);
    throw error;
  }
}

Rust 側でのコマンド実装

rust// src-tauri/src/main.rs
use tauri::{Manager, Window};

#[tauri::command]
async fn send_data_to_child(
    window: Window,
    window_label: String,
    data: serde_json::Value,
) -> Result<(), String> {
    // 指定されたウィンドウにイベントを送信
    window
        .emit_to(&window_label, "data-received", data)
        .map_err(|e| e.to_string())?;

    Ok(())
}

#[tauri::command]
async fn send_data_to_main(
    window: Window,
    data: serde_json::Value,
) -> Result<(), String> {
    // メインウィンドウにイベントを送信
    window
        .emit("data-from-child", data)
        .map_err(|e| e.to_string())?;

    Ok(())
}

よくあるエラーと解決策

エラー 5: Event listener not found

typescript// エラーの原因:イベントリスナーが正しく設定されていない
// 解決策:アプリケーション起動時にリスナーを設定
document.addEventListener('DOMContentLoaded', async () => {
  try {
    await listenToMessages();
    console.log('イベントリスナーが設定されました');
  } catch (error) {
    console.error(
      'イベントリスナーの設定に失敗しました:',
      error
    );
  }
});

ウィンドウ管理のベストプラクティス

効率的なウィンドウ管理は、ユーザーエクスペリエンスとアプリケーションのパフォーマンスに直結します。実践的なベストプラクティスを紹介します。

ウィンドウの状態管理

typescript// src/windows/window-manager.ts
import {
  WebviewWindow,
  getAll,
} from '@tauri-apps/api/window';

class WindowManager {
  private windows: Map<string, WebviewWindow> = new Map();

  // ウィンドウを登録
  async registerWindow(
    label: string,
    window: WebviewWindow
  ) {
    this.windows.set(label, window);

    // ウィンドウが閉じられたときの処理
    window.once('tauri://close-requested', () => {
      this.windows.delete(label);
    });
  }

  // ウィンドウの存在確認
  hasWindow(label: string): boolean {
    return this.windows.has(label);
  }

  // ウィンドウを取得
  getWindow(label: string): WebviewWindow | undefined {
    return this.windows.get(label);
  }

  // 全ウィンドウを閉じる
  async closeAllWindows() {
    for (const [label, window] of this.windows) {
      try {
        await window.close();
      } catch (error) {
        console.error(
          `${label}ウィンドウの閉じる処理に失敗しました:`,
          error
        );
      }
    }
    this.windows.clear();
  }
}

export const windowManager = new WindowManager();

メモリ管理の最適化

typescript// src/windows/memory-management.ts
import { WebviewWindow } from '@tauri-apps/api/window';

// ウィンドウのメモリ使用量を監視
export async function monitorWindowMemory(
  window: WebviewWindow
) {
  // 定期的にメモリ使用量をチェック
  setInterval(async () => {
    try {
      // ウィンドウが非表示の場合は最小化
      const isVisible = await window.isVisible();
      if (!isVisible) {
        await window.minimize();
      }
    } catch (error) {
      console.error('メモリ監視エラー:', error);
    }
  }, 30000); // 30秒ごとにチェック
}

// 非アクティブウィンドウの最適化
export async function optimizeInactiveWindows() {
  const allWindows = await WebviewWindow.getAll();

  for (const window of allWindows) {
    try {
      const isFocused = await window.isFocused();
      if (!isFocused) {
        // 非アクティブウィンドウの処理を軽量化
        await window.evaluate(() => {
          // アニメーションや重い処理を一時停止
          document.body.style.animationPlayState = 'paused';
        });
      }
    } catch (error) {
      console.error('ウィンドウ最適化エラー:', error);
    }
  }
}

エラーハンドリングの実装

typescript// src/windows/error-handling.ts
import { WebviewWindow } from '@tauri-apps/api/window';

// ウィンドウ作成時のエラーハンドリング
export async function createWindowWithErrorHandling(
  label: string,
  options: any
): Promise<WebviewWindow> {
  try {
    const window = new WebviewWindow(label, options);

    // エラーイベントの監視
    window.once('tauri://error', (error) => {
      console.error(`ウィンドウエラー (${label}):`, error);
      // エラー通知の表示
      showErrorMessage(
        `ウィンドウの作成に失敗しました: ${error}`
      );
    });

    return window;
  } catch (error) {
    console.error('ウィンドウ作成エラー:', error);
    throw new Error(
      `ウィンドウの作成に失敗しました: ${error}`
    );
  }
}

// エラーメッセージの表示
function showErrorMessage(message: string) {
  // ユーザーフレンドリーなエラー表示
  const errorDiv = document.createElement('div');
  errorDiv.className = 'error-message';
  errorDiv.textContent = message;
  document.body.appendChild(errorDiv);

  // 3秒後に自動削除
  setTimeout(() => {
    errorDiv.remove();
  }, 3000);
}

実装例:シンプルなマルチウィンドウアプリ

実際のアプリケーション例を通じて、学んだ知識を実践的に活用してみましょう。シンプルなメモアプリを例に、マルチウィンドウ機能を実装します。

プロジェクト構造

cssmy-multi-window-app/
├── src/
│   ├── main.ts
│   ├── windows/
│   │   ├── window-manager.ts
│   │   └── child-window.ts
│   ├── communication/
│   │   └── events.ts
│   └── pages/
│       ├── main.html
│       ├── child.html
│       └── modal.html
├── src-tauri/
│   └── src/
│       └── main.rs
└── tauri.conf.json

メインウィンドウの実装

html<!-- src/pages/main.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>マルチウィンドウメモアプリ</title>
    <style>
      body {
        font-family: 'Segoe UI', Tahoma, Geneva, Verdana,
          sans-serif;
        margin: 20px;
        background: linear-gradient(
          135deg,
          #667eea 0%,
          #764ba2 100%
        );
        color: white;
      }
      .container {
        max-width: 800px;
        margin: 0 auto;
      }
      .button {
        background: rgba(255, 255, 255, 0.2);
        border: 1px solid rgba(255, 255, 255, 0.3);
        color: white;
        padding: 10px 20px;
        margin: 5px;
        border-radius: 5px;
        cursor: pointer;
        transition: all 0.3s ease;
      }
      .button:hover {
        background: rgba(255, 255, 255, 0.3);
      }
      .memo-list {
        margin-top: 20px;
      }
      .memo-item {
        background: rgba(255, 255, 255, 0.1);
        padding: 15px;
        margin: 10px 0;
        border-radius: 8px;
        border-left: 4px solid #4caf50;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <h1>📝 マルチウィンドウメモアプリ</h1>

      <div class="controls">
        <button class="button" onclick="createMemoWindow()">
          📄 新しいメモ
        </button>
        <button
          class="button"
          onclick="createSettingsWindow()"
        >
          ⚙️ 設定
        </button>
        <button class="button" onclick="showHelp()">
          ❓ ヘルプ
        </button>
      </div>

      <div id="memo-list" class="memo-list">
        <h3>保存されたメモ</h3>
        <div id="memos"></div>
      </div>
    </div>

    <script type="module" src="../main.ts"></script>
  </body>
</html>

メインロジックの実装

typescript// src/main.ts
import { WebviewWindow } from '@tauri-apps/api/window';
import { emit, listen } from '@tauri-apps/api/event';
import { windowManager } from './windows/window-manager';

// グローバル関数の定義
declare global {
  interface Window {
    createMemoWindow: () => void;
    createSettingsWindow: () => void;
    showHelp: () => void;
  }
}

// メモウィンドウの作成
window.createMemoWindow = async () => {
  try {
    if (windowManager.hasWindow('memo')) {
      const existingWindow =
        windowManager.getWindow('memo');
      await existingWindow?.setFocus();
      return;
    }

    const memoWindow = new WebviewWindow('memo', {
      url: '/src/pages/child.html',
      title: '新しいメモ',
      width: 600,
      height: 500,
      resizable: true,
      center: true,
    });

    await windowManager.registerWindow('memo', memoWindow);

    // メモ保存イベントの監視
    await listen('memo-saved', (event) => {
      addMemoToList(event.payload);
    });
  } catch (error) {
    console.error(
      'メモウィンドウの作成に失敗しました:',
      error
    );
    showError('メモウィンドウの作成に失敗しました');
  }
};

// 設定ウィンドウの作成
window.createSettingsWindow = async () => {
  try {
    if (windowManager.hasWindow('settings')) {
      const existingWindow =
        windowManager.getWindow('settings');
      await existingWindow?.setFocus();
      return;
    }

    const settingsWindow = new WebviewWindow('settings', {
      url: '/src/pages/modal.html',
      title: '設定',
      width: 400,
      height: 300,
      resizable: false,
      center: true,
      alwaysOnTop: true,
    });

    await windowManager.registerWindow(
      'settings',
      settingsWindow
    );
  } catch (error) {
    console.error(
      '設定ウィンドウの作成に失敗しました:',
      error
    );
    showError('設定ウィンドウの作成に失敗しました');
  }
};

// ヘルプの表示
window.showHelp = () => {
  showInfo(
    'マルチウィンドウメモアプリの使い方',
    '新しいメモボタンでメモウィンドウを開き、設定ボタンで設定を変更できます。'
  );
};

// メモリストへの追加
function addMemoToList(memo: any) {
  const memosContainer = document.getElementById('memos');
  if (!memosContainer) return;

  const memoElement = document.createElement('div');
  memoElement.className = 'memo-item';
  memoElement.innerHTML = `
    <h4>${memo.title}</h4>
    <p>${memo.content.substring(0, 100)}...</p>
    <small>作成日時: ${new Date(
      memo.timestamp
    ).toLocaleString()}</small>
  `;

  memosContainer.appendChild(memoElement);
}

// エラー表示
function showError(message: string) {
  alert(`エラー: ${message}`);
}

// 情報表示
function showInfo(title: string, message: string) {
  alert(`${title}\n\n${message}`);
}

// アプリケーション初期化
document.addEventListener('DOMContentLoaded', async () => {
  console.log('メインウィンドウが初期化されました');

  // ウィンドウ間通信の設定
  await listen('window-message', (event) => {
    console.log(
      '他のウィンドウからのメッセージ:',
      event.payload
    );
  });
});

子ウィンドウの実装

html<!-- src/pages/child.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>メモエディタ</title>
    <style>
      body {
        font-family: 'Segoe UI', Tahoma, Geneva, Verdana,
          sans-serif;
        margin: 20px;
        background: #f5f5f5;
      }
      .editor {
        background: white;
        border-radius: 8px;
        padding: 20px;
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
      }
      input[type='text'] {
        width: 100%;
        padding: 10px;
        border: 1px solid #ddd;
        border-radius: 4px;
        font-size: 16px;
        margin-bottom: 15px;
      }
      textarea {
        width: 100%;
        height: 300px;
        padding: 10px;
        border: 1px solid #ddd;
        border-radius: 4px;
        font-size: 14px;
        resize: vertical;
      }
      .button {
        background: #4caf50;
        color: white;
        padding: 10px 20px;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        margin: 5px;
      }
      .button:hover {
        background: #45a049;
      }
      .button.secondary {
        background: #f44336;
      }
      .button.secondary:hover {
        background: #da190b;
      }
    </style>
  </head>
  <body>
    <div class="editor">
      <h2>📝 メモエディタ</h2>

      <input
        type="text"
        id="memo-title"
        placeholder="メモのタイトルを入力してください"
      />
      <textarea
        id="memo-content"
        placeholder="メモの内容を入力してください"
      ></textarea>

      <div class="controls">
        <button class="button" onclick="saveMemo()">
          💾 保存
        </button>
        <button
          class="button secondary"
          onclick="closeWindow()"
        >
          ❌ 閉じる
        </button>
      </div>
    </div>

    <script type="module" src="../child-window.ts"></script>
  </body>
</html>

子ウィンドウのロジック

typescript// src/child-window.ts
import { emit } from '@tauri-apps/api/event';

// メモの保存
window.saveMemo = async () => {
  const titleInput = document.getElementById(
    'memo-title'
  ) as HTMLInputElement;
  const contentInput = document.getElementById(
    'memo-content'
  ) as HTMLTextAreaElement;

  const title = titleInput.value.trim();
  const content = contentInput.value.trim();

  if (!title || !content) {
    alert('タイトルと内容を入力してください');
    return;
  }

  try {
    const memo = {
      title,
      content,
      timestamp: new Date().toISOString(),
    };

    // メインウィンドウにメモを送信
    await emit('memo-saved', memo);

    alert('メモが保存されました!');

    // フォームをクリア
    titleInput.value = '';
    contentInput.value = '';
  } catch (error) {
    console.error('メモの保存に失敗しました:', error);
    alert('メモの保存に失敗しました');
  }
};

// ウィンドウを閉じる
window.closeWindow = async () => {
  try {
    const { close } = await import(
      '@tauri-apps/api/window'
    );
    await close();
  } catch (error) {
    console.error(
      'ウィンドウの閉じる処理に失敗しました:',
      error
    );
  }
};

// グローバル関数の定義
declare global {
  interface Window {
    saveMemo: () => void;
    closeWindow: () => void;
  }
}

// 初期化処理
document.addEventListener('DOMContentLoaded', () => {
  console.log('メモエディタが初期化されました');

  // 自動保存機能(5分ごと)
  setInterval(() => {
    const titleInput = document.getElementById(
      'memo-title'
    ) as HTMLInputElement;
    const contentInput = document.getElementById(
      'memo-content'
    ) as HTMLTextAreaElement;

    if (titleInput.value || contentInput.value) {
      console.log('自動保存中...');
      // ここでローカルストレージに保存することも可能
    }
  }, 300000); // 5分 = 300,000ミリ秒
});

まとめ

Tauri でマルチウィンドウアプリを実現する方法について、実践的な手順を詳しく解説してきました。

学んだポイント

  1. ウィンドウの基本概念: メインウィンドウと子ウィンドウの違いを理解し、適切に使い分けることが重要です。

  2. 開発環境の準備: 必要なツールのインストールと、よくあるエラーの解決方法を身につけました。

  3. ウィンドウ作成の実装: 基本的なウィンドウ作成から、高度なカスタマイズまで実装できるようになりました。

  4. ウィンドウ間通信: イベントベースとコマンドベースの通信方法を理解し、実装できるようになりました。

  5. ベストプラクティス: ウィンドウ管理、メモリ最適化、エラーハンドリングの実践的な方法を学びました。

今後の発展

この記事で学んだ知識を基に、さらに高度なマルチウィンドウアプリケーションを開発することができます。例えば:

  • ドラッグ&ドロップによるウィンドウ間データ転送
  • ウィンドウのレイアウト保存と復元
  • 複数モニター対応
  • ウィンドウのアニメーション効果

Tauri のマルチウィンドウ機能を活用することで、ユーザーにとって使いやすく、効率的なデスクトップアプリケーションを構築できます。この記事が、あなたのアプリケーション開発の一助となれば幸いです。

関連リンク