最小化ボタンでトレイに格納するようにする

最小化と × でウィンドウを隠し、トレイのアイコンだけを残す。最小化専用のイベントが無いため onResized と isMinimized() で検知する方法と、Rust の on_window_event 版を示す。

メニュー・トレイ 対象: Tauri 2.x 更新日: 読了目安: 約10分 menu-013
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 2. バックエンドから実装する (Rust)
  4. 動作確認
  5. よくあるエラーと対処法
  6. OS ごとの違いと注意点
  7. 関連レシピ

常駐アプリでは、最小化ボタンや × を押すとタスクバーから消え、トレイ(macOS はメニューバー)のアイコンだけが残る動きがよく求められます。Tauri にはこれを有効にする設定項目は無く、「最小化されたら隠す」「閉じる操作を止めて隠す」「トレイから戻す」の 3 つを組み合わせて作ります。最小化を知らせる専用のイベントも無いので、ウィンドウの大きさが変わったときに最小化されているかを確かめるのが要点です。アイコンの作り方は システムトレイに常駐させる、クリックの受け方は クリック時の処理 で説明しています。

前提条件

トレイを使うので、src-tauri/Cargo.toml で tauri の tray-icon 機能を有効にします(npm run tauri add では入りません)。

[dependencies]
tauri = { version = "2", features = ["tray-icon"] }

JS で組む場合、isMinimized() とイベントの購読は core:default で使えますが、hide() / show() / unminimize() / setFocus() の権限は含まれないので足します。格納しない設定のときに onCloseRequested() で閉じさせるなら core:window:allow-destroy も要ります(理由は 閉じる前に確認ダイアログを出す を参照)。Rust だけで組むなら追加は不要です。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:window:allow-hide",
    "core:window:allow-show",
    "core:window:allow-unminimize",
    "core:window:allow-set-focus",
    "core:window:allow-destroy"
  ]
}

操作ごとに、次のように標準の動きを置き換えます。

操作標準の動き格納するアプリでの処理
最小化ボタンタスクバーに残る最小化を検知して hide()
× ボタンウィンドウが破棄され、最後の 1 つならアプリも終了閉じる操作を止めて hide()
トレイのクリック・「表示」なしshow() → unminimize() → setFocus()
トレイの「終了」なしRust は app.exit(0)、JS は Process プラグインの exit()

1. フロントエンドから実装する (TypeScript)

最小化は onResized() で受けます。Windows と Linux では最小化したときにもこのイベントが届くので、その中で isMinimized() を確かめて隠します。× は onCloseRequested() で preventDefault() してから隠します。格納するかどうかを切り替えられるようにしておくと、「× では終了したい」利用者にも合わせられます。

import { getCurrentWindow } from '@tauri-apps/api/window';

const win = getCurrentWindow();
let hideToTray = true; // 設定画面のチェックボックスなどから切り替える

export function setHideToTray(on: boolean) {
  hideToTray = on;
}

// 最小化されたら隠す(最小化専用のイベントは無い)
await win.onResized(async () => {
  if (hideToTray && (await win.isMinimized())) {
    await win.hide(); // core:window:allow-hide
  }
});

// × でも閉じずに隠す
await win.onCloseRequested(async (event) => {
  if (!hideToTray) return; // 止めなければそのまま閉じる(core:window:allow-destroy)
  event.preventDefault();
  await win.hide();
});

// トレイのクリックやメニューの「ウィンドウを表示」から呼ぶ
export async function restoreFromTray() {
  await win.show();
  await win.unminimize(); // 最小化したまま隠れていた場合は元の大きさに戻す
  await win.setFocus();
}

restoreFromTray() は、menu-012 の JS 版で作るアイコンの action や、menu-010 のメニューから呼びます。最小化したまま隠したウィンドウは、show() だけではタスクバーのボタンとして戻るだけなので、unminimize() まで呼びます。

JS 版は手軽ですが、処理はページの中で動きます。再読み込みの途中や、起動処理の例外でハンドラーの登録まで届かなかったときは、× でそのまま閉じてアプリが終了します。確実に格納したいなら次の Rust 版にします。

2. バックエンドから実装する (Rust)

Builder::on_window_event() には、すべてのウィンドウのイベントが届きます。ラベルで main に絞り、CloseRequested では api.prevent_close() で止めてから隠し、Resized では is_minimized() を確かめて隠します。下の例では、トレイのメニューに「閉じる・最小化でトレイに格納」のチェックを置き、利用者が切り替えられるようにしています。

use std::sync::atomic::{AtomicBool, Ordering};
use tauri::menu::{CheckMenuItem, MenuBuilder, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Manager, Window, WindowEvent};

/// × と最小化でトレイに格納するか(トレイのメニューで切り替える)
struct HideToTray(AtomicBool);

/// 隠れているか最小化されている main を前面に戻す
fn restore_main(app: &AppHandle) -> tauri::Result<()> {
    let Some(window) = app.get_webview_window("main") else {
        return Ok(());
    };
    window.show()?;
    window.unminimize()?;
    window.set_focus()
}

