システムトレイ(タスクトレイ)に常駐させる

Rust の TrayIconBuilder や JS の TrayIcon.new() でトレイにアイコンを出し、ツールチップや画像を実行中に変える。Cargo の tray-icon 機能と、再読み込みでアイコンが増える落とし穴も示す。

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

着信待ちや定期的な同期のように裏で動き続けるアプリは、Windows の通知領域(タスクトレイ)や macOS のメニューバーにアイコンを置くと、動いていることが一目で分かります。Tauri v2 のトレイは本体の機能で、Rust の TrayIconBuilder、JS の TrayIcon.new()、設定ファイルの app.trayIcon のどれでも作れます。この記事ではアイコンを出してツールチップを付け、状態に合わせて画像や文字を変えるところまでを扱います。メニューは トレイのメニュー、クリックは クリック時の処理 で説明します。

前提条件

プラグインは不要ですが、src-tauri/Cargo.toml で tauri の tray-icon 機能を有効にします。npm run tauri add では入らないので手で書き足します。PNG ファイルやバイト列からアイコンを読み込むなら image-png(ICO なら image-ico)も付けます。

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

トレイの作成や変更の権限(core:tray:default)は core:default に含まれます。JS でアプリの既定アイコンを取り出す defaultWindowIcon() の core:app:allow-default-window-icon は含まれないので足します。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:app:allow-default-window-icon"
  ]
}

作り方は次のように使い分けます。

やりたいこと使うもの
起動時からアイコンを出すだけ設定ファイルの app.trayIcon
メニューやクリックの処理まで組むRust の TrayIconBuilder(setup の中)
画面の操作に合わせて出し入れするJS の TrayIcon.new() / TrayIcon.removeById()
実行中にツールチップや画像を変えるID で取り出して set_tooltip() / setIcon() など

設定ファイルでは次のように書きます。iconPath は tauri.conf.json からの相対パスで、画像は実行ファイルに埋め込まれるので小さいもの(32 x 32 程度)にします。id の既定値は main です。メニューやクリックの処理は付かないので、必要なら Rust で app.tray_by_id("main") から後付けします。

{
  "app": {
    "trayIcon": {
      "id": "main",
      "iconPath": "icons/32x32.png",
      "tooltip": "My App"
    }
  }
}

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

JS で作ったアイコンはページを再読み込みしても消えないので、起動処理で毎回 TrayIcon.new() を呼ぶと、再読み込みのたびにアイコンが増えていきます。同じ ID が残っていれば消してから作り直します。使い回さないのは、前のページで渡したクリック時の action などが、再読み込み後は呼ばれないためです。

import { TrayIcon } from '@tauri-apps/api/tray';
import { defaultWindowIcon } from '@tauri-apps/api/app';

const TRAY_ID = 'main';

export async function createTray(): Promise<TrayIcon> {
  // 再読み込み前に作ったアイコンが残っていれば消す(残すと 2 つ並ぶ)
  if (await TrayIcon.getById(TRAY_ID)) {
    await TrayIcon.removeById(TRAY_ID);
  }
  const icon = await defaultWindowIcon(); // core:app:allow-default-window-icon が必要
  return TrayIcon.new({
    id: TRAY_ID,
    icon: icon ?? undefined,
    tooltip: 'My App',
  });
}

作った後は TrayIcon.getById() で取り出して変更します。setTooltip() はマウスを乗せたときの文字で、null で消せます。setTitle() はアイコンの横に出る文字で、未読件数のような短い数字に向きます。setVisible(false) ならアイコンを一時的に隠せます。

import { TrayIcon } from '@tauri-apps/api/tray';

// 未読件数をツールチップと、アイコン横の文字(macOS / Linux)に出す
export async function showUnread(count: number) {
  const tray = await TrayIcon.getById('main');
  if (!tray) return;
  await tray.setTooltip(count > 0 ? `My App - 未読 ${count} 件` : 'My App');
  await tray.setTitle(count > 0 ? String(count) : null);
}

setIcon() にはパスの文字列やバイト列も渡せます(image-png 機能が必要)。パスは実行時のカレントディレクトリから探すので配布後に見つからないことがあり、フロントエンドの画像を fetch() してバイト列で渡す方が確実です。

import { TrayIcon } from '@tauri-apps/api/tray';

// public/tray-busy.png をアイコンにする(image-png 機能が必要)
export async function setBusyIcon() {
  const tray = await TrayIcon.getById('main');
  const res = await fetch('/tray-busy.png');
  await tray?.setIcon(new Uint8Array(await res.arrayBuffer()));
}

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

