[Tauri #09] WiredFrame v0.1.0 公開:モニター別設定・トレイ常駐・無操作起動を実装した
はじめに
前回(#08)は、Rustで各モニターの物理座標にウィンドウを置いて、全画面にするところまでを書きました。
そこから v0.1.0 を公開するまで、実装は一気に進みました。進みすぎて、自分でも「いま何が入っているのか」が追えなくなったので、この記事で整理します。
先に言うと、前回の実装は今では少し変わっています。
- F11の全画面切り替えは廃止した(常に全画面)
- 起動しただけでは何も表示せず、タスクトレイに常駐する
- モニターごとに設定を持つ(前回は全モニターが同じ設定だった)
スクリーンショット
設定パネル(画像フォルダの一覧・モニター別の画像フォルダ)
縦画面のモニターで列数・行数と自動フィットを設定
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まで。
Release WiredFrame v0.1.0 · fixtan/WiredFrame
画像フォルダの写真をタイル状に並べて動かす、マルチモニター対応のスクリーンセーバー(Windows 専用)。 主な機能 タスクトレイに常駐。ホットキー・トレイメニュー・無操作で起動、ESC で終了 モニターごとに独立した設定(画像フォルダ・パターン・タイルの大きさ・列数/行数・背景) 名前付きの画像フォルダリストを全モニター共通で管理 ケーブルの抜き差しやモニター構成の変更に自動で追従 W...
github.com