ウィンドウのアイコンを変更する

setIcon()(権限 core:window:allow-set-icon)でウィンドウのアイコンを実行中に差し替える。必要な Cargo の image-png、canvas で描くバッジ、タスクバー・Dock のバッジも示す。

ウィンドウ 対象: Tauri 2.x 更新日: 読了目安: 約11分 win-013
目次
  1. 前提条件
  2. 既定のアイコンと表示される場所
  3. 1. フロントエンドから実装する (TypeScript)
  4. 用意した PNG に切り替える
  5. canvas で描いた画像にする(feature 不要)
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

録音中は赤いアイコンにする、未読があれば印を付ける、サブウィンドウごとに別のアイコンにする、といった場面では、ウィンドウのアイコンを実行中に差し替えます。JS の setIcon() か Rust の set_icon() を使い(プラグイン不要)、画像はファイルのパス・PNG のバイト列・画素データのどれでも渡せます。ただしパスとバイト列には Cargo の feature が必要で、macOS にはウィンドウのアイコン自体がありません。実行ファイルやインストーラーのアイコンを作る方法は アプリアイコンを画像から生成する で扱います。

前提条件

API権限core:default
setIcon()core:window:allow-set-icon含まれない
defaultWindowIcon()(既定のアイコンを取得)core:app:allow-default-window-icon含まれない
Image.new() / fromBytes() / fromPath()core:image:default含まれる
setOverlayIcon()(Windows のタスクバーの印)core:window:allow-set-overlay-icon含まれない
setBadgeCount()(Dock などの数字)core:window:allow-set-badge-count含まれない
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:window:allow-set-icon",
    "core:app:allow-default-window-icon"
  ]
}

PNG のパスやバイト列を渡すには、src-tauri/Cargo.toml の tauri に image-png(ICO なら image-ico)の feature を足します。画素データ(RGBA)を渡す Image.new() と、後述の Rust の include_image! は feature 無しで使えます。

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

既定のアイコンと表示される場所

設定ファイルにはウィンドウごとのアイコンの項目が無く、全ウィンドウ共通の既定のアイコンが bundle.icon から選ばれてビルド時に埋め込まれます(Windows は最初の .ico、それ以外は最初の .png)。setIcon() で変わるのは呼んだウィンドウだけで、後から開くウィンドウは既定のアイコンで開きます。

表示される場所変える方法
タイトルバー左上など、ウィンドウ自身のアイコンsetIcon() / set_icon()
タスクバーのボタンに重ねる小さな印(Windows)setOverlayIcon() / set_overlay_icon()
Dock などに出る数字のバッジ(macOS・Linux)setBadgeCount() / set_badge_count()
実行ファイル・インストーラー・Dock のアプリアイコンbundle.icon(build-002)
システムトレイのアイコントレイの setIcon()(menu-009)

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

用意した PNG に切り替える

画像をフロントエンドの素材として置き(Vite なら public/icons/)、fetch() で読んで渡すと、パスの解決も bundle.resources の設定も要りません。切り替えのたびに画像を送り直さないよう、Image.fromBytes() で一度だけ読み込んで使い回します。

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

type IconState = 'idle' | 'recording' | 'error';

const win = getCurrentWindow();
const cache = new Map<IconState, Image>();

async function loadIcon(state: IconState): Promise<Image> {
  const cached = cache.get(state);
  if (cached) return cached;
  const res = await fetch(`/icons/${state}.png`); // public/icons/ に置いた PNG
  if (!res.ok) throw new Error(`icon ${state}: HTTP ${res.status}`);
  const image = await Image.fromBytes(await res.arrayBuffer()); // Cargo の image-png が必要
  cache.set(state, image);
  return image;
}

export async function showState(state: IconState) {
  await win.setIcon(await loadIcon(state)); // core:window:allow-set-icon が必要
  console.log('[icon]', state);
}

配布物に同梱 したファイルのパスを渡すなら、resolveResource() で絶対パスにします。相対パスは起動したときの作業フォルダーが基準になるので、環境によって見つかりません。

