[Tauri #05] Tauri v2:多機能化したWeb版時計をデスクトップへ移植 — 設定ウィンドウ分離・ネイティブメニュー・トレイ常駐

[Tauri #05] Tauri v2:多機能化したWeb版時計をデスクトップへ移植 — 設定ウィンドウ分離・ネイティブメニュー・トレイ常駐

はじめに

以前、WEBサイト用のアナログ時計をTauriでデスクトップアプリ化し、「Lain Clock」として公開しました。

その後、Web版の時計(Analog Clock UI)は、文字盤のカスタマイズ、背景スキン、プリセット、時報……と機能が増え続け、デスクトップ版とはすっかり別物になっていました。

今回はそのWeb版を、デスクトップ版 Lain Clock v0.2.0 として Windows / macOS / Linux に一括移植した記録です。

スクリーンショット

[Tauri #05] Lain Clock v0.2.0 [Tauri #05] Lain Clock v0.2.0 [Tauri #05] Lain Clock v0.2.0

前回の記事:

Web版の使い方:

1. 移植の前に、Web版を整備する

いきなり移植せず、まずWeb版のコードを「移植しやすい綺麗な状態」に整えました。移植してから直すと、同じバグを2か所で直すことになるからです。

地雷①:localStorage.clear() がサイト全体を消す

設定画面の「RESET ALL」がこうなっていました。

localStorage.clear(); // ★問題点

lain-lab.com は1つのドメインに大量のツールが同居しています。これを押すと、時計以外のツールの設定まで全部消えます。時計が使うキーだけを明示的に消すように変更。

const CLOCK_KEYS = ['clock_master_config', 'clock_preset_1', 'clock_preset_2', 'clock_preset_3'];
CLOCK_KEYS.forEach(k => localStorage.removeItem(k));

clock_ で始まるキーを全部消す方法もありますが、他の時計系ツールが同じ接頭辞を使っていると巻き込むので、リストで持つ方が安全です。

地雷②:instanceof TouchEvent は macOS で落ちる

const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;

Safari(WKWebView)やデスクトップ版Firefoxには TouchEvent 自体が定義されていないため、ここで ReferenceError になりドラッグが死にます。Tauri の macOS 版は WKWebView なので確実に踏みます。

const pt = 'touches' in e ? e.touches[0] : e;

地雷③:Blob URL の作りっぱなし

IndexedDB に保存したスキン画像を URL.createObjectURL() で表示していましたが、revokeObjectURL() がありませんでした。Webならタブを閉じれば消えますが、デスクトップで一日中常駐する時計ではメモリが積み上がっていきます。

if (activeSkinBlobUrl) URL.revokeObjectURL(activeSkinBlobUrl);
activeSkinBlobUrl = URL.createObjectURL(skin.data);

地雷④:requestAnimationFrame だと時報を取りこぼす

秒針の音と正時の時報を、針の描画ループ(requestAnimationFrame)の中で判定していました。rAF はウィンドウが隠れたり最小化されると止まるので、0分0秒ちょうどの判定を取りこぼします。

音の判定だけ setInterval に分け、判定も「0分0秒」ではなく「0分台に入ったら1時間に1回」に変更しました。

setInterval(() => {
  const d = new Date();
  const m = d.getMinutes(), h = d.getHours();
  if (m === 0 && lastChimeHour !== h) {
    playSE(config, sounds.chime);
    lastChimeHour = h;
  }
}, 200);

2. 構成:時計ウィンドウと設定ウィンドウを分ける

Web版は時計の上に設定モーダル(幅360px)を重ねていましたが、デスクトップ版ではウィンドウの大きさ=時計の大きさです。80pxの時計ウィンドウに360pxのモーダルは入りません。

そこで設定を別ウィンドウに分けました。

src/
├── index.html / main.js       … 時計ウィンドウ(透過・枠なし・最前面)
├── settings.html / settings.js … 設定ウィンドウ
└── common.js                   … 設定の読み書き・IndexedDB・効果音(共通)

2つのウィンドウは同じオリジンなので、localStorage と IndexedDB はそのまま共有できます。変更の通知だけ Tauri のイベントで送ります。

// 保存して、もう一方のウィンドウに通知
export async function commitConfig(config) {
  localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
  await window.__TAURI__.event.emit('clock-config-changed');
}

受け取った側は設定を読み直して反映するだけ。設定画面でスライダーを動かすと、時計がリアルタイムで変わります。

設定ウィンドウ

⚠ Windows:同期コマンドでウィンドウを作るとデッドロック

設定ウィンドウは Rust 側のコマンドで開いています。ここで注意なのが、Windows では同期コマンドの中でウィンドウを生成するとデッドロックすること。async にしておけば回避できます。

#[tauri::command]
async fn open_config(app: AppHandle) -> Result<(), String> {
    open_config_window(&app).map_err(|e| e.to_string())
}

トレイメニューのイベントハンドラから開く場合も、tauri::async_runtime::spawn で逃がしています。

3. 右クリックメニューを「OSのメニュー」にする

Web版の右クリックメニューはHTMLで描いていました。しかしデスクトップ版では、時計が小さいとメニューがウィンドウからはみ出して見切れます。

Tauri v2 の Menu API で、OS標準のメニューに置き換えました。

const { Menu, MenuItem, CheckMenuItem, PredefinedMenuItem } = window.__TAURI__.menu;

const sound = await CheckMenuItem.new({
  text: 'Sound', checked: config.isSoundEnabled,
  action: () => toggle('isSoundEnabled'),
});

const menu = await Menu.new({ items: [ /* … */ sound, /* … */ ] });
await menu.popup();

ウィンドウの外にはみ出せるので、80pxの時計でも問題なし。チェック付き項目もOS標準で出ます。

4. HIDE はトレイへ

Web版の「HIDE」は、時計を消して画面端に小さなタブを出す仕様でした。デスクトップには「ページの端」がないので、トレイアイコンに格納する形に変えています。

TrayIconBuilder::with_id("main-tray")
    .icon(app.default_window_icon().cloned().expect("no app icon"))
    .menu(&menu) // Show / Hide / Config / Quit
    .on_menu_event(|app, event| match event.id.as_ref() {
        "show" => show_main(app),
        "hide" => hide_main(app),
        "quit" => app.exit(0),
        _ => {}
    })
    .build(app)?;

Cargo.toml の tauri に tray-icon の feature を足すのを忘れずに。

5. WebView依存を減らす

Web版で当たり前に使っていたものが、WebViewによっては動きません。

Web版デスクトップ版
alert() / confirm()設定画面下のステータス行に表示。リセットは「2回押し」で確定
<a download> でJSON保存Rustコマンドでダウンロードフォルダへ書き出し
window.open('/featured/…')opener プラグインでブラウザを開く
Dexie(npm)バンドラーなし構成なので、素の IndexedDB で30行の自前ラッパー

エクスポートは、ダウンロードフォルダが取れない環境(XDG未設定のLinuxなど)ではホームに逃がしています。

let p = app.path();
let dir = p.download_dir()
    .or_else(|_| p.document_dir())
    .or_else(|_| p.home_dir())
    .map_err(|e| e.to_string())?;

ウィンドウ位置の記憶

tauri-plugin-window-state を使っていますが、記憶するのは位置だけにしています。

tauri_plugin_window_state::Builder::default()
    .with_state_flags(StateFlags::POSITION)
    .with_denylist(&["config"])
    .build()

デフォルトの全部入りだと「表示状態」も記憶するので、トレイに隠したまま終了すると、次回起動時に時計が見えない状態で立ち上がってしまいます。

6. 最後に踏んだ小石

背景スキンが円形に切り抜かれる

v0.1 の丸い時計の名残で #analog-clock に border-radius: 50% が残っていました。背景画像まで円で切られるので、花が枠の外にはみ出すデザインのスキンだと端が欠けます。1行消して解決。

フォルダに画像を置いてもスキンが増えない

WebViewはフォルダの中身を列挙できません。スキンは list.json に書いたものだけが読み込まれます。

{ "id": "flower", "name": "Flower", "url": "/assets/clockSkins/clock-bg-flower.webp" }

macOS Sequoia では「右クリック→開く」が効かない

未署名アプリの起動方法が変わりました。今は「システム設定 → プライバシーとセキュリティ → このまま開く」です。

まとめ:半年前の自分に助けられた

GitHub Actions に v0.2.0 のタグを push すると、macOS 4分、Ubuntu と Windows が 5分で、3つとも一発で緑になりました。

macOS の実機でも、白背景は出ず、ドラッグも右クリックも最初から動きました。macOSPrivateApi、-webkit-app-region: no-drag、GitHub Actions の設定。前回の記事で v0.1.1 から v0.1.16 まで、タグを打ち直しながら潰した問題が、全部そのまま効いていたからです。

あの時の地獄がなければ、今回の一発はありませんでした。