ウィンドウを持たない常駐アプリを作る

起動時にウィンドウを作らずトレイだけで動かし、必要なときだけ設定画面を開く。最後のウィンドウを閉じても終了させない prevent_exit() と、macOS で Dock から消す方法も示す。

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

クリップボードの履歴やフォルダの同期のように、ふだんは画面が要らず、たまに設定を開くだけのアプリもあります。Tauri では起動時にウィンドウを 1 つも作らず、トレイのアイコンだけで動かせます。つまずきやすいのは「開いた設定画面を閉じると、アプリごと終了する」点で、終了を止める処理が要ります。トレイのアイコンとメニューそのものは menu-009 と menu-010 で説明しています。

前提条件

src-tauri/Cargo.toml で tauri の tray-icon 機能を有効にします。JS 版の「終了」には Process プラグイン(npm run tauri add process)を使います。

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

作り方は 2 通りあります。

方式設定ファイルJS常に使うメモリ
隠したウィンドウを 1 つ置くmain に "visible": false隠したページで常に動くWebView 1 つ分
ウィンドウを作らない起動時のウィンドウを置かない開いたウィンドウの中だけ開いている間だけ

app.windows の既定値は空の配列なので、何も書かなければウィンドウは作られません。後から開くウィンドウは "create": false で定義だけ置いておくと、大きさやタイトルを設定ファイルで管理したまま、Rust の WebviewWindowBuilder::from_config() で作れます。

{
  "app": {
    "windows": [
      { "label": "settings", "title": "設定", "url": "settings.html", "width": 480, "height": 360, "create": false }
    ]
  }
}

権限はウィンドウのラベルで判定されるので、後から作るウィンドウのラベルも capability の windows に入れておきます。JS からウィンドウを作るなら core:webview:allow-create-webview-window、前に出すなら show / unminimize / set-focus の権限を足します(どれも core:default に含まれません)。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the hidden main window and the settings window",
  "windows": ["main", "settings"],
  "permissions": [
    "core:default",
    "core:app:allow-default-window-icon",
    "core:webview:allow-create-webview-window",
    "core:window:allow-show",
    "core:window:allow-unminimize",
    "core:window:allow-set-focus",
    "process:allow-exit"
  ]
}

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

JS は WebView の中でしか動かないため、JS で組むなら「隠したウィンドウを 1 つ置く」方式になります。main を "visible": false にして、そのページでトレイを作ります。

{
  "app": {
    "windows": [
      { "label": "main", "url": "index.html", "visible": false }
    ]
  }
}

設定ウィンドウは開くたびに作り、× で破棄されますが、隠した main が残るのでアプリは終了しません。

import { TrayIcon } from '@tauri-apps/api/tray';
import { Menu } from '@tauri-apps/api/menu';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { defaultWindowIcon } from '@tauri-apps/api/app';
import { exit } from '@tauri-apps/plugin-process';

// 設定ウィンドウを出す。開いていれば前に出し、無ければ作る
async function openSettings() {
  const opened = await WebviewWindow.getByLabel('settings');
  if (opened) {
    await opened.show();
    await opened.unminimize();
    await opened.setFocus();
    return;
  }
  const settings = new WebviewWindow('settings', {
    url: 'settings.html',
    title: '設定',
    width: 480,
    height: 360,
  });
  void settings.once('tauri://error', (e) => console.error('設定ウィンドウを作れない:', e.payload));
}

// 隠した main のページで動かす。再読み込みでアイコンを増やさない(menu-009)
if (await TrayIcon.getById('main')) await TrayIcon.removeById('main');
await TrayIcon.new({
  id: 'main',
  icon: (await defaultWindowIcon()) ?? undefined,
  tooltip: 'My Agent',
  menu: await Menu.new({
    items: [
      { id: 'tray-settings', text: '設定を開く', action: () => void openSettings() },
      { item: 'Separator' },
      { id: 'tray-quit', text: '終了', action: () => void exit(0) },
    ],
  }),
});

隠したページのタイマーは間引かれることがあるので(win-011)、時刻の正確さが要る定期処理は Rust 側に置きます。隠したページのエラーは目に入らないため、開発中は一時的に "visible": true にして DevTools で確かめます。

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

ウィンドウを作らない方式は Rust で組みます。要点は 2 つです。

  • 終了を止める: 開いていたウィンドウがすべて閉じると RunEvent::ExitRequested が届き、何もしなければ終了します。code が None(利用者がウィンドウを閉じた)のときだけ api.prevent_exit() を呼びます。app.exit(0) による終了は code が Some(0) で届くので止めません。prevent_exit() はイベントを受けたその場で呼びます。別スレッドに渡した処理の中で呼んでも間に合いません。
  • ウィンドウは別スレッドで作る: Windows では、イベントの処理中や同期コマンドの中でウィンドウを作ると固まることがあります。トレイのメニューからは std::thread::spawn で別スレッドに移して作ります(コマンドから作るなら async にします)。
use tauri::menu::{MenuBuilder, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Manager, RunEvent, WebviewWindowBuilder};

const SETTINGS: &str = "settings";