canvas で描いた画像にする(feature 不要)

未読件数のように中身が変わる印は、既定のアイコンに canvas で描き足し、画素データを Image.new() で渡します。Image は Rust 側に置かれた画像を指すので、使い終わったら close() で解放します。

import { getCurrentWindow } from '@tauri-apps/api/window';
import { Image } from '@tauri-apps/api/image';
import { defaultWindowIcon } from '@tauri-apps/api/app';

const SIZE = 64;
let base: ImageBitmap | null = null;

// 既定のアイコンを一度だけ読み、canvas に描ける形にしておく
async function loadBase(): Promise<ImageBitmap | null> {
  if (base) return base;
  const icon = await defaultWindowIcon(); // core:app:allow-default-window-icon が必要
  if (!icon) return null;
  const { width, height } = await icon.size();
  const pixels = new ImageData(Uint8ClampedArray.from(await icon.rgba()), width, height);
  await icon.close();
  base = await createImageBitmap(pixels);
  return base;
}

export async function showUnread(count: number) {
  const bitmap = await loadBase();
  const ctx = document.createElement('canvas').getContext('2d');
  if (!bitmap || !ctx) return;
  ctx.canvas.width = ctx.canvas.height = SIZE;
  ctx.drawImage(bitmap, 0, 0, SIZE, SIZE);
  if (count > 0) {
    ctx.fillStyle = '#e53935'; // 右下に赤い丸と件数を描く
    ctx.beginPath();
    ctx.arc(SIZE - 18, SIZE - 18, 17, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#fff';
    ctx.font = 'bold 22px sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(count > 9 ? '9+' : String(count), SIZE - 18, SIZE - 17);
  }
  const rgba = new Uint8Array(ctx.getImageData(0, 0, SIZE, SIZE).data);
  const icon = await Image.new(rgba, SIZE, SIZE); // 画素データなので feature は不要
  await getCurrentWindow().setIcon(icon);
  await icon.close(); // ウィンドウに設定した後なら解放してよい
}

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

Rust では set_icon() に tauri::image::Image を渡します。include_image! は PNG / ICO をコンパイル時に画素データへ変換して埋め込むので feature は要りません(パスは src-tauri/ からの相対)。設定ファイルでは指定できないウィンドウごとのアイコンも、ビルダーの icon() で付けられます。タスクバーや Dock の印は OS で API が違い、Windows は set_overlay_icon() で小さな画像を重ね、macOS と Linux は set_badge_count() で数字を出します。

use tauri::image::Image;
use tauri::{include_image, Manager, WebviewUrl, WebviewWindowBuilder};

/// RGBA の画像の右下に赤い丸を描く(ratio は半径の割合)
fn paint_dot(rgba: &mut [u8], width: u32, height: u32, ratio: f32) {
    let r = width.min(height) as f32 * ratio;
    let (cx, cy) = (width as f32 - r, height as f32 - r);
    for y in 0..height {
        for x in 0..width {
            let (dx, dy) = (x as f32 + 0.5 - cx, y as f32 + 0.5 - cy);
            if dx * dx + dy * dy <= r * r {
                let i = ((y * width + x) * 4) as usize;
                rgba[i..i + 4].copy_from_slice(&[229, 57, 53, 255]);
            }
        }
    }
}

/// 呼び出し元ウィンドウのアイコンに印を付ける・外す
#[tauri::command]
fn set_alert_icon(window: tauri::WebviewWindow, alert: bool) -> Result<(), String> {
    let base = window
        .app_handle()
        .default_window_icon()
        .cloned()
        .ok_or("default window icon not found")?;
    if !alert {
        return window.set_icon(base).map_err(|e| e.to_string());
    }
    let (w, h) = (base.width(), base.height());
    let mut rgba = base.rgba().to_vec();
    paint_dot(&mut rgba, w, h, 0.25);
    window.set_icon(Image::new_owned(rgba, w, h)).map_err(|e| e.to_string())
}

/// タスクバー・Dock に未読を出す(0 で消す)
#[tauri::command]
fn set_unread_badge(window: tauri::WebviewWindow, count: u32) -> Result<(), String> {
    #[cfg(target_os = "windows")]
    {
        // 数字のバッジが無いので、赤い丸だけの画像を重ねる
        let overlay = (count > 0).then(|| {
            let mut rgba = vec![0u8; 32 * 32 * 4];
            paint_dot(&mut rgba, 32, 32, 0.5);
            Image::new_owned(rgba, 32, 32)
        });
        window.set_overlay_icon(overlay).map_err(|e| e.to_string())
    }
    #[cfg(not(target_os = "windows"))]
    {
        let badge = (count > 0).then(|| i64::from(count));
        window.set_badge_count(badge).map_err(|e| e.to_string())
    }
}

/// 既定とは別のアイコンでサブウィンドウを開く
#[tauri::command]
async fn open_editor(app: tauri::AppHandle) -> Result<(), String> {
    WebviewWindowBuilder::new(&app, "editor", WebviewUrl::App("editor.html".into()))
        .title("Editor")
        .icon(include_image!("icons/32x32.png")) // 実際は差し替え用の PNG を置いて指定する
        .map_err(|e| e.to_string())?
        .build()
        .map_err(|e| e.to_string())?;
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![set_alert_icon, set_unread_badge, open_editor])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Rust のコマンド経由なら、JS 側に core:window:allow-set-icon などの権限は要りません。include_image! は画素のまま埋め込むので、小さな画像にします。

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

await invoke('set_alert_icon', { alert: true }); // ウィンドウのアイコンに赤い丸
await invoke('set_unread_badge', { count: 3 });  // Windows は赤い丸、macOS・Linux は「3」
await invoke('open_editor');

動作確認

npm run tauri dev で起動して showState('recording') を呼ぶと、Windows ではタイトルバー左上のアイコンが recording.png に変わり、コンソールに次のように出ます。

[icon] recording

showUnread(3) では既定のアイコンに「3」の印が付き、showUnread(0) で戻ります。set_unread_badge は、Windows ではタスクバーのボタンに赤い丸が重なり、macOS では Dock に「3」が付きます。

よくあるエラーと対処法

  • 「from_bytes is only supported if the image-ico or image-png Cargo features are enabled」: feature を付けずに Image.fromBytes() を呼んでいます。setIcon() にパスを直接渡した場合は「expected RGBA image data, found a file path」になります。どちらも image-png を足して tauri dev を再起動します。
  • 「window.set_icon not allowed. Permissions associated with this command: core:window:allow-set-icon」: 権限の追加漏れです。リリースビルドでは「Command plugin:window|set_icon not allowed by ACL」と出ます。
  • include_image! がコンパイル時に「icon <パス> is not RGBA」で止まる: PNG が RGBA 形式ではありません(パレット形式など)。透過付きの 32bit PNG で保存し直すか、tauri icon で作ったものを使います。
  • 「Command set_overlay_icon not found」: Windows 以外で setOverlayIcon() を呼んでいます(権限を付けていてもこうなります)。他の OS では setBadgeCount() を使います。

OS ごとの違いと注意点

  • Windows: 数字のバッジ(setBadgeCount())は非対応なので、setOverlayIcon() で小さな画像を重ねます。重ねる画像はウィンドウごとに設定できます。
  • macOS: ウィンドウにアイコンを出す場所が無く、setIcon() は成功しても見た目は変わりません。Dock に出るのはアプリのアイコンで、tauri dev の間も bundle.icon のものが使われ、実行中に差し替える API はありません。状態は setBadgeCount() や、文字を出せる setBadgeLabel()(macOS 専用)で伝えます。バッジはアプリ全体で 1 つです。
  • Linux: ウィンドウのアイコンがどこに出るかはデスクトップ環境によります。
  • iOS / Android: setIcon() などはデスクトップ専用です。
  • 気付いてほしい通知には タスクバーでウィンドウを点滅させる も組み合わせます。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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