fn handle_window_event(window: &Window, event: &WindowEvent) {
    // main 以外(サブウィンドウ)は普通に閉じさせる
    if window.label() != "main" || !window.state::<HideToTray>().0.load(Ordering::Relaxed) {
        return;
    }
    match event {
        WindowEvent::CloseRequested { api, .. } => {
            api.prevent_close(); // 破棄を止めるので、アプリの終了も起きない
            let _ = window.hide();
        }
        // macOS の最小化は Dock に入れるのが普通なので横取りしない
        #[cfg(not(target_os = "macos"))]
        WindowEvent::Resized(_) => {
            if window.is_minimized().unwrap_or(false) {
                let _ = window.hide();
            }
        }
        _ => {}
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(HideToTray(AtomicBool::new(true)))
        .setup(|app| {
            let show = MenuItem::with_id(app, "tray-show", "ウィンドウを表示", true, None::<&str>)?;
            let hide_opt = CheckMenuItem::with_id(
                app,
                "tray-hide-to-tray",
                "閉じる・最小化でトレイに格納",
                true,
                true,
                None::<&str>,
            )?;
            let quit = MenuItem::with_id(app, "tray-quit", "終了", true, None::<&str>)?;
            let menu = MenuBuilder::new(app)
                .items(&[&show, &hide_opt])
                .separator()
                .item(&quit)
                .build()?;

            TrayIconBuilder::with_id("main")
                .icon(tauri::include_image!("icons/32x32.png"))
                .tooltip("My App")
                .menu(&menu)
                .show_menu_on_left_click(false) // 左クリックはウィンドウを戻すのに使う
                .on_tray_icon_event(|tray, event| {
                    if let TrayIconEvent::Click {
                        button: MouseButton::Left,
                        button_state: MouseButtonState::Up,
                        ..
                    } = event
                    {
                        let _ = restore_main(tray.app_handle());
                    }
                })
                .on_menu_event(move |app, event| match event.id().as_ref() {
                    "tray-show" => {
                        let _ = restore_main(app);
                    }
                    "tray-hide-to-tray" => {
                        // チェックは押した時点で切り替わっている。新しい状態を読む
                        let on = hide_opt.is_checked().unwrap_or(true);
                        app.state::<HideToTray>().0.store(on, Ordering::Relaxed);
                    }
                    "tray-quit" => app.exit(0), // 閉じる要求を通らずに終了する
                    _ => {}
                })
                .build(app)?;
            Ok(())
        })
        .on_window_event(handle_window_event)
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

prevent_close() で止めたウィンドウは破棄されないので、「最後のウィンドウが閉じたらアプリも終了」も起きません。終了に使う app.exit(0) は閉じる要求を出さないので、格納の処理とぶつかりません。同じウィンドウで JS の onCloseRequested() も登録すると JS 側が destroy() を呼んで閉じてしまうため、Rust 版と JS 版はどちらか一方にします。

動作確認

npm run tauri dev で起動し、最小化ボタンを押すとウィンドウがタスクバーから消え、トレイのアイコンだけが残ります。アイコンを左クリックすると、最小化する前の大きさと位置で前面に戻ります。× を押しても同じように隠れます。トレイのメニューで「閉じる・最小化でトレイに格納」のチェックを外してから × を押すと、ウィンドウが閉じてアプリが終了し、アイコンも消えます。

JS 版では、戻した直後に次の関数を呼ぶと状態を確かめられます。

import { getCurrentWindow } from '@tauri-apps/api/window';

export async function logWindowState() {
  const win = getCurrentWindow();
  console.log('visible:', await win.isVisible(), '/ minimized:', await win.isMinimized());
}
visible: true / minimized: false

よくあるエラーと対処法

  • 「終了」を選んでもウィンドウが隠れるだけ: 終了の処理で close() を呼んでいます。close() は × と同じ閉じる要求を出すので、格納の処理に止められます。Rust は app.exit(0)、JS は Process プラグインの exit() で終了します。
  • サブウィンドウの × までトレイに入る: 対象を絞っていません。Rust 版は window.label() で main に限ります。JS 版は登録したページのウィンドウにだけ効くので、共通のスクリプトで全ウィンドウに登録していないかを確かめます。
  • トレイから戻すとタスクバーにボタンが出るだけ: 最小化したまま隠したウィンドウに show() だけを呼んでいます。続けて unminimize() を呼びます。
  • × を押しても何も起きない: JS 版で hide() の権限がありません。コンソールに「window.hide not allowed. Permissions associated with this command: core:window:allow-hide」が出ます(リリースビルドでは「Command plugin:window|hide not allowed by ACL」)。ハンドラーが例外で止まるので、閉じも隠しもしません。
  • Rust で止めたのに閉じてしまう: 同じウィンドウに JS の onCloseRequested() もあります。どちらか一方にします(win-016)。

OS ごとの違いと注意点

  • Windows: 隠したウィンドウはタスクバーから消え、トレイのアイコンも「^」の中に隠れていることが多いため、利用者には終了したように見えます。初めて格納したときだけ「トレイで動作中です」と 通知 を出すと親切です。
  • macOS: 最小化したウィンドウは Dock に入るのが一般的な動きなので、Rust の例では #[cfg(not(target_os = "macos"))] で最小化の横取りを外し、× での格納だけにしています。JS 版の最小化の検知も macOS では当てにしません。× で隠したウィンドウは Dock のアイコンを押しても戻らないので、RunEvent::Reopen で表示します(ウィンドウを表示・非表示にする)。
  • Linux: トレイのアイコンのクリックが届かないので、戻す操作はメニューの「ウィンドウを表示」で行います(menu-012)。
  • 起動時から格納しておく: ウィンドウに "visible": false を付けると、トレイに入った状態で起動します。必要なときだけウィンドウを作る方法は ウィンドウを持たない常駐アプリを作る で扱います。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する