ウィンドウのタイトルを動的に変える

setTitle()(権限 core:window:allow-set-title)と Rust の set_title() でファイル名や未保存マークをタイトルに出す。document.title が自動反映されない点と同期方法も示す。

ウィンドウ 対象: Tauri 2.x 更新日: 読了目安: 約9分 win-002
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 状態からタイトルを組み立てる
  4. ページの document.title と同期する
  5. 別のウィンドウのタイトルを変える
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

エディターなら「● memo.txt - My Editor」のように開いているファイル名と未保存マークを、チャットなら未読件数を、時間のかかる処理なら進捗をタイトルに出しておくと、ウィンドウを切り替えた先でも状態が伝わります。起動時のタイトルは tauri.conf.json の title、実行中の変更は JS の setTitle() か Rust の set_title() で行います。見落としやすいのは、ページの <title>(document.title)がウィンドウのタイトルに自動では反映されないことで、その同期方法も説明します。

前提条件

プラグインは不要です。変更する setTitle() の権限 core:window:allow-set-title は core:window:default に含まれないので追加します。読み取りの title()(core:window:allow-title)は core:window:default に含まれます。権限は呼び出す側のウィンドウに対して判定されるので、サブウィンドウから呼ぶならそのラベルも windows に入れます。"editor-*" のようなワイルドカードも書けます。

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

起動時のタイトルは設定ファイルで指定します。title を省略すると productName ではなく「Tauri App」になる(tauri-utils の既定値)ので、アプリ名を必ず書いておきます。

{
  "productName": "My Editor",
  "app": {
    "windows": [
      { "label": "main", "title": "My Editor", "width": 1024, "height": 768 }
    ]
  }
}

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

状態からタイトルを組み立てる

タイトルは「ファイル名」「未保存かどうか」といった状態から毎回組み立て、変わったときだけ setTitle() を呼ぶ形にすると扱いやすくなります。setTitle() は呼ぶたびに Rust 側とのプロセス間通信(IPC)が発生するので、キー入力ごとに無条件で呼ぶのは避けます。判定の元にするのはアプリ側の状態で、setTitle() の直後に title() で読み戻す使い方はしません(理由は「OS ごとの違い」を参照)。setTitle() を呼ぶ前に一度だけ title() を読み、設定ファイルのタイトルをアプリ名として控えるのは問題ありません。

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

type DocState = { fileName: string | null; dirty: boolean };

const win = getCurrentWindow();
let appName: string | null = null;
let lastTitle = '';

function composeTitle(s: DocState, app: string): string {
  return `${s.dirty ? '● ' : ''}${s.fileName ?? '無題'} - ${app}`;
}

// 何度呼んでもよい。文字列が変わったときだけ IPC を送る
export async function syncTitle(s: DocState) {
  appName ??= await win.title(); // 初回だけ、起動時のタイトル(tauri.conf.json の title)を控える
  const title = composeTitle(s, appName);
  if (title === lastTitle) return;
  lastTitle = title;
  await win.setTitle(title); // core:window:allow-set-title が必要
}

const state: DocState = { fileName: 'memo.txt', dirty: false };
document.querySelector('textarea')?.addEventListener('input', () => {
  state.dirty = true;
  void syncTitle(state);
});

未保存のまま閉じようとしたときの確認は ウィンドウを閉じる前に確認ダイアログを出す と組み合わせます。

ページの document.title と同期する

SPA のルーターなどが document.title を書き換えても、ウィンドウのタイトルは変わりません。「ページのタイトルが変わった」ときの処理は Rust 側で登録したときだけ動き、Tauri は既定では何も登録しないためです(2 章で登録方法を示します)。JS だけで済ませるなら、<head> の変化を監視して転記します。

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

const win = getCurrentWindow();
let synced = '';

async function syncFromDocument() {
  const t = document.title.trim();
  if (!t || t === synced) return;
  synced = t;
  await win.setTitle(t);
}

// title 要素の差し替えと書き換えのどちらも拾えるよう head 全体を監視する
new MutationObserver(() => void syncFromDocument())
  .observe(document.head, { childList: true, subtree: true, characterData: true });
void syncFromDocument();

別のウィンドウのタイトルを変える

Window.getByLabel() でラベルからウィンドウを取得して setTitle() を呼びます。存在しなければ null が返ります。

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

export async function renameWindow(label: string, title: string) {
  const target = await Window.getByLabel(label);
  if (!target) throw new Error(`window '${label}' not found`);
  await target.setTitle(title); // 権限は呼び出し元ウィンドウの capability で判定される
}

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

コマンドの引数に tauri::WebviewWindow を書くと呼び出し元のウィンドウが渡されるので、ラベルなしで set_title() できます。処理の途中で進捗を出すなら async コマンドにし、重い処理は spawn_blocking などに逃がします。アプリ名は package_info().name(productName、無ければ Cargo のパッケージ名)から取れます。