/// 設定ウィンドウを出す。開いていれば前に出し、無ければ tauri.conf.json の定義から作る
fn open_settings(app: &AppHandle) -> tauri::Result<()> {
    if let Some(window) = app.get_webview_window(SETTINGS) {
        window.show()?;
        window.unminimize()?;
        return window.set_focus();
    }
    let config = app
        .config()
        .app
        .windows
        .iter()
        .find(|w| w.label == SETTINGS)
        .cloned()
        .ok_or(tauri::Error::WindowNotFound)?;
    let window = WebviewWindowBuilder::from_config(app, &config)?.build()?;
    window.set_focus()
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let app = tauri::Builder::default()
        .setup(|app| {
            // macOS: Dock にアイコンを出さない
            #[cfg(target_os = "macos")]
            app.set_activation_policy(tauri::ActivationPolicy::Accessory);

            let settings = MenuItem::with_id(app, "tray-settings", "設定を開く", true, None::<&str>)?;
            let quit = MenuItem::with_id(app, "tray-quit", "終了", true, None::<&str>)?;
            let menu = MenuBuilder::new(app).item(&settings).separator().item(&quit).build()?;

            TrayIconBuilder::with_id("main")
                .icon(tauri::include_image!("icons/32x32.png"))
                .tooltip("My Agent")
                .menu(&menu)
                .on_menu_event(|app, event| match event.id().as_ref() {
                    "tray-settings" => {
                        // イベントの処理中に直接作らず、別スレッドで作る(Windows で固まるのを避ける)
                        let app = app.clone();
                        std::thread::spawn(move || {
                            if let Err(e) = open_settings(&app) {
                                eprintln!("設定ウィンドウを開けない: {e}");
                            }
                        });
                    }
                    "tray-quit" => app.exit(0),
                    _ => {}
                })
                .build(app)?;
            Ok(())
        })
        .build(tauri::generate_context!())
        .expect("error while building tauri application");

    app.run(|_app, event| {
        if let RunEvent::ExitRequested { code, api, .. } = event {
            println!("ExitRequested: code = {code:?}");
            // 利用者がウィンドウを閉じたときだけ止める。app.exit() は Some(コード) で届く
            if code.is_none() {
                api.prevent_exit();
            }
        }
    });
}

from_config() は同じラベルのウィンドウが既にあると作れないので、先に get_webview_window() で確かめています。macOS では、ウィンドウが無くても Dock にアイコンが出ます。macOS 専用の set_activation_policy(ActivationPolicy::Accessory) を #[cfg(target_os = "macos")] で囲んで呼ぶと、Dock から外れます。

動作確認

npm run tauri dev で起動すると、ウィンドウは出ずにトレイにアイコンだけが現れます(Windows では「^」の中にあることがあります)。メニューの「設定を開く」で設定ウィンドウが開き、もう一度選ぶと 2 つ目は開かずに前面に出ます。× で閉じてもアイコンは残り、「終了」を選ぶとアプリが終わります。Rust 版のターミナルには、× で閉じたとき(止めた)と「終了」を選んだときに次のように出ます。

ExitRequested: code = None
ExitRequested: code = Some(0)

よくあるエラーと対処法

  • 設定ウィンドウを閉じるとアプリごと終了する: ExitRequested で prevent_exit() を呼んでいないか、別スレッドに渡した処理の中で呼んでいます。run() に渡した関数の中で直接呼びます。
  • 「終了」を選んでも終わらない: code を見ずに毎回 prevent_exit() を呼んでいます。None のときだけにします。
  • 設定を開こうとすると固まる(Windows): メニューのイベント処理の中で直接 build() しています。別スレッドで作ります。
  • 「a window with label settings already exists」: 同じラベルで 2 回作ろうとしています。メニューを続けて選ぶと、1 回目の作成が終わる前に 2 回目が走って起こることがあります。ウィンドウはできているので、無視して構いません。
  • 起動し直すとトレイのアイコンが 2 つになる: ウィンドウが無いので利用者は起動中だと気付かず、もう一度起動すると別のプロセスが立ち上がります。Single Instance プラグイン で 2 回目の起動を受け取り、代わりに設定ウィンドウを開きます。
  • 「webview.create_webview_window not allowed. Permissions associated with this command: core:webview:allow-create-webview-window」: JS 版の権限不足です(リリースビルドでは「Command plugin:webview|create_webview_window not allowed by ACL」)。

OS ごとの違いと注意点

  • Windows: ウィンドウが無いとタスクバーに何も出ず、手がかりはトレイのアイコンだけです。初回の起動時だけ設定ウィンドウを開くか、通知 で常駐を知らせます。
  • macOS: Dock のアイコンを残す場合、押されると RunEvent::Reopen が届きます。ウィンドウが無いときに押されたら設定ウィンドウを開くと自然です(受け方は ウィンドウを表示・非表示にする の Dock の例と同じ)。
  • Linux: アイコンのクリックが届かず、メニューが無いとアイコンが出ないこともあるので、操作はすべてメニューに入れます(menu-012)。
  • 自動起動: 常駐アプリはログインと同時に動かしたいことが多いので、OS 起動時にアプリを自動起動させる と組み合わせます。
  • メイン画面のあるアプリ: 同じウィンドウを出し入れするなら、破棄せずに隠す menu-013 の方が向いています。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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