常駐アプリでは Rust の setup で作るのがおすすめです。画面の再読み込みやウィンドウの状態に左右されず、メニューやクリックの処理も同じ場所にまとめられます。build() の戻り値は保持しなくても消えず、後から app.tray_by_id() で取り出せます。

画像は tauri::include_image! でビルド時に取り込むのが手軽です。パスは src-tauri 基準で、実行時の読み込みではないので image-png 機能は要りません。ウィンドウと同じアイコンなら app.default_window_icon() も使えますが、bundle.icon が空だと None なので unwrap() は避けます。

use tauri::image::Image;
use tauri::tray::TrayIconBuilder;

const TRAY_ID: &str = "main";
// 状態ごとの画像。例として同梱のアイコンを使っている(実際は icons/ に専用の画像を置く)
const ICON_IDLE: Image<'static> = tauri::include_image!("icons/32x32.png");
const ICON_BUSY: Image<'static> = tauri::include_image!("icons/Square30x30Logo.png");

/// 状態に合わせてアイコンとツールチップを変える(JS から invoke で呼ぶ)
#[tauri::command]
fn set_tray_status(app: tauri::AppHandle, busy: bool, message: String) -> Result<(), String> {
    let tray = app.tray_by_id(TRAY_ID).ok_or("tray icon not found")?;
    tray.set_icon(Some(if busy { ICON_BUSY } else { ICON_IDLE }))
        .map_err(|e| e.to_string())?;
    tray.set_tooltip(Some(format!("My App - {message}")))
        .map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            TrayIconBuilder::with_id(TRAY_ID)
                .icon(ICON_IDLE)
                .tooltip("My App - 待機中")
                .build(app)?;
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![set_tray_status])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

動作確認

npm run tauri dev で起動すると、トレイにアイコンが出ます。Windows で見当たらなければ、タスクバー右端の「^」で開く隠れたアイコンの中を探します。マウスを乗せると「My App - 待機中」と出ます。次を実行すると、アイコンとツールチップが切り替わります。

import { invoke } from '@tauri-apps/api/core';

await invoke('set_tray_status', { busy: true, message: '同期中' });

ウィンドウを閉じると、アプリごと終了してアイコンも消えます。トレイがあっても、最後のウィンドウが閉じた時点で終了するためです。残す方法は × でトレイに格納する と ウィンドウを持たない常駐アプリ で説明します。

よくあるエラーと対処法

  • tauri::tray を解決できない趣旨のコンパイルエラー: tray-icon 機能が有効になっていません。Cargo.toml の features に足します。JS のトレイ API もこの機能が前提です。
  • 「expected RGBA image data, found a file path」(または found raw bytes): JS の icon や setIcon() にパスやバイト列を渡したのに、image-png / image-ico 機能がありません。Rust で Image::from_bytes() や Image::from_path() が見つからないのも同じ原因です。
  • 「Provided Image path "…" doesn't exists」: include_image! のパスは src-tauri を基準に書きます。src-tauri/src からの相対で書くと見つかりません。
  • アイコンが 2 つ並ぶ: JS の再読み込み、設定ファイルとコードの両方での作成、アプリの二重起動のどれかで、作成が 2 回動いています。二重起動は Single Instance プラグイン で防げます。同じ ID で 2 つ作ると tray_by_id() は先の方を返すので、「変えたはずのアイコンが変わらない」形で現れることもあります。
  • 「app.default_window_icon not allowed. Permissions associated with this command: core:app:allow-default-window-icon」: 権限の追加漏れです。リリースビルドでは「Command plugin:app|default_window_icon not allowed by ACL」とだけ出ます。

OS ごとの違いと注意点

  • Windows: title(setTitle())は表示されません。数字を見せたいときはツールチップか、数字入りのアイコン画像で表します。
  • macOS: アイコンはメニューバーに並び、title はその横に文字で出ます。黒と透明だけの画像を iconAsTemplate: true(Rust は icon_as_template(true))にすると、メニューバーの明暗に合わせて色が変わります。実行中に差し替えるときは、setIcon() と setIconAsTemplate() を続けて呼ぶとちらつくので setIconWithAsTemplate() を使います。
  • Linux: ツールチップは表示されません。メニューが無いとアイコンが出ないことがあるので、空でもメニューを付けます。クリックのイベントも届かないため、操作はメニューに集めます。title はアイコンがあるときだけ出ます。
  • iOS / Android: トレイはありません。モバイル向けにもビルドするアプリでは、トレイのコードを #[cfg(desktop)] で囲みます。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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