[Tauri #09] WiredFrame v0.1.0 公開:モニター別設定・トレイ常駐・無操作起動を実装した

[Tauri #09] WiredFrame v0.1.0 公開:モニター別設定・トレイ常駐・無操作起動を実装した

はじめに

前回(#08)は、Rustで各モニターの物理座標にウィンドウを置いて、全画面にするところまでを書きました。

そこから v0.1.0 を公開するまで、実装は一気に進みました。進みすぎて、自分でも「いま何が入っているのか」が追えなくなったので、この記事で整理します。

先に言うと、前回の実装は今では少し変わっています。

  • F11の全画面切り替えは廃止した(常に全画面)
  • 起動しただけでは何も表示せず、タスクトレイに常駐する
  • モニターごとに設定を持つ(前回は全モニターが同じ設定だった)

スクリーンショット

スクリーンショット

設定パネル(画像フォルダの一覧・モニター別の画像フォルダ)

[Tauri #09] 設定パネル(画像フォルダの一覧・モニター別の画像フォルダ)

縦画面のモニターで列数・行数と自動フィットを設定

[Tauri #09] 縦画面のモニターで列数・行数と自動フィットを設定

NSISインストーラーとスタートアップへの登録

[Tauri #09] NSISインストーラーとスタートアップへの登録 [Tauri #09] NSISインストーラーとスタートアップへの登録

1. 何を作ったか(全体マップ)

今回の作業は、大きく2つに分かれます。

前半:マルチモニターの基礎工事(Step 1〜5)

Step内容
1モニターを識別する(各ウィンドウが「自分はどのモニターか」を知る)
2設定の保存形式をv2にする(共通設定とモニター別設定を分ける)
3モニターごとの設定パネルとライブプレビュー
4列数・行数を可変にし、カメラ距離を自動調整する
5マルチモニターのON/OFFを即時反映。ケーブルの抜き差しにも追従

後半:アプリとして使えるようにする(A〜E)

作業内容
Aタスクトレイ常駐、起動・停止、ESCで終了
B起動用のグローバルホットキー
Cキー・マウスの無操作で自動起動
D名前付きの画像フォルダリスト、モニターごとの選択
Eアイコン・アプリ名・README・インストーラー、ログイン時の自動起動

2. モニターを識別する

モニターごとに設定を持つには、まず「このウィンドウはどのモニターか」を決める必要があります。

Rustの current_monitor() が返す位置とサイズを、available_monitors() の一覧から探して一致するものを MonitorInfo として返します。そのうえで、保存キーは次の形にしました。

// 名前 + 解像度 をキーにする(例: \\.\DISPLAY12|1920x1080)
export function monitorKeyOf(m: MonitorInfo | null): string {
  if (!m) return 'unknown';
  const id = m.name ?? `idx${m.index}`;
  return `${id}|${m.width}x${m.height}`;
}

再起動とケーブル1本の抜き差しでは、このキーは変わりませんでした。ただし、Windowsのモニター番号は変わることがあり、完全に安定する方法はありません。構成が変わって設定が合わなくなったら設定し直す、という運用にしています。特定できないときは unknown にして、警告をログに出します。

3. 設定をv2にする

設定は localStorage に入れています。全ウィンドウ(main とサブモニター)で共有されることも、実機で確認しました。

1つのJSONを複数のウィンドウが読み書きすると上書きし合うので、キーを分けました。

キー中身
wiredframe_global_v2共通設定(画像フォルダのリスト・ホットキー・無操作・マルチモニター)
wiredframe_monitor_default_v2モニター共通の既定値
wiredframe_monitor_v2:<モニターキー>そのモニター専用の設定

読み込みの優先順は、そのモニター専用 → モニター共通の既定値 → 組み込みの初期値 です。専用に無い項目は、下の層から補います。どの層を使ったか(source)は、読み込みのたびにログに出します。

旧 wiredframe_settings_v1 が残っていれば、初回だけv2に移します。v1は消さず、移行済みの印を付けて、リセットしても再移行しないようにしました。

保存したあとは settings-changed イベントを全ウィンドウに配信します。受け取った各ウィンドウが、自分のモニターの設定を読み直して適用します。実機(6台)では、mainで保存してから全ウィンドウが読み直すまで約2ms以内でした。

4. モニターごとの設定パネルとライブプレビュー

トレイの「設定」を押すと、各モニターに、そのモニター専用の設定パネルが出ます。

  • 入力が0.4秒止まったら、そのウィンドウにだけ反映する(保存はしない)
  • 0や空欄などの不正な値は反映しない
  • 見た目が変わらない変更では、タイルを作り直さない
  • 保存すると、そのモニターのキーにだけ書き込む
  • mainのパネルを閉じると、全モニターのパネルも閉じ、未保存の変更は破棄する

共通設定(画像フォルダのリスト、ホットキー、無操作など)は、mainのパネルにだけ出します。サブのパネルで共通設定を編集できると、他のパネルの未保存のプレビューを壊してしまうためです。

5. 列数・行数とカメラの自動フィット

もともとタイルは10列×6行の固定でした。縦置きのモニター(1200×1600)では、10列が収まりません。

そこで cols / rows を設定にして、fitGrid をONにしたときは、グリッド全体が収まる距離にカメラを引くようにしました。

private fitCamera() {
  let z = 30; // fitGrid OFF のときは従来どおり
  if (this.fitGrid) {
    const tanHalf = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2));
    // 画像の縦横比でタイル幅が変わるので、横長(最大1.6倍)を見込む
    const w = (this.cols - 1) * this.spacingX + this.tileSize * 1.6;
    const h = (this.rows - 1) * this.spacingY + this.tileSize;
    const zForH = h / 2 / tanHalf;
    const zForW = w / 2 / (tanHalf * this.camera.aspect);
    z = Math.max(zForH, zForW) * 1.05; // 5%の余白
  }
  this.camera.position.set(0, 0, z);
}

縦のモニターで「4列×15行・自動調整ON」にしたら、きれいに収まりました。ウィンドウのサイズが変わったときにも、同じ計算をやり直します。

ついでに、タイルを作り直すたびに geometry / material / texture が解放されていなかったので、dispose を入れました。ライブプレビューで何度も作り直すので、放っておくとメモリが増える箇所でした。

6. ケーブルの抜き差しに追従する

マルチモニターのON/OFFは、保存した瞬間にサブウィンドウを開閉します(再起動は要りません)。

さらに、Rustの別スレッドが3秒おきにモニターの「名前・位置・サイズ」の指紋を比べて、構成の変化を検知します。同じ変化を2回続けて見たときだけ mainに monitors-changed を送ります。抜き差しの途中の揺れに反応しないためです。

実際にケーブルを抜いて挿し直すと、即座にサブウィンドウが開き直りました。

7. トレイ常駐・起動・終了

アプリは起動しても何も表示せず、タスクトレイに常駐します。

  • mainウィンドウは visible: false で作る。破棄はせず、設定の読み込みやホットキーの受け口として常駐させる
  • 「スクリーンセーバーを起動」:mainをプライマリモニターで全画面・最前面にし、マルチモニターONならサブも開く
  • ESCで終了:どのウィンドウでも、stop_saver がサブを閉じてmainを隠す
  • 「設定」:動いていなければ「設定モード」で起動する。全モニターに映した状態でパネルを出すので、ライブプレビューで見ながら調整できる

ここで1つハマりました。トレイのメニュー処理(Windowsのメインスレッド)の中でウィンドウを作ると、固まることがあります。そのため、ウィンドウを作る処理は async_runtime::spawn で別タスクにしています。

"start-saver" => {
    let app = app.clone();
    // メインスレッドでウィンドウを作ると固まることがあるので、別タスクで実行する
    tauri::async_runtime::spawn(async move {
        saver::start(&app, "tray", false);
    });
}

8. グローバルホットキー

公式プラグイン tauri-plugin-global-shortcut を使い、設定パネルの入力欄にキーを押して記録する方式にしました。

  • Ctrl+Alt+S のような形で入る。Ctrl / Alt / Shiftのどれかが必要(Fキーだけは単独でも可)
  • Backspace / Delete で解除
  • 他のアプリとキーが重なると登録に失敗するので、その理由をパネルとログに出す

9. 無操作で自動起動する

「無操作を検知するにはフックが要るのでは」と思っていましたが、要りませんでした。Windowsの GetLastInputInfo(システム全体で最後に入力があった時刻)を、Rustから1秒おきに読めば足ります。

#[cfg(windows)]
fn ticks() -> Option<(u32, u32)> {
    use windows_sys::Win32::System::SystemInformation::GetTickCount;
    use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};

    let mut info = LASTINPUTINFO {
        cbSize: std::mem::size_of::<LASTINPUTINFO>() as u32,
        dwTime: 0,
    };
    let ok = unsafe { GetLastInputInfo(&mut info) };
    if ok == 0 { return None; }
    let now = unsafe { GetTickCount() };
    Some((now, info.dwTime))
}

// GetTickCount は約49.7日で一周するので、wrapping で引く
fn idle_ms(now: u32, last_input: u32) -> u32 {
    now.wrapping_sub(last_input)
}

起動と終了のルールは次のとおりです。

  • 設定でONのときだけ動く(初期値はOFF、時間は1〜720分)
  • 自動起動したセーバーは、どのキー・マウス操作でも終了する(席を離れている間だけ動かす使い方のため)。起動から2秒の猶予があり、起動直後の手の動きでは終了しない
  • ホットキーやトレイで手動起動したセーバーは、ESCだけで終了する
  • 動画再生中は判断しないので、再生中でも無操作が続けば起動する

Windows専用のコードは、上の ticks() の数行だけにしてあります。経過時間の計算や一周したときの扱いは、OS非依存の関数にして、単体テストで確認しました。

10. 画像フォルダのリスト

毎回フォルダを指定するのが面倒なので、名前を付けたフォルダのリストを作りました。

  • リスト(folders)と共通の既定(defaultFolderId)は共通設定。編集はmainのパネルだけ
  • 各モニターは、「このモニターの画像フォルダ」で、リストから選ぶ(「共通の既定」も選べる)
  • 以前の targetDir があれば、「デフォルト」という名前の1件に自動で移行する
  • 選んだフォルダが消されていたら、警告をログに出して共通の既定に戻す

これで「縦のモニターだけ別のフォルダ」といった使い方が、設定パネルだけでできます。

11. リリースまで

  • アプリ名を WiredFrame、identifierを com.fixtan.wiredframe に変更
  • npm run tauri build で、NSISのインストーラー(約2.3MB)ができる
  • ログイン時の自動起動は tauri-plugin-autostart(レジストリのRunキー)。状態はOS側が持つので、設定ファイルには保存しない
  • タグを打って、gh release create でインストーラーを添付して公開
git tag v0.1.0
git push origin v0.1.0
gh release create v0.1.0 "…\WiredFrame_0.1.0_x64-setup.exe" --title "WiredFrame v0.1.0" --notes-file RELEASE_NOTES.md

GitHubのWebから手でファイルを上げるものだと思っていましたが、ターミナルから gh コマンドで全部できました。

12. ハマった点

識別子を変えると設定が消える identifierを変えると、WebView2のデータ保存先が変わります。localStorage の設定は引き継がれず、初回は空から入れ直しになりました(旧データは別の場所に残るだけで、消えてはいません)。公開前に変えておいてよかった点でもあります。

1ファイルを上書きし忘れて真っ黒になった 複数ファイルを渡されて、settings.ts だけ古いまま起動したら、画面が真っ黒でログも出ませんでした。ESMのimportエラーでモジュールの読み込み自体が失敗し、ログの仕組みも起動する前に止まっていたためです。この件のあと、index.html に「起動前のエラーを拾ってログに転送する」処理を入れました。

ESCが効かなくなった 設定パネルを閉じても、非表示のホットキー入力欄にフォーカスが残り、ESCを飲み込んでいました。パネルを閉じるときにフォーカスを外して直しています。

設定パネルが画面より縦に長いと保存できない 縦が足りないモニターで、保存ボタンが画面外に出ていました。パネルに最大の高さとスクロールを付けました。

13. 開発の進め方

実装はClaude Codeに書かせて、自分は実機でのデバッグ係でした。6枚のモニター(うち1枚は縦置き)は実機にしかなく、ケーブルの抜き差しも実機でしか試せません。

作業は「設計メモを書く → 小さい単位で実装 → 実機で確認 → 次へ」の繰り返しです。確認結果と仕様は docs/ のMarkdownに残しているので、スレッドが変わっても決めたことを引き継げます。

Windows専用の数行は、AI側の環境でコンパイルできませんでした。そのため、その部分を最小に絞り、残りを単体テストで固める形にしました。結果的に、実機で一発で動きました。

まとめ

  • モニターは「名前+解像度」で識別し、設定はモニター別に保存する。共通設定は別キーに分けて、ウィンドウ同士の上書きを避ける
  • ケーブルの抜き差しには、3秒おきの指紋比較と「同じ変化を2回見たら反応」で追従する
  • 無操作の検知は、フックではなく GetLastInputInfo を1秒おきに読むだけで足りる
  • ウィンドウを作る処理は、トレイのメインスレッドから外す
  • 作業を小さく区切り、実機で確認し、結果を docs/ に残す

v0.1.0はGitHubのReleasesから入手できます。次は、過去に作ったノイズ系のアニメーションを、別のモードとして追加していく予定です(v0.2.0)。機能の要望やフィードバックがあれば、お気軽にGitHubのIssuesまで。