[Tauri #08] Tauri v2:マルチモニターの3Dフルスクリーン同期を、Rustの物理座標制御で完全攻略する

[Tauri #08] Tauri v2:マルチモニターの3Dフルスクリーン同期を、Rustの物理座標制御で完全攻略する

はじめに

手元のデスクトップ環境がマルチモニターになると、すべての画面いっぱいに美しい3Dグラフィックスやライブ壁紙を同期させてみたくなります。

Tauri v2とThree.jsを使って、3Dデスクトップユーティリティ「WiredFrame」を開発しているときも、まさにこの「全モニターでのシームレスなフルスクリーン同期」が大きな目標でした。

しかし、実際に実装を始めると、OSやウィンドウマネージャーの強力な壁にぶつかりました。

「プログラムから複数のウィンドウを同時に、それぞれのモニターで綺麗にフルスクリーンにする」

という処理は、一筋縄ではいかない壁が存在します。

この記事では、フロントエンドからの制御の限界から始まり、Rustバックエンドでモニターの物理座標を完全に掌握してウィンドウを生成・配置し、一括フルスクリーン同期とモード切り替えを実現するまでの全記録をまとめます。

スクリーンショット

[Tauri #08] マルチモニタ対応スクリーンセーバー #01 [Tauri #08] マルチモニタ対応スクリーンセーバー #02 [Tauri #08] マルチモニタ対応スクリーンセーバー #02 [Tauri #08] マルチモニタ対応スクリーンセーバー #02 [Tauri #08] マルチモニタ対応スクリーンセーバー #02

1. なぜマルチモニターのフルスクリーン同期は難しいのか

最初、フロントエンド(TypeScript)側から availableMonitors() でモニター一覧を取得し、ループを回して new WebviewWindow(...) で各モニターの位置にウィンドウを作ろうとしました。

しかし、これには以下のような問題が立ちはだかります。

  1. 非アクティブウィンドウへのフルスクリーン拒否 WindowsなどのOSのウィンドウマネージャーは、セキュリティやフォーカスの都合上、バックグラウンド(非アクティブ)にあるウィンドウに対してプログラムから突然 set_fullscreen(true) を命令しても、完全に無視するか、アクティブなメインウィンドウにしか適用しません。
  2. 生成時の fullscreen: true による位置の暴走 ウィンドウ作成時にオプションで fullscreen: true を指定すると、OSが「どのモニターのフルスクリーンにすべきか」を正しく解釈できず、メインモニターや意図しない位置にウィンドウが飛び出してしまいます。
  3. 論理ピクセルと物理ピクセルのズレ 高DPIスケーリング(125%や150%など)が有効な環境では、通常のウィンドウ座標指定が微妙にズレを生じさせ、ぴったり画面いっぱいに収まりません。

2. 解決策:Rust側で物理座標を合わせてからフルスクリーンにする

この問題を解決するには、フロントエンドの不確実な非同期処理に頼るのではなく、Rustバックエンドでウィンドウの生成から配置、フルスクリーン化までのライフサイクルを完全にコントロールする必要があります。

導き出した決定版の手順は以下の通りです。

  1. app.available_monitors() でOSから直接全モニターの情報を取得する。
  2. メインウィンドウが配置されているモニターを特定し、重複作成を防ぐために除外する。
  3. サブモニターごとに WebviewWindowBuilder を使い、まずは非表示(visible(false))でウィンドウをビルドする。
  4. PhysicalPosition と PhysicalSize を用いて、モニターの生データをそのまま使って位置とサイズをピタッと合わせる。
  5. 位置が確定した状態で w.show() で表示し、その直後に安全に w.set_fullscreen(true) を呼び出す。

3. 実装コード:Rustバックエンドの制御

実際の src-tauri/src/lib.rs に実装したコードです。サブウィンドウの動的生成と、全ウィンドウの一括フルスクリーン切替(F11キー対応など)を行います。

use tauri::{Manager, Emitter, WebviewUrl, WebviewWindowBuilder, PhysicalPosition, PhysicalSize};

// 🖥️ Rust側でサブモニター用のウィンドウを正確な物理座標で生成し、フルスクリーン化する
#[tauri::command]
async fn open_sub_monitors(app: tauri::AppHandle) -> Result<(), String> {
    let monitors = app.available_monitors().map_err(|e| e.to_string())?;
    let main_pos = app.get_webview_window("main")
        .and_then(|w| w.current_monitor().ok().flatten())
        .map(|m| *m.position());

    for (i, m) in monitors.iter().enumerate() {
        // メインウィンドウが存在するモニターはスキップ
        if Some(*m.position()) == main_pos {
            continue;
        }

        let label = format!("sub-monitor-{}", i);
        if app.get_webview_window(&label).is_some() {
            continue;
        }

        // 1. まずは非表示でビルド(位置ズレやチラつきを防ぐ)
        let w = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("index.html".into()))
            .decorations(false)
            .resizable(false)
            .visible(false)
            .build()
            .map_err(|e| e.to_string())?;

        // 2. 物理ピクセル座標とサイズを正確に設定
        let _ = w.set_position(PhysicalPosition::new(m.position().x, m.position().y));
        let _ = w.set_size(PhysicalSize::new(m.size().width, m.size().height));

        // 3. 表示させてからフルスクリーン化
        let _ = w.show();
        let _ = w.set_fullscreen(true);
    }
    Ok(())
}

// 🖥️ すべてのウィンドウのフルスクリーンを一括切替するコマンド
#[tauri::command]
fn set_fullscreen_all(app: tauri::AppHandle, fullscreen: bool) -> Result<(), String> {
    for (_, window) in app.webview_windows() {
        let _ = window.set_fullscreen(fullscreen);
    }
    Ok(())
}

これを tauri::generate_handler! に登録することで、TypeScript側から invoke('open_sub_monitors') や invoke('set_fullscreen_all', { fullscreen: true }) として呼び出せるようになります。

4. フロントエンド(TypeScript)とモード切替の実装

フロントエンド側では、起動時にメインウィンドウかどうかを判定し、設定に応じてマルチモニター展開を呼び出します。また、シングルモニターでも快適に使えるよう、設定モーダルに「マルチモニター連動」のON/OFFスイッチを追加しました。

// 🖥️ マルチモニター自動展開の初期化関数
async function initMultiMonitor() {
  try {
    const currentWin = getCurrentWindow();
    if (currentWin.label !== 'main') {
      return; // サブウィンドウ側では何もしない
    }

    // 設定からマルチモニター有効フラグを読み込む
    const saved = localStorage.getItem(SETTINGS_KEY);
    const settings = saved ? { ...DEFAULT_SETTINGS, ...JSON.parse(saved) } : DEFAULT_SETTINGS;

    if (!settings.multiMonitor) {
      console.log('シングルモニターモードで動作します');
      return;
    }

    console.log('Rust側でマルチモニターウィンドウを展開します...');
    await invoke('open_sub_monitors');
  } catch (err) {
    console.error('マルチモニター初期化エラー:', err);
  }
}

これにより、普段は1枚のモニターで手軽に動かし、ディスプレイを複数繋いで没入感を高めたいときは設定から一発で全画面一括展開に切り替えられるようになりました。

まとめ

  • OSの制約の突破: 非アクティブウィンドウへのフルスクリーン命令や生成時の位置暴走は、Rust側で物理ピクセル座標(PhysicalPosition / PhysicalSize)を固めてから表示・フルスクリーン化することで完全に解決できる。
  • ライフサイクルの分離: ウィンドウの生成や配置といった重たい処理はバックエンドに任せ、フロントは描画と設定に集中する設計が非常に安定する。
  • 柔軟なモード選択: マルチモニターでの全画面同期だけでなく、シングルモニターでの単体運用も設定からシームレスに切り替えられるようにすることで、実用性が大きく向上した。

マルチモニター環境でのウィンドウ管理や全画面同期に悩んでいる方の参考になれば幸いです。機能の要望やフィードバックがあれば、お気軽にGitHubのIssuesまで。