document.title を確実に同期したいなら、Rust でウィンドウを作るときに on_document_title_changed を登録します。これはビルダーのオプションなので、設定ファイルから自動生成されるウィンドウには付けられません。main に "create": false を付けて自動生成を止め、setup で WebviewWindowBuilder::from_config() から作ります。この方式なら JS 側の権限追加は要りません。

use tauri::{Manager, WebviewWindowBuilder};

/// 呼び出し元ウィンドウのタイトルを変える(ラベル指定は不要)
#[tauri::command]
fn set_window_title(window: tauri::WebviewWindow, title: String) -> Result<(), String> {
    window.set_title(&title).map_err(|e| e.to_string())
}

/// 時間のかかる処理の進み具合をタイトルに出し、終わったらアプリ名に戻す
#[tauri::command]
async fn run_job(window: tauri::WebviewWindow) -> Result<(), String> {
    let app_name = window.app_handle().package_info().name.clone();
    for step in 1..=5u32 {
        // 実際の処理の代わりに 0.5 秒待つ(async のスレッドを直接ふさがない)
        tauri::async_runtime::spawn_blocking(|| std::thread::sleep(std::time::Duration::from_millis(500)))
            .await
            .map_err(|e| e.to_string())?;
        window
            .set_title(&format!("処理中 {}% - {}", step * 20, app_name))
            .map_err(|e| e.to_string())?;
    }
    window.set_title(&app_name).map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            // tauri.conf.json の main に "create": false を付けておく
            let config = app
                .config()
                .app
                .windows
                .iter()
                .find(|w| w.label == "main")
                .cloned()
                .ok_or("main window config not found")?;
            WebviewWindowBuilder::from_config(app.handle(), &config)?
                .on_document_title_changed(|window, title| {
                    let _ = window.set_title(&title); // document.title が変わるたびに反映
                })
                .build()?;
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![set_window_title, run_job])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

await invoke('set_window_title', { title: 'memo.txt - My Editor' });
await invoke('run_job'); // 約 2.5 秒かけて「処理中 20%」から「処理中 100%」へ進み、アプリ名に戻る

動作確認

npm run tauri dev で起動してテキストエリアに入力すると、タイトルが「● memo.txt - My Editor」に変わります。run_job を呼ぶとタイトルの数字が 0.5 秒ごとに進み、終わると「My Editor」に戻ります。on_document_title_changed 版では、DevTools のコンソールで次を実行するとタイトルバーも同じ文字列になります。

document.title = '設定 - My Editor';

よくあるエラーと対処法

  • 「window.set_title not allowed. Permissions associated with this command: core:window:allow-set-title」: 権限の追加漏れです。この文面はデバッグビルドのもので、リリースビルドでは「Command plugin:window|set_title not allowed by ACL」だけになります。追加して tauri dev を再起動します。
  • サブウィンドウからだけ失敗する: window.set_title not allowed on window "editor-2", ... で始まるエラーなら、そのラベルが capability の windows に入っていません。ラベルを動的に付けるなら "editor-*" のように書きます(新しいサブウィンドウを開く)。
  • document.title を変えてもタイトルバーが変わらない: 自動では同期されません。MutationObserver か on_document_title_changed で転記します。
  • タイトルが「Tauri App」のまま: 設定ファイルの title が未指定です。HTML の <title> を書いても上記のとおり反映されないので、設定ファイル側に書きます。
  • Window.getByLabel() が null を返す: ラベルの綴り違いか、そのウィンドウがまだ作られていない(または閉じられた後)です。

OS ごとの違いと注意点

  • Windows: 日本語や絵文字もそのまま使えます。
  • macOS: タイトルの変更は非同期に反映されます。hiddenTitle: true だとタイトルバーに文字は出ませんが、setTitle() 自体は通ります。titleBarStyle: "Overlay" ではタイトルの色がシステムのテーマに従います。「保存が必要な状態」をウィンドウに示す macOS の機能は Tauri の API には無いので、未保存は ● などの文字で表します。
  • Linux: こちらも反映は非同期です。macOS と同じく、直後の title() ではなくアプリの状態を正とします。
  • iOS / Android: setTitle() は非対応で、title() は空文字列を返します。
  • フレームレス: decorations: false(枠のないフレームレスウィンドウ)では標準のタイトルバーが無いので文字は描かれません。自作のタイトルバー を使う場合も、タスクバーやウィンドウ切り替えに出る名前は setTitle() の値なので、HTML の表示と両方を更新します。
  • タイトルの変化だけでは気付かれにくい通知は、タスクバーでウィンドウを点滅させる と組み合わせます。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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